Compare commits
37 Commits
ddce5efb13
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b09295570d | |||
| 3f1fee4e44 | |||
| 573246ce98 | |||
| 5fc1e2caf6 | |||
| ca5c623392 | |||
| 8d2a089268 | |||
| c6b27063a4 | |||
| c103430c7d | |||
| c0ff6d8bed | |||
| 2cd03b6a83 | |||
| 4c0de2cbf3 | |||
| 683fc62129 | |||
| 32a71f00ca | |||
| 65aae85369 | |||
| 5f7d56369f | |||
| fd48dcfc28 | |||
| 24a516c2b5 | |||
| 15407e7e9c | |||
| c1263b2e69 | |||
| 1072cf10cd | |||
| ae90681d9b | |||
| bb471a0917 | |||
| 40a6dd78e4 | |||
| a2976481d7 | |||
| 9643cb197b | |||
| 980a2c07e8 | |||
| 4fee7bf50d | |||
| 30a17196f5 | |||
| c3d265d428 | |||
| 5567a4be95 | |||
| a6f7ea7f78 | |||
| d207b8b0b8 | |||
| fe72fc2957 | |||
| 930fbf5965 | |||
| f33f76d874 | |||
| 7e7bec56d5 | |||
| 42f95530de |
+2
-1
@@ -45,7 +45,8 @@ packages/backend/.convex
|
|||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
docker
|
docker/*
|
||||||
|
!docker/agent-job-rootfs
|
||||||
Dockerfile
|
Dockerfile
|
||||||
.dockerignore
|
.dockerignore
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Build and Push Next App
|
name: Build and Push Spoon Images
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -33,7 +33,7 @@ jobs:
|
|||||||
printf '%s\n' "$DOTENV_PROD" > "$env_file"
|
printf '%s\n' "$DOTENV_PROD" > "$env_file"
|
||||||
bunx dotenv -e "$env_file" -- env NODE_ENV=test SKIP_E2E=1 bun run ci:check
|
bunx dotenv -e "$env_file" -- env NODE_ENV=test SKIP_E2E=1 bun run ci:check
|
||||||
|
|
||||||
build-next:
|
build-images:
|
||||||
needs: [quality]
|
needs: [quality]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -44,7 +44,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
bun-version: 1.3.10
|
bun-version: 1.3.10
|
||||||
- run: bun install --frozen-lockfile
|
- run: bun install --frozen-lockfile
|
||||||
- name: Build image
|
- name: Build Next image
|
||||||
env:
|
env:
|
||||||
DOTENV_PROD: ${{ secrets.DOTENV_PROD }}
|
DOTENV_PROD: ${{ secrets.DOTENV_PROD }}
|
||||||
run: |
|
run: |
|
||||||
@@ -52,9 +52,23 @@ jobs:
|
|||||||
trap 'rm -f "$env_file"' EXIT
|
trap 'rm -f "$env_file"' EXIT
|
||||||
printf '%s\n' "$DOTENV_PROD" > "$env_file"
|
printf '%s\n' "$DOTENV_PROD" > "$env_file"
|
||||||
CI_ENV_FILE="$env_file" ./scripts/build-next-app production
|
CI_ENV_FILE="$env_file" ./scripts/build-next-app production
|
||||||
- name: Tag and push image
|
- name: Build agent images
|
||||||
|
env:
|
||||||
|
SPOON_BUILD_SHA: ${{ gitea.sha }}
|
||||||
|
run: SPOON_AGENT_CONTAINER_RUNTIME=docker ./scripts/build-agent-images
|
||||||
|
- name: Tag and push images
|
||||||
run: |
|
run: |
|
||||||
docker tag spoon-next:latest git.gbrown.org/gib/spoon-next:${{ gitea.sha }}
|
docker tag spoon-next:latest git.gbrown.org/gib/spoon-next:${{ gitea.sha }}
|
||||||
docker tag spoon-next:latest git.gbrown.org/gib/spoon-next:latest
|
docker tag spoon-next:latest git.gbrown.org/gib/spoon-next:latest
|
||||||
docker push git.gbrown.org/gib/spoon-next:${{ gitea.sha }}
|
docker push git.gbrown.org/gib/spoon-next:${{ gitea.sha }}
|
||||||
docker push git.gbrown.org/gib/spoon-next:latest
|
docker push git.gbrown.org/gib/spoon-next:latest
|
||||||
|
|
||||||
|
docker tag spoon-agent-worker:latest git.gbrown.org/gib/spoon-agent-worker:${{ gitea.sha }}
|
||||||
|
docker tag spoon-agent-worker:latest git.gbrown.org/gib/spoon-agent-worker:latest
|
||||||
|
docker push git.gbrown.org/gib/spoon-agent-worker:${{ gitea.sha }}
|
||||||
|
docker push git.gbrown.org/gib/spoon-agent-worker:latest
|
||||||
|
|
||||||
|
docker tag spoon-agent-job:latest git.gbrown.org/gib/spoon-agent-job:${{ gitea.sha }}
|
||||||
|
docker tag spoon-agent-job:latest git.gbrown.org/gib/spoon-agent-job:latest
|
||||||
|
docker push git.gbrown.org/gib/spoon-agent-job:${{ gitea.sha }}
|
||||||
|
docker push git.gbrown.org/gib/spoon-agent-job:latest
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
bunx lint-staged --concurrent 1
|
bunx lint-staged --concurrent 1
|
||||||
|
infisical scan git-changes --staged
|
||||||
|
|||||||
@@ -12,10 +12,23 @@
|
|||||||
- `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.
|
||||||
|
- Threads are the canonical user-facing workspace route. Normal navigation
|
||||||
|
should open `/threads/[threadId]`; legacy job URLs under
|
||||||
|
`/spoons/[spoonId]/agent/[jobId]` are compatibility routes for jobs that do
|
||||||
|
not have a thread yet.
|
||||||
- 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
|
Agent jobs are opt-in; build `docker/agent-job.Dockerfile` as
|
||||||
`spoon-agent-job:latest` before running Docker-backed jobs.
|
`spoon-agent-job:latest` before running Docker-backed jobs.
|
||||||
|
- Gitea CI builds and pushes `spoon-next`, `spoon-agent-worker`, and
|
||||||
|
`spoon-agent-job` images to `git.gbrown.org/gib`. In production,
|
||||||
|
`SPOON_AGENT_JOB_IMAGE` should point to
|
||||||
|
`git.gbrown.org/gib/spoon-agent-job:latest`, and the worker service requires
|
||||||
|
access to the host Docker socket. API-key provider jobs run through OpenCode;
|
||||||
|
Codex ChatGPT login profiles run through the Codex CLI with an injected
|
||||||
|
`CODEX_HOME/.codex/auth.json` inside the isolated job workspace.
|
||||||
|
The job image must keep Node, npm, Bun, pnpm, yarn, git, ripgrep, jq,
|
||||||
|
Python, OpenCode, and Codex available.
|
||||||
|
|
||||||
## Protected and generated files
|
## Protected and generated files
|
||||||
|
|
||||||
@@ -33,6 +46,9 @@
|
|||||||
- Local `dev` and `staging` come only from Infisical via
|
- Local `dev` and `staging` come only from Infisical via
|
||||||
`scripts/with-env`; it never falls back to `.env*`.
|
`scripts/with-env`; it never falls back to `.env*`.
|
||||||
- Run `infisical login` and `infisical init` before local development.
|
- Run `infisical login` and `infisical init` before local development.
|
||||||
|
- `scripts/export-env` enforces `.local/infisical.env` when multiple local
|
||||||
|
Infisical accounts are logged in. Put `INFISICAL_EMAIL=you@example.com` there
|
||||||
|
for this project and do not commit it.
|
||||||
- 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,
|
- `scripts/sync-convex-env <dev|staging>` copies Authentik, GitHub App,
|
||||||
@@ -42,7 +58,21 @@
|
|||||||
- Agent workspace proxy env uses `SPOON_AGENT_WORKER_URL`,
|
- Agent workspace proxy env uses `SPOON_AGENT_WORKER_URL`,
|
||||||
`SPOON_AGENT_WORKER_HTTP_PORT`, and `SPOON_AGENT_WORKER_INTERNAL_TOKEN`.
|
`SPOON_AGENT_WORKER_HTTP_PORT`, and `SPOON_AGENT_WORKER_INTERNAL_TOKEN`.
|
||||||
Keep these server-only; the browser must never receive worker tokens.
|
Keep these server-only; the browser must never receive worker tokens.
|
||||||
|
- Host-run worker dev uses `scripts/dev-agent-worker` after Infisical env
|
||||||
|
loading. It prefers Podman, sets `SPOON_AGENT_CONTAINER_ACCESS=host_port`,
|
||||||
|
and expects `spoon-agent-job:latest` to exist locally.
|
||||||
|
- Containerized production workers that control the host Docker socket must set
|
||||||
|
`SPOON_AGENT_HOST_WORKDIR` to the host-side path backing
|
||||||
|
`SPOON_AGENT_WORKDIR`. Docker bind mount source paths are resolved on the host,
|
||||||
|
not inside the worker container.
|
||||||
|
- `bun smoke:agent-container` checks that the local job image has Node, npm,
|
||||||
|
Bun, pnpm, yarn, git, ripgrep, jq, Python, OpenCode, and Codex available.
|
||||||
|
- Old terminal workspaces can be deleted from `Settings -> Worker`; orphaned
|
||||||
|
containers/workdirs are cleaned through the worker HTTP API, not from the
|
||||||
|
browser directly.
|
||||||
- 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.
|
||||||
|
- Gitea image builds force `SPOON_AGENT_CONTAINER_RUNTIME=docker`; keep local
|
||||||
|
Podman auto-detection out of CI image tagging/pushing.
|
||||||
- CI must provide Convex deployment env for codegen, either
|
- CI must provide Convex deployment env for codegen, either
|
||||||
`CONVEX_SELF_HOSTED_URL` plus `CONVEX_SELF_HOSTED_ADMIN_KEY`, or
|
`CONVEX_SELF_HOSTED_URL` plus `CONVEX_SELF_HOSTED_ADMIN_KEY`, or
|
||||||
`CONVEX_DEPLOYMENT`.
|
`CONVEX_DEPLOYMENT`.
|
||||||
@@ -67,6 +97,7 @@
|
|||||||
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 dev:agent # run the optional coding-agent worker on the host
|
||||||
|
bun dev:next:worker # run Next, backend, and agent worker together
|
||||||
bun sync:convex # sync Infisical values into Convex
|
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
|
||||||
|
|||||||
@@ -1,269 +1,557 @@
|
|||||||
# Spoon
|
<p align="center">
|
||||||
|
<img src="apps/next/public/favicon.png" alt="Spoon logo" width="96" height="96" />
|
||||||
|
</p>
|
||||||
|
|
||||||
Spoon is a self-hostable fork maintenance cockpit.
|
<h1 align="center">Spoon</h1>
|
||||||
|
|
||||||
Forking a project should not mean supporting it alone. Spoon tracks managed
|
<p align="center">
|
||||||
forks, called **Spoons**, watches upstream for drift, automatically syncs clean
|
<strong>Fork freely & keep them all intimately close to upstream.</strong>
|
||||||
forks when it can, and opens durable **Threads** when upstream changes need
|
</p>
|
||||||
review, context, or code.
|
|
||||||
|
|
||||||
This repository is the Spoon application itself, not a generic starter.
|
<p align="center">
|
||||||
|
Spoon is a self-hostable fork maintenance cockpit built around managed forks,
|
||||||
|
durable maintenance threads, and OpenCode-powered workspaces.
|
||||||
|
</p>
|
||||||
|
|
||||||
## What Spoon Does
|
<p align="center">
|
||||||
|
<a href="#what-this-is">What this is</a>
|
||||||
|
·
|
||||||
|
<a href="#product-model">Product model</a>
|
||||||
|
·
|
||||||
|
<a href="#architecture">Architecture</a>
|
||||||
|
·
|
||||||
|
<a href="#environment-reference">Environment</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
- Tracks GitHub-backed managed forks and their upstream projects.
|
---
|
||||||
- Shows raw and effective drift, fork-only commits, pull requests, clone URLs,
|
|
||||||
additional remotes, sync history, and open maintenance work.
|
|
||||||
- Uses Threads as the product center for upstream reviews, merge conflicts,
|
|
||||||
ignored commits, user-requested changes, worker logs, and draft PR handoff.
|
|
||||||
- Auto-syncs clean behind forks when there are no fork-only commits.
|
|
||||||
- Creates maintenance threads when custom fork work means upstream changes need
|
|
||||||
a decision.
|
|
||||||
- Runs optional OpenCode-backed workspaces in isolated agent-job containers.
|
|
||||||
- Lets users configure encrypted AI provider profiles, Codex/OpenCode auth,
|
|
||||||
per-Spoon secrets, commands, and agent settings.
|
|
||||||
- Opens draft PRs for code changes instead of auto-merging custom forks.
|
|
||||||
|
|
||||||
## Current Scope
|
## What This Is
|
||||||
|
|
||||||
Implemented today:
|
Spoon is a private, actively evolving project for making forks less lonely to
|
||||||
|
maintain.
|
||||||
|
|
||||||
- Public Next.js landing page for Spoon's thread-first maintenance model.
|
Forking a project is easy. Keeping that fork close to upstream after you add
|
||||||
- Authenticated web routes:
|
custom changes is the hard part. Spoon treats a fork as an ongoing relationship:
|
||||||
- `/dashboard`
|
it watches upstream, understands fork-only commits, automatically syncs clean
|
||||||
- `/spoons`
|
drift when it can, and opens a durable **Thread** when a decision needs context
|
||||||
- `/spoons/new`
|
or code.
|
||||||
- `/spoons/[spoonId]`
|
|
||||||
- `/spoons/[spoonId]/agent/[jobId]`
|
|
||||||
- `/threads`
|
|
||||||
- `/threads/[threadId]`
|
|
||||||
- `/settings/profile`
|
|
||||||
- `/settings/integrations`
|
|
||||||
- `/settings/ai-providers`
|
|
||||||
- Legacy `/updates` and `/agents` routes redirect into Threads.
|
|
||||||
- GitHub App connection, repository listing, fork creation, drift refresh,
|
|
||||||
commit/PR cache, and safe sync foundation.
|
|
||||||
- Thread-first maintenance model with ignored upstream changes and effective
|
|
||||||
drift.
|
|
||||||
- Optional `apps/agent-worker` service that claims queued jobs, clones the
|
|
||||||
current GitHub fork, starts an isolated workspace, exposes file browsing and
|
|
||||||
edits through server-side Next proxies, runs commands, and opens draft PRs.
|
|
||||||
- Browser workspace with persisted thread messages, file tree, Monaco editor
|
|
||||||
with optional Vim mode, diff view, command panel, logs, artifacts, and draft
|
|
||||||
PR actions.
|
|
||||||
- Encrypted per-user AI provider profiles and per-Spoon project secrets.
|
|
||||||
- Password auth and Authentik/GitHub OAuth through Convex Auth.
|
|
||||||
- Expo companion app shell with password and Authentik sign-in.
|
|
||||||
- Self-hosted local Convex using Postgres storage.
|
|
||||||
|
|
||||||
Not implemented yet:
|
The application is currently GitHub-first. Future provider-neutral fields exist
|
||||||
|
in the data model, but GitHub is the active automation surface today.
|
||||||
|
|
||||||
- Automatic merge of custom/diverged forks.
|
## Highlights
|
||||||
- Git provider automation beyond GitHub.
|
|
||||||
- Additional remotes as push targets.
|
- **Managed forks, called Spoons**
|
||||||
- Long-running service-stack orchestration inside agent jobs.
|
Track upstream metadata, fork metadata, clone URLs, extra remotes, sync
|
||||||
- Direct browser access to worker containers.
|
cadence, production-ref strategy, fork-only commits, and pull requests.
|
||||||
- Production mobile build/release setup.
|
|
||||||
|
- **Thread-first maintenance**
|
||||||
|
Upstream updates, conflict review, ignore decisions, user-requested work,
|
||||||
|
worker output, and draft PR handoff all live inside Threads.
|
||||||
|
|
||||||
|
- **Clean drift auto-sync**
|
||||||
|
If upstream moves and the fork has no custom commits, Spoon can fast-forward
|
||||||
|
the fork without creating busywork.
|
||||||
|
|
||||||
|
- **Custom forks get context**
|
||||||
|
If the fork has custom commits, Spoon creates a maintenance thread rather than
|
||||||
|
pretending the update is trivial.
|
||||||
|
|
||||||
|
- **Effective drift**
|
||||||
|
Spoon keeps raw GitHub drift visible while also tracking ignored upstream
|
||||||
|
changes so irrelevant commits do not keep a fork permanently actionable.
|
||||||
|
|
||||||
|
- **OpenCode workspaces**
|
||||||
|
Agent work happens in an isolated workspace with a file tree, browser editor,
|
||||||
|
diff viewer, command panel, logs, artifacts, and draft PR actions.
|
||||||
|
|
||||||
|
- **User-owned providers and secrets**
|
||||||
|
AI provider profiles, Codex/OpenCode auth, and per-Spoon project secrets are
|
||||||
|
encrypted. Secrets are redacted from logs and refused from commits when
|
||||||
|
materialized into env files.
|
||||||
|
|
||||||
|
- **Draft PR handoff**
|
||||||
|
Code changes become branches and draft pull requests. Spoon does not
|
||||||
|
auto-merge custom forks behind the user's back.
|
||||||
|
|
||||||
|
## Product Model
|
||||||
|
|
||||||
|
<details open>
|
||||||
|
<summary><strong>Spoons</strong></summary>
|
||||||
|
|
||||||
|
A **Spoon** is a managed fork. It records the upstream project, the fork
|
||||||
|
repository, default branches, sync policy, extra remotes, current drift, cached
|
||||||
|
commits, cached pull requests, secrets, and agent settings.
|
||||||
|
|
||||||
|
Spoons are the durable project-level objects. They answer:
|
||||||
|
|
||||||
|
- What did I fork?
|
||||||
|
- Where does my fork live?
|
||||||
|
- How far has it drifted?
|
||||||
|
- Which commits are mine?
|
||||||
|
- Which upstream changes matter?
|
||||||
|
- What threads or PRs are open?
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details open>
|
||||||
|
<summary><strong>Threads</strong></summary>
|
||||||
|
|
||||||
|
A **Thread** is the durable place where Spoon talks about maintenance work.
|
||||||
|
Threads can be created by a user or by the system.
|
||||||
|
|
||||||
|
Common thread sources:
|
||||||
|
|
||||||
|
- `user_request`: user asks Spoon to change a fork.
|
||||||
|
- `upstream_update`: upstream moved and the fork needs review.
|
||||||
|
- `merge_conflict`: a sync conflict needs context or code.
|
||||||
|
- `manual_review`: user explicitly asks for a review.
|
||||||
|
- `system`: internal maintenance coordination.
|
||||||
|
|
||||||
|
Threads hold messages, status, outcomes, related sync runs, related jobs,
|
||||||
|
workspace links, draft PR links, and ignored upstream decisions.
|
||||||
|
|
||||||
|
Opening a thread opens its workspace when a run exists. The workspace is the
|
||||||
|
primary surface for that thread: agent messages, tool activity, file edits,
|
||||||
|
manual edits, diffs, commands, and draft PR actions all happen there. Legacy
|
||||||
|
job URLs under `/spoons/[spoonId]/agent/[jobId]` are kept for compatibility,
|
||||||
|
but normal navigation targets `/threads/[threadId]`.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details open>
|
||||||
|
<summary><strong>Maintenance decisions</strong></summary>
|
||||||
|
|
||||||
|
Spoon's maintenance policy is intentionally conservative:
|
||||||
|
|
||||||
|
| Situation | Default action |
|
||||||
|
| ------------------------------------------ | ------------------------------------- |
|
||||||
|
| No fork-only commits and upstream is ahead | Auto-sync |
|
||||||
|
| Fork-only commits and upstream is ahead | Create a maintenance thread |
|
||||||
|
| Merge conflicts | Open or continue a workspace thread |
|
||||||
|
| Irrelevant upstream changes | Record an intentional ignore decision |
|
||||||
|
| Agent/code changes | Open a draft PR |
|
||||||
|
|
||||||
|
The goal is to keep forks close without hiding risk or skipping review when
|
||||||
|
custom work exists.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>OpenCode workspaces</strong></summary>
|
||||||
|
|
||||||
|
Spoon's optional agent worker is designed to run outside Convex actions. The
|
||||||
|
worker claims queued jobs, clones the current GitHub fork, creates a branch,
|
||||||
|
starts an isolated workspace, and exposes workspace operations to the Next app
|
||||||
|
through server-only API proxies.
|
||||||
|
|
||||||
|
Workspace capabilities:
|
||||||
|
|
||||||
|
- browse repository files
|
||||||
|
- edit files in a browser editor
|
||||||
|
- use optional Vim keybindings
|
||||||
|
- resize the agent thread panel on desktop
|
||||||
|
- inspect diffs
|
||||||
|
- send thread messages to the agent
|
||||||
|
- run configured commands
|
||||||
|
- store logs and artifacts
|
||||||
|
- push a branch
|
||||||
|
- open a draft PR
|
||||||
|
|
||||||
|
The browser never receives worker tokens and never talks directly to the worker
|
||||||
|
or job container.
|
||||||
|
|
||||||
|
Worker cleanup is available in `Settings -> Worker`. It can delete old terminal
|
||||||
|
workspace records and ask the active worker to remove orphaned job containers
|
||||||
|
and inactive work directories.
|
||||||
|
|
||||||
|
Local worker development:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/build-agent-images
|
||||||
|
bun smoke:agent-container
|
||||||
|
bun dev:next:worker
|
||||||
|
bun dev:next:worker:staging
|
||||||
|
```
|
||||||
|
|
||||||
|
Local host-run worker commands still load env through Infisical, then
|
||||||
|
`scripts/dev-agent-worker` selects Podman when available, falls back to Docker,
|
||||||
|
and publishes the OpenCode server on a localhost port so the host worker can
|
||||||
|
reach the job container. Override with:
|
||||||
|
|
||||||
|
```env
|
||||||
|
SPOON_AGENT_CONTAINER_RUNTIME=podman
|
||||||
|
SPOON_AGENT_CONTAINER_ACCESS=host_port
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Production agent runtime images</strong></summary>
|
||||||
|
|
||||||
|
Gitea CI builds and pushes three production images:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
git.gbrown.org/gib/spoon-next:latest
|
||||||
|
git.gbrown.org/gib/spoon-agent-worker:latest
|
||||||
|
git.gbrown.org/gib/spoon-agent-job:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
The worker image is the long-running service that polls Convex. The job image is
|
||||||
|
the isolated workbench that the worker launches for each agent job. For the MVP,
|
||||||
|
production should use the repo-provided JS/TS workbench image:
|
||||||
|
|
||||||
|
```env
|
||||||
|
SPOON_AGENT_JOB_IMAGE="git.gbrown.org/gib/spoon-agent-job:latest"
|
||||||
|
```
|
||||||
|
|
||||||
|
The job image includes Node 22, Bun, pnpm and yarn through Corepack, npm, git,
|
||||||
|
ripgrep, Python, build tools, OpenCode, and the Codex CLI. It is not the forked
|
||||||
|
project's production runtime; it is the agent execution environment.
|
||||||
|
|
||||||
|
Production worker runtime requirements:
|
||||||
|
|
||||||
|
- `spoon-agent-worker` must run as a separate service.
|
||||||
|
- The worker needs `/var/run/docker.sock` mounted so it can launch job
|
||||||
|
containers.
|
||||||
|
- Production should keep `SPOON_AGENT_CONTAINER_RUNTIME=docker` and
|
||||||
|
`SPOON_AGENT_CONTAINER_ACCESS=network`.
|
||||||
|
- The production Docker host must be logged into `git.gbrown.org` so worker jobs
|
||||||
|
can pull the private `spoon-agent-job` image.
|
||||||
|
- `SPOON_WORKER_TOKEN` must match the value stored in Convex production env.
|
||||||
|
- `spoon-next` needs `SPOON_AGENT_WORKER_URL=http://spoon-agent-worker:3921` and
|
||||||
|
`SPOON_AGENT_WORKER_INTERNAL_TOKEN` so Next API routes can proxy workspace
|
||||||
|
file, diff, message, command, and draft PR actions.
|
||||||
|
- `spoon-agent-worker` also needs `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY`.
|
||||||
|
If the private key is stored in a single-line dotenv value, encode newlines as
|
||||||
|
literal `\n` characters so the worker can restore the PEM before using it.
|
||||||
|
|
||||||
|
Useful production checks:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker login git.gbrown.org
|
||||||
|
docker pull git.gbrown.org/gib/spoon-agent-worker:latest
|
||||||
|
docker pull git.gbrown.org/gib/spoon-agent-job:latest
|
||||||
|
docker logs --tail=200 spoon-agent-worker
|
||||||
|
curl -H "Authorization: Bearer $SPOON_AGENT_WORKER_INTERNAL_TOKEN" \
|
||||||
|
http://spoon-agent-worker:3921/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Deployment readiness checklist:
|
||||||
|
|
||||||
|
1. Production Convex env has `SPOON_WORKER_TOKEN`, `SPOON_ENCRYPTION_KEY`,
|
||||||
|
GitHub App env, and Convex Auth signing keys.
|
||||||
|
2. Compose env has `SPOON_AGENT_WORKER_URL`,
|
||||||
|
`SPOON_AGENT_WORKER_INTERNAL_TOKEN`, `SPOON_AGENT_JOB_IMAGE`, and the GitHub
|
||||||
|
App private key.
|
||||||
|
3. The production Docker host can pull private images from `git.gbrown.org`.
|
||||||
|
4. `Settings -> Worker` reports the expected job image, runtime, network, and
|
||||||
|
active workspace count.
|
||||||
|
5. The first test thread uses a configured API-key provider or a trusted Codex
|
||||||
|
login profile.
|
||||||
|
6. If a worker restart leaves stale workspace state, use the workspace recovery
|
||||||
|
panel or `Settings -> Worker` cleanup.
|
||||||
|
|
||||||
|
API-key based AI provider profiles run through OpenCode. Codex ChatGPT login
|
||||||
|
profiles run through the Codex CLI: Spoon writes the encrypted `auth.json` into
|
||||||
|
the isolated job workspace as `CODEX_HOME/.codex/auth.json` before execution.
|
||||||
|
Treat that saved auth file like a password and only use it on trusted workers.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- `apps/next`: Next.js 16 web app and primary product UI.
|
<details open>
|
||||||
- `apps/agent-worker`: optional server-side worker for OpenCode workspaces and
|
<summary><strong>Workspace layout</strong></summary>
|
||||||
draft PR 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, codegen, and CI helpers.
|
|
||||||
|
|
||||||
Core domain objects:
|
|
||||||
|
|
||||||
- `spoons`: managed fork records.
|
|
||||||
- `threads`: durable maintenance and work conversations.
|
|
||||||
- `threadMessages`: persisted thread messages.
|
|
||||||
- `syncRuns`: upstream checks, sync attempts, and maintenance decisions.
|
|
||||||
- `ignoredUpstreamChanges`: intentional ignore decisions that affect effective
|
|
||||||
drift.
|
|
||||||
- `gitConnections`: Git provider connection metadata.
|
|
||||||
- `agentJobs`: worker-executed workspace jobs and PR lifecycle.
|
|
||||||
- `agentJobEvents` and `agentJobArtifacts`: logs and structured job outputs.
|
|
||||||
- `agentWorkspaceChanges`: recorded file changes from user, agent, or command
|
|
||||||
activity.
|
|
||||||
- `spoonSecrets`: encrypted per-Spoon environment variables.
|
|
||||||
- `spoonAgentSettings`: per-Spoon runtime, branch, command, and env-file
|
|
||||||
settings.
|
|
||||||
- `aiProviderProfiles`: encrypted provider/auth profiles used by OpenCode.
|
|
||||||
|
|
||||||
## Local Setup
|
|
||||||
|
|
||||||
Requirements:
|
|
||||||
|
|
||||||
- Bun 1.3.10
|
|
||||||
- Node 22
|
|
||||||
- Docker or Podman
|
|
||||||
- Infisical CLI
|
|
||||||
|
|
||||||
```sh
|
|
||||||
bun install --frozen-lockfile
|
|
||||||
infisical login
|
|
||||||
infisical init
|
|
||||||
bun db:up
|
|
||||||
bun dev:next
|
|
||||||
```
|
|
||||||
|
|
||||||
Local services:
|
|
||||||
|
|
||||||
- Next.js: `http://localhost:3000`
|
|
||||||
- Convex API: `http://localhost:3210`
|
|
||||||
- Convex site HTTP routes: `http://localhost:3211`
|
|
||||||
- Convex dashboard: `http://localhost:6791`
|
|
||||||
- Convex Postgres: `localhost:5432`
|
|
||||||
|
|
||||||
Next and Expo run on the host. Local Convex runs in containers with Postgres
|
|
||||||
storage. Normal `bun db:up` never contacts staging; it starts local Postgres,
|
|
||||||
Convex, and 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.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
bun db:down # stop; preserve local data
|
|
||||||
bun db:down:wipe # remove local data volumes and generated admin key
|
|
||||||
```
|
|
||||||
|
|
||||||
Use staging services explicitly:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
INFISICAL_ENV=staging bun dev:next
|
|
||||||
```
|
|
||||||
|
|
||||||
Run the optional local agent worker in a separate terminal:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
bun dev:agent
|
|
||||||
```
|
|
||||||
|
|
||||||
The worker starts an internal HTTP API, defaulting to `http://localhost:3921`,
|
|
||||||
for server-side Next route handlers. The browser never receives the worker token
|
|
||||||
or talks to this API directly.
|
|
||||||
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
The job image includes the OpenCode CLI. Rebuild it after changes to
|
|
||||||
`docker/agent-job.Dockerfile`.
|
|
||||||
|
|
||||||
## Environment Model
|
|
||||||
|
|
||||||
Local `dev` and `staging` values come from Infisical through
|
|
||||||
`scripts/with-env`. App commands do not fall back to root `.env` files.
|
|
||||||
|
|
||||||
Generated local state belongs in:
|
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
.local/<environment>.generated.env
|
.
|
||||||
|
├── apps
|
||||||
|
│ ├── next # Next.js 16 web app and primary Spoon UI
|
||||||
|
│ ├── agent-worker # Optional OpenCode workspace / draft PR worker
|
||||||
|
│ └── expo # Expo companion app scaffold
|
||||||
|
├── packages
|
||||||
|
│ ├── backend # Convex backend package
|
||||||
|
│ │ └── convex # Schema, functions, auth, HTTP routes
|
||||||
|
│ └── ui # Shared shadcn-based UI components
|
||||||
|
├── tools # Shared lint, format, Tailwind, TS, Vitest config
|
||||||
|
├── docker # Compose files and worker/job Dockerfiles
|
||||||
|
└── scripts # Env, Convex, codegen, database, and CI helpers
|
||||||
```
|
```
|
||||||
|
|
||||||
CI uses Gitea-provided secrets or `CI_ENV_FILE` and must not call Infisical.
|
</details>
|
||||||
|
|
||||||
Useful helpers:
|
<details>
|
||||||
|
<summary><strong>Core tables</strong></summary>
|
||||||
|
|
||||||
|
| Table | Purpose |
|
||||||
|
| ------------------------ | --------------------------------------------------------- |
|
||||||
|
| `spoons` | Managed fork records |
|
||||||
|
| `threads` | Durable maintenance and work conversations |
|
||||||
|
| `threadMessages` | Messages inside threads |
|
||||||
|
| `syncRuns` | Upstream checks, sync attempts, and maintenance decisions |
|
||||||
|
| `ignoredUpstreamChanges` | Intentional ignore records that affect effective drift |
|
||||||
|
| `gitConnections` | Git provider connection metadata |
|
||||||
|
| `spoonRepositoryStates` | Latest cached upstream/fork state |
|
||||||
|
| `spoonCommits` | Cached upstream and fork-only commits |
|
||||||
|
| `spoonPullRequests` | Cached fork/upstream pull requests |
|
||||||
|
| `spoonSecrets` | Encrypted per-Spoon environment variables |
|
||||||
|
| `spoonAgentSettings` | Per-Spoon runtime, branch, command, and env-file settings |
|
||||||
|
| `aiProviderProfiles` | Encrypted provider/auth profiles used by OpenCode |
|
||||||
|
| `agentJobs` | Worker-executed workspace jobs and PR lifecycle |
|
||||||
|
| `agentJobEvents` | Append-only worker event log |
|
||||||
|
| `agentJobArtifacts` | Diffs, summaries, command output, PR body drafts |
|
||||||
|
| `agentWorkspaceChanges` | Recorded user, agent, and command file changes |
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Important routes</strong></summary>
|
||||||
|
|
||||||
|
| Route | Purpose |
|
||||||
|
| --------------------------------- | --------------------------------------- |
|
||||||
|
| `/` | Public product landing page |
|
||||||
|
| `/dashboard` | Maintenance overview |
|
||||||
|
| `/spoons` | Managed fork list |
|
||||||
|
| `/spoons/new` | Manual/GitHub Spoon creation |
|
||||||
|
| `/spoons/[spoonId]` | Spoon detail dashboard |
|
||||||
|
| `/spoons/[spoonId]/agent/[jobId]` | Interactive workspace |
|
||||||
|
| `/threads` | Global thread queue |
|
||||||
|
| `/threads/[threadId]` | Thread detail |
|
||||||
|
| `/settings/profile` | User profile settings |
|
||||||
|
| `/settings/integrations` | GitHub and service integration settings |
|
||||||
|
| `/settings/ai-providers` | AI/OpenCode provider profiles |
|
||||||
|
|
||||||
|
Legacy `/updates` and `/agents` routes redirect into `/threads`.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Mobile App
|
||||||
|
|
||||||
|
<details open>
|
||||||
|
<summary><strong>Current Expo scope</strong></summary>
|
||||||
|
|
||||||
|
`apps/expo` is the mobile Spoon client. It is designed to mirror the core web
|
||||||
|
product without exposing worker internals or trying to turn a phone into the
|
||||||
|
primary code-editing surface.
|
||||||
|
|
||||||
|
The mobile app currently supports:
|
||||||
|
|
||||||
|
- password, GitHub, and Authentik sign-in
|
||||||
|
- Dashboard, Spoons, Threads, Workspace Review, and Settings tabs/screens
|
||||||
|
- manual Spoon creation and GitHub-assisted repository tracking
|
||||||
|
- Spoon detail views for overview, upstream commits, fork-only commits, PRs,
|
||||||
|
threads, settings, clone URLs, and additional remotes
|
||||||
|
- Spoon maintenance settings, agent settings, encrypted secrets, and bulk
|
||||||
|
`.env` paste import
|
||||||
|
- thread list/detail, message composer, resolve/cancel actions, and workspace
|
||||||
|
review links
|
||||||
|
- GitHub integration status and repository listing
|
||||||
|
- AI provider profile management, including Codex auth JSON and API-key
|
||||||
|
providers
|
||||||
|
- read-only workspace review for job status, messages, diffs, events,
|
||||||
|
artifacts, and draft PR links
|
||||||
|
|
||||||
|
The mobile app intentionally does not currently support:
|
||||||
|
|
||||||
|
- live workspace file browsing/editing
|
||||||
|
- mobile command execution
|
||||||
|
- direct mobile calls to the agent worker HTTP API
|
||||||
|
- mobile access to worker/container tokens
|
||||||
|
- long-running app preview stacks
|
||||||
|
- production app-store/EAS release flow
|
||||||
|
|
||||||
|
Mobile workspace editing is deferred until worker authorization and mobile
|
||||||
|
editor UX are designed explicitly. For now, the phone is a strong review and
|
||||||
|
control surface; the browser remains the code workspace.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Expo validation</strong></summary>
|
||||||
|
|
||||||
|
Useful mobile checks:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sh scripts/with-env dev -- <command>
|
bun --filter @spoon/expo lint
|
||||||
sh scripts/export-env dev
|
bun --filter @spoon/expo typecheck
|
||||||
bun sync:convex
|
bun --filter @spoon/expo test:unit
|
||||||
bun sync:convex:staging
|
bun --filter @spoon/expo test:component
|
||||||
```
|
```
|
||||||
|
|
||||||
### Convex Deployment Env
|
The Expo unit tests cover pure utilities such as `.env` parsing and formatting.
|
||||||
|
The component tests use a lightweight React Native mock layer to exercise shared
|
||||||
|
mobile controls, higher-value forms, and route smoke renders without booting a
|
||||||
|
native simulator.
|
||||||
|
|
||||||
Convex functions and HTTP actions read environment variables from the Convex
|
</details>
|
||||||
deployment environment, not directly from the host process. OAuth providers,
|
|
||||||
GitHub App credentials, UseSend, encryption keys, worker tokens, and Convex Auth
|
|
||||||
signing keys must be synced into the selected Convex deployment.
|
|
||||||
|
|
||||||
`packages/backend` runs `scripts/sync-convex-env` before `convex dev`, so
|
## Environment Reference
|
||||||
`bun dev:next`, `bun dev:backend`, and `bun db:up` sync the relevant Infisical
|
|
||||||
values into local Convex first. Run it manually when needed:
|
This project is currently private, so this section is a reference for what the
|
||||||
|
application expects rather than public setup documentation.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Local Infisical account selection</strong></summary>
|
||||||
|
|
||||||
|
Local `dev` and `staging` commands export secrets through Infisical. Spoon runs
|
||||||
|
`scripts/infisical-account ensure` from `scripts/export-env` before exporting so
|
||||||
|
machines logged into multiple Infisical accounts do not accidentally use the
|
||||||
|
wrong organization.
|
||||||
|
|
||||||
|
If your machine has only one local Infisical account, no extra setup is needed.
|
||||||
|
If it has multiple accounts, create this ignored local file:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sh scripts/sync-convex-env dev
|
mkdir -p .local
|
||||||
sh scripts/sync-convex-env staging
|
printf "INFISICAL_EMAIL=me@gbrown.org\n" > .local/infisical.env
|
||||||
INFISICAL_ENV=staging bun sync:convex
|
|
||||||
```
|
```
|
||||||
|
|
||||||
For local `dev`, `JWT_PRIVATE_KEY`, `JWKS`, `SPOON_ENCRYPTION_KEY`,
|
Log into each needed account once with `infisical login`. You can inspect local
|
||||||
`SPOON_WORKER_TOKEN`, and related generated values are created automatically if
|
profiles without printing tokens:
|
||||||
they are not already present. The generated Convex admin key remains
|
|
||||||
machine-local in `.local/dev.generated.env`; do not put it in Infisical.
|
|
||||||
|
|
||||||
Local OAuth callback URLs:
|
|
||||||
|
|
||||||
```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
|
jq '.loggedInUsers[] | {email, domain}' ~/.infisical/infisical-config.json
|
||||||
bun dev:expo
|
|
||||||
bun dev:agent
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Physical devices cannot resolve their own `localhost`; override the public
|
`.local/infisical.env` supports only `INFISICAL_EMAIL=...` and must not be
|
||||||
Convex URL with the development host's LAN address when testing Expo on-device.
|
committed. CI is unchanged; it uses injected environment files/secrets and must
|
||||||
|
not call Infisical.
|
||||||
|
|
||||||
Shared dependency versions belong in root catalogs. Edit the root catalog, run
|
</details>
|
||||||
`bun install`, then `bun lint:ws`. Do not run `bun update` inside a workspace.
|
|
||||||
|
|
||||||
## Validation
|
<details open>
|
||||||
|
<summary><strong>Public Next variables</strong></summary>
|
||||||
|
|
||||||
Routine checks:
|
| Variable | Used for |
|
||||||
|
| --------------------------------- | ------------------------------------------- |
|
||||||
|
| `NEXT_PUBLIC_SITE_URL` | Canonical Spoon web URL |
|
||||||
|
| `NEXT_PUBLIC_CONVEX_URL` | Convex client URL |
|
||||||
|
| `NEXT_PUBLIC_DEPLOYMENT_URL` | Convex dashboard/deployment URL when needed |
|
||||||
|
| `NEXT_PUBLIC_PLAUSIBLE_URL` | Plausible analytics endpoint |
|
||||||
|
| `NEXT_PUBLIC_SENTRY_DSN` | Browser Sentry DSN |
|
||||||
|
| `NEXT_PUBLIC_SENTRY_URL` | Sentry instance URL |
|
||||||
|
| `NEXT_PUBLIC_SENTRY_ORG` | Sentry organization |
|
||||||
|
| `NEXT_PUBLIC_SENTRY_PROJECT_NAME` | Sentry project name |
|
||||||
|
|
||||||
```sh
|
</details>
|
||||||
bun lint:ws
|
|
||||||
bun format
|
|
||||||
bun lint
|
|
||||||
bun typecheck
|
|
||||||
bun run test
|
|
||||||
```
|
|
||||||
|
|
||||||
Full local gate without e2e:
|
<details>
|
||||||
|
<summary><strong>Auth and email</strong></summary>
|
||||||
|
|
||||||
```sh
|
| Variable | Used for |
|
||||||
SKIP_E2E=1 bun run ci:check
|
| ----------------------- | ----------------------------- |
|
||||||
```
|
| `SITE_URL` | Convex Auth site URL |
|
||||||
|
| `JWT_PRIVATE_KEY` | Convex Auth signing key |
|
||||||
|
| `JWKS` | Convex Auth JWKS |
|
||||||
|
| `AUTH_AUTHENTIK_ID` | Authentik OAuth client ID |
|
||||||
|
| `AUTH_AUTHENTIK_SECRET` | Authentik OAuth client secret |
|
||||||
|
| `AUTH_AUTHENTIK_ISSUER` | Authentik issuer URL |
|
||||||
|
| `AUTH_GITHUB_ID` | GitHub OAuth client ID |
|
||||||
|
| `AUTH_GITHUB_SECRET` | GitHub OAuth client secret |
|
||||||
|
| `USESEND_API_KEY` | UseSend API key |
|
||||||
|
| `USESEND_URL` | UseSend API URL |
|
||||||
|
| `USESEND_FROM_EMAIL` | Transactional email sender |
|
||||||
|
|
||||||
Local-stack smoke e2e:
|
</details>
|
||||||
|
|
||||||
```sh
|
<details>
|
||||||
bun test:e2e
|
<summary><strong>GitHub App</strong></summary>
|
||||||
```
|
|
||||||
|
|
||||||
`bun test:e2e` starts the isolated local stack when needed and stops it
|
| Variable | Used for |
|
||||||
afterward only when it was not already running.
|
| ---------------------------- | ---------------------------------- |
|
||||||
|
| `GITHUB_APP_ID` | GitHub App ID |
|
||||||
|
| `GITHUB_APP_CLIENT_ID` | GitHub App OAuth client ID |
|
||||||
|
| `GITHUB_APP_CLIENT_SECRET` | GitHub App OAuth client secret |
|
||||||
|
| `GITHUB_APP_PRIVATE_KEY` | GitHub App PEM private key |
|
||||||
|
| `GITHUB_APP_WEBHOOK_SECRET` | GitHub webhook verification secret |
|
||||||
|
| `GITHUB_APP_SLUG` | GitHub App slug |
|
||||||
|
| `GITHUB_APP_INSTALLATION_ID` | Default/local installation ID |
|
||||||
|
| `GITHUB_APP_OWNER` | Default/local installation owner |
|
||||||
|
|
||||||
Use `bun run test`, not bare `bun test`; bare `bun test` invokes Bun's built-in
|
</details>
|
||||||
test runner instead of the repo's Turbo/Vitest test script.
|
|
||||||
|
|
||||||
## Deployment
|
<details>
|
||||||
|
<summary><strong>Convex, storage, and runtime</strong></summary>
|
||||||
|
|
||||||
Production Compose runs the Next image, self-hosted Convex backend/dashboard,
|
| Variable | Used for |
|
||||||
and Postgres. The deployed Next image is expected to be named
|
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||||
`spoon-next:latest` in the Gitea registry.
|
| `CONVEX_SELF_HOSTED_URL` | Self-hosted Convex API URL |
|
||||||
|
| `CONVEX_SELF_HOSTED_ADMIN_KEY` | Admin key for deploying/syncing Convex |
|
||||||
|
| `CONVEX_CLOUD_ORIGIN` | Convex backend origin |
|
||||||
|
| `CONVEX_SITE_ORIGIN` | Convex site-function origin |
|
||||||
|
| `CONVEX_SITE_URL` | Site URL seen by Convex Auth |
|
||||||
|
| `POSTGRES_URL` | Convex storage database URL |
|
||||||
|
| `SPOON_ENCRYPTION_KEY` | Encryption key for stored secrets/provider auth |
|
||||||
|
| `SPOON_WORKER_TOKEN` | Worker token for Convex worker mutations |
|
||||||
|
| `SPOON_AGENT_WORKER_URL` | Internal worker HTTP URL used by Next |
|
||||||
|
| `SPOON_AGENT_WORKER_HTTP_PORT` | Worker HTTP port |
|
||||||
|
| `SPOON_AGENT_WORKER_INTERNAL_TOKEN` | Server-only token for Next-to-worker proxy |
|
||||||
|
| `SPOON_AGENT_JOB_IMAGE` | Agent job container image |
|
||||||
|
| `SPOON_AGENT_RUNTIME` | Runtime mode, currently Docker/Podman-oriented |
|
||||||
|
| `SPOON_AGENT_CONTAINER_RUNTIME` | Container CLI used by worker, `docker`/`podman` |
|
||||||
|
| `SPOON_AGENT_CONTAINER_ACCESS` | `network` in prod, `host_port` for host dev |
|
||||||
|
| `SPOON_AGENT_MAX_CONCURRENT_JOBS` | Worker concurrency limit |
|
||||||
|
| `SPOON_AGENT_JOB_TIMEOUT_MS` | Job timeout |
|
||||||
|
| `SPOON_AGENT_WORKDIR` | Worker work directory |
|
||||||
|
| `SPOON_AGENT_HOST_WORKDIR` | Host path matching `SPOON_AGENT_WORKDIR` when the worker runs in Docker and controls the host Docker socket |
|
||||||
|
| `SPOON_AGENT_NETWORK` | Optional job container network |
|
||||||
|
|
||||||
Gitea runs the quality gate first, runs Convex codegen with deployment env,
|
</details>
|
||||||
builds the Next image from injected secrets or `CI_ENV_FILE`, then pushes SHA
|
|
||||||
and `latest` tags. CI never installs or invokes Infisical.
|
<details>
|
||||||
|
<summary><strong>Deployment and observability</strong></summary>
|
||||||
|
|
||||||
|
| Variable | Used for |
|
||||||
|
| ----------------------- | --------------------------------- |
|
||||||
|
| `NODE_ENV` | Runtime environment |
|
||||||
|
| `SENTRY_AUTH_TOKEN` | Sentry source map/upload auth |
|
||||||
|
| `REDACT_LOGS_TO_CLIENT` | Convex log redaction setting |
|
||||||
|
| `DISABLE_BEACON` | Self-hosted Convex beacon setting |
|
||||||
|
| `DO_NOT_REQUIRE_SSL` | Self-hosted Convex SSL behavior |
|
||||||
|
| `CI_ENV_FILE` | CI-provided env file path |
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
|
||||||
|
<details open>
|
||||||
|
<summary><strong>Implemented</strong></summary>
|
||||||
|
|
||||||
|
- Thread-first Next.js product shell
|
||||||
|
- GitHub App connection and fork creation foundation
|
||||||
|
- GitHub drift refresh, commit cache, PR cache, and sync-run history
|
||||||
|
- Effective drift and ignored upstream change records
|
||||||
|
- Global Threads page and Spoon-scoped Threads tab
|
||||||
|
- OpenCode/Codex-oriented agent worker and browser workspace foundation
|
||||||
|
- Monaco editor with optional Vim mode
|
||||||
|
- Diff viewer, command panel, worker logs, and artifacts
|
||||||
|
- Encrypted Spoon secrets and bulk `.env` import
|
||||||
|
- Encrypted AI provider profiles, including Codex auth JSON and API-key
|
||||||
|
provider support
|
||||||
|
- Authentik, GitHub, and password auth through Convex Auth
|
||||||
|
- Self-hosted Convex/Postgres deployment model
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Intentionally not done yet</strong></summary>
|
||||||
|
|
||||||
|
- Autonomous merging for custom/diverged forks
|
||||||
|
- Non-GitHub provider automation
|
||||||
|
- Pushing agent branches to additional remotes
|
||||||
|
- Long-running preview stacks for arbitrary forked projects
|
||||||
|
- Direct browser access to worker containers
|
||||||
|
- Public self-hosting setup documentation
|
||||||
|
- Production mobile release flow
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
Spoon is built for a very specific maintenance problem: "I want to fork this
|
||||||
|
project, but I do not want to permanently become its maintenance team."
|
||||||
|
|
||||||
|
The current product direction is to make that maintenance visible, threaded,
|
||||||
|
reviewable, and increasingly automated where it is safe. Clean forks can stay
|
||||||
|
close automatically. Custom forks get context, workspace help, and draft PRs.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun with-env src/index.ts",
|
"dev": "bun with-env bash ../../scripts/dev-agent-worker -- bun src/index.ts",
|
||||||
"start": "bun src/index.ts",
|
"start": "bun src/index.ts",
|
||||||
"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",
|
||||||
@@ -19,14 +19,18 @@
|
|||||||
"@octokit/rest": "^22.0.1",
|
"@octokit/rest": "^22.0.1",
|
||||||
"@opencode-ai/sdk": "latest",
|
"@opencode-ai/sdk": "latest",
|
||||||
"convex": "catalog:convex",
|
"convex": "catalog:convex",
|
||||||
|
"dockerode": "^4.0.7",
|
||||||
"execa": "latest",
|
"execa": "latest",
|
||||||
|
"ws": "catalog:",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@spoon/eslint-config": "workspace:*",
|
"@spoon/eslint-config": "workspace:*",
|
||||||
"@spoon/prettier-config": "workspace:*",
|
"@spoon/prettier-config": "workspace:*",
|
||||||
"@spoon/tsconfig": "workspace:*",
|
"@spoon/tsconfig": "workspace:*",
|
||||||
|
"@types/dockerode": "^3.3.42",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"prettier": "catalog:",
|
"prettier": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
|
|||||||
@@ -0,0 +1,466 @@
|
|||||||
|
export type NormalizedAgentEvent =
|
||||||
|
| { kind: 'assistant_delta'; content: string; externalMessageId?: string }
|
||||||
|
| {
|
||||||
|
kind: 'assistant_completed';
|
||||||
|
content?: string;
|
||||||
|
externalMessageId?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'tool_started';
|
||||||
|
name: string;
|
||||||
|
input?: string;
|
||||||
|
externalMessageId?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'tool_completed';
|
||||||
|
name: string;
|
||||||
|
output?: string;
|
||||||
|
externalMessageId?: string;
|
||||||
|
}
|
||||||
|
| { kind: 'file_edited'; path: string }
|
||||||
|
| {
|
||||||
|
kind: 'command_executed';
|
||||||
|
command: string;
|
||||||
|
exitCode?: number;
|
||||||
|
output?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'permission_requested';
|
||||||
|
externalRequestId: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
metadata?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'question_requested';
|
||||||
|
externalRequestId: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
options?: string[];
|
||||||
|
metadata?: string;
|
||||||
|
}
|
||||||
|
| { kind: 'session'; sessionId: string }
|
||||||
|
| { kind: 'status'; status: string; metadata?: string }
|
||||||
|
| { kind: 'error'; message: string; metadata?: string };
|
||||||
|
|
||||||
|
const stringify = (value: unknown) => {
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
if (value === undefined || value === null) return '';
|
||||||
|
if (
|
||||||
|
typeof value === 'number' ||
|
||||||
|
typeof value === 'boolean' ||
|
||||||
|
typeof value === 'bigint'
|
||||||
|
) {
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value, null, 2);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const asRecord = (value: unknown): Record<string, unknown> | null =>
|
||||||
|
value && typeof value === 'object'
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const textFromPart = (part: Record<string, unknown>) => {
|
||||||
|
const text = part.text ?? part.content ?? part.delta;
|
||||||
|
return typeof text === 'string' ? text : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const commandString = (value: unknown) => {
|
||||||
|
if (Array.isArray(value))
|
||||||
|
return value.map((part) => stringify(part)).join(' ');
|
||||||
|
return stringify(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toolNameFromRecord = (record: Record<string, unknown> | null) =>
|
||||||
|
stringify(
|
||||||
|
record?.tool ??
|
||||||
|
record?.tool_name ??
|
||||||
|
record?.toolName ??
|
||||||
|
record?.name ??
|
||||||
|
record?.function ??
|
||||||
|
(stringify(record?.type).toLowerCase().includes('exec') || record?.command
|
||||||
|
? 'Command'
|
||||||
|
: record?.type) ??
|
||||||
|
'tool',
|
||||||
|
);
|
||||||
|
|
||||||
|
const toolInputFromRecord = (record: Record<string, unknown> | null) =>
|
||||||
|
commandString(
|
||||||
|
record?.input ??
|
||||||
|
record?.arguments ??
|
||||||
|
record?.args ??
|
||||||
|
record?.params ??
|
||||||
|
record?.command ??
|
||||||
|
record?.cmd,
|
||||||
|
);
|
||||||
|
|
||||||
|
const toolOutputFromRecord = (
|
||||||
|
record: Record<string, unknown> | null,
|
||||||
|
fallback?: unknown,
|
||||||
|
) =>
|
||||||
|
stringify(
|
||||||
|
record?.output ??
|
||||||
|
record?.aggregated_output ??
|
||||||
|
record?.stdout ??
|
||||||
|
record?.stderr ??
|
||||||
|
record?.result ??
|
||||||
|
record?.content ??
|
||||||
|
record?.text ??
|
||||||
|
(record?.exit_code !== undefined
|
||||||
|
? `exit code: ${stringify(record.exit_code)}`
|
||||||
|
: undefined) ??
|
||||||
|
fallback,
|
||||||
|
);
|
||||||
|
|
||||||
|
const recordLooksLikeTool = (
|
||||||
|
type: string,
|
||||||
|
record: Record<string, unknown> | null,
|
||||||
|
) => {
|
||||||
|
const recordType = stringify(record?.type).toLowerCase();
|
||||||
|
const lowerType = type.toLowerCase();
|
||||||
|
return (
|
||||||
|
lowerType.includes('tool') ||
|
||||||
|
lowerType.includes('function_call') ||
|
||||||
|
recordType.includes('tool') ||
|
||||||
|
recordType.includes('function_call') ||
|
||||||
|
recordType.includes('local_shell_call') ||
|
||||||
|
recordType.includes('exec_command') ||
|
||||||
|
recordType.includes('command') ||
|
||||||
|
recordType.includes('mcp') ||
|
||||||
|
Boolean(
|
||||||
|
record?.tool ?? record?.tool_name ?? record?.name ?? record?.command,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCodexConfigWarning = (message: string) =>
|
||||||
|
message.includes('`[features].codex_hooks` is deprecated') ||
|
||||||
|
message.includes('Use `[features].hooks` instead');
|
||||||
|
|
||||||
|
// Handles the legacy `codex-rs` `{ id, msg: { type, ... } }` envelope.
|
||||||
|
const normalizeCodexMsgEvent = (
|
||||||
|
msg: Record<string, unknown>,
|
||||||
|
envelope: Record<string, unknown>,
|
||||||
|
): NormalizedAgentEvent[] => {
|
||||||
|
const msgType = stringify(msg.type).toLowerCase();
|
||||||
|
const events: NormalizedAgentEvent[] = [];
|
||||||
|
if (msgType === 'session_configured' || msgType.includes('session')) {
|
||||||
|
const sessionId = stringify(
|
||||||
|
msg.session_id ?? envelope.session_id ?? envelope.id,
|
||||||
|
);
|
||||||
|
if (sessionId) events.push({ kind: 'session', sessionId });
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
msgType === 'agent_message_delta' ||
|
||||||
|
msgType === 'agent_reasoning_delta'
|
||||||
|
) {
|
||||||
|
const delta = stringify(msg.delta ?? msg.text);
|
||||||
|
if (delta) events.push({ kind: 'assistant_delta', content: delta });
|
||||||
|
}
|
||||||
|
if (msgType === 'agent_message') {
|
||||||
|
const text = stringify(msg.message ?? msg.text);
|
||||||
|
if (text) {
|
||||||
|
events.push({ kind: 'assistant_delta', content: `${text.trim()}\n\n` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (msgType === 'exec_command_begin') {
|
||||||
|
events.push({
|
||||||
|
kind: 'tool_started',
|
||||||
|
name: 'Command',
|
||||||
|
input: commandString(msg.command),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (msgType === 'exec_command_end') {
|
||||||
|
events.push({
|
||||||
|
kind: 'tool_completed',
|
||||||
|
name: 'Command',
|
||||||
|
output: toolOutputFromRecord(msg),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
msgType === 'error' ||
|
||||||
|
msgType === 'turn_failed' ||
|
||||||
|
msgType === 'task_error'
|
||||||
|
) {
|
||||||
|
const message = stringify(msg.message ?? msg.error ?? msg);
|
||||||
|
if (isCodexConfigWarning(message)) {
|
||||||
|
events.push({ kind: 'status', status: message });
|
||||||
|
} else {
|
||||||
|
events.push({ kind: 'error', message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (msgType === 'task_complete' || msgType === 'turn_complete') {
|
||||||
|
events.push({ kind: 'assistant_completed' });
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeCodexJsonLine = (
|
||||||
|
line: string,
|
||||||
|
): NormalizedAgentEvent[] => {
|
||||||
|
if (!line.trim()) return [];
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(line) as unknown;
|
||||||
|
} catch {
|
||||||
|
return [{ kind: 'status', status: line }];
|
||||||
|
}
|
||||||
|
const event = asRecord(parsed);
|
||||||
|
if (!event) return [];
|
||||||
|
// Older Codex (`codex-rs`) protocol wraps events as `{ id, msg: { type, ... } }`
|
||||||
|
// instead of the newer `{ type, item: { ... } }` shape. Unwrap it so version
|
||||||
|
// skew between the pinned image and an upstream build degrades gracefully
|
||||||
|
// instead of silently producing an empty assistant response.
|
||||||
|
const msg = asRecord(event.msg);
|
||||||
|
if (msg) {
|
||||||
|
const msgEvents = normalizeCodexMsgEvent(msg, event);
|
||||||
|
if (msgEvents.length > 0) return msgEvents;
|
||||||
|
}
|
||||||
|
const type = stringify(event.type ?? event.event);
|
||||||
|
const id =
|
||||||
|
event.id ??
|
||||||
|
event.session_id ??
|
||||||
|
event.sessionId ??
|
||||||
|
event.thread_id ??
|
||||||
|
event.threadId;
|
||||||
|
const sessionId =
|
||||||
|
typeof id === 'string' &&
|
||||||
|
(type.toLowerCase().includes('session') ||
|
||||||
|
type.toLowerCase().includes('thread.started'))
|
||||||
|
? id
|
||||||
|
: undefined;
|
||||||
|
const events: NormalizedAgentEvent[] = sessionId
|
||||||
|
? [{ kind: 'session', sessionId }]
|
||||||
|
: [];
|
||||||
|
const message = asRecord(event.message);
|
||||||
|
const item = asRecord(event.item);
|
||||||
|
const data = asRecord(event.data);
|
||||||
|
const part = asRecord(event.part);
|
||||||
|
const itemType = item ? stringify(item.type) : '';
|
||||||
|
const lowerType = type.toLowerCase();
|
||||||
|
const lowerItemType = itemType.toLowerCase();
|
||||||
|
if (
|
||||||
|
item &&
|
||||||
|
recordLooksLikeTool(type, item) &&
|
||||||
|
(lowerType.includes('started') ||
|
||||||
|
lowerType.includes('in_progress') ||
|
||||||
|
lowerType.includes('created'))
|
||||||
|
) {
|
||||||
|
events.push({
|
||||||
|
kind: 'tool_started',
|
||||||
|
name: toolNameFromRecord(item),
|
||||||
|
input: toolInputFromRecord(item),
|
||||||
|
externalMessageId: stringify(item.id ?? event.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
item &&
|
||||||
|
recordLooksLikeTool(type, item) &&
|
||||||
|
(lowerType.includes('completed') ||
|
||||||
|
lowerType.includes('done') ||
|
||||||
|
lowerType.includes('finished'))
|
||||||
|
) {
|
||||||
|
events.push({
|
||||||
|
kind: 'tool_completed',
|
||||||
|
name: toolNameFromRecord(item),
|
||||||
|
output: toolOutputFromRecord(item, event.output ?? data?.output),
|
||||||
|
externalMessageId: stringify(item.id ?? event.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const delta = event.delta ?? data?.delta;
|
||||||
|
if (typeof delta === 'string') {
|
||||||
|
events.push({ kind: 'assistant_delta', content: delta });
|
||||||
|
}
|
||||||
|
const text =
|
||||||
|
(part ? textFromPart(part) : '') ||
|
||||||
|
(message ? stringify(message.content ?? message.text) : '') ||
|
||||||
|
(item ? stringify(item.content ?? item.text) : '');
|
||||||
|
if (
|
||||||
|
text &&
|
||||||
|
(type.includes('message') ||
|
||||||
|
type.includes('response.output_text') ||
|
||||||
|
type.includes('agent_message') ||
|
||||||
|
itemType.includes('message') ||
|
||||||
|
itemType.includes('agent_message'))
|
||||||
|
) {
|
||||||
|
events.push({
|
||||||
|
kind: 'assistant_delta',
|
||||||
|
content: itemType.includes('agent_message') ? `${text.trim()}\n\n` : text,
|
||||||
|
externalMessageId: stringify(item?.id ?? event.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const error = event.error ?? item?.error;
|
||||||
|
if (error || itemType === 'error') {
|
||||||
|
const message = stringify(error ?? item?.message ?? event.message);
|
||||||
|
if (isCodexConfigWarning(message)) {
|
||||||
|
events.push({ kind: 'status', status: message });
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
events.push({
|
||||||
|
kind: 'error',
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const command =
|
||||||
|
event.command ??
|
||||||
|
data?.command ??
|
||||||
|
(lowerItemType.includes('shell') ? item?.command : undefined);
|
||||||
|
if (typeof command === 'string') {
|
||||||
|
events.push({
|
||||||
|
kind: 'command_executed',
|
||||||
|
command,
|
||||||
|
output: stringify(event.output ?? data?.output),
|
||||||
|
});
|
||||||
|
} else if (Array.isArray(command)) {
|
||||||
|
events.push({
|
||||||
|
kind: 'command_executed',
|
||||||
|
command: command.map((part) => stringify(part)).join(' '),
|
||||||
|
output: stringify(event.output ?? data?.output ?? item?.output),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const file =
|
||||||
|
event.file ??
|
||||||
|
event.path ??
|
||||||
|
data?.file ??
|
||||||
|
data?.path ??
|
||||||
|
item?.file ??
|
||||||
|
item?.path;
|
||||||
|
if (typeof file === 'string' && type.includes('file')) {
|
||||||
|
events.push({ kind: 'file_edited', path: file });
|
||||||
|
}
|
||||||
|
if (type.includes('error')) {
|
||||||
|
events.push({
|
||||||
|
kind: 'error',
|
||||||
|
message: stringify(event.message ?? event.error ?? data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
type.includes('completed') &&
|
||||||
|
itemType !== 'error' &&
|
||||||
|
!itemType.includes('message') &&
|
||||||
|
!itemType.includes('agent_message') &&
|
||||||
|
!recordLooksLikeTool(type, item)
|
||||||
|
) {
|
||||||
|
events.push({ kind: 'assistant_completed' });
|
||||||
|
}
|
||||||
|
if (type.includes('turn.done')) {
|
||||||
|
events.push({ kind: 'assistant_completed' });
|
||||||
|
}
|
||||||
|
if (events.length === 0) {
|
||||||
|
events.push({ kind: 'status', status: type || 'codex_event' });
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeOpenCodeEvent = (
|
||||||
|
input: unknown,
|
||||||
|
): NormalizedAgentEvent[] => {
|
||||||
|
const event = asRecord(input);
|
||||||
|
if (!event) return [];
|
||||||
|
const type = stringify(event.type);
|
||||||
|
const properties =
|
||||||
|
asRecord(event.properties) ?? asRecord(event.data) ?? event;
|
||||||
|
const events: NormalizedAgentEvent[] = [];
|
||||||
|
const sessionId = properties.sessionID ?? properties.sessionId;
|
||||||
|
if (typeof sessionId === 'string' && type.includes('session')) {
|
||||||
|
events.push({ kind: 'session', sessionId });
|
||||||
|
}
|
||||||
|
if (type === 'message.part.delta') {
|
||||||
|
const part = asRecord(properties.part) ?? properties;
|
||||||
|
const text = textFromPart(part);
|
||||||
|
if (text) {
|
||||||
|
events.push({
|
||||||
|
kind: 'assistant_delta',
|
||||||
|
content: text,
|
||||||
|
externalMessageId: stringify(properties.messageID),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (type === 'message.updated' || type === 'message.part.updated') {
|
||||||
|
const part = asRecord(properties.part);
|
||||||
|
const text = part ? textFromPart(part) : stringify(properties.message);
|
||||||
|
if (text) {
|
||||||
|
events.push({
|
||||||
|
kind: 'assistant_delta',
|
||||||
|
content: text,
|
||||||
|
externalMessageId: stringify(properties.messageID),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (type.includes('tool.started')) {
|
||||||
|
events.push({
|
||||||
|
kind: 'tool_started',
|
||||||
|
name: stringify(properties.tool ?? properties.name ?? 'tool'),
|
||||||
|
input: stringify(properties.input),
|
||||||
|
externalMessageId: stringify(properties.messageID),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (type.includes('tool.finished') || type.includes('tool.completed')) {
|
||||||
|
events.push({
|
||||||
|
kind: 'tool_completed',
|
||||||
|
name: stringify(properties.tool ?? properties.name ?? 'tool'),
|
||||||
|
output: stringify(properties.output ?? properties.result),
|
||||||
|
externalMessageId: stringify(properties.messageID),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (type.includes('tool.updated') || type.includes('tool.output')) {
|
||||||
|
events.push({
|
||||||
|
kind: 'tool_completed',
|
||||||
|
name: stringify(properties.tool ?? properties.name ?? 'tool'),
|
||||||
|
output: stringify(properties.output ?? properties.result ?? properties),
|
||||||
|
externalMessageId: stringify(properties.messageID),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (type === 'file.edited') {
|
||||||
|
const file = properties.file;
|
||||||
|
if (typeof file === 'string')
|
||||||
|
events.push({ kind: 'file_edited', path: file });
|
||||||
|
}
|
||||||
|
if (type === 'command.executed') {
|
||||||
|
events.push({
|
||||||
|
kind: 'command_executed',
|
||||||
|
command: stringify(properties.command),
|
||||||
|
output: stringify(properties.output),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (type.includes('permission') && type.includes('asked')) {
|
||||||
|
events.push({
|
||||||
|
kind: 'permission_requested',
|
||||||
|
externalRequestId: stringify(properties.permissionID ?? properties.id),
|
||||||
|
title: 'Permission requested',
|
||||||
|
body: stringify(
|
||||||
|
properties.permission ?? properties.message ?? properties,
|
||||||
|
),
|
||||||
|
metadata: stringify(properties),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (type.includes('question') && type.includes('asked')) {
|
||||||
|
events.push({
|
||||||
|
kind: 'question_requested',
|
||||||
|
externalRequestId: stringify(properties.requestID ?? properties.id),
|
||||||
|
title: 'Agent question',
|
||||||
|
body: stringify(properties.question ?? properties.message ?? properties),
|
||||||
|
metadata: stringify(properties),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (type === 'session.idle') events.push({ kind: 'assistant_completed' });
|
||||||
|
if (type === 'session.error') {
|
||||||
|
events.push({
|
||||||
|
kind: 'error',
|
||||||
|
message: stringify(properties.error ?? properties.message ?? properties),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (events.length === 0 && type) {
|
||||||
|
events.push({
|
||||||
|
kind: 'status',
|
||||||
|
status: type,
|
||||||
|
metadata: stringify(properties),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { chmod, mkdir, stat } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
export const codexContainerWorkspace = '/workspace';
|
||||||
|
export const codexContainerRepo = '/workspace/repo';
|
||||||
|
|
||||||
|
export const prepareCodexWorkspaceFiles = async (args: {
|
||||||
|
workdir: string;
|
||||||
|
repoDir: string;
|
||||||
|
}) => {
|
||||||
|
await mkdir(path.join(args.workdir, '.codex'), { recursive: true });
|
||||||
|
await mkdir(path.join(args.workdir, '.config'), { recursive: true });
|
||||||
|
await mkdir(path.join(args.workdir, '.local', 'share'), { recursive: true });
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
chmod(args.workdir, 0o755),
|
||||||
|
chmod(args.repoDir, 0o755),
|
||||||
|
chmod(path.join(args.workdir, '.codex'), 0o755),
|
||||||
|
chmod(path.join(args.workdir, '.config'), 0o755),
|
||||||
|
chmod(path.join(args.workdir, '.local'), 0o755),
|
||||||
|
chmod(path.join(args.workdir, '.local', 'share'), 0o755),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const projectCodexDir = path.join(args.repoDir, '.codex');
|
||||||
|
const projectConfig = path.join(projectCodexDir, 'config.toml');
|
||||||
|
try {
|
||||||
|
if ((await stat(projectCodexDir)).isDirectory()) {
|
||||||
|
await chmod(projectCodexDir, 0o755);
|
||||||
|
}
|
||||||
|
if ((await stat(projectConfig)).isFile()) {
|
||||||
|
await chmod(projectConfig, 0o644);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const code = error && typeof error === 'object' ? 'code' in error : false;
|
||||||
|
if (!code || (error as { code?: string }).code !== 'ENOENT') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -12,6 +12,8 @@ const requiredEnv = (name: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const env = {
|
export const env = {
|
||||||
|
buildSha: process.env.SPOON_BUILD_SHA?.trim() ?? 'development',
|
||||||
|
buildCreatedAt: process.env.SPOON_BUILD_CREATED_AT?.trim() ?? 'unknown',
|
||||||
convexUrl:
|
convexUrl:
|
||||||
process.env.NEXT_PUBLIC_CONVEX_URL?.trim() ??
|
process.env.NEXT_PUBLIC_CONVEX_URL?.trim() ??
|
||||||
process.env.CONVEX_SELF_HOSTED_URL?.trim() ??
|
process.env.CONVEX_SELF_HOSTED_URL?.trim() ??
|
||||||
@@ -19,9 +21,38 @@ export const env = {
|
|||||||
workerToken: requiredEnv('SPOON_WORKER_TOKEN'),
|
workerToken: requiredEnv('SPOON_WORKER_TOKEN'),
|
||||||
workerId: process.env.SPOON_AGENT_WORKER_ID?.trim() ?? 'local-worker',
|
workerId: process.env.SPOON_AGENT_WORKER_ID?.trim() ?? 'local-worker',
|
||||||
runtime: process.env.SPOON_AGENT_RUNTIME?.trim() ?? 'docker',
|
runtime: process.env.SPOON_AGENT_RUNTIME?.trim() ?? 'docker',
|
||||||
|
containerRuntime:
|
||||||
|
process.env.SPOON_AGENT_CONTAINER_RUNTIME?.trim() ??
|
||||||
|
process.env.SPOON_CONTAINER_RUNTIME?.trim() ??
|
||||||
|
'docker',
|
||||||
|
containerVolumeOptions:
|
||||||
|
process.env.SPOON_AGENT_CONTAINER_VOLUME_OPTIONS?.trim(),
|
||||||
|
containerAccess:
|
||||||
|
process.env.SPOON_AGENT_CONTAINER_ACCESS?.trim() === 'host_port'
|
||||||
|
? 'host_port'
|
||||||
|
: 'network',
|
||||||
jobImage:
|
jobImage:
|
||||||
process.env.SPOON_AGENT_JOB_IMAGE?.trim() ?? 'spoon-agent-job:latest',
|
process.env.SPOON_AGENT_JOB_IMAGE?.trim() ?? 'spoon-agent-job:latest',
|
||||||
|
// Interactive terminal: image for the persistent shell container (defaults to
|
||||||
|
// the job image), the secret shared with the Next app for verifying terminal
|
||||||
|
// tokens, and how long an idle terminal container survives before cleanup.
|
||||||
|
terminalImage:
|
||||||
|
process.env.SPOON_AGENT_TERMINAL_IMAGE?.trim() ??
|
||||||
|
process.env.SPOON_AGENT_JOB_IMAGE?.trim() ??
|
||||||
|
'spoon-agent-job:latest',
|
||||||
|
terminalSecret:
|
||||||
|
process.env.SPOON_AGENT_TERMINAL_SECRET?.trim() ??
|
||||||
|
process.env.SPOON_AGENT_WORKER_INTERNAL_TOKEN?.trim() ??
|
||||||
|
process.env.SPOON_WORKER_TOKEN?.trim() ??
|
||||||
|
'',
|
||||||
|
terminalIdleMs: intEnv('SPOON_AGENT_TERMINAL_IDLE_MS', 1_800_000),
|
||||||
|
// How long a per-user box container survives with no active jobs/terminals.
|
||||||
|
boxIdleMs: intEnv('SPOON_AGENT_BOX_IDLE_MS', 1_800_000),
|
||||||
|
// Dev-only: exit if the parent dev runner dies, so the worker never orphans
|
||||||
|
// and holds port 3921 across restarts. Set by scripts/dev-agent-worker.
|
||||||
|
devWatchdog: process.env.SPOON_AGENT_DEV_WATCHDOG === '1',
|
||||||
workdir: process.env.SPOON_AGENT_WORKDIR?.trim() ?? '.local/agent-work',
|
workdir: process.env.SPOON_AGENT_WORKDIR?.trim() ?? '.local/agent-work',
|
||||||
|
hostWorkdir: process.env.SPOON_AGENT_HOST_WORKDIR?.trim(),
|
||||||
network: process.env.SPOON_AGENT_NETWORK?.trim(),
|
network: process.env.SPOON_AGENT_NETWORK?.trim(),
|
||||||
pollMs: intEnv('SPOON_AGENT_POLL_MS', 5_000),
|
pollMs: intEnv('SPOON_AGENT_POLL_MS', 5_000),
|
||||||
httpPort: intEnv('SPOON_AGENT_WORKER_HTTP_PORT', 3921),
|
httpPort: intEnv('SPOON_AGENT_WORKER_HTTP_PORT', 3921),
|
||||||
|
|||||||
@@ -36,12 +36,16 @@ export const cloneRepository = async (args: {
|
|||||||
workBranch: string;
|
workBranch: string;
|
||||||
redact: (value: string) => string;
|
redact: (value: string) => string;
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
|
// Directory name to clone into under `workdir` (default "repo"). Used to lay
|
||||||
|
// out checkouts as ~/Code/{spoon}/{branch}.
|
||||||
|
dirName?: string;
|
||||||
}) => {
|
}) => {
|
||||||
await mkdir(args.workdir, { recursive: true });
|
await mkdir(args.workdir, { recursive: true });
|
||||||
|
const dirName = args.dirName ?? 'repo';
|
||||||
const repoUrl = `https://x-access-token:${args.token}@github.com/${args.owner}/${args.repo}.git`;
|
const repoUrl = `https://x-access-token:${args.token}@github.com/${args.owner}/${args.repo}.git`;
|
||||||
const clone = await run(
|
const clone = await run(
|
||||||
'git',
|
'git',
|
||||||
['clone', '--branch', args.baseBranch, '--single-branch', repoUrl, 'repo'],
|
['clone', '--branch', args.baseBranch, '--single-branch', repoUrl, dirName],
|
||||||
{
|
{
|
||||||
cwd: args.workdir,
|
cwd: args.workdir,
|
||||||
redact: args.redact,
|
redact: args.redact,
|
||||||
@@ -51,7 +55,7 @@ export const cloneRepository = async (args: {
|
|||||||
if (clone.exitCode !== 0) {
|
if (clone.exitCode !== 0) {
|
||||||
throw new Error(`git clone failed:\n${clone.output}`);
|
throw new Error(`git clone failed:\n${clone.output}`);
|
||||||
}
|
}
|
||||||
const repoDir = path.join(args.workdir, 'repo');
|
const repoDir = path.join(args.workdir, dirName);
|
||||||
const checkout = await run('git', ['checkout', '-b', args.workBranch], {
|
const checkout = await run('git', ['checkout', '-b', args.workBranch], {
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
redact: args.redact,
|
redact: args.redact,
|
||||||
@@ -126,12 +130,41 @@ export const getDiff = async (
|
|||||||
export const getWorktreeDiff = async (
|
export const getWorktreeDiff = async (
|
||||||
repoDir: string,
|
repoDir: string,
|
||||||
redact: (value: string) => string,
|
redact: (value: string) => string,
|
||||||
) =>
|
) => {
|
||||||
await run('git', ['diff', '--', '.'], {
|
const trackedDiff = await run('git', ['diff', '--', '.'], {
|
||||||
cwd: repoDir,
|
cwd: repoDir,
|
||||||
redact,
|
redact,
|
||||||
timeoutMs: 60_000,
|
timeoutMs: 60_000,
|
||||||
});
|
});
|
||||||
|
const untracked = await run(
|
||||||
|
'git',
|
||||||
|
['ls-files', '--others', '--exclude-standard'],
|
||||||
|
{
|
||||||
|
cwd: repoDir,
|
||||||
|
redact,
|
||||||
|
timeoutMs: 60_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const untrackedDiffs: string[] = [];
|
||||||
|
for (const filePath of untracked.output.split('\n').filter(Boolean)) {
|
||||||
|
const diff = await run(
|
||||||
|
'git',
|
||||||
|
['diff', '--no-index', '--', '/dev/null', filePath],
|
||||||
|
{
|
||||||
|
cwd: repoDir,
|
||||||
|
redact,
|
||||||
|
timeoutMs: 60_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (diff.output.trim()) untrackedDiffs.push(diff.output);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
exitCode: trackedDiff.exitCode === 0 && untracked.exitCode === 0 ? 0 : 1,
|
||||||
|
output: [trackedDiff.output, ...untrackedDiffs]
|
||||||
|
.filter((part) => part.trim())
|
||||||
|
.join('\n'),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const getStatus = async (
|
export const getStatus = async (
|
||||||
repoDir: string,
|
repoDir: string,
|
||||||
|
|||||||
@@ -1,5 +1,29 @@
|
|||||||
|
import { env } from './env';
|
||||||
import { startWorkerServer } from './server';
|
import { startWorkerServer } from './server';
|
||||||
import { startWorker } from './worker';
|
import { startWorker } from './worker';
|
||||||
|
|
||||||
|
// Dev-only watchdog: the dev runner chain (turbo → with-env → dotenv → bash)
|
||||||
|
// doesn't always forward the stop signal to this leaf process, so on restart the
|
||||||
|
// worker can orphan and keep holding port 3921. Exit when our original parent
|
||||||
|
// goes away (we get reparented) or on a stop signal, so restarts stay clean.
|
||||||
|
// Never enabled in prod (gated on SPOON_AGENT_DEV_WATCHDOG).
|
||||||
|
if (env.devWatchdog) {
|
||||||
|
// Bun caches `process.ppid`, so poll whether the original parent still exists
|
||||||
|
// (signal 0 throws once it's gone) rather than comparing ppid.
|
||||||
|
const parentPid = process.ppid;
|
||||||
|
const watcher = setInterval(() => {
|
||||||
|
try {
|
||||||
|
process.kill(parentPid, 0);
|
||||||
|
} catch {
|
||||||
|
console.log('Dev parent exited; shutting down worker.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
watcher.unref();
|
||||||
|
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) {
|
||||||
|
process.on(signal, () => process.exit(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
startWorkerServer();
|
startWorkerServer();
|
||||||
await startWorker();
|
await startWorker();
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import type { OpencodeClient } from '@opencode-ai/sdk';
|
||||||
|
import { createOpencodeClient } from '@opencode-ai/sdk';
|
||||||
|
|
||||||
|
import type { NormalizedAgentEvent } from './agent-events';
|
||||||
|
import { normalizeOpenCodeEvent } from './agent-events';
|
||||||
|
|
||||||
|
export type OpenCodeSession = {
|
||||||
|
client: OpencodeClient;
|
||||||
|
sessionId: string;
|
||||||
|
close: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const basicAuth = (username: string, password: string) =>
|
||||||
|
`Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||||
|
|
||||||
|
const modelParts = (model: string) => {
|
||||||
|
const [rawProviderId, ...rest] = model.split('/');
|
||||||
|
const providerID =
|
||||||
|
rawProviderId && rawProviderId.length > 0 ? rawProviderId : 'openai';
|
||||||
|
const modelID = rest.length > 0 ? rest.join('/') : model;
|
||||||
|
return {
|
||||||
|
providerID,
|
||||||
|
modelID,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createOpenCodeSession = async (args: {
|
||||||
|
baseUrl: string;
|
||||||
|
password: string;
|
||||||
|
directory: string;
|
||||||
|
title: string;
|
||||||
|
onEvent: (event: NormalizedAgentEvent) => Promise<void>;
|
||||||
|
}) => {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const client = createOpencodeClient({
|
||||||
|
baseUrl: args.baseUrl,
|
||||||
|
directory: args.directory,
|
||||||
|
headers: {
|
||||||
|
authorization: basicAuth('opencode', args.password),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const created = await client.session.create({
|
||||||
|
query: { directory: args.directory },
|
||||||
|
body: { title: args.title },
|
||||||
|
});
|
||||||
|
if (!created.data) {
|
||||||
|
throw new Error('OpenCode session could not be created.');
|
||||||
|
}
|
||||||
|
const sessionId = created.data.id;
|
||||||
|
void (async () => {
|
||||||
|
const events = await client.event.subscribe({
|
||||||
|
signal: abortController.signal,
|
||||||
|
query: { directory: args.directory },
|
||||||
|
onSseEvent: (event) => {
|
||||||
|
for (const normalized of normalizeOpenCodeEvent(event.data)) {
|
||||||
|
void args.onEvent(normalized);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSseError: (error) => {
|
||||||
|
void args.onEvent({
|
||||||
|
kind: 'error',
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for await (const event of events.stream) {
|
||||||
|
for (const normalized of normalizeOpenCodeEvent(event)) {
|
||||||
|
await args.onEvent(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})().catch((error: unknown) => {
|
||||||
|
if (!abortController.signal.aborted) {
|
||||||
|
void args.onEvent({
|
||||||
|
kind: 'error',
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
sessionId,
|
||||||
|
close: () => abortController.abort(),
|
||||||
|
} satisfies OpenCodeSession;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const promptOpenCodeSession = async (args: {
|
||||||
|
session: OpenCodeSession;
|
||||||
|
prompt: string;
|
||||||
|
model: string;
|
||||||
|
directory: string;
|
||||||
|
}) => {
|
||||||
|
const model = modelParts(args.model);
|
||||||
|
const result = await args.session.client.session.promptAsync({
|
||||||
|
path: { id: args.session.sessionId },
|
||||||
|
query: { directory: args.directory },
|
||||||
|
body: {
|
||||||
|
model,
|
||||||
|
parts: [{ type: 'text', text: args.prompt }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (result.error) {
|
||||||
|
throw new Error('OpenCode prompt was rejected.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const abortOpenCodeSession = async (session: OpenCodeSession) => {
|
||||||
|
await session.client.session.abort({
|
||||||
|
path: { id: session.sessionId },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const replyOpenCodePermission = async (args: {
|
||||||
|
session: OpenCodeSession;
|
||||||
|
permissionId: string;
|
||||||
|
response: 'once' | 'always' | 'reject';
|
||||||
|
directory: string;
|
||||||
|
}) => {
|
||||||
|
const result = await args.session.client.postSessionIdPermissionsPermissionId(
|
||||||
|
{
|
||||||
|
path: { id: args.session.sessionId, permissionID: args.permissionId },
|
||||||
|
query: { directory: args.directory },
|
||||||
|
body: { response: args.response },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (result.error) {
|
||||||
|
throw new Error('OpenCode permission response was rejected.');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,21 +1,112 @@
|
|||||||
|
import { mkdir } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import type { Readable } from 'node:stream';
|
||||||
import { execa } from 'execa';
|
import { execa } from 'execa';
|
||||||
|
|
||||||
import { env } from '../env';
|
import { env } from '../env';
|
||||||
|
|
||||||
|
type CommandResult = {
|
||||||
|
exitCode: number;
|
||||||
|
output: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const environmentArgs = (environment: Record<string, string>) =>
|
||||||
|
Object.entries(environment).flatMap(([name, value]) => [
|
||||||
|
'-e',
|
||||||
|
`${name}=${value}`,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const networkArgs = () => (env.network ? ['--network', env.network] : []);
|
||||||
|
|
||||||
|
const containerRuntime = () => env.containerRuntime;
|
||||||
|
|
||||||
|
// `docker run` reuses a stale local `:latest` forever, so without an explicit
|
||||||
|
// pull the job image never updates in production. Pull once per worker process
|
||||||
|
// (i.e. once per deploy/restart) so a fresh worker always runs a fresh job
|
||||||
|
// image. Best-effort: if the registry is unreachable we fall back to whatever
|
||||||
|
// image is present locally rather than failing the job.
|
||||||
|
let jobImagePullPromise: Promise<void> | undefined;
|
||||||
|
export const ensureJobImagePulled = () => {
|
||||||
|
jobImagePullPromise ??= (async () => {
|
||||||
|
try {
|
||||||
|
await execa(containerRuntime(), ['pull', env.jobImage], {
|
||||||
|
reject: false,
|
||||||
|
stdin: 'ignore',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Ignore: keep running with the locally cached image.
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return jobImagePullPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
// execa with `reject: false` resolves (does not throw) even when the runtime
|
||||||
|
// binary is missing (ENOENT) — `exitCode` is then `undefined`. Coercing that to
|
||||||
|
// 0 makes a failed spawn look like a successful empty run, which is exactly how
|
||||||
|
// a worker image without a `docker` CLI silently produced empty agent
|
||||||
|
// responses. Normalize so any spawn failure is a non-zero exit carrying the
|
||||||
|
// real reason.
|
||||||
|
export const normalizeRunResult = (
|
||||||
|
// Declared nullable on purpose: execa's types claim these are always present,
|
||||||
|
// but on a spawn failure (e.g. missing `docker` binary) `exitCode`/`all` are
|
||||||
|
// actually undefined at runtime.
|
||||||
|
result: { exitCode?: number; shortMessage?: string },
|
||||||
|
output: string | undefined,
|
||||||
|
redact: (value: string) => string,
|
||||||
|
): CommandResult => {
|
||||||
|
const text = output ?? '';
|
||||||
|
if (result.exitCode == null) {
|
||||||
|
const reason = result.shortMessage ?? 'container runtime failed to start';
|
||||||
|
return {
|
||||||
|
exitCode: 1,
|
||||||
|
output: redact(`${text}${text ? '\n' : ''}${reason}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { exitCode: result.exitCode, output: redact(text) };
|
||||||
|
};
|
||||||
|
|
||||||
|
const hostWorkspacePath = (workdir: string) => {
|
||||||
|
if (!env.hostWorkdir) return workdir;
|
||||||
|
const workerRoot = path.resolve(env.workdir);
|
||||||
|
const resolvedWorkdir = path.resolve(workdir);
|
||||||
|
const relative = path.relative(workerRoot, resolvedWorkdir);
|
||||||
|
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||||
|
return workdir;
|
||||||
|
}
|
||||||
|
return path.join(env.hostWorkdir, relative);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const containerVolumeSuffix = () =>
|
||||||
|
env.containerVolumeOptions ??
|
||||||
|
(containerRuntime().endsWith('podman') ? 'Z' : undefined);
|
||||||
|
|
||||||
|
export { hostWorkspacePath };
|
||||||
|
|
||||||
|
export const jobWorkspaceVolumeSpec = (
|
||||||
|
workdir: string,
|
||||||
|
containerHome = '/workspace',
|
||||||
|
) => {
|
||||||
|
const volumeOptions =
|
||||||
|
env.containerVolumeOptions ??
|
||||||
|
(containerRuntime().endsWith('podman') ? 'Z' : undefined);
|
||||||
|
const source = hostWorkspacePath(workdir);
|
||||||
|
return volumeOptions
|
||||||
|
? `${source}:${containerHome}:${volumeOptions}`
|
||||||
|
: `${source}:${containerHome}`;
|
||||||
|
};
|
||||||
|
|
||||||
export const runInJobContainer = async (args: {
|
export const runInJobContainer = async (args: {
|
||||||
workdir: string;
|
workdir: string;
|
||||||
|
containerHome?: string;
|
||||||
|
containerCwd?: string;
|
||||||
command: string[];
|
command: string[];
|
||||||
environment: Record<string, string>;
|
environment: Record<string, string>;
|
||||||
redact: (value: string) => string;
|
redact: (value: string) => string;
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
}) => {
|
}): Promise<CommandResult> => {
|
||||||
const envArgs = Object.entries(args.environment).flatMap(([name, value]) => [
|
await ensureJobImagePulled();
|
||||||
'-e',
|
|
||||||
`${name}=${value}`,
|
|
||||||
]);
|
|
||||||
const networkArgs = env.network ? ['--network', env.network] : [];
|
|
||||||
const result = await execa(
|
const result = await execa(
|
||||||
'docker',
|
containerRuntime(),
|
||||||
[
|
[
|
||||||
'run',
|
'run',
|
||||||
'--rm',
|
'--rm',
|
||||||
@@ -23,18 +114,110 @@ export const runInJobContainer = async (args: {
|
|||||||
'4g',
|
'4g',
|
||||||
'--cpus',
|
'--cpus',
|
||||||
'2',
|
'2',
|
||||||
...networkArgs,
|
...networkArgs(),
|
||||||
...envArgs,
|
...environmentArgs(args.environment),
|
||||||
'-v',
|
'-v',
|
||||||
`${args.workdir}:/workspace`,
|
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
|
||||||
'-w',
|
'-w',
|
||||||
'/workspace/repo',
|
args.containerCwd ?? '/workspace/repo',
|
||||||
env.jobImage,
|
env.jobImage,
|
||||||
...args.command,
|
...args.command,
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
all: true,
|
all: true,
|
||||||
reject: false,
|
reject: false,
|
||||||
|
stdin: 'ignore',
|
||||||
|
timeout: args.timeoutMs,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return normalizeRunResult(result, result.all, args.redact);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const startWorkspaceContainer = async (args: {
|
||||||
|
workdir: string;
|
||||||
|
containerHome?: string;
|
||||||
|
containerCwd?: string;
|
||||||
|
containerName: string;
|
||||||
|
environment: Record<string, string>;
|
||||||
|
command?: string[];
|
||||||
|
publishTcpPort?: number;
|
||||||
|
}) => {
|
||||||
|
await ensureJobImagePulled();
|
||||||
|
await execa(containerRuntime(), ['rm', '-f', args.containerName], {
|
||||||
|
reject: false,
|
||||||
|
});
|
||||||
|
const result = await execa(
|
||||||
|
containerRuntime(),
|
||||||
|
[
|
||||||
|
'run',
|
||||||
|
'-d',
|
||||||
|
'--name',
|
||||||
|
args.containerName,
|
||||||
|
'--memory',
|
||||||
|
'4g',
|
||||||
|
'--cpus',
|
||||||
|
'2',
|
||||||
|
...networkArgs(),
|
||||||
|
...(args.publishTcpPort
|
||||||
|
? ['-p', `127.0.0.1::${args.publishTcpPort}`]
|
||||||
|
: []),
|
||||||
|
...environmentArgs(args.environment),
|
||||||
|
'-v',
|
||||||
|
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
|
||||||
|
'-w',
|
||||||
|
args.containerCwd ?? '/workspace/repo',
|
||||||
|
env.jobImage,
|
||||||
|
...(args.command ?? ['sleep', 'infinity']),
|
||||||
|
],
|
||||||
|
{ all: true, stdin: 'ignore' },
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
containerId: result.stdout.trim(),
|
||||||
|
containerName: args.containerName,
|
||||||
|
hostPort: args.publishTcpPort
|
||||||
|
? await getPublishedPort(args.containerName, args.publishTcpPort)
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPublishedPort = async (
|
||||||
|
containerName: string,
|
||||||
|
containerPort: number,
|
||||||
|
) => {
|
||||||
|
const result = await execa(
|
||||||
|
containerRuntime(),
|
||||||
|
['port', containerName, `${containerPort}/tcp`],
|
||||||
|
{ all: true, reject: false, stdin: 'ignore' },
|
||||||
|
);
|
||||||
|
const output = result.all.trim();
|
||||||
|
const match = /:(\d+)\s*$/.exec(output);
|
||||||
|
if (!match?.[1]) {
|
||||||
|
throw new Error(
|
||||||
|
`Could not determine published port for ${containerName}:${containerPort}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return match[1];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const execInWorkspaceContainer = async (args: {
|
||||||
|
containerName: string;
|
||||||
|
command: string[];
|
||||||
|
environment?: Record<string, string>;
|
||||||
|
redact: (value: string) => string;
|
||||||
|
timeoutMs: number;
|
||||||
|
}): Promise<CommandResult> => {
|
||||||
|
const result = await execa(
|
||||||
|
containerRuntime(),
|
||||||
|
[
|
||||||
|
'exec',
|
||||||
|
...(args.environment ? environmentArgs(args.environment) : []),
|
||||||
|
args.containerName,
|
||||||
|
...args.command,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
all: true,
|
||||||
|
reject: false,
|
||||||
|
stdin: 'ignore',
|
||||||
timeout: args.timeoutMs,
|
timeout: args.timeoutMs,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -43,3 +226,241 @@ export const runInJobContainer = async (args: {
|
|||||||
output: args.redact(result.all),
|
output: args.redact(result.all),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Shared line-streaming + result normalization for a started subprocess
|
||||||
|
// (used by both `docker run` and `docker exec` paths).
|
||||||
|
type StreamingSubprocess = {
|
||||||
|
stdout: Readable | null;
|
||||||
|
stderr: Readable | null;
|
||||||
|
} & Promise<{ exitCode?: number; shortMessage?: string; all?: string }>;
|
||||||
|
|
||||||
|
const streamSubprocess = async (
|
||||||
|
subprocess: StreamingSubprocess,
|
||||||
|
redact: (value: string) => string,
|
||||||
|
onStdoutLine?: (line: string) => Promise<void>,
|
||||||
|
onStderrLine?: (line: string) => Promise<void>,
|
||||||
|
): Promise<CommandResult> => {
|
||||||
|
let stdoutBuffer = '';
|
||||||
|
let stderrBuffer = '';
|
||||||
|
const output: string[] = [];
|
||||||
|
let lineHandlers = Promise.resolve();
|
||||||
|
const consume = async (
|
||||||
|
chunk: Buffer,
|
||||||
|
source: 'stdout' | 'stderr',
|
||||||
|
handler?: (line: string) => Promise<void>,
|
||||||
|
) => {
|
||||||
|
output.push(chunk.toString('utf8'));
|
||||||
|
const next = `${source === 'stdout' ? stdoutBuffer : stderrBuffer}${chunk.toString('utf8')}`;
|
||||||
|
const lines = next.split(/\r?\n/);
|
||||||
|
const remainder = lines.pop() ?? '';
|
||||||
|
if (source === 'stdout') stdoutBuffer = remainder;
|
||||||
|
else stderrBuffer = remainder;
|
||||||
|
for (const line of lines) {
|
||||||
|
if (handler) await handler(redact(line));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
subprocess.stdout?.on('data', (chunk: Buffer) => {
|
||||||
|
lineHandlers = lineHandlers.then(() =>
|
||||||
|
consume(chunk, 'stdout', onStdoutLine),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
subprocess.stderr?.on('data', (chunk: Buffer) => {
|
||||||
|
lineHandlers = lineHandlers.then(() =>
|
||||||
|
consume(chunk, 'stderr', onStderrLine),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
let result: Awaited<StreamingSubprocess>;
|
||||||
|
try {
|
||||||
|
result = await subprocess;
|
||||||
|
} catch (error) {
|
||||||
|
await lineHandlers;
|
||||||
|
const outputText = output.join('');
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : 'Container command failed.';
|
||||||
|
return {
|
||||||
|
exitCode: 1,
|
||||||
|
output: redact(`${outputText}${outputText ? '\n' : ''}${message}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await lineHandlers;
|
||||||
|
if (stdoutBuffer && onStdoutLine) await onStdoutLine(redact(stdoutBuffer));
|
||||||
|
if (stderrBuffer && onStderrLine) await onStderrLine(redact(stderrBuffer));
|
||||||
|
return normalizeRunResult(result, output.join(''), redact);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const streamInJobContainer = async (args: {
|
||||||
|
workdir: string;
|
||||||
|
containerHome?: string;
|
||||||
|
containerCwd?: string;
|
||||||
|
command: string[];
|
||||||
|
environment: Record<string, string>;
|
||||||
|
redact: (value: string) => string;
|
||||||
|
timeoutMs: number;
|
||||||
|
onStdoutLine?: (line: string) => Promise<void>;
|
||||||
|
onStderrLine?: (line: string) => Promise<void>;
|
||||||
|
}): Promise<CommandResult> => {
|
||||||
|
await ensureJobImagePulled();
|
||||||
|
const subprocess = execa(
|
||||||
|
containerRuntime(),
|
||||||
|
[
|
||||||
|
'run',
|
||||||
|
'--rm',
|
||||||
|
'--memory',
|
||||||
|
'4g',
|
||||||
|
'--cpus',
|
||||||
|
'2',
|
||||||
|
...networkArgs(),
|
||||||
|
...environmentArgs(args.environment),
|
||||||
|
'-v',
|
||||||
|
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
|
||||||
|
'-w',
|
||||||
|
args.containerCwd ?? '/workspace/repo',
|
||||||
|
env.jobImage,
|
||||||
|
...args.command,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
all: true,
|
||||||
|
reject: false,
|
||||||
|
stdin: 'ignore',
|
||||||
|
timeout: args.timeoutMs,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return streamSubprocess(
|
||||||
|
subprocess,
|
||||||
|
args.redact,
|
||||||
|
args.onStdoutLine,
|
||||||
|
args.onStderrLine,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Per-user persistent "box" container that all of a user's threads exec into
|
||||||
|
// (Phase 2). Started once, reused; the home volume persists state across stops.
|
||||||
|
export const userContainerName = (username: string) =>
|
||||||
|
`spoon-box-${username.replace(/[^a-zA-Z0-9_.-]/g, '-')}`;
|
||||||
|
|
||||||
|
export const ensureUserContainer = async (args: {
|
||||||
|
username: string;
|
||||||
|
workdir: string;
|
||||||
|
containerHome: string;
|
||||||
|
}): Promise<string> => {
|
||||||
|
await ensureJobImagePulled();
|
||||||
|
const name = userContainerName(args.username);
|
||||||
|
const inspect = await execa(
|
||||||
|
containerRuntime(),
|
||||||
|
['inspect', '-f', '{{.State.Running}}', name],
|
||||||
|
{ reject: false, stdin: 'ignore' },
|
||||||
|
);
|
||||||
|
if (inspect.exitCode === 0 && inspect.stdout.trim() === 'true') return name;
|
||||||
|
// The box mounts the per-user home, but it's created before the thread's clone
|
||||||
|
// populates it — ensure it exists first, since podman (unlike docker) refuses to
|
||||||
|
// bind-mount a missing source directory (statfs: no such file or directory).
|
||||||
|
await mkdir(args.workdir, { recursive: true });
|
||||||
|
// Not running: remove any stale container, then start fresh.
|
||||||
|
await execa(containerRuntime(), ['rm', '-f', name], { reject: false });
|
||||||
|
await execa(
|
||||||
|
containerRuntime(),
|
||||||
|
[
|
||||||
|
'run',
|
||||||
|
'-d',
|
||||||
|
'--name',
|
||||||
|
name,
|
||||||
|
'--memory',
|
||||||
|
'4g',
|
||||||
|
'--cpus',
|
||||||
|
'2',
|
||||||
|
...networkArgs(),
|
||||||
|
'-v',
|
||||||
|
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
|
||||||
|
'-w',
|
||||||
|
args.containerHome,
|
||||||
|
env.jobImage,
|
||||||
|
'sleep',
|
||||||
|
'infinity',
|
||||||
|
],
|
||||||
|
{ stdin: 'ignore' },
|
||||||
|
);
|
||||||
|
return name;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const streamExecInContainer = async (args: {
|
||||||
|
containerName: string;
|
||||||
|
command: string[];
|
||||||
|
environment: Record<string, string>;
|
||||||
|
containerCwd: string;
|
||||||
|
redact: (value: string) => string;
|
||||||
|
timeoutMs: number;
|
||||||
|
onStdoutLine?: (line: string) => Promise<void>;
|
||||||
|
onStderrLine?: (line: string) => Promise<void>;
|
||||||
|
}): Promise<CommandResult> => {
|
||||||
|
const subprocess = execa(
|
||||||
|
containerRuntime(),
|
||||||
|
[
|
||||||
|
'exec',
|
||||||
|
...environmentArgs(args.environment),
|
||||||
|
'-w',
|
||||||
|
args.containerCwd,
|
||||||
|
args.containerName,
|
||||||
|
...args.command,
|
||||||
|
],
|
||||||
|
{ all: true, reject: false, stdin: 'ignore', timeout: args.timeoutMs },
|
||||||
|
);
|
||||||
|
return streamSubprocess(
|
||||||
|
subprocess,
|
||||||
|
args.redact,
|
||||||
|
args.onStdoutLine,
|
||||||
|
args.onStderrLine,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const runExecInContainer = async (args: {
|
||||||
|
containerName: string;
|
||||||
|
command: string[];
|
||||||
|
environment: Record<string, string>;
|
||||||
|
containerCwd: string;
|
||||||
|
redact: (value: string) => string;
|
||||||
|
timeoutMs: number;
|
||||||
|
}): Promise<CommandResult> => {
|
||||||
|
const result = await execa(
|
||||||
|
containerRuntime(),
|
||||||
|
[
|
||||||
|
'exec',
|
||||||
|
...environmentArgs(args.environment),
|
||||||
|
'-w',
|
||||||
|
args.containerCwd,
|
||||||
|
args.containerName,
|
||||||
|
...args.command,
|
||||||
|
],
|
||||||
|
{ all: true, reject: false, stdin: 'ignore', timeout: args.timeoutMs },
|
||||||
|
);
|
||||||
|
return normalizeRunResult(result, result.all, args.redact);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stopWorkspaceContainer = async (containerName: string) => {
|
||||||
|
await execa(containerRuntime(), ['rm', '-f', containerName], {
|
||||||
|
reject: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const inspectWorkspaceContainer = async (containerName: string) => {
|
||||||
|
const result = await execa(containerRuntime(), ['inspect', containerName], {
|
||||||
|
all: true,
|
||||||
|
reject: false,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
exists: result.exitCode === 0,
|
||||||
|
output: result.all,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listWorkspaceContainerNames = async (prefix: string) => {
|
||||||
|
const result = await execa(
|
||||||
|
containerRuntime(),
|
||||||
|
['ps', '-a', '--format', '{{.Names}}'],
|
||||||
|
{ all: true, reject: false },
|
||||||
|
);
|
||||||
|
if (result.exitCode !== 0) return [];
|
||||||
|
return result.all
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.startsWith(prefix));
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
import { createServer } from 'node:http';
|
import { createServer } from 'node:http';
|
||||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
|
||||||
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
|
||||||
import { env } from './env';
|
import { env } from './env';
|
||||||
|
import { attachTerminalServer } from './terminal';
|
||||||
import {
|
import {
|
||||||
|
abortWorkspaceAgent,
|
||||||
|
cleanupOrphanedWorkspaces,
|
||||||
|
getWorkerHealth,
|
||||||
|
getWorkspaceAgentStatus,
|
||||||
getWorkspaceDiff,
|
getWorkspaceDiff,
|
||||||
listWorkspaceTree,
|
listWorkspaceTree,
|
||||||
openWorkspacePullRequest,
|
openWorkspacePullRequest,
|
||||||
readWorkspaceFile,
|
readWorkspaceFile,
|
||||||
|
replyToInteraction,
|
||||||
runWorkspaceCommand,
|
runWorkspaceCommand,
|
||||||
sendWorkspaceMessage,
|
sendWorkspaceMessage,
|
||||||
stopWorkspace,
|
stopWorkspace,
|
||||||
@@ -43,7 +51,7 @@ const requireAuth = (request: IncomingMessage) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const jobRoute = (pathname: string) => {
|
const jobRoute = (pathname: string) => {
|
||||||
const match = /^\/jobs\/([^/]+)\/([^/]+)$/.exec(pathname);
|
const match = /^\/jobs\/([^/]+)\/(.+)$/.exec(pathname);
|
||||||
if (!match?.[1] || !match[2]) return null;
|
if (!match?.[1] || !match[2]) return null;
|
||||||
return { jobId: decodeURIComponent(match[1]), action: match[2] };
|
return { jobId: decodeURIComponent(match[1]), action: match[2] };
|
||||||
};
|
};
|
||||||
@@ -57,8 +65,12 @@ export const startWorkerServer = () => {
|
|||||||
request.url ?? '/',
|
request.url ?? '/',
|
||||||
`http://localhost:${env.httpPort}`,
|
`http://localhost:${env.httpPort}`,
|
||||||
);
|
);
|
||||||
if (url.pathname === '/health') {
|
if (url.pathname === '/health' && request.method === 'GET') {
|
||||||
sendJson(response, 200, { ok: true, workerId: env.workerId });
|
sendJson(response, 200, await getWorkerHealth());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === '/cleanup' && request.method === 'POST') {
|
||||||
|
sendJson(response, 200, await cleanupOrphanedWorkspaces());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const route = jobRoute(url.pathname);
|
const route = jobRoute(url.pathname);
|
||||||
@@ -108,6 +120,35 @@ export const startWorkerServer = () => {
|
|||||||
sendJson(response, 200, { success: true });
|
sendJson(response, 200, { success: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (request.method === 'GET' && route.action === 'agent/status') {
|
||||||
|
sendJson(response, 200, getWorkspaceAgentStatus(route.jobId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (request.method === 'POST' && route.action === 'agent/abort') {
|
||||||
|
sendJson(response, 200, await abortWorkspaceAgent(route.jobId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const interactionMatch = /^interactions\/([^/]+)\/reply$/.exec(
|
||||||
|
route.action,
|
||||||
|
);
|
||||||
|
if (request.method === 'POST' && interactionMatch?.[1]) {
|
||||||
|
const body = await parseJson<{
|
||||||
|
externalRequestId?: string;
|
||||||
|
response?: string;
|
||||||
|
}>(request);
|
||||||
|
sendJson(
|
||||||
|
response,
|
||||||
|
200,
|
||||||
|
await replyToInteraction(route.jobId, {
|
||||||
|
interactionId: decodeURIComponent(
|
||||||
|
interactionMatch[1],
|
||||||
|
) as Id<'agentInteractionRequests'>,
|
||||||
|
externalRequestId: body.externalRequestId ?? '',
|
||||||
|
response: body.response ?? 'once',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (request.method === 'POST' && route.action === 'run-command') {
|
if (request.method === 'POST' && route.action === 'run-command') {
|
||||||
const body = await parseJson<{ command?: string }>(request);
|
const body = await parseJson<{ command?: string }>(request);
|
||||||
sendJson(
|
sendJson(
|
||||||
@@ -128,12 +169,22 @@ export const startWorkerServer = () => {
|
|||||||
sendJson(response, 404, { error: 'Not found' });
|
sendJson(response, 404, { error: 'Not found' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
sendJson(response, message === 'Unauthorized' ? 401 : 500, {
|
console.error(
|
||||||
|
`Worker HTTP ${request.method ?? 'UNKNOWN'} ${request.url ?? '/'} failed: ${message}`,
|
||||||
|
);
|
||||||
|
const status =
|
||||||
|
message === 'Unauthorized'
|
||||||
|
? 401
|
||||||
|
: message.includes('not supported')
|
||||||
|
? 409
|
||||||
|
: 500;
|
||||||
|
sendJson(response, status, {
|
||||||
error: message,
|
error: message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
|
attachTerminalServer(server);
|
||||||
server.listen(env.httpPort, () => {
|
server.listen(env.httpPort, () => {
|
||||||
console.log(
|
console.log(
|
||||||
`Spoon agent worker HTTP server listening on port ${env.httpPort}`,
|
`Spoon agent worker HTTP server listening on port ${env.httpPort}`,
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
|
// Short-lived, job-scoped token authorizing a browser terminal connection.
|
||||||
|
// Minted server-side by the Next app (which has verified job ownership) and
|
||||||
|
// verified here so the browser never sees the shared worker secret. Format:
|
||||||
|
// `${expiresAtMs}.${jobId}.${hmacSha256Hex}`
|
||||||
|
const signature = (payload: string, secret: string) =>
|
||||||
|
createHmac('sha256', secret).update(payload).digest('hex');
|
||||||
|
|
||||||
|
export const verifyTerminalToken = (
|
||||||
|
token: string,
|
||||||
|
jobId: string,
|
||||||
|
secret: string,
|
||||||
|
): boolean => {
|
||||||
|
if (!token || !secret) return false;
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 3) return false;
|
||||||
|
const [expRaw, tokenJobId, provided] = parts;
|
||||||
|
if (tokenJobId !== jobId) return false;
|
||||||
|
const exp = Number.parseInt(expRaw ?? '', 10);
|
||||||
|
if (!Number.isFinite(exp) || Date.now() > exp) return false;
|
||||||
|
const expected = signature(`${expRaw}.${tokenJobId}`, secret);
|
||||||
|
const providedBuf = Buffer.from(provided ?? '', 'hex');
|
||||||
|
const expectedBuf = Buffer.from(expected, 'hex');
|
||||||
|
return (
|
||||||
|
providedBuf.length === expectedBuf.length &&
|
||||||
|
timingSafeEqual(providedBuf, expectedBuf)
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import type { ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||||
|
import type { Server } from 'node:http';
|
||||||
|
import type { WebSocket } from 'ws';
|
||||||
|
import { WebSocketServer } from 'ws';
|
||||||
|
|
||||||
|
import { env } from './env';
|
||||||
|
import { verifyTerminalToken } from './terminal-token';
|
||||||
|
import { acquireUserBox, releaseUserBox } from './user-container';
|
||||||
|
import { getTerminalWorkspace } from './worker';
|
||||||
|
|
||||||
|
const clampDimension = (value: unknown) => {
|
||||||
|
const n = Math.trunc(Number(value));
|
||||||
|
if (!Number.isFinite(n)) return undefined;
|
||||||
|
return Math.min(Math.max(n, 1), 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Single-quote a string for a POSIX shell.
|
||||||
|
const shellQuote = (value: string) => `'${value.replaceAll("'", `'\\''`)}'`;
|
||||||
|
|
||||||
|
const bridge = async (ws: WebSocket, jobId: string) => {
|
||||||
|
const workspace = getTerminalWorkspace(jobId);
|
||||||
|
if (!workspace) {
|
||||||
|
ws.close(1011, 'Workspace is not active.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// bun can't load node-pty (native ABI mismatch) and dockerode can't attach to
|
||||||
|
// podman, so we drive the runtime CLI (`<runtime> exec -i`) and allocate the PTY
|
||||||
|
// *inside* the container with `script`, bridging the plain pipes to the socket.
|
||||||
|
//
|
||||||
|
// Register the message handler immediately and buffer input/size until the exec
|
||||||
|
// is ready (acquiring the box can take seconds on first connect), so the initial
|
||||||
|
// resize and early keystrokes aren't dropped.
|
||||||
|
const procHolder: { current?: ChildProcessWithoutNullStreams } = {};
|
||||||
|
const pendingInput: Buffer[] = [];
|
||||||
|
let cols = 80;
|
||||||
|
let rows = 24;
|
||||||
|
|
||||||
|
ws.on('message', (data: Buffer, isBinary: boolean) => {
|
||||||
|
if (!isBinary) {
|
||||||
|
// Text frames are control messages (resize); anything else is raw input.
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(data.toString('utf8')) as {
|
||||||
|
type?: string;
|
||||||
|
cols?: number;
|
||||||
|
rows?: number;
|
||||||
|
};
|
||||||
|
if (message.type === 'resize') {
|
||||||
|
const c = clampDimension(message.cols);
|
||||||
|
const r = clampDimension(message.rows);
|
||||||
|
if (c && r) {
|
||||||
|
cols = c;
|
||||||
|
rows = r;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through: treat as raw input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (procHolder.current) procHolder.current.stdin.write(data);
|
||||||
|
else pendingInput.push(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
let acquired = false;
|
||||||
|
let released = false;
|
||||||
|
// Read through a function so TS doesn't narrow `released` to a constant — the
|
||||||
|
// cleanup handler flips it asynchronously when the socket closes.
|
||||||
|
const isReleased = () => released;
|
||||||
|
const cleanup = () => {
|
||||||
|
if (released) return;
|
||||||
|
released = true;
|
||||||
|
procHolder.current?.kill();
|
||||||
|
if (acquired) releaseUserBox(workspace.username);
|
||||||
|
};
|
||||||
|
ws.on('close', cleanup);
|
||||||
|
ws.on('error', cleanup);
|
||||||
|
|
||||||
|
// Hold the per-user box open while this terminal is connected; the agent and
|
||||||
|
// the terminal share the exact same container (Phase 2).
|
||||||
|
let boxName: string;
|
||||||
|
try {
|
||||||
|
boxName = await acquireUserBox({
|
||||||
|
username: workspace.username,
|
||||||
|
workdir: workspace.workdir,
|
||||||
|
containerHome: workspace.containerHome,
|
||||||
|
});
|
||||||
|
acquired = true;
|
||||||
|
} catch (error) {
|
||||||
|
ws.close(
|
||||||
|
1011,
|
||||||
|
`Failed to start terminal: ${error instanceof Error ? error.message : 'unknown error'}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isReleased()) return; // client disconnected during startup; cleanup ran
|
||||||
|
|
||||||
|
// Reattach a persistent tmux session across reconnects when available, else a
|
||||||
|
// plain login shell. `stty` sizes the PTY to the client's viewport up front.
|
||||||
|
const launcher =
|
||||||
|
`stty rows ${rows} cols ${cols} 2>/dev/null; ` +
|
||||||
|
// Reattach a persistent tmux session when tmux is present; otherwise fall back
|
||||||
|
// to an interactive login shell (`-i` so it prints a prompt and line-edits).
|
||||||
|
// Check with `command -v` rather than `exec tmux || …`: a failed `exec` makes a
|
||||||
|
// non-interactive shell exit before the `||`, so the fallback never runs.
|
||||||
|
'if command -v tmux >/dev/null 2>&1; then exec tmux new-session -A -s spoon; ' +
|
||||||
|
'else exec bash -il; fi';
|
||||||
|
const envFlags = [
|
||||||
|
'-e',
|
||||||
|
'TERM=xterm-256color',
|
||||||
|
'-e',
|
||||||
|
`HOME=${workspace.containerHome}`,
|
||||||
|
...workspace.secrets.flatMap((s) => ['-e', `${s.name}=${s.value}`]),
|
||||||
|
];
|
||||||
|
|
||||||
|
const proc = spawn(
|
||||||
|
env.containerRuntime,
|
||||||
|
[
|
||||||
|
'exec',
|
||||||
|
'-i',
|
||||||
|
...envFlags,
|
||||||
|
'-w',
|
||||||
|
workspace.containerRepo,
|
||||||
|
boxName,
|
||||||
|
'/bin/bash',
|
||||||
|
'-lc',
|
||||||
|
`exec script -qfc ${shellQuote(launcher)} /dev/null`,
|
||||||
|
],
|
||||||
|
{ stdio: ['pipe', 'pipe', 'pipe'] },
|
||||||
|
);
|
||||||
|
procHolder.current = proc;
|
||||||
|
|
||||||
|
// Replay any keystrokes the client sent before the process was ready.
|
||||||
|
for (const buffered of pendingInput) proc.stdin.write(buffered);
|
||||||
|
pendingInput.length = 0;
|
||||||
|
|
||||||
|
const forward = (chunk: Buffer) => {
|
||||||
|
if (ws.readyState === ws.OPEN) ws.send(chunk, { binary: true });
|
||||||
|
};
|
||||||
|
proc.stdout.on('data', forward);
|
||||||
|
proc.stderr.on('data', forward);
|
||||||
|
proc.on('exit', () => {
|
||||||
|
if (ws.readyState === ws.OPEN) ws.close();
|
||||||
|
});
|
||||||
|
proc.on('error', () => {
|
||||||
|
if (ws.readyState === ws.OPEN) ws.close();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attaches the interactive-terminal WebSocket endpoint to the worker's HTTP
|
||||||
|
* server. Browser connects to `/jobs/:jobId/terminal?token=…` with a short-lived
|
||||||
|
* token minted by the Next app (which has already verified job ownership).
|
||||||
|
*/
|
||||||
|
export const attachTerminalServer = (server: Server) => {
|
||||||
|
if (env.runtime !== 'docker') return;
|
||||||
|
const wss = new WebSocketServer({ noServer: true });
|
||||||
|
|
||||||
|
server.on('upgrade', (request, socket, head) => {
|
||||||
|
const url = new URL(request.url ?? '', `http://localhost:${env.httpPort}`);
|
||||||
|
const match = /^\/jobs\/([^/]+)\/terminal$/.exec(url.pathname);
|
||||||
|
if (!match?.[1]) {
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const jobId = decodeURIComponent(match[1]);
|
||||||
|
const token = url.searchParams.get('token') ?? '';
|
||||||
|
if (!verifyTerminalToken(token, jobId, env.terminalSecret)) {
|
||||||
|
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||||
|
void bridge(ws, jobId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Spoon agent worker terminal WebSocket endpoint enabled.');
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { env } from './env';
|
||||||
|
import {
|
||||||
|
ensureUserContainer,
|
||||||
|
stopWorkspaceContainer,
|
||||||
|
userContainerName,
|
||||||
|
} from './runtime/docker';
|
||||||
|
|
||||||
|
// Phase 2: one persistent "box" container per user that all of their threads
|
||||||
|
// (agent turns + terminal + commands) exec into. Reference-counted so it stays
|
||||||
|
// up while any thread workspace is active or a terminal is connected, and is
|
||||||
|
// reaped after an idle period once nothing holds it.
|
||||||
|
type Box = { refs: number; idleTimer?: NodeJS.Timeout };
|
||||||
|
const boxes = new Map<string, Box>();
|
||||||
|
|
||||||
|
export const acquireUserBox = async (args: {
|
||||||
|
username: string;
|
||||||
|
workdir: string;
|
||||||
|
containerHome: string;
|
||||||
|
}): Promise<string> => {
|
||||||
|
const name = await ensureUserContainer(args);
|
||||||
|
const box = boxes.get(args.username) ?? { refs: 0 };
|
||||||
|
if (box.idleTimer) {
|
||||||
|
clearTimeout(box.idleTimer);
|
||||||
|
box.idleTimer = undefined;
|
||||||
|
}
|
||||||
|
box.refs += 1;
|
||||||
|
boxes.set(args.username, box);
|
||||||
|
return name;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const releaseUserBox = (username: string) => {
|
||||||
|
const box = boxes.get(username);
|
||||||
|
if (!box) return;
|
||||||
|
box.refs = Math.max(0, box.refs - 1);
|
||||||
|
if (box.refs > 0) return;
|
||||||
|
box.idleTimer = setTimeout(() => {
|
||||||
|
void stopWorkspaceContainer(userContainerName(username));
|
||||||
|
boxes.delete(username);
|
||||||
|
}, env.boxIdleMs);
|
||||||
|
};
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { ConvexHttpClient } from 'convex/browser';
|
||||||
|
|
||||||
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
|
import { env } from './env';
|
||||||
|
import { runExecInContainer } from './runtime/docker';
|
||||||
|
|
||||||
|
const client = new ConvexHttpClient(env.convexUrl);
|
||||||
|
|
||||||
|
export type UserEnvironment = {
|
||||||
|
username: string;
|
||||||
|
enabled: boolean;
|
||||||
|
dotfilesRepoUrl?: string;
|
||||||
|
dotfilesRepoRef?: string;
|
||||||
|
setupCommand?: string;
|
||||||
|
files: { path: string; content: string; isExecutable: boolean }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The job owner's resolved environment (username + dotfiles, decrypted). */
|
||||||
|
export const fetchUserEnvironment = async (
|
||||||
|
jobId: Id<'agentJobs'>,
|
||||||
|
): Promise<UserEnvironment | null> =>
|
||||||
|
await client.action(api.userDotfilesNode.getEnvironmentForJob, {
|
||||||
|
workerToken: env.workerToken,
|
||||||
|
jobId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
||||||
|
|
||||||
|
// Keep a written path inside the home directory.
|
||||||
|
const safeHomeJoin = (homeDir: string, relPath: string) => {
|
||||||
|
const target = path.resolve(homeDir, relPath);
|
||||||
|
const root = path.resolve(homeDir);
|
||||||
|
if (target !== root && !target.startsWith(`${root}${path.sep}`)) {
|
||||||
|
throw new Error(`Refusing to write dotfile outside home: ${relPath}`);
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Materializes the persistent per-user home: a `.bash_profile` so login shells
|
||||||
|
* load `~/.bashrc`; (when configured and changed) a clone of the public dotfiles
|
||||||
|
* repo + the setup command, run inside the job image so the user's tools/paths
|
||||||
|
* apply; then the editable overlay files (which win over the repo). Idempotent
|
||||||
|
* via a hash marker so the repo/setup only re-runs when the config changes.
|
||||||
|
*/
|
||||||
|
export const materializeUserHome = async (args: {
|
||||||
|
homeDir: string;
|
||||||
|
containerHome: string;
|
||||||
|
boxName: string;
|
||||||
|
userEnv: UserEnvironment;
|
||||||
|
redact: (value: string) => string;
|
||||||
|
}): Promise<void> => {
|
||||||
|
const { homeDir, containerHome, boxName, userEnv, redact } = args;
|
||||||
|
await mkdir(homeDir, { recursive: true });
|
||||||
|
|
||||||
|
// A mounted home has no /etc/skel, so ensure login shells source ~/.bashrc.
|
||||||
|
const bashProfile = path.join(homeDir, '.bash_profile');
|
||||||
|
await readFile(bashProfile, 'utf8').catch(async () => {
|
||||||
|
await writeFile(
|
||||||
|
bashProfile,
|
||||||
|
'# Spoon: load ~/.bashrc for login shells.\n[ -f ~/.bashrc ] && . ~/.bashrc\n',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userEnv.enabled) return;
|
||||||
|
|
||||||
|
// Public dotfiles repo + setup command, only re-run when the config changes.
|
||||||
|
if (userEnv.dotfilesRepoUrl) {
|
||||||
|
const configHash = createHash('sha256')
|
||||||
|
.update(
|
||||||
|
JSON.stringify({
|
||||||
|
repo: userEnv.dotfilesRepoUrl,
|
||||||
|
ref: userEnv.dotfilesRepoRef ?? '',
|
||||||
|
setup: userEnv.setupCommand ?? '',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.digest('hex');
|
||||||
|
const markerPath = path.join(homeDir, '.spoon', 'env-hash');
|
||||||
|
const previous = await readFile(markerPath, 'utf8').catch(() => '');
|
||||||
|
if (previous.trim() !== configHash) {
|
||||||
|
const branch = userEnv.dotfilesRepoRef
|
||||||
|
? `--branch ${shellQuote(userEnv.dotfilesRepoRef)} `
|
||||||
|
: '';
|
||||||
|
const script = [
|
||||||
|
'set -e',
|
||||||
|
'rm -rf ~/.dotfiles',
|
||||||
|
`git clone --depth 1 ${branch}${shellQuote(userEnv.dotfilesRepoUrl)} ~/.dotfiles`,
|
||||||
|
userEnv.setupCommand
|
||||||
|
? `cd ~/.dotfiles && bash ${shellQuote(userEnv.setupCommand)}`
|
||||||
|
: '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
await runExecInContainer({
|
||||||
|
containerName: boxName,
|
||||||
|
command: ['bash', '-lc', script],
|
||||||
|
containerCwd: containerHome,
|
||||||
|
environment: { HOME: containerHome },
|
||||||
|
redact,
|
||||||
|
timeoutMs: env.jobTimeoutMs,
|
||||||
|
});
|
||||||
|
await mkdir(path.dirname(markerPath), { recursive: true });
|
||||||
|
await writeFile(markerPath, configHash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Editable overlay tree (wins over the repo/setup output).
|
||||||
|
for (const file of userEnv.files) {
|
||||||
|
const target = safeHomeJoin(homeDir, file.path);
|
||||||
|
await mkdir(path.dirname(target), { recursive: true });
|
||||||
|
await writeFile(target, file.content);
|
||||||
|
if (file.isExecutable) await chmod(target, 0o755);
|
||||||
|
}
|
||||||
|
};
|
||||||
+1078
-99
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,294 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
normalizeCodexJsonLine,
|
||||||
|
normalizeOpenCodeEvent,
|
||||||
|
} from '../../src/agent-events';
|
||||||
|
|
||||||
|
describe('agent event normalization', () => {
|
||||||
|
test('normalizes Codex assistant deltas and session ids', () => {
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'session.created',
|
||||||
|
session_id: 'codex-session-1',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({ kind: 'session', sessionId: 'codex-session-1' });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'response.output_text.delta',
|
||||||
|
delta: 'hello',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({ kind: 'assistant_delta', content: 'hello' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes legacy codex-rs msg-wrapped events', () => {
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
id: '0',
|
||||||
|
msg: { type: 'agent_message', message: 'hello there' },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({ kind: 'assistant_delta', content: 'hello there\n\n' });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
id: '1',
|
||||||
|
msg: { type: 'error', message: 'usage limit reached' },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({ kind: 'error', message: 'usage limit reached' });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({ id: '2', msg: { type: 'task_complete' } }),
|
||||||
|
),
|
||||||
|
).toContainEqual({ kind: 'assistant_completed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes Codex CLI thread lifecycle events', () => {
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'thread.started',
|
||||||
|
thread_id: '019ef701-f7d7-76a0-a96b-15c059631dd9',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'session',
|
||||||
|
sessionId: '019ef701-f7d7-76a0-a96b-15c059631dd9',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'turn.started',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({ kind: 'status', status: 'turn.started' });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'turn.completed',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({ kind: 'assistant_completed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes Codex command and file events', () => {
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'command.completed',
|
||||||
|
command: 'bun test',
|
||||||
|
output: 'ok',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'command_executed',
|
||||||
|
command: 'bun test',
|
||||||
|
output: 'ok',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'file.edited',
|
||||||
|
path: 'src/app.ts',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({ kind: 'file_edited', path: 'src/app.ts' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes current Codex item events', () => {
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'item.completed',
|
||||||
|
item: {
|
||||||
|
id: 'item-1',
|
||||||
|
type: 'agent_message',
|
||||||
|
text: 'I updated the auth provider.',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'assistant_delta',
|
||||||
|
content: 'I updated the auth provider.\n\n',
|
||||||
|
externalMessageId: 'item-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'item.completed',
|
||||||
|
item: {
|
||||||
|
id: 'item-2',
|
||||||
|
type: 'error',
|
||||||
|
message: 'sandbox failed',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'error',
|
||||||
|
message: 'sandbox failed',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'turn.failed',
|
||||||
|
error: { message: 'request failed' },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'error',
|
||||||
|
message: '{\n "message": "request failed"\n}',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'item.completed',
|
||||||
|
item: {
|
||||||
|
id: 'item-warning',
|
||||||
|
type: 'error',
|
||||||
|
message:
|
||||||
|
'`[features].codex_hooks` is deprecated. Use `[features].hooks` instead.',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'status',
|
||||||
|
status:
|
||||||
|
'`[features].codex_hooks` is deprecated. Use `[features].hooks` instead.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes Codex tool item lifecycle events', () => {
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'item.started',
|
||||||
|
item: {
|
||||||
|
id: 'tool-1',
|
||||||
|
type: 'local_shell_call',
|
||||||
|
command: ['bash', '-lc', 'rg Authentik'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'tool_started',
|
||||||
|
name: 'Command',
|
||||||
|
input: 'bash -lc rg Authentik',
|
||||||
|
externalMessageId: 'tool-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'item.completed',
|
||||||
|
item: {
|
||||||
|
id: 'tool-1',
|
||||||
|
type: 'local_shell_call',
|
||||||
|
command: ['bash', '-lc', 'rg Authentik'],
|
||||||
|
output: 'apps/web/auth.ts',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'tool_completed',
|
||||||
|
name: 'Command',
|
||||||
|
output: 'apps/web/auth.ts',
|
||||||
|
externalMessageId: 'tool-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeCodexJsonLine(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'item.completed',
|
||||||
|
item: {
|
||||||
|
id: 'tool-2',
|
||||||
|
type: 'exec_command',
|
||||||
|
command: 'cat package.json',
|
||||||
|
aggregated_output: '{"scripts":{"build":"turbo build"}}',
|
||||||
|
exit_code: 0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'tool_completed',
|
||||||
|
name: 'Command',
|
||||||
|
output: '{"scripts":{"build":"turbo build"}}',
|
||||||
|
externalMessageId: 'tool-2',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes OpenCode assistant, tool, and permission events', () => {
|
||||||
|
expect(
|
||||||
|
normalizeOpenCodeEvent({
|
||||||
|
type: 'message.part.delta',
|
||||||
|
properties: {
|
||||||
|
part: { text: 'streamed' },
|
||||||
|
messageID: 'message-1',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'assistant_delta',
|
||||||
|
content: 'streamed',
|
||||||
|
externalMessageId: 'message-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeOpenCodeEvent({
|
||||||
|
type: 'tool.started',
|
||||||
|
properties: { tool: 'edit', input: { path: 'README.md' } },
|
||||||
|
}),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'tool_started',
|
||||||
|
name: 'edit',
|
||||||
|
input: '{\n "path": "README.md"\n}',
|
||||||
|
externalMessageId: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeOpenCodeEvent({
|
||||||
|
type: 'permission.asked',
|
||||||
|
properties: {
|
||||||
|
permissionID: 'perm-1',
|
||||||
|
message: 'Run bun test?',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'permission_requested',
|
||||||
|
externalRequestId: 'perm-1',
|
||||||
|
title: 'Permission requested',
|
||||||
|
body: 'Run bun test?',
|
||||||
|
metadata:
|
||||||
|
'{\n "permissionID": "perm-1",\n "message": "Run bun test?"\n}',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
normalizeOpenCodeEvent({
|
||||||
|
type: 'tool.output',
|
||||||
|
properties: {
|
||||||
|
tool: 'read',
|
||||||
|
output: 'apps/web/auth.ts',
|
||||||
|
messageID: 'message-2',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toContainEqual({
|
||||||
|
kind: 'tool_completed',
|
||||||
|
name: 'read',
|
||||||
|
output: 'apps/web/auth.ts',
|
||||||
|
externalMessageId: 'message-2',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import {
|
||||||
|
mkdir,
|
||||||
|
mkdtemp,
|
||||||
|
readFile,
|
||||||
|
rm,
|
||||||
|
stat,
|
||||||
|
writeFile,
|
||||||
|
} from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { afterEach, describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import { prepareCodexWorkspaceFiles } from '../../src/codex-runtime';
|
||||||
|
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
const mode = async (filePath: string) => (await stat(filePath)).mode & 0o777;
|
||||||
|
|
||||||
|
describe('Codex runtime preparation', () => {
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
tempDirs.map((dir) => rm(dir, { force: true, recursive: true })),
|
||||||
|
);
|
||||||
|
tempDirs.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prepares writable Codex directories and preserves project config contents', async () => {
|
||||||
|
const workdir = await mkdtemp(path.join(os.tmpdir(), 'spoon-codex-'));
|
||||||
|
tempDirs.push(workdir);
|
||||||
|
const repoDir = path.join(workdir, 'repo');
|
||||||
|
await mkdir(path.join(repoDir, '.codex'), { recursive: true });
|
||||||
|
const projectConfig = path.join(repoDir, '.codex', 'config.toml');
|
||||||
|
await writeFile(projectConfig, '[features]\ncodex_hooks = true\n');
|
||||||
|
|
||||||
|
await prepareCodexWorkspaceFiles({ workdir, repoDir });
|
||||||
|
|
||||||
|
await expect(readFile(projectConfig, 'utf8')).resolves.toBe(
|
||||||
|
'[features]\ncodex_hooks = true\n',
|
||||||
|
);
|
||||||
|
await expect(mode(workdir)).resolves.toBe(0o755);
|
||||||
|
await expect(mode(repoDir)).resolves.toBe(0o755);
|
||||||
|
await expect(mode(path.join(workdir, '.codex'))).resolves.toBe(0o755);
|
||||||
|
await expect(mode(path.join(workdir, '.config'))).resolves.toBe(0o755);
|
||||||
|
await expect(mode(path.join(workdir, '.local', 'share'))).resolves.toBe(
|
||||||
|
0o755,
|
||||||
|
);
|
||||||
|
await expect(mode(path.join(repoDir, '.codex'))).resolves.toBe(0o755);
|
||||||
|
await expect(mode(projectConfig)).resolves.toBe(0o644);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
const loadVolumeSpec = async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
process.env.SPOON_WORKER_TOKEN = 'test-worker-token';
|
||||||
|
process.env.GITHUB_APP_ID = '123';
|
||||||
|
process.env.GITHUB_APP_PRIVATE_KEY =
|
||||||
|
'-----BEGIN PRIVATE KEY-----\\ntest\\n-----END PRIVATE KEY-----';
|
||||||
|
return await import('../../src/runtime/docker');
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Docker runtime', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.SPOON_AGENT_CONTAINER_RUNTIME;
|
||||||
|
delete process.env.SPOON_AGENT_CONTAINER_VOLUME_OPTIONS;
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds SELinux relabel option for Podman workspace mounts by default', async () => {
|
||||||
|
process.env.SPOON_AGENT_CONTAINER_RUNTIME = 'podman';
|
||||||
|
const { jobWorkspaceVolumeSpec } = await loadVolumeSpec();
|
||||||
|
|
||||||
|
expect(jobWorkspaceVolumeSpec('/tmp/spoon-job')).toBe(
|
||||||
|
'/tmp/spoon-job:/workspace:Z',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not add Podman volume options for Docker by default', async () => {
|
||||||
|
process.env.SPOON_AGENT_CONTAINER_RUNTIME = 'docker';
|
||||||
|
const { jobWorkspaceVolumeSpec } = await loadVolumeSpec();
|
||||||
|
|
||||||
|
expect(jobWorkspaceVolumeSpec('/tmp/spoon-job')).toBe(
|
||||||
|
'/tmp/spoon-job:/workspace',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allows explicit workspace mount options', async () => {
|
||||||
|
process.env.SPOON_AGENT_CONTAINER_RUNTIME = 'podman';
|
||||||
|
process.env.SPOON_AGENT_CONTAINER_VOLUME_OPTIONS = 'z';
|
||||||
|
const { jobWorkspaceVolumeSpec } = await loadVolumeSpec();
|
||||||
|
|
||||||
|
expect(jobWorkspaceVolumeSpec('/tmp/spoon-job')).toBe(
|
||||||
|
'/tmp/spoon-job:/workspace:z',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('treats a spawn failure (no exitCode) as a non-zero exit, not empty success', async () => {
|
||||||
|
const { normalizeRunResult } = await loadVolumeSpec();
|
||||||
|
// This is what execa returns with `reject: false` when the runtime binary is
|
||||||
|
// missing (e.g. no `docker` CLI in the worker image): exitCode is undefined.
|
||||||
|
const result = normalizeRunResult(
|
||||||
|
{ exitCode: undefined, shortMessage: 'spawn docker ENOENT' },
|
||||||
|
undefined,
|
||||||
|
(value) => value,
|
||||||
|
);
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
expect(result.output).toContain('spawn docker ENOENT');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('passes through a normal command result unchanged', async () => {
|
||||||
|
const { normalizeRunResult } = await loadVolumeSpec();
|
||||||
|
const result = normalizeRunResult(
|
||||||
|
{ exitCode: 0, shortMessage: undefined },
|
||||||
|
'hello',
|
||||||
|
(value) => value,
|
||||||
|
);
|
||||||
|
expect(result).toEqual({ exitCode: 0, output: 'hello' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { spawn, spawnSync } from 'node:child_process';
|
||||||
|
import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
type TestWorkspace = {
|
||||||
|
binDir: string;
|
||||||
|
homeDir: string;
|
||||||
|
localFile: string;
|
||||||
|
projectDir: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scriptPath = fileURLToPath(
|
||||||
|
new URL('../../../../scripts/infisical-account', import.meta.url),
|
||||||
|
);
|
||||||
|
|
||||||
|
let workspaces: TestWorkspace[] = [];
|
||||||
|
|
||||||
|
const createWorkspace = async (): Promise<TestWorkspace> => {
|
||||||
|
const root = await realpathTemp();
|
||||||
|
const homeDir = path.join(root, 'home');
|
||||||
|
const projectDir = path.join(root, 'project');
|
||||||
|
const binDir = path.join(root, 'bin');
|
||||||
|
const localFile = path.join(projectDir, '.local', 'infisical.env');
|
||||||
|
|
||||||
|
await mkdir(path.join(homeDir, '.infisical'), { recursive: true });
|
||||||
|
await mkdir(path.dirname(localFile), { recursive: true });
|
||||||
|
await mkdir(binDir, { recursive: true });
|
||||||
|
|
||||||
|
const fakeInfisical = path.join(binDir, 'infisical');
|
||||||
|
await writeFile(fakeInfisical, '#!/usr/bin/env sh\nexit 0\n');
|
||||||
|
await chmod(fakeInfisical, 0o755);
|
||||||
|
|
||||||
|
const workspace = { binDir, homeDir, localFile, projectDir };
|
||||||
|
workspaces.push(workspace);
|
||||||
|
return workspace;
|
||||||
|
};
|
||||||
|
|
||||||
|
const realpathTemp = async (): Promise<string> => {
|
||||||
|
const base = path.join(tmpdir(), 'spoon-infisical-account-');
|
||||||
|
const { mkdtemp } = await import('node:fs/promises');
|
||||||
|
return mkdtemp(base);
|
||||||
|
};
|
||||||
|
|
||||||
|
const configPath = (workspace: TestWorkspace) =>
|
||||||
|
path.join(workspace.homeDir, '.infisical', 'infisical-config.json');
|
||||||
|
|
||||||
|
const writeConfig = async (
|
||||||
|
workspace: TestWorkspace,
|
||||||
|
config: Record<string, unknown> | string,
|
||||||
|
) => {
|
||||||
|
const content =
|
||||||
|
typeof config === 'string'
|
||||||
|
? config
|
||||||
|
: `${JSON.stringify(config, null, 2)}\n`;
|
||||||
|
await writeFile(configPath(workspace), content);
|
||||||
|
};
|
||||||
|
|
||||||
|
const readConfig = async (
|
||||||
|
workspace: TestWorkspace,
|
||||||
|
): Promise<Record<string, unknown>> =>
|
||||||
|
JSON.parse(await readFile(configPath(workspace), 'utf8')) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
|
||||||
|
const envFor = (workspace: TestWorkspace): NodeJS.ProcessEnv => ({
|
||||||
|
...process.env,
|
||||||
|
HOME: workspace.homeDir,
|
||||||
|
PATH: `${workspace.binDir}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`,
|
||||||
|
SPOON_INFISICAL_LOCAL_FILE: workspace.localFile,
|
||||||
|
});
|
||||||
|
|
||||||
|
const runEnsure = (workspace: TestWorkspace) =>
|
||||||
|
spawnSync(scriptPath, ['ensure'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: envFor(workspace),
|
||||||
|
});
|
||||||
|
|
||||||
|
const writeLocalEmail = async (workspace: TestWorkspace, emailLine: string) => {
|
||||||
|
await mkdir(path.dirname(workspace.localFile), { recursive: true });
|
||||||
|
await writeFile(workspace.localFile, `${emailLine}\n`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const twoAccountConfig = {
|
||||||
|
loggedInUsers: [
|
||||||
|
{ email: 'work@example.com', domain: 'https://app.infisical.com' },
|
||||||
|
{ email: 'home@example.com', domain: 'https://infisical.gbrown.org' },
|
||||||
|
],
|
||||||
|
loggedInUserEmail: 'work@example.com',
|
||||||
|
LoggedInUserDomain: 'https://app.infisical.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
workspaces = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
workspaces.map((workspace) =>
|
||||||
|
rm(path.dirname(workspace.homeDir), { force: true, recursive: true }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('infisical-account', () => {
|
||||||
|
test('single account no-ops without local file', async () => {
|
||||||
|
const workspace = await createWorkspace();
|
||||||
|
await writeConfig(workspace, {
|
||||||
|
loggedInUsers: [
|
||||||
|
{ email: 'work@example.com', domain: 'https://app.infisical.com' },
|
||||||
|
],
|
||||||
|
loggedInUserEmail: 'work@example.com',
|
||||||
|
LoggedInUserDomain: 'https://app.infisical.com',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = runEnsure(workspace);
|
||||||
|
const config = await readConfig(workspace);
|
||||||
|
|
||||||
|
expect(result.status).toBe(0);
|
||||||
|
expect(config.loggedInUserEmail).toBe('work@example.com');
|
||||||
|
expect(config.LoggedInUserDomain).toBe('https://app.infisical.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multiple accounts require local project config', async () => {
|
||||||
|
const workspace = await createWorkspace();
|
||||||
|
await writeConfig(workspace, twoAccountConfig);
|
||||||
|
|
||||||
|
const result = runEnsure(workspace);
|
||||||
|
|
||||||
|
expect(result.status).not.toBe(0);
|
||||||
|
expect(result.stderr).toContain('.local/infisical.env');
|
||||||
|
expect(result.stderr).toContain('work@example.com');
|
||||||
|
expect(result.stderr).toContain('home@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multiple accounts switch to configured email', async () => {
|
||||||
|
const workspace = await createWorkspace();
|
||||||
|
await writeConfig(workspace, twoAccountConfig);
|
||||||
|
await writeLocalEmail(workspace, 'INFISICAL_EMAIL=home@example.com');
|
||||||
|
|
||||||
|
const result = runEnsure(workspace);
|
||||||
|
const config = await readConfig(workspace);
|
||||||
|
|
||||||
|
expect(result.status).toBe(0);
|
||||||
|
expect(config.loggedInUserEmail).toBe('home@example.com');
|
||||||
|
expect(config.LoggedInUserDomain).toBe('https://infisical.gbrown.org');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('configured email missing from local accounts fails clearly', async () => {
|
||||||
|
const workspace = await createWorkspace();
|
||||||
|
await writeConfig(workspace, twoAccountConfig);
|
||||||
|
await writeLocalEmail(workspace, 'INFISICAL_EMAIL=missing@example.com');
|
||||||
|
|
||||||
|
const result = runEnsure(workspace);
|
||||||
|
|
||||||
|
expect(result.status).not.toBe(0);
|
||||||
|
expect(result.stderr).toContain(
|
||||||
|
'not logged in locally: missing@example.com',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
'INFISICAL_EMAIL="home@example.com"',
|
||||||
|
"INFISICAL_EMAIL='home@example.com'",
|
||||||
|
])('quoted email parses correctly: %s', async (line) => {
|
||||||
|
const workspace = await createWorkspace();
|
||||||
|
await writeConfig(workspace, twoAccountConfig);
|
||||||
|
await writeLocalEmail(workspace, line);
|
||||||
|
|
||||||
|
const result = runEnsure(workspace);
|
||||||
|
const config = await readConfig(workspace);
|
||||||
|
|
||||||
|
expect(result.status).toBe(0);
|
||||||
|
expect(config.loggedInUserEmail).toBe('home@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty email fails clearly', async () => {
|
||||||
|
const workspace = await createWorkspace();
|
||||||
|
await writeConfig(workspace, twoAccountConfig);
|
||||||
|
await writeLocalEmail(workspace, 'INFISICAL_EMAIL=');
|
||||||
|
|
||||||
|
const result = runEnsure(workspace);
|
||||||
|
|
||||||
|
expect(result.status).not.toBe(0);
|
||||||
|
expect(result.stderr).toContain(
|
||||||
|
'.local/infisical.env must contain INFISICAL_EMAIL',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('corrupt config fails clearly', async () => {
|
||||||
|
const workspace = await createWorkspace();
|
||||||
|
await writeConfig(workspace, '{not-json');
|
||||||
|
|
||||||
|
const result = runEnsure(workspace);
|
||||||
|
|
||||||
|
expect(result.status).not.toBe(0);
|
||||||
|
expect(result.stderr).toContain(
|
||||||
|
'Infisical config is invalid or missing loggedInUsers',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('concurrent ensure calls do not corrupt config', async () => {
|
||||||
|
const workspace = await createWorkspace();
|
||||||
|
await writeConfig(workspace, twoAccountConfig);
|
||||||
|
await writeLocalEmail(workspace, 'INFISICAL_EMAIL=home@example.com');
|
||||||
|
|
||||||
|
const run = () =>
|
||||||
|
new Promise<{ status: number | null; stderr: string }>((resolve) => {
|
||||||
|
const child = spawn(scriptPath, ['ensure'], { env: envFor(workspace) });
|
||||||
|
let stderr = '';
|
||||||
|
child.stderr.on('data', (chunk: Buffer) => {
|
||||||
|
stderr += chunk.toString('utf8');
|
||||||
|
});
|
||||||
|
child.on('close', (status) => {
|
||||||
|
resolve({ status, stderr });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const [first, second] = await Promise.all([run(), run()]);
|
||||||
|
const config = await readConfig(workspace);
|
||||||
|
|
||||||
|
expect(first).toEqual({ status: 0, stderr: '' });
|
||||||
|
expect(second).toEqual({ status: 0, stderr: '' });
|
||||||
|
expect(config.loggedInUserEmail).toBe('home@example.com');
|
||||||
|
expect(config.LoggedInUserDomain).toBe('https://infisical.gbrown.org');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import { verifyTerminalToken } from '../../src/terminal-token';
|
||||||
|
|
||||||
|
const mint = (jobId: string, expiresAt: number, secret: string) => {
|
||||||
|
const payload = `${expiresAt}.${jobId}`;
|
||||||
|
const sig = createHmac('sha256', secret).update(payload).digest('hex');
|
||||||
|
return `${payload}.${sig}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('verifyTerminalToken', () => {
|
||||||
|
const secret = 'test-secret';
|
||||||
|
|
||||||
|
test('accepts a valid, unexpired, job-matched token', () => {
|
||||||
|
const token = mint('job1', Date.now() + 60_000, secret);
|
||||||
|
expect(verifyTerminalToken(token, 'job1', secret)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects an expired token', () => {
|
||||||
|
const token = mint('job1', Date.now() - 1, secret);
|
||||||
|
expect(verifyTerminalToken(token, 'job1', secret)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a token minted for another job', () => {
|
||||||
|
const token = mint('job1', Date.now() + 60_000, secret);
|
||||||
|
expect(verifyTerminalToken(token, 'job2', secret)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a token signed with a different secret', () => {
|
||||||
|
const token = mint('job1', Date.now() + 60_000, 'other-secret');
|
||||||
|
expect(verifyTerminalToken(token, 'job1', secret)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects malformed input and an empty secret', () => {
|
||||||
|
expect(verifyTerminalToken('garbage', 'job1', secret)).toBe(false);
|
||||||
|
expect(verifyTerminalToken('', 'job1', secret)).toBe(false);
|
||||||
|
expect(
|
||||||
|
verifyTerminalToken(mint('job1', Date.now() + 1000, ''), 'job1', ''),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,6 @@
|
|||||||
"lib": ["ES2022", "DOM"],
|
"lib": ["ES2022", "DOM"],
|
||||||
"types": ["node"]
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["src", "eslint.config.ts", "vitest.config.ts"],
|
"include": ["src", "tests", "eslint.config.ts", "vitest.config.ts"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
"ios": "expo run:ios",
|
"ios": "expo run:ios",
|
||||||
"format": "prettier --check . --ignore-path ../../.gitignore --ignore-path .prettierignore",
|
"format": "prettier --check . --ignore-path ../../.gitignore --ignore-path .prettierignore",
|
||||||
"lint": "eslint --flag unstable_native_nodejs_ts_config",
|
"lint": "eslint --flag unstable_native_nodejs_ts_config",
|
||||||
|
"test:unit": "vitest run --project unit",
|
||||||
|
"test:integration": "vitest run --project integration --passWithNoTests",
|
||||||
|
"test:component": "vitest run --project component",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"with-env": "sh ../../scripts/with-env ${INFISICAL_ENV:-dev} --"
|
"with-env": "sh ../../scripts/with-env ${INFISICAL_ENV:-dev} --"
|
||||||
},
|
},
|
||||||
@@ -27,6 +30,7 @@
|
|||||||
"convex": "catalog:convex",
|
"convex": "catalog:convex",
|
||||||
"expo": "~54.0.33",
|
"expo": "~54.0.33",
|
||||||
"expo-apple-authentication": "~8.0.8",
|
"expo-apple-authentication": "~8.0.8",
|
||||||
|
"expo-clipboard": "~8.0.8",
|
||||||
"expo-constants": "~18.0.13",
|
"expo-constants": "~18.0.13",
|
||||||
"expo-dev-client": "~6.0.20",
|
"expo-dev-client": "~6.0.20",
|
||||||
"expo-font": "~14.0.11",
|
"expo-font": "~14.0.11",
|
||||||
@@ -57,11 +61,14 @@
|
|||||||
"@spoon/prettier-config": "workspace:*",
|
"@spoon/prettier-config": "workspace:*",
|
||||||
"@spoon/tailwind-config": "workspace:*",
|
"@spoon/tailwind-config": "workspace:*",
|
||||||
"@spoon/tsconfig": "workspace:*",
|
"@spoon/tsconfig": "workspace:*",
|
||||||
|
"@spoon/vitest-config": "workspace:*",
|
||||||
|
"@testing-library/react": "catalog:test",
|
||||||
"@types/react": "catalog:react19",
|
"@types/react": "catalog:react19",
|
||||||
"eslint": "catalog:",
|
"eslint": "catalog:",
|
||||||
"prettier": "catalog:",
|
"prettier": "catalog:",
|
||||||
"tailwindcss": "catalog:",
|
"tailwindcss": "catalog:",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:",
|
||||||
|
"vitest": "catalog:test"
|
||||||
},
|
},
|
||||||
"prettier": "@spoon/prettier-config"
|
"prettier": "@spoon/prettier-config"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useColorScheme } from 'react-native';
|
||||||
|
import { Redirect, Tabs } from 'expo-router';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useConvexAuth } from 'convex/react';
|
||||||
|
|
||||||
|
import { LoadingState } from '~/components/ui/loading-state';
|
||||||
|
|
||||||
|
const iconName = (route: string, focused: boolean) => {
|
||||||
|
if (route === 'dashboard') return focused ? 'grid' : 'grid-outline';
|
||||||
|
if (route === 'spoons') return focused ? 'git-branch' : 'git-branch-outline';
|
||||||
|
if (route === 'threads')
|
||||||
|
return focused ? 'chatbubbles' : 'chatbubbles-outline';
|
||||||
|
return focused ? 'settings' : 'settings-outline';
|
||||||
|
};
|
||||||
|
|
||||||
|
const AppTabs = () => {
|
||||||
|
const { isAuthenticated, isLoading } = useConvexAuth();
|
||||||
|
const colorScheme = useColorScheme();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Keeps the auth subscription warm while tab routes mount.
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
if (isLoading) return <LoadingState />;
|
||||||
|
if (!isAuthenticated) return <Redirect href='/sign-in' />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs
|
||||||
|
screenOptions={({ route }) => ({
|
||||||
|
headerShown: false,
|
||||||
|
tabBarActiveTintColor: '#0f766e',
|
||||||
|
tabBarInactiveTintColor: colorScheme === 'dark' ? '#94a3b8' : '#64748b',
|
||||||
|
tabBarStyle: {
|
||||||
|
backgroundColor: colorScheme === 'dark' ? '#111827' : '#f8fafc',
|
||||||
|
borderTopColor: colorScheme === 'dark' ? '#334155' : '#e2e8f0',
|
||||||
|
},
|
||||||
|
tabBarIcon: ({ color, focused, size }) => (
|
||||||
|
<Ionicons
|
||||||
|
color={color}
|
||||||
|
name={iconName(route.name, focused)}
|
||||||
|
size={size}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Tabs.Screen name='dashboard' options={{ title: 'Dashboard' }} />
|
||||||
|
<Tabs.Screen name='spoons' options={{ title: 'Spoons' }} />
|
||||||
|
<Tabs.Screen name='threads' options={{ title: 'Threads' }} />
|
||||||
|
<Tabs.Screen name='workspace/[jobId]' options={{ href: null }} />
|
||||||
|
<Tabs.Screen name='settings' options={{ title: 'Settings' }} />
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppTabs;
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Text, View } from 'react-native';
|
||||||
|
import { Link, Stack, useRouter } from 'expo-router';
|
||||||
|
import { useQuery } from 'convex/react';
|
||||||
|
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
|
import { SpoonListRow } from '~/components/spoons/spoon-list-row';
|
||||||
|
import { ThreadListRow } from '~/components/threads/thread-list-row';
|
||||||
|
import { AppScreen } from '~/components/ui/app-screen';
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { Card } from '~/components/ui/card';
|
||||||
|
import { EmptyState } from '~/components/ui/empty-state';
|
||||||
|
import { MetricCard } from '~/components/ui/metric-card';
|
||||||
|
import { titleize } from '~/utils/format';
|
||||||
|
|
||||||
|
const openThreadStatuses = ['resolved', 'ignored', 'failed', 'cancelled'];
|
||||||
|
|
||||||
|
const DashboardRoute = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
|
||||||
|
const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 }) ?? [];
|
||||||
|
const threads = useQuery(api.threads.listMine, { limit: 25 }) ?? [];
|
||||||
|
const active = spoons.filter((spoon) => spoon.status === 'active').length;
|
||||||
|
const behind = spoons.filter((spoon) => spoon.syncStatus === 'behind').length;
|
||||||
|
const diverged = spoons.filter(
|
||||||
|
(spoon) => spoon.syncStatus === 'diverged',
|
||||||
|
).length;
|
||||||
|
const openThreads = threads.filter(
|
||||||
|
(thread) => !openThreadStatuses.includes(thread.status),
|
||||||
|
);
|
||||||
|
const upstreamWaiting = spoons.reduce(
|
||||||
|
(total, spoon) => total + (spoon.upstreamAheadBy ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const softRefresh = () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
setTimeout(() => setRefreshing(false), 600);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppScreen onRefresh={softRefresh} refreshing={refreshing}>
|
||||||
|
<Stack.Screen options={{ title: 'Dashboard' }} />
|
||||||
|
<View className='flex-row items-start justify-between gap-3'>
|
||||||
|
<View className='min-w-0 flex-1'>
|
||||||
|
<Text className='text-foreground text-3xl font-bold'>Dashboard</Text>
|
||||||
|
<Text className='text-muted-foreground mt-1'>
|
||||||
|
Managed forks, upstream drift, and open maintenance threads.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Link href='/spoons/new' asChild>
|
||||||
|
<Button>New</Button>
|
||||||
|
</Link>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='gap-3'>
|
||||||
|
<View className='flex-row gap-3'>
|
||||||
|
<MetricCard
|
||||||
|
label='Spoons'
|
||||||
|
note={`${active} active`}
|
||||||
|
value={spoons.length}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label='Behind'
|
||||||
|
note={`${diverged} diverged`}
|
||||||
|
value={behind}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className='flex-row gap-3'>
|
||||||
|
<MetricCard label='Open threads' value={openThreads.length} />
|
||||||
|
<MetricCard label='Upstream commits' value={upstreamWaiting} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='gap-3'>
|
||||||
|
<Text className='text-foreground text-lg font-semibold'>
|
||||||
|
Maintenance queue
|
||||||
|
</Text>
|
||||||
|
{openThreads.length ? (
|
||||||
|
openThreads
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((thread) => (
|
||||||
|
<ThreadListRow
|
||||||
|
key={thread._id}
|
||||||
|
thread={thread}
|
||||||
|
onPress={() => router.push(`/threads/${thread._id}`)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
description='Threads appear when you request work or upstream changes need review.'
|
||||||
|
title='No open maintenance threads'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='gap-3'>
|
||||||
|
<Text className='text-foreground text-lg font-semibold'>
|
||||||
|
Recent Spoons
|
||||||
|
</Text>
|
||||||
|
{spoons.length ? (
|
||||||
|
spoons
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((spoon) => (
|
||||||
|
<SpoonListRow
|
||||||
|
key={spoon._id}
|
||||||
|
spoon={spoon}
|
||||||
|
onPress={() => router.push(`/spoons/${spoon._id}`)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
description='Create your first managed fork to start tracking upstream drift.'
|
||||||
|
title='No Spoons yet'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='gap-3'>
|
||||||
|
<Text className='text-foreground text-lg font-semibold'>
|
||||||
|
Recent activity
|
||||||
|
</Text>
|
||||||
|
<Card className='gap-3'>
|
||||||
|
{syncRuns.length ? (
|
||||||
|
syncRuns.map((run) => (
|
||||||
|
<View key={run._id} className='border-border border-b pb-3'>
|
||||||
|
<Text className='text-foreground font-medium'>
|
||||||
|
{titleize(run.kind)}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-muted-foreground text-sm'>
|
||||||
|
{titleize(run.status)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text className='text-muted-foreground text-sm'>
|
||||||
|
Upstream checks will appear here.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</View>
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DashboardRoute;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
const SettingsLayout = () => <Stack screenOptions={{ headerShown: false }} />;
|
||||||
|
|
||||||
|
export default SettingsLayout;
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Alert, Text } from 'react-native';
|
||||||
|
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import { useAction, useMutation, useQuery } from 'convex/react';
|
||||||
|
|
||||||
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
|
import { AiProviderProfileForm } from '~/components/settings/ai-provider-profile-form';
|
||||||
|
import { AppScreen } from '~/components/ui/app-screen';
|
||||||
|
|
||||||
|
const AiProviderFormRoute = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { profileId: rawProfileId } = useLocalSearchParams<{
|
||||||
|
profileId?: string;
|
||||||
|
}>();
|
||||||
|
const profileId = rawProfileId as Id<'aiProviderProfiles'> | undefined;
|
||||||
|
const existing = useQuery(
|
||||||
|
api.aiProviderProfiles.get,
|
||||||
|
profileId ? { profileId } : 'skip',
|
||||||
|
);
|
||||||
|
const save = useAction(api.aiProviderProfilesNode.save);
|
||||||
|
const updateMetadata = useMutation(api.aiProviderProfiles.updateMetadata);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const submit = async (values: Parameters<typeof save>[0]) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (profileId && !values.secret) {
|
||||||
|
await updateMetadata({
|
||||||
|
baseUrl: values.baseUrl,
|
||||||
|
defaultModel: values.defaultModel,
|
||||||
|
enabled: values.enabled,
|
||||||
|
modelOptions: values.modelOptions,
|
||||||
|
name: values.name,
|
||||||
|
profileId,
|
||||||
|
reasoningEffort: values.reasoningEffort,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await save({ ...values, profileId });
|
||||||
|
}
|
||||||
|
router.replace('/settings/ai-providers');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not save AI provider.');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (profileId && !existing) {
|
||||||
|
return (
|
||||||
|
<AppScreen>
|
||||||
|
<Stack.Screen options={{ title: 'Edit provider' }} />
|
||||||
|
<Text className='text-muted-foreground'>Loading provider...</Text>
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppScreen>
|
||||||
|
<Stack.Screen
|
||||||
|
options={{ title: profileId ? 'Edit provider' : 'New provider' }}
|
||||||
|
/>
|
||||||
|
<Text className='text-foreground text-3xl font-bold'>
|
||||||
|
{profileId ? 'Edit provider' : 'New provider'}
|
||||||
|
</Text>
|
||||||
|
<AiProviderProfileForm
|
||||||
|
existing={existing ?? undefined}
|
||||||
|
saving={saving}
|
||||||
|
onSubmit={submit}
|
||||||
|
/>
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AiProviderFormRoute;
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { Alert, Text, View } from 'react-native';
|
||||||
|
import { Link, Stack, useRouter } from 'expo-router';
|
||||||
|
import { useMutation, useQuery } from 'convex/react';
|
||||||
|
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
|
import { AppScreen } from '~/components/ui/app-screen';
|
||||||
|
import { Badge } from '~/components/ui/badge';
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { EmptyState } from '~/components/ui/empty-state';
|
||||||
|
import { ListRow } from '~/components/ui/list-row';
|
||||||
|
import { titleize } from '~/utils/format';
|
||||||
|
|
||||||
|
const AiProvidersRoute = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
|
||||||
|
const setDefault = useMutation(api.aiProviderProfiles.setDefault);
|
||||||
|
const remove = useMutation(api.aiProviderProfiles.remove);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppScreen>
|
||||||
|
<Stack.Screen options={{ title: 'AI providers' }} />
|
||||||
|
<View className='flex-row items-start justify-between gap-3'>
|
||||||
|
<View className='min-w-0 flex-1'>
|
||||||
|
<Text className='text-foreground text-3xl font-bold'>
|
||||||
|
AI providers
|
||||||
|
</Text>
|
||||||
|
<Text className='text-muted-foreground mt-1'>
|
||||||
|
Provider profiles for OpenCode workspaces.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Link href='/settings/ai-provider-form' asChild>
|
||||||
|
<Button>New</Button>
|
||||||
|
</Link>
|
||||||
|
</View>
|
||||||
|
{profiles.length ? (
|
||||||
|
profiles.map((profile) => (
|
||||||
|
<ListRow
|
||||||
|
key={profile._id}
|
||||||
|
subtitle={`${titleize(profile.provider)} · ${profile.defaultModel}`}
|
||||||
|
title={profile.name}
|
||||||
|
onPress={() =>
|
||||||
|
router.push(`/settings/ai-provider-form?profileId=${profile._id}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View className='flex-row flex-wrap gap-2'>
|
||||||
|
<Badge
|
||||||
|
label={profile.configured ? 'configured' : 'missing credential'}
|
||||||
|
tone={profile.configured ? 'success' : 'warning'}
|
||||||
|
/>
|
||||||
|
{profile.isDefault ? (
|
||||||
|
<Badge label='default' tone='primary' />
|
||||||
|
) : null}
|
||||||
|
<Badge label={profile.enabled ? 'enabled' : 'disabled'} />
|
||||||
|
</View>
|
||||||
|
<View className='mt-3 flex-row gap-2'>
|
||||||
|
<Button
|
||||||
|
disabled={!profile.configured || !profile.enabled}
|
||||||
|
variant='outline'
|
||||||
|
onPress={() => void setDefault({ profileId: profile._id })}
|
||||||
|
>
|
||||||
|
Set default
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='danger'
|
||||||
|
onPress={() =>
|
||||||
|
Alert.alert('Remove provider', `Remove ${profile.name}?`, [
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Remove',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => void remove({ profileId: profile._id }),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</ListRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
description='Add an OpenAI, Codex/OpenCode, Anthropic, OpenRouter, or compatible provider before queueing agent work.'
|
||||||
|
title='No AI providers'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AiProvidersRoute;
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { Alert, Text } from 'react-native';
|
||||||
|
import { Link, Stack } from 'expo-router';
|
||||||
|
import { useAuthActions } from '@convex-dev/auth/react';
|
||||||
|
import { useQuery } from 'convex/react';
|
||||||
|
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
|
import { AppScreen } from '~/components/ui/app-screen';
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { ListRow } from '~/components/ui/list-row';
|
||||||
|
|
||||||
|
const SettingsRoute = () => {
|
||||||
|
const { signOut } = useAuthActions();
|
||||||
|
const user = useQuery(api.auth.getUser, {});
|
||||||
|
const connection = useQuery(api.github.getConnection, {});
|
||||||
|
const providers = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
|
||||||
|
const defaultProvider = providers.find((provider) => provider.isDefault);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppScreen>
|
||||||
|
<Stack.Screen options={{ title: 'Settings' }} />
|
||||||
|
<Text className='text-foreground text-3xl font-bold'>Settings</Text>
|
||||||
|
<Link href='/settings/profile' asChild>
|
||||||
|
<ListRow
|
||||||
|
subtitle={
|
||||||
|
user?.email ?? 'Name, email, provider, and password settings'
|
||||||
|
}
|
||||||
|
title='Profile'
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<Link href='/settings/integrations' asChild>
|
||||||
|
<ListRow
|
||||||
|
subtitle={
|
||||||
|
connection
|
||||||
|
? `GitHub connected as ${connection.displayName}`
|
||||||
|
: 'GitHub App connection and accessible repositories'
|
||||||
|
}
|
||||||
|
title='Integrations'
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<Link href='/settings/ai-providers' asChild>
|
||||||
|
<ListRow
|
||||||
|
subtitle={
|
||||||
|
defaultProvider
|
||||||
|
? `${providers.length} provider${providers.length === 1 ? '' : 's'}, default ${defaultProvider.name}`
|
||||||
|
: 'OpenCode, Codex auth, API keys, and default models'
|
||||||
|
}
|
||||||
|
title='AI providers'
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
variant='danger'
|
||||||
|
onPress={() =>
|
||||||
|
Alert.alert('Sign out', 'Sign out of Spoon on this device?', [
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Sign out',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => void signOut(),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</Button>
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SettingsRoute;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Text } from 'react-native';
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
import { useAction, useQuery } from 'convex/react';
|
||||||
|
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
|
import { GitHubIntegrationPanel } from '~/components/settings/github-integration-panel';
|
||||||
|
import { AppScreen } from '~/components/ui/app-screen';
|
||||||
|
|
||||||
|
const IntegrationsRoute = () => {
|
||||||
|
const installUrl = useQuery(api.github.getInstallUrl, {});
|
||||||
|
const connection = useQuery(api.github.getConnection, {});
|
||||||
|
const status = useQuery(api.integrations.getStatus, {});
|
||||||
|
const syncInstallation = useAction(api.githubNode.syncConfiguredInstallation);
|
||||||
|
const repositories = useAction(api.githubNode.listInstallationRepositories);
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const [loadingRepos, setLoadingRepos] = useState(false);
|
||||||
|
|
||||||
|
const sync = async () => {
|
||||||
|
setSyncing(true);
|
||||||
|
try {
|
||||||
|
await syncInstallation({});
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const listRepos = async () => {
|
||||||
|
setLoadingRepos(true);
|
||||||
|
try {
|
||||||
|
const result = await repositories({});
|
||||||
|
return result.map((repo) => repo.fullName);
|
||||||
|
} finally {
|
||||||
|
setLoadingRepos(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppScreen onRefresh={() => void sync()} refreshing={syncing}>
|
||||||
|
<Stack.Screen options={{ title: 'Integrations' }} />
|
||||||
|
<Text className='text-foreground text-3xl font-bold'>Integrations</Text>
|
||||||
|
<GitHubIntegrationPanel
|
||||||
|
connection={connection}
|
||||||
|
installUrl={installUrl}
|
||||||
|
loadingRepos={loadingRepos}
|
||||||
|
runtimeStatus={status}
|
||||||
|
syncing={syncing}
|
||||||
|
onListRepos={listRepos}
|
||||||
|
onSync={sync}
|
||||||
|
/>
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default IntegrationsRoute;
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Alert, Text } from 'react-native';
|
||||||
|
import { Stack } from 'expo-router';
|
||||||
|
import { useAction, useMutation, useQuery } from 'convex/react';
|
||||||
|
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
|
import { AppScreen } from '~/components/ui/app-screen';
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { Card } from '~/components/ui/card';
|
||||||
|
import { Field } from '~/components/ui/field';
|
||||||
|
import { titleize } from '~/utils/format';
|
||||||
|
|
||||||
|
const ProfileRoute = () => {
|
||||||
|
const user = useQuery(api.auth.getUser, {});
|
||||||
|
const provider = useQuery(api.auth.getUserProvider, {});
|
||||||
|
const updateUser = useMutation(api.auth.updateUser);
|
||||||
|
const updatePassword = useAction(api.auth.updateUserPassword);
|
||||||
|
const [name, setName] = useState(user?.name ?? '');
|
||||||
|
const [email, setEmail] = useState(user?.email ?? '');
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [savingProfile, setSavingProfile] = useState(false);
|
||||||
|
const [savingPassword, setSavingPassword] = useState(false);
|
||||||
|
|
||||||
|
const saveProfile = async () => {
|
||||||
|
setSavingProfile(true);
|
||||||
|
try {
|
||||||
|
await updateUser({ name, email });
|
||||||
|
Alert.alert('Saved', 'Profile updated.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not save profile.');
|
||||||
|
} finally {
|
||||||
|
setSavingProfile(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const savePassword = async () => {
|
||||||
|
setSavingPassword(true);
|
||||||
|
try {
|
||||||
|
await updatePassword({ currentPassword, newPassword });
|
||||||
|
setCurrentPassword('');
|
||||||
|
setNewPassword('');
|
||||||
|
Alert.alert('Saved', 'Password updated.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not update password.');
|
||||||
|
} finally {
|
||||||
|
setSavingPassword(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppScreen>
|
||||||
|
<Stack.Screen options={{ title: 'Profile' }} />
|
||||||
|
<Text className='text-foreground text-3xl font-bold'>Profile</Text>
|
||||||
|
<Card className='gap-4'>
|
||||||
|
<Text className='text-muted-foreground text-sm'>
|
||||||
|
Email is currently managed by {titleize(provider ?? 'your provider')}.
|
||||||
|
</Text>
|
||||||
|
<Field label='Name' value={name} onChangeText={setName} />
|
||||||
|
<Field
|
||||||
|
keyboardType='email-address'
|
||||||
|
label='Email'
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
/>
|
||||||
|
<Button disabled={savingProfile} onPress={() => void saveProfile()}>
|
||||||
|
{savingProfile ? 'Saving...' : 'Save profile'}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
{provider === 'password' ? (
|
||||||
|
<Card className='gap-4'>
|
||||||
|
<Text className='text-foreground font-semibold'>Password</Text>
|
||||||
|
<Field
|
||||||
|
label='Current password'
|
||||||
|
secureTextEntry
|
||||||
|
value={currentPassword}
|
||||||
|
onChangeText={setCurrentPassword}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label='New password'
|
||||||
|
secureTextEntry
|
||||||
|
value={newPassword}
|
||||||
|
onChangeText={setNewPassword}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
disabled={savingPassword}
|
||||||
|
variant='outline'
|
||||||
|
onPress={() => void savePassword()}
|
||||||
|
>
|
||||||
|
{savingPassword ? 'Updating...' : 'Update password'}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<Text className='text-muted-foreground text-sm leading-5'>
|
||||||
|
Password changes are hidden because this account is currently using{' '}
|
||||||
|
{titleize(provider ?? 'an OAuth provider')}.
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfileRoute;
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Alert, Text, View } from 'react-native';
|
||||||
|
import { Stack, useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import { useAction, useMutation, useQuery } from 'convex/react';
|
||||||
|
|
||||||
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
|
import type { SpoonDetailSegment } from '~/components/spoons/segment-control';
|
||||||
|
import { SegmentControl } from '~/components/spoons/segment-control';
|
||||||
|
import { SpoonDetailFork } from '~/components/spoons/spoon-detail-fork';
|
||||||
|
import { SpoonDetailOverview } from '~/components/spoons/spoon-detail-overview';
|
||||||
|
import { SpoonDetailPrs } from '~/components/spoons/spoon-detail-prs';
|
||||||
|
import { SpoonDetailSettings } from '~/components/spoons/spoon-detail-settings';
|
||||||
|
import { SpoonDetailThreads } from '~/components/spoons/spoon-detail-threads';
|
||||||
|
import { SpoonDetailUpstream } from '~/components/spoons/spoon-detail-upstream';
|
||||||
|
import { SpoonStatusBadge } from '~/components/spoons/spoon-status-badge';
|
||||||
|
import { AppScreen } from '~/components/ui/app-screen';
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
|
||||||
|
const SpoonDetailRoute = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { spoonId: rawSpoonId } = useLocalSearchParams<{ spoonId: string }>();
|
||||||
|
const spoonId = rawSpoonId as Id<'spoons'>;
|
||||||
|
const [segment, setSegment] = useState<SpoonDetailSegment>('overview');
|
||||||
|
const [threadPrompt, setThreadPrompt] = useState('');
|
||||||
|
const [pending, setPending] = useState<string | undefined>();
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const details = useQuery(api.spoons.getDetails, { spoonId });
|
||||||
|
const upstreamCommits =
|
||||||
|
useQuery(api.spoonCommits.listForSpoon, {
|
||||||
|
limit: 50,
|
||||||
|
side: 'upstream',
|
||||||
|
spoonId,
|
||||||
|
}) ?? [];
|
||||||
|
const forkCommits =
|
||||||
|
useQuery(api.spoonCommits.listForSpoon, {
|
||||||
|
limit: 50,
|
||||||
|
side: 'fork',
|
||||||
|
spoonId,
|
||||||
|
}) ?? [];
|
||||||
|
const pullRequests =
|
||||||
|
useQuery(api.spoonPullRequests.listForSpoon, { limit: 50, spoonId }) ?? [];
|
||||||
|
const remotes = useQuery(api.spoonRemotes.listForSpoon, { spoonId }) ?? [];
|
||||||
|
const threads =
|
||||||
|
useQuery(api.threads.listForSpoon, { limit: 25, spoonId }) ?? [];
|
||||||
|
const spoonSettings = useQuery(api.spoonSettings.getForSpoon, { spoonId });
|
||||||
|
const agentSettings = useQuery(api.spoonAgentSettings.getForSpoon, {
|
||||||
|
spoonId,
|
||||||
|
});
|
||||||
|
const secrets = useQuery(api.spoonSecrets.listForSpoon, { spoonId }) ?? [];
|
||||||
|
const providerProfiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
|
||||||
|
|
||||||
|
const refresh = useAction(api.githubSync.refreshSpoonGithubState);
|
||||||
|
const sync = useAction(api.githubSync.syncForkWithUpstream);
|
||||||
|
const updateSpoonSettings = useMutation(api.spoons.updateSettings);
|
||||||
|
const updateMaintenanceSettings = useMutation(api.spoonSettings.update);
|
||||||
|
const updateAgentSettings = useMutation(api.spoonAgentSettings.update);
|
||||||
|
const createThread = useMutation(api.threads.createUserThread);
|
||||||
|
const createSecret = useAction(api.spoonSecretsNode.create);
|
||||||
|
const removeSecretMutation = useMutation(api.spoonSecrets.remove);
|
||||||
|
const createRemote = useMutation(api.spoonRemotes.create);
|
||||||
|
const removeRemoteMutation = useMutation(api.spoonRemotes.remove);
|
||||||
|
|
||||||
|
const runRefresh = async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
setPending('refresh');
|
||||||
|
try {
|
||||||
|
await refresh({ spoonId });
|
||||||
|
Alert.alert('Refresh started', 'Spoon is checking GitHub state.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not refresh this Spoon.');
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false);
|
||||||
|
setPending(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runSync = async () => {
|
||||||
|
setPending('sync');
|
||||||
|
try {
|
||||||
|
await sync({ spoonId });
|
||||||
|
Alert.alert('Sync started', 'Spoon is syncing the fork.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not sync this Spoon.');
|
||||||
|
} finally {
|
||||||
|
setPending(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitThread = async () => {
|
||||||
|
if (!threadPrompt.trim()) return;
|
||||||
|
setPending('thread');
|
||||||
|
try {
|
||||||
|
const threadId = await createThread({
|
||||||
|
envFilePath:
|
||||||
|
agentSettings?.envFilePath === 'custom'
|
||||||
|
? agentSettings.customEnvFilePath
|
||||||
|
: agentSettings?.envFilePath,
|
||||||
|
materializeEnvFile: agentSettings?.materializeEnvFileByDefault,
|
||||||
|
prompt: threadPrompt,
|
||||||
|
spoonId,
|
||||||
|
});
|
||||||
|
setThreadPrompt('');
|
||||||
|
router.push(`/threads/${threadId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not create thread.');
|
||||||
|
} finally {
|
||||||
|
setPending(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!details) {
|
||||||
|
return (
|
||||||
|
<AppScreen>
|
||||||
|
<Stack.Screen options={{ title: 'Spoon' }} />
|
||||||
|
<Text className='text-muted-foreground'>Loading Spoon...</Text>
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { effectiveUpstreamAheadBy, spoon } = details;
|
||||||
|
const canSync =
|
||||||
|
spoon.provider === 'github' &&
|
||||||
|
(spoon.syncStatus === 'behind' || spoon.syncStatus === 'up_to_date') &&
|
||||||
|
(spoon.forkAheadBy ?? 0) === 0;
|
||||||
|
|
||||||
|
const settingsActions = {
|
||||||
|
addRemote: async (label: string, url: string) => {
|
||||||
|
setPending('addRemote');
|
||||||
|
try {
|
||||||
|
await createRemote({ label, spoonId, url });
|
||||||
|
} finally {
|
||||||
|
setPending(undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
addSecret: async (name: string, value: string) => {
|
||||||
|
setPending('addSecret');
|
||||||
|
try {
|
||||||
|
await createSecret({ name, spoonId, value });
|
||||||
|
} finally {
|
||||||
|
setPending(undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
importSecrets: async (items: { name: string; value: string }[]) => {
|
||||||
|
setPending('importSecrets');
|
||||||
|
let failed = 0;
|
||||||
|
try {
|
||||||
|
for (const item of items) {
|
||||||
|
try {
|
||||||
|
await createSecret({ name: item.name, spoonId, value: item.value });
|
||||||
|
} catch (error) {
|
||||||
|
failed += 1;
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (failed > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`${items.length - failed} imported, ${failed} failed.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setPending(undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
removeRemote: async (remoteId: string) => {
|
||||||
|
setPending(`remote:${remoteId}`);
|
||||||
|
try {
|
||||||
|
await removeRemoteMutation({
|
||||||
|
remoteId: remoteId as Id<'spoonRemotes'>,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setPending(undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
removeSecret: async (secretId: string) => {
|
||||||
|
setPending(`secret:${secretId}`);
|
||||||
|
try {
|
||||||
|
await removeSecretMutation({
|
||||||
|
secretId: secretId as Id<'spoonSecrets'>,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setPending(undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateAgent: async (patch: Record<string, unknown>) => {
|
||||||
|
setPending('settings');
|
||||||
|
try {
|
||||||
|
await updateAgentSettings({ spoonId, ...patch });
|
||||||
|
} finally {
|
||||||
|
setPending(undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateMaintenance: async (patch: Record<string, unknown>) => {
|
||||||
|
setPending('settings');
|
||||||
|
try {
|
||||||
|
await updateMaintenanceSettings({ spoonId, ...patch });
|
||||||
|
} finally {
|
||||||
|
setPending(undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateSpoon: async (patch: Record<string, unknown>) => {
|
||||||
|
setPending('settings');
|
||||||
|
try {
|
||||||
|
await updateSpoonSettings({ spoonId, ...patch });
|
||||||
|
} finally {
|
||||||
|
setPending(undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppScreen onRefresh={() => void runRefresh()} refreshing={refreshing}>
|
||||||
|
<Stack.Screen options={{ title: spoon.name }} />
|
||||||
|
<View className='gap-2'>
|
||||||
|
<Text className='text-foreground text-3xl font-bold'>{spoon.name}</Text>
|
||||||
|
<View className='flex-row flex-wrap gap-2'>
|
||||||
|
<SpoonStatusBadge status={spoon.syncStatus ?? spoon.status} />
|
||||||
|
<SpoonStatusBadge status={spoon.status} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='flex-row gap-3'>
|
||||||
|
<Button
|
||||||
|
disabled={pending === 'refresh'}
|
||||||
|
onPress={() => void runRefresh()}
|
||||||
|
>
|
||||||
|
{pending === 'refresh' ? 'Refreshing...' : 'Refresh'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!canSync || pending === 'sync'}
|
||||||
|
variant='outline'
|
||||||
|
onPress={() => void runSync()}
|
||||||
|
>
|
||||||
|
{pending === 'sync' ? 'Syncing...' : 'Sync fork'}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<SegmentControl value={segment} onChange={setSegment} />
|
||||||
|
|
||||||
|
{segment === 'overview' ? (
|
||||||
|
<SpoonDetailOverview
|
||||||
|
effectiveUpstreamAheadBy={effectiveUpstreamAheadBy}
|
||||||
|
remotes={remotes}
|
||||||
|
spoon={spoon}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{segment === 'upstream' ? (
|
||||||
|
<SpoonDetailUpstream commits={upstreamCommits} />
|
||||||
|
) : null}
|
||||||
|
{segment === 'fork' ? <SpoonDetailFork commits={forkCommits} /> : null}
|
||||||
|
{segment === 'prs' ? (
|
||||||
|
<SpoonDetailPrs pullRequests={pullRequests} />
|
||||||
|
) : null}
|
||||||
|
{segment === 'threads' ? (
|
||||||
|
<SpoonDetailThreads
|
||||||
|
creating={pending === 'thread'}
|
||||||
|
prompt={threadPrompt}
|
||||||
|
setPrompt={setThreadPrompt}
|
||||||
|
threads={threads}
|
||||||
|
onCreate={() => void submitThread()}
|
||||||
|
onOpenThread={(threadId) => router.push(`/threads/${threadId}`)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{segment === 'settings' ? (
|
||||||
|
<SpoonDetailSettings
|
||||||
|
actions={settingsActions}
|
||||||
|
agentSettings={agentSettings ?? undefined}
|
||||||
|
maintenanceSettings={spoonSettings ?? undefined}
|
||||||
|
pending={{
|
||||||
|
addingRemote: pending === 'addRemote',
|
||||||
|
addingSecret: pending === 'addSecret',
|
||||||
|
importingSecrets: pending === 'importSecrets',
|
||||||
|
removingRemoteId: pending?.startsWith('remote:')
|
||||||
|
? pending.slice('remote:'.length)
|
||||||
|
: undefined,
|
||||||
|
removingSecretId: pending?.startsWith('secret:')
|
||||||
|
? pending.slice('secret:'.length)
|
||||||
|
: undefined,
|
||||||
|
savingSettings: pending === 'settings',
|
||||||
|
}}
|
||||||
|
providerProfiles={providerProfiles}
|
||||||
|
remotes={remotes}
|
||||||
|
secrets={secrets}
|
||||||
|
spoon={spoon}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SpoonDetailRoute;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
const SpoonsLayout = () => <Stack screenOptions={{ headerShown: false }} />;
|
||||||
|
|
||||||
|
export default SpoonsLayout;
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Text, View } from 'react-native';
|
||||||
|
import { Link, Stack, useRouter } from 'expo-router';
|
||||||
|
import { useQuery } from 'convex/react';
|
||||||
|
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
|
import { SpoonListRow } from '~/components/spoons/spoon-list-row';
|
||||||
|
import { AppScreen } from '~/components/ui/app-screen';
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { EmptyState } from '~/components/ui/empty-state';
|
||||||
|
import { MetricCard } from '~/components/ui/metric-card';
|
||||||
|
|
||||||
|
const openThreadStatuses = ['resolved', 'ignored', 'failed', 'cancelled'];
|
||||||
|
|
||||||
|
const SpoonsRoute = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const spoons = useQuery(api.spoons.listMine, {}) ?? [];
|
||||||
|
const threads = useQuery(api.threads.listMine, { limit: 100 }) ?? [];
|
||||||
|
const active = spoons.filter((spoon) => spoon.status === 'active').length;
|
||||||
|
const upstreamWaiting = spoons.reduce(
|
||||||
|
(total, spoon) => total + (spoon.upstreamAheadBy ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const openThreadsFor = (spoonId: string) =>
|
||||||
|
threads.filter(
|
||||||
|
(thread) =>
|
||||||
|
thread.spoonId === spoonId &&
|
||||||
|
!openThreadStatuses.includes(thread.status),
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const softRefresh = () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
setTimeout(() => setRefreshing(false), 600);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppScreen onRefresh={softRefresh} refreshing={refreshing}>
|
||||||
|
<Stack.Screen options={{ title: 'Spoons' }} />
|
||||||
|
<View className='flex-row items-start justify-between gap-3'>
|
||||||
|
<View className='min-w-0 flex-1'>
|
||||||
|
<Text className='text-foreground text-3xl font-bold'>Spoons</Text>
|
||||||
|
<Text className='text-muted-foreground mt-1'>
|
||||||
|
Managed forks and their relationship with upstream.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Link href='/spoons/new' asChild>
|
||||||
|
<Button>New</Button>
|
||||||
|
</Link>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='flex-row gap-3'>
|
||||||
|
<MetricCard label='Managed' value={spoons.length} />
|
||||||
|
<MetricCard label='Active' value={active} />
|
||||||
|
<MetricCard label='Waiting' value={upstreamWaiting} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='gap-3'>
|
||||||
|
{spoons.length ? (
|
||||||
|
spoons.map((spoon) => (
|
||||||
|
<SpoonListRow
|
||||||
|
key={spoon._id}
|
||||||
|
openThreads={openThreadsFor(spoon._id)}
|
||||||
|
spoon={spoon}
|
||||||
|
onPress={() => router.push(`/spoons/${spoon._id}`)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
description='Create a manual Spoon record to start shaping fork maintenance.'
|
||||||
|
title='No managed forks yet'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SpoonsRoute;
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Alert, Linking, Text, View } from 'react-native';
|
||||||
|
import { Stack, useRouter } from 'expo-router';
|
||||||
|
import { useAction, useMutation, useQuery } from 'convex/react';
|
||||||
|
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
|
import { AppScreen } from '~/components/ui/app-screen';
|
||||||
|
import { Badge } from '~/components/ui/badge';
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { Card } from '~/components/ui/card';
|
||||||
|
import { Field } from '~/components/ui/field';
|
||||||
|
import { FormSection } from '~/components/ui/form-section';
|
||||||
|
import { PillTabs } from '~/components/ui/pill-tabs';
|
||||||
|
import { SheetSelect } from '~/components/ui/sheet-select';
|
||||||
|
|
||||||
|
type CreateMode = 'manual' | 'github';
|
||||||
|
type Provider = 'github' | 'gitea' | 'gitlab' | 'other';
|
||||||
|
type Visibility = 'public' | 'private' | 'internal' | 'unknown';
|
||||||
|
type MaintenanceMode = 'watch' | 'auto_pr' | 'paused';
|
||||||
|
type SyncCadence = 'daily' | 'weekly' | 'manual';
|
||||||
|
type ProductionRefStrategy =
|
||||||
|
| 'default_branch'
|
||||||
|
| 'latest_release'
|
||||||
|
| 'tag_pattern';
|
||||||
|
|
||||||
|
type Repository = Awaited<
|
||||||
|
ReturnType<
|
||||||
|
ReturnType<
|
||||||
|
typeof useAction<typeof api.githubNode.listInstallationRepositories>
|
||||||
|
>
|
||||||
|
>
|
||||||
|
>[number];
|
||||||
|
|
||||||
|
const NewSpoonRoute = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const createManual = useMutation(api.spoons.createManual);
|
||||||
|
const syncInstallation = useAction(api.githubNode.syncConfiguredInstallation);
|
||||||
|
const listRepositories = useAction(
|
||||||
|
api.githubNode.listInstallationRepositories,
|
||||||
|
);
|
||||||
|
const installUrl = useQuery(api.github.getInstallUrl, {});
|
||||||
|
const connection = useQuery(api.github.getConnection, {});
|
||||||
|
const [mode, setMode] = useState<CreateMode>('manual');
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [provider, setProvider] = useState<Provider>('github');
|
||||||
|
const [upstreamOwner, setUpstreamOwner] = useState('');
|
||||||
|
const [upstreamRepo, setUpstreamRepo] = useState('');
|
||||||
|
const [upstreamDefaultBranch, setUpstreamDefaultBranch] = useState('main');
|
||||||
|
const [upstreamUrl, setUpstreamUrl] = useState('');
|
||||||
|
const [forkOwner, setForkOwner] = useState('');
|
||||||
|
const [forkRepo, setForkRepo] = useState('');
|
||||||
|
const [forkDefaultBranch, setForkDefaultBranch] = useState('main');
|
||||||
|
const [forkUrl, setForkUrl] = useState('');
|
||||||
|
const [visibility, setVisibility] = useState<Visibility>('unknown');
|
||||||
|
const [maintenanceMode, setMaintenanceMode] =
|
||||||
|
useState<MaintenanceMode>('watch');
|
||||||
|
const [syncCadence, setSyncCadence] = useState<SyncCadence>('daily');
|
||||||
|
const [productionRefStrategy, setProductionRefStrategy] =
|
||||||
|
useState<ProductionRefStrategy>('default_branch');
|
||||||
|
const [tagPattern, setTagPattern] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [loadingRepos, setLoadingRepos] = useState(false);
|
||||||
|
const [repositories, setRepositories] = useState<Repository[]>([]);
|
||||||
|
|
||||||
|
const submitManual = async () => {
|
||||||
|
if (!name || !upstreamOwner || !upstreamRepo || !upstreamUrl) {
|
||||||
|
Alert.alert('Missing fields', 'Name and upstream metadata are required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const spoonId = await createManual({
|
||||||
|
description: description || undefined,
|
||||||
|
forkDefaultBranch: forkDefaultBranch || undefined,
|
||||||
|
forkOwner: forkOwner || undefined,
|
||||||
|
forkRepo: forkRepo || undefined,
|
||||||
|
forkUrl: forkUrl || undefined,
|
||||||
|
maintenanceMode,
|
||||||
|
name,
|
||||||
|
productionRefStrategy,
|
||||||
|
provider,
|
||||||
|
syncCadence,
|
||||||
|
tagPattern: tagPattern || undefined,
|
||||||
|
upstreamDefaultBranch,
|
||||||
|
upstreamOwner,
|
||||||
|
upstreamRepo,
|
||||||
|
upstreamUrl,
|
||||||
|
visibility,
|
||||||
|
});
|
||||||
|
router.replace(`/spoons/${spoonId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not create Spoon', 'Check the fields and try again.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadRepos = async () => {
|
||||||
|
setLoadingRepos(true);
|
||||||
|
try {
|
||||||
|
const result = await listRepositories({});
|
||||||
|
setRepositories(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not list repositories.');
|
||||||
|
} finally {
|
||||||
|
setLoadingRepos(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createFromRepo = async (repo: Repository) => {
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const upstreamOwnerValue = upstreamOwner.trim() || repo.owner;
|
||||||
|
const upstreamRepoValue = upstreamRepo.trim() || repo.name;
|
||||||
|
const upstreamUrlValue = upstreamUrl.trim() || repo.url;
|
||||||
|
const spoonId = await createManual({
|
||||||
|
forkDefaultBranch: repo.defaultBranch,
|
||||||
|
forkOwner: repo.owner,
|
||||||
|
forkRepo: repo.name,
|
||||||
|
forkUrl: repo.url,
|
||||||
|
maintenanceMode: 'watch',
|
||||||
|
name: repo.name,
|
||||||
|
productionRefStrategy: 'default_branch',
|
||||||
|
provider: 'github',
|
||||||
|
syncCadence: 'daily',
|
||||||
|
upstreamDefaultBranch: repo.defaultBranch,
|
||||||
|
upstreamOwner: upstreamOwnerValue,
|
||||||
|
upstreamRepo: upstreamRepoValue,
|
||||||
|
upstreamUrl: upstreamUrlValue,
|
||||||
|
visibility: repo.private ? 'private' : 'public',
|
||||||
|
});
|
||||||
|
router.replace(`/spoons/${spoonId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not create Spoon from repository.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmCreateFromRepo = (repo: Repository) => {
|
||||||
|
const message = repo.fork
|
||||||
|
? 'GitHub did not provide parent repository metadata here. Add upstream fields above if you want Spoon to compare against the original project immediately.'
|
||||||
|
: 'This will create a manual Spoon record using this repository as both upstream and fork unless you add upstream fields above.';
|
||||||
|
|
||||||
|
Alert.alert('Create Spoon from repository metadata?', message, [
|
||||||
|
{ style: 'cancel', text: 'Cancel' },
|
||||||
|
{
|
||||||
|
onPress: () => void createFromRepo(repo),
|
||||||
|
text: 'Create Spoon',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncGithub = async () => {
|
||||||
|
setLoadingRepos(true);
|
||||||
|
try {
|
||||||
|
await syncInstallation({});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not sync GitHub installation.');
|
||||||
|
} finally {
|
||||||
|
setLoadingRepos(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppScreen>
|
||||||
|
<Stack.Screen options={{ title: 'New Spoon' }} />
|
||||||
|
<View>
|
||||||
|
<Text className='text-foreground text-3xl font-bold'>New Spoon</Text>
|
||||||
|
<Text className='text-muted-foreground mt-1'>
|
||||||
|
Create a managed fork record manually or from GitHub.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<PillTabs
|
||||||
|
tabs={[
|
||||||
|
{ label: 'Manual', value: 'manual' },
|
||||||
|
{ label: 'GitHub', value: 'github' },
|
||||||
|
]}
|
||||||
|
value={mode}
|
||||||
|
onChange={setMode}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{mode === 'manual' ? (
|
||||||
|
<>
|
||||||
|
<FormSection title='Basics'>
|
||||||
|
<Field label='Spoon name' value={name} onChangeText={setName} />
|
||||||
|
<Field
|
||||||
|
label='Description'
|
||||||
|
multiline
|
||||||
|
value={description}
|
||||||
|
onChangeText={setDescription}
|
||||||
|
/>
|
||||||
|
<SheetSelect
|
||||||
|
label='Git provider'
|
||||||
|
options={[
|
||||||
|
{ label: 'GitHub', value: 'github' },
|
||||||
|
{ label: 'Gitea', value: 'gitea' },
|
||||||
|
{ label: 'GitLab', value: 'gitlab' },
|
||||||
|
{ label: 'Other', value: 'other' },
|
||||||
|
]}
|
||||||
|
value={provider}
|
||||||
|
onChange={setProvider}
|
||||||
|
/>
|
||||||
|
</FormSection>
|
||||||
|
<FormSection title='Upstream'>
|
||||||
|
<Field
|
||||||
|
label='Owner/org'
|
||||||
|
value={upstreamOwner}
|
||||||
|
onChangeText={setUpstreamOwner}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label='Repository'
|
||||||
|
value={upstreamRepo}
|
||||||
|
onChangeText={setUpstreamRepo}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label='Default branch'
|
||||||
|
value={upstreamDefaultBranch}
|
||||||
|
onChangeText={setUpstreamDefaultBranch}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
keyboardType='url'
|
||||||
|
label='Upstream URL'
|
||||||
|
value={upstreamUrl}
|
||||||
|
onChangeText={setUpstreamUrl}
|
||||||
|
/>
|
||||||
|
</FormSection>
|
||||||
|
<FormSection title='Fork'>
|
||||||
|
<Field
|
||||||
|
label='Owner/org'
|
||||||
|
value={forkOwner}
|
||||||
|
onChangeText={setForkOwner}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label='Repository'
|
||||||
|
value={forkRepo}
|
||||||
|
onChangeText={setForkRepo}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label='Default branch'
|
||||||
|
value={forkDefaultBranch}
|
||||||
|
onChangeText={setForkDefaultBranch}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
keyboardType='url'
|
||||||
|
label='Fork URL'
|
||||||
|
value={forkUrl}
|
||||||
|
onChangeText={setForkUrl}
|
||||||
|
/>
|
||||||
|
</FormSection>
|
||||||
|
<FormSection title='Maintenance'>
|
||||||
|
<SheetSelect
|
||||||
|
label='Visibility'
|
||||||
|
options={[
|
||||||
|
{ label: 'Unknown', value: 'unknown' },
|
||||||
|
{ label: 'Public', value: 'public' },
|
||||||
|
{ label: 'Private', value: 'private' },
|
||||||
|
{ label: 'Internal', value: 'internal' },
|
||||||
|
]}
|
||||||
|
value={visibility}
|
||||||
|
onChange={setVisibility}
|
||||||
|
/>
|
||||||
|
<SheetSelect
|
||||||
|
label='Maintenance mode'
|
||||||
|
options={[
|
||||||
|
{ label: 'Watch', value: 'watch' },
|
||||||
|
{ label: 'Auto PR', value: 'auto_pr' },
|
||||||
|
{ label: 'Paused', value: 'paused' },
|
||||||
|
]}
|
||||||
|
value={maintenanceMode}
|
||||||
|
onChange={setMaintenanceMode}
|
||||||
|
/>
|
||||||
|
<SheetSelect
|
||||||
|
label='Sync cadence'
|
||||||
|
options={[
|
||||||
|
{ label: 'Daily', value: 'daily' },
|
||||||
|
{ label: 'Weekly', value: 'weekly' },
|
||||||
|
{ label: 'Manual', value: 'manual' },
|
||||||
|
]}
|
||||||
|
value={syncCadence}
|
||||||
|
onChange={setSyncCadence}
|
||||||
|
/>
|
||||||
|
<SheetSelect
|
||||||
|
label='Production ref'
|
||||||
|
options={[
|
||||||
|
{ label: 'Default branch', value: 'default_branch' },
|
||||||
|
{ label: 'Latest release', value: 'latest_release' },
|
||||||
|
{ label: 'Tag pattern', value: 'tag_pattern' },
|
||||||
|
]}
|
||||||
|
value={productionRefStrategy}
|
||||||
|
onChange={setProductionRefStrategy}
|
||||||
|
/>
|
||||||
|
{productionRefStrategy === 'tag_pattern' ? (
|
||||||
|
<Field
|
||||||
|
label='Tag pattern'
|
||||||
|
value={tagPattern}
|
||||||
|
onChangeText={setTagPattern}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</FormSection>
|
||||||
|
<Button disabled={submitting} onPress={() => void submitManual()}>
|
||||||
|
{submitting ? 'Creating...' : 'Create Spoon'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<FormSection
|
||||||
|
description='Repository listing is read from the GitHub App installation.'
|
||||||
|
title='GitHub'
|
||||||
|
>
|
||||||
|
<View className='flex-row items-center justify-between'>
|
||||||
|
<Text className='text-foreground font-medium'>Connection</Text>
|
||||||
|
<Badge
|
||||||
|
label={connection?.status ?? 'not connected'}
|
||||||
|
tone={connection ? 'success' : 'warning'}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{installUrl ? (
|
||||||
|
<Button onPress={() => void Linking.openURL(installUrl)}>
|
||||||
|
Install or manage GitHub App
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<View className='flex-row gap-3'>
|
||||||
|
<Button
|
||||||
|
disabled={loadingRepos}
|
||||||
|
variant='outline'
|
||||||
|
onPress={() => void syncGithub()}
|
||||||
|
>
|
||||||
|
Sync
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!connection || loadingRepos}
|
||||||
|
onPress={() => void loadRepos()}
|
||||||
|
>
|
||||||
|
{loadingRepos ? 'Loading...' : 'Load repositories'}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
<Text className='text-muted-foreground text-sm leading-5'>
|
||||||
|
Optional upstream fields are used when the selected repository is a
|
||||||
|
fork. If you leave them blank, Spoon tracks the selected repository
|
||||||
|
as both upstream and fork until you correct it later.
|
||||||
|
</Text>
|
||||||
|
<Field
|
||||||
|
label='Upstream owner/org'
|
||||||
|
value={upstreamOwner}
|
||||||
|
onChangeText={setUpstreamOwner}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label='Upstream repository'
|
||||||
|
value={upstreamRepo}
|
||||||
|
onChangeText={setUpstreamRepo}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
keyboardType='url'
|
||||||
|
label='Upstream URL'
|
||||||
|
value={upstreamUrl}
|
||||||
|
onChangeText={setUpstreamUrl}
|
||||||
|
/>
|
||||||
|
{!loadingRepos && connection && repositories.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<Text className='text-muted-foreground text-sm leading-5'>
|
||||||
|
Load accessible repositories to create a Spoon from GitHub
|
||||||
|
metadata.
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
{repositories.map((repo) => (
|
||||||
|
<Card key={repo.id} className='gap-2'>
|
||||||
|
<Text className='text-foreground font-semibold'>
|
||||||
|
{repo.fullName}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-muted-foreground text-xs'>
|
||||||
|
{repo.private ? 'Private' : 'Public'} ·{' '}
|
||||||
|
{repo.fork ? 'Fork' : 'Repository'} · {repo.defaultBranch}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
disabled={submitting}
|
||||||
|
variant='outline'
|
||||||
|
onPress={() => confirmCreateFromRepo(repo)}
|
||||||
|
>
|
||||||
|
Create Spoon from metadata
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</FormSection>
|
||||||
|
)}
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NewSpoonRoute;
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Alert, Linking, Text, View } from 'react-native';
|
||||||
|
import { Link, Stack, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { useMutation, useQuery } from 'convex/react';
|
||||||
|
|
||||||
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
|
import { ThreadMessageList } from '~/components/threads/thread-message-list';
|
||||||
|
import { ThreadStatusBadge } from '~/components/threads/thread-status-badge';
|
||||||
|
import { AppScreen } from '~/components/ui/app-screen';
|
||||||
|
import { Badge } from '~/components/ui/badge';
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { Card } from '~/components/ui/card';
|
||||||
|
import { ConfirmButton } from '~/components/ui/confirm-button';
|
||||||
|
import { Field } from '~/components/ui/field';
|
||||||
|
import { formatDateTime, titleize } from '~/utils/format';
|
||||||
|
|
||||||
|
const ThreadDetailRoute = () => {
|
||||||
|
const { threadId: rawThreadId } = useLocalSearchParams<{
|
||||||
|
threadId: string;
|
||||||
|
}>();
|
||||||
|
const threadId = rawThreadId as Id<'threads'>;
|
||||||
|
const details = useQuery(api.threads.get, { threadId });
|
||||||
|
const messages = useQuery(api.threads.listMessages, { threadId }) ?? [];
|
||||||
|
const appendMessage = useMutation(api.threads.appendUserMessage);
|
||||||
|
const markResolved = useMutation(api.threads.markResolved);
|
||||||
|
const cancel = useMutation(api.threads.cancel);
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [pending, setPending] = useState<string | undefined>();
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
const send = async () => {
|
||||||
|
if (!message.trim()) return;
|
||||||
|
setPending('send');
|
||||||
|
try {
|
||||||
|
await appendMessage({ threadId, content: message });
|
||||||
|
setMessage('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not send message.');
|
||||||
|
} finally {
|
||||||
|
setPending(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const softRefresh = () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
setTimeout(() => setRefreshing(false), 600);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveThread = async () => {
|
||||||
|
setPending('resolve');
|
||||||
|
try {
|
||||||
|
await markResolved({ threadId });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not resolve thread.');
|
||||||
|
} finally {
|
||||||
|
setPending(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelThread = async () => {
|
||||||
|
setPending('cancel');
|
||||||
|
try {
|
||||||
|
await cancel({ threadId });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not cancel thread.');
|
||||||
|
} finally {
|
||||||
|
setPending(undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!details) {
|
||||||
|
return (
|
||||||
|
<AppScreen>
|
||||||
|
<Stack.Screen options={{ title: 'Thread' }} />
|
||||||
|
<Text className='text-muted-foreground'>Loading thread...</Text>
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { thread, spoon, latestJob } = details;
|
||||||
|
const pullRequestUrl = latestJob?.pullRequestUrl;
|
||||||
|
const completed = ['resolved', 'ignored', 'failed', 'cancelled'].includes(
|
||||||
|
thread.status,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppScreen onRefresh={softRefresh} refreshing={refreshing}>
|
||||||
|
<Stack.Screen options={{ title: thread.title }} />
|
||||||
|
<View className='gap-2'>
|
||||||
|
<Text className='text-foreground text-3xl font-bold'>
|
||||||
|
{thread.title}
|
||||||
|
</Text>
|
||||||
|
<View className='flex-row flex-wrap gap-2'>
|
||||||
|
<ThreadStatusBadge status={thread.status} />
|
||||||
|
<Badge label={titleize(thread.source)} />
|
||||||
|
{thread.maintenanceOutcome ? (
|
||||||
|
<Badge label={titleize(thread.maintenanceOutcome)} tone='primary' />
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<Text className='text-muted-foreground text-sm'>
|
||||||
|
Updated {formatDateTime(thread.updatedAt)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{spoon ? (
|
||||||
|
<Card>
|
||||||
|
<Text className='text-muted-foreground text-xs'>Spoon</Text>
|
||||||
|
<Text className='text-foreground mt-1 font-semibold'>
|
||||||
|
{spoon.name}
|
||||||
|
</Text>
|
||||||
|
<Link href={`/spoons/${spoon._id}`} asChild>
|
||||||
|
<Button variant='outline'>Open Spoon</Button>
|
||||||
|
</Link>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{latestJob ? (
|
||||||
|
<Card className='gap-3'>
|
||||||
|
<Text className='text-foreground font-semibold'>Latest job</Text>
|
||||||
|
<Text className='text-muted-foreground text-sm'>
|
||||||
|
{titleize(latestJob.status)} · {titleize(latestJob.workspaceStatus)}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-muted-foreground text-sm'>
|
||||||
|
Branch: {latestJob.workBranch}
|
||||||
|
</Text>
|
||||||
|
<Link href={`/workspace/${latestJob._id}`} asChild>
|
||||||
|
<Button variant='outline'>Open workspace review</Button>
|
||||||
|
</Link>
|
||||||
|
{pullRequestUrl ? (
|
||||||
|
<Button onPress={() => void Linking.openURL(pullRequestUrl)}>
|
||||||
|
Open draft PR
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<ThreadMessageList messages={messages} />
|
||||||
|
|
||||||
|
<Card className='gap-3'>
|
||||||
|
<Text className='text-foreground font-semibold'>Reply</Text>
|
||||||
|
<Field
|
||||||
|
label='Message'
|
||||||
|
multiline
|
||||||
|
value={message}
|
||||||
|
onChangeText={setMessage}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
disabled={completed || pending === 'send'}
|
||||||
|
onPress={() => void send()}
|
||||||
|
>
|
||||||
|
{pending === 'send' ? 'Sending...' : 'Send message'}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<View className='flex-row gap-3'>
|
||||||
|
<Button
|
||||||
|
disabled={completed || pending === 'resolve'}
|
||||||
|
variant='outline'
|
||||||
|
onPress={() => void resolveThread()}
|
||||||
|
>
|
||||||
|
{pending === 'resolve' ? 'Resolving...' : 'Resolve'}
|
||||||
|
</Button>
|
||||||
|
<ConfirmButton
|
||||||
|
confirmLabel='Cancel thread'
|
||||||
|
destructive
|
||||||
|
disabled={completed || pending === 'cancel'}
|
||||||
|
message='Cancel this thread?'
|
||||||
|
title='Cancel thread'
|
||||||
|
onConfirm={() => void cancelThread()}
|
||||||
|
>
|
||||||
|
{pending === 'cancel' ? 'Cancelling...' : 'Cancel'}
|
||||||
|
</ConfirmButton>
|
||||||
|
</View>
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ThreadDetailRoute;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
const ThreadsLayout = () => <Stack screenOptions={{ headerShown: false }} />;
|
||||||
|
|
||||||
|
export default ThreadsLayout;
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Text, View } from 'react-native';
|
||||||
|
import { Stack, useRouter } from 'expo-router';
|
||||||
|
import { useQuery } from 'convex/react';
|
||||||
|
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
|
import type { PillTab } from '~/components/ui/pill-tabs';
|
||||||
|
import { ThreadListRow } from '~/components/threads/thread-list-row';
|
||||||
|
import { AppScreen } from '~/components/ui/app-screen';
|
||||||
|
import { EmptyState } from '~/components/ui/empty-state';
|
||||||
|
import { PillTabs } from '~/components/ui/pill-tabs';
|
||||||
|
|
||||||
|
type StatusFilter =
|
||||||
|
| 'all'
|
||||||
|
| 'open'
|
||||||
|
| 'running'
|
||||||
|
| 'waiting_for_user'
|
||||||
|
| 'resolved';
|
||||||
|
|
||||||
|
const filters: PillTab<StatusFilter>[] = [
|
||||||
|
{ label: 'All', value: 'all' },
|
||||||
|
{ label: 'Open', value: 'open' },
|
||||||
|
{ label: 'Running', value: 'running' },
|
||||||
|
{ label: 'Waiting', value: 'waiting_for_user' },
|
||||||
|
{ label: 'Resolved', value: 'resolved' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ThreadsRoute = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const [status, setStatus] = useState<StatusFilter>('all');
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const threads =
|
||||||
|
useQuery(api.threads.listMine, {
|
||||||
|
limit: 50,
|
||||||
|
status,
|
||||||
|
}) ?? [];
|
||||||
|
|
||||||
|
const softRefresh = () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
setTimeout(() => setRefreshing(false), 600);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppScreen onRefresh={softRefresh} refreshing={refreshing}>
|
||||||
|
<Stack.Screen options={{ title: 'Threads' }} />
|
||||||
|
<View>
|
||||||
|
<Text className='text-foreground text-3xl font-bold'>Threads</Text>
|
||||||
|
<Text className='text-muted-foreground mt-1'>
|
||||||
|
Maintenance decisions, user requests, and workspace handoffs.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<PillTabs onChange={setStatus} tabs={filters} value={status} />
|
||||||
|
<View className='gap-3'>
|
||||||
|
{threads.length ? (
|
||||||
|
threads.map((thread) => (
|
||||||
|
<ThreadListRow
|
||||||
|
key={thread._id}
|
||||||
|
thread={thread}
|
||||||
|
onPress={() => router.push(`/threads/${thread._id}`)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
description='Threads appear when you ask Spoon to change a fork or upstream changes need review.'
|
||||||
|
title='No threads'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ThreadsRoute;
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Alert, Text, View } from 'react-native';
|
||||||
|
import { Stack, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { useMutation, useQuery } from 'convex/react';
|
||||||
|
|
||||||
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
|
import type { PillTab } from '~/components/ui/pill-tabs';
|
||||||
|
import { AppScreen } from '~/components/ui/app-screen';
|
||||||
|
import { PillTabs } from '~/components/ui/pill-tabs';
|
||||||
|
import { WorkspaceArtifacts } from '~/components/workspace/workspace-artifacts';
|
||||||
|
import { WorkspaceEvents } from '~/components/workspace/workspace-events';
|
||||||
|
import { WorkspaceMessages } from '~/components/workspace/workspace-messages';
|
||||||
|
import { WorkspaceSummary } from '~/components/workspace/workspace-summary';
|
||||||
|
|
||||||
|
type WorkspaceTab = 'status' | 'messages' | 'diffs' | 'events' | 'artifacts';
|
||||||
|
|
||||||
|
const tabs: PillTab<WorkspaceTab>[] = [
|
||||||
|
{ label: 'Status', value: 'status' },
|
||||||
|
{ label: 'Messages', value: 'messages' },
|
||||||
|
{ label: 'Diffs', value: 'diffs' },
|
||||||
|
{ label: 'Events', value: 'events' },
|
||||||
|
{ label: 'Artifacts', value: 'artifacts' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const WorkspaceRoute = () => {
|
||||||
|
const { jobId: rawJobId } = useLocalSearchParams<{ jobId: string }>();
|
||||||
|
const jobId = rawJobId as Id<'agentJobs'>;
|
||||||
|
const [tab, setTab] = useState<WorkspaceTab>('status');
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [cancelling, setCancelling] = useState(false);
|
||||||
|
const job = useQuery(api.agentJobs.get, { jobId });
|
||||||
|
const messages = useQuery(api.agentJobs.listMessages, { jobId }) ?? [];
|
||||||
|
const events =
|
||||||
|
useQuery(api.agentJobs.listEvents, { jobId, limit: 200 }) ?? [];
|
||||||
|
const artifacts = useQuery(api.agentJobs.listArtifacts, { jobId }) ?? [];
|
||||||
|
const cancel = useMutation(api.agentJobs.cancel);
|
||||||
|
|
||||||
|
const softRefresh = () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
setTimeout(() => setRefreshing(false), 600);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelJob = async () => {
|
||||||
|
setCancelling(true);
|
||||||
|
try {
|
||||||
|
await cancel({ jobId });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not cancel job.');
|
||||||
|
} finally {
|
||||||
|
setCancelling(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!job) {
|
||||||
|
return (
|
||||||
|
<AppScreen>
|
||||||
|
<Stack.Screen options={{ title: 'Workspace' }} />
|
||||||
|
<Text className='text-muted-foreground'>Loading workspace...</Text>
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppScreen onRefresh={softRefresh} refreshing={refreshing}>
|
||||||
|
<Stack.Screen options={{ title: 'Workspace' }} />
|
||||||
|
<View className='gap-2'>
|
||||||
|
<Text className='text-foreground text-3xl font-bold'>
|
||||||
|
Workspace review
|
||||||
|
</Text>
|
||||||
|
<Text className='text-muted-foreground'>
|
||||||
|
Inspect the active job without exposing worker internals to mobile.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<PillTabs onChange={setTab} tabs={tabs} value={tab} />
|
||||||
|
{tab === 'status' ? (
|
||||||
|
<WorkspaceSummary
|
||||||
|
cancelling={cancelling}
|
||||||
|
job={job}
|
||||||
|
onCancel={() => void cancelJob()}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{tab === 'messages' ? <WorkspaceMessages messages={messages} /> : null}
|
||||||
|
{tab === 'diffs' ? (
|
||||||
|
<WorkspaceArtifacts artifacts={artifacts} mode='diffs' />
|
||||||
|
) : null}
|
||||||
|
{tab === 'events' ? <WorkspaceEvents events={events} /> : null}
|
||||||
|
{tab === 'artifacts' ? (
|
||||||
|
<WorkspaceArtifacts artifacts={artifacts} mode='artifacts' />
|
||||||
|
) : null}
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WorkspaceRoute;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
const WorkspaceLayout = () => <Stack screenOptions={{ headerShown: false }} />;
|
||||||
|
|
||||||
|
export default WorkspaceLayout;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
import { SignInScreen } from '~/components/auth/sign-in-screen';
|
||||||
|
|
||||||
|
const SignInRoute = () => (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: 'Sign in' }} />
|
||||||
|
<SignInScreen />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default SignInRoute;
|
||||||
+17
-166
@@ -1,177 +1,28 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Alert, Pressable, Text, TextInput, View } from 'react-native';
|
import { Stack, useRouter } from 'expo-router';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { useConvexAuth } from 'convex/react';
|
||||||
import * as Linking from 'expo-linking';
|
|
||||||
import { Stack } from 'expo-router';
|
|
||||||
import * as WebBrowser from 'expo-web-browser';
|
|
||||||
import { useAuthActions } from '@convex-dev/auth/react';
|
|
||||||
import { useConvexAuth, useQuery } from 'convex/react';
|
|
||||||
|
|
||||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
import { LoadingState } from '~/components/ui/loading-state';
|
||||||
|
|
||||||
WebBrowser.maybeCompleteAuthSession();
|
const IndexRoute = () => {
|
||||||
|
|
||||||
const Stat = ({ label, value }: { label: string; value: number }) => (
|
|
||||||
<View className='border-border bg-card flex-1 rounded-lg border p-4'>
|
|
||||||
<Text className='text-muted-foreground text-xs'>{label}</Text>
|
|
||||||
<Text className='text-foreground mt-2 text-2xl font-bold'>{value}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
const Index = () => {
|
|
||||||
const { isAuthenticated, isLoading } = useConvexAuth();
|
const { isAuthenticated, isLoading } = useConvexAuth();
|
||||||
const { signIn, signOut } = useAuthActions();
|
const router = useRouter();
|
||||||
const user = useQuery(api.auth.getUser, isAuthenticated ? {} : 'skip');
|
|
||||||
const spoons =
|
|
||||||
useQuery(api.spoons.listMine, isAuthenticated ? {} : 'skip') ?? [];
|
|
||||||
const syncRuns =
|
|
||||||
useQuery(
|
|
||||||
api.syncRuns.listRecent,
|
|
||||||
isAuthenticated ? { limit: 5 } : 'skip',
|
|
||||||
) ?? [];
|
|
||||||
const threads =
|
|
||||||
useQuery(api.threads.listMine, isAuthenticated ? { limit: 5 } : 'skip') ??
|
|
||||||
[];
|
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const redirectTo = useMemo(() => Linking.createURL(''), []);
|
|
||||||
|
|
||||||
const handlePasswordSignIn = async () => {
|
useEffect(() => {
|
||||||
setSubmitting(true);
|
if (isLoading) return;
|
||||||
try {
|
if (isAuthenticated) {
|
||||||
await signIn('password', { email, password, flow: 'signIn' });
|
router.replace('/dashboard');
|
||||||
} catch (error) {
|
} else {
|
||||||
console.error(error);
|
router.replace('/sign-in');
|
||||||
Alert.alert('Sign in failed', 'Check your email and password.');
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
}
|
||||||
};
|
}, [isAuthenticated, isLoading, router]);
|
||||||
|
|
||||||
const handleAuthentikSignIn = async () => {
|
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
|
||||||
const result = await signIn('authentik', { redirectTo });
|
|
||||||
if (!result.redirect) return;
|
|
||||||
const authResult = await WebBrowser.openAuthSessionAsync(
|
|
||||||
result.redirect.toString(),
|
|
||||||
redirectTo,
|
|
||||||
);
|
|
||||||
if (authResult.type !== 'success') return;
|
|
||||||
const parsed = Linking.parse(authResult.url);
|
|
||||||
const code = parsed.queryParams?.code;
|
|
||||||
if (typeof code !== 'string') {
|
|
||||||
Alert.alert('Sign in failed', 'Authentik did not return a code.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await signIn('authentik', { code });
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
Alert.alert('Sign in failed', 'Could not complete Authentik sign in.');
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className='bg-background flex-1'>
|
<>
|
||||||
<Stack.Screen options={{ title: 'Spoon' }} />
|
<Stack.Screen options={{ title: 'Spoon' }} />
|
||||||
<View className='flex-1 gap-5 p-6'>
|
<LoadingState label='Opening Spoon...' />
|
||||||
<View>
|
</>
|
||||||
<Text className='text-foreground text-4xl font-bold'>Spoon</Text>
|
|
||||||
<Text className='text-muted-foreground mt-2 text-base leading-6'>
|
|
||||||
Fork freely. Stay close to upstream.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<Text className='text-muted-foreground'>Loading...</Text>
|
|
||||||
) : isAuthenticated ? (
|
|
||||||
<View className='gap-5'>
|
|
||||||
<View>
|
|
||||||
<Text className='text-foreground text-xl font-semibold'>
|
|
||||||
Welcome{user?.name ? `, ${user.name}` : ''}
|
|
||||||
</Text>
|
|
||||||
<Text className='text-muted-foreground mt-1'>
|
|
||||||
Monitor your managed forks from anywhere.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className='flex-row gap-3'>
|
|
||||||
<Stat label='Spoons' value={spoons.length} />
|
|
||||||
<Stat label='Checks' value={syncRuns.length} />
|
|
||||||
<Stat label='Threads' value={threads.length} />
|
|
||||||
</View>
|
|
||||||
<View className='border-border bg-card rounded-lg border p-4'>
|
|
||||||
<Text className='text-foreground font-semibold'>
|
|
||||||
Recent Spoons
|
|
||||||
</Text>
|
|
||||||
{spoons.length ? (
|
|
||||||
spoons.slice(0, 4).map((spoon) => (
|
|
||||||
<Text key={spoon._id} className='text-muted-foreground mt-3'>
|
|
||||||
{spoon.name} - {spoon.status.replaceAll('_', ' ')}
|
|
||||||
</Text>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<Text className='text-muted-foreground mt-3'>
|
|
||||||
Create your first Spoon from the web dashboard.
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<Pressable
|
|
||||||
className='bg-primary items-center rounded-md p-3'
|
|
||||||
onPress={() => void signOut()}
|
|
||||||
>
|
|
||||||
<Text className='text-primary-foreground font-semibold'>
|
|
||||||
Sign out
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<View className='gap-4'>
|
|
||||||
<TextInput
|
|
||||||
className='border-input text-foreground rounded-md border px-3 py-3'
|
|
||||||
autoCapitalize='none'
|
|
||||||
keyboardType='email-address'
|
|
||||||
placeholder='Email'
|
|
||||||
placeholderTextColor='#64748b'
|
|
||||||
value={email}
|
|
||||||
onChangeText={setEmail}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
className='border-input text-foreground rounded-md border px-3 py-3'
|
|
||||||
secureTextEntry
|
|
||||||
placeholder='Password'
|
|
||||||
placeholderTextColor='#64748b'
|
|
||||||
value={password}
|
|
||||||
onChangeText={setPassword}
|
|
||||||
/>
|
|
||||||
<Pressable
|
|
||||||
className='bg-primary items-center rounded-md p-3 disabled:opacity-60'
|
|
||||||
disabled={submitting}
|
|
||||||
onPress={() => void handlePasswordSignIn()}
|
|
||||||
>
|
|
||||||
<Text className='text-primary-foreground font-semibold'>
|
|
||||||
Sign in with password
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
|
||||||
className='border-border items-center rounded-md border p-3 disabled:opacity-60'
|
|
||||||
disabled={submitting}
|
|
||||||
onPress={() => void handleAuthentikSignIn()}
|
|
||||||
>
|
|
||||||
<Text className='text-foreground font-semibold'>
|
|
||||||
Continue with Authentik
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Text className='text-muted-foreground text-sm'>
|
|
||||||
Register the native redirect URI based on spoon:// in Authentik.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Index;
|
export default IndexRoute;
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import { Text, View } from 'react-native';
|
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
||||||
import { Stack, useLocalSearchParams } from 'expo-router';
|
|
||||||
|
|
||||||
const Post = () => {
|
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SafeAreaView className='bg-background flex-1'>
|
|
||||||
<Stack.Screen options={{ title: 'Post' }} />
|
|
||||||
<View className='flex-1 p-4'>
|
|
||||||
<Text className='text-foreground text-2xl font-bold'>Post {id}</Text>
|
|
||||||
<Text className='text-muted-foreground mt-2'>
|
|
||||||
Implement your post detail screen here using Convex queries.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Post;
|
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Alert, Text, View } from 'react-native';
|
||||||
|
import * as Linking from 'expo-linking';
|
||||||
|
import * as WebBrowser from 'expo-web-browser';
|
||||||
|
import { useAuthActions } from '@convex-dev/auth/react';
|
||||||
|
|
||||||
|
import { AppScreen } from '~/components/ui/app-screen';
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { Card } from '~/components/ui/card';
|
||||||
|
import { Field } from '~/components/ui/field';
|
||||||
|
|
||||||
|
WebBrowser.maybeCompleteAuthSession();
|
||||||
|
|
||||||
|
type OAuthProvider = 'github' | 'authentik';
|
||||||
|
|
||||||
|
export const SignInScreen = () => {
|
||||||
|
const { signIn } = useAuthActions();
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const redirectTo = useMemo(() => Linking.createURL(''), []);
|
||||||
|
|
||||||
|
const signInWithPassword = async () => {
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await signIn('password', { email, password, flow: 'signIn' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Sign in failed', 'Check your email and password.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const signInWithOAuth = async (provider: OAuthProvider) => {
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const result = await signIn(provider, { redirectTo });
|
||||||
|
if (!result.redirect) return;
|
||||||
|
const authResult = await WebBrowser.openAuthSessionAsync(
|
||||||
|
result.redirect.toString(),
|
||||||
|
redirectTo,
|
||||||
|
);
|
||||||
|
if (authResult.type !== 'success') return;
|
||||||
|
const parsed = Linking.parse(authResult.url);
|
||||||
|
const code = parsed.queryParams?.code;
|
||||||
|
if (typeof code !== 'string') {
|
||||||
|
Alert.alert('Sign in failed', 'The provider did not return a code.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await signIn(provider, { code });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Sign in failed', `Could not complete ${provider} sign in.`);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppScreen>
|
||||||
|
<View className='gap-2'>
|
||||||
|
<Text className='text-foreground text-4xl font-bold'>Spoon</Text>
|
||||||
|
<Text className='text-muted-foreground text-base leading-6'>
|
||||||
|
Fork freely & keep them close to upstream.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Card className='gap-3'>
|
||||||
|
<Button
|
||||||
|
disabled={submitting}
|
||||||
|
onPress={() => void signInWithOAuth('github')}
|
||||||
|
>
|
||||||
|
Continue with GitHub
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={submitting}
|
||||||
|
variant='outline'
|
||||||
|
onPress={() => void signInWithOAuth('authentik')}
|
||||||
|
>
|
||||||
|
Continue with Authentik
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className='gap-4'>
|
||||||
|
<Text className='text-foreground font-semibold'>
|
||||||
|
Sign in with email
|
||||||
|
</Text>
|
||||||
|
<Field
|
||||||
|
keyboardType='email-address'
|
||||||
|
label='Email'
|
||||||
|
placeholder='you@example.com'
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label='Password'
|
||||||
|
placeholder='Password'
|
||||||
|
secureTextEntry
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
/>
|
||||||
|
<Button disabled={submitting} onPress={() => void signInWithPassword()}>
|
||||||
|
Sign in with email
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Text className='text-muted-foreground text-sm leading-5'>
|
||||||
|
Native OAuth callbacks should allow the `spoon://` redirect scheme.
|
||||||
|
</Text>
|
||||||
|
</AppScreen>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Text } from 'react-native';
|
||||||
|
|
||||||
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { Field } from '~/components/ui/field';
|
||||||
|
import { FormSection } from '~/components/ui/form-section';
|
||||||
|
import { SheetSelect } from '~/components/ui/sheet-select';
|
||||||
|
import { SwitchRow } from '~/components/ui/switch-row';
|
||||||
|
import { Textarea } from '~/components/ui/textarea';
|
||||||
|
|
||||||
|
type Provider =
|
||||||
|
| 'openai'
|
||||||
|
| 'anthropic'
|
||||||
|
| 'google'
|
||||||
|
| 'openrouter'
|
||||||
|
| 'requesty'
|
||||||
|
| 'litellm'
|
||||||
|
| 'cloudflare_ai_gateway'
|
||||||
|
| 'custom_openai_compatible'
|
||||||
|
| 'opencode_openai_login';
|
||||||
|
type AuthType = 'api_key' | 'opencode_auth_json' | 'none';
|
||||||
|
type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||||
|
|
||||||
|
type ExistingProfile = {
|
||||||
|
_id: Id<'aiProviderProfiles'>;
|
||||||
|
authType: AuthType;
|
||||||
|
baseUrl?: string;
|
||||||
|
defaultModel: string;
|
||||||
|
enabled: boolean;
|
||||||
|
modelOptions?: string[];
|
||||||
|
name: string;
|
||||||
|
provider: Provider;
|
||||||
|
reasoningEffort: ReasoningEffort;
|
||||||
|
};
|
||||||
|
|
||||||
|
const providerDefaults: Record<
|
||||||
|
Provider,
|
||||||
|
{ authType: AuthType; model: string; name: string }
|
||||||
|
> = {
|
||||||
|
anthropic: {
|
||||||
|
authType: 'api_key',
|
||||||
|
model: 'claude-sonnet-4-5',
|
||||||
|
name: 'Anthropic',
|
||||||
|
},
|
||||||
|
cloudflare_ai_gateway: {
|
||||||
|
authType: 'api_key',
|
||||||
|
model: 'gpt-5.1-codex',
|
||||||
|
name: 'Cloudflare AI Gateway',
|
||||||
|
},
|
||||||
|
custom_openai_compatible: {
|
||||||
|
authType: 'api_key',
|
||||||
|
model: 'gpt-5.1-codex',
|
||||||
|
name: 'Custom compatible',
|
||||||
|
},
|
||||||
|
google: { authType: 'api_key', model: 'gemini-2.5-pro', name: 'Google' },
|
||||||
|
litellm: { authType: 'api_key', model: 'gpt-5.1-codex', name: 'LiteLLM' },
|
||||||
|
opencode_openai_login: {
|
||||||
|
authType: 'opencode_auth_json',
|
||||||
|
model: 'gpt-5.1-codex',
|
||||||
|
name: 'OpenCode provider',
|
||||||
|
},
|
||||||
|
openai: { authType: 'api_key', model: 'gpt-5.1-codex', name: 'OpenAI' },
|
||||||
|
openrouter: {
|
||||||
|
authType: 'api_key',
|
||||||
|
model: 'openai/gpt-5.1-codex',
|
||||||
|
name: 'OpenRouter',
|
||||||
|
},
|
||||||
|
requesty: {
|
||||||
|
authType: 'api_key',
|
||||||
|
model: 'openai/gpt-5.1-codex',
|
||||||
|
name: 'Requesty',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseModelOptions = (text: string) =>
|
||||||
|
text
|
||||||
|
.split(/\r?\n|,/)
|
||||||
|
.map((model) => model.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
export const AiProviderProfileForm = ({
|
||||||
|
existing,
|
||||||
|
onSubmit,
|
||||||
|
saving,
|
||||||
|
}: {
|
||||||
|
existing?: ExistingProfile;
|
||||||
|
onSubmit: (values: {
|
||||||
|
authType: AuthType;
|
||||||
|
baseUrl?: string;
|
||||||
|
defaultModel: string;
|
||||||
|
enabled: boolean;
|
||||||
|
modelOptions: string[];
|
||||||
|
name: string;
|
||||||
|
provider: Provider;
|
||||||
|
reasoningEffort: ReasoningEffort;
|
||||||
|
secret?: string;
|
||||||
|
}) => Promise<void>;
|
||||||
|
saving: boolean;
|
||||||
|
}) => {
|
||||||
|
const [name, setName] = useState(existing?.name ?? 'OpenCode provider');
|
||||||
|
const [provider, setProvider] = useState<Provider>(
|
||||||
|
existing?.provider ?? 'opencode_openai_login',
|
||||||
|
);
|
||||||
|
const [authType, setAuthType] = useState<AuthType>(
|
||||||
|
existing?.authType ?? 'opencode_auth_json',
|
||||||
|
);
|
||||||
|
const [secret, setSecret] = useState('');
|
||||||
|
const [baseUrl, setBaseUrl] = useState(existing?.baseUrl ?? '');
|
||||||
|
const [modelOptions, setModelOptions] = useState(
|
||||||
|
(existing?.modelOptions?.length
|
||||||
|
? existing.modelOptions
|
||||||
|
: [existing?.defaultModel ?? 'gpt-5.1-codex']
|
||||||
|
).join('\n'),
|
||||||
|
);
|
||||||
|
const models = useMemo(() => parseModelOptions(modelOptions), [modelOptions]);
|
||||||
|
const [defaultModel, setDefaultModel] = useState(
|
||||||
|
existing?.defaultModel ?? 'gpt-5.1-codex',
|
||||||
|
);
|
||||||
|
const [reasoningEffort, setReasoningEffort] = useState<ReasoningEffort>(
|
||||||
|
existing?.reasoningEffort ?? 'medium',
|
||||||
|
);
|
||||||
|
const [enabled, setEnabled] = useState(existing?.enabled ?? true);
|
||||||
|
|
||||||
|
const changeProvider = (nextProvider: Provider) => {
|
||||||
|
const defaults = providerDefaults[nextProvider];
|
||||||
|
setProvider(nextProvider);
|
||||||
|
setName(defaults.name);
|
||||||
|
setAuthType(defaults.authType);
|
||||||
|
setDefaultModel(defaults.model);
|
||||||
|
setModelOptions(defaults.model);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = () =>
|
||||||
|
void onSubmit({
|
||||||
|
authType,
|
||||||
|
baseUrl: baseUrl || undefined,
|
||||||
|
defaultModel: models.includes(defaultModel)
|
||||||
|
? defaultModel
|
||||||
|
: (models[0] ?? defaultModel),
|
||||||
|
enabled,
|
||||||
|
modelOptions: models,
|
||||||
|
name,
|
||||||
|
provider,
|
||||||
|
reasoningEffort,
|
||||||
|
secret: secret || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormSection title={existing ? 'Edit provider' : 'New provider'}>
|
||||||
|
<Field label='Name' value={name} onChangeText={setName} />
|
||||||
|
<SheetSelect
|
||||||
|
label='Provider'
|
||||||
|
options={[
|
||||||
|
{ label: 'Codex ChatGPT login', value: 'opencode_openai_login' },
|
||||||
|
{ label: 'OpenAI', value: 'openai' },
|
||||||
|
{ label: 'Anthropic', value: 'anthropic' },
|
||||||
|
{ label: 'Google', value: 'google' },
|
||||||
|
{ label: 'OpenRouter', value: 'openrouter' },
|
||||||
|
{ label: 'Requesty', value: 'requesty' },
|
||||||
|
{ label: 'LiteLLM', value: 'litellm' },
|
||||||
|
{ label: 'Cloudflare AI Gateway', value: 'cloudflare_ai_gateway' },
|
||||||
|
{
|
||||||
|
label: 'Custom OpenAI compatible',
|
||||||
|
value: 'custom_openai_compatible',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
value={provider}
|
||||||
|
onChange={changeProvider}
|
||||||
|
/>
|
||||||
|
<SheetSelect
|
||||||
|
label='Auth type'
|
||||||
|
options={[
|
||||||
|
{ label: 'API key', value: 'api_key' },
|
||||||
|
{ label: 'Codex auth JSON', value: 'opencode_auth_json' },
|
||||||
|
{ label: 'None', value: 'none' },
|
||||||
|
]}
|
||||||
|
value={authType}
|
||||||
|
onChange={setAuthType}
|
||||||
|
/>
|
||||||
|
{authType === 'opencode_auth_json' ? (
|
||||||
|
<Text className='text-muted-foreground text-sm leading-5'>
|
||||||
|
Copy auth.json from your Codex auth folder, for example
|
||||||
|
~/.codex/auth.json, and paste it here. Spoon writes it into isolated
|
||||||
|
agent workspaces for Codex CLI runs.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{authType !== 'none' ? (
|
||||||
|
<Field
|
||||||
|
label={authType === 'api_key' ? 'API key' : 'Auth JSON'}
|
||||||
|
multiline={authType === 'opencode_auth_json'}
|
||||||
|
secureTextEntry={authType === 'api_key'}
|
||||||
|
value={secret}
|
||||||
|
onChangeText={setSecret}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Field label='Base URL' value={baseUrl} onChangeText={setBaseUrl} />
|
||||||
|
<Textarea
|
||||||
|
label='Model options'
|
||||||
|
value={modelOptions}
|
||||||
|
onChangeText={setModelOptions}
|
||||||
|
/>
|
||||||
|
<SheetSelect
|
||||||
|
disabled={!models.length}
|
||||||
|
label='Default model'
|
||||||
|
options={
|
||||||
|
models.length
|
||||||
|
? models.map((model) => ({ label: model, value: model }))
|
||||||
|
: [{ label: 'Add model options first', value: '' }]
|
||||||
|
}
|
||||||
|
value={models.includes(defaultModel) ? defaultModel : (models[0] ?? '')}
|
||||||
|
onChange={setDefaultModel}
|
||||||
|
/>
|
||||||
|
<SheetSelect
|
||||||
|
label='Reasoning effort'
|
||||||
|
options={[
|
||||||
|
{ label: 'None', value: 'none' },
|
||||||
|
{ label: 'Minimal', value: 'minimal' },
|
||||||
|
{ label: 'Low', value: 'low' },
|
||||||
|
{ label: 'Medium', value: 'medium' },
|
||||||
|
{ label: 'High', value: 'high' },
|
||||||
|
{ label: 'XHigh', value: 'xhigh' },
|
||||||
|
]}
|
||||||
|
value={reasoningEffort}
|
||||||
|
onChange={setReasoningEffort}
|
||||||
|
/>
|
||||||
|
<SwitchRow label='Enabled' value={enabled} onValueChange={setEnabled} />
|
||||||
|
<Button disabled={saving || !models.length} onPress={submit}>
|
||||||
|
{saving ? 'Saving...' : 'Save provider'}
|
||||||
|
</Button>
|
||||||
|
</FormSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { Alert, Linking, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { Badge } from '~/components/ui/badge';
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { Card } from '~/components/ui/card';
|
||||||
|
import { EmptyState } from '~/components/ui/empty-state';
|
||||||
|
|
||||||
|
export const GitHubIntegrationPanel = ({
|
||||||
|
connection,
|
||||||
|
installUrl,
|
||||||
|
loadingRepos,
|
||||||
|
onListRepos,
|
||||||
|
onSync,
|
||||||
|
runtimeStatus,
|
||||||
|
syncing,
|
||||||
|
}: {
|
||||||
|
connection?: {
|
||||||
|
displayName?: string;
|
||||||
|
installationId?: string;
|
||||||
|
status?: string;
|
||||||
|
} | null;
|
||||||
|
installUrl?: string | null;
|
||||||
|
loadingRepos: boolean;
|
||||||
|
onListRepos: () => Promise<string[]>;
|
||||||
|
onSync: () => Promise<void>;
|
||||||
|
runtimeStatus?: { encryptionConfigured?: boolean } | null;
|
||||||
|
syncing: boolean;
|
||||||
|
}) => {
|
||||||
|
const showRepos = async () => {
|
||||||
|
try {
|
||||||
|
const repos = await onListRepos();
|
||||||
|
Alert.alert(
|
||||||
|
'Accessible repositories',
|
||||||
|
repos.slice(0, 20).join('\n') || 'No repositories returned.',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not list repositories.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sync = async () => {
|
||||||
|
try {
|
||||||
|
await onSync();
|
||||||
|
Alert.alert('GitHub synced', 'Installation metadata was refreshed.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not sync GitHub installation.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className='gap-3'>
|
||||||
|
<View className='flex-row items-center justify-between'>
|
||||||
|
<Text className='text-foreground font-semibold'>GitHub App</Text>
|
||||||
|
<Badge
|
||||||
|
label={connection?.status ?? 'not connected'}
|
||||||
|
tone={connection ? 'success' : 'warning'}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{connection ? (
|
||||||
|
<>
|
||||||
|
<Text className='text-muted-foreground text-sm'>
|
||||||
|
{connection.displayName}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-muted-foreground text-xs'>
|
||||||
|
Installation {connection.installationId ?? 'unknown'}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text className='text-muted-foreground text-sm'>
|
||||||
|
Connect GitHub so Spoon can create forks, compare branches, and open
|
||||||
|
draft PRs.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{installUrl ? (
|
||||||
|
<Button onPress={() => void Linking.openURL(installUrl)}>
|
||||||
|
Install or manage GitHub App
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
disabled={syncing}
|
||||||
|
variant='outline'
|
||||||
|
onPress={() => void sync()}
|
||||||
|
>
|
||||||
|
{syncing ? 'Syncing...' : 'Sync installation'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={loadingRepos}
|
||||||
|
variant='outline'
|
||||||
|
onPress={() => void showRepos()}
|
||||||
|
>
|
||||||
|
{loadingRepos ? 'Loading...' : 'List repositories'}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<Text className='text-foreground font-semibold'>Runtime status</Text>
|
||||||
|
<Text className='text-muted-foreground mt-2 text-sm'>
|
||||||
|
Encryption configured:{' '}
|
||||||
|
{runtimeStatus?.encryptionConfigured ? 'yes' : 'not reported'}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
{!connection ? (
|
||||||
|
<EmptyState
|
||||||
|
description='Install the GitHub App, then sync the installation.'
|
||||||
|
title='GitHub is not connected yet'
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { PillTab } from '~/components/ui/pill-tabs';
|
||||||
|
import { PillTabs } from '~/components/ui/pill-tabs';
|
||||||
|
|
||||||
|
export type SpoonDetailSegment =
|
||||||
|
| 'overview'
|
||||||
|
| 'upstream'
|
||||||
|
| 'fork'
|
||||||
|
| 'prs'
|
||||||
|
| 'threads'
|
||||||
|
| 'settings';
|
||||||
|
|
||||||
|
const tabs: PillTab<SpoonDetailSegment>[] = [
|
||||||
|
{ label: 'Overview', value: 'overview' },
|
||||||
|
{ label: 'Upstream', value: 'upstream' },
|
||||||
|
{ label: 'Fork', value: 'fork' },
|
||||||
|
{ label: 'PRs', value: 'prs' },
|
||||||
|
{ label: 'Threads', value: 'threads' },
|
||||||
|
{ label: 'Settings', value: 'settings' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SegmentControl = ({
|
||||||
|
onChange,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
onChange: (value: SpoonDetailSegment) => void;
|
||||||
|
value: SpoonDetailSegment;
|
||||||
|
}) => <PillTabs onChange={onChange} tabs={tabs} value={value} />;
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { Alert, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
|
||||||
|
import { Field } from '~/components/ui/field';
|
||||||
|
import { FormSection } from '~/components/ui/form-section';
|
||||||
|
import { SheetSelect } from '~/components/ui/sheet-select';
|
||||||
|
import { SwitchRow } from '~/components/ui/switch-row';
|
||||||
|
|
||||||
|
type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||||
|
|
||||||
|
type ProviderProfile = {
|
||||||
|
_id: Id<'aiProviderProfiles'>;
|
||||||
|
defaultModel: string;
|
||||||
|
enabled: boolean;
|
||||||
|
isDefault?: boolean;
|
||||||
|
modelOptions?: string[];
|
||||||
|
name: string;
|
||||||
|
reasoningEffort: ReasoningEffort;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SpoonAgentSettingsForm = ({
|
||||||
|
agent,
|
||||||
|
onUpdate,
|
||||||
|
profiles,
|
||||||
|
}: {
|
||||||
|
agent?: {
|
||||||
|
agentModel: string;
|
||||||
|
aiProviderProfileId?: Id<'aiProviderProfiles'>;
|
||||||
|
autoDetectCommands?: boolean;
|
||||||
|
branchPrefix: string;
|
||||||
|
checkCommand?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
envFilePath?: string;
|
||||||
|
installCommand?: string;
|
||||||
|
materializeEnvFileByDefault?: boolean;
|
||||||
|
reasoningEffort: ReasoningEffort;
|
||||||
|
testCommand?: string;
|
||||||
|
};
|
||||||
|
onUpdate: (patch: {
|
||||||
|
agentModel?: string;
|
||||||
|
aiProviderProfileId?: Id<'aiProviderProfiles'>;
|
||||||
|
autoDetectCommands?: boolean;
|
||||||
|
branchPrefix?: string;
|
||||||
|
checkCommand?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
envFilePath?: string;
|
||||||
|
installCommand?: string;
|
||||||
|
materializeEnvFileByDefault?: boolean;
|
||||||
|
reasoningEffort?: ReasoningEffort;
|
||||||
|
testCommand?: string;
|
||||||
|
}) => Promise<void>;
|
||||||
|
profiles: ProviderProfile[];
|
||||||
|
}) => {
|
||||||
|
const enabledProfiles = profiles.filter((profile) => profile.enabled);
|
||||||
|
const selectedProfile =
|
||||||
|
enabledProfiles.find(
|
||||||
|
(profile) => profile._id === agent?.aiProviderProfileId,
|
||||||
|
) ??
|
||||||
|
enabledProfiles.find((profile) => profile.isDefault) ??
|
||||||
|
enabledProfiles[0];
|
||||||
|
const models = Array.from(
|
||||||
|
new Set(
|
||||||
|
selectedProfile
|
||||||
|
? [
|
||||||
|
selectedProfile.defaultModel,
|
||||||
|
...(selectedProfile.modelOptions ?? []),
|
||||||
|
].filter(Boolean)
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const currentModel =
|
||||||
|
models.find((model) => model === agent?.agentModel) ??
|
||||||
|
selectedProfile?.defaultModel ??
|
||||||
|
'';
|
||||||
|
|
||||||
|
const save = (patch: Parameters<typeof onUpdate>[0]) =>
|
||||||
|
void onUpdate(patch).catch((error: unknown) => {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not save agent settings.');
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormSection
|
||||||
|
description='Mobile can configure the runtime, but code editing still happens from the web workspace.'
|
||||||
|
title='Agent settings'
|
||||||
|
>
|
||||||
|
<SwitchRow
|
||||||
|
label='Enabled'
|
||||||
|
value={agent?.enabled ?? true}
|
||||||
|
onValueChange={(enabled) => save({ enabled })}
|
||||||
|
/>
|
||||||
|
<SwitchRow
|
||||||
|
label='Auto-detect commands'
|
||||||
|
value={agent?.autoDetectCommands ?? true}
|
||||||
|
onValueChange={(autoDetectCommands) => save({ autoDetectCommands })}
|
||||||
|
/>
|
||||||
|
<SwitchRow
|
||||||
|
label='Materialize env file'
|
||||||
|
value={agent?.materializeEnvFileByDefault ?? false}
|
||||||
|
onValueChange={(materializeEnvFileByDefault) =>
|
||||||
|
save({ materializeEnvFileByDefault })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SheetSelect
|
||||||
|
disabled={!enabledProfiles.length}
|
||||||
|
label='AI provider'
|
||||||
|
options={
|
||||||
|
enabledProfiles.length
|
||||||
|
? enabledProfiles.map((profile) => ({
|
||||||
|
label: profile.isDefault
|
||||||
|
? `${profile.name} (default)`
|
||||||
|
: profile.name,
|
||||||
|
value: profile._id,
|
||||||
|
}))
|
||||||
|
: [{ label: 'Configure an AI provider in Settings', value: '' }]
|
||||||
|
}
|
||||||
|
value={selectedProfile?._id ?? ''}
|
||||||
|
onChange={(aiProviderProfileId) => {
|
||||||
|
const profile = enabledProfiles.find(
|
||||||
|
(item) => item._id === aiProviderProfileId,
|
||||||
|
);
|
||||||
|
if (profile) {
|
||||||
|
save({
|
||||||
|
agentModel: profile.defaultModel,
|
||||||
|
aiProviderProfileId: profile._id,
|
||||||
|
reasoningEffort: profile.reasoningEffort,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SheetSelect
|
||||||
|
disabled={!models.length}
|
||||||
|
label='Model'
|
||||||
|
options={
|
||||||
|
models.length
|
||||||
|
? models.map((model) => ({ label: model, value: model }))
|
||||||
|
: [{ label: 'No models available', value: '' }]
|
||||||
|
}
|
||||||
|
value={currentModel}
|
||||||
|
onChange={(agentModel) => save({ agentModel })}
|
||||||
|
/>
|
||||||
|
<SheetSelect
|
||||||
|
label='Reasoning effort'
|
||||||
|
options={[
|
||||||
|
{ label: 'None', value: 'none' },
|
||||||
|
{ label: 'Minimal', value: 'minimal' },
|
||||||
|
{ label: 'Low', value: 'low' },
|
||||||
|
{ label: 'Medium', value: 'medium' },
|
||||||
|
{ label: 'High', value: 'high' },
|
||||||
|
{ label: 'XHigh', value: 'xhigh' },
|
||||||
|
]}
|
||||||
|
value={
|
||||||
|
agent?.reasoningEffort ?? selectedProfile?.reasoningEffort ?? 'medium'
|
||||||
|
}
|
||||||
|
onChange={(reasoningEffort) => save({ reasoningEffort })}
|
||||||
|
/>
|
||||||
|
{!enabledProfiles.length ? (
|
||||||
|
<Text className='text-muted-foreground text-sm leading-5'>
|
||||||
|
Configure an AI provider in Settings before queueing agent work.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<View className='gap-3'>
|
||||||
|
<Field
|
||||||
|
label='Branch prefix'
|
||||||
|
value={agent?.branchPrefix ?? 'spoon/agent'}
|
||||||
|
onChangeText={(branchPrefix) => save({ branchPrefix })}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label='Install command'
|
||||||
|
value={agent?.installCommand ?? ''}
|
||||||
|
onChangeText={(installCommand) => save({ installCommand })}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label='Check command'
|
||||||
|
value={agent?.checkCommand ?? ''}
|
||||||
|
onChangeText={(checkCommand) => save({ checkCommand })}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label='Test command'
|
||||||
|
value={agent?.testCommand ?? ''}
|
||||||
|
onChangeText={(testCommand) => save({ testCommand })}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label='Env file path'
|
||||||
|
value={agent?.envFilePath ?? '.env.local'}
|
||||||
|
onChangeText={(envFilePath) => save({ envFilePath })}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</FormSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Linking, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { Card } from '~/components/ui/card';
|
||||||
|
import { EmptyState } from '~/components/ui/empty-state';
|
||||||
|
import { formatDateTime, truncate } from '~/utils/format';
|
||||||
|
|
||||||
|
type Commit = {
|
||||||
|
_id: string;
|
||||||
|
authorLogin?: string;
|
||||||
|
authorName?: string;
|
||||||
|
committedAt?: number;
|
||||||
|
htmlUrl?: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SpoonCommitList = ({
|
||||||
|
commits,
|
||||||
|
emptyDescription,
|
||||||
|
emptyTitle,
|
||||||
|
intro,
|
||||||
|
showOpenButton = false,
|
||||||
|
}: {
|
||||||
|
commits: Commit[];
|
||||||
|
emptyDescription: string;
|
||||||
|
emptyTitle: string;
|
||||||
|
intro?: string;
|
||||||
|
showOpenButton?: boolean;
|
||||||
|
}) => (
|
||||||
|
<View className='gap-3'>
|
||||||
|
{intro ? (
|
||||||
|
<Text className='text-muted-foreground text-sm'>{intro}</Text>
|
||||||
|
) : null}
|
||||||
|
{commits.length ? (
|
||||||
|
commits.map((commit) => (
|
||||||
|
<Card key={commit._id}>
|
||||||
|
<Text className='text-foreground font-medium'>
|
||||||
|
{truncate(commit.message, 100)}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-muted-foreground mt-2 text-xs'>
|
||||||
|
{commit.authorLogin ?? commit.authorName ?? 'unknown'} ·{' '}
|
||||||
|
{formatDateTime(commit.committedAt)}
|
||||||
|
</Text>
|
||||||
|
{showOpenButton && commit.htmlUrl ? (
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
onPress={() => void Linking.openURL(commit.htmlUrl ?? '')}
|
||||||
|
>
|
||||||
|
Open commit
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<EmptyState description={emptyDescription} title={emptyTitle} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { SpoonCommitList } from './spoon-commit-list';
|
||||||
|
|
||||||
|
export const SpoonDetailFork = ({
|
||||||
|
commits,
|
||||||
|
}: {
|
||||||
|
commits: Parameters<typeof SpoonCommitList>[0]['commits'];
|
||||||
|
}) => (
|
||||||
|
<SpoonCommitList
|
||||||
|
commits={commits}
|
||||||
|
emptyDescription='Fork-only commits appear after Spoon compares your fork with upstream.'
|
||||||
|
emptyTitle='No fork-only commits cached'
|
||||||
|
intro='Fork-only commits are customizations Spoon should preserve.'
|
||||||
|
/>
|
||||||
|
);
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { Card } from '~/components/ui/card';
|
||||||
|
import { CopyRow } from '~/components/ui/copy-row';
|
||||||
|
import { MetricCard } from '~/components/ui/metric-card';
|
||||||
|
import { formatDate, titleize } from '~/utils/format';
|
||||||
|
|
||||||
|
type SpoonOverview = {
|
||||||
|
description?: string;
|
||||||
|
forkAheadBy?: number;
|
||||||
|
forkOwner?: string;
|
||||||
|
forkRepo?: string;
|
||||||
|
forkUrl?: string;
|
||||||
|
lastCheckedAt?: number;
|
||||||
|
syncCadence: string;
|
||||||
|
upstreamAheadBy?: number;
|
||||||
|
upstreamOwner: string;
|
||||||
|
upstreamRepo: string;
|
||||||
|
upstreamUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SpoonDetailOverview = ({
|
||||||
|
effectiveUpstreamAheadBy,
|
||||||
|
remotes,
|
||||||
|
spoon,
|
||||||
|
}: {
|
||||||
|
effectiveUpstreamAheadBy: number;
|
||||||
|
remotes: { _id: string; label: string; url: string }[];
|
||||||
|
spoon: SpoonOverview;
|
||||||
|
}) => (
|
||||||
|
<View className='gap-4'>
|
||||||
|
<View className='flex-row gap-3'>
|
||||||
|
<MetricCard label='Raw upstream' value={spoon.upstreamAheadBy ?? 0} />
|
||||||
|
<MetricCard label='Effective' value={effectiveUpstreamAheadBy} />
|
||||||
|
<MetricCard label='Fork-only' value={spoon.forkAheadBy ?? 0} />
|
||||||
|
</View>
|
||||||
|
{spoon.description ? (
|
||||||
|
<Card>
|
||||||
|
<Text className='text-foreground font-semibold'>Description</Text>
|
||||||
|
<Text className='text-muted-foreground mt-2 text-sm leading-5'>
|
||||||
|
{spoon.description}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
<Card>
|
||||||
|
<CopyRow label='Upstream' value={spoon.upstreamUrl} />
|
||||||
|
<CopyRow label='Fork clone URL' value={spoon.forkUrl} />
|
||||||
|
{remotes.map((remote) => (
|
||||||
|
<CopyRow key={remote._id} label={remote.label} value={remote.url} />
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<Text className='text-foreground font-semibold'>Details</Text>
|
||||||
|
<Text className='text-muted-foreground mt-2 text-sm'>
|
||||||
|
Last checked: {formatDate(spoon.lastCheckedAt)}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-muted-foreground mt-1 text-sm'>
|
||||||
|
Cadence: {titleize(spoon.syncCadence)}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-muted-foreground mt-1 text-sm'>
|
||||||
|
Upstream: {spoon.upstreamOwner}/{spoon.upstreamRepo}
|
||||||
|
</Text>
|
||||||
|
{spoon.forkOwner && spoon.forkRepo ? (
|
||||||
|
<Text className='text-muted-foreground mt-1 text-sm'>
|
||||||
|
Fork: {spoon.forkOwner}/{spoon.forkRepo}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { Linking, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { Card } from '~/components/ui/card';
|
||||||
|
import { EmptyState } from '~/components/ui/empty-state';
|
||||||
|
import { titleize } from '~/utils/format';
|
||||||
|
|
||||||
|
type PullRequest = {
|
||||||
|
_id: string;
|
||||||
|
htmlUrl: string;
|
||||||
|
number: number;
|
||||||
|
repoFullName: string;
|
||||||
|
state: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SpoonDetailPrs = ({
|
||||||
|
pullRequests,
|
||||||
|
}: {
|
||||||
|
pullRequests: PullRequest[];
|
||||||
|
}) => (
|
||||||
|
<View className='gap-3'>
|
||||||
|
{pullRequests.length ? (
|
||||||
|
pullRequests.map((pullRequest) => (
|
||||||
|
<Card key={pullRequest._id}>
|
||||||
|
<Text className='text-foreground font-medium'>
|
||||||
|
#{pullRequest.number} {pullRequest.title}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-muted-foreground mt-2 text-xs'>
|
||||||
|
{titleize(pullRequest.state)} · {pullRequest.repoFullName}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
onPress={() => void Linking.openURL(pullRequest.htmlUrl)}
|
||||||
|
>
|
||||||
|
Open PR
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
description='Cached fork and upstream pull requests appear here.'
|
||||||
|
title='No pull requests cached'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { View } from 'react-native';
|
||||||
|
|
||||||
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
|
||||||
|
import { SpoonAgentSettingsForm } from './spoon-agent-settings-form';
|
||||||
|
import { SpoonMaintenanceSettingsForm } from './spoon-maintenance-settings-form';
|
||||||
|
import { SpoonRemotesPanel } from './spoon-remotes-panel';
|
||||||
|
import { SpoonSecretsPanel } from './spoon-secrets-panel';
|
||||||
|
|
||||||
|
type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
||||||
|
|
||||||
|
export const SpoonDetailSettings = ({
|
||||||
|
actions,
|
||||||
|
agentSettings,
|
||||||
|
maintenanceSettings,
|
||||||
|
pending,
|
||||||
|
providerProfiles,
|
||||||
|
remotes,
|
||||||
|
secrets,
|
||||||
|
spoon,
|
||||||
|
}: {
|
||||||
|
actions: {
|
||||||
|
addRemote: (label: string, url: string) => Promise<void>;
|
||||||
|
addSecret: (name: string, value: string) => Promise<void>;
|
||||||
|
importSecrets: (
|
||||||
|
secrets: { name: string; value: string }[],
|
||||||
|
) => Promise<void>;
|
||||||
|
removeRemote: (remoteId: string) => Promise<void>;
|
||||||
|
removeSecret: (secretId: string) => Promise<void>;
|
||||||
|
updateAgent: (patch: Record<string, unknown>) => Promise<void>;
|
||||||
|
updateMaintenance: (patch: Record<string, unknown>) => Promise<void>;
|
||||||
|
updateSpoon: (patch: Record<string, unknown>) => Promise<void>;
|
||||||
|
};
|
||||||
|
agentSettings?: {
|
||||||
|
agentModel: string;
|
||||||
|
aiProviderProfileId?: Id<'aiProviderProfiles'>;
|
||||||
|
autoDetectCommands?: boolean;
|
||||||
|
branchPrefix: string;
|
||||||
|
checkCommand?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
envFilePath?: string;
|
||||||
|
installCommand?: string;
|
||||||
|
materializeEnvFileByDefault?: boolean;
|
||||||
|
reasoningEffort: ReasoningEffort;
|
||||||
|
testCommand?: string;
|
||||||
|
};
|
||||||
|
maintenanceSettings?: {
|
||||||
|
autoRefreshEnabled: boolean;
|
||||||
|
autoReviewEnabled: boolean;
|
||||||
|
autoSyncEnabled: boolean;
|
||||||
|
};
|
||||||
|
pending: {
|
||||||
|
addingRemote: boolean;
|
||||||
|
addingSecret: boolean;
|
||||||
|
importingSecrets: boolean;
|
||||||
|
removingRemoteId?: string;
|
||||||
|
removingSecretId?: string;
|
||||||
|
savingSettings: boolean;
|
||||||
|
};
|
||||||
|
providerProfiles: {
|
||||||
|
_id: Id<'aiProviderProfiles'>;
|
||||||
|
defaultModel: string;
|
||||||
|
enabled: boolean;
|
||||||
|
isDefault?: boolean;
|
||||||
|
modelOptions?: string[];
|
||||||
|
name: string;
|
||||||
|
reasoningEffort: ReasoningEffort;
|
||||||
|
}[];
|
||||||
|
remotes: { _id: string; label: string; url: string }[];
|
||||||
|
secrets: { _id: string; name: string; valuePreview?: string }[];
|
||||||
|
spoon: {
|
||||||
|
maintenanceMode: 'watch' | 'auto_pr' | 'paused';
|
||||||
|
syncCadence: 'daily' | 'weekly' | 'manual';
|
||||||
|
};
|
||||||
|
}) => (
|
||||||
|
<View className='gap-4'>
|
||||||
|
<SpoonMaintenanceSettingsForm
|
||||||
|
maintenance={maintenanceSettings}
|
||||||
|
saving={pending.savingSettings}
|
||||||
|
spoon={spoon}
|
||||||
|
onUpdateMaintenance={actions.updateMaintenance}
|
||||||
|
onUpdateSpoon={actions.updateSpoon}
|
||||||
|
/>
|
||||||
|
<SpoonAgentSettingsForm
|
||||||
|
agent={agentSettings}
|
||||||
|
profiles={providerProfiles}
|
||||||
|
onUpdate={actions.updateAgent}
|
||||||
|
/>
|
||||||
|
<SpoonSecretsPanel
|
||||||
|
adding={pending.addingSecret}
|
||||||
|
importing={pending.importingSecrets}
|
||||||
|
removingId={pending.removingSecretId}
|
||||||
|
secrets={secrets}
|
||||||
|
onAddSecret={actions.addSecret}
|
||||||
|
onImportSecrets={actions.importSecrets}
|
||||||
|
onRemoveSecret={actions.removeSecret}
|
||||||
|
/>
|
||||||
|
<SpoonRemotesPanel
|
||||||
|
adding={pending.addingRemote}
|
||||||
|
remotes={remotes}
|
||||||
|
removingId={pending.removingRemoteId}
|
||||||
|
onAddRemote={actions.addRemote}
|
||||||
|
onRemoveRemote={actions.removeRemote}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { ThreadListRow } from '~/components/threads/thread-list-row';
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { Card } from '~/components/ui/card';
|
||||||
|
import { EmptyState } from '~/components/ui/empty-state';
|
||||||
|
import { Textarea } from '~/components/ui/textarea';
|
||||||
|
|
||||||
|
type Thread = Parameters<typeof ThreadListRow>[0]['thread'];
|
||||||
|
|
||||||
|
export const SpoonDetailThreads = ({
|
||||||
|
creating,
|
||||||
|
onCreate,
|
||||||
|
onOpenThread,
|
||||||
|
prompt,
|
||||||
|
setPrompt,
|
||||||
|
threads,
|
||||||
|
}: {
|
||||||
|
creating: boolean;
|
||||||
|
onCreate: () => void;
|
||||||
|
onOpenThread: (threadId: string) => void;
|
||||||
|
prompt: string;
|
||||||
|
setPrompt: (prompt: string) => void;
|
||||||
|
threads: Thread[];
|
||||||
|
}) => (
|
||||||
|
<View className='gap-3'>
|
||||||
|
<Card className='gap-3'>
|
||||||
|
<Text className='text-foreground font-semibold'>New thread</Text>
|
||||||
|
<Textarea
|
||||||
|
label='Prompt'
|
||||||
|
placeholder='Ask Spoon to review or change this fork...'
|
||||||
|
value={prompt}
|
||||||
|
onChangeText={setPrompt}
|
||||||
|
/>
|
||||||
|
<Button disabled={creating || !prompt.trim()} onPress={onCreate}>
|
||||||
|
{creating ? 'Creating...' : 'Create thread'}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
{threads.length ? (
|
||||||
|
threads.map((thread) => (
|
||||||
|
<ThreadListRow
|
||||||
|
key={thread._id}
|
||||||
|
thread={thread}
|
||||||
|
onPress={() => onOpenThread(thread._id)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
description='Create a thread when this fork needs review or code.'
|
||||||
|
title='No threads yet'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { SpoonCommitList } from './spoon-commit-list';
|
||||||
|
|
||||||
|
export const SpoonDetailUpstream = ({
|
||||||
|
commits,
|
||||||
|
}: {
|
||||||
|
commits: Parameters<typeof SpoonCommitList>[0]['commits'];
|
||||||
|
}) => (
|
||||||
|
<SpoonCommitList
|
||||||
|
commits={commits}
|
||||||
|
emptyDescription='Upstream commits waiting for this fork will appear after refresh.'
|
||||||
|
emptyTitle='No upstream commits cached'
|
||||||
|
showOpenButton
|
||||||
|
/>
|
||||||
|
);
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
|
||||||
|
import { ListRow } from '~/components/ui/list-row';
|
||||||
|
import { formatDate } from '~/utils/format';
|
||||||
|
import { SpoonStatusBadge } from './spoon-status-badge';
|
||||||
|
|
||||||
|
export const SpoonListRow = ({
|
||||||
|
spoon,
|
||||||
|
openThreads,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
spoon: Doc<'spoons'>;
|
||||||
|
openThreads?: number;
|
||||||
|
onPress: () => void;
|
||||||
|
}) => (
|
||||||
|
<ListRow
|
||||||
|
meta={formatDate(spoon.lastCheckedAt)}
|
||||||
|
subtitle={`${spoon.upstreamOwner}/${spoon.upstreamRepo}`}
|
||||||
|
title={spoon.name}
|
||||||
|
onPress={onPress}
|
||||||
|
>
|
||||||
|
<View className='gap-3'>
|
||||||
|
<View className='flex-row flex-wrap items-center gap-2'>
|
||||||
|
<SpoonStatusBadge status={spoon.syncStatus ?? spoon.status} />
|
||||||
|
{spoon.forkOwner && spoon.forkRepo ? (
|
||||||
|
<Text className='text-muted-foreground text-xs'>
|
||||||
|
fork {spoon.forkOwner}/{spoon.forkRepo}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text className='text-muted-foreground text-xs'>missing fork</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View className='flex-row gap-4'>
|
||||||
|
<Text className='text-muted-foreground text-xs'>
|
||||||
|
{spoon.upstreamAheadBy ?? 0} upstream
|
||||||
|
</Text>
|
||||||
|
<Text className='text-muted-foreground text-xs'>
|
||||||
|
{spoon.forkAheadBy ?? 0} fork-only
|
||||||
|
</Text>
|
||||||
|
<Text className='text-muted-foreground text-xs'>
|
||||||
|
{openThreads ?? 0} threads
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ListRow>
|
||||||
|
);
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { Alert, Text } from 'react-native';
|
||||||
|
|
||||||
|
import { FormSection } from '~/components/ui/form-section';
|
||||||
|
import { SheetSelect } from '~/components/ui/sheet-select';
|
||||||
|
import { SwitchRow } from '~/components/ui/switch-row';
|
||||||
|
|
||||||
|
type Cadence = 'daily' | 'weekly' | 'manual';
|
||||||
|
type MaintenanceMode = 'watch' | 'auto_pr' | 'paused';
|
||||||
|
|
||||||
|
export const SpoonMaintenanceSettingsForm = ({
|
||||||
|
maintenance,
|
||||||
|
onUpdateMaintenance,
|
||||||
|
onUpdateSpoon,
|
||||||
|
saving,
|
||||||
|
spoon,
|
||||||
|
}: {
|
||||||
|
maintenance?: {
|
||||||
|
autoRefreshEnabled: boolean;
|
||||||
|
autoReviewEnabled: boolean;
|
||||||
|
autoSyncEnabled: boolean;
|
||||||
|
};
|
||||||
|
onUpdateMaintenance: (patch: {
|
||||||
|
autoRefreshEnabled?: boolean;
|
||||||
|
autoReviewEnabled?: boolean;
|
||||||
|
autoSyncEnabled?: boolean;
|
||||||
|
}) => Promise<void>;
|
||||||
|
onUpdateSpoon: (patch: {
|
||||||
|
maintenanceMode?: MaintenanceMode;
|
||||||
|
syncCadence?: Cadence;
|
||||||
|
}) => Promise<void>;
|
||||||
|
saving: boolean;
|
||||||
|
spoon: { maintenanceMode: MaintenanceMode; syncCadence: Cadence };
|
||||||
|
}) => {
|
||||||
|
const updateMaintenance = (
|
||||||
|
patch: Parameters<typeof onUpdateMaintenance>[0],
|
||||||
|
) =>
|
||||||
|
void onUpdateMaintenance(patch).catch((error: unknown) => {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not save maintenance settings.');
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateSpoon = (patch: Parameters<typeof onUpdateSpoon>[0]) =>
|
||||||
|
void onUpdateSpoon(patch).catch((error: unknown) => {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not save Spoon settings.');
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormSection
|
||||||
|
description='These settings control scheduled checks and safe automation.'
|
||||||
|
title='Maintenance settings'
|
||||||
|
>
|
||||||
|
<SwitchRow
|
||||||
|
description='Let scheduled checks consider this Spoon.'
|
||||||
|
label='Auto refresh'
|
||||||
|
value={maintenance?.autoRefreshEnabled ?? true}
|
||||||
|
onValueChange={(autoRefreshEnabled) =>
|
||||||
|
updateMaintenance({ autoRefreshEnabled })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SwitchRow
|
||||||
|
label='Auto review'
|
||||||
|
value={maintenance?.autoReviewEnabled ?? true}
|
||||||
|
onValueChange={(autoReviewEnabled) =>
|
||||||
|
updateMaintenance({ autoReviewEnabled })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SwitchRow
|
||||||
|
label='Auto sync'
|
||||||
|
value={maintenance?.autoSyncEnabled ?? false}
|
||||||
|
onValueChange={(autoSyncEnabled) =>
|
||||||
|
updateMaintenance({ autoSyncEnabled })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{saving ? (
|
||||||
|
<Text className='text-muted-foreground text-xs'>Saving...</Text>
|
||||||
|
) : null}
|
||||||
|
</FormSection>
|
||||||
|
<FormSection title='Spoon settings'>
|
||||||
|
<SheetSelect
|
||||||
|
label='Cadence'
|
||||||
|
options={[
|
||||||
|
{ label: 'Daily', value: 'daily' },
|
||||||
|
{ label: 'Weekly', value: 'weekly' },
|
||||||
|
{ label: 'Manual', value: 'manual' },
|
||||||
|
]}
|
||||||
|
value={spoon.syncCadence}
|
||||||
|
onChange={(syncCadence) => updateSpoon({ syncCadence })}
|
||||||
|
/>
|
||||||
|
<SheetSelect
|
||||||
|
label='Maintenance mode'
|
||||||
|
options={[
|
||||||
|
{ label: 'Watch', value: 'watch' },
|
||||||
|
{ label: 'Auto PR', value: 'auto_pr' },
|
||||||
|
{ label: 'Paused', value: 'paused' },
|
||||||
|
]}
|
||||||
|
value={spoon.maintenanceMode}
|
||||||
|
onChange={(maintenanceMode) => updateSpoon({ maintenanceMode })}
|
||||||
|
/>
|
||||||
|
</FormSection>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Alert, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { ConfirmButton } from '~/components/ui/confirm-button';
|
||||||
|
import { Field } from '~/components/ui/field';
|
||||||
|
import { FormSection } from '~/components/ui/form-section';
|
||||||
|
|
||||||
|
export const SpoonRemotesPanel = ({
|
||||||
|
adding,
|
||||||
|
onAddRemote,
|
||||||
|
onRemoveRemote,
|
||||||
|
remotes,
|
||||||
|
removingId,
|
||||||
|
}: {
|
||||||
|
adding: boolean;
|
||||||
|
onAddRemote: (label: string, url: string) => Promise<void>;
|
||||||
|
onRemoveRemote: (remoteId: string) => Promise<void>;
|
||||||
|
remotes: { _id: string; label: string; url: string }[];
|
||||||
|
removingId?: string;
|
||||||
|
}) => {
|
||||||
|
const [label, setLabel] = useState('');
|
||||||
|
const [url, setUrl] = useState('');
|
||||||
|
|
||||||
|
const add = async () => {
|
||||||
|
if (!label.trim() || !url.trim()) return;
|
||||||
|
try {
|
||||||
|
await onAddRemote(label.trim(), url.trim());
|
||||||
|
setLabel('');
|
||||||
|
setUrl('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not add remote.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormSection title='Additional remotes'>
|
||||||
|
{remotes.map((remote) => (
|
||||||
|
<View
|
||||||
|
key={remote._id}
|
||||||
|
className='border-border flex-row items-center justify-between gap-3 border-b py-2'
|
||||||
|
>
|
||||||
|
<View className='min-w-0 flex-1'>
|
||||||
|
<Text className='text-foreground font-medium'>{remote.label}</Text>
|
||||||
|
<Text className='text-muted-foreground text-xs'>{remote.url}</Text>
|
||||||
|
</View>
|
||||||
|
<ConfirmButton
|
||||||
|
confirmLabel='Remove'
|
||||||
|
destructive
|
||||||
|
disabled={removingId === remote._id}
|
||||||
|
message={`Remove ${remote.label} from this Spoon?`}
|
||||||
|
title='Remove remote'
|
||||||
|
onConfirm={() => void onRemoveRemote(remote._id)}
|
||||||
|
>
|
||||||
|
{removingId === remote._id ? 'Removing...' : 'Remove'}
|
||||||
|
</ConfirmButton>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
<Field label='Label' value={label} onChangeText={setLabel} />
|
||||||
|
<Field keyboardType='url' label='URL' value={url} onChangeText={setUrl} />
|
||||||
|
<Button disabled={adding || !label.trim() || !url.trim()} onPress={add}>
|
||||||
|
{adding ? 'Adding...' : 'Add remote'}
|
||||||
|
</Button>
|
||||||
|
</FormSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Alert, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { ConfirmButton } from '~/components/ui/confirm-button';
|
||||||
|
import { Field } from '~/components/ui/field';
|
||||||
|
import { FormSection } from '~/components/ui/form-section';
|
||||||
|
import { Textarea } from '~/components/ui/textarea';
|
||||||
|
import { parseEnvText } from '~/utils/env';
|
||||||
|
|
||||||
|
export const SpoonSecretsPanel = ({
|
||||||
|
adding,
|
||||||
|
importing,
|
||||||
|
onAddSecret,
|
||||||
|
onImportSecrets,
|
||||||
|
onRemoveSecret,
|
||||||
|
removingId,
|
||||||
|
secrets,
|
||||||
|
}: {
|
||||||
|
adding: boolean;
|
||||||
|
importing: boolean;
|
||||||
|
onAddSecret: (name: string, value: string) => Promise<void>;
|
||||||
|
onImportSecrets: (
|
||||||
|
secrets: { name: string; value: string }[],
|
||||||
|
) => Promise<void>;
|
||||||
|
onRemoveSecret: (secretId: string) => Promise<void>;
|
||||||
|
removingId?: string;
|
||||||
|
secrets: { _id: string; name: string; valuePreview?: string }[];
|
||||||
|
}) => {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
const [envText, setEnvText] = useState('');
|
||||||
|
const parsed = useMemo(() => parseEnvText(envText), [envText]);
|
||||||
|
const preview = parsed.slice(0, 25);
|
||||||
|
|
||||||
|
const add = async () => {
|
||||||
|
if (!name.trim() || !value.trim()) return;
|
||||||
|
try {
|
||||||
|
await onAddSecret(name.trim(), value);
|
||||||
|
setName('');
|
||||||
|
setValue('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert('Could not save secret.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const importAll = async () => {
|
||||||
|
if (!parsed.length) return;
|
||||||
|
try {
|
||||||
|
await onImportSecrets(parsed);
|
||||||
|
setEnvText('');
|
||||||
|
Alert.alert('Secrets imported', `${parsed.length} secrets were saved.`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Alert.alert(
|
||||||
|
'Could not import every secret',
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Some secrets may have been saved. Review the list and try again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormSection
|
||||||
|
description='Secret values are encrypted and never shown after saving.'
|
||||||
|
title='Secrets'
|
||||||
|
>
|
||||||
|
{secrets.map((secret) => (
|
||||||
|
<View
|
||||||
|
key={secret._id}
|
||||||
|
className='border-border flex-row items-center justify-between gap-3 border-b py-2'
|
||||||
|
>
|
||||||
|
<View className='min-w-0 flex-1'>
|
||||||
|
<Text className='text-foreground font-medium'>{secret.name}</Text>
|
||||||
|
<Text className='text-muted-foreground text-xs'>
|
||||||
|
{secret.valuePreview ?? 'configured'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<ConfirmButton
|
||||||
|
confirmLabel='Remove'
|
||||||
|
destructive
|
||||||
|
disabled={removingId === secret._id}
|
||||||
|
message={`Remove ${secret.name} from this Spoon?`}
|
||||||
|
title='Remove secret'
|
||||||
|
onConfirm={() => void onRemoveSecret(secret._id)}
|
||||||
|
>
|
||||||
|
{removingId === secret._id ? 'Removing...' : 'Remove'}
|
||||||
|
</ConfirmButton>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
<View className='gap-3'>
|
||||||
|
<Text className='text-foreground font-semibold'>Add one secret</Text>
|
||||||
|
<Field label='Name' value={name} onChangeText={setName} />
|
||||||
|
<Field
|
||||||
|
label='Value'
|
||||||
|
secureTextEntry
|
||||||
|
value={value}
|
||||||
|
onChangeText={setValue}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
disabled={adding || !name.trim() || !value.trim()}
|
||||||
|
onPress={add}
|
||||||
|
>
|
||||||
|
{adding ? 'Adding...' : 'Add secret'}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
<View className='gap-3'>
|
||||||
|
<Text className='text-foreground font-semibold'>Import .env</Text>
|
||||||
|
<Textarea
|
||||||
|
label='.env contents'
|
||||||
|
placeholder='AUTH_SECRET=...'
|
||||||
|
value={envText}
|
||||||
|
onChangeText={setEnvText}
|
||||||
|
/>
|
||||||
|
<Text className='text-muted-foreground text-sm'>
|
||||||
|
{parsed.length
|
||||||
|
? `${parsed.length} valid secrets found: ${preview
|
||||||
|
.map((secret) => secret.name)
|
||||||
|
.join(', ')}${parsed.length > preview.length ? ', ...' : ''}`
|
||||||
|
: 'Paste .env contents to preview secret names.'}
|
||||||
|
</Text>
|
||||||
|
<View className='flex-row gap-3'>
|
||||||
|
<Button
|
||||||
|
disabled={importing || !parsed.length}
|
||||||
|
onPress={() => void importAll()}
|
||||||
|
>
|
||||||
|
{importing ? 'Importing...' : 'Import secrets'}
|
||||||
|
</Button>
|
||||||
|
<Button variant='outline' onPress={() => setEnvText('')}>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</FormSection>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Badge } from '~/components/ui/badge';
|
||||||
|
import { titleize } from '~/utils/format';
|
||||||
|
|
||||||
|
const toneForStatus = (status?: string) => {
|
||||||
|
if (status === 'up_to_date' || status === 'active') return 'success';
|
||||||
|
if (status === 'behind' || status === 'diverged' || status === 'conflict') {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
if (status === 'error' || status === 'archived') return 'danger';
|
||||||
|
if (status === 'checking') return 'primary';
|
||||||
|
return 'neutral';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SpoonStatusBadge = ({ status }: { status?: string }) => (
|
||||||
|
<Badge label={titleize(status)} tone={toneForStatus(status)} />
|
||||||
|
);
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
|
||||||
|
import { Badge } from '~/components/ui/badge';
|
||||||
|
import { ListRow } from '~/components/ui/list-row';
|
||||||
|
import { formatDateTime, titleize, truncate } from '~/utils/format';
|
||||||
|
import { ThreadStatusBadge } from './thread-status-badge';
|
||||||
|
|
||||||
|
export const ThreadListRow = ({
|
||||||
|
thread,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
thread: Doc<'threads'>;
|
||||||
|
onPress: () => void;
|
||||||
|
}) => (
|
||||||
|
<ListRow
|
||||||
|
meta={formatDateTime(thread.updatedAt)}
|
||||||
|
subtitle={thread.summary ? truncate(thread.summary, 90) : undefined}
|
||||||
|
title={thread.title}
|
||||||
|
onPress={onPress}
|
||||||
|
>
|
||||||
|
<View className='flex-row flex-wrap gap-2'>
|
||||||
|
<ThreadStatusBadge status={thread.status} />
|
||||||
|
<Badge label={titleize(thread.source)} />
|
||||||
|
{thread.maintenanceOutcome ? (
|
||||||
|
<Badge label={titleize(thread.maintenanceOutcome)} tone='primary' />
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{thread.upstreamTo ? (
|
||||||
|
<Text className='text-muted-foreground mt-3 text-xs'>
|
||||||
|
upstream {thread.upstreamTo.slice(0, 12)}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</ListRow>
|
||||||
|
);
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
|
|
||||||
|
import { titleize } from '~/utils/format';
|
||||||
|
|
||||||
|
export const ThreadMessageList = ({
|
||||||
|
messages,
|
||||||
|
}: {
|
||||||
|
messages: Doc<'threadMessages'>[];
|
||||||
|
}) => (
|
||||||
|
<View className='gap-3'>
|
||||||
|
{messages.map((message) => (
|
||||||
|
<View
|
||||||
|
key={message._id}
|
||||||
|
className={
|
||||||
|
message.role === 'user'
|
||||||
|
? 'border-primary/30 bg-primary/10 rounded-lg border p-3'
|
||||||
|
: 'border-border bg-card rounded-lg border p-3'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text className='text-muted-foreground text-xs'>
|
||||||
|
{titleize(message.role)} · {titleize(message.status)}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-foreground mt-2 leading-5'>
|
||||||
|
{message.content}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Badge } from '~/components/ui/badge';
|
||||||
|
import { titleize } from '~/utils/format';
|
||||||
|
|
||||||
|
const toneForStatus = (status?: string) => {
|
||||||
|
if (status === 'resolved' || status === 'ignored') return 'success';
|
||||||
|
if (status === 'failed' || status === 'cancelled') return 'danger';
|
||||||
|
if (status === 'waiting_for_user' || status === 'changes_ready') {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
if (status === 'running' || status === 'queued') return 'primary';
|
||||||
|
return 'neutral';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ThreadStatusBadge = ({ status }: { status?: string }) => (
|
||||||
|
<Badge label={titleize(status)} tone={toneForStatus(status)} />
|
||||||
|
);
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { PressableProps } from 'react-native';
|
||||||
|
import { Pressable, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
export const ActionRow = ({
|
||||||
|
detail,
|
||||||
|
label,
|
||||||
|
...props
|
||||||
|
}: PressableProps & {
|
||||||
|
detail?: string;
|
||||||
|
label: string;
|
||||||
|
}) => (
|
||||||
|
<Pressable
|
||||||
|
className='border-border min-h-14 flex-row items-center justify-between gap-3 border-b py-3'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<View className='min-w-0 flex-1'>
|
||||||
|
<Text className='text-foreground font-medium'>{label}</Text>
|
||||||
|
{detail ? (
|
||||||
|
<Text className='text-muted-foreground mt-1 text-xs'>{detail}</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<Text className='text-muted-foreground text-lg'>›</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { RefreshControl, ScrollView, View } from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
export const AppScreen = ({
|
||||||
|
children,
|
||||||
|
onRefresh,
|
||||||
|
refreshing = false,
|
||||||
|
scroll = true,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
onRefresh?: () => void;
|
||||||
|
refreshing?: boolean;
|
||||||
|
scroll?: boolean;
|
||||||
|
}) => {
|
||||||
|
if (!scroll) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView className='bg-background flex-1'>
|
||||||
|
<View className='flex-1 p-4'>{children}</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView className='bg-background flex-1'>
|
||||||
|
<ScrollView
|
||||||
|
className='flex-1'
|
||||||
|
contentContainerClassName='gap-4 p-4 pb-10'
|
||||||
|
keyboardShouldPersistTaps='handled'
|
||||||
|
refreshControl={
|
||||||
|
onRefresh ? (
|
||||||
|
<RefreshControl onRefresh={onRefresh} refreshing={refreshing} />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Text } from 'react-native';
|
||||||
|
|
||||||
|
export const Badge = ({
|
||||||
|
label,
|
||||||
|
tone = 'neutral',
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
tone?: 'neutral' | 'primary' | 'success' | 'warning' | 'danger';
|
||||||
|
}) => {
|
||||||
|
const toneClass =
|
||||||
|
tone === 'primary'
|
||||||
|
? 'bg-primary/10 text-primary'
|
||||||
|
: tone === 'success'
|
||||||
|
? 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300'
|
||||||
|
: tone === 'warning'
|
||||||
|
? 'bg-amber-500/10 text-amber-700 dark:text-amber-300'
|
||||||
|
: tone === 'danger'
|
||||||
|
? 'bg-red-500/10 text-red-700 dark:text-red-300'
|
||||||
|
: 'bg-muted text-muted-foreground';
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
className={`self-start rounded-md px-2 py-1 text-xs font-semibold capitalize ${toneClass}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { ComponentProps, ReactNode } from 'react';
|
||||||
|
import { Pressable, Text } from 'react-native';
|
||||||
|
|
||||||
|
export const Button = ({
|
||||||
|
children,
|
||||||
|
onPress,
|
||||||
|
variant = 'primary',
|
||||||
|
disabled = false,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
onPress?: () => void;
|
||||||
|
variant?: 'primary' | 'outline' | 'danger' | 'ghost';
|
||||||
|
disabled?: boolean;
|
||||||
|
} & Omit<ComponentProps<typeof Pressable>, 'children'>) => {
|
||||||
|
const variantClass =
|
||||||
|
variant === 'outline'
|
||||||
|
? 'border-border border bg-transparent'
|
||||||
|
: variant === 'danger'
|
||||||
|
? 'bg-red-600'
|
||||||
|
: variant === 'ghost'
|
||||||
|
? 'bg-transparent'
|
||||||
|
: 'bg-primary';
|
||||||
|
const textClass =
|
||||||
|
variant === 'outline' || variant === 'ghost'
|
||||||
|
? 'text-foreground'
|
||||||
|
: 'text-primary-foreground';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
className={`items-center rounded-md px-4 py-3 disabled:opacity-50 ${variantClass}`}
|
||||||
|
disabled={disabled}
|
||||||
|
onPress={onPress}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Text className={`font-semibold ${textClass}`}>{children}</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { View } from 'react-native';
|
||||||
|
|
||||||
|
export const Card = ({
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) => (
|
||||||
|
<View className={`border-border bg-card rounded-lg border p-4 ${className}`}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Pressable, ScrollView, Text } from 'react-native';
|
||||||
|
|
||||||
|
export const ChipRow = <T extends string>({
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
options: { label: string; value: T }[];
|
||||||
|
value: T;
|
||||||
|
}) => (
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
contentContainerClassName='gap-2'
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{options.map((option) => {
|
||||||
|
const active = option.value === value;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={option.value}
|
||||||
|
className={
|
||||||
|
active
|
||||||
|
? 'bg-primary rounded-md px-3 py-2'
|
||||||
|
: 'bg-muted rounded-md px-3 py-2'
|
||||||
|
}
|
||||||
|
onPress={() => onChange(option.value)}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={
|
||||||
|
active
|
||||||
|
? 'text-primary-foreground text-xs font-semibold'
|
||||||
|
: 'text-muted-foreground text-xs font-semibold'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Alert } from 'react-native';
|
||||||
|
|
||||||
|
import { Button } from './button';
|
||||||
|
|
||||||
|
export const ConfirmButton = ({
|
||||||
|
children,
|
||||||
|
confirmLabel,
|
||||||
|
destructive = false,
|
||||||
|
disabled = false,
|
||||||
|
message,
|
||||||
|
onConfirm,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
children: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
destructive?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
message: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
title: string;
|
||||||
|
}) => (
|
||||||
|
<Button
|
||||||
|
disabled={disabled}
|
||||||
|
variant={destructive ? 'danger' : 'outline'}
|
||||||
|
onPress={() =>
|
||||||
|
Alert.alert(title, message, [
|
||||||
|
{ style: 'cancel', text: 'Cancel' },
|
||||||
|
{
|
||||||
|
onPress: onConfirm,
|
||||||
|
style: destructive ? 'destructive' : 'default',
|
||||||
|
text: confirmLabel,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Alert, Pressable, Text, View } from 'react-native';
|
||||||
|
import * as Clipboard from 'expo-clipboard';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
|
||||||
|
export const CopyRow = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value?: string;
|
||||||
|
}) => {
|
||||||
|
if (!value) return null;
|
||||||
|
const copy = async () => {
|
||||||
|
await Clipboard.setStringAsync(value);
|
||||||
|
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||||
|
Alert.alert('Copied', `${label} copied to clipboard.`);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Pressable className='border-border border-b py-3' onPress={copy}>
|
||||||
|
<Text className='text-muted-foreground text-xs'>{label}</Text>
|
||||||
|
<View className='mt-1 flex-row items-center justify-between gap-3'>
|
||||||
|
<Text className='text-foreground min-w-0 flex-1 text-sm'>{value}</Text>
|
||||||
|
<Text className='text-primary text-sm font-semibold'>Copy</Text>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Text } from 'react-native';
|
||||||
|
|
||||||
|
import { Card } from './card';
|
||||||
|
|
||||||
|
export const EmptyState = ({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}) => (
|
||||||
|
<Card>
|
||||||
|
<Text className='text-foreground font-semibold'>{title}</Text>
|
||||||
|
<Text className='text-muted-foreground mt-2 text-sm leading-5'>
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Text } from 'react-native';
|
||||||
|
|
||||||
|
import { Card } from './card';
|
||||||
|
|
||||||
|
export const ErrorState = ({ message }: { message: string }) => (
|
||||||
|
<Card>
|
||||||
|
<Text className='font-semibold text-red-600'>Something went wrong</Text>
|
||||||
|
<Text className='text-muted-foreground mt-2 text-sm'>{message}</Text>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Text, TextInput, View } from 'react-native';
|
||||||
|
|
||||||
|
export const Field = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChangeText,
|
||||||
|
placeholder,
|
||||||
|
multiline = false,
|
||||||
|
secureTextEntry = false,
|
||||||
|
keyboardType,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChangeText: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
multiline?: boolean;
|
||||||
|
secureTextEntry?: boolean;
|
||||||
|
keyboardType?: 'default' | 'email-address' | 'url';
|
||||||
|
}) => (
|
||||||
|
<View className='gap-2'>
|
||||||
|
<Text className='text-foreground text-sm font-medium'>{label}</Text>
|
||||||
|
<TextInput
|
||||||
|
className='border-input text-foreground rounded-md border px-3 py-3'
|
||||||
|
keyboardType={keyboardType}
|
||||||
|
multiline={multiline}
|
||||||
|
placeholder={placeholder}
|
||||||
|
placeholderTextColor='#64748b'
|
||||||
|
secureTextEntry={secureTextEntry}
|
||||||
|
textAlignVertical={multiline ? 'top' : 'center'}
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Text } from 'react-native';
|
||||||
|
|
||||||
|
import { Card } from './card';
|
||||||
|
|
||||||
|
export const FormSection = ({
|
||||||
|
children,
|
||||||
|
description,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
description?: string;
|
||||||
|
title: string;
|
||||||
|
}) => (
|
||||||
|
<Card className='gap-4'>
|
||||||
|
<Text className='text-foreground font-semibold'>{title}</Text>
|
||||||
|
{description ? (
|
||||||
|
<Text className='text-muted-foreground -mt-2 text-sm leading-5'>
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{children}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { ComponentProps, ReactNode } from 'react';
|
||||||
|
import { Pressable, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
export const ListRow = ({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
meta,
|
||||||
|
children,
|
||||||
|
onPress,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
meta?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
onPress?: () => void;
|
||||||
|
} & Omit<ComponentProps<typeof Pressable>, 'children'>) => (
|
||||||
|
<Pressable
|
||||||
|
className='border-border bg-card rounded-lg border p-4'
|
||||||
|
onPress={onPress}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<View className='flex-row items-start justify-between gap-3'>
|
||||||
|
<View className='min-w-0 flex-1'>
|
||||||
|
<Text className='text-foreground font-semibold'>{title}</Text>
|
||||||
|
{subtitle ? (
|
||||||
|
<Text className='text-muted-foreground mt-1 text-sm'>{subtitle}</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{meta ? (
|
||||||
|
<Text className='text-muted-foreground text-xs'>{meta}</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{children ? <View className='mt-3'>{children}</View> : null}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { Text, View } from 'react-native';
|
||||||
|
|
||||||
|
export const LoadingState = ({ label = 'Loading...' }: { label?: string }) => (
|
||||||
|
<View className='flex-1 items-center justify-center p-6'>
|
||||||
|
<Text className='text-muted-foreground'>{label}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Text } from 'react-native';
|
||||||
|
|
||||||
|
import { Card } from './card';
|
||||||
|
|
||||||
|
export const MetricCard = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
note,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
note?: string;
|
||||||
|
}) => (
|
||||||
|
<Card className='flex-1'>
|
||||||
|
<Text className='text-muted-foreground text-xs'>{label}</Text>
|
||||||
|
<Text className='text-foreground mt-2 text-2xl font-bold'>{value}</Text>
|
||||||
|
{note ? (
|
||||||
|
<Text className='text-muted-foreground mt-1 text-xs'>{note}</Text>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Pressable, ScrollView, Text } from 'react-native';
|
||||||
|
|
||||||
|
export type PillTab<T extends string> = {
|
||||||
|
badge?: number | string;
|
||||||
|
label: string;
|
||||||
|
value: T;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PillTabs = <T extends string>({
|
||||||
|
onChange,
|
||||||
|
tabs,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
tabs: PillTab<T>[];
|
||||||
|
value: T;
|
||||||
|
}) => (
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
className='-mx-1'
|
||||||
|
contentContainerClassName='gap-2 px-1'
|
||||||
|
keyboardShouldPersistTaps='handled'
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const active = tab.value === value;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={tab.value}
|
||||||
|
className={
|
||||||
|
active
|
||||||
|
? 'bg-primary min-h-9 flex-row items-center gap-2 rounded-md px-3'
|
||||||
|
: 'bg-muted min-h-9 flex-row items-center gap-2 rounded-md px-3'
|
||||||
|
}
|
||||||
|
onPress={() => onChange(tab.value)}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={
|
||||||
|
active
|
||||||
|
? 'text-primary-foreground text-xs font-semibold'
|
||||||
|
: 'text-muted-foreground text-xs font-semibold'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</Text>
|
||||||
|
{tab.badge === undefined ? null : (
|
||||||
|
<Text
|
||||||
|
className={
|
||||||
|
active
|
||||||
|
? 'text-primary-foreground text-xs'
|
||||||
|
: 'text-muted-foreground text-xs'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{tab.badge}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Pressable, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
export type RadioOption<T extends string> = {
|
||||||
|
description?: string;
|
||||||
|
label: string;
|
||||||
|
value: T;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RadioList = <T extends string>({
|
||||||
|
label,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
options: RadioOption<T>[];
|
||||||
|
value: T;
|
||||||
|
}) => (
|
||||||
|
<View className='gap-2'>
|
||||||
|
<Text className='text-muted-foreground text-xs font-medium'>{label}</Text>
|
||||||
|
<View className='gap-2'>
|
||||||
|
{options.map((option) => {
|
||||||
|
const active = option.value === value;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={option.value}
|
||||||
|
className={
|
||||||
|
active
|
||||||
|
? 'border-primary bg-primary/10 rounded-md border p-3'
|
||||||
|
: 'border-border rounded-md border p-3'
|
||||||
|
}
|
||||||
|
onPress={() => onChange(option.value)}
|
||||||
|
>
|
||||||
|
<Text className='text-foreground font-medium'>{option.label}</Text>
|
||||||
|
{option.description ? (
|
||||||
|
<Text className='text-muted-foreground mt-1 text-xs'>
|
||||||
|
{option.description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Modal, Pressable, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { Button } from './button';
|
||||||
|
|
||||||
|
export type SheetSelectOption<T extends string> = {
|
||||||
|
description?: string;
|
||||||
|
label: string;
|
||||||
|
value: T;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SheetSelect = <T extends string>({
|
||||||
|
disabled = false,
|
||||||
|
label,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
disabled?: boolean;
|
||||||
|
label: string;
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
options: SheetSelectOption<T>[];
|
||||||
|
value: T;
|
||||||
|
}) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const selected = options.find((option) => option.value === value);
|
||||||
|
|
||||||
|
const choose = (nextValue: T) => {
|
||||||
|
onChange(nextValue);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='gap-2'>
|
||||||
|
<Text className='text-muted-foreground text-xs font-medium'>{label}</Text>
|
||||||
|
<Pressable
|
||||||
|
className={
|
||||||
|
disabled
|
||||||
|
? 'border-border bg-muted/50 rounded-md border px-3 py-3 opacity-60'
|
||||||
|
: 'border-border bg-background rounded-md border px-3 py-3'
|
||||||
|
}
|
||||||
|
disabled={disabled}
|
||||||
|
onPress={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<Text className='text-foreground font-medium'>
|
||||||
|
{selected?.label ?? 'Select'}
|
||||||
|
</Text>
|
||||||
|
{selected?.description ? (
|
||||||
|
<Text className='text-muted-foreground mt-1 text-xs'>
|
||||||
|
{selected.description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Pressable>
|
||||||
|
<Modal
|
||||||
|
animationType='slide'
|
||||||
|
onRequestClose={() => setOpen(false)}
|
||||||
|
transparent
|
||||||
|
visible={open}
|
||||||
|
>
|
||||||
|
<View className='flex-1 justify-end bg-black/40'>
|
||||||
|
<View className='bg-background border-border max-h-[80%] gap-3 rounded-t-lg border-t p-4'>
|
||||||
|
<View className='flex-row items-center justify-between'>
|
||||||
|
<Text className='text-foreground text-lg font-semibold'>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Button variant='ghost' onPress={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
<View className='gap-2'>
|
||||||
|
{options.map((option) => {
|
||||||
|
const active = option.value === value;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={option.value}
|
||||||
|
className={
|
||||||
|
active
|
||||||
|
? 'border-primary bg-primary/10 rounded-md border p-3'
|
||||||
|
: 'border-border rounded-md border p-3'
|
||||||
|
}
|
||||||
|
onPress={() => choose(option.value)}
|
||||||
|
>
|
||||||
|
<Text className='text-foreground font-medium'>
|
||||||
|
{option.label}
|
||||||
|
</Text>
|
||||||
|
{option.description ? (
|
||||||
|
<Text className='text-muted-foreground mt-1 text-xs'>
|
||||||
|
{option.description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Switch, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
export const SwitchRow = ({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
value: boolean;
|
||||||
|
onValueChange: (value: boolean) => void;
|
||||||
|
}) => (
|
||||||
|
<View className='border-border flex-row items-center justify-between gap-4 border-b py-3'>
|
||||||
|
<View className='min-w-0 flex-1'>
|
||||||
|
<Text className='text-foreground font-medium'>{label}</Text>
|
||||||
|
{description ? (
|
||||||
|
<Text className='text-muted-foreground mt-1 text-xs'>
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<Switch value={value} onValueChange={onValueChange} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { TextInputProps } from 'react-native';
|
||||||
|
import { Text, TextInput, View } from 'react-native';
|
||||||
|
|
||||||
|
export const Textarea = ({
|
||||||
|
label,
|
||||||
|
...props
|
||||||
|
}: TextInputProps & { label: string }) => (
|
||||||
|
<View className='gap-2'>
|
||||||
|
<Text className='text-muted-foreground text-xs font-medium'>{label}</Text>
|
||||||
|
<TextInput
|
||||||
|
className='border-border bg-background text-foreground min-h-28 rounded-md border px-3 py-3 align-top'
|
||||||
|
multiline
|
||||||
|
placeholderTextColor='#71717a'
|
||||||
|
textAlignVertical='top'
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
|
||||||
|
export const DiffPreview = ({
|
||||||
|
content,
|
||||||
|
initialLines = 120,
|
||||||
|
}: {
|
||||||
|
content: string;
|
||||||
|
initialLines?: number;
|
||||||
|
}) => {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const visibleLines = expanded ? lines : lines.slice(0, initialLines);
|
||||||
|
const hiddenCount = Math.max(lines.length - visibleLines.length, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='gap-3 rounded-lg bg-zinc-950 p-3'>
|
||||||
|
<View>
|
||||||
|
{visibleLines.map((line, index) => {
|
||||||
|
const color = line.startsWith('+')
|
||||||
|
? 'text-emerald-300'
|
||||||
|
: line.startsWith('-')
|
||||||
|
? 'text-red-300'
|
||||||
|
: line.startsWith('@@')
|
||||||
|
? 'text-sky-300'
|
||||||
|
: 'text-zinc-100';
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
key={`${index}-${line.slice(0, 12)}`}
|
||||||
|
className={`font-mono text-xs leading-5 ${color}`}
|
||||||
|
>
|
||||||
|
{line || ' '}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
{hiddenCount > 0 ? (
|
||||||
|
<Button variant='outline' onPress={() => setExpanded(true)}>
|
||||||
|
Show {hiddenCount} more lines
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { Text, View } from 'react-native';
|
||||||
|
import * as Clipboard from 'expo-clipboard';
|
||||||
|
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { Card } from '~/components/ui/card';
|
||||||
|
import { DiffPreview } from './diff-preview';
|
||||||
|
|
||||||
|
type Artifact = {
|
||||||
|
_id: string;
|
||||||
|
content: string;
|
||||||
|
contentType: string;
|
||||||
|
kind: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WorkspaceArtifacts = ({
|
||||||
|
artifacts,
|
||||||
|
mode,
|
||||||
|
}: {
|
||||||
|
artifacts: Artifact[];
|
||||||
|
mode: 'diffs' | 'artifacts';
|
||||||
|
}) => {
|
||||||
|
const diffArtifacts = artifacts.filter(
|
||||||
|
(artifact) =>
|
||||||
|
artifact.contentType === 'text/x-diff' || artifact.kind === 'diff',
|
||||||
|
);
|
||||||
|
const visible =
|
||||||
|
mode === 'diffs'
|
||||||
|
? diffArtifacts
|
||||||
|
: artifacts.filter((artifact) => !diffArtifacts.includes(artifact));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className='gap-3'>
|
||||||
|
<Text className='text-foreground font-semibold'>
|
||||||
|
{mode === 'diffs' ? 'Diffs' : 'Artifacts'}
|
||||||
|
</Text>
|
||||||
|
{visible.length ? (
|
||||||
|
visible.map((artifact) => (
|
||||||
|
<View key={artifact._id} className='gap-2'>
|
||||||
|
<Text className='text-muted-foreground text-sm'>
|
||||||
|
{artifact.title}
|
||||||
|
</Text>
|
||||||
|
{mode === 'diffs' ? (
|
||||||
|
<DiffPreview content={artifact.content} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text className='bg-muted text-foreground rounded-md p-3 font-mono text-xs leading-5'>
|
||||||
|
{artifact.content.slice(0, 2_000)}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
onPress={() =>
|
||||||
|
void Clipboard.setStringAsync(artifact.content)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Copy artifact
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text className='text-muted-foreground text-sm'>
|
||||||
|
{mode === 'diffs'
|
||||||
|
? 'Diff artifacts will appear here when the worker records them.'
|
||||||
|
: 'No non-diff artifacts recorded.'}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { Card } from '~/components/ui/card';
|
||||||
|
import { ChipRow } from '~/components/ui/chip-row';
|
||||||
|
import { formatDateTime, titleize } from '~/utils/format';
|
||||||
|
|
||||||
|
type Event = {
|
||||||
|
_id: string;
|
||||||
|
createdAt: number;
|
||||||
|
level: string;
|
||||||
|
message: string;
|
||||||
|
phase: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WorkspaceEvents = ({ events }: { events: Event[] }) => {
|
||||||
|
const [level, setLevel] = useState<'all' | 'info' | 'warn' | 'error'>('all');
|
||||||
|
const filtered =
|
||||||
|
level === 'all' ? events : events.filter((event) => event.level === level);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className='gap-3'>
|
||||||
|
<Text className='text-foreground font-semibold'>Events</Text>
|
||||||
|
<ChipRow
|
||||||
|
options={[
|
||||||
|
{ label: 'All', value: 'all' },
|
||||||
|
{ label: 'Info', value: 'info' },
|
||||||
|
{ label: 'Warn', value: 'warn' },
|
||||||
|
{ label: 'Error', value: 'error' },
|
||||||
|
]}
|
||||||
|
value={level}
|
||||||
|
onChange={setLevel}
|
||||||
|
/>
|
||||||
|
{filtered.length ? (
|
||||||
|
filtered.map((event) => (
|
||||||
|
<View key={event._id} className='border-border border-b pb-2'>
|
||||||
|
<Text className='text-muted-foreground text-xs'>
|
||||||
|
{formatDateTime(event.createdAt)} · {titleize(event.phase)} ·{' '}
|
||||||
|
{titleize(event.level)}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-foreground mt-1'>{event.message}</Text>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text className='text-muted-foreground text-sm'>No events.</Text>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { Card } from '~/components/ui/card';
|
||||||
|
import { titleize } from '~/utils/format';
|
||||||
|
|
||||||
|
export const WorkspaceMessages = ({
|
||||||
|
messages,
|
||||||
|
}: {
|
||||||
|
messages: { _id: string; content: string; role: string; status: string }[];
|
||||||
|
}) => (
|
||||||
|
<Card className='gap-3'>
|
||||||
|
<Text className='text-foreground font-semibold'>Messages</Text>
|
||||||
|
{messages.length ? (
|
||||||
|
messages.map((message) => (
|
||||||
|
<View key={message._id} className='border-border border-b pb-2'>
|
||||||
|
<Text className='text-muted-foreground text-xs'>
|
||||||
|
{titleize(message.role)} · {titleize(message.status)}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-foreground mt-1'>{message.content}</Text>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text className='text-muted-foreground text-sm'>No messages yet.</Text>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { Linking, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { Badge } from '~/components/ui/badge';
|
||||||
|
import { Button } from '~/components/ui/button';
|
||||||
|
import { Card } from '~/components/ui/card';
|
||||||
|
import { ConfirmButton } from '~/components/ui/confirm-button';
|
||||||
|
import { CopyRow } from '~/components/ui/copy-row';
|
||||||
|
import { formatDateTime, titleize } from '~/utils/format';
|
||||||
|
|
||||||
|
export const WorkspaceSummary = ({
|
||||||
|
cancelling,
|
||||||
|
job,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
cancelling: boolean;
|
||||||
|
job: {
|
||||||
|
completedAt?: number;
|
||||||
|
model: string;
|
||||||
|
pullRequestUrl?: string;
|
||||||
|
reasoningEffort: string;
|
||||||
|
startedAt?: number;
|
||||||
|
status: string;
|
||||||
|
workBranch: string;
|
||||||
|
workspaceStatus?: string;
|
||||||
|
};
|
||||||
|
onCancel: () => void;
|
||||||
|
}) => (
|
||||||
|
<Card className='gap-3'>
|
||||||
|
<View className='flex-row flex-wrap gap-2'>
|
||||||
|
<Badge label={titleize(job.status)} tone='primary' />
|
||||||
|
<Badge label={titleize(job.workspaceStatus ?? 'not_started')} />
|
||||||
|
</View>
|
||||||
|
<Text className='text-muted-foreground text-sm'>
|
||||||
|
Branch: {job.workBranch}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-muted-foreground text-sm'>Model: {job.model}</Text>
|
||||||
|
<Text className='text-muted-foreground text-sm'>
|
||||||
|
Reasoning: {titleize(job.reasoningEffort)}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-muted-foreground text-sm'>
|
||||||
|
Started: {formatDateTime(job.startedAt)}
|
||||||
|
</Text>
|
||||||
|
{job.completedAt ? (
|
||||||
|
<Text className='text-muted-foreground text-sm'>
|
||||||
|
Completed: {formatDateTime(job.completedAt)}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<CopyRow label='Draft PR' value={job.pullRequestUrl} />
|
||||||
|
{job.pullRequestUrl ? (
|
||||||
|
<Button onPress={() => void Linking.openURL(job.pullRequestUrl ?? '')}>
|
||||||
|
Open draft PR
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<ConfirmButton
|
||||||
|
confirmLabel='Cancel job'
|
||||||
|
destructive
|
||||||
|
disabled={
|
||||||
|
cancelling ||
|
||||||
|
['cancelled', 'draft_pr_opened', 'failed'].includes(job.status)
|
||||||
|
}
|
||||||
|
message='Cancel this workspace job? Running work will be stopped where possible.'
|
||||||
|
title='Cancel job'
|
||||||
|
onConfirm={onCancel}
|
||||||
|
>
|
||||||
|
{cancelling ? 'Cancelling...' : 'Cancel job'}
|
||||||
|
</ConfirmButton>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export type ParsedEnvSecret = {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseEnvText = (text: string): ParsedEnvSecret[] => {
|
||||||
|
const secrets: ParsedEnvSecret[] = [];
|
||||||
|
for (const rawLine of text.split(/\r?\n/)) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line || line.startsWith('#')) continue;
|
||||||
|
const normalized = line.startsWith('export ') ? line.slice(7).trim() : line;
|
||||||
|
const separator = normalized.indexOf('=');
|
||||||
|
if (separator <= 0) continue;
|
||||||
|
const name = normalized.slice(0, separator).trim();
|
||||||
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) continue;
|
||||||
|
let value = normalized.slice(separator + 1).trim();
|
||||||
|
if (
|
||||||
|
(value.startsWith('"') && value.endsWith('"')) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))
|
||||||
|
) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
secrets.push({ name: name.toUpperCase(), value });
|
||||||
|
}
|
||||||
|
return secrets;
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export const formatDate = (value?: number) =>
|
||||||
|
value
|
||||||
|
? new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(value)
|
||||||
|
: 'Never';
|
||||||
|
|
||||||
|
export const formatDateTime = (value?: number) =>
|
||||||
|
value
|
||||||
|
? new Intl.DateTimeFormat('en', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
}).format(value)
|
||||||
|
: 'Never';
|
||||||
|
|
||||||
|
export const titleize = (value?: string) =>
|
||||||
|
value?.replaceAll('_', ' ') ?? 'unknown';
|
||||||
|
|
||||||
|
export const truncate = (value: string, length = 80) =>
|
||||||
|
value.length > length ? `${value.slice(0, length - 3)}...` : value;
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { AiProviderProfileForm } from '../../src/components/settings/ai-provider-profile-form';
|
||||||
|
import { SpoonAgentSettingsForm } from '../../src/components/spoons/spoon-agent-settings-form';
|
||||||
|
import { SpoonSecretsPanel } from '../../src/components/spoons/spoon-secrets-panel';
|
||||||
|
|
||||||
|
describe('mobile forms', () => {
|
||||||
|
test('SpoonSecretsPanel previews secret names only and imports parsed env values', async () => {
|
||||||
|
const onImportSecrets = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SpoonSecretsPanel
|
||||||
|
adding={false}
|
||||||
|
importing={false}
|
||||||
|
removingId={undefined}
|
||||||
|
secrets={[]}
|
||||||
|
onAddSecret={vi.fn()}
|
||||||
|
onImportSecrets={onImportSecrets}
|
||||||
|
onRemoveSecret={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText('AUTH_SECRET=...'), {
|
||||||
|
target: {
|
||||||
|
value: 'AUTH_SECRET=super-secret\nexport AUTHENTIK_CLIENT_ID=client',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getAllByText(/AUTH_SECRET/).length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getAllByText(/AUTHENTIK_CLIENT_ID/).length).toBeGreaterThan(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/valid secrets found/).textContent).not.toContain(
|
||||||
|
'super-secret',
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Import secrets'));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(onImportSecrets).toHaveBeenCalledWith([
|
||||||
|
{ name: 'AUTH_SECRET', value: 'super-secret' },
|
||||||
|
{ name: 'AUTHENTIK_CLIENT_ID', value: 'client' },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SpoonSecretsPanel disables import with no parsed secrets', () => {
|
||||||
|
render(
|
||||||
|
<SpoonSecretsPanel
|
||||||
|
adding={false}
|
||||||
|
importing={false}
|
||||||
|
removingId={undefined}
|
||||||
|
secrets={[]}
|
||||||
|
onAddSecret={vi.fn()}
|
||||||
|
onImportSecrets={vi.fn()}
|
||||||
|
onRemoveSecret={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Import secrets').closest('button')).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AiProviderProfileForm selects default model from model options', async () => {
|
||||||
|
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AiProviderProfileForm
|
||||||
|
saving={false}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
existing={{
|
||||||
|
_id: 'profile' as never,
|
||||||
|
authType: 'api_key',
|
||||||
|
defaultModel: 'gpt-5.1-codex',
|
||||||
|
enabled: true,
|
||||||
|
modelOptions: ['gpt-5.1-codex', 'gpt-5.5'],
|
||||||
|
name: 'OpenAI',
|
||||||
|
provider: 'openai',
|
||||||
|
reasoningEffort: 'medium',
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('gpt-5.1-codex'));
|
||||||
|
fireEvent.click(screen.getByText('gpt-5.5'));
|
||||||
|
fireEvent.click(screen.getByText('Save provider'));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ defaultModel: 'gpt-5.5' }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AiProviderProfileForm shows Codex auth JSON instructions', () => {
|
||||||
|
render(
|
||||||
|
<AiProviderProfileForm
|
||||||
|
saving={false}
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
existing={{
|
||||||
|
_id: 'profile' as never,
|
||||||
|
authType: 'opencode_auth_json',
|
||||||
|
defaultModel: 'gpt-5.1-codex',
|
||||||
|
enabled: true,
|
||||||
|
modelOptions: ['gpt-5.1-codex'],
|
||||||
|
name: 'Codex',
|
||||||
|
provider: 'opencode_openai_login',
|
||||||
|
reasoningEffort: 'medium',
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/~\/.codex\/auth.json/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SpoonAgentSettingsForm disables provider/model controls without provider profiles', () => {
|
||||||
|
render(
|
||||||
|
<SpoonAgentSettingsForm
|
||||||
|
profiles={[]}
|
||||||
|
onUpdate={vi.fn()}
|
||||||
|
agent={{
|
||||||
|
agentModel: '',
|
||||||
|
autoDetectCommands: true,
|
||||||
|
branchPrefix: 'spoon/agent',
|
||||||
|
enabled: true,
|
||||||
|
materializeEnvFileByDefault: false,
|
||||||
|
reasoningEffort: 'medium',
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText('Configure an AI provider in Settings'),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
screen.getByText('No models available').closest('button'),
|
||||||
|
).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SpoonAgentSettingsForm applies selected provider defaults', async () => {
|
||||||
|
const onUpdate = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SpoonAgentSettingsForm
|
||||||
|
agent={{
|
||||||
|
agentModel: 'gpt-5.1-codex',
|
||||||
|
autoDetectCommands: true,
|
||||||
|
branchPrefix: 'spoon/agent',
|
||||||
|
enabled: true,
|
||||||
|
materializeEnvFileByDefault: false,
|
||||||
|
reasoningEffort: 'high',
|
||||||
|
}}
|
||||||
|
profiles={[
|
||||||
|
{
|
||||||
|
_id: 'profile-a' as never,
|
||||||
|
defaultModel: 'gpt-5.1-codex',
|
||||||
|
enabled: true,
|
||||||
|
modelOptions: ['gpt-5.1-codex'],
|
||||||
|
name: 'OpenAI',
|
||||||
|
reasoningEffort: 'medium',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_id: 'profile-b' as never,
|
||||||
|
defaultModel: 'claude-sonnet-4-5',
|
||||||
|
enabled: true,
|
||||||
|
modelOptions: ['claude-sonnet-4-5'],
|
||||||
|
name: 'Anthropic',
|
||||||
|
reasoningEffort: 'low',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('OpenAI'));
|
||||||
|
fireEvent.click(screen.getByText('Anthropic'));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(onUpdate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
agentModel: 'claude-sonnet-4-5',
|
||||||
|
reasoningEffort: 'low',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import DashboardRoute from '../../src/app/(app)/dashboard';
|
||||||
|
import SettingsRoute from '../../src/app/(app)/settings';
|
||||||
|
import SpoonsRoute from '../../src/app/(app)/spoons';
|
||||||
|
import ThreadsRoute from '../../src/app/(app)/threads';
|
||||||
|
import WorkspaceRoute from '../../src/app/(app)/workspace/[jobId]';
|
||||||
|
import { mockedUseQuery } from '../setup';
|
||||||
|
|
||||||
|
describe('mobile route smoke tests', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockedUseQuery.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Dashboard renders metrics from mocked Convex data', () => {
|
||||||
|
mockedUseQuery
|
||||||
|
.mockReturnValueOnce([
|
||||||
|
{
|
||||||
|
_id: 'spoon-1',
|
||||||
|
status: 'active',
|
||||||
|
syncStatus: 'behind',
|
||||||
|
upstreamAheadBy: 3,
|
||||||
|
},
|
||||||
|
] as never)
|
||||||
|
.mockReturnValueOnce([] as never)
|
||||||
|
.mockReturnValueOnce([
|
||||||
|
{
|
||||||
|
_id: 'thread-1',
|
||||||
|
source: 'user_request',
|
||||||
|
status: 'open',
|
||||||
|
title: 'Update auth',
|
||||||
|
updatedAt: Date.UTC(2026, 0, 1),
|
||||||
|
},
|
||||||
|
] as never);
|
||||||
|
|
||||||
|
render(<DashboardRoute />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Dashboard')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Update auth')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Upstream commits')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Spoons list renders empty state and one row', () => {
|
||||||
|
mockedUseQuery
|
||||||
|
.mockReturnValueOnce([
|
||||||
|
{
|
||||||
|
_id: 'spoon-1',
|
||||||
|
forkOwner: 'gib',
|
||||||
|
forkRepo: 'usesend',
|
||||||
|
name: 'usesend-authentik',
|
||||||
|
status: 'active',
|
||||||
|
syncStatus: 'up_to_date',
|
||||||
|
upstreamAheadBy: 0,
|
||||||
|
upstreamOwner: 'usesend',
|
||||||
|
upstreamRepo: 'usesend',
|
||||||
|
},
|
||||||
|
] as never)
|
||||||
|
.mockReturnValueOnce([] as never);
|
||||||
|
|
||||||
|
render(<SpoonsRoute />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Spoons')).toBeTruthy();
|
||||||
|
expect(screen.getByText('usesend-authentik')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Threads list renders filters and rows', () => {
|
||||||
|
mockedUseQuery.mockReturnValueOnce([
|
||||||
|
{
|
||||||
|
_id: 'thread-1',
|
||||||
|
source: 'upstream_update',
|
||||||
|
status: 'waiting_for_user',
|
||||||
|
title: 'Upstream auth changes landed',
|
||||||
|
updatedAt: Date.UTC(2026, 0, 1),
|
||||||
|
},
|
||||||
|
] as never);
|
||||||
|
|
||||||
|
render(<ThreadsRoute />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Waiting')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Upstream auth changes landed')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Workspace route renders tabs and job status', () => {
|
||||||
|
mockedUseQuery
|
||||||
|
.mockReturnValueOnce({
|
||||||
|
_id: 'job-1',
|
||||||
|
model: 'gpt-5.1-codex',
|
||||||
|
reasoningEffort: 'medium',
|
||||||
|
status: 'running',
|
||||||
|
workBranch: 'spoon/thread/example',
|
||||||
|
workspaceStatus: 'active',
|
||||||
|
} as never)
|
||||||
|
.mockReturnValueOnce([] as never)
|
||||||
|
.mockReturnValueOnce([] as never)
|
||||||
|
.mockReturnValueOnce([] as never);
|
||||||
|
|
||||||
|
render(<WorkspaceRoute />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Workspace review')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Messages')).toBeTruthy();
|
||||||
|
expect(screen.getByText('running')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Settings index renders GitHub and AI provider summaries', () => {
|
||||||
|
mockedUseQuery
|
||||||
|
.mockReturnValueOnce({ email: 'gib@example.com' } as never)
|
||||||
|
.mockReturnValueOnce({
|
||||||
|
displayName: 'gibbyb',
|
||||||
|
status: 'active',
|
||||||
|
} as never)
|
||||||
|
.mockReturnValueOnce([
|
||||||
|
{
|
||||||
|
_id: 'provider-1',
|
||||||
|
isDefault: true,
|
||||||
|
name: 'Codex',
|
||||||
|
},
|
||||||
|
] as never);
|
||||||
|
|
||||||
|
render(<SettingsRoute />);
|
||||||
|
|
||||||
|
expect(screen.getByText('gib@example.com')).toBeTruthy();
|
||||||
|
expect(screen.getByText('GitHub connected as gibbyb')).toBeTruthy();
|
||||||
|
expect(screen.getByText('1 provider, default Codex')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { Alert } from 'react-native';
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { SpoonStatusBadge } from '../../src/components/spoons/spoon-status-badge';
|
||||||
|
import { ThreadStatusBadge } from '../../src/components/threads/thread-status-badge';
|
||||||
|
import { ConfirmButton } from '../../src/components/ui/confirm-button';
|
||||||
|
import { PillTabs } from '../../src/components/ui/pill-tabs';
|
||||||
|
import { SheetSelect } from '../../src/components/ui/sheet-select';
|
||||||
|
import { DiffPreview } from '../../src/components/workspace/diff-preview';
|
||||||
|
|
||||||
|
describe('mobile UI primitives', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PillTabs renders labels and changes selection', () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<PillTabs
|
||||||
|
tabs={[
|
||||||
|
{ label: 'Overview', value: 'overview' },
|
||||||
|
{ label: 'Settings', value: 'settings' },
|
||||||
|
]}
|
||||||
|
value='overview'
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Settings'));
|
||||||
|
|
||||||
|
expect(screen.getByText('Overview')).toBeTruthy();
|
||||||
|
expect(onChange).toHaveBeenCalledWith('settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SheetSelect opens and chooses an option', () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SheetSelect
|
||||||
|
label='Provider'
|
||||||
|
options={[
|
||||||
|
{ label: 'OpenAI', value: 'openai' },
|
||||||
|
{ label: 'Anthropic', value: 'anthropic' },
|
||||||
|
]}
|
||||||
|
value='openai'
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('OpenAI'));
|
||||||
|
fireEvent.click(screen.getByText('Anthropic'));
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith('anthropic');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SheetSelect respects disabled state', () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SheetSelect
|
||||||
|
disabled
|
||||||
|
label='Provider'
|
||||||
|
options={[{ label: 'OpenAI', value: 'openai' }]}
|
||||||
|
value='openai'
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('OpenAI'));
|
||||||
|
|
||||||
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ConfirmButton delegates confirmation to Alert', () => {
|
||||||
|
const onConfirm = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ConfirmButton
|
||||||
|
confirmLabel='Delete'
|
||||||
|
message='Delete this?'
|
||||||
|
title='Delete'
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</ConfirmButton>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Remove'));
|
||||||
|
const calls = vi.mocked(Alert.alert).mock.calls;
|
||||||
|
const confirm = calls[0]?.[2]?.[1];
|
||||||
|
confirm?.onPress?.();
|
||||||
|
|
||||||
|
expect(onConfirm).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DiffPreview truncates and expands long diffs', () => {
|
||||||
|
const diff = Array.from({ length: 125 }, (_, index) =>
|
||||||
|
index % 2 === 0 ? `+added ${index}` : `-removed ${index}`,
|
||||||
|
).join('\n');
|
||||||
|
|
||||||
|
render(<DiffPreview content={diff} initialLines={3} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('+added 0')).toBeTruthy();
|
||||||
|
expect(screen.queryByText('-removed 5')).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Show 122 more lines'));
|
||||||
|
|
||||||
|
expect(screen.getByText('-removed 5')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('status badges render readable labels', () => {
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<SpoonStatusBadge status='up_to_date' />
|
||||||
|
<ThreadStatusBadge status='waiting_for_user' />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('up to date')).toBeTruthy();
|
||||||
|
expect(screen.getByText('waiting for user')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
|
||||||
|
import { cleanup } from '@testing-library/react';
|
||||||
|
import { afterEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, '__DEV__', {
|
||||||
|
configurable: true,
|
||||||
|
value: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createElement =
|
||||||
|
(tag: string) =>
|
||||||
|
({
|
||||||
|
children,
|
||||||
|
onChangeText,
|
||||||
|
onPress,
|
||||||
|
value,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
onChangeText?: (value: string) => void;
|
||||||
|
onPress?: () => void;
|
||||||
|
value?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}) => {
|
||||||
|
const safeProps: Record<string, unknown> = {
|
||||||
|
...props,
|
||||||
|
className:
|
||||||
|
typeof props.className === 'string' ? props.className : undefined,
|
||||||
|
disabled: props.disabled as boolean | undefined,
|
||||||
|
onChange: onChangeText
|
||||||
|
? (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||||
|
onChangeText(event.currentTarget.value)
|
||||||
|
: undefined,
|
||||||
|
onClick: onPress,
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
delete safeProps.keyboardType;
|
||||||
|
delete safeProps.keyboardShouldPersistTaps;
|
||||||
|
delete safeProps.placeholderTextColor;
|
||||||
|
delete safeProps.secureTextEntry;
|
||||||
|
delete safeProps.showsHorizontalScrollIndicator;
|
||||||
|
delete safeProps.textAlignVertical;
|
||||||
|
|
||||||
|
return React.createElement(tag, safeProps, children);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TextInput = ({
|
||||||
|
multiline,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
multiline?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}) => createElement(multiline ? 'textarea' : 'input')(props);
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
alert: vi.fn(),
|
||||||
|
useAction: vi.fn(() => vi.fn()),
|
||||||
|
useMutation: vi.fn(() => vi.fn()),
|
||||||
|
useQuery: vi.fn(() => undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('react-native', () => ({
|
||||||
|
Alert: { alert: mocks.alert },
|
||||||
|
Linking: { openURL: vi.fn() },
|
||||||
|
Modal: ({
|
||||||
|
children,
|
||||||
|
visible,
|
||||||
|
}: {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
visible?: boolean;
|
||||||
|
}) => (visible ? React.createElement('div', {}, children) : null),
|
||||||
|
Pressable: createElement('button'),
|
||||||
|
Platform: {
|
||||||
|
OS: 'web',
|
||||||
|
select: (values: Record<string, unknown>) => values.web ?? values.default,
|
||||||
|
},
|
||||||
|
RefreshControl: createElement('div'),
|
||||||
|
ScrollView: createElement('div'),
|
||||||
|
Switch: createElement('input'),
|
||||||
|
Text: createElement('span'),
|
||||||
|
TextInput,
|
||||||
|
TurboModuleRegistry: {
|
||||||
|
get: vi.fn(() => undefined),
|
||||||
|
getEnforcing: vi.fn(() => ({})),
|
||||||
|
},
|
||||||
|
View: createElement('div'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('expo-clipboard', () => ({
|
||||||
|
setStringAsync: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('expo-haptics', () => ({
|
||||||
|
impactAsync: vi.fn(),
|
||||||
|
notificationAsync: vi.fn(),
|
||||||
|
selectionAsync: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('react-native-safe-area-context', () => ({
|
||||||
|
SafeAreaView: createElement('div'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('expo-router', () => ({
|
||||||
|
Link: ({ children }: { children?: React.ReactNode }) => children,
|
||||||
|
Stack: {
|
||||||
|
Screen: () => null,
|
||||||
|
},
|
||||||
|
useLocalSearchParams: () => ({}),
|
||||||
|
useRouter: () => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
replace: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('convex/react', () => ({
|
||||||
|
useAction: mocks.useAction,
|
||||||
|
useMutation: mocks.useMutation,
|
||||||
|
useQuery: mocks.useQuery,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@convex-dev/auth/react', () => ({
|
||||||
|
useAuthActions: () => ({
|
||||||
|
signIn: vi.fn(),
|
||||||
|
signOut: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const mockedAlert = mocks.alert;
|
||||||
|
export const mockedUseAction = mocks.useAction;
|
||||||
|
export const mockedUseMutation = mocks.useMutation;
|
||||||
|
export const mockedUseQuery = mocks.useQuery;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import { parseEnvText } from '../../src/utils/env';
|
||||||
|
|
||||||
|
describe('parseEnvText', () => {
|
||||||
|
test('parses dotenv content without exposing invalid rows', () => {
|
||||||
|
expect(
|
||||||
|
parseEnvText(`
|
||||||
|
# comment
|
||||||
|
AUTH_SECRET="secret=value"
|
||||||
|
export authentik_client_id='client'
|
||||||
|
1INVALID=nope
|
||||||
|
EMPTY=
|
||||||
|
`),
|
||||||
|
).toEqual([
|
||||||
|
{ name: 'AUTH_SECRET', value: 'secret=value' },
|
||||||
|
{ name: 'AUTHENTIK_CLIENT_ID', value: 'client' },
|
||||||
|
{ name: 'EMPTY', value: '' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores blank lines and strips matching quotes only', () => {
|
||||||
|
expect(
|
||||||
|
parseEnvText(`
|
||||||
|
|
||||||
|
PLAIN=value
|
||||||
|
QUOTED="value"
|
||||||
|
SINGLE='value'
|
||||||
|
UNMATCHED="value
|
||||||
|
`),
|
||||||
|
).toEqual([
|
||||||
|
{ name: 'PLAIN', value: 'value' },
|
||||||
|
{ name: 'QUOTED', value: 'value' },
|
||||||
|
{ name: 'SINGLE', value: 'value' },
|
||||||
|
{ name: 'UNMATCHED', value: '"value' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatDate,
|
||||||
|
formatDateTime,
|
||||||
|
titleize,
|
||||||
|
truncate,
|
||||||
|
} from '../../src/utils/format';
|
||||||
|
|
||||||
|
describe('format utilities', () => {
|
||||||
|
test('formats missing timestamps as never', () => {
|
||||||
|
expect(formatDate(undefined)).toBe('Never');
|
||||||
|
expect(formatDateTime(undefined)).toBe('Never');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formats known timestamps', () => {
|
||||||
|
const value = Date.UTC(2026, 0, 2, 3, 4, 5);
|
||||||
|
|
||||||
|
expect(formatDate(value)).toContain('2026');
|
||||||
|
expect(formatDateTime(value)).toContain('2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('titleizes machine values', () => {
|
||||||
|
expect(titleize('waiting_for_user')).toBe('waiting for user');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('truncates long text', () => {
|
||||||
|
expect(truncate('abcdef', 4)).toBe('a...');
|
||||||
|
expect(truncate('abc', 4)).toBe('abc');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src",
|
"src",
|
||||||
|
"tests",
|
||||||
"*.ts",
|
"*.ts",
|
||||||
"*.js",
|
"*.js",
|
||||||
".expo/types/**/*.ts",
|
".expo/types/**/*.ts",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user