From a8cf24a1f33e5b01edb089c04182dc155e437dc3 Mon Sep 17 00:00:00 2001 From: Poola Nithin Reddy Date: Sun, 7 Jun 2026 23:16:44 -0400 Subject: [PATCH 1/8] Harden public alpha dependencies and branding --- .github/workflows/ci.yml | 1 + .gitignore | 2 - CHANGELOG.md | 6 +- LICENSE | 4 +- README.md | 10 +- action.yml | 4 - docs/agents.md | 2 - docs/bridge.md | 20 +- docs/github.md | 1 - docs/history.md | 10 +- docs/project-brain.md | 32 +- docs/providers.md | 4 +- docs/publishing.md | 11 +- docs/release-notes/v0.1.0-alpha.md | 6 + docs/sandbox.md | 10 +- docs/security-model.md | 34 +- docs/skills.md | 6 +- docs/testing.md | 37 + docs/ui-ux.md | 23 +- docs/vscode-extension.md | 388 +---- docs/workflows.md | 2 - .../{.opencodeignore => .apeironcodeignore} | 0 package-lock.json | 1306 ++++++++++++----- package.json | 20 +- src/agent/planner.ts | 13 +- src/agents/workspace/ignoreRules.ts | 4 +- src/agents/workspace/types.ts | 2 +- src/bridge/connectionFile.ts | 2 +- src/bridge/server.ts | 2 +- src/cli/setup/index.ts | 5 - src/config/config.ts | 7 - src/connectors/github/webhooks.ts | 4 +- src/context/ignore.ts | 7 +- src/diagnostics/doctor.ts | 2 +- src/diagnostics/extraChecks.ts | 41 +- src/githubAction/config.ts | 19 +- src/githubAction/runner.ts | 6 +- src/githubAutomation/idempotency.ts | 17 +- src/githubAutomation/permissions.ts | 23 +- src/projectBrain/audit.ts | 5 - src/sandbox/format.ts | 6 +- src/sandbox/manager.ts | 5 +- src/ui/welcomeDashboard.ts | 2 +- src/utils/logger.ts | 5 +- src/utils/paths.ts | 88 +- src/utils/structuredLogger.ts | 6 +- tests/agent/agent.integration.test.ts | 4 +- tests/agent/checkpoints.test.ts | 2 +- tests/agent/diffBudget.test.ts | 2 +- tests/agent/loop.test.ts | 10 +- tests/agent/lsp-context.test.ts | 10 +- tests/agent/memoryManager.test.ts | 10 +- tests/agent/planner.test.ts | 6 +- tests/agent/planningGate.integration.test.ts | 2 +- tests/agent/runMemoryAuthGuard.test.ts | 2 +- tests/agent/runtimeResume.test.ts | 2 +- tests/agent/workflows.test.ts | 2 +- tests/agents/reviewActions.test.ts | 2 +- tests/agents/team.test.ts | 2 +- tests/agents/workspace.test.ts | 16 +- tests/branding/publicBranding.test.ts | 71 +- tests/branding/releaseReadiness.test.ts | 5 + tests/branding/staleBranding.test.ts | 143 +- tests/cli/branding.test.ts | 15 +- tests/cli/commands.test.ts | 228 +-- tests/cli/connectorCommands.test.ts | 4 +- tests/cli/contextCommands.test.ts | 2 +- tests/cli/debugCommands.test.ts | 10 +- tests/cli/evalCommands.test.ts | 8 +- tests/cli/firstRun.test.ts | 4 +- tests/cli/memoryCommands.test.ts | 10 +- tests/cli/runtimeCommands.test.ts | 10 +- tests/config/config.test.ts | 6 +- .../connectors/githubBranchesCommits.test.ts | 2 +- tests/context/agentContextIntegration.test.ts | 4 +- tests/context/repoIntelligence.test.ts | 2 +- tests/diagnostics/doctor.test.ts | 4 +- ....test.ts => bridge-prompt-routing.test.ts} | 4 +- tests/e2e/cli-github-models-real-flow.test.ts | 5 +- tests/e2e/cli-real-user-flow.test.ts | 17 +- tests/e2e/cli-smoke.test.ts | 16 +- tests/e2e/doctor-debug-export.test.ts | 6 +- tests/e2e/evals-acceptance.test.ts | 8 +- tests/e2e/first-run-setup.test.ts | 18 +- tests/e2e/githubAutomation.test.ts | 4 +- tests/e2e/harness.ts | 6 +- tests/e2e/productFlow.test.ts | 6 +- tests/e2e/project-brain-dominant.test.ts | 6 +- tests/e2e/provider-tools.test.ts | 4 +- tests/e2e/real-dogfood-17g.test.ts | 2 +- tests/e2e/ui-ux.test.ts | 4 +- tests/evals/evals.test.ts | 4 +- tests/evals/results.test.ts | 2 +- .../.apeironcode-agent/repo-map.json | 164 ++- tests/githubAction/runner.test.ts | 10 +- tests/githubAutomation/ciLogParser.test.ts | 2 +- .../githubAutomation/commentCommands.test.ts | 8 +- tests/githubAutomation/idempotency.test.ts | 22 +- tests/githubAutomation/issueToPr.test.ts | 6 +- tests/githubAutomation/permissions.test.ts | 30 +- tests/githubAutomation/prReview.test.ts | 4 +- tests/helpers/workflow.ts | 4 +- tests/hooks/hooks.test.ts | 2 +- tests/hooks/runtime.integration.test.ts | 4 +- tests/lsp/client.test.ts | 2 +- tests/lsp/definitions.test.ts | 2 +- tests/lsp/diagnostics.test.ts | 2 +- tests/lsp/documentStore.test.ts | 4 +- tests/lsp/references.test.ts | 2 +- tests/lsp/sessionManager.test.ts | 2 +- tests/lsp/symbols.test.ts | 8 +- tests/mcp/hardening.test.ts | 2 +- tests/mcp/oauthFlow.test.ts | 4 +- tests/memory/compaction.test.ts | 2 +- tests/memory/graph.test.ts | 2 +- tests/memory/indexStore.test.ts | 8 +- tests/memory/retrievalEval.test.ts | 6 +- tests/memory/search.test.ts | 8 +- tests/memory/suggestions.test.ts | 4 +- tests/memory/verification.test.ts | 2 +- tests/multisession/background.test.ts | 2 +- tests/multisession/behavior.test.ts | 2 +- tests/plugins/loader.test.ts | 2 +- tests/projectBrain/audit.test.ts | 3 +- tests/projectBrain/templates.test.ts | 2 +- tests/safety/safety.integration.test.ts | 2 +- tests/sandbox/docker.test.ts | 2 +- tests/sandbox/podman.test.ts | 2 +- tests/sandbox/sandbox.test.ts | 4 +- tests/sessions/store.test.ts | 2 +- tests/setup/setup.test.ts | 4 +- tests/share/export.test.ts | 2 +- tests/skills/runtime.test.ts | 4 +- tests/skills/skills.test.ts | 2 +- tests/smoke/cli-smoke.test.ts | 6 +- tests/tasks/task-store.test.ts | 2 +- tests/tools/edit-history.test.ts | 2 +- tests/tools/patch-engine.test.ts | 2 +- tests/tools/tools.test.ts | 2 +- tests/ui/brand.test.ts | 2 +- tests/ui/cliSnapshots.test.ts | 4 +- tests/ui/slashCommands.test.ts | 4 +- tests/ui/viewModels.test.ts | 12 +- tests/ui/welcomeDashboard.test.ts | 11 +- tests/utils/legacyHomeMigration.test.ts | 51 - tests/utils/paths.test.ts | 42 + tests/utils/structuredLogger.test.ts | 6 +- tests/workflows/runtime.test.ts | 2 +- 148 files changed, 1725 insertions(+), 1696 deletions(-) create mode 100644 docs/testing.md rename examples/demo/workspace-ignore/{.opencodeignore => .apeironcodeignore} (100%) rename tests/e2e/{vscode-prompt-routing.test.ts => bridge-prompt-routing.test.ts} (99%) delete mode 100644 tests/utils/legacyHomeMigration.test.ts create mode 100644 tests/utils/paths.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd797df..e81d48b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,4 +25,5 @@ jobs: - run: npm run build - run: npm test - run: npm run check:file-size + - run: npm run check:audit - run: npm pack --dry-run diff --git a/.gitignore b/.gitignore index 31050ab..fe2b5bf 100644 --- a/.gitignore +++ b/.gitignore @@ -5,8 +5,6 @@ coverage/ *.log logs/ tmp/ -/.opencode-agent/ - # Local runtime/dogfood state and other-tool configs — not part of the CLI repo. /.apeironcode-agent/ /.agents/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 12c45be..2377f5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,8 @@ ### Changed -- `APEIRONCODE_TEST_OFFLINE` is now the preferred sandbox offline flag; `OPENCODE_TEST_OFFLINE` is kept as a deprecated legacy alias. -- Documentation now uses the `APEIRONCODE_AUTOMATION*` environment variables as the primary names (the `OPENCODE_AUTOMATION*` aliases remain deprecated). +- `APEIRONCODE_TEST_OFFLINE` disables sandbox backend probing in offline test runs. +- Documentation and automation use only the `APEIRONCODE_*` environment namespace. ### Added @@ -150,7 +150,7 @@ - `apeironcode lsp symbols` and `/lsp symbols` try live `documentSymbol` first and preserve fallback behavior when live LSP is missing or fails - `apeironcode lsp diagnostics`, `apeironcode lsp definition`, and `apeironcode lsp references` now report live-vs-fallback results instead of placeholder or ambiguous output - the shared agent project context now enriches relevant workflows with capped diagnostics and reports diagnostics source, files checked, counts, and fallback reasons in final summaries -- agent loop now supports multi-tool turns, ``, and malformed tool-call retry prompts +- agent loop now supports multi-tool turns, ``, and malformed tool-call retry prompts - one-shot CLI preamble, final execution summary, session metadata, and TUI status now share the same effective-mode resolution - slash workflow help now includes `/commands`, usage examples, and better missing-argument guidance - approval prompts, provider diagnostics, slash output, and error panels now safely stringify unexpected structured values diff --git a/LICENSE b/LICENSE index 33916df..537d7dc 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 ApeironCode Agent contributors (formerly OpenCode Agent) +Copyright (c) 2026 ApeironCode contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file +SOFTWARE. diff --git a/README.md b/README.md index d231cd2..301d020 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ See the [complete action example](./examples/github-action-pr-review/). - Terminal-native interactive chat built with Commander.js, Ink, and React. - One-shot execution for direct prompts from the shell. - `apeironcode doctor` diagnostics and `apeironcode provider test` smoke checks. -- Optional Project Brain: `apeironcode brain plan` previews a user-approved `.apeironcode/` workspace memory for long app builds, with large-app orchestration, PLAN/TASKS diff-merge, a persistent sync-preview store, token-efficient context planning, and agent routing. All writes require explicit `--yes` — no silent creation. Runtime brain intelligence adds deterministic intent classification (<1ms) that selects relevant brain context per prompt, surfaced via the `brain.runtime`/`brain.explain` CLI. (A VS Code companion exists in development but is not part of this CLI alpha.) +- Optional Project Brain: `apeironcode brain plan` previews a user-approved `.apeironcode/` workspace memory for long app builds, with large-app orchestration, PLAN/TASKS diff-merge, a persistent sync-preview store, token-efficient context planning, and agent routing. All writes require explicit `--yes` — no silent creation. Runtime brain intelligence adds deterministic intent classification (<1ms) that selects relevant brain context per prompt, surfaced via the `brain.runtime`/`brain.explain` CLI. - First-run setup with `apeironcode setup`, no-key mock-provider trial mode, `/setup`, and setup status/reset commands. - TUI product-home flow with command echo, compact dashboard sections, `/commands beginner`, natural aliases such as `/show memory`, and temp-HOME smoke helpers via `npm run demo:tui`. - Provider readiness UX with `apeironcode provider list`, `apeironcode provider setup`, `apeironcode provider fallback`, `apeironcode provider doctor`, `apeironcode model list`, and `apeironcode model recommend`. @@ -155,7 +155,7 @@ See the [complete action example](./examples/github-action-pr-review/). - Lifecycle hooks wired into sessions, planning, tools, edits, commands, commits, memory suggestions, and skill runs, with disabled-by-default shell execution and explicit approval requirement. - Token-efficient repo brain modules for file summaries, dependency edges, context packing, prompt-context injection, and budget reporting. - Typed code quality workflow recipes for feature implementation, test fixes, debugging, review, refactor, tests, dependency upgrades, security/performance audits, docs, and release prep, with dry-run, report storage, and runtime execution through the agent loop. -- Honest sandbox status detection with `apeironcode sandbox status`; OS-level sandboxed command execution is not implemented. +- Honest sandbox status detection with `apeironcode sandbox status`. Approval gating is not OS-level isolation: commands are not process-isolated by default. - Mock-only local eval commands with `apeironcode eval list`, `apeironcode eval run smoke`, and `apeironcode eval report`. ## Provider Matrix @@ -509,6 +509,8 @@ npm run bench:agent - `docs/memory.md` covers project memory, global memory, and session learning. - `docs/history.md` covers `history`, `search`, and stored edit/task/session data. - `docs/mcp.md` covers the experimental MCP runtime and current limitations. +- `docs/testing.md` explains mock/scripted CI coverage and real-provider limits. +- `docs/sandbox.md` explains why approval gating is not an OS security boundary. ## Demo @@ -521,7 +523,7 @@ build / modify / error-fix flows. - Next: richer history and memory dashboards inside the TUI. - Next: additional web search providers and richer result extraction. - Next: broader MCP transport support beyond the current experimental stdio runtime. -- Later: a polished VS Code companion (in development, not part of this CLI alpha). +- Later: optional IDE integrations. None are included in this CLI alpha. ## Known limitations @@ -536,6 +538,8 @@ build / modify / error-fix flows. - The real TTY interaction harness is still limited. - No full autonomy is claimed — file writes and shell commands are approval-gated. +- Default CI uses fixtures, mocked network responses, and the mock provider. + Real-provider/real-LLM checks require user credentials and are not run by CI. ## Attribution diff --git a/action.yml b/action.yml index aa907b9..b18a7bd 100644 --- a/action.yml +++ b/action.yml @@ -30,9 +30,6 @@ inputs: apeironcode_config_path: description: 'Path to an ApeironCode config file (optional).' required: false - opencode_config_path: - description: 'Legacy alias of apeironcode_config_path. Path to an ApeironCode config file (optional).' - required: false runs: using: 'composite' steps: @@ -50,7 +47,6 @@ runs: INPUT_MAX_ITERATIONS: ${{ inputs.max_iterations }} INPUT_RUN_TESTS: ${{ inputs.run_tests }} INPUT_APEIRONCODE_CONFIG_PATH: ${{ inputs.apeironcode_config_path }} - INPUT_OPENCODE_CONFIG_PATH: ${{ inputs.opencode_config_path }} GITHUB_TOKEN: ${{ github.token }} run: '"$GITHUB_ACTION_PATH/node_modules/.bin/tsx" "$GITHUB_ACTION_PATH/src/githubAction/main.ts"' branding: diff --git a/docs/agents.md b/docs/agents.md index 046aa11..2f67d23 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -41,8 +41,6 @@ apeironcode brain route "review the auth module for security issues" # → selects: security-reviewer, reviewer ``` -In VS Code, use **ApeironCode: Route Prompt to Brain** or the **Route Prompt** button in the Project Brain panel. - ## Commands ```bash diff --git a/docs/bridge.md b/docs/bridge.md index 6fa1f1d..c9d2b0e 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -122,24 +122,14 @@ Protected paths (`.env`, `.git/`, private keys) are flagged as `risky: true`. Connect via: `ws://127.0.0.1:` -## VS Code Extension (Phase 16F) +## Future IDE Integrations -The extension at `extensions/vscode/` provides: - -- Sidebar chat panel streaming bridge events -- Bridge connection management (start/stop) -- Permission approve/deny UI -- Diff preview with risky-path warnings -- Task and worktree status view -- Context view (files, token budget, provider) -- Selected file/range context capture - -See `docs/vscode-extension.md` for setup. +The public alpha does not include an IDE extension. The bridge is an +experimental local API that may support future VS Code, JetBrains, desktop, or +web integrations. ## Known Limitations (Phase 16F) - No remote/cloud bridge (local-only is intentional and enforced). - `diff.apply_requested` / `diff.apply_result` not connected to tool path (auto-apply disabled in MVP). -- Prompt submission from VS Code webview pending bridge command routing. -- No JetBrains or other IDE extension yet. -- No VS Code Marketplace release yet. +- No public IDE extension or Marketplace release. diff --git a/docs/github.md b/docs/github.md index 85743e9..729cffb 100644 --- a/docs/github.md +++ b/docs/github.md @@ -43,7 +43,6 @@ Current status: - Tokens are read from `GITHUB_TOKEN` or `GH_TOKEN` and are never printed. - Automation workflows are dry-run by default. Non-dry-run writes require `APEIRONCODE_AUTOMATION=1` plus the narrow action flag, such as `APEIRONCODE_AUTOMATION_REVIEW=1`, `APEIRONCODE_AUTOMATION_COMMENT=1`, `APEIRONCODE_AUTOMATION_COMMIT=1`, or `APEIRONCODE_AUTOMATION_PR_CREATE=1`. - Automation can also be constrained with `APEIRONCODE_AUTOMATION_REPOS`, `APEIRONCODE_AUTOMATION_ACTORS`, and `APEIRONCODE_AUTOMATION_DENY_ACTORS`. Explicit denies win. -- The matching `OPENCODE_AUTOMATION*` variables remain as deprecated legacy aliases; prefer the `APEIRONCODE_*` names. - Fork PRs are forced into dry-run/comment-only behavior by the GitHub Action runner. Protected base branches are not pushed directly. - Action comments include an ApeironCode run marker so replayed or duplicate events can be skipped safely. - Mention commands support `@apeironcode implement`, `@apeironcode review`, `@apeironcode fix-tests`, `@apeironcode explain`, and `@apeironcode apply-suggestion`. Unknown commands are rejected with a safe help response. diff --git a/docs/history.md b/docs/history.md index 39059b8..77bbaef 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,13 +1,5 @@ # History -> **Brand note (Phase 15A):** ApeironCode was formerly developed and shipped as -> *ApeironCode Agent*. The legacy `apeironcode` CLI binary, `.apeironcode-agent/` -> project directory, `.apeironcodeignore` ignore file, and `OPENCODE_*` -> environment variables are all preserved as backward-compatible aliases. -> New projects use the `apeironcode` binary, `.apeironcode-agent/` directory, -> `.apeironcodeignore`, and `APEIRONCODE_*` environment variables. Storage -> paths below show the legacy on-disk names that remain the default. - ApeironCode now persists and searches several kinds of local history: - saved sessions @@ -89,4 +81,4 @@ Saved sessions now include structured summaries from the run itself, such as dec - Search is text-based and local only. - Edit history is project-scoped; session history is stored globally and filtered by project path when requested. -- Search results are summary-oriented rather than full transcript excerpts. \ No newline at end of file +- Search results are summary-oriented rather than full transcript excerpts. diff --git a/docs/project-brain.md b/docs/project-brain.md index c600228..2a8fe12 100644 --- a/docs/project-brain.md +++ b/docs/project-brain.md @@ -34,19 +34,6 @@ Optional workflow folders: - `apeironcode brain memory` shows `MEMORY.md`. - `apeironcode brain update --yes --summary "..."` appends a short run summary when `RUNS.md` exists. -## VS Code - -The extension exposes: - -- `ApeironCode: Plan Project Brain` -- `ApeironCode: Initialize Project Brain` -- `ApeironCode: Show Project Brain` -- `ApeironCode: Show Project Plan` -- `ApeironCode: Show Project Tasks` -- `ApeironCode: Continue Current Plan` - -Initialization asks for confirmation before writing. - ## Safety Project Brain files are markdown data and workflow definitions. ApeironCode does not execute scripts from `.apeironcode/`. Project agents, skills, and commands still respect project trust and normal permission rules. Untrusted projects can show safe summaries, but behavior-changing workflow files are not auto-loaded. @@ -62,7 +49,7 @@ Sync previews are saved to `.apeironcode/runs/sync-previews/` before any write o - Flags previews older than 7 days as stale. - Requires `approved:true` before any preview is applied. -Use `apeironcode brain previews` to list, inspect, and apply saved previews from the CLI. In VS Code, open the **Sync Previews** panel for a full UI. +Use `apeironcode brain previews` to list, inspect, and apply saved previews from the CLI. ## PLAN/TASKS Merge Engine (Phase 16G.2) @@ -87,15 +74,8 @@ The brain context planner selects files relevant to the current prompt rather th - Verification strategy, risk list, first 3 next actions. - Token strategy for long builds. -This is **deterministic planning**, not autonomous execution. No files are written until you explicitly approve initialization via `--yes` or the VS Code confirmation dialog. - -## VS Code Brain Views (Phase 16G.2) - -**Project Brain Panel** — central control center with all brain actions. Access via `ApeironCode: Open Project Brain Panel`. - -**App Build Plan View** — rich display of orchestration results: vision, stack, phases/tasks, suggested agents/skills, verification plan, risks, token strategy, and action buttons. All writes require VS Code confirmation dialog. - -**Sync Previews View** — list, inspect, and apply saved sync previews. Stale previews (>7 days) are flagged. Applying requires explicit confirmation. Secrets are redacted in the display. +This is **deterministic planning**, not autonomous execution. No files are +written until you explicitly approve initialization via `--yes`. ## CLI Brain Commands (Phase 16G.2) @@ -126,12 +106,6 @@ apeironcode brain runtime "" — show intent classification and injecti apeironcode brain explain "" — show full debug explanation of context selection ``` -### VS Code - -- **`ApeironCode: Brain Runtime Context`** — analyze a prompt's runtime intent and context. -- **`ApeironCode: Brain Context Explanation`** — see detailed context selection debug info. -- An information notification appears when brain context is injected (shows intent and estimated token cost). - ### Token efficiency The runtime context injector respects a configurable token budget (default 900 tokens). For simple prompts (≤6 words, no intent signal), the brain is skipped entirely — zero overhead. diff --git a/docs/providers.md b/docs/providers.md index 8afe2c3..c1ff96b 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -122,9 +122,7 @@ ollama pull qwen2.5-coder:7b apeironcode setup --provider ollama ``` -Config is stored ApeironCode-first at `~/.apeironcode-agent/config.json`. A -legacy `~/.opencode-agent` home is migrated automatically on first run -(non-destructive — the legacy directory is left intact). +Config is stored at `~/.apeironcode-agent/config.json`. ### Verify your GitHub token with curl diff --git a/docs/publishing.md b/docs/publishing.md index 3d8e9db..12ec1d8 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -11,11 +11,16 @@ ## npm publishing notes +The intended public package name is `apeironcode`. Registry checks returned no +published version as of June 8, 2026, but npm availability is only confirmed +when a publish succeeds. The maintainer must authenticate with npm before +release. + The package is configured for global install with: ```json { - "name": "apeironcode-agent", + "name": "apeironcode", "bin": { "apeironcode": "./dist/cli/index.js" } @@ -27,6 +32,6 @@ Publish flow: 1. Bump the version in `package.json`. 2. Build the package. 3. Confirm `dist/` contains the CLI bundle. -4. Run `npm publish` from a trusted environment. +4. Run `npm publish --access public --tag alpha` from a trusted environment. -The CI workflow already validates install, typecheck, test, and build on Node 18, 20, and 22. \ No newline at end of file +The CI workflow already validates install, typecheck, test, and build on Node 18, 20, and 22. diff --git a/docs/release-notes/v0.1.0-alpha.md b/docs/release-notes/v0.1.0-alpha.md index 7cda1f5..3318344 100644 --- a/docs/release-notes/v0.1.0-alpha.md +++ b/docs/release-notes/v0.1.0-alpha.md @@ -34,6 +34,10 @@ public release: the VS Code extension and marketing website are not included. - Provider/model switching UX is still improving. - The real TTY interaction harness is still limited. - No full autonomy is claimed — writes and commands are approval-gated. +- Approval gating is not OS-level isolation; commands are not process-isolated + by default. +- Default CI uses mocked/scripted providers. Real-provider tests require user + credentials and are not run by default. ## Install @@ -47,6 +51,8 @@ apeironcode setup --provider mock # try with no API key apeironcode ``` +The npm package is not published yet. Install from source for this alpha. + ## Safety Reads are low-risk; file writes and shell commands require approval; dangerous diff --git a/docs/sandbox.md b/docs/sandbox.md index f161451..873d7ce 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -2,6 +2,10 @@ ApeironCode Agent does not currently provide OS-level command sandboxing. +OS-level sandboxing is not enabled in this public CLI alpha. Command execution +is approval-gated, but not process-isolated by default. Approval gating is not +the same as an operating-system security boundary. + The Phase 18 sandbox surface is an honest detection and status layer: ```bash @@ -9,7 +13,10 @@ apeironcode sandbox status apeironcode sandbox doctor ``` -It checks whether optional backends such as Docker, Podman, or Firejail appear available, then reports current limitations. Agent tools still run in the local process unless a future sandbox runner is explicitly implemented and enabled. +It checks whether optional backends such as Docker, Podman, or Firejail appear +available, then reports current limitations. Those runners are experimental and +are not wired as default isolation. Agent tools run without a guaranteed +process-isolation boundary in the public CLI alpha. Current limits: @@ -33,4 +40,3 @@ The decision is made via `getSandboxFallbackDecision(semantics, policy)` in `src/sandbox/manager.ts`. Read-only / non-network classification reuses the shell command parser and semantics module. There is no silent fallback in `safe-readonly` or `never` modes. - diff --git a/docs/security-model.md b/docs/security-model.md index bedadbf..29ceba9 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -3,11 +3,11 @@ ApeironCode uses a layered security model to protect users from accidental or malicious misuse. -## VS Code extension (Phase 16F) +## Local Bridge -- The extension talks only to the **local WebSocket bridge** on `127.0.0.1` with a workspace token; it does not ship API keys or call providers directly. -- Model selection is stored per **bridge connection** (`provider.set_session_model`) and merged into the next `session.send_prompt` as `providerName` / `model` overrides for the CLI agent runner (still subject to CLI config and credentials on the host). -- Approved diff applies from the bridge use **ToolRegistry** (`patch_file`) with structured `patchOperations`; there is no parallel write path that bypasses tools. +The experimental local bridge binds to `127.0.0.1` and requires a workspace +token. It is intended for future integrations and is not a public IDE +extension. Bridge-triggered tool execution still goes through `ToolRegistry`. ## Project Trust @@ -23,7 +23,9 @@ Temporary directories are **always untrusted** regardless of explicit trust gran ## Project Brain -Project Brain is optional and uses `.apeironcode/` files. ApeironCode only creates those files after explicit approval (`apeironcode brain init --yes` or a VS Code confirmation). Plan mode writes nothing. +Project Brain is optional and uses `.apeironcode/` files. ApeironCode only +creates those files after explicit CLI approval (`apeironcode brain init +--yes`). Plan mode writes nothing. Safe summaries can be shown in untrusted projects, but behavior-changing `.apeironcode/agents`, `.apeironcode/skills`, and `.apeironcode/commands` still follow the workflow trust model. Scripts from `.apeironcode/` are not executed. @@ -90,29 +92,25 @@ All Project Brain write operations use an **approval-first** model: | Operation | Requirement | |---|---| -| `brain init` | `--yes` flag (CLI) or VS Code confirmation dialog | -| `brain sync` / `brain sync-preview apply` | `--yes` flag (CLI) or VS Code confirmation dialog | -| `brain previews apply ` | `--yes` flag (CLI) or VS Code confirmation dialog | -| VS Code "Initialize Project Brain from Plan" | Modal VS Code confirmation dialog required | -| VS Code "Apply this Preview" | Modal VS Code confirmation dialog required | +| `brain init` | `--yes` flag | +| `brain sync` / `brain sync-preview apply` | `--yes` flag | +| `brain previews apply ` | `--yes` flag | | Orchestration plan display | Read-only. No files written. | | Agent routing plan | Read-only. No files written. | | Context preview | Read-only. No files written. | -There is **no silent creation** of `.apeironcode/` files. Plan and orchestration commands are read-only previews. The `approved:true` flag must be explicitly set before any write is accepted by the bridge handler. - -VS Code webview panels enforce this by: -1. Showing a modal `vscode.window.showWarningMessage` with `{modal: true}` before sending any write request. -2. Only sending `brain.init` or `brain.preview_apply` with `approved: true` after the user clicks the confirmation button. -3. Never writing on button click alone — the confirmation step is mandatory. +There is **no silent creation** of `.apeironcode/` files. Plan and +orchestration commands are read-only previews. The `approved:true` flag must be +explicitly set as confirmation before any write is accepted by the bridge +handler. ## Runtime Brain Context Redaction (Phase 16H) -All Project Brain content that enters agent prompts, bridge messages, doctor output, or the VS Code webview is passed through `redactProjectBrainText()`: +All Project Brain content that enters agent prompts, bridge messages, or doctor +output is passed through `redactProjectBrainText()`: - Tokens ≥ 32 characters matching `[A-Za-z0-9_-]{32,}` (secret-like patterns) are replaced with `[REDACTED]`. - No raw file content from `.apeironcode/` appears in bridge payloads. -- The VS Code brain panel uses `escapeHtml()` on all dynamic values before rendering. - Context injection text is capped at a configurable token budget (default 900 tokens). - For simple prompts (≤ 6 words, no intent signal), brain context is skipped entirely — zero overhead and no file reads. diff --git a/docs/skills.md b/docs/skills.md index ad064c5..56dd152 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -100,7 +100,11 @@ The routing plan explains why each skill is relevant but does not inject skill c ## Runtime Brain Skill Selection (Phase 16H) -The runtime brain intent classifier automatically considers project skills when routing a prompt. Skills listed in `.apeironcode/skills/` appear in the `selectedAgents` field of `brain.runtime` responses and in the VS Code Runtime Brain Decision panel. No extra command needed — skill routing happens as part of every agent run when Project Brain is present. +The runtime brain intent classifier automatically considers project skills +when routing a prompt. Skills listed in `.apeironcode/skills/` appear in the +`selectedAgents` field of `brain.runtime` responses. No extra command is +needed; skill routing happens as part of every agent run when Project Brain is +present. ## Known limitations diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..129ac95 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,37 @@ +# Testing and Validation + +ApeironCode is an early CLI alpha. Its default validation is broad, but it is +not a substitute for testing your chosen model and provider on your own code. + +## Default CI + +Default CI runs TypeScript checks, linting, builds, unit and integration tests, +scripted end-to-end flows, file-size checks, a dependency audit, and an npm +package dry run. + +Most agent workflows use deterministic fixtures, mocked network responses, or +the no-key mock provider. Default CI does not call paid model APIs and does not +require real provider credentials. + +## Smoke and Dogfood Checks + +The repository includes smoke commands for scripted coding, error-fixing, TUI, +and terminal workflows. These verify CLI orchestration and safety behavior with +controlled inputs. They do not prove that every real model will make correct +changes. + +Before trusting a provider on important work: + +1. Run the relevant smoke commands. +2. Test in a disposable repository. +3. Keep approval mode enabled. +4. Review every diff and command. +5. Report failures with the dogfood issue template. + +## Real Providers + +Real-provider checks require your own API key or local model service and are not +run in default CI. Provider behavior, availability, pricing, and model quality +can change independently of ApeironCode. + +Large-project autonomy and long multi-agent workflows remain experimental. diff --git a/docs/ui-ux.md b/docs/ui-ux.md index da26a14..22230a6 100644 --- a/docs/ui-ux.md +++ b/docs/ui-ux.md @@ -1,6 +1,7 @@ # ApeironCode UI/UX System -ApeironCode uses a shared product language across the terminal CLI and VS Code extension: calm surfaces, clear status, explicit permissions, and local-first trust signals. +ApeironCode's terminal CLI uses calm surfaces, clear status, explicit +permissions, and local-first trust signals. ## CLI Welcome @@ -101,31 +102,15 @@ Config options: Tool starts/results, permission requests, diff summaries, Project Brain context, tasks, and errors use compact text cards. Cards truncate huge content and redact token-like strings before rendering. -## VS Code - -The extension uses shared design tokens layered on VS Code theme variables. Dark mode is the primary target, but colors defer to `var(--vscode-*)` where possible. - -Polished areas: - -- Chat empty state with local-first onboarding -- Connection status header -- Selected model and Project Brain status -- Selected context chip -- Tool timeline -- Permission cards with approve/deny actions -- Status bar states: disconnected, connected, running, needs permission, error, brain active -- Project Brain dashboard cards and action groups -- Build plan and sync preview panels - ## Accessibility And Security -Webviews keep strict CSP and nonce usage. Dynamic content is escaped before insertion. Buttons and input controls have readable labels, and state is represented with text rather than color alone. +Terminal state is represented with text rather than color alone. Secrets are never intentionally displayed. UI renderers redact common API keys, bearer tokens, GitHub tokens, Slack tokens, and env-style secret assignments. ## Known Limitations -The CLI welcome is text-only and does not attempt terminal animations. VS Code screenshots are not bundled in this repo; public docs use descriptions until a release screenshot set is produced. +The CLI welcome is text-only and does not attempt terminal animations. ## Normal vs debug terminal output (Phase 17D) diff --git a/docs/vscode-extension.md b/docs/vscode-extension.md index b5cd6c3..39bce8d 100644 --- a/docs/vscode-extension.md +++ b/docs/vscode-extension.md @@ -1,384 +1,10 @@ -# ApeironCode VS Code Extension +# Future IDE Extension -The ApeironCode VS Code Extension provides a local-first AI coding agent interface directly in VS Code. +**Future/private work. Not included in the public CLI alpha.** -## Public beta (VSIX) +The public ApeironCode package is CLI-only. It does not include a VS Code +extension, a VSIX artifact, Marketplace installation, or extension support. -This extension is distributed as a **`.vsix` file** for public beta testing. It is **not** published on the VS Code Marketplace yet. Install with **Extensions: Install from VSIX…** in VS Code, then follow setup below. You still need the **ApeironCode CLI** and a **local bridge** (`apeironcode bridge start`); the extension does not embed the agent engine. - -### Quick start - -1. Build or obtain `apeironcode-.vsix` and install from VSIX. -2. In your project: `apeironcode bridge start` (listens on `127.0.0.1` only). -3. VS Code: **ApeironCode: Start Bridge Connection** and paste the token from `.apeironcode-agent/bridge-secret.json` (stored in workspace secrets; never logged in full). -4. **ApeironCode: Open Chat** — use **Clear Chat** in the webview or the command palette to reset local messages only. -5. **ApeironCode: Select Model / Provider** — updates workspace settings and forwards `provider.set_session_model` to the bridge for the next `session.send_prompt`. -6. **Send Selection to Chat** attaches editor context; risky edits require permission approval in the webview. -7. **Diff preview**: structured applies go through the bridge as `diff.apply_requested` with `patchOperations` (ToolRegistry / `patch_file`); raw unified diff text alone is not auto-applied. -8. **Project Brain**: use **ApeironCode: Plan Project Brain** to preview `.apeironcode/`; **Initialize Project Brain** asks before writing. - -### Known limitations - -- Marketplace listing and auto-updates are out of scope for this beta. -- Bridge is **local-only**; no hosted/cloud bridge in this build. -- JetBrains and remote bridge clients are not in scope. - -## Architecture - -``` -VS Code Extension - ├── BridgeClient (WebSocket, 127.0.0.1 only) - │ └── connects via ws://127.0.0.1: - ├── Sidebar Chat WebviewPanel - ├── Permission Request UI - ├── Diff Preview Panel - ├── Task/Worktree View - ├── Context View - └── Selection Context Capture - │ - ▼ -ApeironCode Core (CLI process) - └── bridge start ←→ BridgeServer + WebSocketTransport -``` - -## UI Overview - -The extension uses the ApeironCode premium UI system: dark-mode-first surfaces, VS Code design tokens, compact cards, clear status text, and explicit permission controls. The chat panel includes a local-first welcome state, connection header, model summary, Project Brain status, selected context chip, tool timeline, error banners, and Clear Chat. See [ui-ux.md](./ui-ux.md) for the shared CLI/VS Code design notes. - -## Setup - -### Step 1: Start the bridge server - -```bash -apeironcode bridge start -# Output: -# ApeironCode Bridge — local WebSocket server started. -# Endpoint: ws://127.0.0.1:51234 -# Token fingerprint: abc123def456 -# Token stored at: /your/project/.apeironcode-agent/bridge-secret.json -``` - -### Step 2: Connect the extension - -1. In VS Code, open the Command Palette (`Cmd+Shift+P` / `Ctrl+Shift+P`). -2. Run **ApeironCode: Start Bridge Connection**. -3. When prompted, paste the full token from `bridge-secret.json`. -4. The extension stores the token in VS Code workspace secrets. - -### Step 3: Open the chat panel - -Run **ApeironCode: Open Chat** from the Command Palette, or click the ApeironCode icon in the Activity Bar. - -## Commands - -| Command | Description | -|---|---| -| `ApeironCode: Open Chat` | Open the sidebar chat panel | -| `ApeironCode: Start Bridge Connection` | Connect to local bridge | -| `ApeironCode: Stop Bridge Connection` | Disconnect from bridge | -| `ApeironCode: Show Setup Guide` | First-run onboarding guide | -| `ApeironCode: Select Model / Provider` | Choose agent model | -| `ApeironCode: Show Context` | Show context summary | -| `ApeironCode: Show Tasks` | Show task/worktree status | -| `ApeironCode: Plan Project Brain` | Preview optional `.apeironcode/` Project Brain files | -| `ApeironCode: Initialize Project Brain` | Create Project Brain files after confirmation | -| `ApeironCode: Show Project Brain` | Show safe Project Brain summary | -| `ApeironCode: Continue Current Plan` | Send a continuation prompt using Project Brain context | -| `ApeironCode: Send Selection to Chat` | Inject selected text as context | -| `ApeironCode: Approve Permission` | Approve a pending permission request | -| `ApeironCode: Deny Permission` | Deny a pending permission request | -| `ApeironCode: Run Doctor` | Troubleshooting hints | - -## Security Model - -- Bridge binds **only** to `127.0.0.1`. Not exposed on LAN or internet. -- Auth token is stored in VS Code **workspace secrets** (not settings). -- Token **fingerprint** (first 12 chars) is shown in logs/UI. Full token never logged. -- All webview content is HTML-escaped and protected by a strict Content Security Policy. -- Diff apply: IDE sends `diff.apply_requested` with explicit approval and optional structured `patchOperations`. Execution uses **ToolRegistry** (`patch_file`); raw unified diff strings without structured operations are **not** auto-applied. - -## Token / Fingerprint - -The token fingerprint is a safe short identifier: - -```bash -apeironcode bridge token -# Bridge token fingerprint: abc123def456 -# Full token stored at: /project/.apeironcode-agent/bridge-secret.json -``` - -Share the **fingerprint** for verification. Never share the full token over chat, email, or logs. - -## Prompt Submission (Phase 16F.1) - -The VS Code chat panel now drives live ApeironCode agent sessions: - -1. Type a prompt in the chat input and press **Send**. -2. The extension sends `session.send_prompt` to the bridge server. -3. The bridge creates or reuses an agent session and calls `Agent.run()`. -4. Assistant messages, tool events, and task events stream back to VS Code. -5. Permission requests appear as approve/deny cards. -6. Errors are shown safely (no raw secrets, no stack traces). - -### Selected Context Attachment - -When you send a prompt, the active editor selection is automatically attached: -- File path (workspace-relative) -- Language ID -- Line range -- Selected text (capped at 8,000 chars, secrets redacted) - -A small note shows: `Context attached: src/foo.ts lines 10–20`. - -If no selection, the prompt is sent without editor context. - -### Bridge Auto-Discovery - -The extension discovers the bridge endpoint automatically: - -1. **VS Code setting**: `apeironcode.bridgeEndpoint` if configured. -2. **Connection file**: `.apeironcode-agent/bridge-connection.json` written by `bridge start`. -3. **Manual**: User is prompted if neither is available. - -The connection file contains endpoint + fingerprint only — the full token is never written there. - -## Bridge CLI Commands (Phase 16F.1) - -```bash -# Start bridge — writes connection file at .apeironcode-agent/bridge-connection.json -apeironcode bridge start - -# Check status — shows endpoint from connection file -apeironcode bridge status - -# Show token fingerprint (never full token) -apeironcode bridge token - -# Show full token (requires --show flag) -apeironcode bridge token --show - -# Stop bridge — removes connection file -apeironcode bridge stop -``` - -## Project Brain Commands (Phase 16G.2) - -| Command | Description | -|---|---| -| `ApeironCode: Open Project Brain Panel` | Open the full brain control center panel | -| `ApeironCode: Route Prompt to Brain` | Route a prompt to the best agents/skills | -| `ApeironCode: Preview Brain Context` | Preview token-efficient context selection | -| `ApeironCode: Build App Plan` | Generate a large app build plan | -| `ApeironCode: Orchestrate App Build` | Full orchestration: vision, stack, phases, agents | -| `ApeironCode: Show Saved Brain Previews` | Open Sync Previews panel | -| `ApeironCode: Audit Project Brain` | Run brain health audit | -| `ApeironCode: Sync Preview` | Preview a brain sync before writing | -| `ApeironCode: Apply Brain Sync` | Apply a sync preview (requires confirmation) | - -### App Build Plan View - -The **App Build Plan** panel shows a rich orchestration result: - -- Product vision, assumptions, and up to 3 clarifying questions. -- Detected stack with technology tags. -- Architecture outline. -- Phased build plan with per-phase task lists. -- Suggested agents with role and reasoning. -- Suggested project skills. -- Verification plan, risk list, first 3 next actions. -- Token strategy for long builds. - -Action buttons: - -- **Initialize Project Brain from this Plan** — requires explicit VS Code confirmation dialog before writing anything. -- **Copy Plan** — copies the full plan text to clipboard. -- **Start First Task** — sends the first action to the agent session (requires connection). -- **Preview Brain Context** — opens context preview in Project Brain panel. - -### Sync Previews View - -The **Sync Previews** panel lists all saved sync previews: - -- Shows preview ID, creation timestamp, target files, risk level, and stale warning. -- Stale previews (older than 7 days) are flagged with a warning banner. -- Secrets are redacted in the changes summary display. -- **Apply** requires an explicit VS Code confirmation modal. No writes without `approved:true`. -- High-risk previews show an extra warning in the confirmation dialog. - -## Runtime Brain Intelligence (Phase 16H) - -The Project Brain panel now includes a **Runtime Brain Intelligence** section: - -| Button | Bridge message | What it does | -|---|---|---| -| **Preview Brain Runtime Decision** | `brain.runtime` | Classifies prompt intent, shows whether brain will be used, confidence %, brain files, agents, token estimate | -| **Explain Brain Context** | `brain.explain` | Full debug explanation of how context was selected for the prompt | - -When the bridge returns a `brain.runtime` response with `useBrain=true`, an information notification appears: -> `Project Brain: debug-fix context injected (120 tokens)` - -The **Brain Used / Brain Skipped** badge appears in the panel with: -- Intent label (e.g. "Debug / Fix", "Continuation", "App Build") -- Confidence percentage -- Token estimate -- List of selected brain files (up to 6) -- List of suggested agents/skills (up to 4) -- Full debug explanation (when brain.explain response arrives) - -All content is HTML-escaped and protected by the webview CSP. No secrets appear in the panel output. - -## Current MVP Features - -- ✅ Sidebar chat with **real prompt submission** to live agent sessions -- ✅ Connection status indicator -- ✅ Bridge start/stop commands + connection file auto-discovery -- ✅ Selected file/range context attachment -- ✅ Permission request approve/deny cards -- ✅ Diff preview with risky-path warnings -- ✅ Task and worktree status -- ✅ Context view (files, token budget, model, runtime brain section) -- ✅ Tool event streaming (tool.started / tool.completed in chat) -- ✅ Session busy detection (rejects concurrent prompts cleanly) -- ✅ Project Brain control center panel with all brain actions -- ✅ App Build Plan rich view with phases, agents, skills, risks -- ✅ Sync Previews view with stale detection and secret redaction -- ✅ Runtime Brain Intelligence panel (intent, confidence, files, agents, tokens) - -## VSIX Packaging + Beta Install (Phase 16F.2) - -### Build and package the extension - -```bash -cd extensions/vscode -npm install -npm run package -# Produces: apeironcode-0.1.0.vsix -``` - -Or from the project root: - -```bash -npm run vscode:package -``` - -### Install from VSIX - -```bash -code --install-extension apeironcode-0.1.0.vsix -``` - -Or via VS Code UI: **Extensions** → **…** → **Install from VSIX…**. - -### Troubleshooting - -| Problem | Solution | -|---|---| -| Bridge not found | Run `apeironcode bridge start` in project dir | -| Auth failed | Re-run **ApeironCode: Start Bridge Connection** | -| Port already in use | Run `apeironcode bridge stop` | -| CLI not found | Set `apeironcode.cli.path` setting | - -### Bridge Auto-Start - -Enable `apeironcode.bridge.autoStart: true` to automatically start the bridge when a trusted workspace is opened. Requires the CLI on PATH or configured via `apeironcode.cli.path`. - -**Security**: auto-start is blocked in untrusted workspaces. Never exposes the token. - -### Model Selection - -Run **ApeironCode: Select Model / Provider** to choose a model from a quick-pick list. The selection is stored in workspace settings as `apeironcode.selectedModel` and used for new agent sessions. - -When the bridge is connected, the extension requests the provider catalog. When disconnected, a safe static fallback list is shown. - -## Production Hardening (Phase 16F.3) - -### Live Provider / Model Catalog - -The bridge now serves `provider.list` returning a safe catalog (no API keys, no env values): - -```bash -# Response includes: provider id, label, kind, configured status, models with contextWindow/toolCalling/streaming -``` - -The VS Code model selector loads live catalog when connected; falls back to a static list when disconnected. - -### Diff Apply Flow - -`diff.apply_requested` messages are permission-gated: -- Non-approved requests return `approval_required` -- Risky paths (`.env`, `package.json`, `node_modules/`, etc.) flag explicitly -- Path traversal rejected -- Oversized patches rejected (>50,000 chars) -- Direct FS apply via bridge is staged (not auto-applied) — see Known Limitations - -### Health Checklist - -Run `ApeironCode: Show Setup` to see a 7-item health report: -- CLI path ✓/⚠ -- Bridge endpoint ✓/⚠ -- Bridge token ✓/✗ -- Bridge connection ✓/✗ -- Model selected ✓/⚠ -- Workspace trusted ✓/⚠ -- Extension version ✓ - -### Error Recovery - -Each error type maps to a human-readable message with a command hint: -- `AUTH_FAILED` → Start Bridge -- `CONNECTION_REFUSED` → Show Setup -- `AGENT_FAILED` → Run Doctor -- `MODEL_UNAVAILABLE` → Select Model -- `SESSION_BUSY` → Open Chat -- (and 9 more error codes) - -## Known Limitations - -- **No auto-apply** of diffs via bridge — returns `unsupported` with guidance; apply manually or via CLI. -- **No JetBrains** or other IDE extension yet. -- **No Marketplace release** yet — install from VSIX only. -- **No remote/cloud** bridge — local-only by design. -- The bridge server runs for the lifetime of the CLI process that started it. -- `provider.list` catalog is read-only; no API key configuration from extension. - -## Extension Location - -Source: `extensions/vscode/` - -``` -extensions/vscode/ -├── package.json # VS Code extension manifest -├── tsconfig.json -├── CHANGELOG.md -├── .vscodeignore # VSIX packaging exclusions -├── src/ -│ ├── extension.ts # activation entry point -│ ├── config.ts # settings + secrets -│ ├── logger.ts # safe logging (no token in logs) -│ ├── types.ts # shared type interfaces -│ ├── bridge/ -│ │ ├── client.ts # BridgeClient (WebSocket) -│ │ ├── messages.ts # message factories (incl. send_prompt) -│ │ ├── auth.ts # token fingerprint helpers -│ │ ├── discovery.ts # bridge endpoint auto-discovery -│ │ └── autoStart.ts # bridge auto-start helper -│ ├── views/ -│ │ ├── chatPanel.ts -│ │ ├── webviewHtml.ts # incl. buildSetupHtml onboarding -│ │ └── messageStore.ts -│ ├── context/ -│ │ ├── selection.ts -│ │ └── contextView.ts -│ ├── permissions/ -│ │ ├── permissionStore.ts -│ │ └── permissionPanel.ts -│ ├── diff/ -│ │ └── diffPreview.ts -│ ├── tasks/ -│ │ └── taskView.ts -│ ├── status/ -│ │ └── statusBar.ts # bridge status bar item -│ └── model/ -│ └── modelSelector.ts # model/provider quick-pick -└── test/ # Vitest tests (mock VS Code APIs) -``` +The local bridge APIs may support future IDE integrations, but they should be +treated as experimental internal interfaces. Use the `apeironcode` CLI for all +documented public-alpha workflows. diff --git a/docs/workflows.md b/docs/workflows.md index 6956f29..5766419 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -85,8 +85,6 @@ apeironcode brain previews apply --yes This workflow is **deterministic** — each command either previews or requires `--yes`. No silent writes. No autonomous execution without your explicit approval at each step. -In VS Code, the same workflow is available through the **Project Brain Panel**, **App Build Plan View**, and **Sync Previews View**, each of which shows a VS Code confirmation dialog before writing anything. - ## Runtime Brain Integration (Phase 16H) When you run any prompt, the agent automatically classifies intent and injects relevant Project Brain context without requiring any extra commands: diff --git a/examples/demo/workspace-ignore/.opencodeignore b/examples/demo/workspace-ignore/.apeironcodeignore similarity index 100% rename from examples/demo/workspace-ignore/.opencodeignore rename to examples/demo/workspace-ignore/.apeironcodeignore diff --git a/package-lock.json b/package-lock.json index 1c15523..c89a0aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "apeironcode-agent", + "name": "apeironcode", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "apeironcode-agent", + "name": "apeironcode", "version": "0.1.0", "license": "MIT", "dependencies": { @@ -13,7 +13,7 @@ "cli-highlight": "^2.1.11", "commander": "^13.1.0", "conf": "^13.1.0", - "diff": "^7.0.0", + "diff": "9.0.0", "execa": "^9.6.0", "fast-glob": "^3.3.3", "ignore": "^7.0.5", @@ -22,13 +22,12 @@ "ora": "^8.2.0", "picocolors": "^1.1.1", "react": "^18.3.1", - "ws": "^8.0.0", + "ws": "8.21.0", "zod": "^3.24.2", "zod-to-json-schema": "^3.25.2" }, "bin": { - "apeironcode": "dist/cli/index.js", - "opencode": "dist/cli/index.js" + "apeironcode": "dist/cli/index.js" }, "devDependencies": { "@eslint/js": "^9.21.0", @@ -47,7 +46,7 @@ "tsup": "^8.4.0", "tsx": "^4.19.3", "typescript": "^5.8.2", - "vitest": "^3.0.8" + "vitest": "4.1.8" }, "engines": { "node": ">=18.18.0" @@ -78,6 +77,40 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -564,24 +597,6 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/config-array/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/@eslint/config-array/node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -662,24 +677,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/@eslint/eslintrc/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/@eslint/eslintrc/node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -865,6 +862,25 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -900,6 +916,280 @@ "node": ">= 8" } }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.60.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", @@ -1268,6 +1558,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/aws4": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@types/aws4/-/aws4-1.11.6.tgz", @@ -1589,39 +1897,40 @@ } }, "node_modules/@vitest/expect": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", - "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", "dev": true, "license": "MIT", "dependencies": { + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", - "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.2.4", + "@vitest/spy": "4.1.8", "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -1633,42 +1942,42 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", - "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^2.0.0" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", - "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.2.4", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" + "@vitest/utils": "4.1.8", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", - "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", - "magic-string": "^0.30.17", + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", + "magic-string": "^0.30.21", "pathe": "^2.0.3" }, "funding": { @@ -1676,28 +1985,25 @@ } }, "node_modules/@vitest/spy": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", - "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", "dev": true, "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", - "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.2.4", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" + "@vitest/pretty-format": "4.1.8", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -1863,9 +2169,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -1924,18 +2230,11 @@ } }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { "node": ">=18" } @@ -1956,16 +2255,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -2203,13 +2492,6 @@ "node": ">=18" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/conf": { "version": "13.1.0", "resolved": "https://registry.npmjs.org/conf/-/conf-13.1.0.tgz", @@ -2250,6 +2532,13 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-to-spaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", @@ -2313,16 +2602,6 @@ } } }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2330,10 +2609,20 @@ "dev": true, "license": "MIT" }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/diff": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", - "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -2385,9 +2674,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", "dev": true, "license": "MIT" }, @@ -2588,24 +2877,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/eslint/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/eslint/node_modules/eslint-visitor-keys": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", @@ -2821,9 +3092,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -3295,107 +3566,368 @@ "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", "license": "MIT", "engines": { - "node": ">=18" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", - "license": "MIT", + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/joycon": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", - "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=10" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "license": "BSD-2-Clause" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.8.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/lilconfig": { @@ -3503,13 +4035,6 @@ "loose-envify": "cli.js" } }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -3611,9 +4136,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -3673,6 +4198,20 @@ "node": ">=0.10.0" } }, + "node_modules/obug": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz", + "integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/onetime": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", @@ -3900,16 +4439,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3951,9 +4480,9 @@ } }, "node_modules/postcss": { - "version": "8.5.12", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", - "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -3971,7 +4500,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -4205,6 +4734,40 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, "node_modules/rollup": { "version": "4.60.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", @@ -4426,9 +4989,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", "dev": true, "license": "MIT" }, @@ -4501,26 +5064,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/strip-literal": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", - "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/strip-literal/node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, "node_modules/stubborn-fs": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", @@ -4617,9 +5160,9 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -4664,30 +5207,10 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, "node_modules/tinyrainbow": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", - "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", - "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -4736,6 +5259,14 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/tsup": { "version": "8.5.1", "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", @@ -4907,18 +5438,17 @@ } }, "node_modules/vite": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", - "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -4934,9 +5464,10 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", - "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -4949,13 +5480,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { + "optional": true + }, + "jiti": { "optional": true }, - "lightningcss": { + "less": { "optional": true }, "sass": { @@ -4981,47 +5515,6 @@ } } }, - "node_modules/vite-node": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", - "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vite/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, "node_modules/vite/node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", @@ -5036,65 +5529,79 @@ } }, "node_modules/vitest": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", - "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.4", - "@vitest/mocker": "3.2.4", - "@vitest/pretty-format": "^3.2.4", - "@vitest/runner": "3.2.4", - "@vitest/snapshot": "3.2.4", - "@vitest/spy": "3.2.4", - "@vitest/utils": "3.2.4", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.4", - "@vitest/ui": "3.2.4", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { "optional": true }, - "@types/debug": { + "@opentelemetry/api": { "optional": true }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { "optional": true }, "@vitest/ui": { @@ -5105,6 +5612,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, @@ -5121,6 +5631,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/when-exit": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", @@ -5214,9 +5734,9 @@ } }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index 4398eef..099e10e 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "apeironcode-agent", + "name": "apeironcode", "version": "0.1.0", "description": "Open-source, local-first AI coding agent. Use any model, see every action, and keep your code under your rules.", "displayName": "ApeironCode", @@ -10,8 +10,7 @@ "type": "module", "license": "MIT", "bin": { - "apeironcode": "./dist/cli/index.js", - "opencode": "./dist/cli/index.js" + "apeironcode": "./dist/cli/index.js" }, "files": [ "dist", @@ -37,6 +36,7 @@ "docs/providers.md", "docs/publishing.md", "docs/safety.md", + "docs/sandbox.md", "docs/security-limits.md", "docs/security-model.md", "docs/sessions.md", @@ -45,6 +45,7 @@ "docs/skills.md", "docs/tasks.md", "docs/tools.md", + "docs/testing.md", "docs/troubleshooting.md", "docs/tui.md", "docs/ui-ux.md", @@ -119,8 +120,9 @@ "smoke:terminal-ux": "node scripts/manual-terminal-ux-smoke.mjs", "smoke:workflows": "vitest run tests/workflows", "smoke:all": "npm run smoke:cli && npm run smoke:workflows", + "check:audit": "npm audit --audit-level=moderate", "acceptance": "npm run typecheck && npm run lint && npm run test && npm run build", - "ci": "npm run typecheck && npm run lint && npm run build && npm test && npm run check:file-size && npm pack --dry-run" + "ci": "npm run typecheck && npm run lint && npm run build && npm test && npm run check:file-size && npm run check:audit && npm pack --dry-run" }, "keywords": [ "ai", @@ -144,7 +146,7 @@ "cli-highlight": "^2.1.11", "commander": "^13.1.0", "conf": "^13.1.0", - "diff": "^7.0.0", + "diff": "9.0.0", "execa": "^9.6.0", "fast-glob": "^3.3.3", "ignore": "^7.0.5", @@ -153,7 +155,7 @@ "ora": "^8.2.0", "picocolors": "^1.1.1", "react": "^18.3.1", - "ws": "^8.0.0", + "ws": "8.21.0", "zod": "^3.24.2", "zod-to-json-schema": "^3.25.2" }, @@ -174,6 +176,10 @@ "tsup": "^8.4.0", "tsx": "^4.19.3", "typescript": "^5.8.2", - "vitest": "^3.0.8" + "vitest": "4.1.8" + }, + "overrides": { + "brace-expansion": "5.0.6", + "fast-uri": "3.1.2" } } diff --git a/src/agent/planner.ts b/src/agent/planner.ts index 5580b0a..380c684 100644 --- a/src/agent/planner.ts +++ b/src/agent/planner.ts @@ -1,11 +1,9 @@ import {AppError} from '../utils/errors.js'; import type {ToolDirective} from './types.js'; -// Legacy XML-style tool-call envelope. The production tool-calling path is -// native ToolSchema/ToolRegistry; this pattern only exists to recognise -// pre-rebrand legacy emissions (`...`) and the canonical -// generic `...` form. -const TOOL_CALL_BLOCK_PATTERN = /<(?:apeironcode_tool_call|opencode_tool_call|tool_call)>\s*([\s\S]*?)\s*<\/(?:apeironcode_tool_call|opencode_tool_call|tool_call)>/gu; +// Compatibility parser only. Production tool execution uses ToolSchema and +// ToolRegistry rather than text directives. +const TOOL_CALL_BLOCK_PATTERN = /<(?:apeironcode_tool_call|tool_call)>\s*([\s\S]*?)\s*<\/(?:apeironcode_tool_call|tool_call)>/gu; export interface ToolDirectiveAnalysis { directives: ToolDirective[]; @@ -75,8 +73,7 @@ const looksLikeToolDirective = (raw: string): boolean => { || trimmed.startsWith('[') || trimmed.startsWith('```json') || trimmed.includes('') - || trimmed.includes('') - || trimmed.includes(''); + || trimmed.includes(''); }; export const analyzeToolDirectives = (raw: string): ToolDirectiveAnalysis => { @@ -119,4 +116,4 @@ export const assertIterationBudget = (iterations: number, maxIterations: number) 'AGENT_ITERATION_LIMIT', ); } -}; \ No newline at end of file +}; diff --git a/src/agents/workspace/ignoreRules.ts b/src/agents/workspace/ignoreRules.ts index 139c24f..96c64e9 100644 --- a/src/agents/workspace/ignoreRules.ts +++ b/src/agents/workspace/ignoreRules.ts @@ -22,7 +22,7 @@ const BUILTIN_IGNORES = [ export interface WorkspaceIgnoreMatch { path: string; rule: string; - source: 'builtin' | 'gitignore' | 'apeironcodeignore' | 'opencodeignore'; + source: 'builtin' | 'gitignore' | 'apeironcodeignore'; } export interface WorkspaceIgnoreRules { @@ -68,8 +68,6 @@ export const loadWorkspaceIgnoreRules = async (root: string): Promise ({pattern, source: 'builtin' as const})), ...await readIgnoreFile(root, '.gitignore', 'gitignore'), ...await readIgnoreFile(root, '.apeironcodeignore', 'apeironcodeignore'), - // Legacy fallback for projects that still use `.opencodeignore`. - ...await readIgnoreFile(root, '.opencodeignore', 'opencodeignore'), ]; return { diff --git a/src/agents/workspace/types.ts b/src/agents/workspace/types.ts index 6f8a5ae..84f72d9 100644 --- a/src/agents/workspace/types.ts +++ b/src/agents/workspace/types.ts @@ -33,7 +33,7 @@ export interface WorkspaceDiff { ignoredFiles?: Array<{ path: string; rule: string; - source: 'builtin' | 'gitignore' | 'apeironcodeignore' | 'opencodeignore'; + source: 'builtin' | 'gitignore' | 'apeironcodeignore'; }>; workspace: SubagentWorkspace; } diff --git a/src/bridge/connectionFile.ts b/src/bridge/connectionFile.ts index 26d8e92..81f99a8 100644 --- a/src/bridge/connectionFile.ts +++ b/src/bridge/connectionFile.ts @@ -1,6 +1,6 @@ /** * ApeironCode Bridge — Runtime connection file. - * Written when bridge starts so VS Code extension can auto-discover endpoint. + * Written when the bridge starts so a future IDE client can discover it. * Contains endpoint + token fingerprint ONLY — never the full token. */ diff --git a/src/bridge/server.ts b/src/bridge/server.ts index 8a09365..f7226c9 100644 --- a/src/bridge/server.ts +++ b/src/bridge/server.ts @@ -457,7 +457,7 @@ export class BridgeServer { const rawSummary = msg.payload['summary']; const summary = createRunSummaryFromAgentResult( {finalMessage: typeof rawSummary === 'string' ? rawSummary : 'Manual Project Brain update', status: 'completed'}, - {prompt: 'VS Code Project Brain update'}, + {prompt: 'IDE bridge Project Brain update'}, ); const appended = await appendRunSummary(cwd, summary, {approved, enabled: approved}); await connection.send(sanitizeBridgeMessage(createBridgeMessage('brain.update', {appended}, {requestId}))); diff --git a/src/cli/setup/index.ts b/src/cli/setup/index.ts index 0fdc3ce..9f1c64f 100644 --- a/src/cli/setup/index.ts +++ b/src/cli/setup/index.ts @@ -5,13 +5,8 @@ import type {BootstrapRuntimeContext} from './runtimeContext.js'; import {createBootstrapSessionStore, createBootstrapTaskStore} from './session.js'; import {initializeToolRegistry, validateToolRegistry} from './tools.js'; import {createCliProgram} from './ui.js'; -import {migrateLegacyAppHome} from '../../utils/paths.js'; export const runCli = async (): Promise => { - // Migrate a legacy `~/.opencode-agent` home to the ApeironCode-branded - // `~/.apeironcode-agent` home before anything reads or writes config, so - // all user-facing paths and new writes are ApeironCode-first. - migrateLegacyAppHome(); const cwd = process.cwd(); const providerRegistry = initializeProviderRegistry(); const toolRegistry = initializeToolRegistry(); diff --git a/src/config/config.ts b/src/config/config.ts index 933d93c..94ba2a2 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -164,13 +164,6 @@ export const ApeironCodeConfigSchema = z.object({ export type ApeironCodeConfig = z.infer; export type ApeironCodeConfigInput = Partial; -/** @deprecated Use ApeironCodeConfigSchema. Compatibility alias for legacy OpenCode brand. */ -export const OpenCodeConfigSchema = ApeironCodeConfigSchema; -/** @deprecated Use ApeironCodeConfig. Compatibility alias for legacy OpenCode brand. */ -export type OpenCodeConfig = ApeironCodeConfig; -/** @deprecated Use ApeironCodeConfigInput. Compatibility alias for legacy OpenCode brand. */ -export type OpenCodeConfigInput = ApeironCodeConfigInput; - export type ConfigKey = | 'provider' | 'model' diff --git a/src/connectors/github/webhooks.ts b/src/connectors/github/webhooks.ts index 285865e..a8c7a0d 100644 --- a/src/connectors/github/webhooks.ts +++ b/src/connectors/github/webhooks.ts @@ -113,9 +113,7 @@ export const parseMentionCommand = ( if (!commentBody) { return null; } - // Recognise the legacy `@opencode` tag in addition to the canonical - // `@apeironcode` tag so existing comments and integrations keep working. - const tags = mentionTag === '@apeironcode' ? ['@apeironcode', '@opencode'] : [mentionTag]; + const tags = [mentionTag]; const lines = commentBody.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean); for (const line of lines) { const lower = line.toLowerCase(); diff --git a/src/context/ignore.ts b/src/context/ignore.ts index a484007..fb4e83b 100644 --- a/src/context/ignore.ts +++ b/src/context/ignore.ts @@ -32,18 +32,15 @@ const readIgnoreFile = async (filePath: string): Promise => { }; export const loadProjectIgnorePatterns = async (cwd: string): Promise => { - const [gitIgnore, apeironIgnore, legacyOpenCodeIgnore] = await Promise.all([ + const [gitIgnore, apeironIgnore] = await Promise.all([ readIgnoreFile(path.join(cwd, '.gitignore')), readIgnoreFile(path.join(cwd, '.apeironcodeignore')), - // Legacy fallback for projects that still use `.opencodeignore`. - readIgnoreFile(path.join(cwd, '.opencodeignore')), ]); return Array.from(new Set([ ...DEFAULT_IGNORE_PATTERNS, ...gitIgnore, ...apeironIgnore, - ...legacyOpenCodeIgnore, ])); }; @@ -62,4 +59,4 @@ export const listProjectFiles = async (cwd: string, patterns: string[]): Promise }); const matcher = createIgnoreMatcher(patterns); return entries.filter((entry) => !matcher.ignores(entry)); -}; \ No newline at end of file +}; diff --git a/src/diagnostics/doctor.ts b/src/diagnostics/doctor.ts index 02f7ce5..4a6b31b 100644 --- a/src/diagnostics/doctor.ts +++ b/src/diagnostics/doctor.ts @@ -420,7 +420,7 @@ export const runDoctor = async ({ }); checks.push(...await buildMemoryTokenEvalChecks(cwd, config)); checks.push(...await buildTaskWorktreeDoctorChecks(cwd)); - checks.push(...await buildBridgeChecks(cwd)); + checks.push(...buildBridgeChecks(cwd)); checks.push({ detail: 'Not enabled. Team subagents share this process environment and configured provider/connector credentials.', fix: 'Use least-privilege environment tokens and avoid exporting secrets unrelated to the task.', diff --git a/src/diagnostics/extraChecks.ts b/src/diagnostics/extraChecks.ts index 41cc7d1..a1fab21 100644 --- a/src/diagnostics/extraChecks.ts +++ b/src/diagnostics/extraChecks.ts @@ -220,20 +220,11 @@ export const buildTaskWorktreeDoctorChecks = async (cwd: string): Promise => { - return buildBridgeChecksSync(cwd, await checkVsCodeExtensionPresent(cwd)); +export const buildBridgeChecks = (cwd: string): DoctorCheck[] => { + return buildBridgeChecksSync(cwd); }; -const checkVsCodeExtensionPresent = async (cwd: string): Promise => { - try { - await fs.access(path.join(cwd, 'extensions', 'vscode', 'package.json')); - return true; - } catch { - return false; - } -}; - -const buildBridgeChecksSync = (cwd: string, vscodePkgPresent: boolean): DoctorCheck[] => { +const buildBridgeChecksSync = (cwd: string): DoctorCheck[] => { const checks: DoctorCheck[] = []; checks.push({ @@ -290,32 +281,6 @@ const buildBridgeChecksSync = (cwd: string, vscodePkgPresent: boolean): DoctorCh status: 'pass', }); - checks.push({ - label: 'VS Code extension: package', - detail: vscodePkgPresent - ? 'VS Code extension package present at extensions/vscode/' - : 'VS Code extension package not found at extensions/vscode/', - status: vscodePkgPresent ? 'pass' : 'warn', - }); - - checks.push({ - label: 'VS Code extension: bridge transport', - detail: 'WebSocket transport available for VS Code extension bridge client', - status: 'pass', - }); - - checks.push({ - label: 'VS Code extension: permission bridge', - detail: 'permission.requested / approved / denied messages supported', - status: 'pass', - }); - - checks.push({ - label: 'VS Code extension: diff preview', - detail: 'diff.preview message supported; no auto-apply in MVP', - status: 'pass', - }); - return checks; }; diff --git a/src/githubAction/config.ts b/src/githubAction/config.ts index 276d366..b63b865 100644 --- a/src/githubAction/config.ts +++ b/src/githubAction/config.ts @@ -50,9 +50,6 @@ const parseList = (value: string | undefined): string[] => { return value.split(/[,\s]+/u).map((item) => item.trim()).filter(Boolean); }; -// Resolve an env var, preferring the new APEIRONCODE_* name and falling back -// to the legacy OPENCODE_* name so existing GitHub Action workflows continue -// to work without modification. const pickEnv = ( env: Record, ...keys: string[] @@ -67,12 +64,12 @@ const pickEnv = ( }; export const loadActionConfigFromEnv = (env: Record = process.env): ActionConfig => ({ - allowedTools: parseList(pickEnv(env, 'APEIRONCODE_ALLOWED_TOOLS', 'OPENCODE_ALLOWED_TOOLS', 'INPUT_ALLOWED_TOOLS')), - configPath: pickEnv(env, 'APEIRONCODE_CONFIG_PATH', 'OPENCODE_CONFIG_PATH', 'INPUT_APEIRONCODE_CONFIG_PATH', 'INPUT_OPENCODE_CONFIG_PATH'), - dryRun: parseBool(pickEnv(env, 'INPUT_DRY_RUN', 'APEIRONCODE_DRY_RUN', 'OPENCODE_DRY_RUN'), true), - maxIterations: Number.parseInt(pickEnv(env, 'INPUT_MAX_ITERATIONS', 'APEIRONCODE_MAX_ITERATIONS', 'OPENCODE_MAX_ITERATIONS') ?? '6', 10) || 6, - mode: parseMode(pickEnv(env, 'INPUT_MODE', 'APEIRONCODE_MODE', 'OPENCODE_MODE')), - model: pickEnv(env, 'INPUT_MODEL', 'APEIRONCODE_MODEL', 'OPENCODE_MODEL'), - provider: pickEnv(env, 'INPUT_PROVIDER', 'APEIRONCODE_PROVIDER', 'OPENCODE_PROVIDER'), - runTests: parseBool(pickEnv(env, 'INPUT_RUN_TESTS', 'APEIRONCODE_RUN_TESTS', 'OPENCODE_RUN_TESTS'), false), + allowedTools: parseList(pickEnv(env, 'APEIRONCODE_ALLOWED_TOOLS', 'INPUT_ALLOWED_TOOLS')), + configPath: pickEnv(env, 'APEIRONCODE_CONFIG_PATH', 'INPUT_APEIRONCODE_CONFIG_PATH'), + dryRun: parseBool(pickEnv(env, 'INPUT_DRY_RUN', 'APEIRONCODE_DRY_RUN'), true), + maxIterations: Number.parseInt(pickEnv(env, 'INPUT_MAX_ITERATIONS', 'APEIRONCODE_MAX_ITERATIONS') ?? '6', 10) || 6, + mode: parseMode(pickEnv(env, 'INPUT_MODE', 'APEIRONCODE_MODE')), + model: pickEnv(env, 'INPUT_MODEL', 'APEIRONCODE_MODEL'), + provider: pickEnv(env, 'INPUT_PROVIDER', 'APEIRONCODE_PROVIDER'), + runTests: parseBool(pickEnv(env, 'INPUT_RUN_TESTS', 'APEIRONCODE_RUN_TESTS'), false), }); diff --git a/src/githubAction/runner.ts b/src/githubAction/runner.ts index ba732a3..aececf7 100644 --- a/src/githubAction/runner.ts +++ b/src/githubAction/runner.ts @@ -27,7 +27,6 @@ const buildAutomationOptions = (config: ActionConfig): AutomationOptions => ({ }); const RUN_MARKER = ''; -const LEGACY_RUN_MARKER = ''; export const buildActionRunMarker = (): string => RUN_MARKER; @@ -71,10 +70,7 @@ export const runActionFromEvent = async (input: RunActionInput): Promise; if (typeof parsed.cmd !== 'string' || typeof parsed.repo !== 'string' || typeof parsed.n !== 'number') { @@ -158,9 +150,6 @@ export const findExistingApeironCodePr = async ( return null; }; -/** @deprecated Use findExistingApeironCodePr. Compatibility alias for legacy OpenCode brand. */ -export const findExistingOpenCodePr = findExistingApeironCodePr; - export const dedupeReviewCommentBodies = ( newComments: Array<{body: string; path: string}>, existingBodies: string[], diff --git a/src/githubAutomation/permissions.ts b/src/githubAutomation/permissions.ts index 99dc8bc..6e4f615 100644 --- a/src/githubAutomation/permissions.ts +++ b/src/githubAutomation/permissions.ts @@ -2,28 +2,21 @@ import type {AutomationPermissionConfig, AutomationOptions} from './types.js'; import {DEFAULT_AUTOMATION_PERMISSIONS} from './types.js'; const ENV_FLAG = 'APEIRONCODE_AUTOMATION'; -const LEGACY_ENV_FLAG = 'OPENCODE_AUTOMATION'; - -const pick = ( - env: Record, - primary: string, - legacy: string, -): string | undefined => env[primary] ?? env[legacy]; export const loadAutomationPermissionsFromEnv = ( env: Record = process.env, ): AutomationPermissionConfig => { - if (pick(env, ENV_FLAG, LEGACY_ENV_FLAG) !== '1') { + if (env[ENV_FLAG] !== '1') { return {...DEFAULT_AUTOMATION_PERMISSIONS}; } - const actors = pick(env, 'APEIRONCODE_AUTOMATION_ACTORS', 'OPENCODE_AUTOMATION_ACTORS'); - const repos = pick(env, 'APEIRONCODE_AUTOMATION_REPOS', 'OPENCODE_AUTOMATION_REPOS'); - const denyActors = pick(env, 'APEIRONCODE_AUTOMATION_DENY_ACTORS', 'OPENCODE_AUTOMATION_DENY_ACTORS'); + const actors = env.APEIRONCODE_AUTOMATION_ACTORS; + const repos = env.APEIRONCODE_AUTOMATION_REPOS; + const denyActors = env.APEIRONCODE_AUTOMATION_DENY_ACTORS; return { - allowComment: pick(env, 'APEIRONCODE_AUTOMATION_COMMENT', 'OPENCODE_AUTOMATION_COMMENT') === '1', - allowCommit: pick(env, 'APEIRONCODE_AUTOMATION_COMMIT', 'OPENCODE_AUTOMATION_COMMIT') === '1', - allowPrCreate: pick(env, 'APEIRONCODE_AUTOMATION_PR_CREATE', 'OPENCODE_AUTOMATION_PR_CREATE') === '1', - allowReview: pick(env, 'APEIRONCODE_AUTOMATION_REVIEW', 'OPENCODE_AUTOMATION_REVIEW') === '1', + allowComment: env.APEIRONCODE_AUTOMATION_COMMENT === '1', + allowCommit: env.APEIRONCODE_AUTOMATION_COMMIT === '1', + allowPrCreate: env.APEIRONCODE_AUTOMATION_PR_CREATE === '1', + allowReview: env.APEIRONCODE_AUTOMATION_REVIEW === '1', allowedActors: actors?.split(',').map((r) => r.trim()).filter(Boolean), allowedRepos: repos?.split(',').map((r) => r.trim()).filter(Boolean), deniedActors: denyActors?.split(',').map((r) => r.trim()).filter(Boolean), diff --git a/src/projectBrain/audit.ts b/src/projectBrain/audit.ts index 26ed266..ed26da8 100644 --- a/src/projectBrain/audit.ts +++ b/src/projectBrain/audit.ts @@ -112,11 +112,6 @@ export const auditProjectBrainFeatures = async ( 'brain.audit', 'brain.sync_preview', 'brain.sync_apply', 'brain.build_plan']; checks.push(check('bridge brain messages', 'available', bridgeMessages.join(', '))); - // VS Code commands - const vscodeCommands = ['apeironcode.brainPlan', 'apeironcode.brainInit', 'apeironcode.brainStatus', - 'apeironcode.brainShow', 'apeironcode.brainUpdate', 'apeironcode.showBrainView']; - checks.push(check('VS Code brain commands', 'available', vscodeCommands.join(', '))); - // doctor checks checks.push(check('doctor checks', 'available', 'Brain status included in doctor output')); diff --git a/src/sandbox/format.ts b/src/sandbox/format.ts index c6792cf..9ec941d 100644 --- a/src/sandbox/format.ts +++ b/src/sandbox/format.ts @@ -14,7 +14,10 @@ export const formatSandboxStatus = (status: SandboxStatus): string => { 'Current limits', ...status.limitations.map((limit) => `- ${limit}`), '', - 'Next step: use approvals, workspace isolation, and review flows for safety; OS sandboxed execution is not enabled.', + 'OS-level sandboxing is not enabled in this public CLI alpha.', + 'Command execution is approval-gated, but not process-isolated by default.', + 'Docker/Podman/Firejail runners are experimental and not wired as default isolation.', + 'Approval gating is not the same as OS-level process isolation.', ].join('\n'); }; @@ -43,4 +46,3 @@ export const formatSandboxExecutionResult = (result: SandboxExecutionResult): st return lines.join('\n'); }; - diff --git a/src/sandbox/manager.ts b/src/sandbox/manager.ts index e55552f..78c6f0a 100644 --- a/src/sandbox/manager.ts +++ b/src/sandbox/manager.ts @@ -76,10 +76,7 @@ export class SandboxManager { } } - // `APEIRONCODE_TEST_OFFLINE` is preferred; `OPENCODE_TEST_OFFLINE` is kept as - // a deprecated legacy alias for existing CI/automation. - const testOffline = - process.env['APEIRONCODE_TEST_OFFLINE'] === '1' || process.env['OPENCODE_TEST_OFFLINE'] === '1'; + const testOffline = process.env['APEIRONCODE_TEST_OFFLINE'] === '1'; if (testOffline && this.options.allowFallbackToLocal !== false) { this.availableBackend = 'local'; return 'local'; diff --git a/src/ui/welcomeDashboard.ts b/src/ui/welcomeDashboard.ts index 37e9f3b..7b163c4 100644 --- a/src/ui/welcomeDashboard.ts +++ b/src/ui/welcomeDashboard.ts @@ -78,7 +78,7 @@ export const buildWelcomeDashboardModel = (context: WelcomeDashboardContext): We ], version: context.version ?? 'dev', whatsNew: context.showWhatsNew === false ? [] : [ - 'Premium CLI and VS Code UI polish.', + 'Premium CLI UI polish.', 'Runtime, memory, context, and token intelligence are active.', ], workspace: shortenPath(context.cwd), diff --git a/src/utils/logger.ts b/src/utils/logger.ts index ad125b7..9841374 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,9 +1,6 @@ import pc from 'picocolors'; -// Read primary `APEIRONCODE_AGENT_DEBUG` and fall back to the legacy -// `OPENCODE_AGENT_DEBUG` env var so existing automation keeps working. -const isDebugEnabled = - process.env.APEIRONCODE_AGENT_DEBUG === '1' || process.env.OPENCODE_AGENT_DEBUG === '1'; +const isDebugEnabled = process.env.APEIRONCODE_AGENT_DEBUG === '1'; export const logger = { debug(message: string, payload?: unknown): void { diff --git a/src/utils/paths.ts b/src/utils/paths.ts index 8b92ecd..1e0e3a5 100644 --- a/src/utils/paths.ts +++ b/src/utils/paths.ts @@ -1,79 +1,15 @@ -import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -// ApeironCode brand: the primary on-disk directory is `.apeironcode-agent`. -// `.opencode-agent` is preserved as a legacy fallback for projects that have -// not yet migrated. When both exist, the new ApeironCode directory wins. export const APP_DIR_NAME = '.apeironcode-agent'; -export const LEGACY_APP_DIR_NAME = '.opencode-agent'; export const PROJECT_CONFIG_DIR_NAME = APP_DIR_NAME; -export const LEGACY_PROJECT_CONFIG_DIR_NAME = LEGACY_APP_DIR_NAME; export const PROJECT_MEMORY_FILE_NAME = 'memory.md'; export const PROJECT_CONFIG_FILE_NAME = 'config.json'; export const IGNORE_FILE_NAME = '.apeironcodeignore'; -export const LEGACY_IGNORE_FILE_NAME = '.opencodeignore'; - -// Deprecated compatibility aliases for older imports. Prefer the canonical -// `APP_DIR_NAME` / `IGNORE_FILE_NAME` constants in new code. -/** @deprecated Use APP_DIR_NAME (now `.apeironcode-agent`). */ -export const NEW_APP_DIR_NAME = APP_DIR_NAME; -/** @deprecated Use PROJECT_CONFIG_DIR_NAME (now `.apeironcode-agent`). */ -export const NEW_PROJECT_CONFIG_DIR_NAME = PROJECT_CONFIG_DIR_NAME; -/** @deprecated Use IGNORE_FILE_NAME (now `.apeironcodeignore`). */ -export const NEW_IGNORE_FILE_NAME = IGNORE_FILE_NAME; - -const directoryExists = (dir: string): boolean => { - try { - return fs.statSync(dir).isDirectory(); - } catch { - return false; - } -}; - -const fileExists = (file: string): boolean => { - try { - return fs.statSync(file).isFile(); - } catch { - return false; - } -}; export const getUserHomeDir = (): string => os.homedir(); -export const getAppHomeDir = (): string => { - const home = getUserHomeDir(); - const primary = path.join(home, APP_DIR_NAME); - if (directoryExists(primary)) return primary; - // Legacy fallback: honour an existing `.opencode-agent` directory if the - // user has not migrated yet. - const legacy = path.join(home, LEGACY_APP_DIR_NAME); - if (directoryExists(legacy)) return legacy; - return primary; -}; - -/** - * One-time, best-effort migration of the legacy `~/.opencode-agent` home to - * the ApeironCode-branded `~/.apeironcode-agent` home. Non-destructive: the - * legacy directory is left intact for rollback. Safe to call repeatedly and - * never throws (a failed migration must not break the CLI). - * - * Returns true when a migration was performed this call. - */ -export const migrateLegacyAppHome = (): boolean => { - try { - const home = getUserHomeDir(); - const primary = path.join(home, APP_DIR_NAME); - const legacy = path.join(home, LEGACY_APP_DIR_NAME); - if (directoryExists(primary) || !directoryExists(legacy)) { - return false; - } - fs.cpSync(legacy, primary, {recursive: true, errorOnExist: false, force: false}); - return true; - } catch { - return false; - } -}; +export const getAppHomeDir = (): string => path.join(getUserHomeDir(), APP_DIR_NAME); export const getGlobalConfigPath = (): string => path.join(getAppHomeDir(), PROJECT_CONFIG_FILE_NAME); @@ -109,17 +45,7 @@ export const getProjectSessionsDir = (cwd: string): string => export const getRepoMapPath = (cwd: string): string => path.join(getProjectConfigDir(cwd), 'repo-map.json'); -export const getProjectConfigDir = (cwd: string): string => { - const primary = path.join(cwd, PROJECT_CONFIG_DIR_NAME); - if (directoryExists(primary)) return primary; - // Legacy fallback for projects that still use `.opencode-agent`. - const legacy = path.join(cwd, LEGACY_PROJECT_CONFIG_DIR_NAME); - if (directoryExists(legacy)) return legacy; - return primary; -}; - -/** @deprecated Use getProjectConfigDir, which now defaults to `.apeironcode-agent`. */ -export const getNewProjectConfigDir = (cwd: string): string => +export const getProjectConfigDir = (cwd: string): string => path.join(cwd, PROJECT_CONFIG_DIR_NAME); export const getProjectConfigPath = (cwd: string): string => @@ -128,14 +54,8 @@ export const getProjectConfigPath = (cwd: string): string => export const getProjectMemoryPath = (cwd: string): string => path.join(getProjectConfigDir(cwd), PROJECT_MEMORY_FILE_NAME); -export const getIgnoreFilePath = (cwd: string): string => { - const primary = path.join(cwd, IGNORE_FILE_NAME); - if (fileExists(primary)) return primary; - // Legacy fallback for projects with a `.opencodeignore` file. - const legacy = path.join(cwd, LEGACY_IGNORE_FILE_NAME); - if (fileExists(legacy)) return legacy; - return primary; -}; +export const getIgnoreFilePath = (cwd: string): string => + path.join(cwd, IGNORE_FILE_NAME); export const getPlanStoragePath = (cwd: string): string => path.join(getProjectConfigDir(cwd), 'plans'); diff --git a/src/utils/structuredLogger.ts b/src/utils/structuredLogger.ts index 35f4810..20dd153 100644 --- a/src/utils/structuredLogger.ts +++ b/src/utils/structuredLogger.ts @@ -59,7 +59,7 @@ export const getDefaultLogDir = (): string => path.join(getAppHomeDir(), 'logs') export const readRecentLogLines = async (logDir = getDefaultLogDir(), limit = 50): Promise => { try { const files = (await fs.readdir(logDir)) - .filter((file) => /^(?:apeironcode|opencode)-\d{4}-\d{2}-\d{2}\.jsonl$/u.test(file)) + .filter((file) => /^apeironcode-\d{4}-\d{2}-\d{2}\.jsonl$/u.test(file)) .sort(); const selected = files.slice(-3); const lines: string[] = []; @@ -82,8 +82,6 @@ export class StructuredLogger implements Logger { this.logDir = options.logDir ?? getDefaultLogDir(); this.level = options.level ?? (process.env.APEIRONCODE_LOG_LEVEL as LogLevel | undefined) - // Legacy fallback for the pre-rebrand env var. - ?? (process.env.OPENCODE_LOG_LEVEL as LogLevel | undefined) ?? 'info'; this.module = options.module; this.retentionDays = options.retentionDays ?? 7; @@ -122,7 +120,7 @@ export class StructuredLogger implements Logger { const cutoff = Date.now() - this.retentionDays * 24 * 60 * 60 * 1000; const files = await fs.readdir(this.logDir); await Promise.all(files.map(async (file) => { - const match = /^(?:apeironcode|opencode)-(\d{4}-\d{2}-\d{2})\.jsonl$/u.exec(file); + const match = /^apeironcode-(\d{4}-\d{2}-\d{2})\.jsonl$/u.exec(file); if (!match?.[1] || new Date(`${match[1]}T00:00:00Z`).getTime() >= cutoff) { return; } diff --git a/tests/agent/agent.integration.test.ts b/tests/agent/agent.integration.test.ts index 1ca1125..433a74a 100644 --- a/tests/agent/agent.integration.test.ts +++ b/tests/agent/agent.integration.test.ts @@ -18,8 +18,8 @@ describe('Agent integration with mock provider', () => { beforeEach(async () => { previousHome = process.env.HOME; - process.env.HOME = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-agent-home-')); - projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-agent-project-')); + process.env.HOME = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-agent-home-')); + projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-agent-project-')); await fs.cp(fixtureRoot, projectDir, {recursive: true}); }); diff --git a/tests/agent/checkpoints.test.ts b/tests/agent/checkpoints.test.ts index 92bbbeb..adfb2f8 100644 --- a/tests/agent/checkpoints.test.ts +++ b/tests/agent/checkpoints.test.ts @@ -15,7 +15,7 @@ import { describe('checkpoints', () => { const dirs: string[] = []; const makeDir = async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-checkpoint-')); + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-checkpoint-')); dirs.push(dir); return dir; }; diff --git a/tests/agent/diffBudget.test.ts b/tests/agent/diffBudget.test.ts index af6614f..b51acd4 100644 --- a/tests/agent/diffBudget.test.ts +++ b/tests/agent/diffBudget.test.ts @@ -13,7 +13,7 @@ const execFileAsync = promisify(execFile); describe('diffBudget', () => { const dirs: string[] = []; const makeRepo = async () => { - const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-diff-')); + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-diff-')); dirs.push(cwd); await execFileAsync('git', ['init'], {cwd}); await execFileAsync('git', ['config', 'user.email', 'test@example.com'], {cwd}); diff --git a/tests/agent/loop.test.ts b/tests/agent/loop.test.ts index 196c928..98ea418 100644 --- a/tests/agent/loop.test.ts +++ b/tests/agent/loop.test.ts @@ -27,7 +27,7 @@ class SequenceProvider implements ModelProvider { const message = this.responses.shift() ?? 'done'; // Parse and emit XML tool directives as tool_use chunks - const toolCallRegex = /([\s\S]*?)<\/opencode_tool_call>/g; + const toolCallRegex = /([\s\S]*?)<\/apeironcode_tool_call>/g; let match: RegExpExecArray | null; const matches = []; @@ -207,8 +207,8 @@ describe('runAgentLoop', () => { it('executes multiple tool-call blocks from a single model response', async () => { const provider = new SequenceProvider([ [ - '{"toolName":"alpha","input":{"value":"one"}}', - '{"toolName":"beta","input":{"value":"two"}}', + '{"toolName":"alpha","input":{"value":"one"}}', + '{"toolName":"beta","input":{"value":"two"}}', ].join('\n'), 'All done.', ]); @@ -258,8 +258,8 @@ describe('runAgentLoop', () => { it('requests a retry when a model emits malformed tool JSON', async () => { const provider = new SequenceProvider([ - '{"toolName":"alpha","input":{"value":"broken"}', - '{"toolName":"alpha","input":{"value":"fixed"}}', + '{"toolName":"alpha","input":{"value":"broken"}', + '{"toolName":"alpha","input":{"value":"fixed"}}', 'Recovered.', ]); const registry = new ToolRegistry([ diff --git a/tests/agent/lsp-context.test.ts b/tests/agent/lsp-context.test.ts index eb7cc0d..561f1ae 100644 --- a/tests/agent/lsp-context.test.ts +++ b/tests/agent/lsp-context.test.ts @@ -135,7 +135,7 @@ describe('buildProjectContext LSP diagnostics integration', () => { manifests: [], monorepo: false, packageManager: 'npm', - projectName: 'opencode', + projectName: 'apeironcode', projectSummary: 'TypeScript CLI project', sourceDirectories: ['src'], testCommand: 'npm test', @@ -278,15 +278,15 @@ describe('buildProjectContext LSP diagnostics integration', () => { const result = await buildProjectContext({ approvalManager: {} as never, config: createResolvedConfig(), - cwd: '/workspace/opencode', + cwd: '/workspace/apeironcode', mode: 'debug', prompt: 'debug auth failure', toolRegistry: toolRegistry as never, }); expect(getFileDiagnosticsMock).toHaveBeenCalledTimes(2); - expect(getFileDiagnosticsMock).toHaveBeenNthCalledWith(1, 'src/auth.ts', {cwd: '/workspace/opencode', timeout: 2000}); - expect(getFileDiagnosticsMock).toHaveBeenNthCalledWith(2, 'src/auth.test.ts', {cwd: '/workspace/opencode', timeout: 2000}); + expect(getFileDiagnosticsMock).toHaveBeenNthCalledWith(1, 'src/auth.ts', {cwd: '/workspace/apeironcode', timeout: 2000}); + expect(getFileDiagnosticsMock).toHaveBeenNthCalledWith(2, 'src/auth.test.ts', {cwd: '/workspace/apeironcode', timeout: 2000}); expect(result.promptContext).toContain('Code Intelligence:'); expect(result.promptContext).toContain('Diagnostics source: live LSP'); expect(result.promptContext).toContain('Files checked: 2'); @@ -324,7 +324,7 @@ describe('buildProjectContext LSP diagnostics integration', () => { const result = await buildProjectContext({ approvalManager: {} as never, config: createResolvedConfig(), - cwd: '/workspace/opencode', + cwd: '/workspace/apeironcode', mode: 'fix', prompt: 'fix auth types', toolRegistry: toolRegistry as never, diff --git a/tests/agent/memoryManager.test.ts b/tests/agent/memoryManager.test.ts index 1bec2f8..d05257e 100644 --- a/tests/agent/memoryManager.test.ts +++ b/tests/agent/memoryManager.test.ts @@ -24,7 +24,7 @@ describe('MemoryManager', () => { }); it('filters sensitive paths from extracted project memory', () => { - const manager = new MemoryManager('/tmp/opencode-memory-test'); + const manager = new MemoryManager('/tmp/apeironcode-memory-test'); const extracted = manager.extractProjectMemoryFromRun({ goal: 'Fix the parser bug', @@ -51,8 +51,8 @@ describe('MemoryManager', () => { }); it('loads only relevant memory with source attribution and dedupes overlapping project/global facts', async () => { - const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-memory-project-')); - const globalMemoryDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-memory-global-')); + const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-memory-project-')); + const globalMemoryDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-memory-global-')); directories.push(projectDir, globalMemoryDir); const manager = new MemoryManager(projectDir, globalMemoryDir); @@ -97,8 +97,8 @@ describe('MemoryManager', () => { }); it('excludes superseded graph memories and secret-like content from relevant memory', async () => { - const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-memory-project-')); - const globalMemoryDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-memory-global-')); + const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-memory-project-')); + const globalMemoryDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-memory-global-')); directories.push(projectDir, globalMemoryDir); const manager = new MemoryManager(projectDir, globalMemoryDir); diff --git a/tests/agent/planner.test.ts b/tests/agent/planner.test.ts index 260e492..7123d73 100644 --- a/tests/agent/planner.test.ts +++ b/tests/agent/planner.test.ts @@ -29,8 +29,8 @@ describe('extractToolDirective', () => { it('parses multiple tool-call blocks in one response', () => { const directives = extractToolDirectives([ - '{"toolName":"package_info","input":{}}', - '{"toolName":"project_tree","input":{"depth":2}}', + '{"toolName":"package_info","input":{}}', + '{"toolName":"project_tree","input":{"depth":2}}', ].join('\n')); expect(directives).toHaveLength(2); @@ -42,7 +42,7 @@ describe('extractToolDirective', () => { it('marks malformed tool-call blocks so the loop can request a retry', () => { const analysis = analyzeToolDirectives( - '{"toolName":"read_file","input":{"path":"src/index.ts"}', + '{"toolName":"read_file","input":{"path":"src/index.ts"}', ); expect(analysis.directives).toHaveLength(0); diff --git a/tests/agent/planningGate.integration.test.ts b/tests/agent/planningGate.integration.test.ts index e5c0d16..d150ae2 100644 --- a/tests/agent/planningGate.integration.test.ts +++ b/tests/agent/planningGate.integration.test.ts @@ -50,7 +50,7 @@ describe('Planning Gate Integration', () => { beforeEach(() => { vi.clearAllMocks(); - testDir = `${tmpdir()}/opencode-test-${Date.now()}`; + testDir = `${tmpdir()}/apeironcode-test-${Date.now()}`; mkdirSync(testDir, {recursive: true}); }); diff --git a/tests/agent/runMemoryAuthGuard.test.ts b/tests/agent/runMemoryAuthGuard.test.ts index 1a8bc6d..9ab5548 100644 --- a/tests/agent/runMemoryAuthGuard.test.ts +++ b/tests/agent/runMemoryAuthGuard.test.ts @@ -96,7 +96,7 @@ describe('runProducedUsefulEvidence (memory eligibility)', () => { describe('memoryManager drops provider/auth failures from pitfalls', () => { it('does not record "Provider returned 401" as a pitfall or recent error', () => { - const manager = new MemoryManager('/tmp/opencode-memory-auth-test'); + const manager = new MemoryManager('/tmp/apeironcode-memory-auth-test'); const extracted = manager.extractProjectMemoryFromRun({ goal: 'hi', mode: 'fix', diff --git a/tests/agent/runtimeResume.test.ts b/tests/agent/runtimeResume.test.ts index df097c4..1a77495 100644 --- a/tests/agent/runtimeResume.test.ts +++ b/tests/agent/runtimeResume.test.ts @@ -21,7 +21,7 @@ describe('runtimeResume', () => { }); it('serializes, saves, loads, and clears redacted snapshots', async () => { - const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-resume-')); + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-resume-')); dirs.push(cwd); const state = snapshotRuntimeState(transitionRuntimeState(createRuntimeState(), {message: 'interrupted', to: 'cancelled'})); const snapshot = serializeRuntimeSnapshot({ diff --git a/tests/agent/workflows.test.ts b/tests/agent/workflows.test.ts index f7637ba..79eeb40 100644 --- a/tests/agent/workflows.test.ts +++ b/tests/agent/workflows.test.ts @@ -25,7 +25,7 @@ const projectContext: ProjectContextBundle = { manifests: [], monorepo: false, packageManager: 'npm', - projectName: 'opencode', + projectName: 'apeironcode', projectSummary: 'TypeScript CLI project', sourceDirectories: ['src'], testCommand: 'npm test', diff --git a/tests/agents/reviewActions.test.ts b/tests/agents/reviewActions.test.ts index 553d4b7..7d95ea2 100644 --- a/tests/agents/reviewActions.test.ts +++ b/tests/agents/reviewActions.test.ts @@ -11,7 +11,7 @@ import {MemorySuggestionStore} from '../../src/memory/suggestions.js'; import {createReviewCockpitState} from '../../src/ui/reviewCockpitState.js'; const makeProject = async () => { - const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-review-actions-')); + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-review-actions-')); await fs.mkdir(path.join(cwd, 'src'), {recursive: true}); await fs.writeFile(path.join(cwd, 'src/example.ts'), 'export const value = 1;\n'); return cwd; diff --git a/tests/agents/team.test.ts b/tests/agents/team.test.ts index ae56b8b..b43b4a6 100644 --- a/tests/agents/team.test.ts +++ b/tests/agents/team.test.ts @@ -23,7 +23,7 @@ describe('agent teams', () => { }); it('executes sequential subagents with scoped tools and records memory suggestions', async () => { - const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-team-')); + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-team-')); await fs.cp(path.resolve('tests/fixtures/node-basic'), cwd, {recursive: true}); const config: ResolvedConfig = { effective: createMockConfig(), diff --git a/tests/agents/workspace.test.ts b/tests/agents/workspace.test.ts index e97d0b4..5846750 100644 --- a/tests/agents/workspace.test.ts +++ b/tests/agents/workspace.test.ts @@ -23,7 +23,7 @@ const mockResolvedConfig = (): ResolvedConfig => ({ }); const makeProject = async () => { - const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-workspace-')); + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-workspace-')); await fs.mkdir(path.join(cwd, 'src'), {recursive: true}); await fs.writeFile(path.join(cwd, 'src/example.ts'), 'export const value = 1;\n'); await fs.writeFile(path.join(cwd, 'README.md'), '# Demo\n'); @@ -159,7 +159,7 @@ describe('subagent workspaces', () => { it('respects workspace ignore rules and records ignored files', async () => { const cwd = await makeProject(); - await fs.writeFile(path.join(cwd, '.opencodeignore'), 'home/\n*.tmp\n'); + await fs.writeFile(path.join(cwd, '.apeironcodeignore'), 'home/\n*.tmp\n'); const manager = new SubagentWorkspaceManager(cwd); const workspace = await manager.createWorkspace({ agentName: 'tester', @@ -201,8 +201,8 @@ describe('subagent workspaces', () => { it('exports git-apply-compatible patches with sidecar validation metadata', async () => { const cwd = await makeProject(); await execa('git', ['init'], {cwd}); - await execa('git', ['config', 'user.email', 'opencode@example.test'], {cwd}); - await execa('git', ['config', 'user.name', 'OpenCode Test'], {cwd}); + await execa('git', ['config', 'user.email', 'apeironcode@example.test'], {cwd}); + await execa('git', ['config', 'user.name', 'ApeironCode Test'], {cwd}); await execa('git', ['add', '.'], {cwd}); await execa('git', ['commit', '-m', 'initial'], {cwd}); @@ -253,8 +253,8 @@ describe('subagent workspaces', () => { it('creates and cleans real git worktree workspaces in temp repos', async () => { const cwd = await makeProject(); await execa('git', ['init'], {cwd}); - await execa('git', ['config', 'user.email', 'opencode@example.test'], {cwd}); - await execa('git', ['config', 'user.name', 'OpenCode Test'], {cwd}); + await execa('git', ['config', 'user.email', 'apeironcode@example.test'], {cwd}); + await execa('git', ['config', 'user.name', 'ApeironCode Test'], {cwd}); await execa('git', ['add', '.'], {cwd}); await execa('git', ['commit', '-m', 'initial'], {cwd}); @@ -277,8 +277,8 @@ describe('subagent workspaces', () => { it('runs a mock-provider team in real git worktree mode', async () => { const cwd = await makeProject(); await execa('git', ['init'], {cwd}); - await execa('git', ['config', 'user.email', 'opencode@example.test'], {cwd}); - await execa('git', ['config', 'user.name', 'OpenCode Test'], {cwd}); + await execa('git', ['config', 'user.email', 'apeironcode@example.test'], {cwd}); + await execa('git', ['config', 'user.name', 'ApeironCode Test'], {cwd}); await execa('git', ['add', '.'], {cwd}); await execa('git', ['commit', '-m', 'initial'], {cwd}); diff --git a/tests/branding/publicBranding.test.ts b/tests/branding/publicBranding.test.ts index 5368878..a8d4db8 100644 --- a/tests/branding/publicBranding.test.ts +++ b/tests/branding/publicBranding.test.ts @@ -1,64 +1,29 @@ -import {readFileSync} from 'node:fs'; -import {fileURLToPath} from 'node:url'; -import {dirname, resolve} from 'node:path'; +import fs from 'node:fs'; +import path from 'node:path'; import {describe, expect, it} from 'vitest'; -const here = dirname(fileURLToPath(import.meta.url)); -const repoRoot = resolve(here, '..', '..'); - -const read = (relPath: string): string => readFileSync(resolve(repoRoot, relPath), 'utf8'); - -interface PackageJson { - name: string; - displayName?: string; - description?: string; - bin?: Record; - files?: string[]; -} - -const pkg = JSON.parse(read('package.json')) as PackageJson; +const ROOT = path.resolve(__dirname, '../..'); +const read = (relativePath: string): string => + fs.readFileSync(path.join(ROOT, relativePath), 'utf8'); describe('public ApeironCode branding', () => { - it('README presents ApeironCode and no user-facing OpenCode brand', () => { - const readme = read('README.md'); - expect(readme).toContain('ApeironCode'); - // The capitalized product brand must not appear (lowercase `opencode` - // legacy command/path references are checked separately). - expect(readme).not.toContain('OpenCode'); - }); + it('uses ApeironCode metadata and exposes only the apeironcode binary', () => { + const pkg = JSON.parse(read('package.json')) as { + bin: Record; + description: string; + displayName: string; + }; - it('package metadata uses the ApeironCode brand', () => { expect(pkg.displayName).toBe('ApeironCode'); - expect(pkg.description ?? '').not.toContain('OpenCode'); + expect(pkg.description).toMatch(/local-first/i); + expect(pkg.bin).toEqual({apeironcode: './dist/cli/index.js'}); }); - it('keeps apeironcode as the primary binary with opencode as a documented legacy alias', () => { - // The `opencode` bin alias is intentionally retained for backward - // compatibility (see docs/history.md and the brand-migration test). - expect(pkg.bin).toHaveProperty('apeironcode'); - expect(pkg.bin).toHaveProperty('opencode'); - expect(pkg.bin?.apeironcode).toBe(pkg.bin?.opencode); - }); - - it('packaged docs do not present the OpenCode product brand', () => { - const packagedDocs = (pkg.files ?? []).filter( - (entry) => entry.startsWith('docs/') && entry.endsWith('.md'), - ); - expect(packagedDocs.length).toBeGreaterThan(0); - - // history.md exists specifically to document the OpenCode -> ApeironCode - // rename, so it is allowed to name the former brand. - const allowed = new Set(['docs/history.md']); - for (const doc of packagedDocs) { - if (allowed.has(doc)) continue; - expect(read(doc), `${doc} should not contain the OpenCode brand`).not.toContain('OpenCode'); - } - }); - - it('history.md documents the rename and the retained legacy alias', () => { - const history = read('docs/history.md'); - expect(history).toContain('ApeironCode'); - expect(history.toLowerCase()).toContain('opencode'); + it('presents a CLI-only public alpha', () => { + const readme = read('README.md'); + expect(readme).toContain('ApeironCode'); + expect(readme).toMatch(/CLI-only public release/i); + expect(readme).toMatch(/VS Code extension is not part of this public alpha/i); }); }); diff --git a/tests/branding/releaseReadiness.test.ts b/tests/branding/releaseReadiness.test.ts index ae52967..a355f6a 100644 --- a/tests/branding/releaseReadiness.test.ts +++ b/tests/branding/releaseReadiness.test.ts @@ -35,6 +35,9 @@ describe('release readiness — honest, accurate copy', () => { expect(readme).not.toMatch(/production[- ]proven|enterprise[- ]ready|fully autonomous/i); // The over-eager rename bug: must not claim non-affiliation with itself. expect(readme).not.toMatch(/not affiliated with[^.]*ApeironCode team/i); + expect(readme).toMatch(/package and one-line installer are not published yet/i); + expect(readme).toMatch(/not process-isolated by default/i); + expect(readme).toMatch(/real-provider\/real-LLM checks[\s\S]*not run by CI/i); }); it('.env.example contains placeholders only, never real keys', () => { @@ -77,5 +80,7 @@ describe('CLI-only alpha — VS Code / web excluded from the package', () => { const notes = read('docs/release-notes/v0.1.0-alpha.md'); expect(notes).toMatch(/CLI[- ]only/i); expect(notes).toMatch(/VS Code extension is not part of this public alpha/i); + expect(notes).toMatch(/npm package is not published yet/i); + expect(notes).toMatch(/mocked\/scripted providers/i); }); }); diff --git a/tests/branding/staleBranding.test.ts b/tests/branding/staleBranding.test.ts index eec9498..46586b2 100644 --- a/tests/branding/staleBranding.test.ts +++ b/tests/branding/staleBranding.test.ts @@ -1,108 +1,53 @@ -import {execSync} from 'node:child_process'; +import {execFileSync} from 'node:child_process'; +import fs from 'node:fs'; import path from 'node:path'; import {describe, expect, it} from 'vitest'; const ROOT = path.resolve(__dirname, '../..'); - -// Patterns that are explicitly allowed to keep references to the legacy -// OpenCode brand: external company names, deliberate legacy fallbacks, the -// preserved CLI alias, deprecated TS aliases, and historical/migration notes. -const ALLOWED_PATTERNS: RegExp[] = [ - /openai/i, // External company / product name. Never renamed. - /formerly.*opencode/i, - /legacy.*opencode/i, - /opencode.*legacy/i, - /opencode.*alias/i, - /opencode.*fallback/i, - /opencode.*compat/i, - /opencode.*deprecated/i, - /Deprecated compatibility alias/i, - /Compatibility alias for legacy OpenCode brand/i, - /pre-rebrand/i, - /OPENCODE_\w+.*fallback/i, - /\.opencode.*fallback/i, - // Historical note line in CHANGELOG referencing the old XML envelope. - //i, -]; - -// Substrings in path text that disqualify a hit (skip files entirely). -const SKIP_PATH_SUBSTRINGS = [ - 'node_modules', - '/dist/', - '.git/', - 'package-lock.json', - '.tgz', - // The branding test itself talks about both names by design. - 'tests/branding/staleBranding.test.ts', +const PUBLIC_PREFIXES = [ + 'docs/', + 'src/', + 'tests/', + 'scripts/', + 'examples/', + '.github/', ]; - -// File suffixes whose content references are allowed wholesale (these are the -// authoritative places that document the migration or define legacy -// fallbacks; the per-line allowlist above already covers their key lines). -const ALLOWED_FILE_PATHS = [ - // Tests that explicitly verify legacy fallback behaviour. - 'tests/githubAutomation/permissions.test.ts', - 'tests/githubAutomation/issueToPr.test.ts', - 'tests/githubAutomation/idempotency.test.ts', - 'tests/githubAutomation/prReview.test.ts', - 'tests/githubAction/runner.test.ts', - 'tests/e2e/githubAutomation-phase13_6.test.ts', - 'tests/e2e/productFlow.test.ts', - 'tests/smoke/cli-smoke.test.ts', - // CHANGELOG keeps historical entries under the old brand name. +const PUBLIC_FILES = new Set([ + 'README.md', + 'package.json', + 'CONTRIBUTING.md', + 'SECURITY.md', 'CHANGELOG.md', - // Phase plan/audit docs are historical artefacts. - 'docs/PHASE15_PLAN.md', -]; - -const isAllowedPath = (line: string): boolean => { - for (const skip of SKIP_PATH_SUBSTRINGS) { - if (line.includes(skip)) return true; - } - for (const allowed of ALLOWED_FILE_PATHS) { - if (line.startsWith(allowed) || line.includes(`/${allowed}`)) return true; - } - return false; -}; - -const isAllowedContent = (line: string): boolean => { - for (const pattern of ALLOWED_PATTERNS) { - if (pattern.test(line)) return true; - } - return false; -}; - -describe('ApeironCode brand migration', () => { - it('has no unintended stale OpenCode references in source/test files', () => { - let output = ''; - try { - output = execSync( - 'rg -n --type ts --type tsx --type json ' - + '"OpenCode|opencode-agent|OpenCode-Agent|OpenCodeConfig" ' - + '. 2>/dev/null || true', - {cwd: ROOT, encoding: 'utf8', timeout: 30_000}, - ); - } catch { - output = ''; - } - - const violations: string[] = []; - for (const line of output.split('\n')) { - if (!line) continue; - if (isAllowedPath(line)) continue; - if (isAllowedContent(line)) continue; - // Allow the legacy `opencode` binary alias entry in package.json. - if (line.includes('package.json') && line.includes('"opencode"')) continue; - violations.push(line); - } - - if (violations.length > 0) { - console.error( - `Stale brand references found (${violations.length}):\n` - + violations.slice(0, 30).join('\n'), - ); - } - expect(violations).toHaveLength(0); + 'action.yml', +]); + +describe('legacy branding regression gate', () => { + it('has no former product names in public tracked files', () => { + const formerBrand = ['Open', 'Code'].join(''); + const formerCommand = formerBrand.toLowerCase(); + const legacyPattern = new RegExp([ + formerBrand, + formerCommand, + ['open', 'code'].join('-'), + `${formerCommand}-agent`, + formerCommand.toUpperCase(), + `@${formerCommand}`, + ].join('|'), 'u'); + const trackedFiles = execFileSync('git', ['ls-files'], { + cwd: ROOT, + encoding: 'utf8', + }).split('\n').filter(Boolean); + + const violations = trackedFiles + .filter((file) => fs.existsSync(path.join(ROOT, file))) + .filter((file) => + PUBLIC_FILES.has(file) + || PUBLIC_PREFIXES.some((prefix) => file.startsWith(prefix))) + .filter((file) => + legacyPattern.test(file) + || legacyPattern.test(fs.readFileSync(path.join(ROOT, file), 'utf8'))); + + expect(violations).toEqual([]); }); }); diff --git a/tests/cli/branding.test.ts b/tests/cli/branding.test.ts index ee096fe..b9441c6 100644 --- a/tests/cli/branding.test.ts +++ b/tests/cli/branding.test.ts @@ -1,7 +1,7 @@ import {describe, expect, it, vi} from 'vitest'; import {buildProgram} from '../../src/cli/commands.js'; -import {APP_DIR_NAME, LEGACY_APP_DIR_NAME, NEW_APP_DIR_NAME} from '../../src/utils/paths.js'; +import {APP_DIR_NAME} from '../../src/utils/paths.js'; const createProgram = () => { const handlers = new Proxy({}, { @@ -30,20 +30,15 @@ describe('Phase 15A brand migration', () => { expect(program.helpInformation()).toContain('ApeironCode'); }); - it('paths utility exposes both legacy and new project directory names', () => { - // Primary directory is the ApeironCode-branded one; the legacy - // `.opencode-agent` name is preserved for backward compatibility. + it('paths utility exposes the ApeironCode project directory name', () => { expect(APP_DIR_NAME).toBe('.apeironcode-agent'); - expect(LEGACY_APP_DIR_NAME).toBe('.opencode-agent'); - // Deprecated compatibility alias still points to the new primary name. - expect(NEW_APP_DIR_NAME).toBe('.apeironcode-agent'); }); - it('package.json exposes both the apeironcode and legacy opencode binaries', async () => { + it('package.json exposes the apeironcode binary', async () => { const pkg = await import('../../package.json', {with: {type: 'json'}}); const data = (pkg as {default: {name: string; bin: Record}}).default; - expect(data.name).toBe('apeironcode-agent'); + expect(data.name).toBe('apeironcode'); expect(data.bin).toHaveProperty('apeironcode'); - expect(data.bin).toHaveProperty('opencode'); + expect(Object.keys(data.bin)).toEqual(['apeironcode']); }); }); diff --git a/tests/cli/commands.test.ts b/tests/cli/commands.test.ts index a05432b..8f419e9 100644 --- a/tests/cli/commands.test.ts +++ b/tests/cli/commands.test.ts @@ -196,7 +196,7 @@ describe('buildProgram', () => { await program.parseAsync([ 'node', - 'opencode', + 'apeironcode', '--provider', 'mock', '--model', @@ -216,7 +216,7 @@ describe('buildProgram', () => { await program.parseAsync([ 'node', - 'opencode', + 'apeironcode', '--mode', 'review', 'review this diff', @@ -234,7 +234,7 @@ describe('buildProgram', () => { await program.parseAsync([ 'node', - 'opencode', + 'apeironcode', 'provider', 'test', '--provider', @@ -262,14 +262,14 @@ describe('buildProgram', () => { await program.parseAsync([ 'node', - 'opencode', + 'apeironcode', 'provider', 'list', ]); await program.parseAsync([ 'node', - 'opencode', + 'apeironcode', 'provider', 'setup', 'mock', @@ -285,7 +285,7 @@ describe('buildProgram', () => { await program.parseAsync([ 'node', - 'opencode', + 'apeironcode', 'model', 'list', 'coding', @@ -293,7 +293,7 @@ describe('buildProgram', () => { await program.parseAsync([ 'node', - 'opencode', + 'apeironcode', 'model', 'recommend', 'fast', @@ -307,14 +307,14 @@ describe('buildProgram', () => { const handlers = createHandlers(); const program = buildProgram(handlers); - await program.parseAsync(['node', 'opencode', 'provider', 'fallback', 'coding']); - await program.parseAsync(['node', 'opencode', 'provider', 'fallback', 'test', 'coding']); - await program.parseAsync(['node', 'opencode', 'provider', 'fallback', 'simulate', 'rate-limit', 'coding']); - await program.parseAsync(['node', 'opencode', 'provider', 'fallback', 'set', 'coding', 'mock:mock-coder']); - await program.parseAsync(['node', 'opencode', 'ollama', 'status']); - await program.parseAsync(['node', 'opencode', 'ollama', 'models']); - await program.parseAsync(['node', 'opencode', 'ollama', 'recommend']); - await program.parseAsync(['node', 'opencode', 'ollama', 'pull-hint', 'qwen2.5-coder:7b']); + await program.parseAsync(['node', 'apeironcode', 'provider', 'fallback', 'coding']); + await program.parseAsync(['node', 'apeironcode', 'provider', 'fallback', 'test', 'coding']); + await program.parseAsync(['node', 'apeironcode', 'provider', 'fallback', 'simulate', 'rate-limit', 'coding']); + await program.parseAsync(['node', 'apeironcode', 'provider', 'fallback', 'set', 'coding', 'mock:mock-coder']); + await program.parseAsync(['node', 'apeironcode', 'ollama', 'status']); + await program.parseAsync(['node', 'apeironcode', 'ollama', 'models']); + await program.parseAsync(['node', 'apeironcode', 'ollama', 'recommend']); + await program.parseAsync(['node', 'apeironcode', 'ollama', 'pull-hint', 'qwen2.5-coder:7b']); expect(handlers.providerFallback).toHaveBeenCalledWith('coding'); expect(handlers.providerFallbackTest).toHaveBeenCalledWith('coding'); @@ -332,7 +332,7 @@ describe('buildProgram', () => { await program.parseAsync([ 'node', - 'opencode', + 'apeironcode', 'web', 'fetch', 'https://example.com', @@ -340,7 +340,7 @@ describe('buildProgram', () => { await program.parseAsync([ 'node', - 'opencode', + 'apeironcode', 'web', 'search', 'parser', @@ -348,7 +348,7 @@ describe('buildProgram', () => { await program.parseAsync([ 'node', - 'opencode', + 'apeironcode', 'web', 'research', 'parser design', @@ -365,7 +365,7 @@ describe('buildProgram', () => { await program.parseAsync([ 'node', - 'opencode', + 'apeironcode', 'doctor', '--provider-check', '--strict', @@ -382,7 +382,7 @@ describe('buildProgram', () => { await program.parseAsync([ 'node', - 'opencode', + 'apeironcode', 'context', 'refresh', '--force', @@ -395,74 +395,74 @@ describe('buildProgram', () => { const handlers = createHandlers(); const program = buildProgram(handlers); - await program.parseAsync(['node', 'opencode', 'context', 'index']); - await program.parseAsync(['node', 'opencode', 'context', 'budget']); - await program.parseAsync(['node', 'opencode', 'context', 'why', 'fix tests']); - await program.parseAsync(['node', 'opencode', 'memory', 'graph']); - await program.parseAsync(['node', 'opencode', 'memory', 'related', 'auth']); - await program.parseAsync(['node', 'opencode', 'memory', 'why', 'auth']); - await program.parseAsync(['node', 'opencode', 'memory', 'review', '--status', 'pending', '--confidence', 'high', '--source', 'team', '--team', 'team_1']); - await program.parseAsync(['node', 'opencode', 'memory', 'suggestions']); - await program.parseAsync(['node', 'opencode', 'memory', 'suggestion', 'show', 'memsug_1']); - await program.parseAsync(['node', 'opencode', 'memory', 'approve', 'memsug_1']); - await program.parseAsync(['node', 'opencode', 'memory', 'reject', '--all']); - await program.parseAsync(['node', 'opencode', 'memory', 'conflicts']); - await program.parseAsync(['node', 'opencode', 'memory', 'stale']); - await program.parseAsync(['node', 'opencode', 'memory', 'source', 'mem_1']); - await program.parseAsync(['node', 'opencode', 'memory', 'rollback', 'mem_1', '--yes']); - await program.parseAsync(['node', 'opencode', 'memory', 'forget-session', 'sess_1', '--yes']); - await program.parseAsync(['node', 'opencode', 'setup', '--provider', 'mock']); - await program.parseAsync(['node', 'opencode', 'setup', 'status']); - await program.parseAsync(['node', 'opencode', 'setup', 'reset', '--dry-run']); - await program.parseAsync(['node', 'opencode', 'skills']); - await program.parseAsync(['node', 'opencode', 'skill', 'browser', '--filter', 'enabled', '--search', 'test']); - await program.parseAsync(['node', 'opencode', 'skill', 'templates']); - await program.parseAsync(['node', 'opencode', 'skill', 'test', 'fix-tests']); - await program.parseAsync(['node', 'opencode', 'skill', 'trust', 'fix-tests']); - await program.parseAsync(['node', 'opencode', 'skill', 'enable', 'fix-tests']); - await program.parseAsync(['node', 'opencode', 'skill', 'disable', 'fix-tests']); - await program.parseAsync(['node', 'opencode', 'skill', 'run', 'fix-tests', '--input', 'unit failure']); - await program.parseAsync(['node', 'opencode', 'github', 'status']); - await program.parseAsync(['node', 'opencode', 'github', 'issue', 'comment', '7', 'looks good', '--dry-run']); - await program.parseAsync(['node', 'opencode', 'github', 'issue', 'create', '--title', 'Bug', '--body', 'Details', '--dry-run']); - await program.parseAsync(['node', 'opencode', 'github', 'pr', 'summary', '8']); - await program.parseAsync(['node', 'opencode', 'github', 'pr', 'review', '8', '--dry-run']); - await program.parseAsync(['node', 'opencode', 'github', 'pr', 'create', '--title', 'Fix', '--body', 'Details', '--base', 'main', '--head', 'branch', '--dry-run']); - await program.parseAsync(['node', 'opencode', 'github', 'actions']); - await program.parseAsync(['node', 'opencode', 'github', 'actions', '99']); - await program.parseAsync(['node', 'opencode', 'github', 'ci', 'explain', '99']); - await program.parseAsync(['node', 'opencode', 'agents']); - await program.parseAsync(['node', 'opencode', 'agent', 'run', 'reviewer', 'review diff']); - await program.parseAsync(['node', 'opencode', 'team', 'plan', 'fix tests', '--parallel-readonly']); - await program.parseAsync(['node', 'opencode', 'team', 'run', 'fix tests', '--workspace', 'temp-copy', '--parallel-readonly', '--dry-run']); - await program.parseAsync(['node', 'opencode', 'team', 'workspaces']); - await program.parseAsync(['node', 'opencode', 'team', 'runs']); - await program.parseAsync(['node', 'opencode', 'team', 'show', 'team_1']); - await program.parseAsync(['node', 'opencode', 'team', 'review', 'team_1', '--interactive']); - await program.parseAsync(['node', 'opencode', 'team', 'cockpit', 'team_1']); - await program.parseAsync(['node', 'opencode', 'team', 'artifacts', 'team_1', '--filter', 'diff', '--search', 'auth']); - await program.parseAsync(['node', 'opencode', 'team', 'artifact', 'team_1', 'summary-1', '--preview']); - await program.parseAsync(['node', 'opencode', 'team', 'export', 'team_1']); - await program.parseAsync(['node', 'opencode', 'team', 'export-patch', 'team_1', '--file', 'src/a.ts']); - await program.parseAsync(['node', 'opencode', 'team', 'validate-patch', 'team_1']); - await program.parseAsync(['node', 'opencode', 'team', 'ignored', 'team_1']); - await program.parseAsync(['node', 'opencode', 'team', 'merge-plan', 'team_1']); - await program.parseAsync(['node', 'opencode', 'team', 'conflicts', 'team_1', '--file', 'src/a.ts', '--json']); - await program.parseAsync(['node', 'opencode', 'team', 'apply', 'team_1', '--file', 'src/a.ts']); - await program.parseAsync(['node', 'opencode', 'team', 'resolve', 'team_1', '--file', 'src/a.ts', '--action', 'skip']); - await program.parseAsync(['node', 'opencode', 'team', 'discard', 'team_1']); - await program.parseAsync(['node', 'opencode', 'security', 'status']); - await program.parseAsync(['node', 'opencode', 'sandbox', 'status']); - await program.parseAsync(['node', 'opencode', 'sandbox', 'doctor']); - await program.parseAsync(['node', 'opencode', 'team', 'workspace', 'cleanup']); - await program.parseAsync(['node', 'opencode', 'hooks']); - await program.parseAsync(['node', 'opencode', 'hook', 'events']); - await program.parseAsync(['node', 'opencode', 'workflow', 'show', 'fix-tests']); - await program.parseAsync(['node', 'opencode', 'workflow', 'run', 'fix-tests', 'math failure', '--dry-run']); - await program.parseAsync(['node', 'opencode', 'workflow', 'report', 'workflow_1']); - await program.parseAsync(['node', 'opencode', 'eval', 'list']); - await program.parseAsync(['node', 'opencode', 'eval', 'run', 'smoke']); - await program.parseAsync(['node', 'opencode', 'eval', 'report']); + await program.parseAsync(['node', 'apeironcode', 'context', 'index']); + await program.parseAsync(['node', 'apeironcode', 'context', 'budget']); + await program.parseAsync(['node', 'apeironcode', 'context', 'why', 'fix tests']); + await program.parseAsync(['node', 'apeironcode', 'memory', 'graph']); + await program.parseAsync(['node', 'apeironcode', 'memory', 'related', 'auth']); + await program.parseAsync(['node', 'apeironcode', 'memory', 'why', 'auth']); + await program.parseAsync(['node', 'apeironcode', 'memory', 'review', '--status', 'pending', '--confidence', 'high', '--source', 'team', '--team', 'team_1']); + await program.parseAsync(['node', 'apeironcode', 'memory', 'suggestions']); + await program.parseAsync(['node', 'apeironcode', 'memory', 'suggestion', 'show', 'memsug_1']); + await program.parseAsync(['node', 'apeironcode', 'memory', 'approve', 'memsug_1']); + await program.parseAsync(['node', 'apeironcode', 'memory', 'reject', '--all']); + await program.parseAsync(['node', 'apeironcode', 'memory', 'conflicts']); + await program.parseAsync(['node', 'apeironcode', 'memory', 'stale']); + await program.parseAsync(['node', 'apeironcode', 'memory', 'source', 'mem_1']); + await program.parseAsync(['node', 'apeironcode', 'memory', 'rollback', 'mem_1', '--yes']); + await program.parseAsync(['node', 'apeironcode', 'memory', 'forget-session', 'sess_1', '--yes']); + await program.parseAsync(['node', 'apeironcode', 'setup', '--provider', 'mock']); + await program.parseAsync(['node', 'apeironcode', 'setup', 'status']); + await program.parseAsync(['node', 'apeironcode', 'setup', 'reset', '--dry-run']); + await program.parseAsync(['node', 'apeironcode', 'skills']); + await program.parseAsync(['node', 'apeironcode', 'skill', 'browser', '--filter', 'enabled', '--search', 'test']); + await program.parseAsync(['node', 'apeironcode', 'skill', 'templates']); + await program.parseAsync(['node', 'apeironcode', 'skill', 'test', 'fix-tests']); + await program.parseAsync(['node', 'apeironcode', 'skill', 'trust', 'fix-tests']); + await program.parseAsync(['node', 'apeironcode', 'skill', 'enable', 'fix-tests']); + await program.parseAsync(['node', 'apeironcode', 'skill', 'disable', 'fix-tests']); + await program.parseAsync(['node', 'apeironcode', 'skill', 'run', 'fix-tests', '--input', 'unit failure']); + await program.parseAsync(['node', 'apeironcode', 'github', 'status']); + await program.parseAsync(['node', 'apeironcode', 'github', 'issue', 'comment', '7', 'looks good', '--dry-run']); + await program.parseAsync(['node', 'apeironcode', 'github', 'issue', 'create', '--title', 'Bug', '--body', 'Details', '--dry-run']); + await program.parseAsync(['node', 'apeironcode', 'github', 'pr', 'summary', '8']); + await program.parseAsync(['node', 'apeironcode', 'github', 'pr', 'review', '8', '--dry-run']); + await program.parseAsync(['node', 'apeironcode', 'github', 'pr', 'create', '--title', 'Fix', '--body', 'Details', '--base', 'main', '--head', 'branch', '--dry-run']); + await program.parseAsync(['node', 'apeironcode', 'github', 'actions']); + await program.parseAsync(['node', 'apeironcode', 'github', 'actions', '99']); + await program.parseAsync(['node', 'apeironcode', 'github', 'ci', 'explain', '99']); + await program.parseAsync(['node', 'apeironcode', 'agents']); + await program.parseAsync(['node', 'apeironcode', 'agent', 'run', 'reviewer', 'review diff']); + await program.parseAsync(['node', 'apeironcode', 'team', 'plan', 'fix tests', '--parallel-readonly']); + await program.parseAsync(['node', 'apeironcode', 'team', 'run', 'fix tests', '--workspace', 'temp-copy', '--parallel-readonly', '--dry-run']); + await program.parseAsync(['node', 'apeironcode', 'team', 'workspaces']); + await program.parseAsync(['node', 'apeironcode', 'team', 'runs']); + await program.parseAsync(['node', 'apeironcode', 'team', 'show', 'team_1']); + await program.parseAsync(['node', 'apeironcode', 'team', 'review', 'team_1', '--interactive']); + await program.parseAsync(['node', 'apeironcode', 'team', 'cockpit', 'team_1']); + await program.parseAsync(['node', 'apeironcode', 'team', 'artifacts', 'team_1', '--filter', 'diff', '--search', 'auth']); + await program.parseAsync(['node', 'apeironcode', 'team', 'artifact', 'team_1', 'summary-1', '--preview']); + await program.parseAsync(['node', 'apeironcode', 'team', 'export', 'team_1']); + await program.parseAsync(['node', 'apeironcode', 'team', 'export-patch', 'team_1', '--file', 'src/a.ts']); + await program.parseAsync(['node', 'apeironcode', 'team', 'validate-patch', 'team_1']); + await program.parseAsync(['node', 'apeironcode', 'team', 'ignored', 'team_1']); + await program.parseAsync(['node', 'apeironcode', 'team', 'merge-plan', 'team_1']); + await program.parseAsync(['node', 'apeironcode', 'team', 'conflicts', 'team_1', '--file', 'src/a.ts', '--json']); + await program.parseAsync(['node', 'apeironcode', 'team', 'apply', 'team_1', '--file', 'src/a.ts']); + await program.parseAsync(['node', 'apeironcode', 'team', 'resolve', 'team_1', '--file', 'src/a.ts', '--action', 'skip']); + await program.parseAsync(['node', 'apeironcode', 'team', 'discard', 'team_1']); + await program.parseAsync(['node', 'apeironcode', 'security', 'status']); + await program.parseAsync(['node', 'apeironcode', 'sandbox', 'status']); + await program.parseAsync(['node', 'apeironcode', 'sandbox', 'doctor']); + await program.parseAsync(['node', 'apeironcode', 'team', 'workspace', 'cleanup']); + await program.parseAsync(['node', 'apeironcode', 'hooks']); + await program.parseAsync(['node', 'apeironcode', 'hook', 'events']); + await program.parseAsync(['node', 'apeironcode', 'workflow', 'show', 'fix-tests']); + await program.parseAsync(['node', 'apeironcode', 'workflow', 'run', 'fix-tests', 'math failure', '--dry-run']); + await program.parseAsync(['node', 'apeironcode', 'workflow', 'report', 'workflow_1']); + await program.parseAsync(['node', 'apeironcode', 'eval', 'list']); + await program.parseAsync(['node', 'apeironcode', 'eval', 'run', 'smoke']); + await program.parseAsync(['node', 'apeironcode', 'eval', 'report']); expect(handlers.contextIndex).toHaveBeenCalledTimes(1); expect(handlers.contextBudget).toHaveBeenCalledTimes(1); @@ -539,10 +539,10 @@ describe('buildProgram', () => { const handlers = createHandlers(); const program = buildProgram(handlers); - await program.parseAsync(['node', 'opencode', 'eval', 'run', '--all']); - await program.parseAsync(['node', 'opencode', 'eval', 'result', 'coding']); - await program.parseAsync(['node', 'opencode', 'connector', 'list']); - await program.parseAsync(['node', 'opencode', 'connector', 'env', 'linear']); + await program.parseAsync(['node', 'apeironcode', 'eval', 'run', '--all']); + await program.parseAsync(['node', 'apeironcode', 'eval', 'result', 'coding']); + await program.parseAsync(['node', 'apeironcode', 'connector', 'list']); + await program.parseAsync(['node', 'apeironcode', 'connector', 'env', 'linear']); expect(handlers.evalRun).toHaveBeenCalledWith(undefined, {all: true}); expect(handlers.evalReport).toHaveBeenCalledWith('coding'); @@ -556,7 +556,7 @@ describe('buildProgram', () => { await program.parseAsync([ 'node', - 'opencode', + 'apeironcode', 'lsp', 'symbols', 'src/agent/loop.ts', @@ -571,7 +571,7 @@ describe('buildProgram', () => { await program.parseAsync([ 'node', - 'opencode', + 'apeironcode', 'lsp', 'diagnostics', 'src/agent/loop.ts', @@ -586,7 +586,7 @@ describe('buildProgram', () => { await program.parseAsync([ 'node', - 'opencode', + 'apeironcode', 'lsp', 'sessions', '--language', @@ -602,7 +602,7 @@ describe('buildProgram', () => { await program.parseAsync([ 'node', - 'opencode', + 'apeironcode', 'lsp', 'restart', '--language', @@ -610,7 +610,7 @@ describe('buildProgram', () => { ]); await program.parseAsync([ 'node', - 'opencode', + 'apeironcode', 'lsp', 'stop', ]); @@ -625,13 +625,13 @@ describe('buildProgram', () => { await program.parseAsync([ 'node', - 'opencode', + 'apeironcode', 'lsp', 'cache', ]); await program.parseAsync([ 'node', - 'opencode', + 'apeironcode', 'lsp', 'cache', 'clear', @@ -647,7 +647,7 @@ describe('buildProgram', () => { await program.parseAsync([ 'node', - 'opencode', + 'apeironcode', 'lsp', 'definition', 'src/agent/loop.ts', @@ -664,7 +664,7 @@ describe('buildProgram', () => { await program.parseAsync([ 'node', - 'opencode', + 'apeironcode', 'lsp', 'references', 'src/agent/loop.ts', @@ -681,20 +681,20 @@ describe('buildProgram', () => { await program.parseAsync([ 'node', - 'opencode', + 'apeironcode', 'repo', ]); await program.parseAsync([ 'node', - 'opencode', + 'apeironcode', 'repo', 'map', ]); await program.parseAsync([ 'node', - 'opencode', + 'apeironcode', 'repo', 'symbols', 'value', @@ -711,7 +711,7 @@ describe('buildProgram', () => { await program.parseAsync([ 'node', - 'opencode', + 'apeironcode', 'continue', 'task-123', ]); @@ -725,7 +725,7 @@ describe('buildProgram', () => { await program.parseAsync([ 'node', - 'opencode', + 'apeironcode', 'plan', 'show', 'task-123', @@ -733,7 +733,7 @@ describe('buildProgram', () => { await program.parseAsync([ 'node', - 'opencode', + 'apeironcode', 'plan', 'status', 'task-456', @@ -749,7 +749,7 @@ describe('buildProgram', () => { await program.parseAsync([ 'node', - 'opencode', + 'apeironcode', 'plan', 'pause', 'task-123', @@ -757,7 +757,7 @@ describe('buildProgram', () => { await program.parseAsync([ 'node', - 'opencode', + 'apeironcode', 'plan', 'resume', 'task-123', @@ -765,7 +765,7 @@ describe('buildProgram', () => { await program.parseAsync([ 'node', - 'opencode', + 'apeironcode', 'plan', 'delete', 'task-123', @@ -782,7 +782,7 @@ describe('buildProgram', () => { await program.parseAsync([ 'node', - 'opencode', + 'apeironcode', 'revert', '--file', 'src/example.ts', @@ -800,7 +800,7 @@ describe('buildProgram', () => { await program.parseAsync([ 'node', - 'opencode', + 'apeironcode', 'cost', ]); @@ -813,7 +813,7 @@ describe('buildProgram', () => { await program.parseAsync([ 'node', - 'opencode', + 'apeironcode', 'cost', '--all', '--session', @@ -831,7 +831,7 @@ describe('buildProgram', () => { await program.parseAsync([ 'node', - 'opencode', + 'apeironcode', 'history', '--all', '--file', diff --git a/tests/cli/connectorCommands.test.ts b/tests/cli/connectorCommands.test.ts index 519c5b1..a61a302 100644 --- a/tests/cli/connectorCommands.test.ts +++ b/tests/cli/connectorCommands.test.ts @@ -15,8 +15,8 @@ describe('connector CLI commands', () => { }) as never; const program = buildProgram(handlers); - await program.parseAsync(['node', 'opencode', 'connector', 'list']); - await program.parseAsync(['node', 'opencode', 'connector', 'env', 'slack']); + await program.parseAsync(['node', 'apeironcode', 'connector', 'list']); + await program.parseAsync(['node', 'apeironcode', 'connector', 'env', 'slack']); expect(connectorList).toHaveBeenCalledTimes(1); expect(connectorEnv).toHaveBeenCalledWith('slack'); diff --git a/tests/cli/contextCommands.test.ts b/tests/cli/contextCommands.test.ts index ced6628..0ed9449 100644 --- a/tests/cli/contextCommands.test.ts +++ b/tests/cli/contextCommands.test.ts @@ -30,7 +30,7 @@ describe('context CLI commands', () => { }); const program = buildProgram(proxy); program.exitOverride(); - await program.parseAsync(['node', 'opencode', 'context', 'plan', 'fix the failing test']); + await program.parseAsync(['node', 'apeironcode', 'context', 'plan', 'fix the failing test']); expect(fn).toHaveBeenCalledWith('fix the failing test'); }); }); diff --git a/tests/cli/debugCommands.test.ts b/tests/cli/debugCommands.test.ts index da1c5ae..2995a02 100644 --- a/tests/cli/debugCommands.test.ts +++ b/tests/cli/debugCommands.test.ts @@ -13,11 +13,11 @@ describe('debug CLI commands', () => { sessionExport: vi.fn(), } as unknown as CliHandlers; const program = buildProgram(handlers); - await program.parseAsync(['node', 'opencode', 'debug', 'traces', '--last', '7']); - await program.parseAsync(['node', 'opencode', 'debug', 'logs', '--last', '9']); - await program.parseAsync(['node', 'opencode', 'debug', 'tokens']); - await program.parseAsync(['node', 'opencode', 'debug', 'config']); - await program.parseAsync(['node', 'opencode', 'session', 'export', 'abc123', '--format', 'html', '--output', 'out.html']); + await program.parseAsync(['node', 'apeironcode', 'debug', 'traces', '--last', '7']); + await program.parseAsync(['node', 'apeironcode', 'debug', 'logs', '--last', '9']); + await program.parseAsync(['node', 'apeironcode', 'debug', 'tokens']); + await program.parseAsync(['node', 'apeironcode', 'debug', 'config']); + await program.parseAsync(['node', 'apeironcode', 'session', 'export', 'abc123', '--format', 'html', '--output', 'out.html']); expect(handlers.debugTraces).toHaveBeenCalledWith({last: 7}); expect(handlers.debugLogs).toHaveBeenCalledWith({last: 9}); expect(handlers.debugTokens).toHaveBeenCalled(); diff --git a/tests/cli/evalCommands.test.ts b/tests/cli/evalCommands.test.ts index ceda9e0..7dc5841 100644 --- a/tests/cli/evalCommands.test.ts +++ b/tests/cli/evalCommands.test.ts @@ -12,10 +12,10 @@ describe('eval CLI commands', () => { const h = handlers(); const program = buildProgram(h); - await program.parseAsync(['node', 'opencode', 'eval', 'list']); - await program.parseAsync(['node', 'opencode', 'eval', 'run', 'smoke']); - await program.parseAsync(['node', 'opencode', 'eval', 'run', '--all']); - await program.parseAsync(['node', 'opencode', 'eval', 'result', 'smoke']); + await program.parseAsync(['node', 'apeironcode', 'eval', 'list']); + await program.parseAsync(['node', 'apeironcode', 'eval', 'run', 'smoke']); + await program.parseAsync(['node', 'apeironcode', 'eval', 'run', '--all']); + await program.parseAsync(['node', 'apeironcode', 'eval', 'result', 'smoke']); expect(h.evalList).toHaveBeenCalledTimes(1); expect(h.evalRun).toHaveBeenCalledWith('smoke', {}); diff --git a/tests/cli/firstRun.test.ts b/tests/cli/firstRun.test.ts index 9714bce..45746c3 100644 --- a/tests/cli/firstRun.test.ts +++ b/tests/cli/firstRun.test.ts @@ -2,6 +2,8 @@ import {describe, expect, it} from 'vitest'; import {detectFirstRunState, formatFirstRunReason, shouldShowFirstRunSetup} from '../../src/cli/setup/firstRun.js'; +const formerCommandPattern = new RegExp(`\\b${['open', 'code'].join('')}\\b`, 'iu'); + describe('detectFirstRunState', () => { it('returns isFirstRun=false for a configured real provider', () => { const state = detectFirstRunState({ @@ -154,6 +156,6 @@ describe('formatFirstRunReason', () => { it('does not leak provider names or secrets in reason text', () => { const result = formatFirstRunReason({isFirstRun: true, reason: 'mock-provider', provider: 'mock', model: ''}); expect(result).not.toMatch(/sk-[A-Za-z0-9]/u); - expect(result).not.toContain('opencode'); + expect(result).not.toMatch(formerCommandPattern); }); }); diff --git a/tests/cli/memoryCommands.test.ts b/tests/cli/memoryCommands.test.ts index 7304699..05f2116 100644 --- a/tests/cli/memoryCommands.test.ts +++ b/tests/cli/memoryCommands.test.ts @@ -52,7 +52,7 @@ describe('memory CLI commands', () => { }); const program = buildProgram(proxy); program.exitOverride(); - await program.parseAsync(['node', 'opencode', 'memory', 'explain', 'fix the auth error']); + await program.parseAsync(['node', 'apeironcode', 'memory', 'explain', 'fix the auth error']); expect(fn).toHaveBeenCalledWith('fix the auth error'); }); @@ -63,7 +63,7 @@ describe('memory CLI commands', () => { }); const program = buildProgram(proxy); program.exitOverride(); - await program.parseAsync(['node', 'opencode', 'memory', 'verify']); + await program.parseAsync(['node', 'apeironcode', 'memory', 'verify']); expect(fn).toHaveBeenCalled(); }); @@ -74,7 +74,7 @@ describe('memory CLI commands', () => { }); const program = buildProgram(proxy); program.exitOverride(); - await program.parseAsync(['node', 'opencode', 'memory', 'compact']); + await program.parseAsync(['node', 'apeironcode', 'memory', 'compact']); expect(fn).toHaveBeenCalled(); }); @@ -85,7 +85,7 @@ describe('memory CLI commands', () => { }); const program = buildProgram(proxy); program.exitOverride(); - await program.parseAsync(['node', 'opencode', 'memory', 'export', '--redacted']); + await program.parseAsync(['node', 'apeironcode', 'memory', 'export', '--redacted']); expect(fn).toHaveBeenCalled(); }); @@ -96,7 +96,7 @@ describe('memory CLI commands', () => { }); const program = buildProgram(proxy); program.exitOverride(); - await program.parseAsync(['node', 'opencode', 'memory', 'forget', 'entity-id-123', '--yes']); + await program.parseAsync(['node', 'apeironcode', 'memory', 'forget', 'entity-id-123', '--yes']); expect(fn).toHaveBeenCalledWith('entity-id-123', expect.objectContaining({yes: true})); }); }); diff --git a/tests/cli/runtimeCommands.test.ts b/tests/cli/runtimeCommands.test.ts index 42de7a8..0eb15ab 100644 --- a/tests/cli/runtimeCommands.test.ts +++ b/tests/cli/runtimeCommands.test.ts @@ -29,7 +29,7 @@ describe('runtime CLI commands', () => { }); const program = buildProgram(proxy); program.exitOverride(); - await program.parseAsync(['node', 'opencode', 'runtime', 'status']); + await program.parseAsync(['node', 'apeironcode', 'runtime', 'status']); expect(fn).toHaveBeenCalled(); }); @@ -40,7 +40,7 @@ describe('runtime CLI commands', () => { }); const program = buildProgram(proxy); program.exitOverride(); - await program.parseAsync(['node', 'opencode', 'runtime', 'checkpoints']); + await program.parseAsync(['node', 'apeironcode', 'runtime', 'checkpoints']); expect(fn).toHaveBeenCalled(); }); @@ -51,7 +51,7 @@ describe('runtime CLI commands', () => { }); const program = buildProgram(proxy); program.exitOverride(); - await program.parseAsync(['node', 'opencode', 'runtime', 'rollback', 'cp-abc-123', '--yes']); + await program.parseAsync(['node', 'apeironcode', 'runtime', 'rollback', 'cp-abc-123', '--yes']); expect(fn).toHaveBeenCalledWith('cp-abc-123', {yes: true}); }); @@ -62,7 +62,7 @@ describe('runtime CLI commands', () => { }); const program = buildProgram(proxy); program.exitOverride(); - await program.parseAsync(['node', 'opencode', 'runtime', 'summary']); + await program.parseAsync(['node', 'apeironcode', 'runtime', 'summary']); expect(fn).toHaveBeenCalledWith(undefined); }); @@ -73,7 +73,7 @@ describe('runtime CLI commands', () => { }); const program = buildProgram(proxy); program.exitOverride(); - await program.parseAsync(['node', 'opencode', 'runtime', 'summary', 'sess-xyz']); + await program.parseAsync(['node', 'apeironcode', 'runtime', 'summary', 'sess-xyz']); expect(fn).toHaveBeenCalledWith('sess-xyz'); }); }); diff --git a/tests/config/config.test.ts b/tests/config/config.test.ts index 565a60a..ff0e843 100644 --- a/tests/config/config.test.ts +++ b/tests/config/config.test.ts @@ -12,10 +12,10 @@ describe('ConfigStore', () => { beforeEach(async () => { previousHome = process.env.HOME; - projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-config-')); - process.env.HOME = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-home-')); + projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-config-')); + process.env.HOME = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-home-')); await fs.mkdir(path.join(projectDir, '.apeironcode-agent'), {recursive: true}); - await fs.writeFile(path.join(projectDir, '.opencodeignore'), 'generated\n', 'utf8'); + await fs.writeFile(path.join(projectDir, '.apeironcodeignore'), 'generated\n', 'utf8'); }); afterEach(async () => { diff --git a/tests/connectors/githubBranchesCommits.test.ts b/tests/connectors/githubBranchesCommits.test.ts index 800131c..ea63cf7 100644 --- a/tests/connectors/githubBranchesCommits.test.ts +++ b/tests/connectors/githubBranchesCommits.test.ts @@ -61,7 +61,7 @@ describe('github branches/commits/checks/reviews', () => { it('parses webhook payload and extracts mention command', () => { const ctx = parseGitHubWebhookPayload({ action: 'created', - comment: {body: '@opencode implement', id: 1}, + comment: {body: '@apeironcode implement', id: 1}, issue: {number: 5, pull_request: {url: 'x'}}, repository: {full_name: 'o/r'}, sender: {login: 'alice'}, diff --git a/tests/context/agentContextIntegration.test.ts b/tests/context/agentContextIntegration.test.ts index 1b90be5..046c2ad 100644 --- a/tests/context/agentContextIntegration.test.ts +++ b/tests/context/agentContextIntegration.test.ts @@ -20,8 +20,8 @@ describe('agent prompt context integration', () => { beforeEach(async () => { previousHome = process.env.HOME; - process.env.HOME = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-context-home-')); - projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-context-project-')); + process.env.HOME = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-context-home-')); + projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-context-project-')); await fs.cp(fixtureRoot, projectDir, {recursive: true}); }); diff --git a/tests/context/repoIntelligence.test.ts b/tests/context/repoIntelligence.test.ts index a62ec26..68c9f6d 100644 --- a/tests/context/repoIntelligence.test.ts +++ b/tests/context/repoIntelligence.test.ts @@ -43,7 +43,7 @@ describe('repoIntelligence', () => { }); it('keeps repo maps out of node_modules by default', async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-repo-map-')); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-repo-map-')); tempDirs.push(tempDir); await fs.mkdir(path.join(tempDir, 'src'), {recursive: true}); diff --git a/tests/diagnostics/doctor.test.ts b/tests/diagnostics/doctor.test.ts index a890cca..1930674 100644 --- a/tests/diagnostics/doctor.test.ts +++ b/tests/diagnostics/doctor.test.ts @@ -48,8 +48,8 @@ describe('doctor diagnostics', () => { beforeEach(async () => { previousHome = process.env.HOME; - projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-doctor-')); - process.env.HOME = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-doctor-home-')); + projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-doctor-')); + process.env.HOME = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-doctor-home-')); }); afterEach(async () => { diff --git a/tests/e2e/vscode-prompt-routing.test.ts b/tests/e2e/bridge-prompt-routing.test.ts similarity index 99% rename from tests/e2e/vscode-prompt-routing.test.ts rename to tests/e2e/bridge-prompt-routing.test.ts index f3e4059..8f0f922 100644 --- a/tests/e2e/vscode-prompt-routing.test.ts +++ b/tests/e2e/bridge-prompt-routing.test.ts @@ -1,5 +1,5 @@ /** - * E2E tests for VS Code prompt routing through the bridge. + * E2E tests for future IDE prompt routing through the local bridge. * Uses mocked Agent runner. No real provider calls. */ @@ -351,7 +351,7 @@ describe('Scenario 9: bridge connection file has no full token', () => { // ─── Test 10: No full token in outputs ─────────────────────────────────────── -describe('Scenario 10: no full token or secret in extension output', () => { +describe('Scenario 10: no full token or secret in bridge output', () => { it('bridge error messages do not expose wrong tokens', async () => { const transport = new InMemoryTransport(); const server = new BridgeServer( diff --git a/tests/e2e/cli-github-models-real-flow.test.ts b/tests/e2e/cli-github-models-real-flow.test.ts index db31c86..1c420d0 100644 --- a/tests/e2e/cli-github-models-real-flow.test.ts +++ b/tests/e2e/cli-github-models-real-flow.test.ts @@ -26,6 +26,8 @@ import {editFileTool} from '../../src/tools/editFile.js'; import {createMockConfig} from '../support/mocks.js'; const TOKEN = 'github_pat_11SECRETflowTokenDoNotLeak1234567890'; +const formerBrand = ['Open', 'Code'].join(''); +const formerCommandPattern = new RegExp(`\\b${['open', 'code'].join('')}\\b`, 'iu'); describe('GitHub Models real-user flow', () => { const originalHome = process.env.HOME; @@ -63,7 +65,8 @@ describe('GitHub Models real-user flow', () => { expect(out).toContain('ApeironCode'); expect(out).toContain('github-models/openai/gpt-4.1'); expect(out).not.toContain('Workspace Home'); - expect(out.toLowerCase()).not.toContain('opencode'); + expect(out).not.toContain(formerBrand); + expect(out).not.toMatch(formerCommandPattern); expect(out).not.toContain('mock-coder'); expect(out.split('\n').length).toBeLessThanOrEqual(15); }); diff --git a/tests/e2e/cli-real-user-flow.test.ts b/tests/e2e/cli-real-user-flow.test.ts index 9e79e4e..d7ea41b 100644 --- a/tests/e2e/cli-real-user-flow.test.ts +++ b/tests/e2e/cli-real-user-flow.test.ts @@ -17,6 +17,8 @@ import {shouldRequirePlan} from '../../src/agent/planningGate.js'; import {requiresRuntimeApproval} from '../../src/agent/runtimePermissions.js'; const TOKEN = 'github_pat_11SECRETSECRETSECRETdonotleak1234567890'; +const formerBrand = ['Open', 'Code'].join(''); +const formerCommandPattern = new RegExp(`\\b${['open', 'code'].join('')}\\b`, 'iu'); describe('CLI real-user flow', () => { const originalHome = process.env.HOME; @@ -62,7 +64,8 @@ describe('CLI real-user flow', () => { expect(home).toContain('ApeironCode'); expect(home).toContain('github-models/openai/gpt-4.1'); expect(home).not.toContain('mock-coder'); - expect(home.toLowerCase()).not.toContain('opencode'); + expect(home).not.toContain(formerBrand); + expect(home).not.toMatch(formerCommandPattern); expect(home).not.toContain(TOKEN); }); @@ -91,18 +94,10 @@ describe('CLI real-user flow', () => { expect(src).toMatch(/runCli\(\)\.then\(/u); }); - it('12. legacy .opencode-agent config migrates to ApeironCode home', async () => { - const {migrateLegacyAppHome, getGlobalConfigPath} = await import( + it('12. global config uses the ApeironCode home', async () => { + const {getGlobalConfigPath} = await import( '../../src/utils/paths.js' ); - const fsSync = await import('node:fs'); - const legacyDir = path.join(home, '.opencode-agent'); - fsSync.mkdirSync(legacyDir, {recursive: true}); - fsSync.writeFileSync( - path.join(legacyDir, 'config.json'), - JSON.stringify({defaultProvider: 'github-models', defaultModel: 'openai/gpt-4.1'}), - ); - expect(migrateLegacyAppHome()).toBe(true); expect(getGlobalConfigPath()).toBe( path.join(home, '.apeironcode-agent', 'config.json'), ); diff --git a/tests/e2e/cli-smoke.test.ts b/tests/e2e/cli-smoke.test.ts index 4460cd8..7b878a3 100644 --- a/tests/e2e/cli-smoke.test.ts +++ b/tests/e2e/cli-smoke.test.ts @@ -20,17 +20,17 @@ describe('CLI smoke acceptance E2E', () => { it('renders root help without invoking a handler', () => { const {program} = createProgram(); - expect(() => program.parse(['node', 'opencode', '--help'])).toThrow(); + expect(() => program.parse(['node', 'apeironcode', '--help'])).toThrow(); expect(program.helpInformation()).toContain('ApeironCode'); }); it('routes provider and connector inspection commands', async () => { const {calls, program} = createProgram(); - await program.parseAsync(['node', 'opencode', 'provider', 'list']); - await program.parseAsync(['node', 'opencode', 'provider', 'env', 'gemini']); - await program.parseAsync(['node', 'opencode', 'connector', 'list']); - await program.parseAsync(['node', 'opencode', 'connector', 'env', 'linear']); + await program.parseAsync(['node', 'apeironcode', 'provider', 'list']); + await program.parseAsync(['node', 'apeironcode', 'provider', 'env', 'gemini']); + await program.parseAsync(['node', 'apeironcode', 'connector', 'list']); + await program.parseAsync(['node', 'apeironcode', 'connector', 'env', 'linear']); expect(calls.providerList).toHaveBeenCalledTimes(1); expect(calls.providerEnv).toHaveBeenCalledWith('gemini'); @@ -41,9 +41,9 @@ describe('CLI smoke acceptance E2E', () => { it('routes eval, doctor, and debug commands without real services', async () => { const {calls, program} = createProgram(); - await program.parseAsync(['node', 'opencode', 'eval', 'list']); - await program.parseAsync(['node', 'opencode', 'doctor']); - await program.parseAsync(['node', 'opencode', 'debug', 'config']); + await program.parseAsync(['node', 'apeironcode', 'eval', 'list']); + await program.parseAsync(['node', 'apeironcode', 'doctor']); + await program.parseAsync(['node', 'apeironcode', 'debug', 'config']); expect(calls.evalList).toHaveBeenCalledTimes(1); expect(calls.doctor).toHaveBeenCalledWith(expect.objectContaining({dangerouslySkipApprovals: false})); diff --git a/tests/e2e/doctor-debug-export.test.ts b/tests/e2e/doctor-debug-export.test.ts index ce02155..9c97a17 100644 --- a/tests/e2e/doctor-debug-export.test.ts +++ b/tests/e2e/doctor-debug-export.test.ts @@ -27,8 +27,8 @@ describe('doctor, debug, and export acceptance E2E', () => { const setup = async () => { previousHome = process.env.HOME; - home = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-doctor-home-')); - cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-doctor-cwd-')); + home = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-doctor-home-')); + cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-doctor-cwd-')); process.env.HOME = home; const store = new ConfigStore(cwd); await store.patchUserConfig({approvalMode: 'bypass', defaultModel: 'mock-coder', defaultProvider: 'mock'}); @@ -53,7 +53,7 @@ describe('doctor, debug, and export acceptance E2E', () => { const span = startSpan('acceptance.trace', {authorization: 'Bearer secret-token'}); span.end(); const traceOutput = formatTraceSummary([span.span]); - const logDir = path.join(home, '.opencode-agent', 'logs'); + const logDir = path.join(home, '.apeironcode-agent', 'logs'); new StructuredLogger({level: 'debug', logDir}).info('token=secret-token', {apiKey: 'secret-token', config}); await new Promise((resolve) => setTimeout(resolve, 20)); const logs = await readRecentLogLines(logDir, 10); diff --git a/tests/e2e/evals-acceptance.test.ts b/tests/e2e/evals-acceptance.test.ts index 5e8349c..5fd2090 100644 --- a/tests/e2e/evals-acceptance.test.ts +++ b/tests/e2e/evals-acceptance.test.ts @@ -38,7 +38,7 @@ describe('eval framework acceptance E2E', () => { }); it('saves and loads eval results with token efficiency metrics', async () => { - const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-eval-results-')); + const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-eval-results-')); try { const summary = await runSuiteById('smoke'); await saveEvalResult(summary, outputDir); @@ -65,9 +65,9 @@ describe('eval framework acceptance E2E', () => { }) as never); program.exitOverride(); - await program.parseAsync(['node', 'opencode', 'eval', 'list']); - await program.parseAsync(['node', 'opencode', 'eval', 'run', 'smoke']); - await program.parseAsync(['node', 'opencode', 'eval', 'result', 'smoke']); + await program.parseAsync(['node', 'apeironcode', 'eval', 'list']); + await program.parseAsync(['node', 'apeironcode', 'eval', 'run', 'smoke']); + await program.parseAsync(['node', 'apeironcode', 'eval', 'result', 'smoke']); expect(handlers.evalList).toHaveBeenCalledTimes(1); expect(handlers.evalRun).toHaveBeenCalledWith('smoke', {}); diff --git a/tests/e2e/first-run-setup.test.ts b/tests/e2e/first-run-setup.test.ts index 8fcaed1..233bcbe 100644 --- a/tests/e2e/first-run-setup.test.ts +++ b/tests/e2e/first-run-setup.test.ts @@ -9,6 +9,9 @@ import {maskApiKey, formatProviderList, listProviderOptions} from '../../src/cli import {formatCompactHome} from '../../src/ui/welcomeDashboard.js'; import {isInteractiveContext} from '../../src/cli/setup/interactiveGuard.js'; +const formerBrand = ['Open', 'Code'].join(''); +const formerCommandPattern = new RegExp(`\\b${['open', 'code'].join('')}\\b`, 'iu'); + describe('detectFirstRunState — all scenarios', () => { it('no config → first run triggered', () => { const state = detectFirstRunState({hasUserConfigFile: false, defaultProvider: 'ollama', defaultModel: 'qwen2.5-coder:7b'}); @@ -117,14 +120,15 @@ describe('compact home — format and line count', () => { expect(lines.length).toBeLessThanOrEqual(15); }); - it('no stale branding in output', () => { + it('uses current ApeironCode branding in output', () => { const output = formatCompactHome({ provider: 'anthropic', model: 'claude-sonnet', workspacePath: '/home/user/project', }); - expect(output).not.toContain('OpenCode'); - expect(output).not.toContain(' opencode'); + expect(output).toContain('ApeironCode'); + expect(output).not.toContain(formerBrand); + expect(output).not.toMatch(formerCommandPattern); }); it('no api keys in output even with secret-looking path', () => { @@ -174,19 +178,19 @@ describe('interactive guard — CI detection', () => { }); }); -describe('formatFirstRunReason — no stale branding', () => { +describe('formatFirstRunReason — current branding', () => { const allReasons = ['no-config', 'mock-provider', 'mock-model', 'ok'] as const; for (const reason of allReasons) { - it(`reason=${reason} contains no OpenCode branding`, () => { + it(`reason=${reason} contains no former branding`, () => { const text = formatFirstRunReason({ isFirstRun: reason !== 'ok', reason, provider: 'mock', model: 'mock-coder', }); - expect(text).not.toContain('OpenCode'); - expect(text).not.toMatch(/\bopencode\b/iu); + expect(text).not.toContain(formerBrand); + expect(text).not.toMatch(formerCommandPattern); }); } }); diff --git a/tests/e2e/githubAutomation.test.ts b/tests/e2e/githubAutomation.test.ts index 49b8809..a838902 100644 --- a/tests/e2e/githubAutomation.test.ts +++ b/tests/e2e/githubAutomation.test.ts @@ -21,7 +21,7 @@ describe('GitHub automation e2e behaviors', () => { } if (path.startsWith('/pulls?')) { return Promise.resolve(new Response(JSON.stringify([ - {number: 99, body: `existing\n${formatRunMarkerComment(marker)}`, head: {ref: 'opencode/issue-42'}, html_url: 'http://x/99'}, + {number: 99, body: `existing\n${formatRunMarkerComment(marker)}`, head: {ref: 'apeironcode/issue-42'}, html_url: 'http://x/99'}, ]), {status: 200})); } if (path === '/') { @@ -36,7 +36,7 @@ describe('GitHub automation e2e behaviors', () => { }); const result = await runIssueToPrAutomation({ client, - config: loadAutomationPermissionsFromEnv({OPENCODE_AUTOMATION: '1', OPENCODE_AUTOMATION_PR_CREATE: '1', OPENCODE_AUTOMATION_COMMIT: '1'}), + config: loadAutomationPermissionsFromEnv({APEIRONCODE_AUTOMATION: '1', APEIRONCODE_AUTOMATION_PR_CREATE: '1', APEIRONCODE_AUTOMATION_COMMIT: '1'}), issueNumber: 42, options: {dryRun: false}, repoFullName: 'acme/widgets', diff --git a/tests/e2e/harness.ts b/tests/e2e/harness.ts index af30642..a0abe47 100644 --- a/tests/e2e/harness.ts +++ b/tests/e2e/harness.ts @@ -90,8 +90,8 @@ export class E2EHarness { async setup(): Promise { this.previousHome = process.env.HOME; - this.homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-e2e-home-')); - this.workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-e2e-workspace-')); + this.homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-e2e-home-')); + this.workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-e2e-workspace-')); process.env.HOME = this.homeDir; await this.createWorkspace(this.initialFixtures); this.agent = await this.createAgent(); @@ -177,7 +177,7 @@ export class E2EHarness { async cleanup(): Promise { process.env.HOME = this.previousHome; for (const target of [this.homeDir, this.workspace]) { - if (target && path.relative(os.tmpdir(), target).startsWith('opencode-e2e-')) { + if (target && path.relative(os.tmpdir(), target).startsWith('apeironcode-e2e-')) { await fs.rm(target, {force: true, recursive: true}); } } diff --git a/tests/e2e/productFlow.test.ts b/tests/e2e/productFlow.test.ts index 8f7b7b3..895ef62 100644 --- a/tests/e2e/productFlow.test.ts +++ b/tests/e2e/productFlow.test.ts @@ -16,7 +16,7 @@ const runCli = ( env: { ...process.env, HOME: options.home ?? process.env.HOME, - OPENCODE_HOME: options.home ?? process.env.OPENCODE_HOME, + APEIRONCODE_HOME: options.home ?? process.env.APEIRONCODE_HOME, }, }); return { @@ -27,8 +27,8 @@ const runCli = ( describe('product user flows', () => { it('supports first-run mock setup and product-health commands from the built CLI', async () => { - const home = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-e2e-home-')); - const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-e2e-workspace-')); + const home = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-e2e-home-')); + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-e2e-workspace-')); try { const setup = runCli(['setup', '--provider', 'mock'], {cwd, home}); expect(setup.status).toBe(0); diff --git a/tests/e2e/project-brain-dominant.test.ts b/tests/e2e/project-brain-dominant.test.ts index 1abd117..e48e07c 100644 --- a/tests/e2e/project-brain-dominant.test.ts +++ b/tests/e2e/project-brain-dominant.test.ts @@ -247,7 +247,7 @@ describe('Scenario 6: large app orchestration with phases/tasks/agents', () => { }); }); -// ─── Scenario 7: VS Code brain view lists saved previews (unit) ────────────── +// ─── Scenario 7: IDE bridge brain view lists saved previews (unit) ────────── describe('Scenario 7: formatSyncPreviewList output safety', () => { it('returns no-previews message when empty', () => { @@ -265,7 +265,7 @@ describe('Scenario 7: formatSyncPreviewList output safety', () => { }); }); -// ─── Scenario 8: VS Code build-plan view renders rich plan ────────────────── +// ─── Scenario 8: IDE bridge build-plan view renders rich plan ─────────────── describe('Scenario 8: orchestration output has all required fields', () => { it('all required AppBuildOrchestration fields are present and non-empty', () => { @@ -363,7 +363,7 @@ describe('Scenario 11: No silent .apeironcode/ creation', () => { }); }); -// ─── Scenario 12: No secrets in brain previews or VS Code views ────────────── +// ─── Scenario 12: No secrets in brain previews or IDE bridge views ────────── describe('Scenario 12: No secrets in brain previews or formatted output', () => { it('sync preview changes summary is redacted', async () => { diff --git a/tests/e2e/provider-tools.test.ts b/tests/e2e/provider-tools.test.ts index 242b56e..de79235 100644 --- a/tests/e2e/provider-tools.test.ts +++ b/tests/e2e/provider-tools.test.ts @@ -77,7 +77,7 @@ describe('provider and tool schema integration E2E', () => { const run = await harness.run('Explain the project', {mode: 'explain'}); const payload = JSON.stringify(run.providerCalls.flatMap((call) => call.messages)); - expect(payload).not.toContain(''); - expect(payload).not.toContain(''); + expect(payload).not.toContain(''); + expect(payload).not.toContain(''); }); }); diff --git a/tests/e2e/real-dogfood-17g.test.ts b/tests/e2e/real-dogfood-17g.test.ts index e56859f..61e499d 100644 --- a/tests/e2e/real-dogfood-17g.test.ts +++ b/tests/e2e/real-dogfood-17g.test.ts @@ -262,7 +262,7 @@ function calculate(a,b,op){return op==='+'?a+b:0} it('path-safety: traversal attempts in detailed prompts are rejected by the workspace snapshot', async () => { // workspaceFileSnapshot's assessPath blocks ../../etc/passwd. const {readWorkspaceFiles} = await import('../../src/agent/workspaceFileSnapshot.js'); - const tmp = await fs.mkdtemp(path.join((await import('node:os')).tmpdir(), 'opencode-e2e-17g-')); + const tmp = await fs.mkdtemp(path.join((await import('node:os')).tmpdir(), 'apeironcode-e2e-17g-')); try { await fs.writeFile(path.join(tmp, 'calculator-x.css'), 'body{}'); const entries = await readWorkspaceFiles(['../../etc/passwd', 'calculator-x.css'], {cwd: tmp}); diff --git a/tests/e2e/ui-ux.test.ts b/tests/e2e/ui-ux.test.ts index 8e16835..249e569 100644 --- a/tests/e2e/ui-ux.test.ts +++ b/tests/e2e/ui-ux.test.ts @@ -4,6 +4,8 @@ import {buildWelcomeDashboardModel, renderWelcomeDashboard} from '../../src/ui/w import {renderPermissionCard, renderToolResultCard} from '../../src/ui/toolCards.js'; import {stripAnsi} from '../../src/ui/theme.js'; +const formerBrand = ['Open', 'Code'].join(''); + describe('Premium UI/UX E2E', () => { it('CLI welcome dashboard renders without secrets and supports no-color', () => { const output = stripAnsi(renderWelcomeDashboard(buildWelcomeDashboardModel({ @@ -18,7 +20,7 @@ describe('Premium UI/UX E2E', () => { expect(output).toContain('Project Brain'); expect(output).toContain('Bridge'); expect(output).not.toContain(String.fromCharCode(27)); - expect(output).not.toContain('OpenCode'); + expect(output).not.toContain(formerBrand); }); it('permission and tool cards preserve risk while truncating secret output', () => { diff --git a/tests/evals/evals.test.ts b/tests/evals/evals.test.ts index be77297..d92d6df 100644 --- a/tests/evals/evals.test.ts +++ b/tests/evals/evals.test.ts @@ -9,7 +9,7 @@ import {runEval} from '../../src/evals/runner.js'; describe('local eval compatibility', () => { it('keeps legacy eval list and run imports working against real suites', async () => { - const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-evals-')); + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-evals-')); expect(formatEvalList()).toContain('smoke'); const report = await runEval(cwd, 'smoke'); @@ -20,7 +20,7 @@ describe('local eval compatibility', () => { }); it('records unknown eval suites as failed reports with guidance', async () => { - const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-evals-')); + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-evals-')); const report = await runEval(cwd, 'missing'); expect(report.results[0]?.status).toBe('fail'); diff --git a/tests/evals/results.test.ts b/tests/evals/results.test.ts index cda219a..d57fa2b 100644 --- a/tests/evals/results.test.ts +++ b/tests/evals/results.test.ts @@ -47,7 +47,7 @@ const summary: EvalRunSummary = { describe('eval results', () => { it('saves, loads, formats, and redacts secrets', async () => { - const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-eval-results-')); + const outputDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-eval-results-')); await saveEvalResult({ ...summary, results: [{...summary.results[0]!, failures: ['token ghp_secret123 should hide']}], diff --git a/tests/fixtures/node-basic/.apeironcode-agent/repo-map.json b/tests/fixtures/node-basic/.apeironcode-agent/repo-map.json index ab29284..3d188d7 100644 --- a/tests/fixtures/node-basic/.apeironcode-agent/repo-map.json +++ b/tests/fixtures/node-basic/.apeironcode-agent/repo-map.json @@ -1,5 +1,5 @@ { - "configSignature": "package.json:1777352631428.6963", + "configSignature": "package.json:1780886759577.2012", "entries": [ { "path": "package.json", @@ -28,10 +28,158 @@ "entrypoints": [], "frameworks": [], "git": { - "branch": null, - "changedFiles": 0, - "changedPaths": [], - "isRepo": false + "branch": "phase-19g-public-hardening", + "changedFiles": 147, + "changedPaths": [ + "../../../.github/workflows/ci.yml", + "../../../.gitignore", + "../../../CHANGELOG.md", + "../../../LICENSE", + "../../../README.md", + "../../../action.yml", + "../../../docs/agents.md", + "../../../docs/bridge.md", + "../../../docs/github.md", + "../../../docs/history.md", + "../../../docs/project-brain.md", + "../../../docs/providers.md", + "../../../docs/publishing.md", + "../../../docs/release-notes/v0.1.0-alpha.md", + "../../../docs/sandbox.md", + "../../../docs/security-model.md", + "../../../docs/skills.md", + "../../../docs/testing.md", + "../../../docs/ui-ux.md", + "../../../docs/vscode-extension.md", + "../../../docs/workflows.md", + "../../../examples/demo/workspace-ignore/.apeironcodeignore", + "../../../package-lock.json", + "../../../package.json", + "../../../src/agent/planner.ts", + "../../../src/agents/workspace/ignoreRules.ts", + "../../../src/agents/workspace/types.ts", + "../../../src/bridge/connectionFile.ts", + "../../../src/bridge/server.ts", + "../../../src/cli/setup/index.ts", + "../../../src/config/config.ts", + "../../../src/connectors/github/webhooks.ts", + "../../../src/context/ignore.ts", + "../../../src/diagnostics/doctor.ts", + "../../../src/diagnostics/extraChecks.ts", + "../../../src/githubAction/config.ts", + "../../../src/githubAction/runner.ts", + "../../../src/githubAutomation/idempotency.ts", + "../../../src/githubAutomation/permissions.ts", + "../../../src/projectBrain/audit.ts", + "../../../src/sandbox/format.ts", + "../../../src/sandbox/manager.ts", + "../../../src/ui/welcomeDashboard.ts", + "../../../src/utils/logger.ts", + "../../../src/utils/paths.ts", + "../../../src/utils/structuredLogger.ts", + "../../agent/agent.integration.test.ts", + "../../agent/checkpoints.test.ts", + "../../agent/diffBudget.test.ts", + "../../agent/loop.test.ts", + "../../agent/lsp-context.test.ts", + "../../agent/memoryManager.test.ts", + "../../agent/planner.test.ts", + "../../agent/planningGate.integration.test.ts", + "../../agent/runMemoryAuthGuard.test.ts", + "../../agent/runtimeResume.test.ts", + "../../agent/workflows.test.ts", + "../../agents/reviewActions.test.ts", + "../../agents/team.test.ts", + "../../agents/workspace.test.ts", + "../../branding/publicBranding.test.ts", + "../../branding/releaseReadiness.test.ts", + "../../branding/staleBranding.test.ts", + "../../cli/branding.test.ts", + "../../cli/commands.test.ts", + "../../cli/connectorCommands.test.ts", + "../../cli/contextCommands.test.ts", + "../../cli/debugCommands.test.ts", + "../../cli/evalCommands.test.ts", + "../../cli/firstRun.test.ts", + "../../cli/memoryCommands.test.ts", + "../../cli/runtimeCommands.test.ts", + "../../config/config.test.ts", + "../../connectors/githubBranchesCommits.test.ts", + "../../context/agentContextIntegration.test.ts", + "../../context/repoIntelligence.test.ts", + "../../diagnostics/doctor.test.ts", + "../../e2e/vscode-prompt-routing.test.ts -> ../../e2e/bridge-prompt-routing.test.ts", + "../../e2e/cli-github-models-real-flow.test.ts", + "../../e2e/cli-real-user-flow.test.ts", + "../../e2e/cli-smoke.test.ts", + "../../e2e/doctor-debug-export.test.ts", + "../../e2e/evals-acceptance.test.ts", + "../../e2e/first-run-setup.test.ts", + "../../e2e/githubAutomation.test.ts", + "../../e2e/harness.ts", + "../../e2e/productFlow.test.ts", + "../../e2e/project-brain-dominant.test.ts", + "../../e2e/provider-tools.test.ts", + "../../e2e/real-dogfood-17g.test.ts", + "../../e2e/ui-ux.test.ts", + "../../evals/evals.test.ts", + "../../evals/results.test.ts", + "../../githubAction/runner.test.ts", + "../../githubAutomation/ciLogParser.test.ts", + "../../githubAutomation/commentCommands.test.ts", + "../../githubAutomation/idempotency.test.ts", + "../../githubAutomation/issueToPr.test.ts", + "../../githubAutomation/permissions.test.ts", + "../../githubAutomation/prReview.test.ts", + "../../helpers/workflow.ts", + "../../hooks/hooks.test.ts", + "../../hooks/runtime.integration.test.ts", + "../../lsp/client.test.ts", + "../../lsp/definitions.test.ts", + "../../lsp/diagnostics.test.ts", + "../../lsp/documentStore.test.ts", + "../../lsp/references.test.ts", + "../../lsp/sessionManager.test.ts", + "../../lsp/symbols.test.ts", + "../../mcp/hardening.test.ts", + "../../mcp/oauthFlow.test.ts", + "../../memory/compaction.test.ts", + "../../memory/graph.test.ts", + "../../memory/indexStore.test.ts", + "../../memory/retrievalEval.test.ts", + "../../memory/search.test.ts", + "../../memory/suggestions.test.ts", + "../../memory/verification.test.ts", + "../../multisession/background.test.ts", + "../../multisession/behavior.test.ts", + "../../plugins/loader.test.ts", + "../../projectBrain/audit.test.ts", + "../../projectBrain/templates.test.ts", + "../../safety/safety.integration.test.ts", + "../../sandbox/docker.test.ts", + "../../sandbox/podman.test.ts", + "../../sandbox/sandbox.test.ts", + "../../sessions/store.test.ts", + "../../setup/setup.test.ts", + "../../share/export.test.ts", + "../../skills/runtime.test.ts", + "../../skills/skills.test.ts", + "../../smoke/cli-smoke.test.ts", + "../../tasks/task-store.test.ts", + "../../tools/edit-history.test.ts", + "../../tools/patch-engine.test.ts", + "../../tools/tools.test.ts", + "../../ui/brand.test.ts", + "../../ui/cliSnapshots.test.ts", + "../../ui/slashCommands.test.ts", + "../../ui/viewModels.test.ts", + "../../ui/welcomeDashboard.test.ts", + "../../utils/legacyHomeMigration.test.ts", + "../../utils/paths.test.ts", + "../../utils/structuredLogger.test.ts", + "../../workflows/runtime.test.ts" + ], + "isRepo": true }, "languages": [ "TypeScript/Node.js" @@ -56,7 +204,7 @@ "monorepo": false, "packageManager": null, "projectName": "fixture-node-basic", - "projectSummary": "Project: fixture-node-basic\nLanguages: TypeScript/Node.js\nFrameworks: none detected\nPackage manager: unknown\nSource directories: src\nConfig files: package.json\nEntrypoints: none detected\nTest command: node tests/check.mjs\nLint command: none detected\nBuild command: none detected", + "projectSummary": "Project: fixture-node-basic\nLanguages: TypeScript/Node.js\nFrameworks: none detected\nPackage manager: unknown\nSource directories: src\nConfig files: package.json\nEntrypoints: none detected\nTest command: node tests/check.mjs\nLint command: none detected\nBuild command: none detected\nGit: branch phase-19g-public-hardening, 147 changed file(s)", "sourceDirectories": [ "src" ], @@ -76,6 +224,6 @@ "configFiles": [ "package.json" ], - "lastIndexed": "2026-06-06T22:20:18.322Z", + "lastIndexed": "2026-06-08T03:15:15.826Z", "version": "1.0" -} \ No newline at end of file +} diff --git a/tests/githubAction/runner.test.ts b/tests/githubAction/runner.test.ts index 00a826b..bd2d5c2 100644 --- a/tests/githubAction/runner.test.ts +++ b/tests/githubAction/runner.test.ts @@ -28,12 +28,12 @@ describe('action runner', () => { it('parses issue_comment event and routes to mention workflow', async () => { const event = parseRawActionEvent('issue_comment', JSON.stringify({ action: 'created', - comment: {body: '@opencode review please', id: 1}, + comment: {body: '@apeironcode review please', id: 1}, issue: {number: 9, pull_request: {url: 'x'}}, repository: {full_name: 'octo/example'}, })); expect(event.context.eventType).toBe('issue_comment'); - expect(event.context.commentBody).toContain('@opencode'); + expect(event.context.commentBody).toContain('@apeironcode'); const env = buildEnv({INPUT_DRY_RUN: 'true'}); const config = loadActionConfigFromEnv(env); @@ -62,7 +62,7 @@ describe('action runner', () => { it('rejects unknown mention commands safely', async () => { const event = parseRawActionEvent('issue_comment', JSON.stringify({ - comment: {body: '@opencode launch-rocket'}, + comment: {body: '@apeironcode launch-rocket'}, issue: {number: 4}, repository: {full_name: 'octo/example'}, })); @@ -89,7 +89,7 @@ describe('action runner', () => { try { const result = await runActionFromEvent({ config: loadActionConfigFromEnv(buildEnv({INPUT_DRY_RUN: 'false'})), - env: buildEnv({OPENCODE_AUTOMATION: '1', OPENCODE_AUTOMATION_REVIEW: '1'}), + env: buildEnv({APEIRONCODE_AUTOMATION: '1', APEIRONCODE_AUTOMATION_REVIEW: '1'}), event, }); expect(result.workflow).toBe('pr-review'); @@ -101,7 +101,7 @@ describe('action runner', () => { it('skips replayed comments that contain the action run marker', async () => { const event = parseRawActionEvent('issue_comment', JSON.stringify({ - comment: {body: '@opencode review\n'}, + comment: {body: '@apeironcode review\n'}, issue: {number: 4, pull_request: {url: 'x'}}, repository: {full_name: 'octo/example'}, })); diff --git a/tests/githubAutomation/ciLogParser.test.ts b/tests/githubAutomation/ciLogParser.test.ts index e6e5da1..f7dd81e 100644 --- a/tests/githubAutomation/ciLogParser.test.ts +++ b/tests/githubAutomation/ciLogParser.test.ts @@ -8,7 +8,7 @@ describe('compressCiLog', () => { 'npm notice please update', 'added 250 packages, and audited 251 packages in 2s', '23 vulnerabilities (1 high, 22 low)', - '> opencode-agent@0.1.0 test', + '> apeironcode@0.1.0 test', 'running tests', ].join('\n'); const compressed = compressCiLog(raw); diff --git a/tests/githubAutomation/commentCommands.test.ts b/tests/githubAutomation/commentCommands.test.ts index 618396f..adcafc0 100644 --- a/tests/githubAutomation/commentCommands.test.ts +++ b/tests/githubAutomation/commentCommands.test.ts @@ -13,7 +13,7 @@ describe('mention command parsing', () => { }); it('parses a known implement command', () => { - const mention = resolveMentionFromComment('Hey @opencode implement this please'); + const mention = resolveMentionFromComment('Hey @apeironcode implement this please'); expect(mention).not.toBeNull(); expect(mention?.command).toBe('implement'); expect(mention?.known).toBe(true); @@ -21,18 +21,18 @@ describe('mention command parsing', () => { }); it('parses review command across multiple lines', () => { - const mention = resolveMentionFromComment('first line\n@opencode review'); + const mention = resolveMentionFromComment('first line\n@apeironcode review'); expect(mention?.command).toBe('review'); expect(mention?.known).toBe(true); }); it('marks unknown commands as unknown', () => { - const mention = resolveMentionFromComment('@opencode launch-rocket'); + const mention = resolveMentionFromComment('@apeironcode launch-rocket'); expect(mention?.known).toBe(false); }); it('builds a safe result for unknown commands', () => { - const mention = resolveMentionFromComment('@opencode launch-rocket'); + const mention = resolveMentionFromComment('@apeironcode launch-rocket'); expect(mention).not.toBeNull(); const result = buildUnknownCommandResult(mention!); expect(result.status).toBe('skipped'); diff --git a/tests/githubAutomation/idempotency.test.ts b/tests/githubAutomation/idempotency.test.ts index 95d718e..30080d7 100644 --- a/tests/githubAutomation/idempotency.test.ts +++ b/tests/githubAutomation/idempotency.test.ts @@ -4,7 +4,7 @@ import { formatRunMarkerComment, parseRunMarker, findExistingRunMarker, - findExistingOpenCodePr, + findExistingApeironCodePr, findDuplicateRunMarkerInIssue, dedupeReviewCommentBodies, markersMatch, @@ -45,9 +45,9 @@ describe('idempotency markers', () => { it('parseRunMarker safely ignores malformed markers', () => { expect(parseRunMarker(undefined)).toBeNull(); expect(parseRunMarker('no marker here')).toBeNull(); - expect(parseRunMarker('')).toBeNull(); - expect(parseRunMarker('')).toBeNull(); - expect(parseRunMarker('')).toBeNull(); + expect(parseRunMarker('')).toBeNull(); + expect(parseRunMarker('')).toBeNull(); + expect(parseRunMarker('')).toBeNull(); }); it('markersMatch matches on key fields', () => { @@ -61,7 +61,7 @@ describe('idempotency markers', () => { const marker = buildRunMarker(key); const comments = [ {body: 'unrelated'}, - {body: `OpenCode result\n${formatRunMarkerComment(marker)}`}, + {body: `ApeironCode result\n${formatRunMarkerComment(marker)}`}, ]; expect(findExistingRunMarker(comments, key)?.hash).toBe(marker.hash); expect(findExistingRunMarker(comments, {...key, issueOrPrNumber: 99})).toBeNull(); @@ -104,24 +104,24 @@ describe('idempotency lookups', () => { expect(found?.hash).toBe(marker.hash); }); - it('findExistingOpenCodePr matches by branch name', async () => { + it('findExistingApeironCodePr matches by branch name', async () => { const client = buildClient(new Map([ ['/pulls?state=all&per_page=100', [ - {number: 7, head: {ref: 'opencode/issue-42'}, html_url: 'http://example/7', body: ''}, + {number: 7, head: {ref: 'apeironcode/issue-42'}, html_url: 'http://example/7', body: ''}, ]], ])); - const found = await findExistingOpenCodePr(client, key, 'opencode/issue-42'); + const found = await findExistingApeironCodePr(client, key, 'apeironcode/issue-42'); expect(found?.number).toBe(7); }); - it('findExistingOpenCodePr matches by marker in PR body', async () => { + it('findExistingApeironCodePr matches by marker in PR body', async () => { const marker = buildRunMarker(key); const client = buildClient(new Map([ ['/pulls?state=all&per_page=100', [ {number: 9, head: {ref: 'somebody/branch'}, body: `# Body\n${formatRunMarkerComment(marker)}`}, ]], ])); - const found = await findExistingOpenCodePr(client, key); + const found = await findExistingApeironCodePr(client, key); expect(found?.number).toBe(9); }); @@ -129,7 +129,7 @@ describe('idempotency lookups', () => { const client = buildClient(new Map([ ['/pulls?state=all&per_page=100', []], ])); - expect(await findExistingOpenCodePr(client, key, 'opencode/issue-42')).toBeNull(); + expect(await findExistingApeironCodePr(client, key, 'apeironcode/issue-42')).toBeNull(); }); it('marker comment never includes raw token-like values', () => { diff --git a/tests/githubAutomation/issueToPr.test.ts b/tests/githubAutomation/issueToPr.test.ts index d878bdf..2d5bb8b 100644 --- a/tests/githubAutomation/issueToPr.test.ts +++ b/tests/githubAutomation/issueToPr.test.ts @@ -81,9 +81,9 @@ describe('runIssueToPrAutomation', () => { const result = await runIssueToPrAutomation({ client, config: loadAutomationPermissionsFromEnv({ - OPENCODE_AUTOMATION: '1', - OPENCODE_AUTOMATION_COMMIT: '1', - OPENCODE_AUTOMATION_PR_CREATE: '1', + APEIRONCODE_AUTOMATION: '1', + APEIRONCODE_AUTOMATION_COMMIT: '1', + APEIRONCODE_AUTOMATION_PR_CREATE: '1', }), issueNumber: 42, options: {dryRun: false}, diff --git a/tests/githubAutomation/permissions.test.ts b/tests/githubAutomation/permissions.test.ts index b8350f9..bc26eab 100644 --- a/tests/githubAutomation/permissions.test.ts +++ b/tests/githubAutomation/permissions.test.ts @@ -16,9 +16,9 @@ describe('automation permissions', () => { it('honours per-action env flags when automation enabled', () => { const config = loadAutomationPermissionsFromEnv({ - OPENCODE_AUTOMATION: '1', - OPENCODE_AUTOMATION_COMMENT: '1', - OPENCODE_AUTOMATION_REVIEW: '1', + APEIRONCODE_AUTOMATION: '1', + APEIRONCODE_AUTOMATION_COMMENT: '1', + APEIRONCODE_AUTOMATION_REVIEW: '1', }); expect(config.allowComment).toBe(true); expect(config.allowReview).toBe(true); @@ -28,8 +28,8 @@ describe('automation permissions', () => { it('parses allowed repos from comma list', () => { const config = loadAutomationPermissionsFromEnv({ - OPENCODE_AUTOMATION: '1', - OPENCODE_AUTOMATION_REPOS: 'org/repo-a, org/repo-b', + APEIRONCODE_AUTOMATION: '1', + APEIRONCODE_AUTOMATION_REPOS: 'org/repo-a, org/repo-b', }); expect(config.allowedRepos).toEqual(['org/repo-a', 'org/repo-b']); }); @@ -50,9 +50,9 @@ describe('automation permissions', () => { it('honours allowed repository allowlist', () => { const config = loadAutomationPermissionsFromEnv({ - OPENCODE_AUTOMATION: '1', - OPENCODE_AUTOMATION_COMMIT: '1', - OPENCODE_AUTOMATION_REPOS: 'org/repo-a', + APEIRONCODE_AUTOMATION: '1', + APEIRONCODE_AUTOMATION_COMMIT: '1', + APEIRONCODE_AUTOMATION_REPOS: 'org/repo-a', }); const allowed = checkAutomationPermission('commit', config, {dryRun: false}, 'org/repo-a'); const blocked = checkAutomationPermission('commit', config, {dryRun: false}, 'org/repo-b'); @@ -63,10 +63,10 @@ describe('automation permissions', () => { it('restricts fork PRs and protected branches to safe modes', () => { const config = loadAutomationPermissionsFromEnv({ - OPENCODE_AUTOMATION: '1', - OPENCODE_AUTOMATION_ACTORS: 'octocat', - OPENCODE_AUTOMATION_COMMIT: '1', - OPENCODE_AUTOMATION_PR_CREATE: '1', + APEIRONCODE_AUTOMATION: '1', + APEIRONCODE_AUTOMATION_ACTORS: 'octocat', + APEIRONCODE_AUTOMATION_COMMIT: '1', + APEIRONCODE_AUTOMATION_PR_CREATE: '1', }); expect(decideAutomationMode(config, { actor: 'octocat', @@ -86,9 +86,9 @@ describe('automation permissions', () => { it('denies unknown or explicitly blocked actors for write automation', () => { const config = loadAutomationPermissionsFromEnv({ - OPENCODE_AUTOMATION: '1', - OPENCODE_AUTOMATION_ACTORS: 'maintainer', - OPENCODE_AUTOMATION_DENY_ACTORS: 'blocked', + APEIRONCODE_AUTOMATION: '1', + APEIRONCODE_AUTOMATION_ACTORS: 'maintainer', + APEIRONCODE_AUTOMATION_DENY_ACTORS: 'blocked', }); expect(decideAutomationMode(config, {actor: 'stranger', desiredMode: 'pr-create'}).allowed).toBe(false); expect(decideAutomationMode(config, {actor: 'blocked', desiredMode: 'pr-create'}).allowed).toBe(false); diff --git a/tests/githubAutomation/prReview.test.ts b/tests/githubAutomation/prReview.test.ts index 2776c3b..6d04796 100644 --- a/tests/githubAutomation/prReview.test.ts +++ b/tests/githubAutomation/prReview.test.ts @@ -82,8 +82,8 @@ describe('runPrReviewAutomation', () => { const result = await runPrReviewAutomation({ client, config: loadAutomationPermissionsFromEnv({ - OPENCODE_AUTOMATION: '1', - OPENCODE_AUTOMATION_REVIEW: '1', + APEIRONCODE_AUTOMATION: '1', + APEIRONCODE_AUTOMATION_REVIEW: '1', }), options: {dryRun: false}, prNumber: 7, diff --git a/tests/helpers/workflow.ts b/tests/helpers/workflow.ts index 90fba6b..38949f0 100644 --- a/tests/helpers/workflow.ts +++ b/tests/helpers/workflow.ts @@ -17,8 +17,8 @@ export interface WorkspaceSetup { export async function createWorkspace(fixtureDir?: string): Promise { const previousHome = process.env.HOME; - const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-test-home-')); - const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-test-project-')); + const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-test-home-')); + const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-test-project-')); process.env.HOME = homeDir; diff --git a/tests/hooks/hooks.test.ts b/tests/hooks/hooks.test.ts index 3258a30..49d82c7 100644 --- a/tests/hooks/hooks.test.ts +++ b/tests/hooks/hooks.test.ts @@ -10,7 +10,7 @@ import {runHook} from '../../src/hooks/runner.js'; describe('hooks', () => { it('loads hooks and requires approval for shell hooks', async () => { - const cwd = await mkdtemp(path.join(tmpdir(), 'opencode-hooks-')); + const cwd = await mkdtemp(path.join(tmpdir(), 'apeironcode-hooks-')); await writeFile(path.join(cwd, '.apeironcode-agent', 'hooks.json'), JSON.stringify({ hooks: [{command: 'echo ok', enabled: true, event: 'before_plan', name: 'say-ok', type: 'shell'}], }), {encoding: 'utf8', flag: 'w'}).catch(async () => { diff --git a/tests/hooks/runtime.integration.test.ts b/tests/hooks/runtime.integration.test.ts index 84f29b0..beaf5eb 100644 --- a/tests/hooks/runtime.integration.test.ts +++ b/tests/hooks/runtime.integration.test.ts @@ -19,8 +19,8 @@ describe('runtime hooks', () => { beforeEach(async () => { previousHome = process.env.HOME; - process.env.HOME = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-hook-home-')); - projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-hook-project-')); + process.env.HOME = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-hook-home-')); + projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-hook-project-')); await fs.cp(fixtureRoot, projectDir, {recursive: true}); await fs.mkdir(path.join(projectDir, '.apeironcode-agent'), {recursive: true}); }); diff --git a/tests/lsp/client.test.ts b/tests/lsp/client.test.ts index 359a454..71a41c0 100644 --- a/tests/lsp/client.test.ts +++ b/tests/lsp/client.test.ts @@ -22,7 +22,7 @@ describe('LspClient', () => { }); const createTempFile = async (): Promise => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-lsp-client-')); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-lsp-client-')); tempDirs.push(tempDir); const filePath = path.join(tempDir, 'example.ts'); await fs.writeFile(filePath, 'export class MockAgent {}\nexport function runAgentLoop() {}\n'); diff --git a/tests/lsp/definitions.test.ts b/tests/lsp/definitions.test.ts index 7ed63a0..dc11e65 100644 --- a/tests/lsp/definitions.test.ts +++ b/tests/lsp/definitions.test.ts @@ -20,7 +20,7 @@ describe('LspDefinitionsProvider#getDefinition', () => { }); const createTempFile = async (): Promise<{cwd: string; filePath: string}> => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-lsp-definitions-')); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-lsp-definitions-')); tempDirs.push(tempDir); const filePath = path.join(tempDir, 'example.ts'); await fs.writeFile(filePath, 'export const answer = 42;\nconsole.log(answer);\n'); diff --git a/tests/lsp/diagnostics.test.ts b/tests/lsp/diagnostics.test.ts index 24212ba..6dbc14e 100644 --- a/tests/lsp/diagnostics.test.ts +++ b/tests/lsp/diagnostics.test.ts @@ -20,7 +20,7 @@ describe('LspDiagnosticsProvider', () => { }); const createTempFile = async (): Promise<{cwd: string; filePath: string}> => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-lsp-diagnostics-')); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-lsp-diagnostics-')); tempDirs.push(tempDir); const filePath = path.join(tempDir, 'example.ts'); await fs.writeFile(filePath, 'const value: number = "wrong";\n'); diff --git a/tests/lsp/documentStore.test.ts b/tests/lsp/documentStore.test.ts index 9e9f2f3..3971800 100644 --- a/tests/lsp/documentStore.test.ts +++ b/tests/lsp/documentStore.test.ts @@ -16,7 +16,7 @@ describe('LspDocumentStore', () => { }); it('plans didOpen once and didChange after file content changes', async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-lsp-doc-store-')); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-lsp-doc-store-')); tempDirs.push(tempDir); const filePath = path.join(tempDir, 'example.ts'); await fs.writeFile(filePath, 'export const value = 1;\n'); @@ -37,7 +37,7 @@ describe('LspDocumentStore', () => { }); it('closes tracked documents', async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-lsp-doc-store-')); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-lsp-doc-store-')); tempDirs.push(tempDir); const filePath = path.join(tempDir, 'example.ts'); await fs.writeFile(filePath, 'export const value = 1;\n'); diff --git a/tests/lsp/references.test.ts b/tests/lsp/references.test.ts index adb26c0..9577f2b 100644 --- a/tests/lsp/references.test.ts +++ b/tests/lsp/references.test.ts @@ -20,7 +20,7 @@ describe('LspDefinitionsProvider#getReferences', () => { }); const createTempFile = async (): Promise<{cwd: string; filePath: string}> => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-lsp-references-')); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-lsp-references-')); tempDirs.push(tempDir); const filePath = path.join(tempDir, 'example.ts'); await fs.writeFile(filePath, 'export const answer = 42;\nconsole.log(answer);\n'); diff --git a/tests/lsp/sessionManager.test.ts b/tests/lsp/sessionManager.test.ts index d18561f..0be4fe0 100644 --- a/tests/lsp/sessionManager.test.ts +++ b/tests/lsp/sessionManager.test.ts @@ -54,7 +54,7 @@ describe('LspSessionManager', () => { }); const createTempWorkspace = async (fileName = 'example.ts'): Promise<{filePath: string; workspaceRoot: string}> => { - const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-lsp-session-')); + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-lsp-session-')); tempDirs.push(workspaceRoot); const filePath = path.join(workspaceRoot, fileName); await fs.writeFile(filePath, 'export function runAgentLoop() {}\n'); diff --git a/tests/lsp/symbols.test.ts b/tests/lsp/symbols.test.ts index c8d7599..36202a0 100644 --- a/tests/lsp/symbols.test.ts +++ b/tests/lsp/symbols.test.ts @@ -20,7 +20,7 @@ describe('LspSymbolsProvider', () => { }); it('returns fallback symbols extracted from source files', async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-lsp-symbols-')); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-lsp-symbols-')); tempDirs.push(tempDir); const filePath = path.join(tempDir, 'example.ts'); await fs.writeFile(filePath, [ @@ -52,7 +52,7 @@ describe('LspSymbolsProvider', () => { }); it('returns live LSP symbols when the server is available', async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-lsp-symbols-')); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-lsp-symbols-')); tempDirs.push(tempDir); const filePath = path.join(tempDir, 'example.ts'); await fs.writeFile(filePath, 'export function runAgentLoop() {}\n'); @@ -79,7 +79,7 @@ describe('LspSymbolsProvider', () => { }); it('prefers the long-lived session result when available', async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-lsp-symbols-')); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-lsp-symbols-')); tempDirs.push(tempDir); const filePath = path.join(tempDir, 'example.ts'); await fs.writeFile(filePath, 'export function runAgentLoop() {}\n'); @@ -124,7 +124,7 @@ describe('LspSymbolsProvider', () => { }); it('falls back when live LSP fails', async () => { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-lsp-symbols-')); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-lsp-symbols-')); tempDirs.push(tempDir); const filePath = path.join(tempDir, 'example.ts'); await fs.writeFile(filePath, 'export function runAgentLoop() {}\n'); diff --git a/tests/mcp/hardening.test.ts b/tests/mcp/hardening.test.ts index 504d4d1..693e3f0 100644 --- a/tests/mcp/hardening.test.ts +++ b/tests/mcp/hardening.test.ts @@ -46,7 +46,7 @@ describe('MCP hardening', () => { }); it('stores auth status without exposing token values', async () => { - const dir = await mkdtemp(path.join(os.tmpdir(), 'opencode-mcp-auth-')); + const dir = await mkdtemp(path.join(os.tmpdir(), 'apeironcode-mcp-auth-')); const store = new FileMcpTokenStore(dir); await store.set('srv', {accessToken: 'secret-access-token', expiresAt: Date.now() + 60_000, refreshToken: 'secret-refresh-token', tokenType: 'Bearer'}); const token = await store.get('srv'); diff --git a/tests/mcp/oauthFlow.test.ts b/tests/mcp/oauthFlow.test.ts index e17fb5c..3220191 100644 --- a/tests/mcp/oauthFlow.test.ts +++ b/tests/mcp/oauthFlow.test.ts @@ -38,7 +38,7 @@ describe('discoverMcpOAuthMetadata', () => { } return Promise.resolve(new Response('', {status: 404})); }); - const config = await discoverMcpOAuthMetadata({clientId: 'opencode-agent', fetchImpl: fetchImpl as typeof fetch, resourceUrl: 'https://server.example'}); + const config = await discoverMcpOAuthMetadata({clientId: 'apeironcode-agent', fetchImpl: fetchImpl as typeof fetch, resourceUrl: 'https://server.example'}); expect(config.tokenEndpoint).toBe('https://auth.example/token'); expect(config.deviceAuthorizationEndpoint).toBe('https://auth.example/device'); }); @@ -53,7 +53,7 @@ describe('discoverMcpOAuthMetadata', () => { describe('runMcpDeviceLogin (mocked)', () => { it('completes device flow and stores token', async () => { const oauthConfig = { - clientId: 'opencode-agent', + clientId: 'apeironcode-agent', deviceAuthorizationEndpoint: 'https://auth.example/device', tokenEndpoint: 'https://auth.example/token', }; diff --git a/tests/memory/compaction.test.ts b/tests/memory/compaction.test.ts index 6b06d67..563b2a9 100644 --- a/tests/memory/compaction.test.ts +++ b/tests/memory/compaction.test.ts @@ -119,7 +119,7 @@ describe('memory compaction', () => { }); it('auto-compacts oversized graphs on save', async () => { - const cwd = await mkdtemp(path.join(tmpdir(), 'opencode-memory-compact-')); + const cwd = await mkdtemp(path.join(tmpdir(), 'apeironcode-memory-compact-')); directories.push(cwd); const store = new MemoryGraphStore(cwd); const now = new Date().toISOString(); diff --git a/tests/memory/graph.test.ts b/tests/memory/graph.test.ts index fc03c62..86d753a 100644 --- a/tests/memory/graph.test.ts +++ b/tests/memory/graph.test.ts @@ -12,7 +12,7 @@ import {MemoryGraphStore} from '../../src/memory/graphStore.js'; describe('memory graph', () => { it('deduplicates facts, redacts secrets, searches, and persists', async () => { - const cwd = await mkdtemp(path.join(tmpdir(), 'opencode-memory-')); + const cwd = await mkdtemp(path.join(tmpdir(), 'apeironcode-memory-')); const store = new MemoryGraphStore(cwd); let graph = await store.load(); diff --git a/tests/memory/indexStore.test.ts b/tests/memory/indexStore.test.ts index a297ab2..b9b3d49 100644 --- a/tests/memory/indexStore.test.ts +++ b/tests/memory/indexStore.test.ts @@ -18,7 +18,7 @@ describe('MemoryIndexStore', () => { }); it('saves and loads an index', async () => { - const cwd = await mkdtemp(path.join(tmpdir(), 'opencode-memory-index-')); + const cwd = await mkdtemp(path.join(tmpdir(), 'apeironcode-memory-index-')); directories.push(cwd); const store = new MemoryIndexStore(cwd); const index = new TextIndex(); @@ -31,7 +31,7 @@ describe('MemoryIndexStore', () => { }); it('rebuilds from a graph and creates a queryable index file', async () => { - const cwd = await mkdtemp(path.join(tmpdir(), 'opencode-memory-index-')); + const cwd = await mkdtemp(path.join(tmpdir(), 'apeironcode-memory-index-')); directories.push(cwd); const store = new MemoryIndexStore(cwd); const graph = upsertMemoryFact(createEmptyMemoryGraph(), { @@ -48,7 +48,7 @@ describe('MemoryIndexStore', () => { }); it('recovers from a corrupted index by rebuilding on ensureCurrent', async () => { - const cwd = await mkdtemp(path.join(tmpdir(), 'opencode-memory-index-')); + const cwd = await mkdtemp(path.join(tmpdir(), 'apeironcode-memory-index-')); directories.push(cwd); const store = new MemoryIndexStore(cwd); const graph = upsertMemoryFact(createEmptyMemoryGraph(), { @@ -67,7 +67,7 @@ describe('MemoryIndexStore', () => { }); it('handles a missing index by rebuilding it', async () => { - const cwd = await mkdtemp(path.join(tmpdir(), 'opencode-memory-index-')); + const cwd = await mkdtemp(path.join(tmpdir(), 'apeironcode-memory-index-')); directories.push(cwd); const store = new MemoryIndexStore(cwd); const graph = upsertMemoryFact(createEmptyMemoryGraph(), { diff --git a/tests/memory/retrievalEval.test.ts b/tests/memory/retrievalEval.test.ts index a1ef785..6e00917 100644 --- a/tests/memory/retrievalEval.test.ts +++ b/tests/memory/retrievalEval.test.ts @@ -16,8 +16,8 @@ describe('memory retrieval evals', () => { }); it('selects relevant architecture memory, excludes irrelevant and redacts secrets', async () => { - const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-memory-eval-project-')); - const globalMemoryDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-memory-eval-global-')); + const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-memory-eval-project-')); + const globalMemoryDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-memory-eval-global-')); directories.push(projectDir, globalMemoryDir); const manager = new MemoryManager(projectDir, globalMemoryDir); @@ -76,7 +76,7 @@ describe('memory retrieval evals', () => { }); it('handles empty memory cleanly and deterministically', async () => { - const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-memory-eval-empty-')); + const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-memory-eval-empty-')); directories.push(projectDir); const manager = new MemoryManager(projectDir); diff --git a/tests/memory/search.test.ts b/tests/memory/search.test.ts index 3f0316a..4372b3f 100644 --- a/tests/memory/search.test.ts +++ b/tests/memory/search.test.ts @@ -15,7 +15,7 @@ describe('MemoryGraphStore.search', () => { }); it('returns semantically relevant entities and respects topK', async () => { - const cwd = await mkdtemp(path.join(tmpdir(), 'opencode-memory-search-')); + const cwd = await mkdtemp(path.join(tmpdir(), 'apeironcode-memory-search-')); directories.push(cwd); const store = new MemoryGraphStore(cwd); @@ -50,7 +50,7 @@ describe('MemoryGraphStore.search', () => { }); it('supports type and confidence filtering', async () => { - const cwd = await mkdtemp(path.join(tmpdir(), 'opencode-memory-search-')); + const cwd = await mkdtemp(path.join(tmpdir(), 'apeironcode-memory-search-')); directories.push(cwd); const store = new MemoryGraphStore(cwd); @@ -82,7 +82,7 @@ describe('MemoryGraphStore.search', () => { }); it('applies recency and access bonuses when ranking similar matches', async () => { - const cwd = await mkdtemp(path.join(tmpdir(), 'opencode-memory-search-')); + const cwd = await mkdtemp(path.join(tmpdir(), 'apeironcode-memory-search-')); directories.push(cwd); const store = new MemoryGraphStore(cwd); @@ -133,7 +133,7 @@ describe('MemoryGraphStore.search', () => { }); it('returns explainable hybrid scores with file and graph-neighborhood reasons', async () => { - const cwd = await mkdtemp(path.join(tmpdir(), 'opencode-memory-search-')); + const cwd = await mkdtemp(path.join(tmpdir(), 'apeironcode-memory-search-')); directories.push(cwd); const store = new MemoryGraphStore(cwd); diff --git a/tests/memory/suggestions.test.ts b/tests/memory/suggestions.test.ts index 65d2888..9de4167 100644 --- a/tests/memory/suggestions.test.ts +++ b/tests/memory/suggestions.test.ts @@ -9,7 +9,7 @@ import {MemoryGraphStore} from '../../src/memory/graphStore.js'; describe('memory suggestions', () => { it('redacts, approves, applies, and rejects suggestions', async () => { - const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-memory-suggestions-')); + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-memory-suggestions-')); const store = new MemorySuggestionStore(cwd); const suggestion = await store.append({ proposedFacts: [{ @@ -40,7 +40,7 @@ describe('memory suggestions', () => { }); it('approves all pending suggestions', async () => { - const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-memory-approve-all-')); + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-memory-approve-all-')); const store = new MemorySuggestionStore(cwd); await store.append({proposedFacts: [{name: 'one', observation: 'first', type: 'task'}], source: 'manual', summary: 'one'}); await store.append({proposedFacts: [{name: 'two', observation: 'second', type: 'task'}], source: 'manual', summary: 'two'}); diff --git a/tests/memory/verification.test.ts b/tests/memory/verification.test.ts index cb916ae..a5045d1 100644 --- a/tests/memory/verification.test.ts +++ b/tests/memory/verification.test.ts @@ -26,7 +26,7 @@ const makeEntity = (overrides: Partial = {}): MemoryEntity => ({ }); beforeEach(async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-verification-')); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-verification-')); await fs.mkdir(path.join(tmpDir, 'src'), {recursive: true}); await fs.writeFile(path.join(tmpDir, 'src', 'app.ts'), 'export {}'); await fs.mkdir(path.join(tmpDir, 'tests'), {recursive: true}); diff --git a/tests/multisession/background.test.ts b/tests/multisession/background.test.ts index 7c94d84..a7aa3d3 100644 --- a/tests/multisession/background.test.ts +++ b/tests/multisession/background.test.ts @@ -16,7 +16,7 @@ describe('Background Runner', () => { let runner: BackgroundSessionRunner; beforeEach(async () => { - tempDir = await mkdtemp(join(tmpdir(), 'opencode-test-')); + tempDir = await mkdtemp(join(tmpdir(), 'apeironcode-test-')); await ensureDirectory(tempDir); sessionManager = new MultiAgentSessionManager(tempDir); logStore = new SessionLogStore(tempDir); diff --git a/tests/multisession/behavior.test.ts b/tests/multisession/behavior.test.ts index c672ce3..b568301 100644 --- a/tests/multisession/behavior.test.ts +++ b/tests/multisession/behavior.test.ts @@ -11,7 +11,7 @@ describe('MultiAgentSessionManager behavior', () => { let manager: MultiAgentSessionManager; beforeEach(async () => { - tempDir = await mkdtemp(join(tmpdir(), 'opencode-test-')); + tempDir = await mkdtemp(join(tmpdir(), 'apeironcode-test-')); await ensureDirectory(tempDir); manager = new MultiAgentSessionManager(tempDir); }); diff --git a/tests/plugins/loader.test.ts b/tests/plugins/loader.test.ts index f4e70d1..048ac73 100644 --- a/tests/plugins/loader.test.ts +++ b/tests/plugins/loader.test.ts @@ -11,7 +11,7 @@ describe('plugin loader', () => { let projectDir: string; beforeEach(async () => { - projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-plugin-')); + projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-plugin-')); await fs.mkdir(path.join(projectDir, '.apeironcode-agent', 'plugins'), {recursive: true}); await fs.writeFile( path.join(projectDir, '.apeironcode-agent', 'plugins', 'demo.json'), diff --git a/tests/projectBrain/audit.test.ts b/tests/projectBrain/audit.test.ts index f5ba74d..75e6822 100644 --- a/tests/projectBrain/audit.test.ts +++ b/tests/projectBrain/audit.test.ts @@ -42,13 +42,12 @@ describe('Project Brain audit', () => { await fs.rm(cwd, {recursive: true, force: true}); }); - it('includes CLI, bridge, and VS Code checks', async () => { + it('includes CLI and bridge checks', async () => { const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'apeiron-audit-checks-')); const report = await auditProjectBrainFeatures(cwd); const features = report.checks.map((c) => c.feature); expect(features.some((f) => f.includes('CLI'))).toBe(true); expect(features.some((f) => f.includes('bridge'))).toBe(true); - expect(features.some((f) => f.includes('VS Code'))).toBe(true); await fs.rm(cwd, {recursive: true, force: true}); }); }); diff --git a/tests/projectBrain/templates.test.ts b/tests/projectBrain/templates.test.ts index e266d01..bb5fb11 100644 --- a/tests/projectBrain/templates.test.ts +++ b/tests/projectBrain/templates.test.ts @@ -15,7 +15,7 @@ describe('Project Brain templates', () => { projectRootFingerprint: 'demo-123', }); expect(content).toContain('Project Brain'); - expect(content).not.toContain('OpenCode'); + expect(content).not.toContain('ApeironCode'); expect(content).not.toMatch(/sk-[A-Za-z0-9_-]+/u); }); diff --git a/tests/safety/safety.integration.test.ts b/tests/safety/safety.integration.test.ts index 52f5bc9..ad73687 100644 --- a/tests/safety/safety.integration.test.ts +++ b/tests/safety/safety.integration.test.ts @@ -12,7 +12,7 @@ describe('runtime safety integration', () => { let projectDir: string; beforeEach(async () => { - projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-safety-')); + projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-safety-')); await fs.writeFile(path.join(projectDir, '.env'), 'TOKEN=secret\n', 'utf8'); }); diff --git a/tests/sandbox/docker.test.ts b/tests/sandbox/docker.test.ts index b86662d..522053b 100644 --- a/tests/sandbox/docker.test.ts +++ b/tests/sandbox/docker.test.ts @@ -21,7 +21,7 @@ const isDockerAvailable = async (): Promise => { describe('DockerSandboxRunner', { skip: process.env['APEIRONCODE_TEST_OFFLINE'] === '1' || - process.env['OPENCODE_TEST_OFFLINE'] === '1' || + process.env['APEIRONCODE_TEST_OFFLINE'] === '1' || !(await isDockerAvailable()), }, () => { it('executes simple command in container', async () => { diff --git a/tests/sandbox/podman.test.ts b/tests/sandbox/podman.test.ts index 13b1c76..f3b6603 100644 --- a/tests/sandbox/podman.test.ts +++ b/tests/sandbox/podman.test.ts @@ -19,7 +19,7 @@ const isPodmanAvailable = async (): Promise => { describe('PodmanSandboxRunner', { skip: process.env['APEIRONCODE_TEST_OFFLINE'] === '1' || - process.env['OPENCODE_TEST_OFFLINE'] === '1' || + process.env['APEIRONCODE_TEST_OFFLINE'] === '1' || !(await isPodmanAvailable()), }, () => { it('executes simple command in container', async () => { diff --git a/tests/sandbox/sandbox.test.ts b/tests/sandbox/sandbox.test.ts index 2db8363..94b36fa 100644 --- a/tests/sandbox/sandbox.test.ts +++ b/tests/sandbox/sandbox.test.ts @@ -12,7 +12,9 @@ describe('sandbox status', () => { expect(status.mode).toBe('none'); expect(status.backends.find((backend) => backend.id === 'docker')?.available).toBe(true); - expect(formatSandboxStatus(status)).toContain('OS sandboxed execution is not enabled'); + expect(formatSandboxStatus(status)).toContain('OS-level sandboxing is not enabled in this public CLI alpha'); + expect(formatSandboxStatus(status)).toContain('not process-isolated by default'); + expect(formatSandboxStatus(status)).toContain('Approval gating is not the same as OS-level process isolation'); expect(formatSandboxStatus(status)).toContain('Provider credentials are inherited'); }); }); diff --git a/tests/sessions/store.test.ts b/tests/sessions/store.test.ts index ba97df6..d10b977 100644 --- a/tests/sessions/store.test.ts +++ b/tests/sessions/store.test.ts @@ -12,7 +12,7 @@ describe('SessionStore', () => { beforeEach(async () => { previousHome = process.env.HOME; - process.env.HOME = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-session-home-')); + process.env.HOME = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-session-home-')); }); afterEach(() => { diff --git a/tests/setup/setup.test.ts b/tests/setup/setup.test.ts index 63bf56a..1ac0de4 100644 --- a/tests/setup/setup.test.ts +++ b/tests/setup/setup.test.ts @@ -13,8 +13,8 @@ describe('first-run setup', () => { let cwd: string; beforeEach(async () => { - home = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-setup-home-')); - cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-setup-project-')); + home = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-setup-home-')); + cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-setup-project-')); process.env.HOME = home; }); diff --git a/tests/share/export.test.ts b/tests/share/export.test.ts index 93a3301..31b43f6 100644 --- a/tests/share/export.test.ts +++ b/tests/share/export.test.ts @@ -10,7 +10,7 @@ describe('SessionExporter', () => { let tempDir: string; beforeEach(async () => { - tempDir = join(tmpdir(), `opencode-test-${Date.now()}`); + tempDir = join(tmpdir(), `apeironcode-test-${Date.now()}`); await ensureDirectory(tempDir); }); diff --git a/tests/skills/runtime.test.ts b/tests/skills/runtime.test.ts index 6cfe3da..e61388c 100644 --- a/tests/skills/runtime.test.ts +++ b/tests/skills/runtime.test.ts @@ -21,8 +21,8 @@ describe('skill runtime execution', () => { beforeEach(async () => { previousHome = process.env.HOME; - process.env.HOME = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-skill-home-')); - projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-skill-project-')); + process.env.HOME = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-skill-home-')); + projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-skill-project-')); await fs.cp(fixtureRoot, projectDir, {recursive: true}); }); diff --git a/tests/skills/skills.test.ts b/tests/skills/skills.test.ts index 0b3fe68..a0c997a 100644 --- a/tests/skills/skills.test.ts +++ b/tests/skills/skills.test.ts @@ -13,7 +13,7 @@ import {validateSkillMetadata} from '../../src/skills/validator.js'; describe('skills', () => { it('creates, loads, matches, and builds a scoped run plan', async () => { - const cwd = await mkdtemp(path.join(tmpdir(), 'opencode-skills-')); + const cwd = await mkdtemp(path.join(tmpdir(), 'apeironcode-skills-')); const store = new SkillStore(cwd); const starter = createStarterSkill('fix-tests'); await store.save(starter.metadata, starter.markdown); diff --git a/tests/smoke/cli-smoke.test.ts b/tests/smoke/cli-smoke.test.ts index 96e1607..2aa7a1e 100644 --- a/tests/smoke/cli-smoke.test.ts +++ b/tests/smoke/cli-smoke.test.ts @@ -6,15 +6,15 @@ import os from 'node:os'; // Helper to run CLI in isolated environment async function runCLIInIsolatedEnv(args: string[]): Promise<{stdout: string; stderr: string; exitCode: number}> { - const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-cli-test-')); - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-cli-workspace-')); + const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-cli-test-')); + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-cli-workspace-')); const cliPath = path.join(process.cwd(), 'dist/cli/index.js'); try { const env = { ...process.env, HOME: tempHome, - OPENCODE_HOME: tempHome, + APEIRONCODE_HOME: tempHome, }; const result = spawnSync(process.execPath, [cliPath, ...args], { diff --git a/tests/tasks/task-store.test.ts b/tests/tasks/task-store.test.ts index b4ac136..af87f04 100644 --- a/tests/tasks/task-store.test.ts +++ b/tests/tasks/task-store.test.ts @@ -17,7 +17,7 @@ describe('task store', () => { let taskStore: TaskStore; beforeEach(async () => { - projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-task-')); + projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-task-')); taskStore = new TaskStore(projectDir); }); diff --git a/tests/tools/edit-history.test.ts b/tests/tools/edit-history.test.ts index 3bfb66e..398299d 100644 --- a/tests/tools/edit-history.test.ts +++ b/tests/tools/edit-history.test.ts @@ -10,7 +10,7 @@ describe('edit history queries', () => { let projectDir: string; beforeEach(async () => { - projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-edit-history-')); + projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-edit-history-')); }); afterEach(async () => { diff --git a/tests/tools/patch-engine.test.ts b/tests/tools/patch-engine.test.ts index a9107a3..cbc0caf 100644 --- a/tests/tools/patch-engine.test.ts +++ b/tests/tools/patch-engine.test.ts @@ -13,7 +13,7 @@ describe('patch engine integration', () => { let projectDir: string; beforeEach(async () => { - projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-patch-')); + projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-patch-')); await fs.mkdir(path.join(projectDir, 'src'), {recursive: true}); await fs.writeFile(path.join(projectDir, 'src', 'example.ts'), 'export const value = 1;\n', 'utf8'); }); diff --git a/tests/tools/tools.test.ts b/tests/tools/tools.test.ts index b7db552..9fb146d 100644 --- a/tests/tools/tools.test.ts +++ b/tests/tools/tools.test.ts @@ -12,7 +12,7 @@ describe('tool registry', () => { let projectDir: string; beforeEach(async () => { - projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-tools-')); + projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-tools-')); await fs.mkdir(path.join(projectDir, 'src'), {recursive: true}); await fs.mkdir(path.join(projectDir, 'docs'), {recursive: true}); await fs.mkdir(path.join(projectDir, 'node_modules'), {recursive: true}); diff --git a/tests/ui/brand.test.ts b/tests/ui/brand.test.ts index bed88de..7bb8086 100644 --- a/tests/ui/brand.test.ts +++ b/tests/ui/brand.test.ts @@ -10,6 +10,6 @@ describe('ApeironCode brand', () => { const wide = stripAnsi(renderApeironLogo({variant: 'wide', colorMode: 'no-color'})); expect(compact).toContain('APEIRONCODE'); expect(wide).toContain('local-first coding agent OS'); - expect(`${compact}\n${wide}`).not.toContain('OpenCode'); + expect(`${compact}\n${wide}`).not.toContain('ApeironCode'); }); }); diff --git a/tests/ui/cliSnapshots.test.ts b/tests/ui/cliSnapshots.test.ts index 6452c70..92cd03d 100644 --- a/tests/ui/cliSnapshots.test.ts +++ b/tests/ui/cliSnapshots.test.ts @@ -4,6 +4,8 @@ import {buildWelcomeDashboardModel, renderWelcomeDashboard} from '../../src/ui/w import {renderPermissionCard, renderDiffSummaryCard, renderErrorCard, renderBrainContextCard} from '../../src/ui/toolCards.js'; import {stripAnsi} from '../../src/ui/theme.js'; +const formerBrand = ['Open', 'Code'].join(''); + describe('CLI premium output assertions', () => { it('renders stable no-color dashboard and cards', () => { const dashboard = renderWelcomeDashboard(buildWelcomeDashboardModel({ @@ -23,7 +25,7 @@ describe('CLI premium output assertions', () => { const output = stripAnsi(`${dashboard}\n${cards}`); expect(output).toContain('ApeironCode'); expect(output).toContain('Project Brain'); - expect(output).not.toContain('OpenCode'); + expect(output).not.toContain(formerBrand); expect(output).not.toContain('topsecret'); }); }); diff --git a/tests/ui/slashCommands.test.ts b/tests/ui/slashCommands.test.ts index 920efd2..0276048 100644 --- a/tests/ui/slashCommands.test.ts +++ b/tests/ui/slashCommands.test.ts @@ -235,7 +235,7 @@ describe('executeSlashCommand', () => { }); it('runs slash skills through the scoped agent path', async () => { - const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-slash-skill-')); + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-slash-skill-')); await fs.mkdir(path.join(cwd, '.apeironcode-agent/skills/explain-repo'), {recursive: true}); await fs.writeFile(path.join(cwd, '.apeironcode-agent/skills/explain-repo/skill.json'), JSON.stringify({ allowedTools: ['package_info'], @@ -307,7 +307,7 @@ describe('executeSlashCommand', () => { }); it('routes team workspace and typed workflow slash commands', async () => { - const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-slash-phase13-')); + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-slash-phase13-')); const setDashboard = vi.fn(); const {context, messages} = createSlashContext({cwd, setDashboard}); diff --git a/tests/ui/viewModels.test.ts b/tests/ui/viewModels.test.ts index e1c800f..e03e593 100644 --- a/tests/ui/viewModels.test.ts +++ b/tests/ui/viewModels.test.ts @@ -31,7 +31,7 @@ describe('ui view models', () => { { id: 'session-1', model: 'mock-coder', - projectPath: '/tmp/opencode', + projectPath: '/tmp/apeironcode', provider: 'mock', title: 'Explain the repo', updatedAt: '2026-04-28T00:10:00.000Z', @@ -42,11 +42,11 @@ describe('ui view models', () => { {command: '/repo', description: 'See repo architecture and hotspots'}, ], title: 'Workspace Home', - workspacePath: '/Users/example/opencode', + workspacePath: '/Users/example/apeironcode', }); - expect(viewModel.projectPathLine).toBe('/Users/example/opencode'); - expect(viewModel.headerLine).toContain('opencode | mock/mock-coder | mode:explain (inferred from prompt)'); + expect(viewModel.projectPathLine).toBe('/Users/example/apeironcode'); + expect(viewModel.headerLine).toContain('apeironcode | mock/mock-coder | mode:explain (inferred from prompt)'); expect(viewModel.codeIntelligenceLine).toContain('Code Intelligence: Fallback code intelligence'); expect(viewModel.codeIntelligenceLine).toContain('sessions:0 cache:0'); expect(viewModel.shortcutLines).toContain('/feature | Ship a focused feature slice'); @@ -62,7 +62,7 @@ describe('ui view models', () => { activeTaskStatus: 'running', approvalMode: 'ask', codeIntelligenceStatus: 'fallback/0srv/0sess/0cache', - cwd: '/Users/example/opencode', + cwd: '/Users/example/apeironcode', gitBranch: 'main', model: 'mock-coder', provider: 'mock', @@ -73,7 +73,7 @@ describe('ui view models', () => { usageSummary: '120 tokens', }); - expect(viewModel.workspaceLabel).toBe('opencode'); + expect(viewModel.workspaceLabel).toBe('apeironcode'); expect(viewModel.providerLabel).toBe('Mock provider · testing only'); expect(viewModel.modeLabel).toBe('mode:review'); expect(viewModel.repoMapLabel).toBe('repo:fresh/1m'); diff --git a/tests/ui/welcomeDashboard.test.ts b/tests/ui/welcomeDashboard.test.ts index dfbbf41..0ed70b5 100644 --- a/tests/ui/welcomeDashboard.test.ts +++ b/tests/ui/welcomeDashboard.test.ts @@ -3,6 +3,9 @@ import {describe, expect, it} from 'vitest'; import {buildWelcomeDashboardModel, formatCompactHome, renderWelcomeDashboard} from '../../src/ui/welcomeDashboard.js'; import {stripAnsi} from '../../src/ui/theme.js'; +const formerBrand = ['Open', 'Code'].join(''); +const formerCommandPattern = new RegExp(`\\b${['open', 'code'].join('')}\\b`, 'iu'); + describe('compact home', () => { it('renders ≤15 lines', () => { const output = formatCompactHome({ @@ -47,11 +50,11 @@ describe('compact home', () => { expect(output).not.toContain('apeironcode setup'); }); - it('contains ApeironCode branding (not OpenCode)', () => { + it('contains ApeironCode branding without former brand', () => { const output = formatCompactHome({provider: 'anthropic', model: 'claude-sonnet'}); expect(output).toContain('ApeironCode'); - expect(output).not.toContain('OpenCode'); - expect(output).not.toContain('opencode'); + expect(output).not.toContain(formerBrand); + expect(output).not.toMatch(formerCommandPattern); }); it('does not leak secrets', () => { @@ -93,7 +96,7 @@ describe('welcome dashboard', () => { expect(output).toContain('Project Brain'); expect(output).toContain('connected'); expect(output).toContain('Try: build an app'); - expect(output).not.toContain('OpenCode'); + expect(output).not.toContain(formerBrand); }); it('redacts tokens and truncates narrow output', () => { diff --git a/tests/utils/legacyHomeMigration.test.ts b/tests/utils/legacyHomeMigration.test.ts deleted file mode 100644 index bcf9f73..0000000 --- a/tests/utils/legacyHomeMigration.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; - -import {afterEach, beforeEach, describe, expect, it} from 'vitest'; - -import {getGlobalConfigPath, migrateLegacyAppHome} from '../../src/utils/paths.js'; - -describe('legacy .opencode-agent -> .apeironcode-agent migration', () => { - const originalHome = process.env.HOME; - let home: string; - - beforeEach(() => { - home = fs.mkdtempSync(path.join(os.tmpdir(), 'apeiron-mig-')); - process.env.HOME = home; - }); - - afterEach(() => { - process.env.HOME = originalHome; - }); - - it('copies legacy config into the ApeironCode home and uses it for new paths', () => { - const legacyDir = path.join(home, '.opencode-agent'); - fs.mkdirSync(legacyDir, {recursive: true}); - fs.writeFileSync( - path.join(legacyDir, 'config.json'), - JSON.stringify({defaultProvider: 'github-models', defaultModel: 'openai/gpt-4.1'}), - ); - - expect(migrateLegacyAppHome()).toBe(true); - - const newConfig = path.join(home, '.apeironcode-agent', 'config.json'); - expect(fs.existsSync(newConfig)).toBe(true); - // Legacy preserved for rollback (non-destructive). - expect(fs.existsSync(path.join(legacyDir, 'config.json'))).toBe(true); - // Subsequent global config path is ApeironCode-first. - expect(getGlobalConfigPath()).toBe(newConfig); - const parsed = JSON.parse(fs.readFileSync(newConfig, 'utf8')) as {defaultProvider: string}; - expect(parsed.defaultProvider).toBe('github-models'); - }); - - it('is a no-op when the ApeironCode home already exists', () => { - fs.mkdirSync(path.join(home, '.apeironcode-agent'), {recursive: true}); - fs.mkdirSync(path.join(home, '.opencode-agent'), {recursive: true}); - expect(migrateLegacyAppHome()).toBe(false); - }); - - it('is a no-op when there is no legacy home', () => { - expect(migrateLegacyAppHome()).toBe(false); - }); -}); diff --git a/tests/utils/paths.test.ts b/tests/utils/paths.test.ts new file mode 100644 index 0000000..d19732b --- /dev/null +++ b/tests/utils/paths.test.ts @@ -0,0 +1,42 @@ +import os from 'node:os'; +import path from 'node:path'; + +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; + +import { + getAppHomeDir, + getGlobalConfigPath, + getIgnoreFilePath, + getProjectConfigDir, +} from '../../src/utils/paths.js'; + +describe('ApeironCode paths', () => { + const originalHome = process.env.HOME; + let home: string; + + beforeEach(() => { + home = path.join(os.tmpdir(), 'apeironcode-paths-home'); + process.env.HOME = home; + }); + + afterEach(() => { + process.env.HOME = originalHome; + }); + + it('uses the canonical ApeironCode home and config path', () => { + expect(getAppHomeDir()).toBe(path.join(home, '.apeironcode-agent')); + expect(getGlobalConfigPath()).toBe( + path.join(home, '.apeironcode-agent', 'config.json'), + ); + }); + + it('uses canonical project config and ignore paths', () => { + const project = path.join(os.tmpdir(), 'apeironcode-project'); + expect(getProjectConfigDir(project)).toBe( + path.join(project, '.apeironcode-agent'), + ); + expect(getIgnoreFilePath(project)).toBe( + path.join(project, '.apeironcodeignore'), + ); + }); +}); diff --git a/tests/utils/structuredLogger.test.ts b/tests/utils/structuredLogger.test.ts index 8fef3e4..d9feb12 100644 --- a/tests/utils/structuredLogger.test.ts +++ b/tests/utils/structuredLogger.test.ts @@ -10,8 +10,8 @@ const waitForWrite = () => new Promise((resolve) => setTimeout(resolve, 20)); describe('StructuredLogger', () => { it('writes JSONL, filters levels, redacts secrets, and rotates old logs', async () => { - const logDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-logs-')); - await fs.writeFile(path.join(logDir, 'opencode-2000-01-01.jsonl'), '{}\n'); + const logDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-logs-')); + await fs.writeFile(path.join(logDir, 'apeironcode-2000-01-01.jsonl'), '{}\n'); const logger = new StructuredLogger({level: 'info', logDir, retentionDays: 7}); logger.debug('hidden', {token: 'secret'}); @@ -22,7 +22,7 @@ describe('StructuredLogger', () => { expect(lines).toHaveLength(1); expect(lines[0]).toContain('hello api_key=[REDACTED]'); expect(lines[0]).not.toContain('secret-token'); - expect(await fs.readdir(logDir)).not.toContain('opencode-2000-01-01.jsonl'); + expect(await fs.readdir(logDir)).not.toContain('apeironcode-2000-01-01.jsonl'); }); it('handles write errors gracefully', () => { diff --git a/tests/workflows/runtime.test.ts b/tests/workflows/runtime.test.ts index fdca710..3dead73 100644 --- a/tests/workflows/runtime.test.ts +++ b/tests/workflows/runtime.test.ts @@ -28,7 +28,7 @@ describe('workflow runtime recipes', () => { }); it('creates dry-run and runtime reports with mock provider', async () => { - const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-workflow-runtime-')); + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'apeironcode-workflow-runtime-')); await fs.cp(path.resolve('tests/fixtures/node-basic'), cwd, {recursive: true}); const dry = await runWorkflowRecipe({ config: createConfig(), From 3cdceecd4328a19303e3ac31d75879442b289527 Mon Sep 17 00:00:00 2001 From: Poola Nithin Reddy Date: Sun, 7 Jun 2026 23:49:24 -0400 Subject: [PATCH 2/8] Improve first-run CLI demo and setup experience Make the first five minutes excellent and focus the product on four workflows. - Demo mode: deterministic, no-key `apeironcode demo` with todo-app, fix-test, improve-ui, and review scenarios. Writes real files and runs real local validation; never calls a provider, model, Ollama, or the network. - CLI help: beginner-focused `--help` (fix/build/improve/review/demo/doctor/ setup), with `help developer` and `help advanced` for the rest. Adds the four headline workflow commands. Every existing command stays registered. - Setup/doctor: intent-based `setup` menu (demo/local/cheap/best/BYO), new `local setup`, `local doctor`, `real setup`; doctor appends friendly next steps and points to demo when no real provider is configured. - Providers: local OpenAI-compatible endpoints (localhost/127.0.0.1/0.0.0.0/ ::1/host.docker.internal/.local) no longer require an API key; remote ones still require OPENAI_API_KEY and fail fast cleanly. - Docs: README four-workflows + no-key demo sections; new docs/demo.md and docs/getting-started.md; providers.md documents no-key paths and that mock is a test/demo stub, not a real coding model. --- README.md | 34 ++++- docs/demo.md | 37 +++++ docs/getting-started.md | 61 ++++++++ docs/providers.md | 17 +++ package.json | 2 + src/cli/commands.ts | 19 ++- src/cli/commands/types.ts | 1 + src/cli/help.ts | 205 ++++++++++++++++++++++++++ src/cli/onboarding.ts | 89 +++++++++++ src/cli/setup/coreHandlers.ts | 33 +++++ src/demo/format.ts | 77 ++++++++++ src/demo/index.ts | 72 +++++++++ src/demo/scenarios/fixTest.ts | 76 ++++++++++ src/demo/scenarios/improveUi.ts | 89 +++++++++++ src/demo/scenarios/review.ts | 81 ++++++++++ src/demo/scenarios/todoApp.ts | 108 ++++++++++++++ src/demo/types.ts | 54 +++++++ src/demo/validate.ts | 75 ++++++++++ src/providers/localEndpoint.ts | 39 +++++ src/providers/openaiCompatible.ts | 20 ++- src/providers/registry.ts | 4 + tests/cli/commands.test.ts | 1 + tests/cli/evalCommands.test.ts | 2 +- tests/cli/help.test.ts | 86 +++++++++++ tests/cli/onboarding.test.ts | 46 ++++++ tests/demo/demo.test.ts | 98 ++++++++++++ tests/demo/docs.test.ts | 33 +++++ tests/providers/localEndpoint.test.ts | 126 ++++++++++++++++ 28 files changed, 1579 insertions(+), 6 deletions(-) create mode 100644 docs/demo.md create mode 100644 docs/getting-started.md create mode 100644 src/cli/help.ts create mode 100644 src/cli/onboarding.ts create mode 100644 src/demo/format.ts create mode 100644 src/demo/index.ts create mode 100644 src/demo/scenarios/fixTest.ts create mode 100644 src/demo/scenarios/improveUi.ts create mode 100644 src/demo/scenarios/review.ts create mode 100644 src/demo/scenarios/todoApp.ts create mode 100644 src/demo/types.ts create mode 100644 src/demo/validate.ts create mode 100644 src/providers/localEndpoint.ts create mode 100644 tests/cli/help.test.ts create mode 100644 tests/cli/onboarding.test.ts create mode 100644 tests/demo/demo.test.ts create mode 100644 tests/demo/docs.test.ts create mode 100644 tests/providers/localEndpoint.test.ts diff --git a/README.md b/README.md index 301d020..aa75622 100644 --- a/README.md +++ b/README.md @@ -30,12 +30,40 @@ Verify the installation: ```bash apeironcode --help -apeironcode setup --provider mock +apeironcode demo apeironcode doctor ``` -Mock mode is deterministic and needs no API key. For real work, configure -[Ollama or a cloud provider](./docs/providers.md). +## Try the demo (no API key) + +`apeironcode demo` runs deterministic, local examples — no API key, model, or +Ollama required: + +```bash +apeironcode demo todo-app # scaffold a working static todo app +apeironcode demo fix-test # reproduce a failing test, then fix it to green +apeironcode demo improve-ui # turn a plain page into a premium one +apeironcode demo review # deterministic review of a sample diff +``` + +Demo mode uses deterministic local examples, not a model. For real projects, +configure [Ollama or a cloud provider](./docs/providers.md). The `mock` +provider is a deterministic test/demo stub, **not** a real coding model. + +## Four workflows + +ApeironCode is a local-first coding agent centered on four workflows, all with +approval-gated writes and commands: + +```bash +apeironcode fix # fix a bug or failing tests +apeironcode build "a todo app" # build an app or feature +apeironcode improve "premium UI" # improve UI or code quality +apeironcode review # review the current changes +``` + +`apeironcode --help` is beginner-focused. Run `apeironcode help developer` or +`apeironcode help advanced` for the full command set. ## See It Work diff --git a/docs/demo.md b/docs/demo.md new file mode 100644 index 0000000..38220c5 --- /dev/null +++ b/docs/demo.md @@ -0,0 +1,37 @@ +# Demo mode + +`apeironcode demo` is a deterministic, no-key way to see ApeironCode's +plan → write → validate flow in under a minute. It needs no API key, model, or +Ollama. + +Demo mode never calls a provider, model, Ollama, or the network. Each scenario +writes real files into a demo workspace (under your OS temp directory) and runs +real local validation (for example `node --check`). + +```bash +apeironcode demo # list the scenarios +apeironcode demo todo-app # scaffold a working static HTML/CSS/JS todo app +apeironcode demo fix-test # reproduce a failing test, apply a fix, re-run to green +apeironcode demo improve-ui # turn a plain page into a premium one +apeironcode demo review # deterministic review of a sample diff +``` + +## What each scenario does + +- **todo-app** — writes `index.html`, `styles.css`, and `app.js`, then validates + that the JS parses (`node --check app.js`) and that the HTML's linked assets + exist. Open `index.html` in a browser. +- **fix-test** — writes a tiny project with a deliberate bug and a small test + runner, runs it (fails), applies the deterministic fix, and re-runs (passes). +- **improve-ui** — writes a plain pricing card, then restyles it with a premium + dark theme and confirms no CSS/JS references are missing. +- **review** — loads a fixed sample diff and prints deterministic, rule-based + findings across security, bug risk, and maintainability. + +## Honest limitations + +- Demo mode is **deterministic local examples, not a model.** It does not use + AI. The output is fixed. +- The `mock` provider is a deterministic test/demo stub, **not** a real coding + model. It exists for tests and offline checks. +- For real projects, configure [Ollama or a cloud provider](./providers.md). diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..e227c4f --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,61 @@ +# Getting started + +ApeironCode is a local-first coding agent that fixes bugs, builds apps, improves +UI, and reviews code — with approval-gated writes and commands. + +## 1. Install (from source) + +```bash +git clone https://github.com/poolanithinreddy/ApeironCode.git +cd ApeironCode +npm ci +npm run build +npm link +apeironcode --help +``` + +The default `--help` is beginner-focused. Run `apeironcode help developer` or +`apeironcode help advanced` for the full command set. + +## 2. Try it with no API key + +```bash +apeironcode demo todo-app +``` + +Demo mode is deterministic and needs no API key, model, or Ollama. See +[demo.md](./demo.md). + +## 3. Choose how to run for real + +```bash +apeironcode setup +``` + +`setup` is intent-based — pick how you want to run: + +1. **Demo mode only** — `apeironcode demo` +2. **Free local model with Ollama** — `apeironcode local setup` +3. **Cheap cloud model** — `apeironcode setup --provider github-models` +4. **Best coding model** — `apeironcode setup --provider anthropic` +5. **Bring my own OpenAI-compatible endpoint** — `apeironcode setup --provider openaiCompatible` + +Local OpenAI-compatible endpoints (localhost/127.0.0.1/0.0.0.0) do **not** need +an API key. See [providers.md](./providers.md). + +Check your environment any time: + +```bash +apeironcode doctor +``` + +## 4. The four workflows + +```bash +apeironcode fix # fix a bug or failing tests +apeironcode build "a todo app" # build an app or feature +apeironcode improve "premium UI" # improve UI or code quality +apeironcode review # review the current changes +``` + +Every write and command is approval-gated. See [safety.md](./safety.md). diff --git a/docs/providers.md b/docs/providers.md index c1ff96b..4b63b8c 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -20,6 +20,23 @@ Provider families in the current implementation: API keys are resolved from environment variables. Raw secrets are not stored in the local config file. ApeironCode does not reuse ChatGPT Plus, GitHub Copilot, browser cookies, or other subscription logins as provider credentials. +## No-key paths + +- **Demo mode** (`apeironcode demo`) is deterministic and needs no key, model, + or Ollama. See [demo.md](./demo.md). The `mock` provider is a deterministic + test/demo stub, **not** a real coding model. +- **Local OpenAI-compatible endpoints do not require an API key.** When the + base URL host is `localhost`, `127.0.0.1`, `0.0.0.0`, `::1`, + `host.docker.internal`, or a `*.local` name, ApeironCode treats the endpoint + as a self-hosted model server (Ollama's OpenAI shim, LM Studio, vLLM, …) and + sends no `Authorization` header when no key is set. +- **Remote OpenAI-compatible endpoints require `OPENAI_API_KEY`** and fail fast + with a clear message if it is missing, so an unauthenticated request is never + sent. + +Real coding needs Ollama or a cloud provider. Run `apeironcode setup` for the +intent-based picker, or `apeironcode local setup` for a free local model. + Provider routing adds a second layer above raw provider implementations. - `src/providers/modelCatalog.ts` stores known model roles, capabilities, context windows, and pricing metadata. diff --git a/package.json b/package.json index 099e10e..52d3ee4 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,11 @@ "docs/commands.md", "docs/connectors.md", "docs/context-engine.md", + "docs/demo.md", "docs/demo-recording.md", "docs/assets/apeironcode-demo.gif", "docs/assets/apeironcode-demo.txt", + "docs/getting-started.md", "docs/github.md", "docs/history.md", "docs/hooks.md", diff --git a/src/cli/commands.ts b/src/cli/commands.ts index 3b0610c..f94796d 100644 --- a/src/cli/commands.ts +++ b/src/cli/commands.ts @@ -21,6 +21,7 @@ import {registerAdvancedCommands} from './commands/advanced.js'; import {registerContextAndRepoCommands} from './commands/contextRepoCommands.js'; import {registerProviderWebCommands} from './commands/providerWeb.js'; import {registerRuntimeCommands} from './commands/runtimeCommands.js'; +import {registerWorkflowAndHelp} from './help.js'; export const collectOptions = (options: T, command?: Command): T => { return { @@ -36,7 +37,19 @@ export const buildProgram = (handlers: CliHandlers): Command => { program .name('apeironcode') .description( - 'ApeironCode is an open-source, local-first AI coding assistant for local and cloud models.', + [ + 'ApeironCode is a local-first coding agent that fixes bugs, builds apps,', + 'improves UI, and reviews code with approval-gated changes.', + '', + 'Start here:', + ' apeironcode demo try it with no API key', + ' apeironcode fix fix a bug or failing tests', + ' apeironcode build "a todo app" build an app or feature', + ' apeironcode improve "premium UI" improve UI or code quality', + ' apeironcode review review the current changes', + '', + 'More: apeironcode help developer · apeironcode help advanced', + ].join('\n'), ) .argument('[prompt]', 'run in one-shot mode with the provided prompt') .option('--provider ', 'override the configured provider for this run') @@ -578,5 +591,9 @@ export const buildProgram = (handlers: CliHandlers): Command => { registerAdvancedCommands(program, handlers); registerRuntimeCommands(program, handlers); + // Headline workflows, demo/local/real onboarding, grouped help, and the + // beginner-focused `--help` filter. Registered last for visibility filtering. + registerWorkflowAndHelp(program, handlers); + return program; }; diff --git a/src/cli/commands/types.ts b/src/cli/commands/types.ts index 326753e..41f96bd 100644 --- a/src/cli/commands/types.ts +++ b/src/cli/commands/types.ts @@ -47,6 +47,7 @@ export interface CliHandlers { continueTask: (taskId?: string) => Promise; cost: (options: CostCliOptions) => Promise; deleteSession: (sessionId: string) => Promise; + demo: (scenario?: string) => Promise; doctor: (options: DoctorCliOptions) => Promise; debugConfig: () => Promise; debugLogs: (options?: {last?: number}) => Promise; diff --git a/src/cli/help.ts b/src/cli/help.ts new file mode 100644 index 0000000..3b73545 --- /dev/null +++ b/src/cli/help.ts @@ -0,0 +1,205 @@ +import type {Command} from 'commander'; + +import type {CliHandlers} from './commands/types.js'; +import type {DoctorCliOptions, RootCliOptions} from './args.js'; +import {collectOptions} from './commands.js'; + +/** + * Commands shown in the default `apeironcode --help`. Everything else still + * works but is grouped under `apeironcode help developer` / `help advanced` so + * the first-run surface stays focused on the four killer workflows plus the + * three onboarding commands. + */ +export const BEGINNER_COMMANDS = [ + 'fix', + 'build', + 'improve', + 'review', + 'demo', + 'doctor', + 'setup', + 'help', +] as const; + +const BEGINNER_SET = new Set(BEGINNER_COMMANDS); + +interface HelpGroup { + title: string; + intro: string; + rows: Array<{command: string; description: string}>; + footer?: string; +} + +const DEVELOPER_GROUP: HelpGroup = { + title: 'ApeironCode developer commands', + intro: 'Everyday commands for configuring providers and driving the agent.', + rows: [ + {command: 'provider', description: 'list, test, and configure model providers'}, + {command: 'model', description: 'list and recommend models for the active provider'}, + {command: 'context', description: 'inspect repo context, budget, and selected files'}, + {command: 'workflow', description: 'list and run typed multi-step workflows'}, + {command: 'github', description: 'issues, PRs, and CI inspection (dry-run by default)'}, + {command: 'memory', description: 'review and manage durable project memory'}, + {command: 'session(s)', description: 'manage saved local sessions and history'}, + {command: 'config', description: 'inspect or update local configuration'}, + {command: 'revert', description: 'undo the last edit or a specific edit id'}, + ], + footer: 'Run `apeironcode --help` for details.', +}; + +const ADVANCED_GROUP: HelpGroup = { + title: 'ApeironCode advanced / experimental commands', + intro: 'Power features. Some are experimental and may change.', + rows: [ + {command: 'mcp', description: 'manage Model Context Protocol servers'}, + {command: 'skills', description: 'discover, create, and run scoped skills'}, + {command: 'team', description: 'run isolated multi-agent team workspaces'}, + {command: 'hooks', description: 'manage lifecycle hooks'}, + {command: 'bridge', description: 'editor/IDE bridge integration'}, + {command: 'runtime', description: 'inspect runtime routing and intent'}, + {command: 'sandbox', description: 'inspect optional sandbox backends'}, + {command: 'plugins', description: 'list loaded plugins and their tools'}, + {command: 'brain', description: 'preview and manage optional Project Brain files'}, + {command: 'permissions', description: 'manage permission rules'}, + {command: 'debug', description: 'safe local debugging snapshots'}, + ], + footer: 'Run `apeironcode --help` for details.', +}; + +export const formatHelpGroup = (group: HelpGroup): string => { + const width = Math.max(...group.rows.map((row) => row.command.length)); + const lines: string[] = [group.title, '', group.intro, '']; + for (const row of group.rows) { + lines.push(` ${row.command.padEnd(width)} ${row.description}`); + } + if (group.footer) { + lines.push(''); + lines.push(group.footer); + } + return lines.join('\n'); +}; + +export const formatDeveloperHelp = (): string => formatHelpGroup(DEVELOPER_GROUP); +export const formatAdvancedHelp = (): string => formatHelpGroup(ADVANCED_GROUP); + +const buildWorkflowPrompt = (kind: 'fix' | 'build' | 'improve' | 'review', text?: string): string => { + const trimmed = text?.trim(); + switch (kind) { + case 'fix': + return trimmed && trimmed.length > 0 + ? `Fix this: ${trimmed}` + : 'Find and fix the failing tests or the most obvious bug in this project.'; + case 'build': + return trimmed && trimmed.length > 0 + ? `Build this: ${trimmed}` + : 'Describe what to build, e.g. apeironcode build "a todo app".'; + case 'improve': + return trimmed && trimmed.length > 0 + ? `Improve this: ${trimmed}` + : 'Improve the UI/code quality of this project where it is weakest.'; + case 'review': + return trimmed && trimmed.length > 0 + ? `Review: ${trimmed}` + : 'Review the current changes (git diff) for bugs, maintainability, and security.'; + } +}; + +/** + * Register the four headline workflow commands plus `help developer` / + * `help advanced`, and restrict the default `--help` listing to beginner + * commands. Every other command stays registered and functional. + */ +export const registerWorkflowAndHelp = (program: Command, handlers: CliHandlers): void => { + const workflow = ( + kind: 'fix' | 'build' | 'improve' | 'review', + mode: string, + summary: string, + ): void => { + program + .command(kind) + .description(summary) + .argument('[description...]', 'what to ' + kind) + .action(async (parts: string[] | undefined, _options: unknown, command: Command) => { + const text = (parts ?? []).join(' '); + const prompt = buildWorkflowPrompt(kind, text); + await handlers.runRoot(prompt, collectOptions({mode} as Partial, command)); + }); + }; + + workflow('fix', 'fix', 'fix a bug or failing tests (approval-gated)'); + workflow('build', 'feature', 'build an app or feature, e.g. build "a todo app"'); + workflow('improve', 'refactor', 'improve UI or code quality, e.g. improve "make the UI premium"'); + workflow('review', 'review', 'review the current changes for bugs, maintainability, and security'); + + program + .command('demo') + .description('run a deterministic no-key demo (no API key, model, or Ollama)') + .argument('[scenario]', 'todo-app | fix-test | improve-ui | review') + .action(async (scenario: string | undefined) => { + await handlers.demo(scenario); + }); + + const local = program.command('local').description('free local model (Ollama) setup and diagnostics'); + local + .command('setup') + .description('configure a free local model with Ollama') + .action(async () => { + await handlers.setup({local: true}); + }); + local + .command('doctor') + .description('diagnose local model / Ollama setup') + .action(async (options: DoctorCliOptions, command: Command) => { + await handlers.doctor(collectOptions(options, command)); + }); + + program + .command('real') + .description('cloud provider setup') + .command('setup') + .description('choose and configure a real cloud provider') + .action(async () => { + await handlers.setup({}); + }); + + // A single `help [topic]` command so commander's `help ` still works + // while adding the curated `developer` / `advanced` groups. + program + .command('help') + .description('show help; topics: developer, advanced, or a command name') + .argument('[topic]', 'developer | advanced | ') + .action((topic: string | undefined) => { + if (!topic) { + program.outputHelp(); + return; + } + if (topic === 'developer') { + process.stdout.write(`${formatDeveloperHelp()}\n`); + return; + } + if (topic === 'advanced') { + process.stdout.write(`${formatAdvancedHelp()}\n`); + return; + } + const sub = program.commands.find((c) => c.name() === topic); + if (sub) { + sub.outputHelp(); + return; + } + process.stdout.write( + `Unknown help topic "${topic}". Try: developer, advanced, or a command name.\n`, + ); + }); + + // Restrict only the root program's help to beginner commands. Subcommand help + // is unchanged. Hidden commands stay hidden everywhere (default behavior). + program.configureHelp({ + visibleCommands: (cmd: Command) => { + const visible = cmd.commands.filter((c) => !(c as {_hidden?: boolean})._hidden); + if (cmd === program) { + return visible.filter((c) => BEGINNER_SET.has(c.name())); + } + return visible; + }, + }); +}; diff --git a/src/cli/onboarding.ts b/src/cli/onboarding.ts new file mode 100644 index 0000000..910282d --- /dev/null +++ b/src/cli/onboarding.ts @@ -0,0 +1,89 @@ +/** + * Intent-based onboarding copy for `setup` and `doctor`. + * + * New users should pick *how they want to run* ApeironCode, not a provider's + * internal baseUrl/auth details. Each intent maps to a single concrete command. + */ + +export interface SetupIntent { + id: number; + label: string; + detail: string; + command: string; +} + +export const SETUP_INTENTS: SetupIntent[] = [ + { + id: 1, + label: 'Demo mode only', + detail: 'No API key, model, or Ollama. Deterministic local examples.', + command: 'apeironcode demo', + }, + { + id: 2, + label: 'Free local model with Ollama', + detail: 'Run a model on your machine. Private and free.', + command: 'apeironcode local setup', + }, + { + id: 3, + label: 'Cheap cloud model', + detail: 'Use GitHub Models (free tier) with a GitHub token.', + command: 'apeironcode setup --provider github-models', + }, + { + id: 4, + label: 'Best coding model', + detail: 'Use a top cloud model (e.g. Anthropic Claude) with an API key.', + command: 'apeironcode setup --provider anthropic', + }, + { + id: 5, + label: 'Bring my own OpenAI-compatible endpoint', + detail: 'Point at any OpenAI-compatible server. Local endpoints need no key.', + command: 'apeironcode setup --provider openaiCompatible', + }, +]; + +export const formatSetupIntentMenu = (): string => { + const lines: string[] = []; + lines.push('Choose how ApeironCode should run:'); + lines.push(''); + for (const intent of SETUP_INTENTS) { + lines.push(` ${intent.id}. ${intent.label}`); + lines.push(` ${intent.detail}`); + lines.push(` → ${intent.command}`); + } + lines.push(''); + lines.push('Not sure? Start with `apeironcode demo` — it needs nothing.'); + lines.push('Local OpenAI-compatible endpoints (localhost/127.0.0.1) do not need an API key.'); + return lines.join('\n'); +}; + +export interface OnboardingState { + /** A real (non-mock) provider that can actually run is configured. */ + hasRealProvider: boolean; + ollamaRunning: boolean; +} + +/** + * Friendly next-steps appended to `doctor` output. Always points to a usable + * path: demo (always works), local (Ollama), or real (cloud). + */ +export const formatDoctorNextSteps = (state: OnboardingState): string => { + const lines: string[] = []; + lines.push('Next steps'); + if (state.hasRealProvider) { + lines.push(' ✓ A real model is configured. Try: apeironcode fix'); + } else { + lines.push(' ✗ No real model configured.'); + lines.push(' → Demo (no key): apeironcode demo todo-app'); + lines.push( + state.ollamaRunning + ? ' → Local (Ollama): apeironcode local setup' + : ' → Local (Ollama): apeironcode local setup (Ollama not detected)', + ); + lines.push(' → Cloud provider: apeironcode setup'); + } + return lines.join('\n'); +}; diff --git a/src/cli/setup/coreHandlers.ts b/src/cli/setup/coreHandlers.ts index 9ef8719..40cae3d 100644 --- a/src/cli/setup/coreHandlers.ts +++ b/src/cli/setup/coreHandlers.ts @@ -142,6 +142,11 @@ async doctor(options: DoctorCliOptions) { testProviderConnectivity: Boolean(options.providerCheck), }); process.stdout.write(`${formatDoctorReport(report)}\n`); + const {formatDoctorNextSteps} = await import('../onboarding.js'); + const {validateProviderEnv} = await import('../../providers/envValidation.js'); + const provider = config.effective.defaultProvider; + const hasRealProvider = provider !== 'mock' && validateProviderEnv(provider).ok; + process.stdout.write(`\n${formatDoctorNextSteps({hasRealProvider, ollamaRunning: false})}\n`); if (options.providerCheck && options.strict) { const providerCheck = report.checks.find((check) => check.label === 'Provider connectivity'); if (providerCheck?.status !== 'pass') { @@ -169,6 +174,15 @@ async debugConfig() { }, async setup(options?: {local?: boolean; provider?: string}) { const {formatProviderList} = await import('./providerWizard.js'); + // With no explicit intent (provider/local), show the intent-based menu + // instead of silently configuring a profile. Picking an intent is a + // single follow-up command. + if (!options?.provider && !options?.local) { + const {formatSetupIntentMenu} = await import('../onboarding.js'); + process.stdout.write(`${formatSetupIntentMenu()}\n\n`); + process.stdout.write(`${formatProviderList(process.env)}\n`); + return; + } process.stdout.write(`${formatProviderList(process.env)}\n\n`); process.stdout.write(`${formatSetupResult(await applySetupProfile(configStore, options))}\n`); }, @@ -185,6 +199,25 @@ async setupReset(options?: {dryRun?: boolean}) { : result.deleted ? 'User setup config removed.' : 'No user setup config found.', ].join('\n') + '\n'); }, +async demo(scenario?: string) { + const {DEMO_SCENARIOS, formatDemoMenu, formatDemoResult, isDemoScenarioId, runDemoScenario} = await import( + '../../demo/index.js' + ); + if (!scenario) { + process.stdout.write(`${formatDemoMenu(DEMO_SCENARIOS)}\n`); + return; + } + if (!isDemoScenarioId(scenario)) { + process.stdout.write( + `Unknown demo "${scenario}". Available: ${DEMO_SCENARIOS.map((s) => s.id).join(', ')}.\n` + + `Run \`apeironcode demo\` to see all scenarios.\n`, + ); + process.exitCode = 1; + return; + } + const result = await runDemoScenario(scenario); + process.stdout.write(`${formatDemoResult(result)}\n`); + }, async listTools() { const config = await configStore.load(); const toolRegistry = createDefaultToolRegistry(); diff --git a/src/demo/format.ts b/src/demo/format.ts new file mode 100644 index 0000000..2ecf37b --- /dev/null +++ b/src/demo/format.ts @@ -0,0 +1,77 @@ +import {DEMO_DISCLAIMER, type DemoScenarioMeta, type DemoScenarioResult} from './types.js'; + +const check = (ok: boolean): string => (ok ? '✓' : '✗'); + +/** + * Render the `apeironcode demo` menu (no scenario argument): explains demo mode + * and lists the runnable scenarios. + */ +export const formatDemoMenu = (scenarios: DemoScenarioMeta[]): string => { + const lines: string[] = []; + lines.push('ApeironCode Demo'); + lines.push(''); + lines.push('Deterministic, no-key examples. No API key, model, or Ollama required.'); + lines.push(''); + lines.push('Scenarios'); + for (const scenario of scenarios) { + lines.push(` ${scenario.title}`); + lines.push(` ${scenario.description}`); + lines.push(` → ${scenario.command}`); + } + lines.push(''); + lines.push(DEMO_DISCLAIMER); + return lines.join('\n'); +}; + +/** + * Render a completed demo scenario result: plan, files, validation, findings, + * and an honest disclaimer. + */ +export const formatDemoResult = (result: DemoScenarioResult): string => { + const lines: string[] = []; + lines.push(`ApeironCode Demo · ${result.title}`); + lines.push(''); + lines.push(`Workspace: ${result.workspaceDir}`); + lines.push(''); + + lines.push('Plan'); + for (const step of result.plan) lines.push(` • ${step}`); + lines.push(''); + + if (result.filesCreated.length > 0) { + lines.push(`Files created: ${result.filesCreated.join(', ')}`); + } + if (result.filesChanged.length > 0) { + lines.push(`Files changed: ${result.filesChanged.join(', ')}`); + } + if (result.beforeAfter) { + lines.push(''); + lines.push('Before (excerpt)'); + for (const line of result.beforeAfter.before) lines.push(` - ${line}`); + lines.push('After (excerpt)'); + for (const line of result.beforeAfter.after) lines.push(` + ${line}`); + } + lines.push(''); + + lines.push('Validation'); + for (const step of result.validation) { + lines.push(` ${check(step.ok)} ${step.name} — ${step.detail}`); + } + + if (result.findings && result.findings.length > 0) { + lines.push(''); + lines.push('Review findings'); + for (const finding of result.findings) { + lines.push(` [${finding.category}] ${finding.message}`); + } + } + + lines.push(''); + lines.push(result.summary); + if (result.openHint) { + lines.push(`Next: ${result.openHint} (in ${result.workspaceDir})`); + } + lines.push(''); + lines.push(DEMO_DISCLAIMER); + return lines.join('\n'); +}; diff --git a/src/demo/index.ts b/src/demo/index.ts new file mode 100644 index 0000000..5dd7ced --- /dev/null +++ b/src/demo/index.ts @@ -0,0 +1,72 @@ +import {promises as fs} from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import {runTodoAppDemo} from './scenarios/todoApp.js'; +import {runFixTestDemo} from './scenarios/fixTest.js'; +import {runImproveUiDemo} from './scenarios/improveUi.js'; +import {runReviewDemo} from './scenarios/review.js'; +import type {DemoScenarioId, DemoScenarioMeta, DemoScenarioResult} from './types.js'; + +export type {DemoScenarioId, DemoScenarioMeta, DemoScenarioResult} from './types.js'; +export {DEMO_DISCLAIMER} from './types.js'; +export {formatDemoResult, formatDemoMenu} from './format.js'; + +export const DEMO_SCENARIOS: DemoScenarioMeta[] = [ + { + id: 'todo-app', + title: 'Build a todo app', + description: 'Scaffold a working static HTML/CSS/JS todo app and validate it.', + command: 'apeironcode demo todo-app', + }, + { + id: 'fix-test', + title: 'Fix a failing test', + description: 'Reproduce a failing test, apply a deterministic fix, and re-run to green.', + command: 'apeironcode demo fix-test', + }, + { + id: 'improve-ui', + title: 'Improve the UI', + description: 'Turn a plain page into a premium one and verify assets still resolve.', + command: 'apeironcode demo improve-ui', + }, + { + id: 'review', + title: 'Review a change', + description: 'Run a deterministic review over a sample diff and list findings.', + command: 'apeironcode demo review', + }, +]; + +export const isDemoScenarioId = (value: string): value is DemoScenarioId => + DEMO_SCENARIOS.some((scenario) => scenario.id === value); + +/** + * Resolve a default demo workspace path under the OS temp dir. Callers (and + * tests) may pass an explicit directory instead. + */ +export const defaultDemoWorkspace = (scenario: DemoScenarioId): string => + path.join(os.tmpdir(), 'apeironcode-demo', scenario); + +const RUNNERS: Record Promise> = { + 'todo-app': runTodoAppDemo, + 'fix-test': runFixTestDemo, + 'improve-ui': runImproveUiDemo, + review: runReviewDemo, +}; + +/** + * Run a deterministic, no-key demo scenario. Writes real files into a clean + * workspace (cleared first so repeated runs are reproducible) and runs real + * local validation. Never calls a provider, model, Ollama, or the network. + */ +export const runDemoScenario = async ( + scenario: DemoScenarioId, + options: {workspaceDir?: string} = {}, +): Promise => { + const workspaceDir = options.workspaceDir ?? defaultDemoWorkspace(scenario); + await fs.rm(workspaceDir, {recursive: true, force: true}); + await fs.mkdir(workspaceDir, {recursive: true}); + return RUNNERS[scenario](workspaceDir); +}; diff --git a/src/demo/scenarios/fixTest.ts b/src/demo/scenarios/fixTest.ts new file mode 100644 index 0000000..45b88f0 --- /dev/null +++ b/src/demo/scenarios/fixTest.ts @@ -0,0 +1,76 @@ +import {promises as fs} from 'node:fs'; +import path from 'node:path'; + +import type {DemoScenarioResult} from '../types.js'; +import {runNodeScript} from '../validate.js'; + +const BROKEN_SUM = `'use strict'; +// Intentionally broken: subtracts instead of adds. +function sum(a, b) { + return a - b; +} +module.exports = {sum}; +`; + +const FIXED_SUM = `'use strict'; +function sum(a, b) { + return a + b; +} +module.exports = {sum}; +`; + +const CHECK_JS = `'use strict'; +const {sum} = require('./sum.js'); +const cases = [[2, 3, 5], [10, 5, 15], [0, 0, 0]]; +let failures = 0; +for (const [a, b, expected] of cases) { + const actual = sum(a, b); + if (actual !== expected) { + console.error('FAIL sum(' + a + ',' + b + ') = ' + actual + ', expected ' + expected); + failures++; + } +} +if (failures > 0) { + console.error(failures + ' test(s) failed'); + process.exit(1); +} +console.log('All ' + cases.length + ' tests passed'); +`; + +/** + * Deterministically create a tiny project with a failing test, run it (red), + * apply the known fix, and re-run (green). No provider/model involved. + */ +export const runFixTestDemo = async (workspaceDir: string): Promise => { + await fs.mkdir(workspaceDir, {recursive: true}); + await fs.writeFile(path.join(workspaceDir, 'sum.js'), BROKEN_SUM, 'utf8'); + await fs.writeFile(path.join(workspaceDir, 'check.js'), CHECK_JS, 'utf8'); + + const before = await runNodeScript(workspaceDir, 'check.js'); + + // Apply the deterministic fix. + await fs.writeFile(path.join(workspaceDir, 'sum.js'), FIXED_SUM, 'utf8'); + + const after = await runNodeScript(workspaceDir, 'check.js'); + + return { + scenario: 'fix-test', + title: 'Fix a failing test', + workspaceDir, + plan: [ + 'Create sum.js (with a deliberate bug) and check.js (a tiny test runner)', + 'Run check.js → expect failure', + 'Fix sum.js to add instead of subtract', + 'Re-run check.js → expect pass', + ], + filesCreated: ['sum.js', 'check.js'], + filesChanged: ['sum.js'], + validation: [ + {name: 'before fix: node check.js', ok: false, detail: `${before.detail}${before.ok ? '' : ' (expected failure)'}`}, + {name: 'after fix: node check.js', ok: after.ok, detail: after.detail}, + ], + summary: after.ok + ? 'Reproduced a failing test, applied a deterministic fix, and the test now passes.' + : 'Demo ran but the post-fix check did not pass in this environment.', + }; +}; diff --git a/src/demo/scenarios/improveUi.ts b/src/demo/scenarios/improveUi.ts new file mode 100644 index 0000000..1bae007 --- /dev/null +++ b/src/demo/scenarios/improveUi.ts @@ -0,0 +1,89 @@ +import {promises as fs} from 'node:fs'; +import path from 'node:path'; + +import type {DemoScenarioResult} from '../types.js'; +import {checkLinkedAssets} from '../validate.js'; + +const INDEX_HTML = ` + + + + + Pricing + + + +
+

Pro plan

+

$12/mo

+
    +
  • Unlimited projects
  • +
  • Priority support
  • +
  • Local-first privacy
  • +
+ +
+ + +`; + +const UGLY_CSS = `body { font-family: Times New Roman; background: #fff; color: #000; } +.card { width: 300px; border: 1px solid #000; padding: 10px; } +.price { font-size: 20px; } +button { background: gray; color: white; padding: 5px; } +`; + +const PREMIUM_CSS = `:root { + --bg: #0b0d12; --surface: #151922; --fg: #eef1f6; --muted: #9aa4b2; --accent: #14b8a6; +} +* { box-sizing: border-box; } +body { margin: 0; min-height: 100vh; display: grid; place-items: center; + font-family: ui-sans-serif, system-ui, sans-serif; background: radial-gradient(1200px 600px at 50% -10%, #1b2230, var(--bg)); + color: var(--fg); padding: 2rem; } +.card { width: min(22rem, 100%); background: linear-gradient(180deg, var(--surface), #11151d); + border: 1px solid #232a36; border-radius: 1rem; padding: 2rem; box-shadow: 0 20px 60px rgba(0,0,0,.45); } +h1 { margin: 0 0 .25rem; font-size: 1.25rem; letter-spacing: -0.01em; } +.price { font-size: 2.75rem; font-weight: 700; margin: .5rem 0 1rem; } +.price span { font-size: 1rem; color: var(--muted); font-weight: 500; } +ul { list-style: none; padding: 0; margin: 0 0 1.5rem; display: grid; gap: .6rem; color: var(--muted); } +li::before { content: "✓ "; color: var(--accent); font-weight: 700; } +button { width: 100%; padding: .8rem 1rem; border: 0; border-radius: .7rem; cursor: pointer; + background: linear-gradient(180deg, var(--accent), #0e8f80); color: #02110e; font-weight: 700; + transition: transform .08s ease; } +button:hover { transform: translateY(-1px); } +`; + +/** + * Deterministically create a plain "ugly" pricing card, then apply a premium + * restyle and confirm the page's linked assets still resolve. + */ +export const runImproveUiDemo = async (workspaceDir: string): Promise => { + await fs.mkdir(workspaceDir, {recursive: true}); + await fs.writeFile(path.join(workspaceDir, 'index.html'), INDEX_HTML, 'utf8'); + await fs.writeFile(path.join(workspaceDir, 'styles.css'), UGLY_CSS, 'utf8'); + + const before = UGLY_CSS.split('\n').filter(Boolean).slice(0, 4); + + await fs.writeFile(path.join(workspaceDir, 'styles.css'), PREMIUM_CSS, 'utf8'); + const after = PREMIUM_CSS.split('\n').filter(Boolean).slice(0, 4); + + const exists = async (rel: string): Promise => + fs.access(path.join(workspaceDir, rel)).then(() => true).catch(() => false); + + return { + scenario: 'improve-ui', + title: 'Improve the UI (make it premium)', + workspaceDir, + plan: [ + 'Create index.html and a plain styles.css', + 'Rewrite styles.css with a premium dark theme (gradients, spacing, depth)', + 'Verify no CSS/JS references are missing after the change', + ], + filesCreated: ['index.html', 'styles.css'], + filesChanged: ['styles.css'], + validation: [await checkLinkedAssets(exists, INDEX_HTML)], + beforeAfter: {before, after}, + openHint: 'open index.html', + summary: 'Restyled the pricing card from plain to premium; all linked assets resolve. Open index.html.', + }; +}; diff --git a/src/demo/scenarios/review.ts b/src/demo/scenarios/review.ts new file mode 100644 index 0000000..9577469 --- /dev/null +++ b/src/demo/scenarios/review.ts @@ -0,0 +1,81 @@ +import {promises as fs} from 'node:fs'; +import path from 'node:path'; + +import type {DemoFinding, DemoScenarioResult} from '../types.js'; + +const SAMPLE_DIFF = `--- a/src/login.js ++++ b/src/login.js +@@ +-function authenticate(user, password) { +- const query = "SELECT * FROM users WHERE name = '" + user + "'"; +- const row = db.querySync(query); +- if (row.password == password) { +- return true; +- } +-} ++function authenticate(user, password) { ++ const query = "SELECT * FROM users WHERE name = '" + user + "'"; ++ const row = db.querySync(query); ++ if (row.password == password) { ++ return true; ++ } ++} +`; + +// Deterministic findings derived from the sample diff above. These are static +// rule-based observations, not model output. +const FINDINGS: DemoFinding[] = [ + { + category: 'security', + message: + 'SQL injection: user input is concatenated into the query string. Use a parameterized query (db.query("… WHERE name = ?", [user])).', + }, + { + category: 'security', + message: + 'Password compared in plaintext with `==`. Store and compare a salted hash (e.g. bcrypt.compare), and use strict equality.', + }, + { + category: 'bug risk', + message: + 'authenticate() returns undefined on the failure path; callers treating the result as a boolean get a falsy value by accident, not by design. Return false explicitly.', + }, + { + category: 'maintainability', + message: + 'Synchronous db.querySync blocks the event loop. Prefer an async query and propagate errors.', + }, + { + category: 'suggestion', + message: + 'Add a unit test covering a wrong password and a missing user before changing auth behavior.', + }, +]; + +/** + * Deterministic code review over a fixed sample diff. No provider/model: the + * findings are static, rule-based observations written into the workspace so a + * user can see the review output format. + */ +export const runReviewDemo = async (workspaceDir: string): Promise => { + await fs.mkdir(workspaceDir, {recursive: true}); + await fs.writeFile(path.join(workspaceDir, 'sample.diff'), SAMPLE_DIFF, 'utf8'); + + return { + scenario: 'review', + title: 'Review a change', + workspaceDir, + plan: [ + 'Load a fixed sample diff (sample.diff)', + 'Apply deterministic review rules: security, bug risk, maintainability', + 'Report findings and suggested changes', + ], + filesCreated: ['sample.diff'], + filesChanged: [], + validation: [ + {name: 'diff parsed', ok: true, detail: '1 file, rule-based review'}, + ], + findings: FINDINGS, + summary: `Reviewed sample.diff and produced ${FINDINGS.length} deterministic findings (2 security, 1 bug risk, 1 maintainability, 1 suggestion).`, + }; +}; diff --git a/src/demo/scenarios/todoApp.ts b/src/demo/scenarios/todoApp.ts new file mode 100644 index 0000000..d2ff6f0 --- /dev/null +++ b/src/demo/scenarios/todoApp.ts @@ -0,0 +1,108 @@ +import {promises as fs} from 'node:fs'; +import path from 'node:path'; + +import type {DemoScenarioResult} from '../types.js'; +import {checkLinkedAssets, nodeCheck} from '../validate.js'; + +const INDEX_HTML = ` + + + + + ApeironCode Demo · Todo + + + +
+

Todo

+
+ + +
+
    +
    + + + +`; + +const STYLES_CSS = `:root { color-scheme: light dark; --bg: #0f1115; --fg: #e6e9ef; --accent: #14b8a6; } +* { box-sizing: border-box; } +body { margin: 0; font-family: system-ui, sans-serif; background: var(--bg); color: var(--fg); + display: grid; place-items: start center; min-height: 100vh; padding: 2rem; } +.app { width: min(28rem, 100%); } +h1 { margin: 0 0 1rem; } +form { display: flex; gap: .5rem; margin-bottom: 1rem; } +input { flex: 1; padding: .6rem .8rem; border-radius: .5rem; border: 1px solid #333; background: #181b22; color: var(--fg); } +button { padding: .6rem 1rem; border: 0; border-radius: .5rem; background: var(--accent); color: #02110e; font-weight: 600; cursor: pointer; } +.list { list-style: none; margin: 0; padding: 0; display: grid; gap: .4rem; } +.list li { display: flex; align-items: center; gap: .6rem; padding: .6rem .8rem; background: #181b22; border-radius: .5rem; } +.list li.done span { text-decoration: line-through; opacity: .55; } +`; + +const APP_JS = `'use strict'; +(function () { + var form = document.getElementById('add-form'); + var input = document.getElementById('new-todo'); + var list = document.getElementById('list'); + + function addTodo(text) { + var li = document.createElement('li'); + var span = document.createElement('span'); + span.textContent = text; + li.appendChild(span); + li.addEventListener('click', function () { li.classList.toggle('done'); }); + list.appendChild(li); + } + + form.addEventListener('submit', function (event) { + event.preventDefault(); + var text = input.value.trim(); + if (!text) return; + addTodo(text); + input.value = ''; + input.focus(); + }); +})(); +`; + +/** + * Deterministically scaffold a small working static todo app and validate that + * its JS parses and its HTML asset links resolve. + */ +export const runTodoAppDemo = async (workspaceDir: string): Promise => { + await fs.mkdir(workspaceDir, {recursive: true}); + const files: Array<[string, string]> = [ + ['index.html', INDEX_HTML], + ['styles.css', STYLES_CSS], + ['app.js', APP_JS], + ]; + for (const [name, content] of files) { + await fs.writeFile(path.join(workspaceDir, name), content, 'utf8'); + } + + const exists = async (rel: string): Promise => + fs.access(path.join(workspaceDir, rel)).then(() => true).catch(() => false); + + const validation = [ + await nodeCheck(workspaceDir, 'app.js'), + await checkLinkedAssets(exists, INDEX_HTML), + ]; + + return { + scenario: 'todo-app', + title: 'Build a todo app', + workspaceDir, + plan: [ + 'Create index.html (markup + links to styles.css and app.js)', + 'Create styles.css (calm dark theme)', + 'Create app.js (add/complete tasks)', + 'Validate: node --check app.js and confirm linked assets exist', + ], + filesCreated: files.map(([name]) => name), + filesChanged: [], + validation, + openHint: 'open index.html', + summary: `Created a working static todo app (${files.length} files). Open index.html in a browser.`, + }; +}; diff --git a/src/demo/types.ts b/src/demo/types.ts new file mode 100644 index 0000000..6bcb060 --- /dev/null +++ b/src/demo/types.ts @@ -0,0 +1,54 @@ +/** + * Deterministic, no-key demo mode types. + * + * Demo mode never calls a provider, model, Ollama, or the network. Each scenario + * writes real files into a demo workspace and runs real local validation so a + * brand-new user can see ApeironCode's plan → write → validate flow in under a + * minute without any API credits. + */ + +export type DemoScenarioId = 'todo-app' | 'fix-test' | 'improve-ui' | 'review'; + +export interface DemoValidationStep { + name: string; + ok: boolean; + detail: string; +} + +export interface DemoFinding { + category: 'bug risk' | 'maintainability' | 'security' | 'suggestion'; + message: string; +} + +export interface DemoScenarioResult { + scenario: DemoScenarioId; + title: string; + /** Absolute path to the demo workspace where files were written. */ + workspaceDir: string; + /** Human-readable plan steps shown before applying changes. */ + plan: string[]; + /** Files newly created, relative to workspaceDir. */ + filesCreated: string[]; + /** Files modified in place, relative to workspaceDir. */ + filesChanged: string[]; + /** Real local validation results (e.g. `node --check`, a test run). */ + validation: DemoValidationStep[]; + /** Review findings, for the `review` scenario. */ + findings?: DemoFinding[]; + /** "before" / "after" file snapshots, for `improve-ui`. */ + beforeAfter?: {before: string[]; after: string[]}; + /** A short hint such as `open index.html`. */ + openHint?: string; + /** One-line outcome summary. */ + summary: string; +} + +export interface DemoScenarioMeta { + id: DemoScenarioId; + title: string; + description: string; + command: string; +} + +export const DEMO_DISCLAIMER = + 'Demo mode uses deterministic local examples. For real projects, configure Ollama or a cloud provider.'; diff --git a/src/demo/validate.ts b/src/demo/validate.ts new file mode 100644 index 0000000..d9f3bd6 --- /dev/null +++ b/src/demo/validate.ts @@ -0,0 +1,75 @@ +import {execa} from 'execa'; + +import type {DemoValidationStep} from './types.js'; + +/** + * Run `node --check ` to confirm a JS file parses. Deterministic and + * offline; treats a missing `node` as a skipped (not failed) step so the demo + * still completes on unusual environments. + */ +export const nodeCheck = async ( + cwd: string, + file: string, +): Promise => { + try { + await execa('node', ['--check', file], {cwd, timeout: 15_000}); + return {name: `node --check ${file}`, ok: true, detail: 'syntax OK'}; + } catch (error) { + const err = error as {exitCode?: number; shortMessage?: string}; + if (err.exitCode === undefined) { + return {name: `node --check ${file}`, ok: true, detail: 'skipped (node unavailable)'}; + } + return { + name: `node --check ${file}`, + ok: false, + detail: (err.shortMessage ?? 'syntax error').split('\n')[0] ?? 'syntax error', + }; + } +}; + +/** + * Run a self-contained Node script (e.g. a demo check/test) and report whether + * it exited zero. Returns `ok` plus a one-line detail. + */ +export const runNodeScript = async ( + cwd: string, + file: string, +): Promise => { + try { + const result = await execa('node', [file], {cwd, timeout: 15_000, reject: false}); + const ok = result.exitCode === 0; + const firstLine = + (ok ? result.stdout : result.stderr || result.stdout).split('\n').find(Boolean) ?? + (ok ? 'passed' : 'failed'); + return {name: `node ${file}`, ok, detail: firstLine.slice(0, 200)}; + } catch { + return {name: `node ${file}`, ok: true, detail: 'skipped (node unavailable)'}; + } +}; + +/** + * Verify that every `href`/`src` in an HTML string points at a file that exists + * in the workspace (ignoring absolute/remote URLs). Catches broken CSS/JS links. + */ +export const checkLinkedAssets = async ( + exists: (relPath: string) => Promise, + html: string, +): Promise => { + const refs = new Set(); + const linkRe = /(?:href|src)\s*=\s*["']([^"']+)["']/giu; + let match: RegExpExecArray | null; + while ((match = linkRe.exec(html)) !== null) { + const ref = match[1]!; + if (/^(?:https?:)?\/\//u.test(ref) || ref.startsWith('#') || ref.startsWith('data:')) continue; + refs.add(ref.replace(/^\.\//u, '')); + } + const missing: string[] = []; + for (const ref of refs) { + if (!(await exists(ref))) missing.push(ref); + } + return { + name: 'linked assets exist', + ok: missing.length === 0, + detail: missing.length === 0 ? `${refs.size} reference(s) resolved` : `missing: ${missing.join(', ')}`, + }; +}; diff --git a/src/providers/localEndpoint.ts b/src/providers/localEndpoint.ts new file mode 100644 index 0000000..a1885a5 --- /dev/null +++ b/src/providers/localEndpoint.ts @@ -0,0 +1,39 @@ +/** + * Local endpoint detection for OpenAI-compatible providers. + * + * A local base URL (localhost, loopback, or a common dev host) points at a + * model server the user runs themselves (Ollama's OpenAI shim, LM Studio, + * llama.cpp, vLLM, etc.). These do not need an API key, so ApeironCode must not + * demand one. Remote endpoints still require a key so we fail fast with a clear + * message instead of sending an unauthenticated request. + */ + +const LOCAL_HOSTNAMES = new Set([ + 'localhost', + '127.0.0.1', + '0.0.0.0', + '::1', + '[::1]', + 'host.docker.internal', +]); + +/** + * True when `baseUrl` targets a local/loopback/dev model server. + * + * Unparseable or empty URLs are treated as non-local (return `false`) so we + * never accidentally drop an API-key requirement for a malformed remote URL. + */ +export const isLocalBaseUrl = (baseUrl: string | undefined | null): boolean => { + if (!baseUrl) return false; + let hostname: string; + try { + hostname = new URL(baseUrl).hostname.toLowerCase(); + } catch { + return false; + } + if (LOCAL_HOSTNAMES.has(hostname)) return true; + // *.local mDNS hostnames and 127.0.0.0/8 loopback range. + if (hostname.endsWith('.local')) return true; + if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/u.test(hostname)) return true; + return false; +}; diff --git a/src/providers/openaiCompatible.ts b/src/providers/openaiCompatible.ts index 7ecba96..63c782e 100644 --- a/src/providers/openaiCompatible.ts +++ b/src/providers/openaiCompatible.ts @@ -12,6 +12,7 @@ import { isToolSchemaProviderError, sanitizeOpenAICompatibleTools, } from './toolSchemaSanitizer.js'; +import {isLocalBaseUrl} from './localEndpoint.js'; export {sanitizeOpenAICompatibleTools as sanitizeGitHubTools} from './toolSchemaSanitizer.js'; @@ -24,6 +25,12 @@ interface OpenAICompatibleProviderOptions { extraHeaders?: Record; /** When true, a missing API key is a hard, fast-fail auth error. */ requiresApiKey?: boolean; + /** + * When true, a missing API key is a hard error only for remote base URLs. + * Local endpoints (localhost/127.0.0.1/0.0.0.0/::1/host.docker.internal/.local) + * are self-hosted model servers and never require a key. + */ + requireApiKeyWhenRemote?: boolean; /** Env var name to suggest when the API key is missing. */ missingApiKeyEnvVar?: string; /** @@ -510,7 +517,13 @@ export class OpenAICompatibleProvider implements ModelProvider { 'PROVIDER_NOT_CONFIGURED', ); } - if (this.options.requiresApiKey && !this.options.apiKey) { + const local = isLocalBaseUrl(this.options.baseUrl); + // A remote OpenAI-compatible endpoint that opts into key enforcement still + // requires a key; a local self-hosted endpoint never does. + const keyRequired = + this.options.requiresApiKey || + (this.options.requireApiKeyWhenRemote === true && !local); + if (keyRequired && !this.options.apiKey) { throw buildProviderAuthError( this.options.displayName, this.options.missingApiKeyEnvVar, @@ -518,6 +531,11 @@ export class OpenAICompatibleProvider implements ModelProvider { ); } } + + /** Whether this provider's configured endpoint is local/self-hosted. */ + isLocalEndpoint(): boolean { + return isLocalBaseUrl(this.options.baseUrl); + } } export const createOpenAICompatibleProvider = ( diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 4c9ecff..1877c91 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -25,6 +25,10 @@ export class ProviderRegistry { defaultModels: ['gpt-4.1-mini'], displayName: 'OpenAI Compatible', name: 'openaiCompatible', + // Local self-hosted endpoints (Ollama shim, LM Studio, vLLM, …) need no + // key; remote endpoints require OPENAI_API_KEY so we fail fast cleanly. + requireApiKeyWhenRemote: true, + missingApiKeyEnvVar: 'OPENAI_API_KEY', })); this.register('github-models', createGitHubModelsProvider); this.register('openrouter', createOpenRouterProvider); diff --git a/tests/cli/commands.test.ts b/tests/cli/commands.test.ts index 8f419e9..504faac 100644 --- a/tests/cli/commands.test.ts +++ b/tests/cli/commands.test.ts @@ -23,6 +23,7 @@ const createHandlers = () => ({ contextSymbols: vi.fn(() => Promise.resolve()), contextWhy: vi.fn(() => Promise.resolve()), deleteSession: vi.fn(() => Promise.resolve()), + demo: vi.fn(() => Promise.resolve()), doctor: vi.fn(() => Promise.resolve()), getConfigValue: vi.fn(() => Promise.resolve()), history: vi.fn(() => Promise.resolve()), diff --git a/tests/cli/evalCommands.test.ts b/tests/cli/evalCommands.test.ts index 7dc5841..a41ef73 100644 --- a/tests/cli/evalCommands.test.ts +++ b/tests/cli/evalCommands.test.ts @@ -3,7 +3,7 @@ import {describe, expect, it, vi} from 'vitest'; import {buildProgram, type CliHandlers} from '../../src/cli/commands.js'; const handlers = (): CliHandlers => ({ - addPermission: vi.fn(), agentRun: vi.fn(), agentShow: vi.fn(), agents: vi.fn(), checkPermission: vi.fn(), connectorEnv: vi.fn(), connectorList: vi.fn(), contextBudget: vi.fn(), contextExplain: vi.fn(), contextFiles: vi.fn(), contextIndex: vi.fn(), contextMap: vi.fn(), contextRefresh: vi.fn(), contextSymbols: vi.fn(), contextWhy: vi.fn(), continueTask: vi.fn(), cost: vi.fn(), debugConfig: vi.fn(), debugLogs: vi.fn(), debugTokens: vi.fn(), debugTraces: vi.fn(), deleteSession: vi.fn(), doctor: vi.fn(), evalList: vi.fn(), evalReport: vi.fn(), evalRun: vi.fn(), getConfigValue: vi.fn(), githubActions: vi.fn(), githubCiExplain: vi.fn(), githubIssue: vi.fn(), githubIssueComment: vi.fn(), githubIssueCreate: vi.fn(), githubIssues: vi.fn(), githubPr: vi.fn(), githubPrComment: vi.fn(), githubPrCreate: vi.fn(), githubPrReview: vi.fn(), githubPrSummary: vi.fn(), githubPrs: vi.fn(), githubRepo: vi.fn(), githubStatus: vi.fn(), history: vi.fn(), hookDisable: vi.fn(), hookEnable: vi.fn(), hookEvents: vi.fn(), hookList: vi.fn(), hookShow: vi.fn(), hookTest: vi.fn(), hooks: vi.fn(), listConfig: vi.fn(), listMcp: vi.fn(), listMcpTools: vi.fn(), listPermissions: vi.fn(), listPlugins: vi.fn(), listSessions: vi.fn(), listTools: vi.fn(), lspCache: vi.fn(), lspCacheClear: vi.fn(), lspDefinition: vi.fn(), lspDiagnostics: vi.fn(), lspReferences: vi.fn(), lspRestart: vi.fn(), lspSessions: vi.fn(), lspStatus: vi.fn(), lspStop: vi.fn(), lspSymbols: vi.fn(), memoryClear: vi.fn(), memoryConflicts: vi.fn(), memoryEdit: vi.fn(), memoryForgetSession: vi.fn(), memoryGraph: vi.fn(), memoryLearn: vi.fn(), memoryPrune: vi.fn(), memoryRelated: vi.fn(), memoryReview: vi.fn(), memoryRollback: vi.fn(), memorySearch: vi.fn(), memoryShow: vi.fn(), memorySource: vi.fn(), memoryStale: vi.fn(), memorySuggestionApprove: vi.fn(), memorySuggestionReject: vi.fn(), memorySuggestionShow: vi.fn(), memorySuggestions: vi.fn(), memorySummarize: vi.fn(), memoryWhy: vi.fn(), modelList: vi.fn(), modelRecommend: vi.fn(), ollamaModels: vi.fn(), ollamaPullHint: vi.fn(), ollamaRecommend: vi.fn(), ollamaStatus: vi.fn(), planApprove: vi.fn(), planClear: vi.fn(), planCreate: vi.fn(), planDelete: vi.fn(), planExecute: vi.fn(), planList: vi.fn(), planPause: vi.fn(), planResume: vi.fn(), planRevise: vi.fn(), planShow: vi.fn(), planStatus: vi.fn(), providerDoctor: vi.fn(), providerEnv: vi.fn(), providerFallback: vi.fn(), providerFallbackSet: vi.fn(), providerFallbackSimulate: vi.fn(), providerFallbackTest: vi.fn(), providerList: vi.fn(), providerSetup: vi.fn(), providerTest: vi.fn(), removePermission: vi.fn(), repoMap: vi.fn(), repoSummary: vi.fn(), repoSymbols: vi.fn(), revert: vi.fn(), runRoot: vi.fn(), sandboxDoctor: vi.fn(), sandboxStatus: vi.fn(), search: vi.fn(), securityStatus: vi.fn(), sessionAttach: vi.fn(), sessionDelete: vi.fn(), sessionExport: vi.fn(), sessionList: vi.fn(), sessionLocks: vi.fn(), sessionLogs: vi.fn(), sessionPause: vi.fn(), sessionResume: vi.fn(), sessionRunWorker: vi.fn(), sessionShow: vi.fn(), sessionStart: vi.fn(), sessionStop: vi.fn(), sessionUnlock: vi.fn(), sessions: vi.fn(), setConfigValue: vi.fn(), setup: vi.fn(), setupReset: vi.fn(), setupStatus: vi.fn(), share: vi.fn(), skillBrowser: vi.fn(), skillCreate: vi.fn(), skillDelete: vi.fn(), skillDisable: vi.fn(), skillEnable: vi.fn(), skillExport: vi.fn(), skillGenerate: vi.fn(), skillImport: vi.fn(), skillList: vi.fn(), skillRun: vi.fn(), skillShow: vi.fn(), skillTemplates: vi.fn(), skillTrust: vi.fn(), skillValidate: vi.fn(), skills: vi.fn(), teamApply: vi.fn(), teamArtifact: vi.fn(), teamArtifacts: vi.fn(), teamCockpit: vi.fn(), teamConflicts: vi.fn(), teamDiscard: vi.fn(), teamExport: vi.fn(), teamExportPatch: vi.fn(), teamIgnored: vi.fn(), teamMergePlan: vi.fn(), teamPlan: vi.fn(), teamResolve: vi.fn(), teamReview: vi.fn(), teamRun: vi.fn(), teamRunShow: vi.fn(), teamRuns: vi.fn(), teamValidatePatch: vi.fn(), teamWorkspaceCleanup: vi.fn(), teamWorkspaces: vi.fn(), testMcp: vi.fn(), webFetch: vi.fn(), webResearch: vi.fn(), webSearch: vi.fn(), workflowList: vi.fn(), workflowReport: vi.fn(), workflowRun: vi.fn(), workflowShow: vi.fn(), + addPermission: vi.fn(), agentRun: vi.fn(), agentShow: vi.fn(), agents: vi.fn(), checkPermission: vi.fn(), connectorEnv: vi.fn(), connectorList: vi.fn(), contextBudget: vi.fn(), contextExplain: vi.fn(), contextFiles: vi.fn(), contextIndex: vi.fn(), contextMap: vi.fn(), contextRefresh: vi.fn(), contextSymbols: vi.fn(), contextWhy: vi.fn(), continueTask: vi.fn(), cost: vi.fn(), debugConfig: vi.fn(), debugLogs: vi.fn(), debugTokens: vi.fn(), debugTraces: vi.fn(), deleteSession: vi.fn(), demo: vi.fn(), doctor: vi.fn(), evalList: vi.fn(), evalReport: vi.fn(), evalRun: vi.fn(), getConfigValue: vi.fn(), githubActions: vi.fn(), githubCiExplain: vi.fn(), githubIssue: vi.fn(), githubIssueComment: vi.fn(), githubIssueCreate: vi.fn(), githubIssues: vi.fn(), githubPr: vi.fn(), githubPrComment: vi.fn(), githubPrCreate: vi.fn(), githubPrReview: vi.fn(), githubPrSummary: vi.fn(), githubPrs: vi.fn(), githubRepo: vi.fn(), githubStatus: vi.fn(), history: vi.fn(), hookDisable: vi.fn(), hookEnable: vi.fn(), hookEvents: vi.fn(), hookList: vi.fn(), hookShow: vi.fn(), hookTest: vi.fn(), hooks: vi.fn(), listConfig: vi.fn(), listMcp: vi.fn(), listMcpTools: vi.fn(), listPermissions: vi.fn(), listPlugins: vi.fn(), listSessions: vi.fn(), listTools: vi.fn(), lspCache: vi.fn(), lspCacheClear: vi.fn(), lspDefinition: vi.fn(), lspDiagnostics: vi.fn(), lspReferences: vi.fn(), lspRestart: vi.fn(), lspSessions: vi.fn(), lspStatus: vi.fn(), lspStop: vi.fn(), lspSymbols: vi.fn(), memoryClear: vi.fn(), memoryConflicts: vi.fn(), memoryEdit: vi.fn(), memoryForgetSession: vi.fn(), memoryGraph: vi.fn(), memoryLearn: vi.fn(), memoryPrune: vi.fn(), memoryRelated: vi.fn(), memoryReview: vi.fn(), memoryRollback: vi.fn(), memorySearch: vi.fn(), memoryShow: vi.fn(), memorySource: vi.fn(), memoryStale: vi.fn(), memorySuggestionApprove: vi.fn(), memorySuggestionReject: vi.fn(), memorySuggestionShow: vi.fn(), memorySuggestions: vi.fn(), memorySummarize: vi.fn(), memoryWhy: vi.fn(), modelList: vi.fn(), modelRecommend: vi.fn(), ollamaModels: vi.fn(), ollamaPullHint: vi.fn(), ollamaRecommend: vi.fn(), ollamaStatus: vi.fn(), planApprove: vi.fn(), planClear: vi.fn(), planCreate: vi.fn(), planDelete: vi.fn(), planExecute: vi.fn(), planList: vi.fn(), planPause: vi.fn(), planResume: vi.fn(), planRevise: vi.fn(), planShow: vi.fn(), planStatus: vi.fn(), providerDoctor: vi.fn(), providerEnv: vi.fn(), providerFallback: vi.fn(), providerFallbackSet: vi.fn(), providerFallbackSimulate: vi.fn(), providerFallbackTest: vi.fn(), providerList: vi.fn(), providerSetup: vi.fn(), providerTest: vi.fn(), removePermission: vi.fn(), repoMap: vi.fn(), repoSummary: vi.fn(), repoSymbols: vi.fn(), revert: vi.fn(), runRoot: vi.fn(), sandboxDoctor: vi.fn(), sandboxStatus: vi.fn(), search: vi.fn(), securityStatus: vi.fn(), sessionAttach: vi.fn(), sessionDelete: vi.fn(), sessionExport: vi.fn(), sessionList: vi.fn(), sessionLocks: vi.fn(), sessionLogs: vi.fn(), sessionPause: vi.fn(), sessionResume: vi.fn(), sessionRunWorker: vi.fn(), sessionShow: vi.fn(), sessionStart: vi.fn(), sessionStop: vi.fn(), sessionUnlock: vi.fn(), sessions: vi.fn(), setConfigValue: vi.fn(), setup: vi.fn(), setupReset: vi.fn(), setupStatus: vi.fn(), share: vi.fn(), skillBrowser: vi.fn(), skillCreate: vi.fn(), skillDelete: vi.fn(), skillDisable: vi.fn(), skillEnable: vi.fn(), skillExport: vi.fn(), skillGenerate: vi.fn(), skillImport: vi.fn(), skillList: vi.fn(), skillRun: vi.fn(), skillShow: vi.fn(), skillTemplates: vi.fn(), skillTrust: vi.fn(), skillValidate: vi.fn(), skills: vi.fn(), teamApply: vi.fn(), teamArtifact: vi.fn(), teamArtifacts: vi.fn(), teamCockpit: vi.fn(), teamConflicts: vi.fn(), teamDiscard: vi.fn(), teamExport: vi.fn(), teamExportPatch: vi.fn(), teamIgnored: vi.fn(), teamMergePlan: vi.fn(), teamPlan: vi.fn(), teamResolve: vi.fn(), teamReview: vi.fn(), teamRun: vi.fn(), teamRunShow: vi.fn(), teamRuns: vi.fn(), teamValidatePatch: vi.fn(), teamWorkspaceCleanup: vi.fn(), teamWorkspaces: vi.fn(), testMcp: vi.fn(), webFetch: vi.fn(), webResearch: vi.fn(), webSearch: vi.fn(), workflowList: vi.fn(), workflowReport: vi.fn(), workflowRun: vi.fn(), workflowShow: vi.fn(), resumeSession: vi.fn(), }); diff --git a/tests/cli/help.test.ts b/tests/cli/help.test.ts new file mode 100644 index 0000000..4e437ff --- /dev/null +++ b/tests/cli/help.test.ts @@ -0,0 +1,86 @@ +import {describe, expect, it, vi} from 'vitest'; + +import {buildProgram} from '../../src/cli/commands.js'; +import {formatAdvancedHelp, formatDeveloperHelp} from '../../src/cli/help.js'; +import type {CliHandlers} from '../../src/cli/commands/types.js'; + +const makeHandlers = (runRoot = vi.fn(() => Promise.resolve())): CliHandlers => { + const target: Record = {runRoot}; + return new Proxy(target, { + get(t, prop: string) { + if (!(prop in t)) t[prop] = vi.fn(() => Promise.resolve()); + return t[prop]; + }, + }) as unknown as CliHandlers; +}; + +const rootHelp = (): string => { + const program = buildProgram(makeHandlers()); + program.configureOutput({writeErr: () => undefined, writeOut: () => undefined}); + return program.helpInformation(); +}; + +describe('beginner-focused CLI help', () => { + it('default help lists the four workflows plus demo/doctor/setup', () => { + const help = rootHelp(); + for (const cmd of ['fix', 'build', 'improve', 'review', 'demo', 'doctor', 'setup']) { + expect(help, cmd).toMatch(new RegExp(`\\n ${cmd}\\b`)); + } + }); + + it('default help does not expose the advanced command list', () => { + const help = rootHelp(); + // These remain runnable but must not appear in the beginner command list. + for (const hidden of ['mcp', 'team', 'skills', 'hooks', 'bridge', 'brain']) { + expect(help.includes(`\n ${hidden} `), hidden).toBe(false); + } + }); + + it('developer help groups provider/model/context/workflow/github/memory', () => { + const dev = formatDeveloperHelp(); + for (const cmd of ['provider', 'model', 'context', 'workflow', 'github', 'memory']) { + expect(dev, cmd).toContain(cmd); + } + }); + + it('advanced help groups mcp/skills/team/hooks/bridge/runtime', () => { + const advanced = formatAdvancedHelp(); + for (const cmd of ['mcp', 'skills', 'team', 'hooks', 'bridge', 'runtime']) { + expect(advanced, cmd).toContain(cmd); + } + }); +}); + +describe('headline workflow commands route to the agent', () => { + const run = async (args: string[]): Promise<{prompt: string; mode: string}> => { + const runRoot = vi.fn(() => Promise.resolve()); + const program = buildProgram(makeHandlers(runRoot)); + await program.parseAsync(['node', 'apeironcode', ...args]); + expect(runRoot).toHaveBeenCalledTimes(1); + const [prompt, options] = runRoot.mock.calls[0] as unknown as [string, {mode: string}]; + return {prompt, mode: options.mode}; + }; + + it('fix routes with mode fix and includes the description', async () => { + const {prompt, mode} = await run(['fix', 'the', 'login', 'bug']); + expect(mode).toBe('fix'); + expect(prompt).toContain('the login bug'); + }); + + it('build routes with mode feature', async () => { + const {prompt, mode} = await run(['build', 'a todo app']); + expect(mode).toBe('feature'); + expect(prompt).toContain('a todo app'); + }); + + it('improve routes with mode refactor', async () => { + const {mode} = await run(['improve', 'make', 'the', 'UI', 'premium']); + expect(mode).toBe('refactor'); + }); + + it('review routes with mode review and a sensible default prompt', async () => { + const {prompt, mode} = await run(['review']); + expect(mode).toBe('review'); + expect(prompt.toLowerCase()).toContain('review'); + }); +}); diff --git a/tests/cli/onboarding.test.ts b/tests/cli/onboarding.test.ts new file mode 100644 index 0000000..faa6d0e --- /dev/null +++ b/tests/cli/onboarding.test.ts @@ -0,0 +1,46 @@ +import {describe, expect, it} from 'vitest'; + +import { + SETUP_INTENTS, + formatDoctorNextSteps, + formatSetupIntentMenu, +} from '../../src/cli/onboarding.js'; + +describe('intent-based setup menu', () => { + it('offers exactly the five documented intents in order', () => { + expect(SETUP_INTENTS.map((i) => i.label)).toEqual([ + 'Demo mode only', + 'Free local model with Ollama', + 'Cheap cloud model', + 'Best coding model', + 'Bring my own OpenAI-compatible endpoint', + ]); + }); + + it('renders each intent with a concrete command and no baseUrl jargon', () => { + const menu = formatSetupIntentMenu(); + expect(menu).toContain('Choose how ApeironCode should run'); + expect(menu).toContain('apeironcode demo'); + expect(menu).toContain('apeironcode local setup'); + // First-run users are not asked about baseUrl/auth internals up front. + expect(menu).not.toMatch(/baseUrl|Authorization header/); + // Local endpoints are documented as key-free. + expect(menu).toMatch(/do not need an API key/i); + }); +}); + +describe('doctor next steps', () => { + it('points to demo/local/cloud when no real provider is configured', () => { + const text = formatDoctorNextSteps({hasRealProvider: false, ollamaRunning: false}); + expect(text).toContain('No real model configured'); + expect(text).toContain('apeironcode demo'); + expect(text).toContain('apeironcode local setup'); + expect(text).toContain('apeironcode setup'); + }); + + it('confirms readiness when a real provider is configured', () => { + const text = formatDoctorNextSteps({hasRealProvider: true, ollamaRunning: true}); + expect(text).toContain('A real model is configured'); + expect(text).toContain('apeironcode fix'); + }); +}); diff --git a/tests/demo/demo.test.ts b/tests/demo/demo.test.ts new file mode 100644 index 0000000..0f1843e --- /dev/null +++ b/tests/demo/demo.test.ts @@ -0,0 +1,98 @@ +import {promises as fs} from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; + +import { + DEMO_SCENARIOS, + formatDemoMenu, + formatDemoResult, + isDemoScenarioId, + runDemoScenario, +} from '../../src/demo/index.js'; + +let workspaceRoot: string; + +beforeEach(async () => { + workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'apeiron-demo-test-')); +}); + +afterEach(async () => { + await fs.rm(workspaceRoot, {recursive: true, force: true}); +}); + +const exists = (dir: string, rel: string): Promise => + fs.access(path.join(dir, rel)).then(() => true).catch(() => false); + +describe('demo mode (no key required)', () => { + it('lists four scenarios with apeironcode commands', () => { + expect(DEMO_SCENARIOS.map((s) => s.id).sort()).toEqual(['fix-test', 'improve-ui', 'review', 'todo-app']); + const menu = formatDemoMenu(DEMO_SCENARIOS); + expect(menu).toContain('No API key, model, or Ollama required'); + expect(menu).toContain('apeironcode demo todo-app'); + }); + + it('validates scenario ids', () => { + expect(isDemoScenarioId('todo-app')).toBe(true); + expect(isDemoScenarioId('nope')).toBe(false); + }); + + it('todo-app creates a working static app with passing validation', async () => { + const dir = path.join(workspaceRoot, 'todo'); + const result = await runDemoScenario('todo-app', {workspaceDir: dir}); + + expect(result.filesCreated).toEqual(['index.html', 'styles.css', 'app.js']); + expect(await exists(dir, 'index.html')).toBe(true); + expect(await exists(dir, 'app.js')).toBe(true); + expect(result.validation.every((step) => step.ok)).toBe(true); + expect(result.openHint).toBe('open index.html'); + }); + + it('fix-test reproduces a failure then fixes it to green', async () => { + const dir = path.join(workspaceRoot, 'fix'); + const result = await runDemoScenario('fix-test', {workspaceDir: dir}); + + expect(result.filesChanged).toContain('sum.js'); + const before = result.validation.find((s) => s.name.startsWith('before')); + const after = result.validation.find((s) => s.name.startsWith('after')); + expect(before?.ok).toBe(false); + expect(after?.ok).toBe(true); + // The fixed file actually computes addition. + const fixed = await fs.readFile(path.join(dir, 'sum.js'), 'utf8'); + expect(fixed).toContain('a + b'); + }); + + it('improve-ui changes the stylesheet and keeps assets resolvable', async () => { + const dir = path.join(workspaceRoot, 'ui'); + const result = await runDemoScenario('improve-ui', {workspaceDir: dir}); + + expect(result.filesChanged).toEqual(['styles.css']); + expect(result.beforeAfter).toBeDefined(); + expect(result.validation.every((step) => step.ok)).toBe(true); + const css = await fs.readFile(path.join(dir, 'styles.css'), 'utf8'); + expect(css).toContain('gradient'); + }); + + it('review returns deterministic findings with no fake-AI wording', async () => { + const dir = path.join(workspaceRoot, 'review'); + const result = await runDemoScenario('review', {workspaceDir: dir}); + + expect(result.findings && result.findings.length).toBeGreaterThan(0); + const categories = new Set(result.findings!.map((f) => f.category)); + expect(categories.has('security')).toBe(true); + const text = formatDemoResult(result); + expect(text).not.toMatch(/AI (?:wrote|generated|thinks)/i); + expect(text).toContain('Demo mode uses deterministic local examples'); + }); + + it('every formatted result includes the honest disclaimer', async () => { + for (const scenario of DEMO_SCENARIOS) { + const dir = path.join(workspaceRoot, scenario.id); + const result = await runDemoScenario(scenario.id, {workspaceDir: dir}); + expect(formatDemoResult(result)).toContain( + 'For real projects, configure Ollama or a cloud provider', + ); + } + }); +}); diff --git a/tests/demo/docs.test.ts b/tests/demo/docs.test.ts new file mode 100644 index 0000000..457ad01 --- /dev/null +++ b/tests/demo/docs.test.ts @@ -0,0 +1,33 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import {describe, expect, it} from 'vitest'; + +const ROOT = path.resolve(__dirname, '../..'); +// Collapse whitespace/newlines so line-wrapped markdown still matches phrases. +const read = (rel: string): string => + fs.readFileSync(path.join(ROOT, rel), 'utf8').replace(/\s+/gu, ' '); + +describe('demo / provider documentation honesty', () => { + it('documents the mock provider as a test/demo stub, not a real coding model', () => { + expect(read('docs/demo.md')).toMatch(/not\*{0,2} a real coding model/i); + expect(read('docs/providers.md')).toMatch(/not\*{0,2} a real coding model/i); + }); + + it('documents demo mode as deterministic and no-key', () => { + const demo = read('docs/demo.md'); + expect(demo).toMatch(/no API key, model, or Ollama/i); + expect(demo).toMatch(/deterministic/i); + }); + + it('documents that local OpenAI-compatible endpoints need no API key', () => { + expect(read('docs/providers.md')).toMatch(/Local OpenAI-compatible endpoints do not require an API key/i); + }); + + it('README points first-run users at the no-key demo and the four workflows', () => { + const readme = read('README.md'); + expect(readme).toMatch(/apeironcode demo/); + expect(readme).toMatch(/Four workflows/i); + expect(readme).toMatch(/help developer/); + }); +}); diff --git a/tests/providers/localEndpoint.test.ts b/tests/providers/localEndpoint.test.ts new file mode 100644 index 0000000..43169a2 --- /dev/null +++ b/tests/providers/localEndpoint.test.ts @@ -0,0 +1,126 @@ +import {describe, expect, it, vi, afterEach} from 'vitest'; + +import {isLocalBaseUrl} from '../../src/providers/localEndpoint.js'; +import {createOpenAICompatibleProvider} from '../../src/providers/openaiCompatible.js'; + +const makeProvider = (baseUrl: string, apiKey: string | null) => + createOpenAICompatibleProvider({ + defaultModels: ['m'], + displayName: 'OpenAI Compatible', + name: 'openaiCompatible', + requireApiKeyWhenRemote: true, + missingApiKeyEnvVar: 'OPENAI_API_KEY', + })({apiKey, baseUrl}); + +const drain = async (gen: AsyncGenerator): Promise => { + for await (const _chunk of gen) void _chunk; +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('isLocalBaseUrl', () => { + it('treats loopback and dev hosts as local', () => { + for (const url of [ + 'http://localhost:11434/v1', + 'http://127.0.0.1:1234/v1', + 'http://0.0.0.0:8000/v1', + 'http://[::1]:8080/v1', + 'http://host.docker.internal:1234/v1', + 'http://127.0.0.5:5000/v1', + 'http://my-box.local:1234/v1', + ]) { + expect(isLocalBaseUrl(url), url).toBe(true); + } + }); + + it('treats remote and malformed URLs as non-local', () => { + for (const url of [ + 'https://api.openai.com/v1', + 'https://models.github.ai/inference', + 'https://example.com', + 'not a url', + '', + undefined, + ]) { + expect(isLocalBaseUrl(url), String(url)).toBe(false); + } + }); +}); + +describe('OpenAI-compatible local endpoint key handling', () => { + it('does not require an API key for a local endpoint and sends no Authorization header', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + body: { + getReader() { + let done = false; + return { + read() { + if (done) return Promise.resolve({done: true, value: undefined}); + done = true; + return Promise.resolve({ + done: false, + value: new TextEncoder().encode('data: [DONE]\n'), + }); + }, + releaseLock() {}, + }; + }, + }, + }); + vi.stubGlobal('fetch', fetchMock); + + const provider = makeProvider('http://localhost:11434/v1', null); + await drain(provider.stream({model: 'm', messages: [{role: 'user', content: 'hi'}]})); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const init = fetchMock.mock.calls[0]![1] as {headers: Record}; + expect(init.headers.Authorization).toBeUndefined(); + }); + + it('requires an API key for a remote endpoint with a clean error and no request', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const provider = makeProvider('https://api.example.com/v1', null); + let error: Error | undefined; + try { + await drain(provider.stream({model: 'm', messages: [{role: 'user', content: 'hi'}]})); + } catch (e) { + error = e as Error; + } + + expect(error).toBeDefined(); + expect(error?.message).toContain('OPENAI_API_KEY'); + expect((error as {code?: string}).code).toBe('PROVIDER_AUTH_ERROR'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('sends an Authorization header when a key is provided', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + body: { + getReader() { + let done = false; + return { + read() { + if (done) return Promise.resolve({done: true, value: undefined}); + done = true; + return Promise.resolve({done: false, value: new TextEncoder().encode('data: [DONE]\n')}); + }, + releaseLock() {}, + }; + }, + }, + }); + vi.stubGlobal('fetch', fetchMock); + + const provider = makeProvider('https://api.example.com/v1', 'sk-secret-value'); + await drain(provider.stream({model: 'm', messages: [{role: 'user', content: 'hi'}]})); + + const init = fetchMock.mock.calls[0]![1] as {headers: Record}; + expect(init.headers.Authorization).toBe('Bearer sk-secret-value'); + }); +}); From e9a7aff0ae5e31c060910aea621a8106cec6a02a Mon Sep 17 00:00:00 2001 From: Poola Nithin Reddy Date: Mon, 8 Jun 2026 00:21:21 -0400 Subject: [PATCH 3/8] Add reliable fix workflow and file plan repair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 20B: make `apeironcode fix` a real repair loop and harden file-plan parsing. File plans (Task A/B): - New robust parser (src/agent/filePlanParser.ts) with multi-strategy extraction (direct/fenced/balanced/tolerant-repair/tool-call-envelope), a zod schema (filePlanSchema.ts), and shape coercion/repair (filePlanRepair.ts). No single strict JSON.parse failure kills the workflow; failures return exact validation errors plus a ready-to-send correction prompt. Rejects absolute/.. paths, .git, .env (by default), and bad operation/content combinations. - Legacy parseFilePlanResponse now delegates to the robust parser. Repair workflow (Tasks C–H) under src/repair/: - projectDetector / commandDetector / checkRunner: detect package manager, framework, scripts; ordered checks (build/e2e gated behind --all); redacted, truncated check output. - errorParser / failureClassifier: structured issues (typecheck, lint, test, build, module-not-found, syntax) with file/line and confidence. - contextBuilder: small, secret-free context packet with a selection trace. - approvalBundle: one bundled approval with risk level; high-risk commands (rm -rf, sudo, curl|sh, npm publish, git push) blocked. - checkpoint: snapshot before apply, rollback on failed apply (.apeironcode/ checkpoints, gitignored). - repairLoop + finalReport + repairPlanner: detect → run → propose minimal plan → one correction pass → bundled approval → checkpoint → apply → rerun, up to a mode-based limit. Safe/dry-run/all/until-green modes; --commit deferred. CLI: `apeironcode fix [--dry-run --safe --all --until-green --commit --yes]` wired via src/cli/fixHandler.ts; dry-run never constructs a provider or writes. Tests: file-plan parser, detectors, error parser, checkpoint, approval bundle, repair-loop (injected deterministic deps), and real-node fixtures under tests/fixtures/repair. Docs: docs/fix-code.md + README/getting-started updates. --- .gitignore | 2 + README.md | 9 +- docs/fix-code.md | 52 +++++ docs/getting-started.md | 6 +- eslint.config.js | 3 + package.json | 1 + src/agent/filePlanParser.ts | 108 +++++++++ src/agent/filePlanProtocol.ts | 61 ++--- src/agent/filePlanRepair.ts | 138 +++++++++++ src/agent/filePlanSchema.ts | 142 +++++++++++ src/cli/commands/types.ts | 2 + src/cli/fixHandler.ts | 115 +++++++++ src/cli/help.ts | 24 +- src/cli/setup/coreHandlers.ts | 17 +- src/repair/approvalBundle.ts | 78 +++++++ src/repair/checkRunner.ts | 73 ++++++ src/repair/checkpoint.ts | 77 ++++++ src/repair/commandDetector.ts | 68 ++++++ src/repair/contextBuilder.ts | 95 ++++++++ src/repair/errorParser.ts | 135 +++++++++++ src/repair/failureClassifier.ts | 44 ++++ src/repair/finalReport.ts | 96 ++++++++ src/repair/projectDetector.ts | 97 ++++++++ src/repair/repairLoop.ts | 221 ++++++++++++++++++ src/repair/repairPlanner.ts | 64 +++++ src/repair/types.ts | 68 ++++++ tests/agent/filePlanParser.test.ts | 143 ++++++++++++ tests/cli/commands.test.ts | 1 + tests/cli/evalCommands.test.ts | 2 +- tests/cli/help.test.ts | 15 +- tests/fixtures/repair/failing-test/check.mjs | 3 + .../fixtures/repair/failing-test/package.json | 4 + tests/fixtures/repair/failing-test/sum.mjs | 1 + .../repair/module-not-found/build.mjs | 1 + .../repair/module-not-found/package.json | 4 + .../fixtures/repair/no-package-json/.gitkeep | 1 + tests/fixtures/repair/ts-error/index.ts | 1 + tests/fixtures/repair/ts-error/package.json | 4 + tests/fixtures/repair/ts-error/tsconfig.json | 1 + .../repair/unknown-project/README.txt | 1 + tests/repair/checkpoint.test.ts | 73 ++++++ tests/repair/detectors.test.ts | 80 +++++++ tests/repair/errorParser.test.ts | 62 +++++ tests/repair/fixtures.test.ts | 49 ++++ tests/repair/repairLoop.test.ts | 154 ++++++++++++ tsconfig.json | 3 +- 46 files changed, 2333 insertions(+), 66 deletions(-) create mode 100644 docs/fix-code.md create mode 100644 src/agent/filePlanParser.ts create mode 100644 src/agent/filePlanRepair.ts create mode 100644 src/agent/filePlanSchema.ts create mode 100644 src/cli/fixHandler.ts create mode 100644 src/repair/approvalBundle.ts create mode 100644 src/repair/checkRunner.ts create mode 100644 src/repair/checkpoint.ts create mode 100644 src/repair/commandDetector.ts create mode 100644 src/repair/contextBuilder.ts create mode 100644 src/repair/errorParser.ts create mode 100644 src/repair/failureClassifier.ts create mode 100644 src/repair/finalReport.ts create mode 100644 src/repair/projectDetector.ts create mode 100644 src/repair/repairLoop.ts create mode 100644 src/repair/repairPlanner.ts create mode 100644 src/repair/types.ts create mode 100644 tests/agent/filePlanParser.test.ts create mode 100644 tests/fixtures/repair/failing-test/check.mjs create mode 100644 tests/fixtures/repair/failing-test/package.json create mode 100644 tests/fixtures/repair/failing-test/sum.mjs create mode 100644 tests/fixtures/repair/module-not-found/build.mjs create mode 100644 tests/fixtures/repair/module-not-found/package.json create mode 100644 tests/fixtures/repair/no-package-json/.gitkeep create mode 100644 tests/fixtures/repair/ts-error/index.ts create mode 100644 tests/fixtures/repair/ts-error/package.json create mode 100644 tests/fixtures/repair/ts-error/tsconfig.json create mode 100644 tests/fixtures/repair/unknown-project/README.txt create mode 100644 tests/repair/checkpoint.test.ts create mode 100644 tests/repair/detectors.test.ts create mode 100644 tests/repair/errorParser.test.ts create mode 100644 tests/repair/fixtures.test.ts create mode 100644 tests/repair/repairLoop.test.ts diff --git a/.gitignore b/.gitignore index fe2b5bf..f7fb2ff 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ logs/ tmp/ # Local runtime/dogfood state and other-tool configs — not part of the CLI repo. /.apeironcode-agent/ +# Repair-loop checkpoints (Phase 20B) — local rollback snapshots. +.apeironcode/checkpoints/ /.agents/ /.codex/ /.claude/ diff --git a/README.md b/README.md index aa75622..f3a738f 100644 --- a/README.md +++ b/README.md @@ -56,12 +56,19 @@ ApeironCode is a local-first coding agent centered on four workflows, all with approval-gated writes and commands: ```bash -apeironcode fix # fix a bug or failing tests +apeironcode fix # detect failures, propose fixes, rerun checks apeironcode build "a todo app" # build an app or feature apeironcode improve "premium UI" # improve UI or code quality apeironcode review # review the current changes ``` +`apeironcode fix` detects your project's check commands (typecheck/lint/test/ +build), runs them, proposes an **approval-gated** minimal patch, applies it, and +reruns the checks. Use `--dry-run` to preview, `--safe` to restrict the edit +scope, and `--until-green` to loop. See [docs/fix-code.md](./docs/fix-code.md). +It is alpha — you review and approve every change, and repair quality depends on +your configured provider. + `apeironcode --help` is beginner-focused. Run `apeironcode help developer` or `apeironcode help advanced` for the full command set. diff --git a/docs/fix-code.md b/docs/fix-code.md new file mode 100644 index 0000000..7e93011 --- /dev/null +++ b/docs/fix-code.md @@ -0,0 +1,52 @@ +# Fixing code with `apeironcode fix` + +`apeironcode fix` is ApeironCode's core repair workflow. The runtime owns the +loop; the model only proposes small patches. + +```bash +apeironcode fix # detect checks, run them, propose approval-gated fixes +apeironcode fix --dry-run # preview: detect project + checks, run/change nothing +apeironcode fix --safe # only edit files referenced by errors; max 2 attempts +apeironcode fix --all # also run build/e2e; allow more attempts +apeironcode fix --until-green # keep attempting until checks pass (up to 10) +``` + +## What it does + +1. Detects the project type, package manager (npm/pnpm/yarn/bun), and the + available check scripts (typecheck, lint, test, build, e2e). +2. Runs checks in order: typecheck → lint → test → build → e2e + (build/e2e only with `--all` / `--until-green`). +3. Parses failures into structured issues (TypeScript errors, ESLint errors, + failing tests, module-not-found, …) and selects a focused context packet. +4. Asks the configured provider for a **minimal** file plan, parsed robustly + (markdown/fenced/tool-call output is tolerated) and validated against a + schema. One correction attempt is made if the plan is invalid. +5. Shows a **single approval bundle** (issues + files + commands + risk). +6. On approval, creates a checkpoint, applies the change through the + ToolRegistry, and reruns the failed checks. +7. Repeats up to a mode-based limit, then prints an honest final report. + +## Safety + +- **Approval is required before any change.** In `ask` mode you confirm the + bundle; in `bypass`/`trusted` mode it is auto-approved. +- **`--safe`** restricts edits to files referenced by the error and forbids + delete/rename and dependency installs. +- **`--dry-run`** never runs checks or writes files. +- High-risk commands (`rm -rf`, `sudo`, `curl | sh`, `npm publish`, `git push`, + …) are blocked from the auto-applied bundle. +- A checkpoint is created before applying; a failed apply is rolled back so the + workspace is never left half-changed. Checkpoints live under + `.apeironcode/checkpoints/` (gitignored). + +## Honest limitations (alpha) + +- This is alpha. It does not fix every repo, and it is not fully autonomous — + you review and approve changes. +- Repair quality depends on the configured provider/model. A weak model may + return plans that do not fix the issue; the loop stops safely after the + attempt limit. +- The `mock`/demo provider is deterministic and is **not** a real coding model; + use Ollama or a cloud provider for real repairs. +- `--commit` is planned for a later phase and is not active yet. diff --git a/docs/getting-started.md b/docs/getting-started.md index e227c4f..a9ebde1 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -52,10 +52,14 @@ apeironcode doctor ## 4. The four workflows ```bash -apeironcode fix # fix a bug or failing tests +apeironcode fix # detect failures, propose fixes, rerun checks apeironcode build "a todo app" # build an app or feature apeironcode improve "premium UI" # improve UI or code quality apeironcode review # review the current changes ``` +`apeironcode fix` runs the repair loop: detect checks → run → propose an +approval-gated patch → apply → rerun. Try `apeironcode fix --dry-run` first. +See [fix-code.md](./fix-code.md). + Every write and command is approval-gated. See [safety.md](./safety.md). diff --git a/eslint.config.js b/eslint.config.js index 26e4dd0..6d1920c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -47,6 +47,9 @@ export default [ 'extensions/**', 'ApeironCode-web/**', '.playwright-mcp/**', + // Deliberate type-error fixture for the repair workflow; excluded from the + // TS project and not linted. + 'tests/fixtures/repair/ts-error/**', ], }, ]; \ No newline at end of file diff --git a/package.json b/package.json index 52d3ee4..4fb0616 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "docs/demo-recording.md", "docs/assets/apeironcode-demo.gif", "docs/assets/apeironcode-demo.txt", + "docs/fix-code.md", "docs/getting-started.md", "docs/github.md", "docs/history.md", diff --git a/src/agent/filePlanParser.ts b/src/agent/filePlanParser.ts new file mode 100644 index 0000000..5aa02e7 --- /dev/null +++ b/src/agent/filePlanParser.ts @@ -0,0 +1,108 @@ +import { + FilePlanSchema, + buildFilePlanCorrectionText, + formatZodIssues, + validateFilePlanSemantics, + type FilePlanSemanticOptions, +} from './filePlanSchema.js'; +import { + coercePlanShape, + extractBalancedObject, + extractBalancedObjects, + extractFencedJson, + repairLooseJson, + unwrapToolCallEnvelope, +} from './filePlanRepair.js'; +import type {FilePlan} from './filePlanProtocol.js'; + +export type ParseOptions = FilePlanSemanticOptions; + +export type FilePlanParseStage = 'json' | 'schema' | 'semantic'; + +export type ParseResult = + | {ok: true; plan: FilePlan; strategy: string} + | {ok: false; stage: FilePlanParseStage; errors: string[]; correctionText: string}; + +/** + * Yield candidate JSON strings from raw model output, cheapest/most-likely + * first: the raw text, a fenced block, the largest balanced object, and tolerant + * repairs of each. + */ +const candidateJsonStrings = (input: string): Array<{strategy: string; text: string}> => { + const trimmed = input.trim(); + const candidates: Array<{strategy: string; text: string}> = [{strategy: 'direct', text: trimmed}]; + + const fenced = extractFencedJson(input); + if (fenced) candidates.push({strategy: 'fenced', text: fenced}); + + // All top-level balanced objects, largest first (the real plan tends to be the + // largest), so prose like `{ not json }` before the plan does not win. + const balanced = [...extractBalancedObjects(input)].sort((a, b) => b.length - a.length); + for (const text of balanced) candidates.push({strategy: 'balanced', text}); + + const fencedBalanced = fenced ? extractBalancedObject(fenced) : undefined; + if (fencedBalanced) candidates.push({strategy: 'fenced-balanced', text: fencedBalanced}); + + // Tolerant repairs of each distinct candidate. + const repaired: Array<{strategy: string; text: string}> = []; + for (const candidate of candidates) { + const fixed = repairLooseJson(candidate.text); + if (fixed && fixed !== candidate.text) { + repaired.push({strategy: `${candidate.strategy}+repair`, text: fixed}); + } + } + return [...candidates, ...repaired]; +}; + +/** + * Robustly parse a model's file-plan response (Phase 20B, Task A). + * + * Strategy order: direct JSON → fenced block → largest balanced object → + * tolerant repair (comments/trailing commas/smart quotes) → tool-call envelope + * unwrap → shape coercion → zod schema → semantic safety validation. No single + * strict `JSON.parse` failure kills the workflow, and failures return exact, + * model-facing validation errors plus a ready-to-send correction prompt. + */ +export const parseFilePlanResponse = (input: string, options: ParseOptions = {}): ParseResult => { + if (!input || !input.trim()) { + const errors = ['empty response: expected a JSON file plan']; + return {ok: false, stage: 'json', errors, correctionText: buildFilePlanCorrectionText(errors)}; + } + + let parsedAny: {value: unknown; strategy: string} | undefined; + for (const candidate of candidateJsonStrings(input)) { + try { + parsedAny = {value: JSON.parse(candidate.text), strategy: candidate.strategy}; + break; + } catch { + // try next strategy + } + } + + if (!parsedAny) { + const errors = ['response was not valid JSON after fence/balance/repair extraction']; + return {ok: false, stage: 'json', errors, correctionText: buildFilePlanCorrectionText(errors)}; + } + + const coerced = coercePlanShape(unwrapToolCallEnvelope(parsedAny.value)); + + const schemaResult = FilePlanSchema.safeParse(coerced); + if (!schemaResult.success) { + const errors = formatZodIssues(schemaResult.error); + return {ok: false, stage: 'schema', errors, correctionText: buildFilePlanCorrectionText(errors)}; + } + + const semanticErrors = validateFilePlanSemantics(schemaResult.data, options); + if (semanticErrors.length > 0) { + return { + ok: false, + stage: 'semantic', + errors: semanticErrors, + correctionText: buildFilePlanCorrectionText(semanticErrors), + }; + } + + // schemaResult.data is structurally a FilePlan (patch is an extra optional field). + const plan: FilePlan = schemaResult.data; + return {ok: true, plan, strategy: parsedAny.strategy}; +}; diff --git a/src/agent/filePlanProtocol.ts b/src/agent/filePlanProtocol.ts index 7f07911..4feb3e5 100644 --- a/src/agent/filePlanProtocol.ts +++ b/src/agent/filePlanProtocol.ts @@ -1,6 +1,7 @@ import path from 'node:path'; import type {CodingIntent} from './codingIntent.js'; +import {parseFilePlanResponse as robustParseFilePlanResponse} from './filePlanParser.js'; export type FilePlanOperation = 'create' | 'overwrite' | 'modify' | 'delete' | 'rename'; @@ -9,6 +10,8 @@ export interface FilePlanFileChange { from?: string; operation: FilePlanOperation; path: string; + /** Optional unified-diff patch (Phase 20B). Reserved for patch-based edits. */ + patch?: string; } export interface FilePlanCommand { @@ -67,45 +70,6 @@ export const buildPremiumUiDirective = (): string => const SECRET_RE = /\b(?:api[_-]?key|secret|token|password)\s*[:=]\s*["']?[a-z0-9_./+=-]{12,}/iu; const BINARY_EXT_RE = /\.(?:png|jpe?g|gif|webp|ico|pdf|zip|gz|tar|mp4|mov|wasm|woff2?|ttf)$/iu; -const normalizePlan = (value: unknown): FilePlan | null => { - if (!value || typeof value !== 'object' || Array.isArray(value)) return null; - const obj = value as Record; - if (typeof obj.summary !== 'string') return null; - if (!Array.isArray(obj.files) || !Array.isArray(obj.commands) || !Array.isArray(obj.validation)) return null; - const files: FilePlanFileChange[] = []; - for (const entry of obj.files) { - if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null; - const file = entry as Record; - if (typeof file.path !== 'string' || typeof file.operation !== 'string') return null; - if (!['create', 'overwrite', 'modify', 'delete', 'rename'].includes(file.operation)) return null; - files.push({ - content: typeof file.content === 'string' ? file.content : undefined, - from: typeof file.from === 'string' ? file.from : undefined, - operation: file.operation as FilePlanOperation, - path: file.path, - }); - } - const commands: FilePlanCommand[] = []; - for (const entry of obj.commands) { - if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null; - const command = entry as Record; - if (typeof command.command !== 'string' || typeof command.reason !== 'string') return null; - commands.push({command: command.command, reason: command.reason}); - } - const validation = obj.validation.every((item) => typeof item === 'string') - ? obj.validation - : null; - return validation ? {commands, files, summary: obj.summary, validation} : null; -}; - -const extractJson = (text: string): string => { - const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/iu); - if (fenced?.[1]) return fenced[1].trim(); - const first = text.indexOf('{'); - const last = text.lastIndexOf('}'); - return first >= 0 && last > first ? text.slice(first, last + 1) : text.trim(); -}; - export function buildFilePlanPrompt( intent: CodingIntent, workspaceSnapshot: string, @@ -138,15 +102,16 @@ export function buildFilePlanPrompt( } export function parseFilePlanResponse(text: string): FilePlanParseResult { - try { - const parsed = JSON.parse(extractJson(text)) as unknown; - const plan = normalizePlan(parsed); - if (!plan) return {error: 'File plan JSON did not match the required shape.', ok: false}; - return {ok: true, plan}; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return {error: `Invalid file plan JSON: ${message}`, ok: false}; - } + // Delegate to the robust multi-strategy parser (Phase 20B, Task A). The + // legacy shape `{ok, plan} | {ok:false, error}` is preserved for existing + // callers; semantic safety checks here stay permissive (allowDelete) because + // executeFilePlan re-validates with the caller's real options. + const result = robustParseFilePlanResponse(text, {allowDelete: true, allowEnv: true}); + if (result.ok) return {ok: true, plan: result.plan}; + const prefix = result.stage === 'json' + ? 'Invalid file plan JSON' + : 'File plan JSON did not match the required shape'; + return {error: `${prefix}: ${result.errors.join('; ')}`, ok: false}; } const isUnsafePath = (cwd: string, filePath: string): boolean => { diff --git a/src/agent/filePlanRepair.ts b/src/agent/filePlanRepair.ts new file mode 100644 index 0000000..8b76abf --- /dev/null +++ b/src/agent/filePlanRepair.ts @@ -0,0 +1,138 @@ +/** + * Low-level text → candidate-JSON extraction and tolerant repair (Phase 20B, + * Task A). These helpers never throw; they return candidate strings/objects for + * the parser to validate. Repair stays conservative — we do not "fix" content in + * ways that could change a plan's meaning. + */ + +/** Extract the body of the first ```json (or bare ```) fenced block, if any. */ +export const extractFencedJson = (text: string): string | undefined => { + const fenced = text.match(/```(?:json|json5)?\s*([\s\S]*?)```/iu); + return fenced?.[1]?.trim(); +}; + +/** + * Collect every top-level balanced `{...}` object substring, respecting string + * literals and escapes. Nested objects are not returned separately. Order is + * left-to-right. + */ +export const extractBalancedObjects = (text: string): string[] => { + const objects: string[] = []; + let depth = 0; + let start = -1; + let inString = false; + let escape = false; + for (let i = 0; i < text.length; i += 1) { + const ch = text[i]!; + if (escape) { + escape = false; + continue; + } + if (ch === '\\') { + if (inString) escape = true; + continue; + } + if (ch === '"') { + inString = !inString; + continue; + } + if (inString) continue; + if (ch === '{') { + if (depth === 0) start = i; + depth += 1; + } else if (ch === '}' && depth > 0) { + depth -= 1; + if (depth === 0 && start >= 0) { + objects.push(text.slice(start, i + 1)); + start = -1; + } + } + } + return objects; +}; + +/** + * The largest top-level balanced object substring, or undefined when none. + */ +export const extractBalancedObject = (text: string): string | undefined => { + const objects = extractBalancedObjects(text); + if (objects.length === 0) return undefined; + return objects.reduce((longest, current) => (current.length > longest.length ? current : longest)); +}; + +/** + * Tolerant cleanup of near-JSON: strip `//` and block comments, remove trailing + * commas, and normalize smart quotes. Returns a new string; never throws. + */ +export const repairLooseJson = (input: string): string => { + let out = input; + // Normalize smart quotes to straight quotes. + out = out.replace(/[“”]/gu, '"').replace(/[‘’]/gu, "'"); + // Strip block comments. + out = out.replace(/\/\*[\s\S]*?\*\//gu, ''); + // Strip line comments that are not inside a string (best-effort: only when the + // `//` is preceded by whitespace, a comma, or a brace/bracket). + out = out.replace(/(^|[\s,{}[\]])\/\/[^\n]*/gu, '$1'); + // Remove trailing commas before } or ]. + out = out.replace(/,(\s*[}\]])/gu, '$1'); + return out.trim(); +}; + +interface JsonObject { + [key: string]: unknown; +} + +const isObject = (value: unknown): value is JsonObject => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** + * Unwrap a known tool-call envelope to the inner plan object. Models sometimes + * wrap the plan as `{tool: "...", arguments: {...}}` / `{input: {...}}` / + * `{file_plan: {...}}` / `{plan: {...}}`. `arguments`/`input` may be a JSON + * string. Returns the inner object, or the value itself when no envelope. + */ +export const unwrapToolCallEnvelope = (value: unknown): unknown => { + if (!isObject(value)) return value; + for (const key of ['file_plan', 'filePlan', 'plan', 'arguments', 'input']) { + if (key in value) { + const inner = value[key]; + if (typeof inner === 'string') { + try { + return JSON.parse(inner) as unknown; + } catch { + return inner; + } + } + if (isObject(inner)) return inner; + } + } + return value; +}; + +/** + * Coerce common "almost right" plan shapes into the canonical object so schema + * validation can succeed: + * - a single file object → wrapped in an array + * - `commands` as a string or array of strings → `{command, reason}` objects + * - missing `commands`/`validation` → empty arrays + */ +export const coercePlanShape = (value: unknown): unknown => { + if (!isObject(value)) return value; + const out: JsonObject = {...value}; + + if (isObject(out.files)) out.files = [out.files]; + + if (out.commands === undefined) out.commands = []; + else if (typeof out.commands === 'string') { + out.commands = [{command: out.commands, reason: ''}]; + } else if (Array.isArray(out.commands)) { + out.commands = (out.commands as unknown[]).map((entry: unknown) => + typeof entry === 'string' ? {command: entry, reason: ''} : entry, + ); + } + + if (out.validation === undefined) out.validation = []; + else if (typeof out.validation === 'string') out.validation = [out.validation]; + + return out; +}; diff --git a/src/agent/filePlanSchema.ts b/src/agent/filePlanSchema.ts new file mode 100644 index 0000000..aa9ab6e --- /dev/null +++ b/src/agent/filePlanSchema.ts @@ -0,0 +1,142 @@ +import path from 'node:path'; +import {z} from 'zod'; + +/** + * Zod schema + semantic validation for file plans (Phase 20B, Task A). + * + * The schema validates the JSON shape; `validateFilePlanSemantics` enforces the + * safety rules (no absolute paths, no `..` escapes, no `.git`/`.env`, operation + * content requirements). Both produce structured, model-facing error strings so + * a malformed plan can be corrected rather than crashing the workflow. + */ + +export const FILE_PLAN_OPERATIONS = ['create', 'overwrite', 'modify', 'delete', 'rename'] as const; + +export const FilePlanOperationSchema = z.enum(FILE_PLAN_OPERATIONS); + +export const FilePlanFileSchema = z.object({ + path: z.string().min(1), + operation: FilePlanOperationSchema, + content: z.string().optional(), + from: z.string().optional(), + patch: z.string().optional(), +}); + +export const FilePlanCommandSchema = z.object({ + command: z.string().min(1), + reason: z.string(), +}); + +export const FilePlanSchema = z.object({ + summary: z.string(), + files: z.array(FilePlanFileSchema), + commands: z.array(FilePlanCommandSchema), + validation: z.array(z.string()), +}); + +export type FilePlanShape = z.infer; + +export interface FilePlanSemanticOptions { + cwd?: string; + /** Allow delete operations (off by default). */ + allowDelete?: boolean; + /** Allow modifying `.env*` files (off by default). */ + allowEnv?: boolean; + /** Max total content bytes. */ + maxContentBytes?: number; +} + +const SECRET_RE = /\b(?:api[_-]?key|secret|token|password)\s*[:=]\s*["']?[a-z0-9_./+=-]{12,}/iu; +const ENV_FILE_RE = /(?:^|\/)\.env(?:\.[\w.-]+)?$/iu; +const GIT_PATH_RE = /(?:^|\/)\.git(?:\/|$)/iu; + +const isAbsoluteOrEscaping = (cwd: string, filePath: string): boolean => { + if (!filePath.trim() || path.isAbsolute(filePath) || filePath.startsWith('~')) return true; + const resolved = path.resolve(cwd, filePath); + const relative = path.relative(cwd, resolved); + return relative === '' || relative.startsWith('..') || path.isAbsolute(relative); +}; + +/** + * Enforce file-plan safety semantics that the JSON schema cannot express. + * Returns an array of model-facing error strings (empty when the plan is safe). + */ +export const validateFilePlanSemantics = ( + plan: FilePlanShape, + options: FilePlanSemanticOptions = {}, +): string[] => { + const cwd = options.cwd ?? process.cwd(); + const maxBytes = options.maxContentBytes ?? 120_000; + const errors: string[] = []; + let contentBytes = 0; + + if (plan.files.length === 0 && plan.commands.length === 0) { + errors.push('plan is empty: provide at least one file change or command'); + } + + plan.files.forEach((file, index) => { + const at = `files[${index}]`; + if (isAbsoluteOrEscaping(cwd, file.path)) { + errors.push(`${at}.path must be a relative path inside the workspace (no absolute paths or "..")`); + } + if (GIT_PATH_RE.test(file.path)) errors.push(`${at}.path must not target the .git directory`); + if (ENV_FILE_RE.test(file.path) && !options.allowEnv) { + errors.push(`${at}.path must not modify .env files (use --allow-env to override)`); + } + + const needsBody = file.operation === 'create' || file.operation === 'overwrite' || file.operation === 'modify'; + if (needsBody && file.content === undefined && file.patch === undefined) { + errors.push(`${at}.content (or .patch) is required for operation=${file.operation}`); + } + if (file.operation === 'delete' && file.content !== undefined) { + errors.push(`${at}.content must not be set for operation=delete`); + } + if (file.operation === 'delete' && !options.allowDelete) { + errors.push(`${at} delete requires explicit intent and is not allowed here`); + } + if (file.operation === 'rename') { + if (!file.from) errors.push(`${at}.from is required for operation=rename`); + if (file.from && isAbsoluteOrEscaping(cwd, file.from)) { + errors.push(`${at}.from must be a relative path inside the workspace`); + } + } + if (typeof file.content === 'string') { + contentBytes += Buffer.byteLength(file.content, 'utf8'); + if (SECRET_RE.test(file.content)) errors.push(`${at}.content appears to contain a secret; remove it`); + } + }); + + if (contentBytes > maxBytes) { + errors.push(`total file content is too large (${contentBytes} > ${maxBytes} bytes)`); + } + + plan.commands.forEach((command, index) => { + if (!command.command.trim()) errors.push(`commands[${index}].command must not be empty`); + if (SECRET_RE.test(command.command)) errors.push(`commands[${index}].command appears to contain a secret`); + }); + + return errors; +}; + +/** + * Flatten zod issues into short model-facing strings like `files[0].content is required`. + */ +export const formatZodIssues = (error: z.ZodError): string[] => + error.issues.map((issue) => { + const at = issue.path.length > 0 ? issue.path.join('.') : '(root)'; + return `${at}: ${issue.message}`; + }); + +/** + * Build the correction prompt body shown to the model when a plan is invalid. + */ +export const buildFilePlanCorrectionText = (errors: string[]): string => + [ + 'Your file plan failed validation:', + ...errors.map((error) => `- ${error}`), + '', + 'Required JSON shape:', + '{"summary":"string","files":[{"path":"relative/path","operation":"create|overwrite|modify|delete|rename","content":"string optional","from":"string optional","patch":"string optional"}],"commands":[{"command":"string","reason":"string"}],"validation":["string"]}', + '', + 'Return corrected JSON only. No prose, no markdown fences.', + ].join('\n'); diff --git a/src/cli/commands/types.ts b/src/cli/commands/types.ts index 41f96bd..3b59a82 100644 --- a/src/cli/commands/types.ts +++ b/src/cli/commands/types.ts @@ -1,4 +1,5 @@ import type {AgentMode} from '../../agent/types.js'; +import type {FixCliOptions} from '../fixHandler.js'; export interface BgTaskCreateOptions { kind?: string; @@ -48,6 +49,7 @@ export interface CliHandlers { cost: (options: CostCliOptions) => Promise; deleteSession: (sessionId: string) => Promise; demo: (scenario?: string) => Promise; + fix: (options: FixCliOptions) => Promise; doctor: (options: DoctorCliOptions) => Promise; debugConfig: () => Promise; debugLogs: (options?: {last?: number}) => Promise; diff --git a/src/cli/fixHandler.ts b/src/cli/fixHandler.ts new file mode 100644 index 0000000..fdb0cb2 --- /dev/null +++ b/src/cli/fixHandler.ts @@ -0,0 +1,115 @@ +import readline from 'node:readline/promises'; + +import {ConfigStore} from '../config/config.js'; +import {providerRegistry} from '../providers/registry.js'; +import type {ModelProvider} from '../providers/types.js'; +import {ApprovalManager} from '../safety/approvals.js'; +import {createDefaultToolRegistry} from '../tools/registry.js'; +import {executeFilePlan} from '../agent/filePlanExecutor.js'; +import type {FilePlan} from '../agent/filePlanProtocol.js'; +import {runRepairLoop, type RepairMode} from '../repair/repairLoop.js'; +import {formatRepairReport} from '../repair/finalReport.js'; +import type {ApprovalBundle} from '../repair/approvalBundle.js'; + +export interface FixCliOptions { + all?: boolean; + safe?: boolean; + untilGreen?: boolean; + dryRun?: boolean; + commit?: boolean; + yes?: boolean; +} + +const resolveMode = (options: FixCliOptions): RepairMode => { + if (options.safe) return 'safe'; + if (options.untilGreen) return 'until-green'; + if (options.all) return 'all'; + return 'normal'; +}; + +const streamProviderText = async ( + provider: ModelProvider, + model: string, + prompt: string, +): Promise => { + let text = ''; + for await (const chunk of provider.stream({ + messages: [{content: prompt, role: 'user'}], + model, + temperature: 0.2, + tools: [], + })) { + if (chunk.type === 'token') text += chunk.token ?? ''; + } + return text; +}; + +/** + * `apeironcode fix` handler (Phase 20B). Owns the repair workflow via + * `runRepairLoop`; the provider only proposes minimal plans. Dry-run never + * constructs a provider or writes anything. Approval is required before changes: + * auto in bypass/trusted mode, otherwise a single bundled y/N prompt. + */ +export const runFixCommand = async (cwd: string, options: FixCliOptions): Promise => { + const configStore = new ConfigStore(cwd); + const resolved = await configStore.load(); + const config = resolved.effective; + const mode = resolveMode(options); + + if (options.commit) { + process.stdout.write('--commit is planned for Phase 20E ship workflow and is not active yet.\n'); + } + + const autoApprove = config.approvalMode === 'bypass' || config.approvalMode === 'trusted'; + + const approve = async (bundle: ApprovalBundle): Promise => { + process.stdout.write(`\n${bundle.body}\n`); + if (autoApprove || options.yes) { + process.stdout.write('Auto-approved (approval mode allows it).\n'); + return true; + } + if (!process.stdin.isTTY) { + process.stdout.write('Non-interactive terminal: declining repair plan (use --yes or set approval mode).\n'); + return false; + } + const rl = readline.createInterface({input: process.stdin, output: process.stdout}); + try { + const answer = (await rl.question('Approve this repair plan? [y/N] ')).trim().toLowerCase(); + return answer === 'y' || answer === 'yes'; + } finally { + rl.close(); + } + }; + + const applyPlan = async ( + plan: FilePlan, + planCwd: string, + ): Promise<{ok: boolean; errors: string[]; filesChanged: string[]}> => { + // The loop already obtained bundled approval, so apply with a bypass manager + // (writes still go through ToolRegistry). + const toolRegistry = createDefaultToolRegistry(); + const result = await executeFilePlan(plan, { + approvalManager: new ApprovalManager('bypass'), + config, + cwd: planCwd, + toolRegistry, + }); + return {ok: result.ok, errors: result.errors, filesChanged: result.filesChanged}; + }; + + // Dry-run never constructs a provider. + const requestPlan = async (prompt: string): Promise => { + const provider = providerRegistry.createActive(config); + return streamProviderText(provider, config.defaultModel, prompt); + }; + + const result = await runRepairLoop( + {cwd, mode, dryRun: Boolean(options.dryRun)}, + {requestPlan, approve, applyPlan}, + ); + + process.stdout.write(`${formatRepairReport(result)}\n`); + if (result.status !== 'green' && result.status !== 'dry-run' && result.status !== 'no-checks') { + process.exitCode = 1; + } +}; diff --git a/src/cli/help.ts b/src/cli/help.ts index 3b73545..2b65b29 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -126,7 +126,29 @@ export const registerWorkflowAndHelp = (program: Command, handlers: CliHandlers) }); }; - workflow('fix', 'fix', 'fix a bug or failing tests (approval-gated)'); + // `fix` runs the Phase 20B repair loop (detect → check → propose → approve → + // apply → rerun), not the generic agent prompt. + program + .command('fix') + .description('detect failures, propose approval-gated fixes, and rerun checks') + .argument('[description...]', 'optional note about what to fix') + .option('--all', 'include build/e2e checks and allow more attempts') + .option('--safe', 'restrict edits to files referenced by errors; max 2 attempts') + .option('--until-green', 'keep attempting until checks pass (up to 10)') + .option('--dry-run', 'detect project and list checks without running or writing') + .option('--commit', '(planned) commit after a successful fix') + .option('--yes', 'approve the repair bundle without prompting') + .action(async (_parts: string[] | undefined, options: Record) => { + await handlers.fix({ + all: options.all, + safe: options.safe, + untilGreen: options.untilGreen, + dryRun: options.dryRun, + commit: options.commit, + yes: options.yes, + }); + }); + workflow('build', 'feature', 'build an app or feature, e.g. build "a todo app"'); workflow('improve', 'refactor', 'improve UI or code quality, e.g. improve "make the UI premium"'); workflow('review', 'review', 'review the current changes for bugs, maintainability, and security'); diff --git a/src/cli/setup/coreHandlers.ts b/src/cli/setup/coreHandlers.ts index 40cae3d..bd4375f 100644 --- a/src/cli/setup/coreHandlers.ts +++ b/src/cli/setup/coreHandlers.ts @@ -5,6 +5,7 @@ import type {AgentMode} from '../../agent/types.js'; import type {TaskPlan} from '../../tasks/types.js'; import type {CostCliOptions, ContextRefreshCliOptions, ConfigCommandKey, ConfigSetOptions, DoctorCliOptions, HistoryCliOptions, ProviderTestCliOptions, RevertCliOptions, RootCliOptions, SearchCliOptions, SessionCliOptions} from '../args.js'; import type {CliHandlers} from '../commands.js'; +import type {FixCliOptions} from '../fixHandler.js'; import type {BootstrapRuntimeContext} from './runtimeContext.js'; import type {ApeironCodeConfig} from '../../config/config.js'; import type {PluginMcpServer} from '../../plugins/types.js'; @@ -199,24 +200,22 @@ async setupReset(options?: {dryRun?: boolean}) { : result.deleted ? 'User setup config removed.' : 'No user setup config found.', ].join('\n') + '\n'); }, +async fix(options: FixCliOptions) { + const {runFixCommand} = await import('../fixHandler.js'); + await runFixCommand(cwd, options); + }, async demo(scenario?: string) { - const {DEMO_SCENARIOS, formatDemoMenu, formatDemoResult, isDemoScenarioId, runDemoScenario} = await import( - '../../demo/index.js' - ); + const {DEMO_SCENARIOS, formatDemoMenu, formatDemoResult, isDemoScenarioId, runDemoScenario} = await import('../../demo/index.js'); if (!scenario) { process.stdout.write(`${formatDemoMenu(DEMO_SCENARIOS)}\n`); return; } if (!isDemoScenarioId(scenario)) { - process.stdout.write( - `Unknown demo "${scenario}". Available: ${DEMO_SCENARIOS.map((s) => s.id).join(', ')}.\n` + - `Run \`apeironcode demo\` to see all scenarios.\n`, - ); + process.stdout.write(`Unknown demo "${scenario}". Available: ${DEMO_SCENARIOS.map((s) => s.id).join(', ')}. Run \`apeironcode demo\` for the menu.\n`); process.exitCode = 1; return; } - const result = await runDemoScenario(scenario); - process.stdout.write(`${formatDemoResult(result)}\n`); + process.stdout.write(`${formatDemoResult(await runDemoScenario(scenario))}\n`); }, async listTools() { const config = await configStore.load(); diff --git a/src/repair/approvalBundle.ts b/src/repair/approvalBundle.ts new file mode 100644 index 0000000..93433b2 --- /dev/null +++ b/src/repair/approvalBundle.ts @@ -0,0 +1,78 @@ +import type {FilePlan} from '../agent/filePlanProtocol.js'; +import {formatIssueLine, type FailureSummary} from './failureClassifier.js'; + +// Commands that must never be auto-approved in the repair bundle. +const HIGH_RISK_PATTERNS: Array<{re: RegExp; label: string}> = [ + {re: /\brm\s+-rf\b/u, label: 'recursive delete (rm -rf)'}, + {re: /\bsudo\b/u, label: 'sudo'}, + {re: /\bchmod\s+-R\b|\bchown\s+-R\b/u, label: 'broad permission change'}, + {re: /\bcurl\b[^\n|]*\|\s*(?:sh|bash)\b/u, label: 'pipe-to-shell'}, + {re: /\bwget\b[^\n|]*\|\s*(?:sh|bash)\b/u, label: 'pipe-to-shell'}, + {re: /\bnpm\s+publish\b/u, label: 'npm publish'}, + {re: /\bgit\s+push\b/u, label: 'git push'}, +]; + +export interface RiskAssessment { + level: 'low' | 'medium' | 'high'; + highRiskCommands: Array<{command: string; reason: string}>; +} + +export const assessPlanRisk = (plan: FilePlan): RiskAssessment => { + const highRiskCommands: Array<{command: string; reason: string}> = []; + for (const command of plan.commands) { + for (const pattern of HIGH_RISK_PATTERNS) { + if (pattern.re.test(command.command)) { + highRiskCommands.push({command: command.command, reason: pattern.label}); + } + } + } + const destructiveFiles = plan.files.some((f) => f.operation === 'delete' || f.operation === 'rename'); + const manyFiles = plan.files.length > 8; + let level: RiskAssessment['level'] = 'low'; + if (highRiskCommands.length > 0 || (destructiveFiles && manyFiles)) level = 'high'; + else if (destructiveFiles || plan.commands.length > 0 || manyFiles) level = 'medium'; + return {level, highRiskCommands}; +}; + +export interface ApprovalBundle { + title: string; + body: string; + risk: RiskAssessment; +} + +/** + * Build a single bundled approval preview for a repair attempt (Phase 20B, + * Task G): the detected issues, the files to change, and the commands to rerun — + * one approval, not one-per-file. + */ +export const buildApprovalBundle = ( + failure: FailureSummary, + plan: FilePlan, +): ApprovalBundle => { + const risk = assessPlanRisk(plan); + const lines: string[] = []; + lines.push('ApeironCode wants to fix detected failures:'); + lines.push(''); + lines.push('Issues:'); + for (const issue of failure.issues.slice(0, 6)) lines.push(`- ${formatIssueLine(issue)}`); + lines.push(''); + lines.push('Files:'); + for (const file of plan.files) { + const from = file.operation === 'rename' && file.from ? ` (from ${file.from})` : ''; + lines.push(`- ${file.operation} ${file.path}${from}`); + } + if (plan.files.length === 0) lines.push('- (no file changes)'); + if (plan.commands.length > 0) { + lines.push(''); + lines.push('Commands after change:'); + for (const command of plan.commands) lines.push(`- ${command.command}`); + } + if (risk.highRiskCommands.length > 0) { + lines.push(''); + lines.push('HIGH-RISK commands (blocked unless explicitly approved):'); + for (const hr of risk.highRiskCommands) lines.push(`- ${hr.command} — ${hr.reason}`); + } + lines.push(''); + lines.push(`Risk: ${risk.level}`); + return {title: 'Approve repair plan', body: lines.join('\n'), risk}; +}; diff --git a/src/repair/checkRunner.ts b/src/repair/checkRunner.ts new file mode 100644 index 0000000..6a1d763 --- /dev/null +++ b/src/repair/checkRunner.ts @@ -0,0 +1,73 @@ +import {execa} from 'execa'; + +import type {CheckCommand, CheckResult} from './types.js'; + +const MAX_OUTPUT_CHARS = 8_000; + +const SECRET_PATTERNS: RegExp[] = [ + /\b(sk-[A-Za-z0-9]{8,})\b/gu, + /\b(ghp_[A-Za-z0-9]{8,})\b/gu, + /\b(github_pat_[A-Za-z0-9_]{8,})\b/gu, + /\b((?:AKIA|ASIA)[A-Z0-9]{8,})\b/gu, + /(-----BEGIN [A-Z ]*PRIVATE KEY-----)/gu, +]; + +/** Redact common secret token shapes from command output before reuse. */ +export const redactOutput = (text: string): string => { + let out = text; + for (const pattern of SECRET_PATTERNS) out = out.replace(pattern, '[redacted]'); + // Redact `KEY=value`-style secret assignments. + out = out.replace(/((?:API_KEY|SECRET|TOKEN|PASSWORD)\s*[:=]\s*)\S+/giu, '$1[redacted]'); + return out; +}; + +/** Truncate output to a safe size, keeping the head and tail (errors often at the end). */ +export const truncateOutput = (text: string, max = MAX_OUTPUT_CHARS): string => { + if (text.length <= max) return text; + const head = text.slice(0, Math.floor(max * 0.4)); + const tail = text.slice(text.length - Math.floor(max * 0.6)); + return `${head}\n…[${text.length - max} chars omitted]…\n${tail}`; +}; + +export interface RunCheckOptions { + cwd: string; + signal?: AbortSignal; + timeoutMs?: number; +} + +/** + * Run a single check command and capture a redacted, truncated combined output. + * Never throws on non-zero exit; a missing binary is reported as `ok:false`. + */ +export const runCheck = async ( + check: CheckCommand, + options: RunCheckOptions, +): Promise => { + try { + const result = await execa(check.command, { + cwd: options.cwd, + shell: true, + reject: false, + all: true, + timeout: options.timeoutMs ?? 120_000, + signal: options.signal, + }); + const combined = redactOutput(truncateOutput(result.all ?? `${result.stdout}\n${result.stderr}`)); + return { + kind: check.kind, + command: check.command, + ok: result.exitCode === 0, + exitCode: result.exitCode ?? null, + output: combined, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + kind: check.kind, + command: check.command, + ok: false, + exitCode: null, + output: redactOutput(truncateOutput(message)), + }; + } +}; diff --git a/src/repair/checkpoint.ts b/src/repair/checkpoint.ts new file mode 100644 index 0000000..2c0e0e1 --- /dev/null +++ b/src/repair/checkpoint.ts @@ -0,0 +1,77 @@ +import {promises as fs} from 'node:fs'; +import path from 'node:path'; + +import type {FilePlan} from '../agent/filePlanProtocol.js'; + +const CHECKPOINT_ROOT = '.apeironcode/checkpoints'; +const MAX_SNAPSHOT_BYTES = 1_000_000; + +export interface FileSnapshot { + path: string; + /** Prior content, or null if the file did not exist (so rollback can delete it). */ + content: string | null; +} + +export interface Checkpoint { + id: string; + dir: string; + snapshots: FileSnapshot[]; +} + +const readIfExists = async (abs: string): Promise => { + try { + const stat = await fs.stat(abs); + if (stat.size > MAX_SNAPSHOT_BYTES) return null; // skip huge files + return await fs.readFile(abs, 'utf8'); + } catch { + return null; + } +}; + +/** + * Snapshot the files a plan will modify/delete/rename before applying it + * (Phase 20B, Task H). Stored under `.apeironcode/checkpoints/` (gitignored). + * Secrets are not specially included beyond the file's own content, and huge + * files are skipped. + */ +export const createCheckpoint = async (cwd: string, plan: FilePlan): Promise => { + const id = `ckpt-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`; + const dir = path.join(cwd, CHECKPOINT_ROOT, id); + await fs.mkdir(dir, {recursive: true}); + + const targets = new Set(); + for (const file of plan.files) { + targets.add(file.path); + if (file.operation === 'rename' && file.from) targets.add(file.from); + } + + const snapshots: FileSnapshot[] = []; + for (const rel of targets) { + const content = await readIfExists(path.resolve(cwd, rel)); + snapshots.push({path: rel, content}); + } + await fs.writeFile(path.join(dir, 'manifest.json'), JSON.stringify({id, snapshots}, null, 2), 'utf8'); + return {id, dir, snapshots}; +}; + +/** + * Restore files to their snapshotted state. Files that did not exist before are + * removed. Used internally after a failed apply to avoid partial corrupt state. + */ +export const rollbackCheckpoint = async (cwd: string, checkpoint: Checkpoint): Promise => { + const restored: string[] = []; + for (const snapshot of checkpoint.snapshots) { + const abs = path.resolve(cwd, snapshot.path); + if (snapshot.content === null) { + await fs.rm(abs, {force: true}); + } else { + await fs.mkdir(path.dirname(abs), {recursive: true}); + await fs.writeFile(abs, snapshot.content, 'utf8'); + } + restored.push(snapshot.path); + } + return restored; +}; + +export const checkpointRelativeDir = (cwd: string, checkpoint: Checkpoint): string => + path.relative(cwd, checkpoint.dir) || checkpoint.dir; diff --git a/src/repair/commandDetector.ts b/src/repair/commandDetector.ts new file mode 100644 index 0000000..c1eb184 --- /dev/null +++ b/src/repair/commandDetector.ts @@ -0,0 +1,68 @@ +import type {CheckCommand, CheckKind, PackageManager, ProjectInfo} from './types.js'; + +const RUN_PREFIX: Record = { + npm: 'npm run', + pnpm: 'pnpm', + yarn: 'yarn', + bun: 'bun run', +}; + +const TEST_PREFIX: Record = { + npm: 'npm test', + pnpm: 'pnpm test', + yarn: 'yarn test', + bun: 'bun test', +}; + +const runScript = (pm: PackageManager, script: string): string => + script === 'test' ? TEST_PREFIX[pm] : `${RUN_PREFIX[pm]} ${script}`; + +// Order in which checks run. e2e only when explicitly requested. +const CHECK_ORDER: CheckKind[] = ['typecheck', 'lint', 'test', 'build', 'e2e']; + +export interface DetectCommandsOptions { + /** Include slower checks (build/e2e). Set by `--all` / `--until-green`. */ + includeAll?: boolean; +} + +/** + * Build the ordered list of validation commands to run for a project. Uses the + * detected scripts; `build`/`e2e` are only included when `includeAll` is set. + * Returns an empty list when no runnable checks exist. + */ +export const detectCheckCommands = ( + info: ProjectInfo, + options: DetectCommandsOptions = {}, +): CheckCommand[] => { + const commands: CheckCommand[] = []; + for (const kind of CHECK_ORDER) { + if ((kind === 'build' || kind === 'e2e') && !options.includeAll) continue; + const script = info.scripts[kind]; + if (!script) continue; + commands.push({ + kind, + command: runScript(info.packageManager, script), + reason: `run ${kind} (${script})`, + }); + } + return commands; +}; + +/** + * Decide whether `install` should run before checks: only when a package.json + * exists but node_modules is missing. Returns the install command or undefined. + */ +export const detectInstallCommand = (info: ProjectInfo): CheckCommand | undefined => { + if (!info.hasPackageJson || info.hasNodeModules) return undefined; + const installCommands: Record = { + npm: 'npm install', + pnpm: 'pnpm install', + yarn: 'yarn install', + bun: 'bun install', + }; + return { + kind: 'install', + command: installCommands[info.packageManager], + reason: 'install dependencies (node_modules missing)', + }; +}; diff --git a/src/repair/contextBuilder.ts b/src/repair/contextBuilder.ts new file mode 100644 index 0000000..6475ae0 --- /dev/null +++ b/src/repair/contextBuilder.ts @@ -0,0 +1,95 @@ +import {promises as fs} from 'node:fs'; +import path from 'node:path'; + +import {redactOutput, truncateOutput} from './checkRunner.js'; +import type {FailureSummary} from './failureClassifier.js'; +import type {CheckResult, ProjectInfo} from './types.js'; + +export interface ContextFile { + path: string; + content: string; + reason: string; +} + +export interface RepairContextPacket { + files: ContextFile[]; + failingCommand: string; + errorExcerpt: string; + project: string; + /** Human-readable explanation of why each file was selected. */ + trace: string[]; +} + +const CONFIG_CANDIDATES = ['tsconfig.json', 'vite.config.ts', 'vite.config.js', 'next.config.js', 'next.config.mjs', '.eslintrc.json', 'eslint.config.js']; +const MAX_FILE_BYTES = 16_000; +const ENV_RE = /(?:^|\/)\.env(?:\.|$)/u; + +const readFileSafe = async (cwd: string, rel: string): Promise => { + if (ENV_RE.test(rel)) return undefined; // never include .env in context + try { + const content = await fs.readFile(path.join(cwd, rel), 'utf8'); + return redactOutput(truncateOutput(content, MAX_FILE_BYTES)); + } catch { + return undefined; + } +}; + +// Guess a related test file for a source file, and vice versa. +const relatedCandidates = (file: string): string[] => { + const ext = path.extname(file); + const base = file.slice(0, -ext.length); + if (/\.(test|spec)$/u.test(base)) { + return [base.replace(/\.(test|spec)$/u, '') + ext, base.replace(/\.(test|spec)$/u, '') + '.ts']; + } + return [`${base}.test${ext}`, `${base}.spec${ext}`, base.replace(/^src\//u, 'tests/') + `.test${ext}`]; +}; + +/** + * Build a focused, secret-free context packet for one repair attempt + * (Phase 20B, Task E). Includes package.json, relevant config, the failing + * file(s) and related test files, the failing command, and a short error + * excerpt — never the whole repo and never `.env`. + */ +export const buildRepairContext = async ( + info: ProjectInfo, + failure: FailureSummary, + failed: CheckResult, +): Promise => { + const files: ContextFile[] = []; + const trace: string[] = []; + const added = new Set(); + + const add = async (rel: string, reason: string): Promise => { + if (added.has(rel)) return; + const content = await readFileSafe(info.cwd, rel); + if (content === undefined) return; + added.add(rel); + files.push({path: rel, content, reason}); + trace.push(`${rel} (${reason})`); + }; + + if (info.hasPackageJson) await add('package.json', 'project manifest and scripts'); + for (const candidate of CONFIG_CANDIDATES) { + if (files.length >= 6) break; + await add(candidate, 'project configuration'); + } + + for (const file of failure.files) { + await add(file, 'file referenced by the error'); + for (const related of relatedCandidates(file)) await add(related, 'related test/source file'); + } + + return { + files, + failingCommand: failed.command, + errorExcerpt: redactOutput(truncateOutput(failed.output, 3_000)), + project: info.framework, + trace, + }; +}; + +/** Render the "Context selected: … because …" explanation for reports. */ +export const formatContextTrace = (packet: RepairContextPacket): string => + packet.files.length === 0 + ? 'Context selected: none (no readable files found)' + : `Context selected: ${packet.files.map((f) => f.path).join(', ')}`; diff --git a/src/repair/errorParser.ts b/src/repair/errorParser.ts new file mode 100644 index 0000000..d21b564 --- /dev/null +++ b/src/repair/errorParser.ts @@ -0,0 +1,135 @@ +import {redactOutput} from './checkRunner.js'; +import type {CheckResult, RepairIssue, RepairIssueKind} from './types.js'; + +const shortExcerpt = (text: string, max = 600): string => { + const redacted = redactOutput(text).trim(); + return redacted.length <= max ? redacted : `${redacted.slice(0, max)}…`; +}; + +// TypeScript: `src/foo.ts(12,5): error TS2304: Cannot find name 'x'.` +const TS_ERROR_RE = /(?[^\s(]+)\((?\d+),(?\d+)\):\s*error\s+TS\d+:\s*(?.+)/u; +// ESLint stylish: a file path line followed by ` 12:5 error msg rule`. +const ESLINT_LOC_RE = /^\s*(?\d+):(?\d+)\s+error\s+(?.+?)(?:\s{2,}[\w./-]+)?\s*$/u; +// Vitest/Jest failing test names. +const TEST_FAIL_RE = /(?:^|\s)(?:×|✕|FAIL|✗)\s+(?.+?)(?:\s+\d+\s*ms)?\s*$/u; +// Module not found. +const MODULE_NOT_FOUND_RE = + /(?:Cannot find module|Module not found|ERR_MODULE_NOT_FOUND).*?['"]?(?[@\w./-]+)['"]?/u; +const SYNTAX_ERR_RE = /SyntaxError:\s*(?.+)/u; + +const firstMatch = (lines: string[], re: RegExp): RegExpMatchArray | undefined => { + for (const line of lines) { + const m = line.match(re); + if (m) return m; + } + return undefined; +}; + +/** + * Parse a failed check's output into structured issues (Phase 20B, Task D). + * Prefers the first actionable root cause, groups duplicates, keeps excerpts + * short, and redacts secrets. Returns at least one issue for a failed check. + */ +export const parseCheckFailure = (result: CheckResult): RepairIssue[] => { + const lines = result.output.split('\n'); + const issues: RepairIssue[] = []; + const seen = new Set(); + + const push = (issue: RepairIssue): void => { + const key = `${issue.kind}:${issue.file ?? ''}:${issue.line ?? ''}:${issue.message}`; + if (seen.has(key)) return; + seen.add(key); + issues.push(issue); + }; + + // Module-not-found and syntax errors are high-confidence root causes; surface first. + const moduleMatch = firstMatch(lines, MODULE_NOT_FOUND_RE); + if (moduleMatch?.groups?.module) { + push({ + kind: 'module-not-found', + message: `Cannot find module '${moduleMatch.groups.module}'`, + command: result.command, + rawExcerpt: shortExcerpt(moduleMatch[0]), + confidence: 'high', + }); + } + + const syntaxMatch = firstMatch(lines, SYNTAX_ERR_RE); + if (syntaxMatch?.groups?.message) { + push({ + kind: 'syntax', + message: `SyntaxError: ${syntaxMatch.groups.message}`, + command: result.command, + rawExcerpt: shortExcerpt(syntaxMatch[0]), + confidence: 'high', + }); + } + + // TypeScript errors with file/line/column. + for (const line of lines) { + const m = line.match(TS_ERROR_RE); + if (m?.groups) { + push({ + kind: 'typecheck', + file: m.groups.file, + line: Number(m.groups.line), + column: Number(m.groups.col), + message: m.groups.message!.trim(), + command: result.command, + rawExcerpt: shortExcerpt(line), + confidence: 'high', + }); + } + } + + // ESLint errors: track the most recent file header line. + let eslintFile: string | undefined; + for (const line of lines) { + if (/^(?:\/|\.\/|[A-Za-z]:\\|[\w.-]+\/).*\.\w+\s*$/u.test(line.trim()) && !line.includes(':')) { + eslintFile = line.trim(); + continue; + } + const m = line.match(ESLINT_LOC_RE); + if (m?.groups && result.kind === 'lint') { + push({ + kind: 'lint', + file: eslintFile, + line: Number(m.groups.line), + column: Number(m.groups.col), + message: m.groups.message!.trim(), + command: result.command, + rawExcerpt: shortExcerpt(line), + confidence: eslintFile ? 'medium' : 'low', + }); + } + } + + // Failing test names. + if (result.kind === 'test') { + for (const line of lines) { + const m = line.match(TEST_FAIL_RE); + if (m?.groups?.name && !/\d+\s+(?:passed|failed)/u.test(m.groups.name)) { + push({ + kind: 'test', + message: `Failing test: ${m.groups.name.trim()}`, + command: result.command, + rawExcerpt: shortExcerpt(line), + confidence: 'medium', + }); + } + } + } + + if (issues.length === 0) { + const kind: RepairIssueKind = result.kind === 'install' ? 'dependency' : (result.kind as RepairIssueKind); + push({ + kind: ['typecheck', 'lint', 'test', 'build'].includes(result.kind) ? kind : 'unknown', + message: `${result.command} failed (exit ${result.exitCode ?? 'n/a'})`, + command: result.command, + rawExcerpt: shortExcerpt(result.output.split('\n').slice(-12).join('\n')), + confidence: 'low', + }); + } + + return issues; +}; diff --git a/src/repair/failureClassifier.ts b/src/repair/failureClassifier.ts new file mode 100644 index 0000000..95c04a5 --- /dev/null +++ b/src/repair/failureClassifier.ts @@ -0,0 +1,44 @@ +import type {RepairIssue} from './types.js'; + +export interface FailureSummary { + /** The single most actionable issue to fix first. */ + primary: RepairIssue; + issues: RepairIssue[]; + /** Distinct files referenced by the issues (for context selection). */ + files: string[]; +} + +const CONFIDENCE_RANK = {high: 3, medium: 2, low: 1} as const; +// Prefer root-cause kinds first. +const KIND_RANK: Record = { + syntax: 6, + 'module-not-found': 5, + dependency: 4, + typecheck: 3, + test: 2, + build: 1, + lint: 1, + unknown: 0, +}; + +/** + * Rank parsed issues to pick the first actionable root cause and collect the + * files involved. Higher kind rank and confidence win; a known file breaks ties. + */ +export const classifyFailures = (issues: RepairIssue[]): FailureSummary | undefined => { + if (issues.length === 0) return undefined; + const sorted = [...issues].sort((a, b) => { + const kind = KIND_RANK[b.kind] - KIND_RANK[a.kind]; + if (kind !== 0) return kind; + const conf = CONFIDENCE_RANK[b.confidence] - CONFIDENCE_RANK[a.confidence]; + if (conf !== 0) return conf; + return (b.file ? 1 : 0) - (a.file ? 1 : 0); + }); + const files = [...new Set(issues.map((issue) => issue.file).filter((f): f is string => Boolean(f)))]; + return {primary: sorted[0]!, issues: sorted, files}; +}; + +export const formatIssueLine = (issue: RepairIssue): string => { + const loc = issue.file ? ` in ${issue.file}${issue.line ? `:${issue.line}` : ''}` : ''; + return `[${issue.kind}] ${issue.message}${loc}`; +}; diff --git a/src/repair/finalReport.ts b/src/repair/finalReport.ts new file mode 100644 index 0000000..65e864d --- /dev/null +++ b/src/repair/finalReport.ts @@ -0,0 +1,96 @@ +import type {CheckCommand, CheckResult, ProjectInfo} from './types.js'; + +export type RepairStatus = + | 'green' + | 'no-checks' + | 'gave-up' + | 'rejected' + | 'invalid-plan' + | 'dry-run'; + +export interface RepairAttemptRecord { + attempt: number; + failingCommand: string; + primaryIssue: string; + contextFiles: string[]; + planApplied: boolean; + rolledBack: boolean; + blockedCommands: string[]; + note?: string; +} + +export interface RepairLoopResult { + project: ProjectInfo; + plannedChecks: CheckCommand[]; + dryRun: boolean; + attempts: RepairAttemptRecord[]; + finalChecks: CheckResult[]; + status: RepairStatus; + checkpoints: string[]; + summary: string; +} + +const STATUS_LABEL: Record = { + green: 'All selected checks pass.', + 'no-checks': 'No runnable checks were detected.', + 'gave-up': 'Stopped after the repair attempt limit without going green.', + rejected: 'Stopped: approval was declined; no changes were applied.', + 'invalid-plan': 'Stopped: the model returned an invalid plan after correction; no changes were applied.', + 'dry-run': 'Dry run: detected project and planned checks; nothing was run or changed.', +}; + +/** + * Format the final repair report for `apeironcode fix` (Phase 20B). Honest about + * what was done — never claims green unless checks actually pass. + */ +export const formatRepairReport = (result: RepairLoopResult): string => { + const lines: string[] = []; + lines.push('ApeironCode fix'); + lines.push(''); + lines.push(`Project: ${result.project.framework} · ${result.project.packageManager}`); + + lines.push(''); + lines.push(result.plannedChecks.length > 0 ? 'Planned checks:' : 'Planned checks: none'); + for (const check of result.plannedChecks) lines.push(` • ${check.command}`); + + if (result.dryRun) { + lines.push(''); + lines.push(STATUS_LABEL['dry-run']); + return lines.join('\n'); + } + + if (result.attempts.length > 0) { + lines.push(''); + lines.push('Repair attempts:'); + for (const attempt of result.attempts) { + lines.push(` ${attempt.attempt}. ${attempt.failingCommand} — ${attempt.primaryIssue}`); + if (attempt.contextFiles.length > 0) { + lines.push(` Context selected: ${attempt.contextFiles.join(', ')}`); + } + lines.push( + ` ${attempt.planApplied ? 'applied plan' : 'no changes applied'}` + + `${attempt.rolledBack ? ' (rolled back after failed apply)' : ''}`, + ); + if (attempt.blockedCommands.length > 0) { + lines.push(` Blocked high-risk commands: ${attempt.blockedCommands.join(', ')}`); + } + if (attempt.note) lines.push(` ${attempt.note}`); + } + } + + lines.push(''); + lines.push('Final check status:'); + for (const check of result.finalChecks) { + lines.push(` ${check.ok ? '✓' : '✗'} ${check.command}`); + } + + if (result.checkpoints.length > 0) { + lines.push(''); + lines.push(`Checkpoint(s) created: ${result.checkpoints.join(', ')}`); + lines.push('A rollback command is planned for a future release.'); + } + + lines.push(''); + lines.push(result.summary || STATUS_LABEL[result.status]); + return lines.join('\n'); +}; diff --git a/src/repair/projectDetector.ts b/src/repair/projectDetector.ts new file mode 100644 index 0000000..30a2d43 --- /dev/null +++ b/src/repair/projectDetector.ts @@ -0,0 +1,97 @@ +import {promises as fs} from 'node:fs'; +import path from 'node:path'; + +import type {CheckKind, PackageManager, ProjectFramework, ProjectInfo} from './types.js'; + +const exists = (p: string): Promise => + fs.access(p).then(() => true).catch(() => false); + +const detectPackageManager = async (cwd: string): Promise => { + if (await exists(path.join(cwd, 'pnpm-lock.yaml'))) return 'pnpm'; + if (await exists(path.join(cwd, 'yarn.lock'))) return 'yarn'; + if ((await exists(path.join(cwd, 'bun.lockb'))) || (await exists(path.join(cwd, 'bun.lock')))) return 'bun'; + return 'npm'; +}; + +interface PackageJson { + scripts?: Record; + dependencies?: Record; + devDependencies?: Record; +} + +const readPackageJson = async (cwd: string): Promise => { + try { + return JSON.parse(await fs.readFile(path.join(cwd, 'package.json'), 'utf8')) as PackageJson; + } catch { + return undefined; + } +}; + +const detectFramework = (pkg: PackageJson | undefined, typescript: boolean): ProjectFramework => { + const deps = {...pkg?.dependencies, ...pkg?.devDependencies}; + if (deps.next) return 'next'; + if (deps.vite) return 'vite'; + if (deps.react || deps['react-dom']) return 'react'; + if (deps.express) return 'express'; + if (!pkg) return 'unknown'; + // A package with a main/exports and no app framework looks like a library. + if (typescript) return 'typescript'; + return 'node-library'; +}; + +// Map a project's npm scripts onto canonical check kinds by name heuristics. +const SCRIPT_MATCHERS: Array<{kind: CheckKind; re: RegExp}> = [ + {kind: 'typecheck', re: /^(typecheck|type-check|tsc|types)$/iu}, + {kind: 'lint', re: /^(lint|eslint)$/iu}, + {kind: 'test', re: /^(test|tests|test:unit)$/iu}, + {kind: 'build', re: /^(build|compile)$/iu}, + {kind: 'e2e', re: /^(test:e2e|e2e|test:acceptance)$/iu}, +]; + +const detectScripts = (pkg: PackageJson | undefined): Partial> => { + const scripts: Partial> = {}; + if (!pkg?.scripts) return scripts; + for (const name of Object.keys(pkg.scripts)) { + for (const matcher of SCRIPT_MATCHERS) { + if (matcher.re.test(name) && !scripts[matcher.kind]) scripts[matcher.kind] = name; + } + } + return scripts; +}; + +/** + * Detect project type, package manager, framework, and available check scripts. + * Never throws — missing package.json / non-git directories return a usable + * `unknown` result so `apeironcode fix` can degrade gracefully. + */ +export const detectProject = async (cwd: string): Promise => { + const [pkg, isGitRepo, hasNodeModules, hasTsconfig] = await Promise.all([ + readPackageJson(cwd), + exists(path.join(cwd, '.git')), + exists(path.join(cwd, 'node_modules')), + exists(path.join(cwd, 'tsconfig.json')), + ]); + + const typescript = hasTsconfig; + return { + cwd, + isGitRepo, + hasPackageJson: Boolean(pkg), + hasNodeModules, + packageManager: await detectPackageManager(cwd), + framework: detectFramework(pkg, typescript), + scripts: detectScripts(pkg), + typescript, + }; +}; + +/** Format a one-line project summary for reports. */ +export const formatProjectInfo = (info: ProjectInfo): string => { + const scripts = Object.keys(info.scripts); + return [ + `Project: ${info.framework}`, + `package manager: ${info.packageManager}`, + info.hasPackageJson ? `scripts: ${scripts.length ? scripts.join(', ') : 'none'}` : 'no package.json', + info.isGitRepo ? 'git repo' : 'not a git repo', + ].join(' · '); +}; diff --git a/src/repair/repairLoop.ts b/src/repair/repairLoop.ts new file mode 100644 index 0000000..ad3e21c --- /dev/null +++ b/src/repair/repairLoop.ts @@ -0,0 +1,221 @@ +import {parseFilePlanResponse} from '../agent/filePlanParser.js'; +import type {FilePlan} from '../agent/filePlanProtocol.js'; +import {buildApprovalBundle, type ApprovalBundle} from './approvalBundle.js'; +import {createCheckpoint, rollbackCheckpoint, checkpointRelativeDir} from './checkpoint.js'; +import {detectCheckCommands, detectInstallCommand} from './commandDetector.js'; +import {buildRepairContext} from './contextBuilder.js'; +import {classifyFailures} from './failureClassifier.js'; +import {parseCheckFailure} from './errorParser.js'; +import {detectProject} from './projectDetector.js'; +import {buildCorrectionPrompt, buildRepairPrompt} from './repairPlanner.js'; +import {runCheck as defaultRunCheck} from './checkRunner.js'; +import type {RepairAttemptRecord, RepairLoopResult, RepairStatus} from './finalReport.js'; +import type {CheckCommand, CheckResult, ProjectInfo} from './types.js'; + +export type RepairMode = 'normal' | 'safe' | 'all' | 'until-green'; + +export interface RepairLoopDeps { + /** Provider call returning raw model text for a plan/correction prompt. */ + requestPlan: (prompt: string) => Promise; + /** Approval gate for the bundled plan. Returns true to apply. */ + approve: (bundle: ApprovalBundle) => Promise; + /** Apply an approved, validated plan. Returns changed files / errors. */ + applyPlan: (plan: FilePlan, cwd: string) => Promise<{ok: boolean; errors: string[]; filesChanged: string[]}>; + /** Override for tests. */ + detectProjectFn?: (cwd: string) => Promise; + runCheckFn?: typeof defaultRunCheck; +} + +export interface RepairLoopOptions { + cwd: string; + mode: RepairMode; + dryRun?: boolean; + maxAttempts?: number; + maxCorrections?: number; + signal?: AbortSignal; +} + +const DEFAULT_ATTEMPTS: Record = {normal: 3, safe: 2, all: 5, 'until-green': 10}; + +// In safe mode, restrict a plan to files referenced by the failure and forbid +// destructive operations. Returns validation errors (empty = ok). +const safeModeViolations = (plan: FilePlan, allowedFiles: Set): string[] => { + const errors: string[] = []; + for (const file of plan.files) { + if (file.operation === 'delete' || file.operation === 'rename') { + errors.push(`safe mode forbids ${file.operation} (${file.path})`); + } else if (allowedFiles.size > 0 && !allowedFiles.has(file.path)) { + errors.push(`safe mode only allows editing files referenced by the error; ${file.path} is not one of them`); + } + } + return errors; +}; + +const runAllChecks = async ( + checks: CheckCommand[], + cwd: string, + runCheck: typeof defaultRunCheck, + signal?: AbortSignal, +): Promise => { + const results: CheckResult[] = []; + for (const check of checks) results.push(await runCheck(check, {cwd, signal})); + return results; +}; + +/** + * The Phase 20B repair loop MVP. Detects the project, runs checks, asks the + * provider for a minimal plan on failure (with one correction attempt), + * bundles approval, checkpoints, applies, and reruns — up to a mode-based limit. + * Never writes without approval; high-risk commands are stripped and reported. + */ +export const runRepairLoop = async ( + options: RepairLoopOptions, + deps: RepairLoopDeps, +): Promise => { + const detectProjectFn = deps.detectProjectFn ?? detectProject; + const runCheck = deps.runCheckFn ?? defaultRunCheck; + const project = await detectProjectFn(options.cwd); + const includeAll = options.mode === 'all' || options.mode === 'until-green'; + const plannedChecks = detectCheckCommands(project, {includeAll}); + + const base: Omit = { + project, + plannedChecks, + dryRun: Boolean(options.dryRun), + attempts: [], + checkpoints: [], + }; + + if (options.dryRun) { + const install = detectInstallCommand(project); + const summary = install + ? `Dry run. Would also install dependencies first: ${install.command}.` + : 'Dry run. No checks were run and no files were changed.'; + return {...base, status: 'dry-run', summary, finalChecks: []}; + } + + if (plannedChecks.length === 0) { + return { + ...base, + status: 'no-checks', + summary: project.hasPackageJson + ? 'No typecheck/lint/test/build scripts were found in package.json. Nothing to run.' + : 'No package.json and no runnable checks were found. Nothing to run.', + finalChecks: [], + }; + } + + const maxAttempts = options.maxAttempts ?? DEFAULT_ATTEMPTS[options.mode]; + const maxCorrections = options.maxCorrections ?? 1; + const attempts: RepairAttemptRecord[] = []; + const checkpoints: string[] = []; + let status: RepairStatus = 'gave-up'; + + let finalChecks = await runAllChecks(plannedChecks, options.cwd, runCheck, options.signal); + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const failing = finalChecks.find((check) => !check.ok); + if (!failing) { + status = 'green'; + break; + } + + const issues = parseCheckFailure(failing); + const failure = classifyFailures(issues); + if (!failure) { + status = 'gave-up'; + break; + } + + const context = await buildRepairContext(project, failure, failing); + const allowedFiles = new Set(failure.files); + + // Ask provider for a plan, with up to maxCorrections correction passes. + let prompt = buildRepairPrompt(failure, context, {safe: options.mode === 'safe'}); + let plan: FilePlan | undefined; + let lastErrors: string[] = []; + for (let pass = 0; pass <= maxCorrections; pass += 1) { + const raw = await deps.requestPlan(prompt); + const parsed = parseFilePlanResponse(raw, {cwd: options.cwd}); + if (parsed.ok) { + const violations = options.mode === 'safe' ? safeModeViolations(parsed.plan, allowedFiles) : []; + if (violations.length === 0) { + plan = parsed.plan; + break; + } + lastErrors = violations; + prompt = buildCorrectionPrompt(violations); + } else { + lastErrors = parsed.errors; + prompt = buildCorrectionPrompt(parsed.errors); + } + } + + if (!plan) { + attempts.push({ + attempt, failingCommand: failing.command, primaryIssue: failure.primary.message, + contextFiles: context.files.map((f) => f.path), planApplied: false, rolledBack: false, + blockedCommands: [], note: `Invalid plan: ${lastErrors.slice(0, 2).join('; ')}`, + }); + status = 'invalid-plan'; + break; + } + + // Strip high-risk commands; they are blocked from the auto-applied bundle. + const bundle = buildApprovalBundle(failure, plan); + const blocked = bundle.risk.highRiskCommands.map((c) => c.command); + const safePlan: FilePlan = { + ...plan, + commands: plan.commands.filter((c) => !blocked.includes(c.command)), + }; + + const approved = await deps.approve(bundle); + if (!approved) { + attempts.push({ + attempt, failingCommand: failing.command, primaryIssue: failure.primary.message, + contextFiles: context.files.map((f) => f.path), planApplied: false, rolledBack: false, + blockedCommands: blocked, + }); + status = 'rejected'; + break; + } + + const checkpoint = await createCheckpoint(options.cwd, safePlan); + checkpoints.push(checkpointRelativeDir(options.cwd, checkpoint)); + const applied = await deps.applyPlan(safePlan, options.cwd); + let rolledBack = false; + if (!applied.ok) { + await rollbackCheckpoint(options.cwd, checkpoint); + rolledBack = true; + } + + attempts.push({ + attempt, failingCommand: failing.command, primaryIssue: failure.primary.message, + contextFiles: context.files.map((f) => f.path), planApplied: applied.ok, rolledBack, + blockedCommands: blocked, + note: applied.ok ? undefined : `Apply failed: ${applied.errors.slice(0, 1).join('; ')}`, + }); + + finalChecks = await runAllChecks(plannedChecks, options.cwd, runCheck, options.signal); + } + + if (status === 'gave-up' && finalChecks.every((c) => c.ok)) status = 'green'; + + const summaryByStatus: Record = { + green: 'All selected checks pass.', + 'no-checks': 'Nothing to run.', + 'gave-up': `Reached the attempt limit (${maxAttempts}) without going green. Review the remaining failures.`, + rejected: 'Approval was declined; no changes were applied.', + 'invalid-plan': 'The provider returned an invalid plan after correction; no changes were applied. Try demo/local or a stronger provider.', + 'dry-run': 'Dry run.', + }; + + return { + ...base, + attempts, + checkpoints, + status, + finalChecks, + summary: summaryByStatus[status], + }; +}; diff --git a/src/repair/repairPlanner.ts b/src/repair/repairPlanner.ts new file mode 100644 index 0000000..1668e2b --- /dev/null +++ b/src/repair/repairPlanner.ts @@ -0,0 +1,64 @@ +import type {RepairContextPacket} from './contextBuilder.js'; +import type {FailureSummary} from './failureClassifier.js'; +import {formatIssueLine} from './failureClassifier.js'; + +const SCHEMA_LINE = + '{"summary":"string","files":[{"path":"relative/path","operation":"create|overwrite|modify|delete|rename","content":"full file content"}],"commands":[{"command":"string","reason":"string"}],"validation":["string"]}'; + +export interface BuildRepairPromptOptions { + safe?: boolean; +} + +/** + * Build the model prompt asking for a MINIMAL file plan that fixes the primary + * failure. The runtime owns the workflow; the model only proposes a small patch. + */ +export const buildRepairPrompt = ( + failure: FailureSummary, + context: RepairContextPacket, + options: BuildRepairPromptOptions = {}, +): string => { + const fileSections = context.files + .map((file) => `--- ${file.path} (${file.reason}) ---\n${file.content}`) + .join('\n\n'); + + return [ + 'You are a coding agent runtime fixing a failing check. Return ONLY a JSON file plan.', + 'Do not call tools. Do not wrap the plan in tool-call JSON. Do not add prose or markdown.', + '', + `Failing command: ${context.failingCommand}`, + 'Issues:', + ...failure.issues.slice(0, 6).map((issue) => `- ${formatIssueLine(issue)}`), + '', + 'Error output (truncated):', + context.errorExcerpt, + '', + 'Relevant files:', + fileSections || '(no files could be read)', + '', + 'Return this exact JSON shape:', + SCHEMA_LINE, + '', + 'Rules:', + '- Make the smallest change that fixes the failure.', + '- For modify/overwrite include the COMPLETE new file content.', + '- Use relative workspace paths only. Never touch .git or .env.', + options.safe ? '- SAFE MODE: only modify files referenced by the error. No delete, rename, or install.' : '- Include rerun commands only when useful.', + '- Do not include secrets.', + ].join('\n'); +}; + +/** + * Build the short correction prompt (Phase 20B, Task B) shown after an invalid + * plan: only the validation errors plus the schema, asking for corrected JSON. + */ +export const buildCorrectionPrompt = (validationErrors: string[]): string => + [ + 'Your previous file plan was invalid:', + ...validationErrors.map((error) => `- ${error}`), + '', + 'Required JSON shape:', + SCHEMA_LINE, + '', + 'Return corrected JSON only. No prose, no markdown.', + ].join('\n'); diff --git a/src/repair/types.ts b/src/repair/types.ts new file mode 100644 index 0000000..fc7193f --- /dev/null +++ b/src/repair/types.ts @@ -0,0 +1,68 @@ +/** + * Shared types for the Phase 20B repair workflow (`apeironcode fix`). + */ + +export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun'; + +export type ProjectFramework = + | 'next' + | 'vite' + | 'react' + | 'express' + | 'node-library' + | 'typescript' + | 'unknown'; + +export type CheckKind = 'typecheck' | 'lint' | 'test' | 'build' | 'e2e' | 'install'; + +export interface ProjectInfo { + cwd: string; + isGitRepo: boolean; + hasPackageJson: boolean; + hasNodeModules: boolean; + packageManager: PackageManager; + framework: ProjectFramework; + /** Script names present in package.json, by check kind. */ + scripts: Partial>; + /** True when TypeScript is configured (tsconfig present). */ + typescript: boolean; +} + +export interface CheckCommand { + kind: CheckKind; + /** Full shell command, e.g. `npm run typecheck`. */ + command: string; + /** Short reason shown in plans/approvals. */ + reason: string; +} + +export interface CheckResult { + kind: CheckKind; + command: string; + ok: boolean; + exitCode: number | null; + /** Combined stdout+stderr, already truncated/redacted for context use. */ + output: string; + skipped?: boolean; +} + +export type RepairIssueKind = + | 'typecheck' + | 'lint' + | 'test' + | 'build' + | 'dependency' + | 'syntax' + | 'module-not-found' + | 'unknown'; + +export interface RepairIssue { + kind: RepairIssueKind; + file?: string; + line?: number; + column?: number; + message: string; + command: string; + rawExcerpt: string; + confidence: 'low' | 'medium' | 'high'; +} diff --git a/tests/agent/filePlanParser.test.ts b/tests/agent/filePlanParser.test.ts new file mode 100644 index 0000000..49b8e83 --- /dev/null +++ b/tests/agent/filePlanParser.test.ts @@ -0,0 +1,143 @@ +import {describe, expect, it} from 'vitest'; + +import {parseFilePlanResponse} from '../../src/agent/filePlanParser.js'; + +const validPlan = { + summary: 'Add a file', + files: [{path: 'src/a.ts', operation: 'create', content: 'export const a = 1;\n'}], + commands: [{command: 'npm run typecheck', reason: 'verify types'}], + validation: ['typecheck passes'], +}; + +describe('parseFilePlanResponse (robust)', () => { + it('parses direct JSON', () => { + const result = parseFilePlanResponse(JSON.stringify(validPlan)); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.plan.files[0]!.path).toBe('src/a.ts'); + expect(result.strategy).toBe('direct'); + } + }); + + it('parses fenced ```json blocks with surrounding prose', () => { + const text = `Sure! Here is the plan:\n\n\`\`\`json\n${JSON.stringify(validPlan)}\n\`\`\`\nLet me know.`; + const result = parseFilePlanResponse(text); + expect(result.ok).toBe(true); + if (result.ok) expect(['fenced', 'balanced', 'fenced-balanced']).toContain(result.strategy); + }); + + it('extracts the largest balanced object from surrounding text', () => { + const text = `prose { not json } more ${JSON.stringify(validPlan)} trailing`; + const result = parseFilePlanResponse(text); + expect(result.ok).toBe(true); + }); + + it('repairs trailing commas and comments', () => { + const text = `{ + // plan + "summary": "x", + "files": [{"path": "a.ts", "operation": "create", "content": "x",}], + "commands": [], + "validation": [], + }`; + const result = parseFilePlanResponse(text); + expect(result.ok).toBe(true); + }); + + it('unwraps a tool-call envelope (arguments as JSON string)', () => { + const text = JSON.stringify({tool: 'apply_plan', arguments: JSON.stringify(validPlan)}); + const result = parseFilePlanResponse(text); + expect(result.ok).toBe(true); + if (result.ok) expect(result.plan.summary).toBe('Add a file'); + }); + + it('unwraps a file_plan envelope (object)', () => { + const text = JSON.stringify({file_plan: validPlan}); + expect(parseFilePlanResponse(text).ok).toBe(true); + }); + + it('normalizes a single file object into an array', () => { + const text = JSON.stringify({summary: 's', files: validPlan.files[0], commands: [], validation: []}); + const result = parseFilePlanResponse(text); + expect(result.ok).toBe(true); + if (result.ok) expect(result.plan.files).toHaveLength(1); + }); + + it('normalizes missing commands/validation to empty arrays', () => { + const text = JSON.stringify({summary: 's', files: validPlan.files}); + const result = parseFilePlanResponse(text); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.plan.commands).toEqual([]); + expect(result.plan.validation).toEqual([]); + } + }); + + it('normalizes commands given as strings', () => { + const text = JSON.stringify({summary: 's', files: validPlan.files, commands: ['npm test'], validation: []}); + const result = parseFilePlanResponse(text); + expect(result.ok).toBe(true); + if (result.ok) expect(result.plan.commands[0]!.command).toBe('npm test'); + }); + + it('returns exact schema errors for missing create content', () => { + const text = JSON.stringify({summary: 's', files: [{path: 'a.ts', operation: 'create'}], commands: [], validation: []}); + const result = parseFilePlanResponse(text); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.stage).toBe('semantic'); + expect(result.errors.join(' ')).toMatch(/content.*required/i); + expect(result.correctionText).toContain('Return corrected JSON only'); + } + }); + + it('rejects absolute and parent-escaping paths', () => { + const abs = parseFilePlanResponse( + JSON.stringify({summary: 's', files: [{path: '/etc/passwd', operation: 'create', content: 'x'}], commands: [], validation: []}), + ); + expect(abs.ok).toBe(false); + const escape = parseFilePlanResponse( + JSON.stringify({summary: 's', files: [{path: '../outside.ts', operation: 'create', content: 'x'}], commands: [], validation: []}), + ); + expect(escape.ok).toBe(false); + }); + + it('rejects .git and .env targets by default', () => { + const git = parseFilePlanResponse( + JSON.stringify({summary: 's', files: [{path: '.git/config', operation: 'modify', content: 'x'}], commands: [], validation: []}), + ); + expect(git.ok).toBe(false); + const env = parseFilePlanResponse( + JSON.stringify({summary: 's', files: [{path: '.env', operation: 'overwrite', content: 'x'}], commands: [], validation: []}), + ); + expect(env.ok).toBe(false); + const envAllowed = parseFilePlanResponse( + JSON.stringify({summary: 's', files: [{path: '.env', operation: 'overwrite', content: 'x'}], commands: [], validation: []}), + {allowEnv: true}, + ); + expect(envAllowed.ok).toBe(true); + }); + + it('rejects delete with content and allows delete only with allowDelete', () => { + const withContent = parseFilePlanResponse( + JSON.stringify({summary: 's', files: [{path: 'a.ts', operation: 'delete', content: 'x'}], commands: [], validation: []}), + {allowDelete: true}, + ); + expect(withContent.ok).toBe(false); + const blocked = parseFilePlanResponse( + JSON.stringify({summary: 's', files: [{path: 'a.ts', operation: 'delete'}], commands: [], validation: []}), + ); + expect(blocked.ok).toBe(false); + const allowed = parseFilePlanResponse( + JSON.stringify({summary: 's', files: [{path: 'a.ts', operation: 'delete'}], commands: [], validation: []}), + {allowDelete: true}, + ); + expect(allowed.ok).toBe(true); + }); + + it('does not crash on non-JSON garbage', () => { + const result = parseFilePlanResponse('I cannot help with that.'); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.stage).toBe('json'); + }); +}); diff --git a/tests/cli/commands.test.ts b/tests/cli/commands.test.ts index 504faac..70c2bb8 100644 --- a/tests/cli/commands.test.ts +++ b/tests/cli/commands.test.ts @@ -24,6 +24,7 @@ const createHandlers = () => ({ contextWhy: vi.fn(() => Promise.resolve()), deleteSession: vi.fn(() => Promise.resolve()), demo: vi.fn(() => Promise.resolve()), + fix: vi.fn(() => Promise.resolve()), doctor: vi.fn(() => Promise.resolve()), getConfigValue: vi.fn(() => Promise.resolve()), history: vi.fn(() => Promise.resolve()), diff --git a/tests/cli/evalCommands.test.ts b/tests/cli/evalCommands.test.ts index a41ef73..1b076ed 100644 --- a/tests/cli/evalCommands.test.ts +++ b/tests/cli/evalCommands.test.ts @@ -3,7 +3,7 @@ import {describe, expect, it, vi} from 'vitest'; import {buildProgram, type CliHandlers} from '../../src/cli/commands.js'; const handlers = (): CliHandlers => ({ - addPermission: vi.fn(), agentRun: vi.fn(), agentShow: vi.fn(), agents: vi.fn(), checkPermission: vi.fn(), connectorEnv: vi.fn(), connectorList: vi.fn(), contextBudget: vi.fn(), contextExplain: vi.fn(), contextFiles: vi.fn(), contextIndex: vi.fn(), contextMap: vi.fn(), contextRefresh: vi.fn(), contextSymbols: vi.fn(), contextWhy: vi.fn(), continueTask: vi.fn(), cost: vi.fn(), debugConfig: vi.fn(), debugLogs: vi.fn(), debugTokens: vi.fn(), debugTraces: vi.fn(), deleteSession: vi.fn(), demo: vi.fn(), doctor: vi.fn(), evalList: vi.fn(), evalReport: vi.fn(), evalRun: vi.fn(), getConfigValue: vi.fn(), githubActions: vi.fn(), githubCiExplain: vi.fn(), githubIssue: vi.fn(), githubIssueComment: vi.fn(), githubIssueCreate: vi.fn(), githubIssues: vi.fn(), githubPr: vi.fn(), githubPrComment: vi.fn(), githubPrCreate: vi.fn(), githubPrReview: vi.fn(), githubPrSummary: vi.fn(), githubPrs: vi.fn(), githubRepo: vi.fn(), githubStatus: vi.fn(), history: vi.fn(), hookDisable: vi.fn(), hookEnable: vi.fn(), hookEvents: vi.fn(), hookList: vi.fn(), hookShow: vi.fn(), hookTest: vi.fn(), hooks: vi.fn(), listConfig: vi.fn(), listMcp: vi.fn(), listMcpTools: vi.fn(), listPermissions: vi.fn(), listPlugins: vi.fn(), listSessions: vi.fn(), listTools: vi.fn(), lspCache: vi.fn(), lspCacheClear: vi.fn(), lspDefinition: vi.fn(), lspDiagnostics: vi.fn(), lspReferences: vi.fn(), lspRestart: vi.fn(), lspSessions: vi.fn(), lspStatus: vi.fn(), lspStop: vi.fn(), lspSymbols: vi.fn(), memoryClear: vi.fn(), memoryConflicts: vi.fn(), memoryEdit: vi.fn(), memoryForgetSession: vi.fn(), memoryGraph: vi.fn(), memoryLearn: vi.fn(), memoryPrune: vi.fn(), memoryRelated: vi.fn(), memoryReview: vi.fn(), memoryRollback: vi.fn(), memorySearch: vi.fn(), memoryShow: vi.fn(), memorySource: vi.fn(), memoryStale: vi.fn(), memorySuggestionApprove: vi.fn(), memorySuggestionReject: vi.fn(), memorySuggestionShow: vi.fn(), memorySuggestions: vi.fn(), memorySummarize: vi.fn(), memoryWhy: vi.fn(), modelList: vi.fn(), modelRecommend: vi.fn(), ollamaModels: vi.fn(), ollamaPullHint: vi.fn(), ollamaRecommend: vi.fn(), ollamaStatus: vi.fn(), planApprove: vi.fn(), planClear: vi.fn(), planCreate: vi.fn(), planDelete: vi.fn(), planExecute: vi.fn(), planList: vi.fn(), planPause: vi.fn(), planResume: vi.fn(), planRevise: vi.fn(), planShow: vi.fn(), planStatus: vi.fn(), providerDoctor: vi.fn(), providerEnv: vi.fn(), providerFallback: vi.fn(), providerFallbackSet: vi.fn(), providerFallbackSimulate: vi.fn(), providerFallbackTest: vi.fn(), providerList: vi.fn(), providerSetup: vi.fn(), providerTest: vi.fn(), removePermission: vi.fn(), repoMap: vi.fn(), repoSummary: vi.fn(), repoSymbols: vi.fn(), revert: vi.fn(), runRoot: vi.fn(), sandboxDoctor: vi.fn(), sandboxStatus: vi.fn(), search: vi.fn(), securityStatus: vi.fn(), sessionAttach: vi.fn(), sessionDelete: vi.fn(), sessionExport: vi.fn(), sessionList: vi.fn(), sessionLocks: vi.fn(), sessionLogs: vi.fn(), sessionPause: vi.fn(), sessionResume: vi.fn(), sessionRunWorker: vi.fn(), sessionShow: vi.fn(), sessionStart: vi.fn(), sessionStop: vi.fn(), sessionUnlock: vi.fn(), sessions: vi.fn(), setConfigValue: vi.fn(), setup: vi.fn(), setupReset: vi.fn(), setupStatus: vi.fn(), share: vi.fn(), skillBrowser: vi.fn(), skillCreate: vi.fn(), skillDelete: vi.fn(), skillDisable: vi.fn(), skillEnable: vi.fn(), skillExport: vi.fn(), skillGenerate: vi.fn(), skillImport: vi.fn(), skillList: vi.fn(), skillRun: vi.fn(), skillShow: vi.fn(), skillTemplates: vi.fn(), skillTrust: vi.fn(), skillValidate: vi.fn(), skills: vi.fn(), teamApply: vi.fn(), teamArtifact: vi.fn(), teamArtifacts: vi.fn(), teamCockpit: vi.fn(), teamConflicts: vi.fn(), teamDiscard: vi.fn(), teamExport: vi.fn(), teamExportPatch: vi.fn(), teamIgnored: vi.fn(), teamMergePlan: vi.fn(), teamPlan: vi.fn(), teamResolve: vi.fn(), teamReview: vi.fn(), teamRun: vi.fn(), teamRunShow: vi.fn(), teamRuns: vi.fn(), teamValidatePatch: vi.fn(), teamWorkspaceCleanup: vi.fn(), teamWorkspaces: vi.fn(), testMcp: vi.fn(), webFetch: vi.fn(), webResearch: vi.fn(), webSearch: vi.fn(), workflowList: vi.fn(), workflowReport: vi.fn(), workflowRun: vi.fn(), workflowShow: vi.fn(), + addPermission: vi.fn(), agentRun: vi.fn(), agentShow: vi.fn(), agents: vi.fn(), checkPermission: vi.fn(), connectorEnv: vi.fn(), connectorList: vi.fn(), contextBudget: vi.fn(), contextExplain: vi.fn(), contextFiles: vi.fn(), contextIndex: vi.fn(), contextMap: vi.fn(), contextRefresh: vi.fn(), contextSymbols: vi.fn(), contextWhy: vi.fn(), continueTask: vi.fn(), cost: vi.fn(), debugConfig: vi.fn(), debugLogs: vi.fn(), debugTokens: vi.fn(), debugTraces: vi.fn(), deleteSession: vi.fn(), demo: vi.fn(), fix: vi.fn(), doctor: vi.fn(), evalList: vi.fn(), evalReport: vi.fn(), evalRun: vi.fn(), getConfigValue: vi.fn(), githubActions: vi.fn(), githubCiExplain: vi.fn(), githubIssue: vi.fn(), githubIssueComment: vi.fn(), githubIssueCreate: vi.fn(), githubIssues: vi.fn(), githubPr: vi.fn(), githubPrComment: vi.fn(), githubPrCreate: vi.fn(), githubPrReview: vi.fn(), githubPrSummary: vi.fn(), githubPrs: vi.fn(), githubRepo: vi.fn(), githubStatus: vi.fn(), history: vi.fn(), hookDisable: vi.fn(), hookEnable: vi.fn(), hookEvents: vi.fn(), hookList: vi.fn(), hookShow: vi.fn(), hookTest: vi.fn(), hooks: vi.fn(), listConfig: vi.fn(), listMcp: vi.fn(), listMcpTools: vi.fn(), listPermissions: vi.fn(), listPlugins: vi.fn(), listSessions: vi.fn(), listTools: vi.fn(), lspCache: vi.fn(), lspCacheClear: vi.fn(), lspDefinition: vi.fn(), lspDiagnostics: vi.fn(), lspReferences: vi.fn(), lspRestart: vi.fn(), lspSessions: vi.fn(), lspStatus: vi.fn(), lspStop: vi.fn(), lspSymbols: vi.fn(), memoryClear: vi.fn(), memoryConflicts: vi.fn(), memoryEdit: vi.fn(), memoryForgetSession: vi.fn(), memoryGraph: vi.fn(), memoryLearn: vi.fn(), memoryPrune: vi.fn(), memoryRelated: vi.fn(), memoryReview: vi.fn(), memoryRollback: vi.fn(), memorySearch: vi.fn(), memoryShow: vi.fn(), memorySource: vi.fn(), memoryStale: vi.fn(), memorySuggestionApprove: vi.fn(), memorySuggestionReject: vi.fn(), memorySuggestionShow: vi.fn(), memorySuggestions: vi.fn(), memorySummarize: vi.fn(), memoryWhy: vi.fn(), modelList: vi.fn(), modelRecommend: vi.fn(), ollamaModels: vi.fn(), ollamaPullHint: vi.fn(), ollamaRecommend: vi.fn(), ollamaStatus: vi.fn(), planApprove: vi.fn(), planClear: vi.fn(), planCreate: vi.fn(), planDelete: vi.fn(), planExecute: vi.fn(), planList: vi.fn(), planPause: vi.fn(), planResume: vi.fn(), planRevise: vi.fn(), planShow: vi.fn(), planStatus: vi.fn(), providerDoctor: vi.fn(), providerEnv: vi.fn(), providerFallback: vi.fn(), providerFallbackSet: vi.fn(), providerFallbackSimulate: vi.fn(), providerFallbackTest: vi.fn(), providerList: vi.fn(), providerSetup: vi.fn(), providerTest: vi.fn(), removePermission: vi.fn(), repoMap: vi.fn(), repoSummary: vi.fn(), repoSymbols: vi.fn(), revert: vi.fn(), runRoot: vi.fn(), sandboxDoctor: vi.fn(), sandboxStatus: vi.fn(), search: vi.fn(), securityStatus: vi.fn(), sessionAttach: vi.fn(), sessionDelete: vi.fn(), sessionExport: vi.fn(), sessionList: vi.fn(), sessionLocks: vi.fn(), sessionLogs: vi.fn(), sessionPause: vi.fn(), sessionResume: vi.fn(), sessionRunWorker: vi.fn(), sessionShow: vi.fn(), sessionStart: vi.fn(), sessionStop: vi.fn(), sessionUnlock: vi.fn(), sessions: vi.fn(), setConfigValue: vi.fn(), setup: vi.fn(), setupReset: vi.fn(), setupStatus: vi.fn(), share: vi.fn(), skillBrowser: vi.fn(), skillCreate: vi.fn(), skillDelete: vi.fn(), skillDisable: vi.fn(), skillEnable: vi.fn(), skillExport: vi.fn(), skillGenerate: vi.fn(), skillImport: vi.fn(), skillList: vi.fn(), skillRun: vi.fn(), skillShow: vi.fn(), skillTemplates: vi.fn(), skillTrust: vi.fn(), skillValidate: vi.fn(), skills: vi.fn(), teamApply: vi.fn(), teamArtifact: vi.fn(), teamArtifacts: vi.fn(), teamCockpit: vi.fn(), teamConflicts: vi.fn(), teamDiscard: vi.fn(), teamExport: vi.fn(), teamExportPatch: vi.fn(), teamIgnored: vi.fn(), teamMergePlan: vi.fn(), teamPlan: vi.fn(), teamResolve: vi.fn(), teamReview: vi.fn(), teamRun: vi.fn(), teamRunShow: vi.fn(), teamRuns: vi.fn(), teamValidatePatch: vi.fn(), teamWorkspaceCleanup: vi.fn(), teamWorkspaces: vi.fn(), testMcp: vi.fn(), webFetch: vi.fn(), webResearch: vi.fn(), webSearch: vi.fn(), workflowList: vi.fn(), workflowReport: vi.fn(), workflowRun: vi.fn(), workflowShow: vi.fn(), resumeSession: vi.fn(), }); diff --git a/tests/cli/help.test.ts b/tests/cli/help.test.ts index 4e437ff..356d5e3 100644 --- a/tests/cli/help.test.ts +++ b/tests/cli/help.test.ts @@ -61,10 +61,17 @@ describe('headline workflow commands route to the agent', () => { return {prompt, mode: options.mode}; }; - it('fix routes with mode fix and includes the description', async () => { - const {prompt, mode} = await run(['fix', 'the', 'login', 'bug']); - expect(mode).toBe('fix'); - expect(prompt).toContain('the login bug'); + it('fix routes to the repair-loop handler (not the generic agent prompt)', async () => { + const runRoot = vi.fn(() => Promise.resolve()); + const fix = vi.fn(() => Promise.resolve()); + const handlers = makeHandlers(runRoot); + (handlers as unknown as {fix: typeof fix}).fix = fix; + const program = buildProgram(handlers); + await program.parseAsync(['node', 'apeironcode', 'fix', '--dry-run']); + expect(fix).toHaveBeenCalledTimes(1); + expect(runRoot).not.toHaveBeenCalled(); + const [options] = fix.mock.calls[0] as unknown as [{dryRun?: boolean}]; + expect(options.dryRun).toBe(true); }); it('build routes with mode feature', async () => { diff --git a/tests/fixtures/repair/failing-test/check.mjs b/tests/fixtures/repair/failing-test/check.mjs new file mode 100644 index 0000000..0fe771a --- /dev/null +++ b/tests/fixtures/repair/failing-test/check.mjs @@ -0,0 +1,3 @@ +import {sum} from "./sum.mjs"; +if (sum(2,3) !== 5) { console.error("FAIL sum(2,3) expected 5"); process.exit(1); } +console.log("ok"); diff --git a/tests/fixtures/repair/failing-test/package.json b/tests/fixtures/repair/failing-test/package.json new file mode 100644 index 0000000..af3175e --- /dev/null +++ b/tests/fixtures/repair/failing-test/package.json @@ -0,0 +1,4 @@ +{ + "name": "failing-test-fixture", + "scripts": {"test": "node check.mjs"} +} diff --git a/tests/fixtures/repair/failing-test/sum.mjs b/tests/fixtures/repair/failing-test/sum.mjs new file mode 100644 index 0000000..2abed46 --- /dev/null +++ b/tests/fixtures/repair/failing-test/sum.mjs @@ -0,0 +1 @@ +export const sum = (a, b) => a - b; diff --git a/tests/fixtures/repair/module-not-found/build.mjs b/tests/fixtures/repair/module-not-found/build.mjs new file mode 100644 index 0000000..41da3eb --- /dev/null +++ b/tests/fixtures/repair/module-not-found/build.mjs @@ -0,0 +1 @@ +import "./does-not-exist.mjs"; diff --git a/tests/fixtures/repair/module-not-found/package.json b/tests/fixtures/repair/module-not-found/package.json new file mode 100644 index 0000000..89fc369 --- /dev/null +++ b/tests/fixtures/repair/module-not-found/package.json @@ -0,0 +1,4 @@ +{ + "name": "mnf-fixture", + "scripts": {"build": "node build.mjs"} +} diff --git a/tests/fixtures/repair/no-package-json/.gitkeep b/tests/fixtures/repair/no-package-json/.gitkeep new file mode 100644 index 0000000..a3b92f6 --- /dev/null +++ b/tests/fixtures/repair/no-package-json/.gitkeep @@ -0,0 +1 @@ +placeholder for no-package-json detection fixture diff --git a/tests/fixtures/repair/ts-error/index.ts b/tests/fixtures/repair/ts-error/index.ts new file mode 100644 index 0000000..993dd50 --- /dev/null +++ b/tests/fixtures/repair/ts-error/index.ts @@ -0,0 +1 @@ +export const n: number = "not a number"; diff --git a/tests/fixtures/repair/ts-error/package.json b/tests/fixtures/repair/ts-error/package.json new file mode 100644 index 0000000..ee68c24 --- /dev/null +++ b/tests/fixtures/repair/ts-error/package.json @@ -0,0 +1,4 @@ +{ + "name": "ts-error-fixture", + "scripts": {"typecheck": "tsc --noEmit"} +} diff --git a/tests/fixtures/repair/ts-error/tsconfig.json b/tests/fixtures/repair/ts-error/tsconfig.json new file mode 100644 index 0000000..9aa18d1 --- /dev/null +++ b/tests/fixtures/repair/ts-error/tsconfig.json @@ -0,0 +1 @@ +{"compilerOptions":{"strict":true,"noEmit":true}} diff --git a/tests/fixtures/repair/unknown-project/README.txt b/tests/fixtures/repair/unknown-project/README.txt new file mode 100644 index 0000000..33a8a88 --- /dev/null +++ b/tests/fixtures/repair/unknown-project/README.txt @@ -0,0 +1 @@ +just some notes diff --git a/tests/repair/checkpoint.test.ts b/tests/repair/checkpoint.test.ts new file mode 100644 index 0000000..9fa6f94 --- /dev/null +++ b/tests/repair/checkpoint.test.ts @@ -0,0 +1,73 @@ +import {promises as fs} from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; + +import {createCheckpoint, rollbackCheckpoint} from '../../src/repair/checkpoint.js'; +import {buildApprovalBundle, assessPlanRisk} from '../../src/repair/approvalBundle.js'; +import type {FilePlan} from '../../src/agent/filePlanProtocol.js'; +import type {FailureSummary} from '../../src/repair/failureClassifier.js'; + +let dir: string; +beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ac-ckpt-')); +}); +afterEach(async () => { + await fs.rm(dir, {recursive: true, force: true}); +}); + +describe('checkpoint and rollback', () => { + it('snapshots existing files and restores them on rollback', async () => { + await fs.writeFile(path.join(dir, 'a.ts'), 'original\n'); + const plan: FilePlan = { + summary: 's', validation: [], commands: [], + files: [{path: 'a.ts', operation: 'modify', content: 'changed'}, {path: 'new.ts', operation: 'create', content: 'new'}], + }; + const checkpoint = await createCheckpoint(dir, plan); + // Simulate a bad apply. + await fs.writeFile(path.join(dir, 'a.ts'), 'corrupted'); + await fs.writeFile(path.join(dir, 'new.ts'), 'leftover'); + + await rollbackCheckpoint(dir, checkpoint); + expect(await fs.readFile(path.join(dir, 'a.ts'), 'utf8')).toBe('original\n'); + // A file that did not exist before is removed on rollback. + await expect(fs.access(path.join(dir, 'new.ts'))).rejects.toThrow(); + }); + + it('writes the checkpoint under .apeironcode/checkpoints', async () => { + const plan: FilePlan = {summary: 's', validation: [], commands: [], files: [{path: 'a.ts', operation: 'create', content: 'x'}]}; + const checkpoint = await createCheckpoint(dir, plan); + expect(checkpoint.dir).toContain(path.join('.apeironcode', 'checkpoints')); + await expect(fs.access(path.join(checkpoint.dir, 'manifest.json'))).resolves.toBeUndefined(); + }); +}); + +describe('approval bundle risk', () => { + const failure: FailureSummary = { + primary: {kind: 'typecheck', message: 'type error', command: 'npm run typecheck', rawExcerpt: '', confidence: 'high', file: 'src/a.ts', line: 1}, + issues: [{kind: 'typecheck', message: 'type error', command: 'npm run typecheck', rawExcerpt: '', confidence: 'high'}], + files: ['src/a.ts'], + }; + + it('flags high-risk commands and blocks them', () => { + const plan: FilePlan = { + summary: 's', validation: [], files: [{path: 'a.ts', operation: 'modify', content: 'x'}], + commands: [{command: 'rm -rf /', reason: 'cleanup'}, {command: 'npm run typecheck', reason: 'verify'}], + }; + const risk = assessPlanRisk(plan); + expect(risk.level).toBe('high'); + expect(risk.highRiskCommands.map((c) => c.command)).toContain('rm -rf /'); + const bundle = buildApprovalBundle(failure, plan); + expect(bundle.body).toContain('HIGH-RISK'); + expect(bundle.body).toContain('Risk: high'); + }); + + it('builds a low-risk bundle for a single file modify', () => { + const plan: FilePlan = {summary: 's', validation: [], commands: [], files: [{path: 'src/a.ts', operation: 'modify', content: 'x'}]}; + const bundle = buildApprovalBundle(failure, plan); + expect(bundle.risk.level).toBe('low'); + expect(bundle.body).toContain('modify src/a.ts'); + expect(bundle.body).toContain('Issues:'); + }); +}); diff --git a/tests/repair/detectors.test.ts b/tests/repair/detectors.test.ts new file mode 100644 index 0000000..0042dfb --- /dev/null +++ b/tests/repair/detectors.test.ts @@ -0,0 +1,80 @@ +import {promises as fs} from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; + +import {detectProject} from '../../src/repair/projectDetector.js'; +import {detectCheckCommands, detectInstallCommand} from '../../src/repair/commandDetector.js'; + +let dir: string; + +beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ac-detect-')); +}); +afterEach(async () => { + await fs.rm(dir, {recursive: true, force: true}); +}); + +const writePkg = (scripts: Record, deps: Record = {}): Promise => + fs.writeFile(path.join(dir, 'package.json'), JSON.stringify({scripts, dependencies: deps}), 'utf8'); + +describe('project detector', () => { + it('detects npm and scripts', async () => { + await writePkg({typecheck: 'tsc', lint: 'eslint .', test: 'vitest', build: 'tsup'}); + await fs.writeFile(path.join(dir, 'package-lock.json'), '{}'); + const info = await detectProject(dir); + expect(info.packageManager).toBe('npm'); + expect(info.scripts.typecheck).toBe('typecheck'); + expect(info.scripts.build).toBe('build'); + }); + + it('detects pnpm/yarn/bun from lockfiles', async () => { + await writePkg({}); + await fs.writeFile(path.join(dir, 'pnpm-lock.yaml'), ''); + expect((await detectProject(dir)).packageManager).toBe('pnpm'); + await fs.rm(path.join(dir, 'pnpm-lock.yaml')); + await fs.writeFile(path.join(dir, 'yarn.lock'), ''); + expect((await detectProject(dir)).packageManager).toBe('yarn'); + await fs.rm(path.join(dir, 'yarn.lock')); + await fs.writeFile(path.join(dir, 'bun.lockb'), ''); + expect((await detectProject(dir)).packageManager).toBe('bun'); + }); + + it('detects frameworks (next/vite/typescript)', async () => { + await writePkg({}, {next: '14'}); + expect((await detectProject(dir)).framework).toBe('next'); + await writePkg({}, {vite: '5'}); + expect((await detectProject(dir)).framework).toBe('vite'); + await writePkg({}); + await fs.writeFile(path.join(dir, 'tsconfig.json'), '{}'); + expect((await detectProject(dir)).framework).toBe('typescript'); + }); + + it('does not crash without package.json or git', async () => { + const info = await detectProject(dir); + expect(info.hasPackageJson).toBe(false); + expect(info.framework).toBe('unknown'); + expect(info.isGitRepo).toBe(false); + }); +}); + +describe('command detector', () => { + it('orders checks and gates build/e2e behind includeAll', async () => { + await writePkg({typecheck: 'tsc', lint: 'eslint', test: 'vitest', build: 'tsup', 'test:e2e': 'vitest e2e'}); + await fs.writeFile(path.join(dir, 'package-lock.json'), '{}'); + const info = await detectProject(dir); + const normal = detectCheckCommands(info).map((c) => c.kind); + expect(normal).toEqual(['typecheck', 'lint', 'test']); + const all = detectCheckCommands(info, {includeAll: true}).map((c) => c.kind); + expect(all).toEqual(['typecheck', 'lint', 'test', 'build', 'e2e']); + }); + + it('suggests install only when node_modules missing', async () => { + await writePkg({test: 'vitest'}); + const info = await detectProject(dir); + expect(detectInstallCommand(info)?.command).toBe('npm install'); + await fs.mkdir(path.join(dir, 'node_modules')); + expect(detectInstallCommand(await detectProject(dir))).toBeUndefined(); + }); +}); diff --git a/tests/repair/errorParser.test.ts b/tests/repair/errorParser.test.ts new file mode 100644 index 0000000..3ab05db --- /dev/null +++ b/tests/repair/errorParser.test.ts @@ -0,0 +1,62 @@ +import {describe, expect, it} from 'vitest'; + +import {parseCheckFailure} from '../../src/repair/errorParser.js'; +import {classifyFailures} from '../../src/repair/failureClassifier.js'; +import {redactOutput} from '../../src/repair/checkRunner.js'; +import type {CheckResult} from '../../src/repair/types.js'; + +const result = (kind: CheckResult['kind'], output: string): CheckResult => ({ + kind, command: `npm run ${kind}`, ok: false, exitCode: 1, output, +}); + +describe('error parser', () => { + it('extracts TypeScript file/line/column', () => { + const issues = parseCheckFailure(result('typecheck', + "src/foo.ts(12,5): error TS2304: Cannot find name 'bar'.")); + const ts = issues.find((i) => i.kind === 'typecheck'); + expect(ts?.file).toBe('src/foo.ts'); + expect(ts?.line).toBe(12); + expect(ts?.column).toBe(5); + expect(ts?.confidence).toBe('high'); + }); + + it('detects module-not-found as a high-confidence root cause', () => { + const issues = parseCheckFailure(result('build', + "Error: Cannot find module './missing.js'\n at ...")); + expect(issues[0]!.kind).toBe('module-not-found'); + expect(issues[0]!.message).toContain('missing.js'); + }); + + it('extracts failing test names for test runs', () => { + const issues = parseCheckFailure(result('test', + ' ❯ tests/a.test.ts (1)\n × adds two todos 3ms\n Tests 1 failed')); + expect(issues.some((i) => i.kind === 'test' && /adds two todos/.test(i.message))).toBe(true); + }); + + it('extracts ESLint errors with location', () => { + const issues = parseCheckFailure(result('lint', + '/repo/src/a.ts\n 10:5 error Unexpected console statement no-console\n')); + const lint = issues.find((i) => i.kind === 'lint'); + expect(lint?.line).toBe(10); + }); + + it('falls back to an unknown/low-confidence issue', () => { + const issues = parseCheckFailure(result('build', 'some opaque failure output')); + expect(issues).toHaveLength(1); + expect(issues[0]!.confidence).toBe('low'); + }); + + it('redacts secrets in output', () => { + expect(redactOutput('token=ghp_abcdefgh12345678 failed')).not.toContain('ghp_abcdefgh12345678'); + expect(redactOutput('OPENAI_API_KEY=sk-abcdefghijklmnop')).toContain('[redacted]'); + }); +}); + +describe('failure classifier', () => { + it('prefers module-not-found over a generic build failure', () => { + const issues = parseCheckFailure(result('build', + "Cannot find module 'x'\nsome other noise")); + const summary = classifyFailures(issues); + expect(summary?.primary.kind).toBe('module-not-found'); + }); +}); diff --git a/tests/repair/fixtures.test.ts b/tests/repair/fixtures.test.ts new file mode 100644 index 0000000..1092be5 --- /dev/null +++ b/tests/repair/fixtures.test.ts @@ -0,0 +1,49 @@ +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +import {describe, expect, it} from 'vitest'; + +import {detectProject} from '../../src/repair/projectDetector.js'; +import {detectCheckCommands} from '../../src/repair/commandDetector.js'; +import {runCheck} from '../../src/repair/checkRunner.js'; +import {parseCheckFailure} from '../../src/repair/errorParser.js'; + +const FIXTURES = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'fixtures', 'repair'); +const fixture = (name: string): string => path.join(FIXTURES, name); + +describe('repair fixtures (deterministic, no provider)', () => { + it('classifies the ts-error fixture as a typescript project with a typecheck script', async () => { + const info = await detectProject(fixture('ts-error')); + expect(info.hasPackageJson).toBe(true); + expect(info.scripts.typecheck).toBeDefined(); + expect(detectCheckCommands(info)[0]!.kind).toBe('typecheck'); + }); + + it('runs the failing-test fixture and parses the failure (real node check)', async () => { + const info = await detectProject(fixture('failing-test')); + const [check] = detectCheckCommands(info); + expect(check?.kind).toBe('test'); + const result = await runCheck(check!, {cwd: fixture('failing-test')}); + expect(result.ok).toBe(false); + const issues = parseCheckFailure(result); + expect(issues.length).toBeGreaterThan(0); + }); + + it('detects module-not-found in the module-not-found fixture (real node build)', async () => { + const info = await detectProject(fixture('module-not-found')); + const [check] = detectCheckCommands(info, {includeAll: true}); + const result = await runCheck(check!, {cwd: fixture('module-not-found')}); + expect(result.ok).toBe(false); + const issues = parseCheckFailure(result); + expect(issues.some((i) => i.kind === 'module-not-found')).toBe(true); + }); + + it('treats no-package-json and unknown-project gracefully', async () => { + const noPkg = await detectProject(fixture('no-package-json')); + expect(noPkg.hasPackageJson).toBe(false); + expect(detectCheckCommands(noPkg)).toEqual([]); + const unknown = await detectProject(fixture('unknown-project')); + expect(unknown.hasPackageJson).toBe(false); + expect(unknown.framework).toBe('unknown'); + }); +}); diff --git a/tests/repair/repairLoop.test.ts b/tests/repair/repairLoop.test.ts new file mode 100644 index 0000000..b679ef9 --- /dev/null +++ b/tests/repair/repairLoop.test.ts @@ -0,0 +1,154 @@ +/* eslint-disable @typescript-eslint/require-await -- deterministic async test stubs */ +import {describe, expect, it, vi} from 'vitest'; + +import {runRepairLoop, type RepairLoopDeps} from '../../src/repair/repairLoop.js'; +import type {CheckCommand, CheckResult, ProjectInfo} from '../../src/repair/types.js'; + +const project: ProjectInfo = { + cwd: '/tmp/proj', + isGitRepo: false, + hasPackageJson: true, + hasNodeModules: true, + packageManager: 'npm', + framework: 'typescript', + scripts: {typecheck: 'typecheck', test: 'test'}, + typescript: true, +}; + +const validPlan = JSON.stringify({ + summary: 'fix type', + files: [{path: 'src/a.ts', operation: 'modify', content: 'export const a: number = 1;\n'}], + commands: [], + validation: [], +}); + +const makeChecks = (failingUntilFixed: () => boolean): RepairLoopDeps['runCheckFn'] => + vi.fn(async (check: CheckCommand): Promise => { + const ok = check.kind === 'typecheck' ? failingUntilFixed() : true; + return {kind: check.kind, command: check.command, ok, exitCode: ok ? 0 : 1, + output: ok ? '' : 'src/a.ts(1,14): error TS2322: Type string is not assignable to number.'}; + }); + +describe('repair loop (Phase 20B)', () => { + it('dry-run detects checks and changes nothing', async () => { + const applyPlan = vi.fn(); + const requestPlan = vi.fn(); + const result = await runRepairLoop( + {cwd: '/tmp/proj', mode: 'normal', dryRun: true}, + {detectProjectFn: async () => project, requestPlan, approve: async () => true, applyPlan}, + ); + expect(result.status).toBe('dry-run'); + expect(result.plannedChecks.length).toBeGreaterThan(0); + expect(applyPlan).not.toHaveBeenCalled(); + expect(requestPlan).not.toHaveBeenCalled(); + }); + + it('reports no-checks when no scripts exist', async () => { + const result = await runRepairLoop( + {cwd: '/tmp/proj', mode: 'normal'}, + { + detectProjectFn: async () => ({...project, scripts: {}}), + requestPlan: vi.fn(), approve: async () => true, applyPlan: vi.fn(), + }, + ); + expect(result.status).toBe('no-checks'); + }); + + it('fixes a failing typecheck with a valid provider plan after approval', async () => { + let fixed = false; + const applyPlan = vi.fn(async () => { + fixed = true; + return {ok: true, errors: [], filesChanged: ['src/a.ts']}; + }); + const result = await runRepairLoop( + {cwd: '/tmp/proj', mode: 'normal'}, + { + detectProjectFn: async () => project, + runCheckFn: makeChecks(() => fixed), + requestPlan: vi.fn(async () => validPlan), + approve: async () => true, + applyPlan, + }, + ); + expect(result.status).toBe('green'); + expect(applyPlan).toHaveBeenCalledTimes(1); + expect(result.checkpoints.length).toBe(1); + expect(result.attempts[0]!.planApplied).toBe(true); + }); + + it('corrects an invalid first plan then applies the valid second', async () => { + let fixed = false; + const requestPlan = vi.fn() + .mockResolvedValueOnce('not json at all') + .mockResolvedValue(validPlan); + const result = await runRepairLoop( + {cwd: '/tmp/proj', mode: 'normal', maxCorrections: 1}, + { + detectProjectFn: async () => project, + runCheckFn: makeChecks(() => fixed), + requestPlan, + approve: async () => true, + applyPlan: async () => { + fixed = true; + return {ok: true, errors: [], filesChanged: ['src/a.ts']}; + }, + }, + ); + expect(requestPlan).toHaveBeenCalledTimes(2); // first invalid, second valid + expect(result.status).toBe('green'); + }); + + it('stops safely when the provider keeps returning invalid plans', async () => { + const applyPlan = vi.fn(); + const result = await runRepairLoop( + {cwd: '/tmp/proj', mode: 'normal', maxCorrections: 1}, + { + detectProjectFn: async () => project, + runCheckFn: makeChecks(() => false), + requestPlan: vi.fn(async () => 'still not json'), + approve: async () => true, + applyPlan, + }, + ); + expect(result.status).toBe('invalid-plan'); + expect(applyPlan).not.toHaveBeenCalled(); + }); + + it('changes nothing when approval is rejected', async () => { + const applyPlan = vi.fn(); + const result = await runRepairLoop( + {cwd: '/tmp/proj', mode: 'normal'}, + { + detectProjectFn: async () => project, + runCheckFn: makeChecks(() => false), + requestPlan: vi.fn(async () => validPlan), + approve: async () => false, + applyPlan, + }, + ); + expect(result.status).toBe('rejected'); + expect(applyPlan).not.toHaveBeenCalled(); + }); + + it('safe mode rejects edits to files not referenced by the error', async () => { + // Plan edits an unrelated file; safe mode should reject and (after correction + // also unrelated) stop without applying. + const offTargetPlan = JSON.stringify({ + summary: 'x', files: [{path: 'src/unrelated.ts', operation: 'modify', content: 'x'}], + commands: [], validation: [], + }); + const applyPlan = vi.fn(); + const result = await runRepairLoop( + {cwd: '/tmp/proj', mode: 'safe', maxCorrections: 1}, + { + detectProjectFn: async () => project, + runCheckFn: makeChecks(() => false), + requestPlan: vi.fn(async () => offTargetPlan), + approve: async () => true, + applyPlan, + }, + ); + expect(result.status).toBe('invalid-plan'); + expect(applyPlan).not.toHaveBeenCalled(); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 2da3b62..2b4b456 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,5 +18,6 @@ "outDir": "dist", "types": ["node"] }, - "include": ["src", "tests", "scripts", "tsup.config.ts", "vitest.config.ts", "eslint.config.js"] + "include": ["src", "tests", "scripts", "tsup.config.ts", "vitest.config.ts", "eslint.config.js"], + "exclude": ["tests/fixtures/repair/ts-error"] } From 1cc663f2a43d5f14c15f2891543bc7b4e5b20163 Mon Sep 17 00:00:00 2001 From: Poola Nithin Reddy Date: Mon, 8 Jun 2026 00:42:36 -0400 Subject: [PATCH 4/8] Add reliable template-based app builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 20C: make `apeironcode new` / `apeironcode build` create small apps from deterministic templates + real validation — no API key or model required. App builder (src/appBuilder/): - requestParser: free-text idea → honest AppRequest (name/slug/kind/stack/style/ features). Auth/db/billing become mock/local UI; Next.js downgrades to Vite. - templateRegistry + templates: static-todo, static-landing, dashboard-static, vite-react. Shared premium design system (tokens, spacing/type scale, focus states, responsive, empty states). - templateRenderer: deterministic render + path safety (no absolute/.., .git, .env, no writes outside the app dir). - designQuality: static checklist (title, viewport, linked assets, button/focus states, labeled forms, responsive, no obvious overflow). - appValidation: files-exist, linked-assets, node --check, design checklist; Vite build/typecheck detected and reported as skipped (never auto-installed, never faked green). - appBuilder orchestrator + appReport: parse → select → approval bundle → write → validate → honest report. Non-empty dirs require --overwrite and existing files are never replaced. --dry-run writes nothing. CLI: `apeironcode new`/`build [--stack --style --dir --dry-run --overwrite --yes]` wired via src/cli/appBuilderHandler.ts; both are beginner commands. Approval is required (auto in bypass/trusted or --yes; non-interactive declines). Tests: requestParser, templateRegistry/renderer (+path safety), designQuality, appValidation (broken JS / missing CSS / skipped Vite), orchestrator (create/ dry-run/overwrite-protection/rejection), and docs/packaging honesty. Docs: new docs/build-apps.md (packaged) + README "Build an app" section. --- README.md | 23 +++- docs/build-apps.md | 72 ++++++++++ docs/getting-started.md | 10 +- package.json | 1 + src/appBuilder/appBuilder.ts | 119 +++++++++++++++++ src/appBuilder/appReport.ts | 123 +++++++++++++++++ src/appBuilder/appValidation.ts | 129 ++++++++++++++++++ src/appBuilder/designQuality.ts | 64 +++++++++ src/appBuilder/requestParser.ts | 140 ++++++++++++++++++++ src/appBuilder/templateRegistry.ts | 39 ++++++ src/appBuilder/templateRenderer.ts | 74 +++++++++++ src/appBuilder/templates/dashboardStatic.ts | 125 +++++++++++++++++ src/appBuilder/templates/shared.ts | 120 +++++++++++++++++ src/appBuilder/templates/staticLanding.ts | 102 ++++++++++++++ src/appBuilder/templates/staticTodo.ts | 104 +++++++++++++++ src/appBuilder/templates/viteReact.ts | 136 +++++++++++++++++++ src/appBuilder/types.ts | 70 ++++++++++ src/cli/appBuilderHandler.ts | 76 +++++++++++ src/cli/commands/types.ts | 2 + src/cli/help.ts | 29 +++- src/cli/setup/coreHandlers.ts | 13 +- tests/appBuilder/appBuilder.test.ts | 109 +++++++++++++++ tests/appBuilder/docs.test.ts | 34 +++++ tests/appBuilder/requestParser.test.ts | 54 ++++++++ tests/appBuilder/templates.test.ts | 89 +++++++++++++ tests/cli/commands.test.ts | 1 + tests/cli/evalCommands.test.ts | 2 +- tests/cli/help.test.ts | 18 ++- 28 files changed, 1861 insertions(+), 17 deletions(-) create mode 100644 docs/build-apps.md create mode 100644 src/appBuilder/appBuilder.ts create mode 100644 src/appBuilder/appReport.ts create mode 100644 src/appBuilder/appValidation.ts create mode 100644 src/appBuilder/designQuality.ts create mode 100644 src/appBuilder/requestParser.ts create mode 100644 src/appBuilder/templateRegistry.ts create mode 100644 src/appBuilder/templateRenderer.ts create mode 100644 src/appBuilder/templates/dashboardStatic.ts create mode 100644 src/appBuilder/templates/shared.ts create mode 100644 src/appBuilder/templates/staticLanding.ts create mode 100644 src/appBuilder/templates/staticTodo.ts create mode 100644 src/appBuilder/templates/viteReact.ts create mode 100644 src/appBuilder/types.ts create mode 100644 src/cli/appBuilderHandler.ts create mode 100644 tests/appBuilder/appBuilder.test.ts create mode 100644 tests/appBuilder/docs.test.ts create mode 100644 tests/appBuilder/requestParser.test.ts create mode 100644 tests/appBuilder/templates.test.ts diff --git a/README.md b/README.md index f3a738f..ffab4a7 100644 --- a/README.md +++ b/README.md @@ -57,11 +57,32 @@ approval-gated writes and commands: ```bash apeironcode fix # detect failures, propose fixes, rerun checks -apeironcode build "a todo app" # build an app or feature +apeironcode new "a todo app" # create a new app from a template +apeironcode build "a CRM dashboard"# build also creates apps from templates apeironcode improve "premium UI" # improve UI or code quality apeironcode review # review the current changes ``` +## Build an app + +`apeironcode new` (and `apeironcode build`) create a small app from a +**deterministic template** — no API key or model needed: + +```bash +apeironcode new "premium todo app" +apeironcode new "CRM dashboard" --stack dashboard +apeironcode new "landing page for an AI startup" --style premium-saas +apeironcode build "a React task app" --stack vite --dir my-app +``` + +It picks a template, shows an approval bundle, writes real files, and validates +them (linked assets, `node --check`, a design checklist). Static apps +(todo/landing/dashboard) are the most reliable and need no install; Vite + React +is alpha and needs a manual `npm install`. Auth/database/billing requests become +**mock/local UI**, not real backends — the report is honest about scope. A +non-empty directory is never overwritten silently. See +[docs/build-apps.md](./docs/build-apps.md). + `apeironcode fix` detects your project's check commands (typecheck/lint/test/ build), runs them, proposes an **approval-gated** minimal patch, applies it, and reruns the checks. Use `--dry-run` to preview, `--safe` to restrict the edit diff --git a/docs/build-apps.md b/docs/build-apps.md new file mode 100644 index 0000000..096a32d --- /dev/null +++ b/docs/build-apps.md @@ -0,0 +1,72 @@ +# Building apps with `apeironcode new` + +ApeironCode builds small apps from **deterministic templates** — no API key or +model is required for template generation. AI customization (via a configured +provider) is an optional layer on top. + +```bash +apeironcode new "premium todo app" +apeironcode new "CRM dashboard" --stack dashboard +apeironcode new "landing page for an AI startup" --style premium-saas +apeironcode new "a React task app" --stack vite --dir my-app +apeironcode build "create a premium todo app" # build also creates apps +``` + +`apeironcode new` and `apeironcode build` run the same app-builder engine: +parse the idea → pick a template → show an approval bundle → create real files → +run validation → print how to open the app. + +## Supported templates + +| Stack | Template | Notes | +| --- | --- | --- | +| `static` | static todo | HTML/CSS/JS. **Most reliable today** — no install, always works. | +| `static` | static landing | Premium landing page with a mock waitlist. | +| `dashboard` | static dashboard | Responsive, frontend-only dashboard with local sample data. | +| `vite` | Vite + React (TypeScript) | Alpha. Requires `npm install` to run; the CLI does not auto-install. | + +Next.js is not available yet; a Next.js request downgrades to Vite + React and +says so honestly. + +## What's a mock, and what's real + +This phase builds **frontend prototypes**. To stay honest: + +- "login", "auth", "sign in" → a **mock login** UI only (no real backend). +- "CRM", "customers", "orders" → **local sample data** rendered in the browser. +- "billing", "payments" → a **mock** UI only. + +ApeironCode does not create real authentication, databases, or billing in this +phase, and the final report says so. + +## Validation + +After creating files, ApeironCode validates the app: + +- **Static apps**: files exist, the HTML's linked CSS/JS exist, `node --check` + passes on the JS, and a design checklist (title, viewport, labels, responsive + rules, no obvious overflow) passes. No network required. +- **Vite apps**: `npm run build` / `npm run typecheck` are detected. The CLI + does **not** auto-install or run them; run `npm install && npm run dev` + yourself. They are reported as *skipped*, never as a fake pass. + +If a static app fails validation, the report shows exactly which check failed — +it never claims a broken app passed. + +## Safety + +- App creation is **approval-gated**: you see a bundle (directory + files + + validation) before anything is written. Auto-approved only in bypass/trusted + mode or with `--yes`. +- A **non-empty directory** is never written into without `--overwrite`, and + even then **existing files are never replaced** — only new files are added. +- Files are only ever written inside the chosen app directory; `.git` and + `.env` are never written. +- `--dry-run` shows the plan and writes nothing. + +## Limitations (alpha) + +- Static templates are the most reliable. Vite is alpha and needs a manual + install/build. Next.js is not implemented yet. +- These are prototypes, not production apps. Use `apeironcode improve` and + `apeironcode fix` to iterate. diff --git a/docs/getting-started.md b/docs/getting-started.md index a9ebde1..08b223e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -53,13 +53,15 @@ apeironcode doctor ```bash apeironcode fix # detect failures, propose fixes, rerun checks -apeironcode build "a todo app" # build an app or feature +apeironcode new "a todo app" # create a new app from a template +apeironcode build "a CRM dashboard"# build also creates apps apeironcode improve "premium UI" # improve UI or code quality apeironcode review # review the current changes ``` -`apeironcode fix` runs the repair loop: detect checks → run → propose an -approval-gated patch → apply → rerun. Try `apeironcode fix --dry-run` first. -See [fix-code.md](./fix-code.md). +`apeironcode new` creates a small app from a deterministic template (no API key +needed); see [build-apps.md](./build-apps.md). `apeironcode fix` runs the repair +loop: detect checks → run → propose an approval-gated patch → apply → rerun. Try +`apeironcode fix --dry-run` first. See [fix-code.md](./fix-code.md). Every write and command is approval-gated. See [safety.md](./safety.md). diff --git a/package.json b/package.json index 4fb0616..e3da492 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "LICENSE", "docs/agents.md", "docs/architecture.md", + "docs/build-apps.md", "docs/commands.md", "docs/connectors.md", "docs/context-engine.md", diff --git a/src/appBuilder/appBuilder.ts b/src/appBuilder/appBuilder.ts new file mode 100644 index 0000000..14504df --- /dev/null +++ b/src/appBuilder/appBuilder.ts @@ -0,0 +1,119 @@ +import {promises as fs} from 'node:fs'; +import path from 'node:path'; + +import {parseAppRequest, slugify, type ParseAppRequestOptions} from './requestParser.js'; +import {selectTemplate} from './templateRegistry.js'; +import {renderTemplate, writeTemplateFiles} from './templateRenderer.js'; +import {validateApp} from './appValidation.js'; +import {buildAppApprovalBundle, type AppBuildResult} from './appReport.js'; +import type {AppRequest, AppTemplate, TemplateVariables} from './types.js'; + +export interface BuildAppOptions extends ParseAppRequestOptions { + cwd: string; + /** Explicit output directory (absolute or relative to cwd). */ + dir?: string; + dryRun?: boolean; + /** Allow writing into a non-empty directory (still never overwrites files). */ + overwrite?: boolean; + /** Run command validation steps (Vite build) — only in temp/approved flows. */ + runCommands?: boolean; + signal?: AbortSignal; + /** Approval gate. Returns true to proceed with writes. */ + approve: (bundle: {title: string; body: string; risk: 'low' | 'medium'}) => Promise; +} + +const toVars = (request: AppRequest): TemplateVariables => ({ + appName: request.appName, + appSlug: request.appSlug, + description: request.description, + style: request.style, + features: request.features, +}); + +const isNonEmptyDir = async (dir: string): Promise => { + try { + const entries = await fs.readdir(dir); + return entries.length > 0; + } catch { + return false; // does not exist → treated as empty/creatable + } +}; + +const resolveAppDir = (options: BuildAppOptions, request: AppRequest): string => + options.dir + ? path.resolve(options.cwd, options.dir) + : path.resolve(options.cwd, request.appSlug); + +/** + * Build a new app from a template (Phase 20C). Deterministic: parse → select → + * render → approve → write → validate. Never overwrites a non-empty directory + * without `overwrite`, never writes outside the app dir, and never fakes green. + */ +export const buildApp = async (idea: string, options: BuildAppOptions): Promise => { + const request = parseAppRequest(idea, options); + if (options.dir) { + // Keep the slug aligned with an explicit directory name for nicer reports. + request.appSlug = slugify(path.basename(path.resolve(options.cwd, options.dir))); + } + const template: AppTemplate = selectTemplate(request); + const vars = toVars(request); + const appDir = resolveAppDir(options, request); + const relativeDir = path.relative(options.cwd, appDir); + // Show a clean relative path when inside the workspace, otherwise the + // absolute path (avoids ugly ../../../ chains for temp/explicit dirs). + const displayDir = !relativeDir || relativeDir.startsWith('..') || path.isAbsolute(relativeDir) + ? appDir + : `./${relativeDir}`; + + const rendered = renderTemplate(template, vars); + if (rendered.errors.length > 0) { + return baseResult(request, template, appDir, displayDir, 'write-error', rendered.errors); + } + const fileList = rendered.files.map((file) => file.path); + + // Overwrite protection: a non-empty target needs explicit --overwrite. + if ((await isNonEmptyDir(appDir)) && !options.overwrite) { + return baseResult(request, template, appDir, displayDir, 'blocked-existing', []); + } + + if (options.dryRun) { + return {...baseResult(request, template, appDir, displayDir, 'dry-run', request.notes), filesCreated: fileList}; + } + + const bundle = buildAppApprovalBundle(request, template, displayDir, fileList); + if (!(await options.approve(bundle))) { + return baseResult(request, template, appDir, displayDir, 'rejected', []); + } + + // `--overwrite` only unblocks a non-empty directory; individual existing + // files are still never replaced (reported as refusals in notes). + const writeResult = await writeTemplateFiles(appDir, rendered.files, {overwrite: false}); + if (writeResult.errors.length > 0 && writeResult.written.length === 0) { + return baseResult(request, template, appDir, displayDir, 'write-error', writeResult.errors); + } + + const validation = await validateApp(appDir, template, vars, rendered.files, { + runCommands: options.runCommands, + signal: options.signal, + }); + + return { + request, + template, + appDir, + displayDir, + filesCreated: writeResult.written, + validation, + status: 'created', + notes: [...request.notes, ...writeResult.errors], + }; +}; + +const baseResult = ( + request: AppRequest, + template: AppTemplate, + appDir: string, + displayDir: string, + status: AppBuildResult['status'], + notes: string[], +): AppBuildResult => ({request, template, appDir, displayDir, filesCreated: [], status, notes}); diff --git a/src/appBuilder/appReport.ts b/src/appBuilder/appReport.ts new file mode 100644 index 0000000..2228671 --- /dev/null +++ b/src/appBuilder/appReport.ts @@ -0,0 +1,123 @@ +import type {AppValidationResult} from './appValidation.js'; +import {formatValidation} from './appValidation.js'; +import type {AppRequest, AppTemplate} from './types.js'; + +export interface AppBuildResult { + request: AppRequest; + template: AppTemplate; + appDir: string; + displayDir: string; + filesCreated: string[]; + validation?: AppValidationResult; + status: 'created' | 'rejected' | 'blocked-existing' | 'write-error' | 'dry-run'; + notes: string[]; +} + +/** + * Build the approval bundle text for creating a new app (Phase 20C, Task F). + */ +export const buildAppApprovalBundle = ( + request: AppRequest, + template: AppTemplate, + displayDir: string, + files: string[], +): {title: string; body: string; risk: 'low' | 'medium'} => { + const risk: 'low' | 'medium' = template.requiresCommands ? 'medium' : 'low'; + const lines: string[] = []; + lines.push('ApeironCode wants to create a new app:'); + lines.push(''); + lines.push(`Template: ${template.name} (${template.stack})`); + lines.push(''); + lines.push('Directory:'); + lines.push(`- ${displayDir}`); + lines.push(''); + lines.push('Files:'); + for (const file of files) lines.push(`- create ${file}`); + lines.push(''); + lines.push('Validation:'); + for (const step of template.validation(toVars(request))) { + lines.push(`- ${step.command ?? step.name}`); + } + if (request.notes.length > 0) { + lines.push(''); + lines.push('Notes:'); + for (const note of request.notes) lines.push(`- ${note}`); + } + lines.push(''); + lines.push(`Risk: ${risk}`); + return {title: 'Approve new app', body: lines.join('\n'), risk}; +}; + +const toVars = (request: AppRequest) => ({ + appName: request.appName, + appSlug: request.appSlug, + description: request.description, + style: request.style, + features: request.features, +}); + +/** + * Format the final app-build report. Honest: never says a server started, never + * claims real auth/database, and surfaces validation results as-is. + */ +export const formatAppReport = (result: AppBuildResult): string => { + const lines: string[] = []; + + if (result.status === 'blocked-existing') { + lines.push(`Cannot create the app: ${result.displayDir} already exists and is not empty.`); + lines.push('Choose one:'); + lines.push(`- create in a subdirectory: apeironcode new "${result.request.description}" --dir ${result.displayDir}/${result.request.appSlug}`); + lines.push('- re-run with --overwrite to write into it (existing files are still never silently replaced)'); + lines.push('- cancel'); + return lines.join('\n'); + } + if (result.status === 'rejected') { + return 'No app was created because approval was declined.'; + } + if (result.status === 'write-error') { + lines.push('The app could not be created cleanly:'); + for (const note of result.notes) lines.push(`- ${note}`); + return lines.join('\n'); + } + + if (result.status === 'dry-run') { + lines.push(`Dry run. Would create ${result.template.name}: ${result.request.appName}`); + lines.push(''); + lines.push(`Directory: ${result.displayDir}`); + lines.push('Files:'); + for (const file of result.filesCreated) lines.push(` + ${file}`); + lines.push(''); + lines.push('Nothing was written.'); + return lines.join('\n'); + } + + lines.push(`Done. Created ${result.request.appName}.`); + lines.push(''); + lines.push(`App directory:\n${result.displayDir}`); + lines.push(''); + lines.push('Files created:'); + for (const file of result.filesCreated) lines.push(` + ${file}`); + + if (result.validation) { + lines.push(''); + lines.push(result.validation.passed ? 'Validation:' : 'Validation (some checks failed):'); + lines.push(formatValidation(result.validation)); + } + + if (result.request.notes.length > 0) { + lines.push(''); + lines.push('Notes (honest scope):'); + for (const note of result.request.notes) lines.push(` - ${note}`); + } + + lines.push(''); + lines.push('Open:'); + for (const step of result.template.openInstructions(result.displayDir)) lines.push(` ${step}`); + + lines.push(''); + lines.push('Next:'); + lines.push(' - apeironcode improve "make this more premium"'); + lines.push(' - apeironcode fix'); + lines.push(` - git add ${result.displayDir}`); + return lines.join('\n'); +}; diff --git a/src/appBuilder/appValidation.ts b/src/appBuilder/appValidation.ts new file mode 100644 index 0000000..ac92322 --- /dev/null +++ b/src/appBuilder/appValidation.ts @@ -0,0 +1,129 @@ +import {promises as fs} from 'node:fs'; +import path from 'node:path'; + +import {execa} from 'execa'; + +import {redactOutput, truncateOutput} from '../repair/checkRunner.js'; +import {runDesignChecklist} from './designQuality.js'; +import type {AppTemplate, TemplateFile, TemplateVariables} from './types.js'; + +export interface AppValidationStepResult { + name: string; + command?: string; + passed: boolean; + skipped?: boolean; + outputExcerpt: string; +} + +export interface AppValidationResult { + passed: boolean; + steps: AppValidationStepResult[]; +} + +const fileExists = (abs: string): Promise => + fs.access(abs).then(() => true).catch(() => false); + +const linkedRefs = (html: string): string[] => { + const refs: string[] = []; + const re = /(?:href|src)\s*=\s*["']([^"']+)["']/giu; + let match: RegExpExecArray | null; + while ((match = re.exec(html)) !== null) { + const ref = match[1]!; + if (/^(?:https?:)?\/\//u.test(ref) || ref.startsWith('#') || ref.startsWith('data:') || ref.startsWith('/')) continue; + refs.push(ref.replace(/^\.\//u, '')); + } + return refs; +}; + +export interface ValidateAppOptions { + /** Run command steps (npm build/typecheck). Only in temp/approved workspaces. */ + runCommands?: boolean; + signal?: AbortSignal; +} + +/** + * Validate a generated app (Phase 20C, Task E). Static checks (files exist, + * linked assets, `node --check`, design checklist) always run and need no + * network. Command steps (Vite build/typecheck) only run when `runCommands` is + * set; otherwise they are reported as skipped (not failed). Never fakes green. + */ +export const validateApp = async ( + appDir: string, + template: AppTemplate, + vars: TemplateVariables, + files: TemplateFile[], + options: ValidateAppOptions = {}, +): Promise => { + const steps: AppValidationStepResult[] = []; + + for (const step of template.validation(vars)) { + if (step.kind === 'files-exist') { + const missing: string[] = []; + for (const file of files) { + if (!(await fileExists(path.join(appDir, file.path)))) missing.push(file.path); + } + steps.push({name: step.name, passed: missing.length === 0, outputExcerpt: missing.length ? `missing: ${missing.join(', ')}` : `${files.length} file(s)`}); + } else if (step.kind === 'linked-assets') { + const entry = step.entry ?? 'index.html'; + const html = await fs.readFile(path.join(appDir, entry), 'utf8').catch(() => ''); + const refs = linkedRefs(html); + const missing: string[] = []; + for (const ref of refs) { + if (!(await fileExists(path.join(appDir, ref)))) missing.push(ref); + } + steps.push({name: step.name, passed: missing.length === 0, outputExcerpt: missing.length ? `missing: ${missing.join(', ')}` : `${refs.length} reference(s)`}); + } else if (step.kind === 'node-check') { + const file = step.file ?? 'app.js'; + const result = await runNodeCheck(appDir, file, options.signal); + steps.push({name: step.name, passed: result.ok, outputExcerpt: result.detail}); + } else if (step.kind === 'design-checklist') { + const checklist = runDesignChecklist(files, step.entry ?? 'index.html'); + const failed = checklist.checks.filter((c) => !c.ok).map((c) => c.name); + steps.push({name: step.name, passed: checklist.ok, outputExcerpt: checklist.ok ? 'all design checks pass' : `failed: ${failed.join(', ')}`}); + } else if (step.kind === 'command') { + if (!options.runCommands || !step.command) { + steps.push({name: step.name, command: step.command, passed: true, skipped: true, outputExcerpt: 'skipped (run with install/approval)'}); + continue; + } + const result = await runCommandStep(appDir, step.command, options.signal); + steps.push({name: step.name, command: step.command, passed: result.ok, outputExcerpt: result.detail}); + } + } + + // Skipped command steps do not fail the overall result. + const passed = steps.every((step) => step.passed || step.skipped); + return {passed, steps}; +}; + +const runNodeCheck = async ( + cwd: string, + file: string, + signal?: AbortSignal, +): Promise<{ok: boolean; detail: string}> => { + try { + await execa('node', ['--check', file], {cwd, timeout: 15_000, signal}); + return {ok: true, detail: 'syntax OK'}; + } catch (error) { + const err = error as {exitCode?: number; stderr?: string; shortMessage?: string}; + if (err.exitCode === undefined) return {ok: true, detail: 'skipped (node unavailable)'}; + return {ok: false, detail: redactOutput(truncateOutput(err.stderr || err.shortMessage || 'syntax error', 400)).split('\n')[0] ?? 'syntax error'}; + } +}; + +const runCommandStep = async ( + cwd: string, + command: string, + signal?: AbortSignal, +): Promise<{ok: boolean; detail: string}> => { + try { + const result = await execa(command, {cwd, shell: true, reject: false, all: true, timeout: 300_000, signal}); + return {ok: result.exitCode === 0, detail: redactOutput(truncateOutput(result.all ?? '', 600)).split('\n').slice(-3).join(' ')}; + } catch (error) { + return {ok: false, detail: redactOutput(truncateOutput(String(error), 400))}; + } +}; + +export const formatValidation = (result: AppValidationResult): string => + result.steps + .map((step) => ` ${step.skipped ? '·' : step.passed ? '✓' : '✗'} ${step.name}${step.skipped ? ' (skipped)' : ''} — ${step.outputExcerpt}`) + .join('\n'); diff --git a/src/appBuilder/designQuality.ts b/src/appBuilder/designQuality.ts new file mode 100644 index 0000000..a408e05 --- /dev/null +++ b/src/appBuilder/designQuality.ts @@ -0,0 +1,64 @@ +import type {TemplateFile} from './types.js'; + +export interface DesignCheck { + name: string; + ok: boolean; + detail: string; +} + +const findFile = (files: TemplateFile[], rel: string): TemplateFile | undefined => + files.find((file) => file.path === rel || file.path === rel.replace(/^\.\//u, '')); + +const linkedRefs = (html: string): string[] => { + const refs: string[] = []; + const re = /(?:href|src)\s*=\s*["']([^"']+)["']/giu; + let match: RegExpExecArray | null; + while ((match = re.exec(html)) !== null) { + const ref = match[1]!; + if (/^(?:https?:)?\/\//u.test(ref) || ref.startsWith('#') || ref.startsWith('data:') || ref.startsWith('/')) continue; + refs.push(ref.replace(/^\.\//u, '')); + } + return refs; +}; + +/** + * Run the deterministic static design checklist over rendered files + * (Phase 20C, Task D). No browser/network: heuristics on the HTML/CSS source. + * Generated apps should pass every relevant check. + */ +export const runDesignChecklist = (files: TemplateFile[], entry: string): {ok: boolean; checks: DesignCheck[]} => { + const checks: DesignCheck[] = []; + const add = (name: string, ok: boolean, detail: string): void => { + checks.push({name, ok, detail}); + }; + + const html = findFile(files, entry)?.content ?? ''; + const css = files.filter((f) => f.path.endsWith('.css')).map((f) => f.content).join('\n'); + + add('entry HTML present', Boolean(html), html ? entry : `missing ${entry}`); + add('has ', /<title>[^<]+<\/title>/iu.test(html), 'document title'); + add('has viewport meta', /<meta[^>]+name=["']viewport["']/iu.test(html), 'responsive viewport'); + + const refs = linkedRefs(html); + const missing = refs.filter((ref) => !findFile(files, ref)); + add('linked assets exist', missing.length === 0, missing.length ? `missing: ${missing.join(', ')}` : `${refs.length} reference(s)`); + + const hasButtons = /<button|class=["'][^"']*\bbtn\b/iu.test(html); + add('buttons have visible states', !hasButtons || /:hover|:focus(?:-visible)?/u.test(css), 'hover/focus states'); + + const inputs = (html.match(/<input\b/giu) ?? []).length; + const labels = (html.match(/<label\b/giu) ?? []).length; + add('form inputs have labels', inputs === 0 || labels >= inputs, `${labels} label(s) for ${inputs} input(s)`); + + const mentionsList = /<ul|<table|class=["'][^"']*\blist\b/iu.test(html); + add('empty state present where relevant', !mentionsList || /empty-state/u.test(html), 'empty state'); + + add('no obvious horizontal overflow', /box-sizing\s*:\s*border-box/u.test(css), 'box-sizing border-box'); + add('responsive rules present', /@media/u.test(css), 'media query'); + + const ok = checks.every((check) => check.ok); + return {ok, checks}; +}; + +export const formatDesignChecks = (checks: DesignCheck[]): string => + checks.map((check) => ` ${check.ok ? '✓' : '✗'} ${check.name} — ${check.detail}`).join('\n'); diff --git a/src/appBuilder/requestParser.ts b/src/appBuilder/requestParser.ts new file mode 100644 index 0000000..f8d7eb0 --- /dev/null +++ b/src/appBuilder/requestParser.ts @@ -0,0 +1,140 @@ +import type {AppKind, AppRequest, AppStack, AppStyle} from './types.js'; + +const STOP_WORDS = new Set([ + 'a', 'an', 'the', 'app', 'application', 'for', 'with', 'and', 'to', 'of', 'create', 'build', + 'make', 'me', 'my', 'please', 'simple', 'small', +]); + +/** Convert free text to a directory-safe slug. */ +export const slugify = (text: string): string => + text + .toLowerCase() + .replace(/[^a-z0-9]+/gu, '-') + .replace(/^-+|-+$/gu, '') + .slice(0, 60) || 'app'; + +/** Build a Title Case display name from the idea, dropping filler words. */ +const toAppName = (idea: string): string => { + const words = idea + .replace(/[^a-zA-Z0-9\s]/gu, ' ') + .split(/\s+/u) + .filter((word) => word && !STOP_WORDS.has(word.toLowerCase())); + const picked = (words.length > 0 ? words : ['App']).slice(0, 5); + return picked.map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); +}; + +const KIND_PATTERNS: Array<{kind: AppKind; re: RegExp}> = [ + {kind: 'dashboard', re: /\b(dashboard|crm|admin|analytics|console|panel|metrics)\b/iu}, + {kind: 'landing', re: /\b(landing|startup|portfolio|marketing|homepage|product page|waitlist)\b/iu}, + {kind: 'todo', re: /\b(todo|to-do|task|tasks|habit|checklist|notes?|reminder)\b/iu}, +]; + +const STYLE_PATTERNS: Array<{style: AppStyle; re: RegExp}> = [ + {style: 'premium-saas', re: /\b(premium|saas|professional|polished|sleek)\b/iu}, + {style: 'apple-like', re: /\b(apple|ios|iphone|cupertino)\b/iu}, + {style: 'dark', re: /\b(dark|midnight|black)\b/iu}, +]; + +const FEATURE_PATTERNS: Array<{feature: string; re: RegExp; mock?: boolean}> = [ + {feature: 'tasks', re: /\b(task|todo|checklist|habit)\b/iu}, + {feature: 'dashboard', re: /\b(dashboard|analytics|metrics|charts?)\b/iu}, + {feature: 'login-mock', re: /\b(login|sign\s?in|auth|account|sign\s?up)\b/iu, mock: true}, + {feature: 'sample-data', re: /\b(crm|customers?|contacts?|users?|orders?|leads?)\b/iu}, + {feature: 'billing-mock', re: /\b(billing|payment|stripe|subscription|pricing)\b/iu, mock: true}, +]; + +export interface ParseAppRequestOptions { + stack?: string; + style?: string; + /** Whether next-minimal is actually available; downgrade if not. */ + nextAvailable?: boolean; +} + +const detectKind = (idea: string): AppKind => { + for (const pattern of KIND_PATTERNS) if (pattern.re.test(idea)) return pattern.kind; + return 'custom'; +}; + +const normalizeStack = (raw: string | undefined): AppStack | undefined => { + if (!raw) return undefined; + const value = raw.toLowerCase(); + if (value === 'static' || value === 'dashboard' || value === 'dashboard-static') return 'static'; + if (value === 'vite' || value === 'vite-react' || value === 'react') return 'vite-react'; + if (value === 'next' || value === 'next-minimal' || value === 'nextjs') return 'next-minimal'; + return undefined; +}; + +const detectStackFromIdea = (idea: string): AppStack | undefined => { + if (/\bnext\.?js\b/iu.test(idea)) return 'next-minimal'; + if (/\b(react|vite)\b/iu.test(idea)) return 'vite-react'; + return undefined; +}; + +/** + * Parse a free-text app idea into a deterministic, honest AppRequest + * (Phase 20C, Task C). Auth/database/billing become mock/local UI features — + * never claimed as real. Unsupported Next.js downgrades to Vite when next is + * unavailable. + */ +export const parseAppRequest = (idea: string, options: ParseAppRequestOptions = {}): AppRequest => { + const trimmed = idea.trim() || 'app'; + const kind = detectKind(trimmed); + const notes: string[] = []; + + let style: AppStyle = 'minimal'; + if (options.style) { + style = (normalizeStyle(options.style) ?? 'premium-saas'); + } else { + for (const pattern of STYLE_PATTERNS) { + if (pattern.re.test(trimmed)) { + style = pattern.style; + break; + } + } + } + + const features: string[] = []; + let downgraded = false; + for (const pattern of FEATURE_PATTERNS) { + if (pattern.re.test(trimmed) && !features.includes(pattern.feature)) { + features.push(pattern.feature); + if (pattern.mock) { + downgraded = true; + notes.push(`${pattern.feature.replace('-mock', '')} is a mock/local UI only (no real backend in this phase)`); + } + } + } + + // Stack resolution: explicit flag → idea hint → kind default. + let stack = normalizeStack(options.stack) ?? detectStackFromIdea(trimmed); + if (!stack) { + stack = kind === 'dashboard' || kind === 'landing' || kind === 'todo' || kind === 'custom' ? 'static' : 'static'; + } + if (stack === 'next-minimal' && !options.nextAvailable) { + stack = 'vite-react'; + downgraded = true; + notes.push('Next.js is not available in this phase yet; using Vite + React instead'); + } + + const appName = toAppName(trimmed); + return { + appName, + appSlug: slugify(appName), + description: trimmed, + kind, + stack, + style, + features, + downgraded, + notes, + }; +}; + +const normalizeStyle = (raw: string): AppStyle | undefined => { + const value = raw.toLowerCase(); + if (value === 'premium-saas' || value === 'premium' || value === 'saas') return 'premium-saas'; + if (value === 'apple-like' || value === 'apple') return 'apple-like'; + if (value === 'dark') return 'dark'; + if (value === 'minimal') return 'minimal'; + return undefined; +}; diff --git a/src/appBuilder/templateRegistry.ts b/src/appBuilder/templateRegistry.ts new file mode 100644 index 0000000..04900d1 --- /dev/null +++ b/src/appBuilder/templateRegistry.ts @@ -0,0 +1,39 @@ +import {staticTodoTemplate} from './templates/staticTodo.js'; +import {staticLandingTemplate} from './templates/staticLanding.js'; +import {dashboardStaticTemplate} from './templates/dashboardStatic.js'; +import {viteReactTemplate} from './templates/viteReact.js'; +import type {AppRequest, AppTemplate} from './types.js'; + +export const APP_TEMPLATES: AppTemplate[] = [ + staticTodoTemplate, + staticLandingTemplate, + dashboardStaticTemplate, + viteReactTemplate, +]; + +export const getTemplateById = (id: string): AppTemplate | undefined => + APP_TEMPLATES.find((template) => template.id === id); + +/** + * Select a template for a parsed request (Phase 20C, Task C). Vite requests use + * the vite-react template; otherwise the kind maps to a static template, with a + * sensible fallback for `custom`. + */ +export const selectTemplate = (request: AppRequest): AppTemplate => { + if (request.stack === 'vite-react' || request.stack === 'next-minimal') { + return viteReactTemplate; + } + switch (request.kind) { + case 'todo': + return staticTodoTemplate; + case 'dashboard': + return dashboardStaticTemplate; + case 'landing': + return staticLandingTemplate; + case 'custom': + default: + // A generic "app" with no clear kind gets the most universally useful + // static starting point. + return staticTodoTemplate; + } +}; diff --git a/src/appBuilder/templateRenderer.ts b/src/appBuilder/templateRenderer.ts new file mode 100644 index 0000000..5930063 --- /dev/null +++ b/src/appBuilder/templateRenderer.ts @@ -0,0 +1,74 @@ +import {promises as fs} from 'node:fs'; +import path from 'node:path'; + +import type {AppTemplate, TemplateFile, TemplateVariables} from './types.js'; + +/** A rendered file path is unsafe if it is absolute, escapes the app dir, or hits .git/.env. */ +export const isUnsafeTemplatePath = (relPath: string): boolean => { + if (!relPath.trim() || path.isAbsolute(relPath) || relPath.startsWith('~')) return true; + const normalized = path.normalize(relPath); + if (normalized.startsWith('..') || normalized.split(/[\\/]/u).includes('..')) return true; + if (/(?:^|[\\/])\.git(?:[\\/]|$)/u.test(normalized)) return true; + if (/(?:^|[\\/])\.env(?:\.|$)/u.test(normalized)) return true; + return false; +}; + +export interface RenderResult { + files: TemplateFile[]; + errors: string[]; +} + +/** + * Render a template's files for the given variables and validate every output + * path is safe (relative, inside the app dir, never `.git`/`.env`). + */ +export const renderTemplate = (template: AppTemplate, vars: TemplateVariables): RenderResult => { + const files = template.render(vars); + const errors: string[] = []; + for (const file of files) { + if (isUnsafeTemplatePath(file.path)) errors.push(`unsafe template path: ${file.path}`); + } + return {files, errors}; +}; + +export interface WriteResult { + written: string[]; + errors: string[]; +} + +/** + * Write rendered files into `appDir`. Refuses to write outside the directory and + * never overwrites existing files (callers handle overwrite policy upstream). + */ +export const writeTemplateFiles = async ( + appDir: string, + files: TemplateFile[], + options: {overwrite?: boolean} = {}, +): Promise<WriteResult> => { + const written: string[] = []; + const errors: string[] = []; + const root = path.resolve(appDir); + + for (const file of files) { + if (isUnsafeTemplatePath(file.path)) { + errors.push(`refused unsafe path: ${file.path}`); + continue; + } + const target = path.resolve(root, file.path); + if (target !== root && !target.startsWith(root + path.sep)) { + errors.push(`refused path outside app dir: ${file.path}`); + continue; + } + if (!options.overwrite) { + const exists = await fs.access(target).then(() => true).catch(() => false); + if (exists) { + errors.push(`refused to overwrite existing file: ${file.path}`); + continue; + } + } + await fs.mkdir(path.dirname(target), {recursive: true}); + await fs.writeFile(target, file.content, 'utf8'); + written.push(file.path); + } + return {written, errors}; +}; diff --git a/src/appBuilder/templates/dashboardStatic.ts b/src/appBuilder/templates/dashboardStatic.ts new file mode 100644 index 0000000..6c549f3 --- /dev/null +++ b/src/appBuilder/templates/dashboardStatic.ts @@ -0,0 +1,125 @@ +import type {AppTemplate, TemplateFile, TemplateVariables} from '../types.js'; +import {designSystemCss, htmlHead, readme} from './shared.js'; + +const indexHtml = (vars: TemplateVariables): string => `${htmlHead(vars.appName)} +<body> + <div class="layout"> + <aside class="sidebar"> + <div class="brand">${vars.appName}</div> + <nav aria-label="Sidebar"> + <a href="#" class="active">Overview</a> + <a href="#">Customers</a> + <a href="#">Reports</a> + <a href="#">Settings</a> + </nav> + </aside> + <main class="main"> + <header class="topbar"> + <h1>Overview</h1> + <button class="btn" id="refresh">Refresh data</button> + </header> + <section class="stats" id="stats" aria-label="Key metrics"></section> + <section class="card table-card"> + <h2>Recent customers</h2> + <table class="table"> + <thead><tr><th>Name</th><th>Plan</th><th>Status</th><th>MRR</th></tr></thead> + <tbody id="rows"></tbody> + </table> + <p id="empty" class="empty-state" hidden>No data to show.</p> + </section> + </main> + </div> + <script src="app.js"></script> +</body> +</html> +`; + +const stylesCss = (vars: TemplateVariables): string => `${designSystemCss(vars.style)} +.layout { display: grid; grid-template-columns: 16rem 1fr; min-height: 100vh; } +.sidebar { background: var(--surface); border-right: 1px solid var(--border); padding: var(--space-6); } +.sidebar .brand { font-weight: 700; margin-bottom: var(--space-6); } +.sidebar nav { display: grid; gap: var(--space-1); } +.sidebar nav a { color: var(--muted); text-decoration: none; padding: var(--space-2) var(--space-3); border-radius: var(--radius); } +.sidebar nav a.active, .sidebar nav a:hover { background: color-mix(in srgb, var(--accent) 16%, transparent); color: var(--fg); } +.main { padding: var(--space-8); } +.topbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--space-6); } +.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); gap: var(--space-4); margin-bottom: var(--space-6); } +.stat { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--space-6); } +.stat .value { font-size: 1.75rem; font-weight: 700; } +.stat .label { color: var(--muted); font-size: .9rem; } +.table { width: 100%; border-collapse: collapse; } +.table th, .table td { text-align: left; padding: var(--space-3); border-bottom: 1px solid var(--border); } +.table th { color: var(--muted); font-weight: 600; font-size: .85rem; } +.badge { padding: 2px 8px; border-radius: 999px; font-size: .8rem; background: color-mix(in srgb, var(--accent) 18%, transparent); } +@media (max-width: 720px) { .layout { grid-template-columns: 1fr; } .sidebar { display: none; } } +`; + +const appJs = (): string => `'use strict'; +(function () { + // Local sample data (frontend-only, no backend). + var customers = [ + {name: 'Acme Co', plan: 'Pro', status: 'Active', mrr: 240}, + {name: 'Globex', plan: 'Team', status: 'Active', mrr: 120}, + {name: 'Initech', plan: 'Starter', status: 'Trial', mrr: 0}, + {name: 'Umbrella', plan: 'Pro', status: 'Active', mrr: 240}, + {name: 'Hooli', plan: 'Team', status: 'Past due', mrr: 120} + ]; + + function money(n) { return '$' + n.toLocaleString(); } + + function renderStats() { + var mrr = customers.reduce(function (sum, c) { return sum + c.mrr; }, 0); + var active = customers.filter(function (c) { return c.status === 'Active'; }).length; + var stats = [ + {label: 'Customers', value: String(customers.length)}, + {label: 'Active', value: String(active)}, + {label: 'MRR', value: money(mrr)}, + {label: 'Avg / customer', value: money(Math.round(mrr / customers.length))} + ]; + var el = document.getElementById('stats'); + el.innerHTML = stats.map(function (s) { + return '<div class="stat"><div class="value">' + s.value + '</div><div class="label">' + s.label + '</div></div>'; + }).join(''); + } + + function renderRows() { + var rows = document.getElementById('rows'); + var empty = document.getElementById('empty'); + empty.hidden = customers.length > 0; + rows.innerHTML = customers.map(function (c) { + return '<tr><td>' + c.name + '</td><td>' + c.plan + '</td><td><span class="badge">' + c.status + '</span></td><td>' + money(c.mrr) + '</td></tr>'; + }).join(''); + } + + document.getElementById('refresh').addEventListener('click', function () { + customers = customers.slice().sort(function () { return Math.random() - 0.5; }); + renderStats(); + renderRows(); + }); + + renderStats(); + renderRows(); +})(); +`; + +export const dashboardStaticTemplate: AppTemplate = { + id: 'dashboard-static', + name: 'Static Dashboard', + stack: 'static', + kind: 'dashboard', + description: 'A responsive, frontend-only dashboard UI with local sample data.', + requiresCommands: false, + render: (vars): TemplateFile[] => [ + {path: 'index.html', content: indexHtml(vars)}, + {path: 'styles.css', content: stylesCss(vars)}, + {path: 'app.js', content: appJs()}, + {path: 'README.md', content: readme(vars.appName, vars.description, ['open index.html'])}, + ], + validation: () => [ + {kind: 'files-exist', name: 'files created'}, + {kind: 'linked-assets', name: 'linked assets exist', entry: 'index.html'}, + {kind: 'node-check', name: 'JavaScript syntax', file: 'app.js'}, + {kind: 'design-checklist', name: 'design checklist', entry: 'index.html'}, + ], + openInstructions: (slug) => [`open ${slug}/index.html`], +}; diff --git a/src/appBuilder/templates/shared.ts b/src/appBuilder/templates/shared.ts new file mode 100644 index 0000000..503aba6 --- /dev/null +++ b/src/appBuilder/templates/shared.ts @@ -0,0 +1,120 @@ +import type {AppStyle} from '../types.js'; + +export interface Palette { + bg: string; + surface: string; + fg: string; + muted: string; + accent: string; + accentInk: string; + border: string; +} + +/** Deterministic palette per style. Premium-feeling, accessible contrast. */ +export const paletteFor = (style: AppStyle): Palette => { + switch (style) { + case 'apple-like': + return {bg: '#f5f5f7', surface: '#ffffff', fg: '#1d1d1f', muted: '#6e6e73', accent: '#0071e3', accentInk: '#ffffff', border: '#d2d2d7'}; + case 'dark': + return {bg: '#0b0d12', surface: '#151922', fg: '#e6e9ef', muted: '#9aa4b2', accent: '#8b5cf6', accentInk: '#0e0717', border: '#232a36'}; + case 'minimal': + return {bg: '#ffffff', surface: '#ffffff', fg: '#111827', muted: '#6b7280', accent: '#111827', accentInk: '#ffffff', border: '#e5e7eb'}; + case 'premium-saas': + default: + return {bg: '#0b0d12', surface: '#141821', fg: '#eef1f6', muted: '#9aa4b2', accent: '#14b8a6', accentInk: '#02110e', border: '#222a36'}; + } +}; + +/** + * Shared design-system CSS: tokens, reset, spacing/typography scale, accessible + * focus states, buttons, cards, and responsive helpers. Used by all static + * templates so generated apps don't look like student projects. + */ +export const designSystemCss = (style: AppStyle): string => { + const p = paletteFor(style); + return `:root { + --bg: ${p.bg}; + --surface: ${p.surface}; + --fg: ${p.fg}; + --muted: ${p.muted}; + --accent: ${p.accent}; + --accent-ink: ${p.accentInk}; + --border: ${p.border}; + --radius: 0.75rem; + --space-1: 0.25rem; --space-2: 0.5rem; --space-3: 0.75rem; --space-4: 1rem; + --space-6: 1.5rem; --space-8: 2rem; --space-12: 3rem; + --shadow: 0 10px 30px rgba(0,0,0,.18); + color-scheme: light dark; +} +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; } +body { + font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + background: var(--bg); + color: var(--fg); + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} +h1, h2, h3 { line-height: 1.15; letter-spacing: -0.01em; margin: 0 0 var(--space-3); } +h1 { font-size: clamp(1.6rem, 4vw, 2.5rem); } +h2 { font-size: clamp(1.25rem, 3vw, 1.6rem); } +p { color: var(--muted); margin: 0 0 var(--space-4); } +a { color: var(--accent); } +.container { width: min(64rem, 100% - 2rem); margin-inline: auto; } +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2); + padding: var(--space-3) var(--space-6); border: 0; border-radius: var(--radius); + background: var(--accent); color: var(--accent-ink); font-weight: 600; cursor: pointer; + transition: transform .08s ease, opacity .15s ease; +} +.btn:hover { transform: translateY(-1px); } +.btn:focus-visible { outline: 3px solid color-mix(in srgb, var(--accent) 50%, transparent); outline-offset: 2px; } +.btn.secondary { background: transparent; color: var(--fg); border: 1px solid var(--border); } +.card { + background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); + padding: var(--space-6); box-shadow: var(--shadow); +} +.input { + width: 100%; padding: var(--space-3) var(--space-4); border-radius: var(--radius); + border: 1px solid var(--border); background: var(--surface); color: var(--fg); +} +.input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } +.muted { color: var(--muted); } +.empty-state { text-align: center; color: var(--muted); padding: var(--space-8); } +.visually-hidden { + position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; + overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; +} +@media (max-width: 390px) { + .container { width: calc(100% - 1.5rem); } + .btn { width: 100%; } +} +`; +}; + +export const htmlHead = (title: string, extraLinks = ''): string => + `<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>${title} + +${extraLinks}`; + +export const readme = (appName: string, description: string, openSteps: string[]): string => + `# ${appName} + +${description} + +Generated by ApeironCode (template-based, no API key required). + +## Open + +${openSteps.map((s) => `- \`${s}\``).join('\n')} + +## Customize + +- \`apeironcode improve "make this more premium"\` +- \`apeironcode fix\` +`; diff --git a/src/appBuilder/templates/staticLanding.ts b/src/appBuilder/templates/staticLanding.ts new file mode 100644 index 0000000..b1b40f1 --- /dev/null +++ b/src/appBuilder/templates/staticLanding.ts @@ -0,0 +1,102 @@ +import type {AppTemplate, TemplateFile, TemplateVariables} from '../types.js'; +import {designSystemCss, htmlHead, readme} from './shared.js'; + +const indexHtml = (vars: TemplateVariables): string => `${htmlHead(vars.appName)} + + +
    +
    +

    ${vars.appName}

    +

    ${vars.description}

    + +
    +
    +

    Fast

    Loads instantly with zero bloat.

    +

    Polished

    Accessible, responsive, premium by default.

    +

    Local-first

    Your data stays yours.

    +
    +
    +
    +

    Ready to start?

    +

    Join the waitlist and we'll be in touch.

    +
    + + + +
    +

    +
    +
    +
    +

    © ${new Date().getFullYear()} ${vars.appName}. Built with ApeironCode.

    + + + +`; + +const stylesCss = (vars: TemplateVariables): string => `${designSystemCss(vars.style)} +.nav { position: sticky; top: 0; backdrop-filter: blur(8px); background: color-mix(in srgb, var(--bg) 85%, transparent); border-bottom: 1px solid var(--border); } +.nav-inner { display: flex; align-items: center; justify-content: space-between; padding-block: var(--space-3); } +.brand { font-weight: 700; } +.nav nav { display: flex; align-items: center; gap: var(--space-4); } +.nav nav a:not(.btn) { color: var(--muted); text-decoration: none; } +.hero { text-align: center; padding-block: var(--space-12); } +.lead { font-size: 1.125rem; max-width: 40rem; margin-inline: auto; } +.hero-actions { display: flex; gap: var(--space-3); justify-content: center; flex-wrap: wrap; } +.features { display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: var(--space-4); padding-block: var(--space-8); } +.cta { padding-block: var(--space-12); } +.cta-card { text-align: center; max-width: 36rem; margin-inline: auto; } +.waitlist { display: flex; gap: var(--space-2); margin-top: var(--space-4); } +.waitlist .input { flex: 1; } +.footer { padding-block: var(--space-8); text-align: center; } +@media (max-width: 390px) { .waitlist { flex-direction: column; } } +`; + +const appJs = (): string => `'use strict'; +(function () { + var form = document.getElementById('waitlist'); + var email = document.getElementById('email'); + var status = document.getElementById('waitlist-status'); + form.addEventListener('submit', function (event) { + event.preventDefault(); + var value = email.value.trim(); + if (!value) return; + status.textContent = 'Thanks! ' + value + ' is on the waitlist (demo only).'; + email.value = ''; + }); +})(); +`; + +export const staticLandingTemplate: AppTemplate = { + id: 'static-landing', + name: 'Static Landing Page', + stack: 'static', + kind: 'landing', + description: 'A premium static landing page with hero, features, and a mock waitlist.', + requiresCommands: false, + render: (vars): TemplateFile[] => [ + {path: 'index.html', content: indexHtml(vars)}, + {path: 'styles.css', content: stylesCss(vars)}, + {path: 'app.js', content: appJs()}, + {path: 'README.md', content: readme(vars.appName, vars.description, ['open index.html'])}, + ], + validation: () => [ + {kind: 'files-exist', name: 'files created'}, + {kind: 'linked-assets', name: 'linked assets exist', entry: 'index.html'}, + {kind: 'node-check', name: 'JavaScript syntax', file: 'app.js'}, + {kind: 'design-checklist', name: 'design checklist', entry: 'index.html'}, + ], + openInstructions: (slug) => [`open ${slug}/index.html`], +}; diff --git a/src/appBuilder/templates/staticTodo.ts b/src/appBuilder/templates/staticTodo.ts new file mode 100644 index 0000000..cfc580a --- /dev/null +++ b/src/appBuilder/templates/staticTodo.ts @@ -0,0 +1,104 @@ +import type {AppTemplate, TemplateFile, TemplateVariables} from '../types.js'; +import {designSystemCss, htmlHead, readme} from './shared.js'; + +const indexHtml = (vars: TemplateVariables): string => `${htmlHead(vars.appName)} + +
    +
    +

    ${vars.appName}

    +

    ${vars.description}

    +
    +
    + + + +
    +
    +
      +

      No tasks yet. Add your first one above.

      +
      +
      + + + +`; + +const stylesCss = (vars: TemplateVariables): string => `${designSystemCss(vars.style)} +.app { padding-block: var(--space-12); max-width: 34rem; } +.app-header { margin-bottom: var(--space-6); } +.add-form { display: flex; gap: var(--space-2); margin-bottom: var(--space-6); } +.add-form .input { flex: 1; } +.list { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-2); } +.list li { + display: flex; align-items: center; gap: var(--space-3); + padding: var(--space-3) var(--space-4); background: var(--surface); + border: 1px solid var(--border); border-radius: var(--radius); +} +.list li.done .label { text-decoration: line-through; color: var(--muted); } +.list .label { flex: 1; cursor: pointer; } +.list .remove { background: transparent; border: 0; color: var(--muted); cursor: pointer; font-size: 1.1rem; } +.list .remove:hover { color: var(--fg); } +`; + +const appJs = (): string => `'use strict'; +(function () { + var form = document.getElementById('add-form'); + var input = document.getElementById('new-todo'); + var list = document.getElementById('list'); + var empty = document.getElementById('empty'); + + function refreshEmpty() { + empty.style.display = list.children.length === 0 ? 'block' : 'none'; + } + + function addTodo(text) { + var li = document.createElement('li'); + var label = document.createElement('span'); + label.className = 'label'; + label.textContent = text; + label.addEventListener('click', function () { li.classList.toggle('done'); }); + var remove = document.createElement('button'); + remove.className = 'remove'; + remove.setAttribute('aria-label', 'Remove task'); + remove.textContent = '\\u00d7'; + remove.addEventListener('click', function () { li.remove(); refreshEmpty(); }); + li.appendChild(label); + li.appendChild(remove); + list.appendChild(li); + refreshEmpty(); + } + + form.addEventListener('submit', function (event) { + event.preventDefault(); + var text = input.value.trim(); + if (!text) return; + addTodo(text); + input.value = ''; + input.focus(); + }); + + refreshEmpty(); +})(); +`; + +export const staticTodoTemplate: AppTemplate = { + id: 'static-todo', + name: 'Static Todo App', + stack: 'static', + kind: 'todo', + description: 'A polished static HTML/CSS/JS todo app. No install required.', + requiresCommands: false, + render: (vars): TemplateFile[] => [ + {path: 'index.html', content: indexHtml(vars)}, + {path: 'styles.css', content: stylesCss(vars)}, + {path: 'app.js', content: appJs()}, + {path: 'README.md', content: readme(vars.appName, vars.description, ['open index.html'])}, + ], + validation: () => [ + {kind: 'files-exist', name: 'files created'}, + {kind: 'linked-assets', name: 'linked assets exist', entry: 'index.html'}, + {kind: 'node-check', name: 'JavaScript syntax', file: 'app.js'}, + {kind: 'design-checklist', name: 'design checklist', entry: 'index.html'}, + ], + openInstructions: (slug) => [`open ${slug}/index.html`], +}; diff --git a/src/appBuilder/templates/viteReact.ts b/src/appBuilder/templates/viteReact.ts new file mode 100644 index 0000000..0b6c2d0 --- /dev/null +++ b/src/appBuilder/templates/viteReact.ts @@ -0,0 +1,136 @@ +import type {AppTemplate, TemplateFile, TemplateVariables} from '../types.js'; +import {designSystemCss, readme} from './shared.js'; + +const packageJson = (vars: TemplateVariables): string => `${JSON.stringify({ + name: vars.appSlug, + private: true, + version: '0.1.0', + type: 'module', + scripts: { + dev: 'vite', + build: 'tsc -b && vite build', + typecheck: 'tsc --noEmit', + preview: 'vite preview', + }, + dependencies: {react: '^18.3.1', 'react-dom': '^18.3.1'}, + devDependencies: { + '@types/react': '^18.3.3', + '@types/react-dom': '^18.3.0', + '@vitejs/plugin-react': '^4.3.1', + typescript: '^5.5.0', + vite: '^5.4.0', + }, +}, null, 2)}\n`; + +const tsconfig = (): string => `${JSON.stringify({ + compilerOptions: { + target: 'ES2020', useDefineForClassFields: true, lib: ['ES2020', 'DOM', 'DOM.Iterable'], + module: 'ESNext', skipLibCheck: true, moduleResolution: 'bundler', resolveJsonModule: true, + isolatedModules: true, noEmit: true, jsx: 'react-jsx', strict: true, + }, + include: ['src'], +}, null, 2)}\n`; + +const viteConfig = (): string => `import {defineConfig} from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({plugins: [react()]}); +`; + +const indexHtml = (vars: TemplateVariables): string => ` + + + + + ${vars.appName} + + +
      + + + +`; + +const mainTsx = (): string => `import React from 'react'; +import {createRoot} from 'react-dom/client'; +import {App} from './App.js'; +import './styles.css'; + +createRoot(document.getElementById('root')!).render( + + + , +); +`; + +const appTsx = (vars: TemplateVariables): string => `import {useState} from 'react'; + +interface Todo { + id: number; + text: string; + done: boolean; +} + +export function App(): JSX.Element { + const [todos, setTodos] = useState([]); + const [text, setText] = useState(''); + + const add = (event: React.FormEvent): void => { + event.preventDefault(); + const value = text.trim(); + if (!value) return; + setTodos((prev) => [...prev, {id: Date.now(), text: value, done: false}]); + setText(''); + }; + + return ( +
      +

      ${vars.appName}

      +

      ${vars.description}

      +
      + + setText(e.target.value)} placeholder="Add a task…" /> + +
      + {todos.length === 0 ? ( +

      No tasks yet. Add your first one above.

      + ) : ( +
        + {todos.map((todo) => ( +
      • + setTodos((p) => p.map((t) => (t.id === todo.id ? {...t, done: !t.done} : t)))}> + {todo.text} + +
      • + ))} +
      + )} +
      + ); +} +`; + +export const viteReactTemplate: AppTemplate = { + id: 'vite-react', + name: 'Vite + React (TypeScript)', + stack: 'vite-react', + kind: 'custom', + description: 'A React + TypeScript app scaffolded with Vite. Requires npm install to run.', + requiresCommands: true, + render: (vars): TemplateFile[] => [ + {path: 'package.json', content: packageJson(vars)}, + {path: 'tsconfig.json', content: tsconfig()}, + {path: 'vite.config.ts', content: viteConfig()}, + {path: 'index.html', content: indexHtml(vars)}, + {path: 'src/main.tsx', content: mainTsx()}, + {path: 'src/App.tsx', content: appTsx(vars)}, + {path: 'src/styles.css', content: `${designSystemCss(vars.style)}\n.app { padding-block: var(--space-12); max-width: 34rem; }\n.add-form { display: flex; gap: var(--space-2); margin-bottom: var(--space-6); }\n.add-form .input { flex: 1; }\n.list { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-2); }\n.list li { padding: var(--space-3) var(--space-4); background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); }\n.list li.done .label { text-decoration: line-through; color: var(--muted); }\n.list .label { cursor: pointer; }\n`}, + {path: 'README.md', content: readme(vars.appName, vars.description, ['cd ' + vars.appSlug, 'npm install', 'npm run dev'])}, + ], + validation: () => [ + {kind: 'files-exist', name: 'files created'}, + {kind: 'command', name: 'build', command: 'npm run build'}, + {kind: 'command', name: 'typecheck', command: 'npm run typecheck'}, + ], + openInstructions: (slug) => [`cd ${slug}`, 'npm install', 'npm run dev'], +}; diff --git a/src/appBuilder/types.ts b/src/appBuilder/types.ts new file mode 100644 index 0000000..169424a --- /dev/null +++ b/src/appBuilder/types.ts @@ -0,0 +1,70 @@ +/** + * Phase 20C app-builder types. + * + * The app builder is deterministic: a parsed request selects a template, the + * template renders real files, and a validator checks them. No API key or model + * is required for template generation; AI customization is an optional layer. + */ + +export type AppStack = 'static' | 'vite-react' | 'next-minimal'; +export type AppKind = 'todo' | 'dashboard' | 'landing' | 'custom'; +export type AppStyle = 'premium-saas' | 'minimal' | 'dark' | 'apple-like'; + +export interface AppRequest { + /** Display name, e.g. "Premium Todo App". */ + appName: string; + /** Directory-safe slug, e.g. "premium-todo-app". */ + appSlug: string; + description: string; + kind: AppKind; + stack: AppStack; + style: AppStyle; + /** Mock/local UI features detected (e.g. "tasks", "dashboard", "login-mock"). */ + features: string[]; + /** True when the user asked for something this phase can only mock (auth/db). */ + downgraded: boolean; + /** Human-readable notes about downgrades/assumptions (shown honestly in report). */ + notes: string[]; +} + +export interface TemplateFile { + path: string; + content: string; +} + +export interface TemplateVariables { + appName: string; + appSlug: string; + description: string; + style: AppStyle; + features: string[]; +} + +export type ValidationStepKind = 'files-exist' | 'linked-assets' | 'node-check' | 'design-checklist' | 'command'; + +export interface TemplateValidationStep { + kind: ValidationStepKind; + /** For 'command' steps: the script command to run (requires approval/temp). */ + command?: string; + /** For 'node-check': the JS file to syntax-check, relative to the app dir. */ + file?: string; + /** For 'linked-assets'/'design-checklist': the HTML entry file. */ + entry?: string; + name: string; +} + +export interface AppTemplate { + id: string; + name: string; + stack: AppStack; + kind: AppKind; + description: string; + /** Render deterministic files for the given variables. */ + render: (vars: TemplateVariables) => TemplateFile[]; + /** Validation steps run after files are created. */ + validation: (vars: TemplateVariables) => TemplateValidationStep[]; + /** How to open/run the app, shown in the final report. */ + openInstructions: (slug: string) => string[]; + /** True when this stack may require install/build commands (Vite/Next). */ + requiresCommands: boolean; +} diff --git a/src/cli/appBuilderHandler.ts b/src/cli/appBuilderHandler.ts new file mode 100644 index 0000000..5fa89ac --- /dev/null +++ b/src/cli/appBuilderHandler.ts @@ -0,0 +1,76 @@ +import readline from 'node:readline/promises'; + +import {ConfigStore} from '../config/config.js'; +import {buildApp} from '../appBuilder/appBuilder.js'; +import {formatAppReport} from '../appBuilder/appReport.js'; + +export interface NewCliOptions { + stack?: string; + style?: string; + dir?: string; + yes?: boolean; + dryRun?: boolean; + overwrite?: boolean; +} + +/** + * `apeironcode new` / `apeironcode build` handler (Phase 20C). Deterministic + * template-based app creation — no API key or model required. Writes are + * approval-gated: auto in bypass/trusted mode or with `--yes`, otherwise a + * single bundled y/N prompt. Non-interactive without `--yes` declines. + */ +export const runNewCommand = async (cwd: string, idea: string | undefined, options: NewCliOptions): Promise => { + const trimmed = (idea ?? '').trim(); + if (!trimmed && !options.dir) { + process.stdout.write( + 'Describe the app to create, e.g.\n' + + ' apeironcode new "premium todo app"\n' + + ' apeironcode new "CRM dashboard" --stack dashboard\n' + + ' apeironcode new "landing page for an AI startup" --style premium-saas\n', + ); + return; + } + + const configStore = new ConfigStore(cwd); + const resolved = await configStore.load(); + const autoApprove = + resolved.effective.approvalMode === 'bypass' || resolved.effective.approvalMode === 'trusted'; + + const approve = async (bundle: {title: string; body: string; risk: string}): Promise => { + process.stdout.write(`\n${bundle.body}\n`); + if (options.dryRun) return false; + if (autoApprove || options.yes) { + process.stdout.write('Auto-approved (approval mode allows it).\n'); + return true; + } + if (!process.stdin.isTTY) { + process.stdout.write('Non-interactive terminal: declining (re-run with --yes to create the app).\n'); + return false; + } + const rl = readline.createInterface({input: process.stdin, output: process.stdout}); + try { + const answer = (await rl.question('Create this app? [y/N] ')).trim().toLowerCase(); + return answer === 'y' || answer === 'yes'; + } finally { + rl.close(); + } + }; + + const result = await buildApp(trimmed || 'app', { + cwd, + dir: options.dir, + stack: options.stack, + style: options.style, + dryRun: options.dryRun, + overwrite: options.overwrite, + // Command validation (Vite build) is left to the user via the open + // instructions; the CLI never auto-installs dependencies. + runCommands: false, + approve, + }); + + process.stdout.write(`${formatAppReport(result)}\n`); + if (result.status === 'blocked-existing' || result.status === 'write-error') { + process.exitCode = 1; + } +}; diff --git a/src/cli/commands/types.ts b/src/cli/commands/types.ts index 3b59a82..c1c1b5a 100644 --- a/src/cli/commands/types.ts +++ b/src/cli/commands/types.ts @@ -1,5 +1,6 @@ import type {AgentMode} from '../../agent/types.js'; import type {FixCliOptions} from '../fixHandler.js'; +import type {NewCliOptions} from '../appBuilderHandler.js'; export interface BgTaskCreateOptions { kind?: string; @@ -50,6 +51,7 @@ export interface CliHandlers { deleteSession: (sessionId: string) => Promise; demo: (scenario?: string) => Promise; fix: (options: FixCliOptions) => Promise; + newApp: (idea: string | undefined, options: NewCliOptions) => Promise; doctor: (options: DoctorCliOptions) => Promise; debugConfig: () => Promise; debugLogs: (options?: {last?: number}) => Promise; diff --git a/src/cli/help.ts b/src/cli/help.ts index 2b65b29..82d9e8c 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -11,6 +11,7 @@ import {collectOptions} from './commands.js'; * three onboarding commands. */ export const BEGINNER_COMMANDS = [ + 'new', 'fix', 'build', 'improve', @@ -149,7 +150,33 @@ export const registerWorkflowAndHelp = (program: Command, handlers: CliHandlers) }); }); - workflow('build', 'feature', 'build an app or feature, e.g. build "a todo app"'); + // `new` and `build` create a real app from a deterministic template + // (Phase 20C). No API key or model required for template generation. + const appBuilder = (name: 'new' | 'build', summary: string): void => { + program + .command(name) + .description(summary) + .argument('[idea...]', 'what to build, e.g. "premium todo app"') + .option('--stack ', 'static | dashboard | vite | next') + .option('--style