Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d56e31a25a | ||
|
|
92af2dd7e8 | ||
|
|
e20075db3f | ||
|
|
159e7ad7ac | ||
|
|
aaa71df9b2 | ||
|
|
a7fa8c76a4 | ||
|
|
1ee91fcc2f | ||
|
|
427f0d5132 | ||
|
|
2a824b6d19 | ||
|
|
2c17c9e7f6 | ||
|
|
1f97febf2a | ||
|
|
adc34d31f1 | ||
|
|
f2c31a0c6f | ||
|
|
f52ee2510d | ||
|
|
4f9872e7f3 | ||
|
|
9683eb0d05 | ||
|
|
3d7c16fb2a | ||
|
|
d532a2a490 | ||
|
|
23832461a7 | ||
|
|
4baf27837e |
25
.github/workflows/ci.yml
vendored
25
.github/workflows/ci.yml
vendored
@@ -5,7 +5,10 @@ on:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
@@ -25,24 +28,20 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Cache OpenCode binary
|
||||
id: cache-opencode
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.bun/bin/opencode
|
||||
key: ${{ runner.os }}-opencode-${{ hashFiles('.github/workflows/ci.yml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-opencode-
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Install OpenCode CLI
|
||||
if: steps.cache-opencode.outputs.cache-hit != 'true'
|
||||
run: bun install -g opencode-ai
|
||||
run: |
|
||||
bun install -g opencode-ai
|
||||
echo "$HOME/.bun/bin" >> $GITHUB_PATH
|
||||
shell: bash
|
||||
|
||||
- name: Verify OpenCode installation
|
||||
run: opencode --version
|
||||
run: |
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
opencode --version
|
||||
shell: bash
|
||||
|
||||
- name: Type check
|
||||
run: bun run tsc -noEmit -skipLibCheck
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
/.opencode/plans
|
||||
/node_modules
|
||||
/data.json
|
||||
/main.js
|
||||
|
||||
@@ -4,7 +4,7 @@ description: Implement tasks from an OpenSpec change (Experimental)
|
||||
|
||||
Implement tasks from an OpenSpec change.
|
||||
|
||||
**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
**Input**: Optionally specify a change name (e.g., `/opsx-apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
@@ -15,14 +15,14 @@ Implement tasks from an OpenSpec change.
|
||||
- Auto-select if only one active change exists
|
||||
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
|
||||
|
||||
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
|
||||
Always announce: "Using change: <name>" and how to override (e.g., `/opsx-apply <other>`).
|
||||
|
||||
2. **Check status to understand the schema**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used (e.g., "spec-driven", "tdd")
|
||||
- `schemaName`: The workflow being used (e.g., "spec-driven")
|
||||
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
|
||||
|
||||
3. **Get apply instructions**
|
||||
@@ -38,7 +38,7 @@ Implement tasks from an OpenSpec change.
|
||||
- Dynamic instruction based on current state
|
||||
|
||||
**Handle states:**
|
||||
- If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx:continue`
|
||||
- If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx-continue`
|
||||
- If `state: "all_done"`: congratulate, suggest archive
|
||||
- Otherwise: proceed to implementation
|
||||
|
||||
@@ -47,7 +47,6 @@ Implement tasks from an OpenSpec change.
|
||||
Read the files listed in `contextFiles` from the apply instructions output.
|
||||
The files depend on the schema being used:
|
||||
- **spec-driven**: proposal, specs, design, tasks
|
||||
- **tdd**: spec, tests, implementation, docs
|
||||
- Other schemas: follow the contextFiles from CLI output
|
||||
|
||||
5. **Show current progress**
|
||||
@@ -109,7 +108,7 @@ Working on task 4/7: <task description>
|
||||
- [x] Task 2
|
||||
...
|
||||
|
||||
All tasks complete! Ready to archive this change.
|
||||
All tasks complete! You can archive this change with `/opsx-archive`.
|
||||
```
|
||||
|
||||
**Output On Pause (Issue Encountered)**
|
||||
|
||||
@@ -4,7 +4,7 @@ description: Archive a completed change in the experimental workflow
|
||||
|
||||
Archive a completed change in the experimental workflow.
|
||||
|
||||
**Input**: Optionally specify a change name after `/opsx:archive` (e.g., `/opsx:archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
**Input**: Optionally specify a change name after `/opsx-archive` (e.g., `/opsx-archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
@@ -56,7 +56,7 @@ Archive a completed change in the experimental workflow.
|
||||
- If changes needed: "Sync now (recommended)", "Archive without syncing"
|
||||
- If already synced: "Archive now", "Sync anyway", "Cancel"
|
||||
|
||||
If user chooses sync, execute `/opsx:sync` logic. Proceed to archive regardless of choice.
|
||||
If user chooses sync, execute `/opsx-sync` logic. Proceed to archive regardless of choice.
|
||||
|
||||
5. **Perform the archive**
|
||||
|
||||
@@ -150,5 +150,5 @@ Target archive directory already exists.
|
||||
- Don't block archive on warnings - just inform and confirm
|
||||
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
|
||||
- Show clear summary of what happened
|
||||
- If sync is requested, use /opsx:sync approach (agent-driven)
|
||||
- If sync is requested, use /opsx-sync approach (agent-driven)
|
||||
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
|
||||
|
||||
@@ -222,7 +222,7 @@ Failed K changes:
|
||||
```
|
||||
## No Changes to Archive
|
||||
|
||||
No active changes found. Use `/opsx:new` to create a new change.
|
||||
No active changes found. Use `/opsx-new` to create a new change.
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
|
||||
@@ -4,7 +4,7 @@ description: Continue working on a change - create the next artifact (Experiment
|
||||
|
||||
Continue working on a change by creating the next artifact.
|
||||
|
||||
**Input**: Optionally specify a change name after `/opsx:continue` (e.g., `/opsx:continue add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
**Input**: Optionally specify a change name after `/opsx-continue` (e.g., `/opsx-continue add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
@@ -27,7 +27,7 @@ Continue working on a change by creating the next artifact.
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to understand current state. The response includes:
|
||||
- `schemaName`: The workflow schema being used (e.g., "spec-driven", "tdd")
|
||||
- `schemaName`: The workflow schema being used (e.g., "spec-driven")
|
||||
- `artifacts`: Array of artifacts with their status ("done", "ready", "blocked")
|
||||
- `isComplete`: Boolean indicating if all artifacts are complete
|
||||
|
||||
@@ -38,7 +38,7 @@ Continue working on a change by creating the next artifact.
|
||||
**If all artifacts are complete (`isComplete: true`)**:
|
||||
- Congratulate the user
|
||||
- Show final status including the schema used
|
||||
- Suggest: "All artifacts created! You can now implement this change or archive it."
|
||||
- Suggest: "All artifacts created! You can now implement this change with `/opsx-apply` or archive it with `/opsx-archive`."
|
||||
- STOP
|
||||
|
||||
---
|
||||
@@ -82,7 +82,7 @@ After each invocation, show:
|
||||
- Schema workflow being used
|
||||
- Current progress (N/M complete)
|
||||
- What artifacts are now unlocked
|
||||
- Prompt: "Run `/opsx:continue` to create the next artifact"
|
||||
- Prompt: "Run `/opsx-continue` to create the next artifact"
|
||||
|
||||
**Artifact Creation Guidelines**
|
||||
|
||||
@@ -93,16 +93,10 @@ Common artifact patterns:
|
||||
**spec-driven schema** (proposal → specs → design → tasks):
|
||||
- **proposal.md**: Ask user about the change if not clear. Fill in Why, What Changes, Capabilities, Impact.
|
||||
- The Capabilities section is critical - each capability listed will need a spec file.
|
||||
- **specs/*.md**: Create one spec per capability listed in the proposal.
|
||||
- **specs/<capability>/spec.md**: Create one spec per capability listed in the proposal's Capabilities section (use the capability name, not the change name).
|
||||
- **design.md**: Document technical decisions, architecture, and implementation approach.
|
||||
- **tasks.md**: Break down implementation into checkboxed tasks.
|
||||
|
||||
**tdd schema** (spec → tests → implementation → docs):
|
||||
- **spec.md**: Feature specification defining what to build.
|
||||
- **tests/*.test.ts**: Write tests BEFORE implementation (TDD red phase).
|
||||
- **src/*.ts**: Implement to make tests pass (TDD green phase).
|
||||
- **docs/*.md**: Document the implemented feature.
|
||||
|
||||
For other schemas, follow the `instruction` field from the CLI output.
|
||||
|
||||
**Guardrails**
|
||||
|
||||
@@ -4,11 +4,11 @@ description: Enter explore mode - think through ideas, investigate problems, cla
|
||||
|
||||
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
||||
|
||||
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first (e.g., start a change with `/opsx:new` or `/opsx:ff`). You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
|
||||
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first (e.g., start a change with `/opsx-new` or `/opsx-ff`). You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
|
||||
|
||||
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
||||
|
||||
**Input**: The argument after `/opsx:explore` is whatever the user wants to think about. Could be:
|
||||
**Input**: The argument after `/opsx-explore` is whatever the user wants to think about. Could be:
|
||||
- A vague idea: "real-time collaboration"
|
||||
- A specific problem: "the auth system is getting unwieldy"
|
||||
- A change name: "add-dark-mode" (to explore in context of that change)
|
||||
@@ -98,7 +98,7 @@ If the user mentioned a specific change name, read its artifacts for context.
|
||||
Think freely. When insights crystallize, you might offer:
|
||||
|
||||
- "This feels solid enough to start a change. Want me to create one?"
|
||||
→ Can transition to `/opsx:new` or `/opsx:ff`
|
||||
→ Can transition to `/opsx-new` or `/opsx-ff`
|
||||
- Or keep exploring - no pressure to formalize
|
||||
|
||||
### When a change exists
|
||||
@@ -150,7 +150,7 @@ If the user mentions a change or you detect one is relevant:
|
||||
|
||||
There's no required ending. Discovery might:
|
||||
|
||||
- **Flow into action**: "Ready to start? `/opsx:new` or `/opsx:ff`"
|
||||
- **Flow into action**: "Ready to start? `/opsx-new` or `/opsx-ff`"
|
||||
- **Result in artifact updates**: "Updated design.md with these decisions"
|
||||
- **Just provide clarity**: User has what they need, moves on
|
||||
- **Continue later**: "We can pick this up anytime"
|
||||
|
||||
@@ -4,7 +4,7 @@ description: Create a change and generate all artifacts needed for implementatio
|
||||
|
||||
Fast-forward through artifact creation - generate everything needed to start implementation.
|
||||
|
||||
**Input**: The argument after `/opsx:ff` is the change name (kebab-case), OR a description of what the user wants to build.
|
||||
**Input**: The argument after `/opsx-ff` is the change name (kebab-case), OR a description of what the user wants to build.
|
||||
|
||||
**Steps**
|
||||
|
||||
@@ -74,7 +74,7 @@ After completing all artifacts, summarize:
|
||||
- Change name and location
|
||||
- List of artifacts created with brief descriptions
|
||||
- What's ready: "All artifacts created! Ready for implementation."
|
||||
- Prompt: "Run `/opsx:apply` to start implementing."
|
||||
- Prompt: "Run `/opsx-apply` to start implementing."
|
||||
|
||||
**Artifact Creation Guidelines**
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description: Start a new change using the experimental artifact workflow (OPSX)
|
||||
|
||||
Start a new change using the experimental artifact-driven approach.
|
||||
|
||||
**Input**: The argument after `/opsx:new` is the change name (kebab-case), OR a description of what the user wants to build.
|
||||
**Input**: The argument after `/opsx-new` is the change name (kebab-case), OR a description of what the user wants to build.
|
||||
|
||||
**Steps**
|
||||
|
||||
@@ -22,7 +22,6 @@ Start a new change using the experimental artifact-driven approach.
|
||||
Use the default schema (omit `--schema`) unless the user explicitly requests a different workflow.
|
||||
|
||||
**Use a different schema only if the user mentions:**
|
||||
- "tdd" or "test-driven" → use `--schema tdd`
|
||||
- A specific schema name → use `--schema <name>`
|
||||
- "show workflows" or "what workflows" → run `openspec schemas --json` and let them choose
|
||||
|
||||
@@ -57,11 +56,11 @@ After completing the steps, summarize:
|
||||
- Schema/workflow being used and its artifact sequence
|
||||
- Current status (0/N artifacts complete)
|
||||
- The template for the first artifact
|
||||
- Prompt: "Ready to create the first artifact? Run `/opsx:continue` or just describe what this change is about and I'll draft it."
|
||||
- Prompt: "Ready to create the first artifact? Run `/opsx-continue` or just describe what this change is about and I'll draft it."
|
||||
|
||||
**Guardrails**
|
||||
- Do NOT create any artifacts yet - just show the instructions
|
||||
- Do NOT advance beyond showing the first artifact template
|
||||
- If the name is invalid (not kebab-case), ask for a valid name
|
||||
- If a change with that name already exists, suggest using `/opsx:continue` instead
|
||||
- If a change with that name already exists, suggest using `/opsx-continue` instead
|
||||
- Pass --schema if using a non-default workflow
|
||||
|
||||
@@ -15,7 +15,7 @@ openspec status --json 2>&1 || echo "NOT_INITIALIZED"
|
||||
```
|
||||
|
||||
**If not initialized:**
|
||||
> OpenSpec isn't set up in this project yet. Run `openspec init` first, then come back to `/opsx:onboard`.
|
||||
> OpenSpec isn't set up in this project yet. Run `openspec init` first, then come back to `/opsx-onboard`.
|
||||
|
||||
Stop here if not initialized.
|
||||
|
||||
@@ -139,7 +139,7 @@ Spend 1-2 minutes investigating the relevant code:
|
||||
│ [Optional: ASCII diagram if helpful] │
|
||||
└─────────────────────────────────────────┘
|
||||
|
||||
Explore mode (`/opsx:explore`) is for this kind of thinking—investigating before implementing. You can use it anytime you need to think through a problem.
|
||||
Explore mode (`/opsx-explore`) is for this kind of thinking—investigating before implementing. You can use it anytime you need to think through a problem.
|
||||
|
||||
Now let's create a change to hold our work.
|
||||
```
|
||||
@@ -409,7 +409,7 @@ The change is implemented! One more step—let's archive it.
|
||||
```
|
||||
## Archiving
|
||||
|
||||
When a change is complete, we archive it. This moves it from `openspec/changes/` to `openspec/archive/YYYY-MM-DD--<name>/`.
|
||||
When a change is complete, we archive it. This moves it from `openspec/changes/` to `openspec/changes/archive/YYYY-MM-DD-<name>/`.
|
||||
|
||||
Archived changes become your project's decision history—you can always find them later to understand why something was built a certain way.
|
||||
```
|
||||
@@ -421,7 +421,7 @@ openspec archive "<name>"
|
||||
|
||||
**SHOW:**
|
||||
```
|
||||
Archived to: `openspec/archive/YYYY-MM-DD--<name>/`
|
||||
Archived to: `openspec/changes/archive/YYYY-MM-DD-<name>/`
|
||||
|
||||
The change is now part of your project's history. The code is in your codebase, the decision record is preserved.
|
||||
```
|
||||
@@ -452,19 +452,19 @@ This same rhythm works for any size change—a small fix or a major feature.
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `/opsx:explore` | Think through problems before/during work |
|
||||
| `/opsx:new` | Start a new change, step through artifacts |
|
||||
| `/opsx:ff` | Fast-forward: create all artifacts at once |
|
||||
| `/opsx:continue` | Continue working on an existing change |
|
||||
| `/opsx:apply` | Implement tasks from a change |
|
||||
| `/opsx:verify` | Verify implementation matches artifacts |
|
||||
| `/opsx:archive` | Archive a completed change |
|
||||
| `/opsx-explore` | Think through problems before/during work |
|
||||
| `/opsx-new` | Start a new change, step through artifacts |
|
||||
| `/opsx-ff` | Fast-forward: create all artifacts at once |
|
||||
| `/opsx-continue` | Continue working on an existing change |
|
||||
| `/opsx-apply` | Implement tasks from a change |
|
||||
| `/opsx-verify` | Verify implementation matches artifacts |
|
||||
| `/opsx-archive` | Archive a completed change |
|
||||
|
||||
---
|
||||
|
||||
## What's Next?
|
||||
|
||||
Try `/opsx:new` or `/opsx:ff` on something you actually want to build. You've got the rhythm now!
|
||||
Try `/opsx-new` or `/opsx-ff` on something you actually want to build. You've got the rhythm now!
|
||||
```
|
||||
|
||||
---
|
||||
@@ -479,8 +479,8 @@ If the user says they need to stop, want to pause, or seem disengaged:
|
||||
No problem! Your change is saved at `openspec/changes/<name>/`.
|
||||
|
||||
To pick up where we left off later:
|
||||
- `/opsx:continue <name>` - Resume artifact creation
|
||||
- `/opsx:apply <name>` - Jump to implementation (if tasks exist)
|
||||
- `/opsx-continue <name>` - Resume artifact creation
|
||||
- `/opsx-apply <name>` - Jump to implementation (if tasks exist)
|
||||
|
||||
The work won't be lost. Come back whenever you're ready.
|
||||
```
|
||||
@@ -496,15 +496,15 @@ If the user says they just want to see the commands or skip the tutorial:
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `/opsx:explore` | Think through problems (no code changes) |
|
||||
| `/opsx:new <name>` | Start a new change, step by step |
|
||||
| `/opsx:ff <name>` | Fast-forward: all artifacts at once |
|
||||
| `/opsx:continue <name>` | Continue an existing change |
|
||||
| `/opsx:apply <name>` | Implement tasks |
|
||||
| `/opsx:verify <name>` | Verify implementation |
|
||||
| `/opsx:archive <name>` | Archive when done |
|
||||
| `/opsx-explore` | Think through problems (no code changes) |
|
||||
| `/opsx-new <name>` | Start a new change, step by step |
|
||||
| `/opsx-ff <name>` | Fast-forward: all artifacts at once |
|
||||
| `/opsx-continue <name>` | Continue an existing change |
|
||||
| `/opsx-apply <name>` | Implement tasks |
|
||||
| `/opsx-verify <name>` | Verify implementation |
|
||||
| `/opsx-archive <name>` | Archive when done |
|
||||
|
||||
Try `/opsx:new` to start your first change, or `/opsx:ff` if you want to move fast.
|
||||
Try `/opsx-new` to start your first change, or `/opsx-ff` if you want to move fast.
|
||||
```
|
||||
|
||||
Exit gracefully.
|
||||
|
||||
@@ -6,7 +6,7 @@ Sync delta specs from a change to main specs.
|
||||
|
||||
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
|
||||
|
||||
**Input**: Optionally specify a change name after `/opsx:sync` (e.g., `/opsx:sync add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
**Input**: Optionally specify a change name after `/opsx-sync` (e.g., `/opsx-sync add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description: Verify implementation matches change artifacts before archiving
|
||||
|
||||
Verify that an implementation matches the change artifacts (specs, tasks, design).
|
||||
|
||||
**Input**: Optionally specify a change name after `/opsx:verify` (e.g., `/opsx:verify add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
**Input**: Optionally specify a change name after `/opsx-verify` (e.g., `/opsx-verify add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
@@ -23,7 +23,7 @@ Verify that an implementation matches the change artifacts (specs, tasks, design
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used (e.g., "spec-driven", "tdd")
|
||||
- `schemaName`: The workflow being used (e.g., "spec-driven")
|
||||
- Which artifacts exist for this change
|
||||
|
||||
3. **Get the change directory and load artifacts**
|
||||
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.0"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Implement tasks from an OpenSpec change.
|
||||
@@ -22,14 +22,14 @@ Implement tasks from an OpenSpec change.
|
||||
- Auto-select if only one active change exists
|
||||
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
|
||||
|
||||
Always announce: "Using change: <name>" and how to override (e.g., `/opsx:apply <other>`).
|
||||
Always announce: "Using change: <name>" and how to override (e.g., `/opsx-apply <other>`).
|
||||
|
||||
2. **Check status to understand the schema**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used (e.g., "spec-driven", "tdd")
|
||||
- `schemaName`: The workflow being used (e.g., "spec-driven")
|
||||
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
|
||||
|
||||
3. **Get apply instructions**
|
||||
@@ -54,7 +54,6 @@ Implement tasks from an OpenSpec change.
|
||||
Read the files listed in `contextFiles` from the apply instructions output.
|
||||
The files depend on the schema being used:
|
||||
- **spec-driven**: proposal, specs, design, tasks
|
||||
- **tdd**: spec, tests, implementation, docs
|
||||
- Other schemas: follow the contextFiles from CLI output
|
||||
|
||||
5. **Show current progress**
|
||||
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.0"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Archive a completed change in the experimental workflow.
|
||||
@@ -63,7 +63,7 @@ Archive a completed change in the experimental workflow.
|
||||
- If changes needed: "Sync now (recommended)", "Archive without syncing"
|
||||
- If already synced: "Archive now", "Sync anyway", "Cancel"
|
||||
|
||||
If user chooses sync, execute /opsx:sync logic (use the openspec-sync-specs skill). Proceed to archive regardless of choice.
|
||||
If user chooses sync, execute /opsx-sync logic (use the openspec-sync-specs skill). Proceed to archive regardless of choice.
|
||||
|
||||
5. **Perform the archive**
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.0"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Archive multiple completed changes in a single operation.
|
||||
@@ -229,7 +229,7 @@ Failed K changes:
|
||||
```
|
||||
## No Changes to Archive
|
||||
|
||||
No active changes found. Use `/opsx:new` to create a new change.
|
||||
No active changes found. Use `/opsx-new` to create a new change.
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.0"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Continue working on a change by creating the next artifact.
|
||||
@@ -34,7 +34,7 @@ Continue working on a change by creating the next artifact.
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to understand current state. The response includes:
|
||||
- `schemaName`: The workflow schema being used (e.g., "spec-driven", "tdd")
|
||||
- `schemaName`: The workflow schema being used (e.g., "spec-driven")
|
||||
- `artifacts`: Array of artifacts with their status ("done", "ready", "blocked")
|
||||
- `isComplete`: Boolean indicating if all artifacts are complete
|
||||
|
||||
@@ -100,16 +100,10 @@ Common artifact patterns:
|
||||
**spec-driven schema** (proposal → specs → design → tasks):
|
||||
- **proposal.md**: Ask user about the change if not clear. Fill in Why, What Changes, Capabilities, Impact.
|
||||
- The Capabilities section is critical - each capability listed will need a spec file.
|
||||
- **specs/*.md**: Create one spec per capability listed in the proposal.
|
||||
- **specs/<capability>/spec.md**: Create one spec per capability listed in the proposal's Capabilities section (use the capability name, not the change name).
|
||||
- **design.md**: Document technical decisions, architecture, and implementation approach.
|
||||
- **tasks.md**: Break down implementation into checkboxed tasks.
|
||||
|
||||
**tdd schema** (spec → tests → implementation → docs):
|
||||
- **spec.md**: Feature specification defining what to build.
|
||||
- **tests/*.test.ts**: Write tests BEFORE implementation (TDD red phase).
|
||||
- **src/*.ts**: Implement to make tests pass (TDD green phase).
|
||||
- **docs/*.md**: Document the implemented feature.
|
||||
|
||||
For other schemas, follow the `instruction` field from the CLI output.
|
||||
|
||||
**Guardrails**
|
||||
|
||||
@@ -6,12 +6,12 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.0"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
||||
|
||||
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first (e.g., start a change with `/opsx:new` or `/opsx:ff`). You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
|
||||
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first (e.g., start a change with `/opsx-new` or `/opsx-ff`). You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
|
||||
|
||||
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
||||
|
||||
@@ -96,7 +96,7 @@ This tells you:
|
||||
Think freely. When insights crystallize, you might offer:
|
||||
|
||||
- "This feels solid enough to start a change. Want me to create one?"
|
||||
→ Can transition to `/opsx:new` or `/opsx:ff`
|
||||
→ Can transition to `/opsx-new` or `/opsx-ff`
|
||||
- Or keep exploring - no pressure to formalize
|
||||
|
||||
### When a change exists
|
||||
@@ -202,7 +202,7 @@ You: [reads codebase]
|
||||
|
||||
**User is stuck mid-implementation:**
|
||||
```
|
||||
User: /opsx:explore add-auth-system
|
||||
User: /opsx-explore add-auth-system
|
||||
The OAuth integration is more complex than expected
|
||||
|
||||
You: [reads change artifacts]
|
||||
@@ -252,7 +252,7 @@ You: That changes everything.
|
||||
|
||||
There's no required ending. Discovery might:
|
||||
|
||||
- **Flow into action**: "Ready to start? /opsx:new or /opsx:ff"
|
||||
- **Flow into action**: "Ready to start? /opsx-new or /opsx-ff"
|
||||
- **Result in artifact updates**: "Updated design.md with these decisions"
|
||||
- **Just provide clarity**: User has what they need, moves on
|
||||
- **Continue later**: "We can pick this up anytime"
|
||||
@@ -269,8 +269,8 @@ When it feels like things are crystallizing, you might summarize:
|
||||
**Open questions**: [if any remain]
|
||||
|
||||
**Next steps** (if ready):
|
||||
- Create a change: /opsx:new <name>
|
||||
- Fast-forward to tasks: /opsx:ff <name>
|
||||
- Create a change: /opsx-new <name>
|
||||
- Fast-forward to tasks: /opsx-ff <name>
|
||||
- Keep exploring: just keep talking
|
||||
```
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.0"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Fast-forward through artifact creation - generate everything needed to start implementation in one go.
|
||||
@@ -81,7 +81,7 @@ After completing all artifacts, summarize:
|
||||
- Change name and location
|
||||
- List of artifacts created with brief descriptions
|
||||
- What's ready: "All artifacts created! Ready for implementation."
|
||||
- Prompt: "Run `/opsx:apply` or ask me to implement to start working on the tasks."
|
||||
- Prompt: "Run `/opsx-apply` or ask me to implement to start working on the tasks."
|
||||
|
||||
**Artifact Creation Guidelines**
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.0"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Start a new change using the experimental artifact-driven approach.
|
||||
@@ -29,7 +29,6 @@ Start a new change using the experimental artifact-driven approach.
|
||||
Use the default schema (omit `--schema`) unless the user explicitly requests a different workflow.
|
||||
|
||||
**Use a different schema only if the user mentions:**
|
||||
- "tdd" or "test-driven" → use `--schema tdd`
|
||||
- A specific schema name → use `--schema <name>`
|
||||
- "show workflows" or "what workflows" → run `openspec schemas --json` and let them choose
|
||||
|
||||
@@ -49,7 +48,7 @@ Start a new change using the experimental artifact-driven approach.
|
||||
This shows which artifacts need to be created and which are ready (dependencies satisfied).
|
||||
|
||||
5. **Get instructions for the first artifact**
|
||||
The first artifact depends on the schema (e.g., `proposal` for spec-driven, `spec` for tdd).
|
||||
The first artifact depends on the schema (e.g., `proposal` for spec-driven).
|
||||
Check the status output to find the first artifact with status "ready".
|
||||
```bash
|
||||
openspec instructions <first-artifact-id> --change "<name>"
|
||||
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.0"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Guide the user through their first complete OpenSpec workflow cycle. This is a teaching experience—you'll do real work in their codebase while explaining each step.
|
||||
@@ -22,7 +22,7 @@ openspec status --json 2>&1 || echo "NOT_INITIALIZED"
|
||||
```
|
||||
|
||||
**If not initialized:**
|
||||
> OpenSpec isn't set up in this project yet. Run `openspec init` first, then come back to `/opsx:onboard`.
|
||||
> OpenSpec isn't set up in this project yet. Run `openspec init` first, then come back to `/opsx-onboard`.
|
||||
|
||||
Stop here if not initialized.
|
||||
|
||||
@@ -146,7 +146,7 @@ Spend 1-2 minutes investigating the relevant code:
|
||||
│ [Optional: ASCII diagram if helpful] │
|
||||
└─────────────────────────────────────────┘
|
||||
|
||||
Explore mode (`/opsx:explore`) is for this kind of thinking—investigating before implementing. You can use it anytime you need to think through a problem.
|
||||
Explore mode (`/opsx-explore`) is for this kind of thinking—investigating before implementing. You can use it anytime you need to think through a problem.
|
||||
|
||||
Now let's create a change to hold our work.
|
||||
```
|
||||
@@ -416,7 +416,7 @@ The change is implemented! One more step—let's archive it.
|
||||
```
|
||||
## Archiving
|
||||
|
||||
When a change is complete, we archive it. This moves it from `openspec/changes/` to `openspec/archive/YYYY-MM-DD--<name>/`.
|
||||
When a change is complete, we archive it. This moves it from `openspec/changes/` to `openspec/changes/archive/YYYY-MM-DD-<name>/`.
|
||||
|
||||
Archived changes become your project's decision history—you can always find them later to understand why something was built a certain way.
|
||||
```
|
||||
@@ -428,7 +428,7 @@ openspec archive "<name>"
|
||||
|
||||
**SHOW:**
|
||||
```
|
||||
Archived to: `openspec/archive/YYYY-MM-DD--<name>/`
|
||||
Archived to: `openspec/changes/archive/YYYY-MM-DD-<name>/`
|
||||
|
||||
The change is now part of your project's history. The code is in your codebase, the decision record is preserved.
|
||||
```
|
||||
@@ -459,19 +459,19 @@ This same rhythm works for any size change—a small fix or a major feature.
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `/opsx:explore` | Think through problems before/during work |
|
||||
| `/opsx:new` | Start a new change, step through artifacts |
|
||||
| `/opsx:ff` | Fast-forward: create all artifacts at once |
|
||||
| `/opsx:continue` | Continue working on an existing change |
|
||||
| `/opsx:apply` | Implement tasks from a change |
|
||||
| `/opsx:verify` | Verify implementation matches artifacts |
|
||||
| `/opsx:archive` | Archive a completed change |
|
||||
| `/opsx-explore` | Think through problems before/during work |
|
||||
| `/opsx-new` | Start a new change, step through artifacts |
|
||||
| `/opsx-ff` | Fast-forward: create all artifacts at once |
|
||||
| `/opsx-continue` | Continue working on an existing change |
|
||||
| `/opsx-apply` | Implement tasks from a change |
|
||||
| `/opsx-verify` | Verify implementation matches artifacts |
|
||||
| `/opsx-archive` | Archive a completed change |
|
||||
|
||||
---
|
||||
|
||||
## What's Next?
|
||||
|
||||
Try `/opsx:new` or `/opsx:ff` on something you actually want to build. You've got the rhythm now!
|
||||
Try `/opsx-new` or `/opsx-ff` on something you actually want to build. You've got the rhythm now!
|
||||
```
|
||||
|
||||
---
|
||||
@@ -486,8 +486,8 @@ If the user says they need to stop, want to pause, or seem disengaged:
|
||||
No problem! Your change is saved at `openspec/changes/<name>/`.
|
||||
|
||||
To pick up where we left off later:
|
||||
- `/opsx:continue <name>` - Resume artifact creation
|
||||
- `/opsx:apply <name>` - Jump to implementation (if tasks exist)
|
||||
- `/opsx-continue <name>` - Resume artifact creation
|
||||
- `/opsx-apply <name>` - Jump to implementation (if tasks exist)
|
||||
|
||||
The work won't be lost. Come back whenever you're ready.
|
||||
```
|
||||
@@ -503,15 +503,15 @@ If the user says they just want to see the commands or skip the tutorial:
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `/opsx:explore` | Think through problems (no code changes) |
|
||||
| `/opsx:new <name>` | Start a new change, step by step |
|
||||
| `/opsx:ff <name>` | Fast-forward: all artifacts at once |
|
||||
| `/opsx:continue <name>` | Continue an existing change |
|
||||
| `/opsx:apply <name>` | Implement tasks |
|
||||
| `/opsx:verify <name>` | Verify implementation |
|
||||
| `/opsx:archive <name>` | Archive when done |
|
||||
| `/opsx-explore` | Think through problems (no code changes) |
|
||||
| `/opsx-new <name>` | Start a new change, step by step |
|
||||
| `/opsx-ff <name>` | Fast-forward: all artifacts at once |
|
||||
| `/opsx-continue <name>` | Continue an existing change |
|
||||
| `/opsx-apply <name>` | Implement tasks |
|
||||
| `/opsx-verify <name>` | Verify implementation |
|
||||
| `/opsx-archive <name>` | Archive when done |
|
||||
|
||||
Try `/opsx:new` to start your first change, or `/opsx:ff` if you want to move fast.
|
||||
Try `/opsx-new` to start your first change, or `/opsx-ff` if you want to move fast.
|
||||
```
|
||||
|
||||
Exit gracefully.
|
||||
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.0"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Sync delta specs from a change to main specs.
|
||||
|
||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.0.0"
|
||||
generatedBy: "1.1.1"
|
||||
---
|
||||
|
||||
Verify that an implementation matches the change artifacts (specs, tasks, design).
|
||||
@@ -30,7 +30,7 @@ Verify that an implementation matches the change artifacts (specs, tasks, design
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used (e.g., "spec-driven", "tdd")
|
||||
- `schemaName`: The workflow being used (e.g., "spec-driven")
|
||||
- Which artifacts exist for this change
|
||||
|
||||
3. **Get the change directory and load artifacts**
|
||||
|
||||
46
README.md
46
README.md
@@ -56,16 +56,44 @@ If you want to contribute or develop the plugin:
|
||||
- `Cmd/Ctrl+Shift+O` to toggle the panel
|
||||
- Server starts automatically when you open the panel
|
||||
|
||||
## Context injection (experimental)
|
||||
|
||||
This plugin can automatically inject context to the running OC instance: list of open notes and currently selected text.
|
||||
|
||||
It can be configured form the plugin settings.
|
||||
|
||||
Currently, this is work-in-progress feature with some limitations:
|
||||
- It won't work when creating new session from OC interface.
|
||||
|
||||
## Settings
|
||||
|
||||
<img src="./assets/plugin_settings.png" alt="Available plugin settings" />
|
||||
### Custom Command Mode
|
||||
|
||||
Enable "Use custom command" when you need more control over how OpenCode starts—for example, to add extra CLI flags, use a custom wrapper script, or run OpenCode through a container or virtual environment.
|
||||
|
||||
When using custom command:
|
||||
|
||||
- **Hostname and port must match** the values set in the Port and Hostname fields above
|
||||
- You **must include `--cors app://obsidian.md`** to allow Obsidian to embed the OpenCode interface
|
||||
|
||||
Example:
|
||||
```bash
|
||||
opencode serve --port 14096 --hostname 127.0.0.1 --cors app://obsidian.md
|
||||
```
|
||||
|
||||
Other settings (port, hostname, auto-start, view location, context injection) are available through the settings UI and are self-explanatory.
|
||||
|
||||
### Context injection (experimental)
|
||||
|
||||
This plugin can automatically inject context to the running OC instance: list of open notes and currently selected text.
|
||||
|
||||
Currently, this is work-in-progress feature with some limitations - it won't work when creating new session from OC interface.
|
||||
|
||||
## Windows Troubleshooting
|
||||
|
||||
If you see "Executable not found at 'opencode'" despite opencode being installed:
|
||||
|
||||
1. Find your opencode.cmd path:
|
||||
```
|
||||
where opencode.cmd
|
||||
```
|
||||
|
||||
2. Configure the full path in plugin settings:
|
||||
```
|
||||
C:\Users\{username}\AppData\Roaming\npm\opencode.cmd
|
||||
```
|
||||
|
||||
This is due to Electron/Obsidian not fully inheriting PATH on Windows.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "opencode-obsidian",
|
||||
"name": "OpenCode-Obsidian",
|
||||
"version": "0.0.0",
|
||||
"version": "0.2.1",
|
||||
"minAppVersion": "1.4.0",
|
||||
"description": "Embed OpenCode AI assistant in Obsidian for AI-powered note management",
|
||||
"author": "mtymek",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-14
|
||||
164
openspec/changes/improved-opencode-process-management/design.md
Normal file
164
openspec/changes/improved-opencode-process-management/design.md
Normal file
@@ -0,0 +1,164 @@
|
||||
## Context
|
||||
|
||||
### Current Implementation
|
||||
The plugin uses a simple `opencodePath: string` setting that defaults to "opencode". The `ServerManager` spawns this directly with default arguments:
|
||||
```typescript
|
||||
this.process = this.processImpl.start(
|
||||
this.settings.opencodePath,
|
||||
["serve", "--port", this.settings.port.toString(), ...],
|
||||
options
|
||||
);
|
||||
```
|
||||
|
||||
Platform-specific process implementations (`PosixProcess`, `WindowsProcess`) handle spawning and verification. The `verifyCommand` method checks if the executable exists.
|
||||
|
||||
### New Requirements
|
||||
1. One-time autodetect for new users (empty path on first run)
|
||||
2. Support custom shell commands with full user control
|
||||
3. Maintain backward compatibility with existing path-based configuration
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Provide zero-configuration setup for new users via autodetect
|
||||
- Enable power users to use custom commands with full flexibility
|
||||
- Maintain backward compatibility for existing users
|
||||
- Clear UI distinction between path mode and custom command mode
|
||||
- Platform-aware executable detection (PATH + common locations)
|
||||
|
||||
**Non-Goals:**
|
||||
- Complex command builder UI (simple text input for custom commands)
|
||||
- Automatic installation of opencode
|
||||
- Validation of custom commands before execution
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Settings Schema Extension
|
||||
**Decision:** Extend existing settings rather than replace.
|
||||
|
||||
**Rationale:**
|
||||
- Maintains backward compatibility - existing configs continue working
|
||||
- Simple migration - just add new fields with sensible defaults
|
||||
- Minimal code changes to existing path-based logic
|
||||
|
||||
**Alternatives considered:**
|
||||
- Replace with structured command object - rejected due to breaking change
|
||||
- Separate settings sections - rejected as overkill for this feature
|
||||
|
||||
**Implementation:**
|
||||
```typescript
|
||||
interface OpenCodeSettings {
|
||||
// ... existing fields ...
|
||||
opencodePath: string; // Still used as primary path
|
||||
customCommand: string; // New: shell command
|
||||
useCustomCommand: boolean; // New: toggle mode
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Autodetect Trigger Strategy
|
||||
**Decision:** Autodetect runs on every plugin startup when path is empty.
|
||||
|
||||
**Rationale:**
|
||||
- Reminds user to configure or disable the plugin if opencode is missing
|
||||
- Simpler implementation - no state tracking needed
|
||||
- If user installs opencode later, it will be detected automatically
|
||||
|
||||
**Alternatives considered:**
|
||||
- Run once and remember with flag - rejected as hides the problem
|
||||
- Run only manually - rejected as adds friction for new users
|
||||
- Explicit "first setup" wizard - rejected as overkill
|
||||
|
||||
**Implementation:**
|
||||
```typescript
|
||||
// In main.ts onload()
|
||||
if (!this.settings.opencodePath && !this.settings.useCustomCommand) {
|
||||
await this.attemptAutodetect();
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Custom Command Spawning Strategy
|
||||
**Decision:** Use `shell: true` for custom commands, user controls ALL arguments.
|
||||
|
||||
**Rationale:**
|
||||
- Maximum flexibility - env vars, pipes, complex invocations all work
|
||||
- Simple mental model - "what you type is what runs"
|
||||
- No ambiguity about argument concatenation
|
||||
|
||||
**Alternatives considered:**
|
||||
- Parse and split custom command - rejected as fragile
|
||||
- Merge plugin args with custom args - rejected as confusing
|
||||
- Separate args array - rejected as limiting
|
||||
|
||||
**Implementation:**
|
||||
```typescript
|
||||
// Path mode
|
||||
this.process = spawn(opencodePath, ["serve", "--port", port, ...], options);
|
||||
|
||||
// Custom mode
|
||||
this.process = spawn(customCommand, [], { ...options, shell: true });
|
||||
```
|
||||
|
||||
### 4. Executable Detection Order
|
||||
**Decision:** Check PATH first, then platform-specific common locations.
|
||||
|
||||
**Rationale:**
|
||||
- Respects user's environment setup
|
||||
- Common locations cover most package manager installs (homebrew, cargo, npm -g, etc.)
|
||||
|
||||
**Search algorithm:**
|
||||
1. If configured path is absolute and exists, return it directly
|
||||
2. Extract basename from configured path (e.g., "opencode" from "/path/to/opencode" or just "opencode")
|
||||
3. Search platform-specific locations for that basename:
|
||||
- **Linux:** `~/.local/bin/`, `~/.opencode/bin/`, `~/.bun/bin/`, `~/.npm-global/bin/`, `~/.nvm/versions/node/*/bin/`, `/usr/local/bin/`, `/usr/bin/`
|
||||
- **macOS:** `~/.local/bin/`, `/opt/homebrew/bin/`, `/usr/local/bin/`
|
||||
- **Windows:** `%LOCALAPPDATA%\opencode\bin\`, `%USERPROFILE%\.bun\bin\`, `%USERPROFILE%\.local\bin\`
|
||||
4. If found, return full path; if not found, return configured path as fallback
|
||||
|
||||
**nvm wildcard handling:** For `~/.nvm/versions/node/*/bin/`, expand the wildcard to find actual Node version directories.
|
||||
|
||||
### 5. UI Layout
|
||||
**Decision:** Toggle switch to select mode, conditional display of relevant input.
|
||||
|
||||
**Rationale:**
|
||||
- Clear mental model - one or the other, not both
|
||||
- Reduces visual clutter
|
||||
- Toggle state directly maps to `useCustomCommand` boolean
|
||||
|
||||
**Layout:**
|
||||
```
|
||||
Command Mode:
|
||||
[ Use custom command ●─────○ ]
|
||||
|
||||
[Path Mode - shown when toggle off]
|
||||
OpenCode path: [____________] [Autodetect]
|
||||
|
||||
[Custom Mode - shown when toggle on]
|
||||
Custom command:
|
||||
[______________________________]
|
||||
(Full shell command with all arguments)
|
||||
```
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**[Risk]** Custom command mode is powerful but dangerous - users can break their setup.
|
||||
→ **Mitigation:** This is intentional flexibility. Users opting into "custom command" are advanced users. No validation performed - natural failure on spawn.
|
||||
|
||||
**[Risk]** Autodetect could find wrong executable (different binary with same name).
|
||||
→ **Mitigation:** Low probability - "opencode" is unique. Could add version check in future if needed.
|
||||
|
||||
**[Risk]** Users may not understand difference between path and custom command modes.
|
||||
→ **Mitigation:** Clear UI labels and descriptions. Path mode is default, custom mode opt-in.
|
||||
|
||||
**[Risk]** Toast notification on every startup might be annoying if user intentionally leaves path empty.
|
||||
→ **Mitigation:** User can switch to custom command mode (even with empty command) to suppress autodetect, or configure a path.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. **No breaking changes** - existing configs with `opencodePath` set continue working
|
||||
2. **New fields** default to empty/false
|
||||
3. **Autodetect** triggers on every startup when path is empty and custom command mode is disabled
|
||||
4. **Settings UI** adapts to existing data - if `opencodePath` is set, starts in path mode
|
||||
|
||||
## Open Questions
|
||||
|
||||
None - design is complete based on user requirements.
|
||||
@@ -0,0 +1,64 @@
|
||||
## Why
|
||||
|
||||
Currently, the plugin requires users to manually specify an OpenCode path in settings, defaulting to just "opencode" (expecting it in PATH). This creates friction for new users who haven't configured anything yet. We need:
|
||||
1. **Autodetect on first run** - Seamless setup for new users
|
||||
2. **Custom command support** - Power users need flexibility for custom flags, env vars, wrapper scripts
|
||||
3. **Backward compatibility** - Existing `opencodePath` settings must continue working
|
||||
|
||||
## What Changes
|
||||
|
||||
### New Capabilities
|
||||
- **Startup autodetect**: On every plugin startup with empty `opencodePath`, automatically search for opencode executable
|
||||
- Check PATH first
|
||||
- Check platform-specific common locations (homebrew, ~/.local/bin, etc.)
|
||||
- Save found path to settings if successful
|
||||
- Show toast notification if not found, prompting user to check Settings
|
||||
- **Manual autodetect button**: "Autodetect" button in Settings to trigger search on demand
|
||||
- **Custom command mode**: Toggle between "Path" and "Custom command"
|
||||
- Path mode: Use `opencodePath` directly, append default args (`--serve --port X`)
|
||||
- Custom mode: Full shell command, user controls all arguments
|
||||
- Custom mode uses `shell: true` for maximum flexibility
|
||||
|
||||
### Settings Schema Changes
|
||||
```typescript
|
||||
interface OpenCodeSettings {
|
||||
// ... existing fields ...
|
||||
opencodePath: string; // Path to executable (or empty)
|
||||
customCommand: string; // Full shell command
|
||||
useCustomCommand: boolean; // Toggle: false=path, true=custom
|
||||
}
|
||||
```
|
||||
|
||||
### UI Changes
|
||||
- Toggle: "Use custom command" (default: off)
|
||||
- When off: Show path input + "Autodetect" button
|
||||
- When on: Show custom command textarea
|
||||
|
||||
### Validation
|
||||
- Path mode: Verify with `opencode --version` (existing behavior)
|
||||
- Custom mode: Trust user, let it fail naturally
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `executable-autodetect`: Cross-platform executable detection on startup when path is empty
|
||||
- `custom-command-launch`: Shell-based command execution with full user control
|
||||
|
||||
### Modified Capabilities
|
||||
- `process-launch`: Extended to support both direct path execution and shell-based custom commands
|
||||
|
||||
## Impact
|
||||
|
||||
- **Settings (`src/types.ts`)**: Add new fields to `OpenCodeSettings` interface
|
||||
- **Settings UI (`src/settings/SettingsTab.ts`)**: Add toggle, autodetect button, conditional inputs
|
||||
- **Process spawning (`src/server/ServerManager.ts`)**: Route to appropriate spawn method based on mode
|
||||
- **New module**: `src/server/ExecutableResolver.ts` for cross-platform autodetect logic
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- [ ] Plugin attempts autodetect on every startup when path is empty
|
||||
- [ ] If autodetect fails, user sees clear toast notification with action to check Settings
|
||||
- [ ] Existing `opencodePath` values continue working (backward compatibility)
|
||||
- [ ] Users can switch to custom command mode for full control
|
||||
- [ ] Settings UI clearly distinguishes path vs custom command modes
|
||||
- [ ] Manual "Autodetect" button works in Settings
|
||||
@@ -0,0 +1,50 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Custom shell command execution
|
||||
The system SHALL support executing user-defined shell commands with full control over arguments and environment.
|
||||
|
||||
#### Scenario: Execute custom command with shell
|
||||
- **GIVEN** useCustomCommand setting is true
|
||||
- **AND** customCommand setting contains a shell command string
|
||||
- **WHEN** the server is started
|
||||
- **THEN** the system SHALL spawn the process with shell: true option
|
||||
- **AND** execute the exact command string as provided by the user
|
||||
|
||||
#### Scenario: Custom command with environment variables
|
||||
- **GIVEN** customCommand is "FOO=bar opencode serve --port 14096"
|
||||
- **WHEN** the server is started
|
||||
- **THEN** the system SHALL execute the command with shell: true
|
||||
- **AND** the environment variable FOO SHALL be set to "bar"
|
||||
|
||||
#### Scenario: Custom command with custom arguments
|
||||
- **GIVEN** customCommand is "opencode serve --port 9999 --verbose"
|
||||
- **WHEN** the server is started
|
||||
- **THEN** the system SHALL execute the command with shell: true
|
||||
- **AND** pass exactly "--port 9999 --verbose" as arguments
|
||||
- **AND** NOT append any default arguments (port, hostname, etc.)
|
||||
|
||||
#### Scenario: Custom command with wrapper script
|
||||
- **GIVEN** customCommand is "/path/to/my-wrapper.sh"
|
||||
- **WHEN** the server is started
|
||||
- **THEN** the system SHALL execute the wrapper script via shell
|
||||
- **AND** the wrapper script SHALL have full control of opencode invocation
|
||||
|
||||
### Requirement: No validation for custom commands
|
||||
The system SHALL NOT validate custom commands before execution.
|
||||
|
||||
#### Scenario: Invalid custom command fails naturally
|
||||
- **GIVEN** customCommand is "invalid-command-that-does-not-exist"
|
||||
- **WHEN** the server is started
|
||||
- **THEN** the system SHALL attempt to execute it
|
||||
- **AND** let the spawn fail naturally with ENOENT error
|
||||
- **AND** NOT perform pre-flight validation
|
||||
|
||||
### Requirement: User controls all arguments in custom mode
|
||||
The system SHALL NOT append any default arguments when using custom command mode.
|
||||
|
||||
#### Scenario: User provides complete command
|
||||
- **GIVEN** useCustomCommand is true
|
||||
- **AND** customCommand is "opencode serve"
|
||||
- **WHEN** the server is started
|
||||
- **THEN** the system SHALL execute exactly "opencode serve"
|
||||
- **AND** NOT append --port, --hostname, or --cors arguments
|
||||
@@ -0,0 +1,75 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Cross-platform executable autodetection
|
||||
The system SHALL provide a mechanism to automatically detect the opencode executable location across macOS, Linux, and Windows platforms.
|
||||
|
||||
#### Scenario: Autodetect finds executable in PATH
|
||||
- **GIVEN** the platform PATH contains an executable named "opencode"
|
||||
- **WHEN** autodetection is triggered
|
||||
- **THEN** the system SHALL return the full path to the executable
|
||||
|
||||
#### Scenario: Configured absolute path takes precedence
|
||||
- **GIVEN** opencodePath is set to an absolute path like "/opt/opencode/bin/opencode"
|
||||
- **AND** the file exists at that location
|
||||
- **WHEN** autodetection is triggered
|
||||
- **THEN** the system SHALL return the configured absolute path directly
|
||||
- **AND** skip searching platform-specific locations
|
||||
|
||||
#### Scenario: Autodetect finds executable in platform-specific location
|
||||
- **GIVEN** opencodePath is set to "opencode" (not an absolute path)
|
||||
- **OR** the configured absolute path does not exist
|
||||
- **AND** opencode is installed at a platform-specific common location
|
||||
- Linux: ~/.local/bin/opencode, ~/.opencode/bin/opencode, ~/.bun/bin/opencode, ~/.npm-global/bin/opencode, ~/.nvm/versions/node/*/bin/opencode, /usr/local/bin/opencode, /usr/bin/opencode
|
||||
- macOS: ~/.local/bin/opencode, /opt/homebrew/bin/opencode, /usr/local/bin/opencode
|
||||
- Windows: %LOCALAPPDATA%\opencode\bin\opencode.exe, %USERPROFILE%\.bun\bin\opencode.exe
|
||||
- **WHEN** autodetection is triggered
|
||||
- **THEN** the system SHALL extract the basename from configured path (e.g., "opencode")
|
||||
- **AND** search platform-specific locations for that basename
|
||||
- **AND** return the full path if found
|
||||
|
||||
#### Scenario: Basename extraction for custom executable names
|
||||
- **GIVEN** opencodePath is set to "my-custom-opencode"
|
||||
- **WHEN** autodetection is triggered
|
||||
- **THEN** the system SHALL search for "my-custom-opencode" in platform-specific locations
|
||||
- **AND** return the full path if found
|
||||
|
||||
#### Scenario: Autodetect fails to find executable - fallback to configured path
|
||||
- **GIVEN** opencode is not in PATH or any platform-specific location
|
||||
- **AND** opencodePath is set to "opencode"
|
||||
- **WHEN** autodetection is triggered
|
||||
- **THEN** the system SHALL return the configured path "opencode" as fallback
|
||||
- **AND** display a toast notification to the user: "Could not find opencode. Please check Settings"
|
||||
|
||||
### Requirement: Startup autodetection
|
||||
The system SHALL attempt to autodetect the opencode executable on every plugin startup when the path is not configured.
|
||||
|
||||
#### Scenario: Startup autodetect succeeds
|
||||
- **GIVEN** the plugin loads
|
||||
- **AND** opencodePath setting is empty
|
||||
- **WHEN** the plugin initializes
|
||||
- **THEN** the system SHALL trigger autodetection
|
||||
- **AND** if found, save the path to opencodePath setting
|
||||
- **AND** display a success notification: "OpenCode executable found at <path>"
|
||||
|
||||
#### Scenario: Startup autodetect fails
|
||||
- **GIVEN** the plugin loads
|
||||
- **AND** opencodePath setting is empty
|
||||
- **WHEN** the plugin initializes
|
||||
- **THEN** the system SHALL trigger autodetection
|
||||
- **AND** if not found, display a toast notification: "Could not find opencode. Please check Settings"
|
||||
|
||||
### Requirement: Manual autodetect trigger
|
||||
The system SHALL provide a UI control to manually trigger autodetection at any time.
|
||||
|
||||
#### Scenario: User clicks Autodetect button
|
||||
- **GIVEN** the user is on the Settings page
|
||||
- **AND** Path mode is selected
|
||||
- **WHEN** the user clicks the "Autodetect" button
|
||||
- **THEN** the system SHALL trigger autodetection
|
||||
- **AND** if found, update the opencodePath field with the detected path
|
||||
- **AND** display a success notification
|
||||
|
||||
#### Scenario: Manual autodetect fails
|
||||
- **GIVEN** the user clicks the "Autodetect" button
|
||||
- **WHEN** autodetection fails to find the executable
|
||||
- **THEN** the system SHALL display a toast notification: "Could not find opencode. Please check your installation."
|
||||
@@ -0,0 +1,96 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Server Process Spawning (Modified)
|
||||
The plugin SHALL spawn the OpenCode server process using either a configured executable path with default arguments OR a user-defined custom shell command.
|
||||
|
||||
**FROM:**
|
||||
The plugin SHALL spawn the OpenCode server process using the configured executable path, port, and hostname when the user initiates server start.
|
||||
|
||||
**TO:**
|
||||
The plugin SHALL support two execution modes for spawning the OpenCode server process based on the `useCustomCommand` setting.
|
||||
|
||||
#### Scenario: Path mode spawn (Modified)
|
||||
- **GIVEN** useCustomCommand is false
|
||||
- **WHEN** the user starts the server
|
||||
- **THEN** the plugin spawns the executable at `opencodePath` with arguments `["serve", "--port", <port>, "--hostname", <hostname>, "--cors", "app://obsidian.md"]`
|
||||
- **AND** the process runs with the vault directory as the working directory
|
||||
- **AND** the spawn uses `shell: false`
|
||||
|
||||
#### Scenario: Custom command mode spawn (New)
|
||||
- **GIVEN** useCustomCommand is true
|
||||
- **WHEN** the user starts the server
|
||||
- **THEN** the plugin spawns `customCommand` as a shell command
|
||||
- **AND** the spawn uses `shell: true`
|
||||
- **AND** NO additional arguments are appended to the command
|
||||
- **AND** the process runs with the vault directory as the working directory
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Command mode selection
|
||||
The system SHALL support toggling between path mode and custom command mode via settings.
|
||||
|
||||
#### Scenario: Path mode configuration
|
||||
- **GIVEN** useCustomCommand is false (default)
|
||||
- **WHEN** the server starts
|
||||
- **THEN** the system uses the configured opencodePath executable
|
||||
- **AND** appends default arguments for port, hostname, and CORS
|
||||
|
||||
#### Scenario: Custom command mode configuration
|
||||
- **GIVEN** useCustomCommand is true
|
||||
- **WHEN** the server starts
|
||||
- **THEN** the system uses the configured customCommand string
|
||||
- **AND** executes it via shell with no argument manipulation
|
||||
|
||||
### Requirement: Executable verification
|
||||
The system SHALL verify the executable exists in path mode before attempting to spawn.
|
||||
|
||||
#### Scenario: Verify path mode executable
|
||||
- **GIVEN** useCustomCommand is false
|
||||
- **AND** opencodePath is set to an absolute path
|
||||
- **WHEN** the server is about to start
|
||||
- **THEN** the system SHALL verify the file exists and is executable
|
||||
- **AND** if verification fails, return an error before spawning
|
||||
|
||||
#### Scenario: Skip verification for custom command
|
||||
- **GIVEN** useCustomCommand is true
|
||||
- **WHEN** the server is about to start
|
||||
- **THEN** the system SHALL NOT verify the command before spawning
|
||||
- **AND** let execution fail naturally if the command is invalid
|
||||
|
||||
### Requirement: Settings backward compatibility
|
||||
The system SHALL maintain backward compatibility with existing opencodePath configurations.
|
||||
|
||||
#### Scenario: Existing path setting works
|
||||
- **GIVEN** a configuration from a previous plugin version
|
||||
- **AND** opencodePath is set to "/usr/local/bin/opencode"
|
||||
- **AND** useCustomCommand is not set (defaults to false)
|
||||
- **WHEN** the plugin loads
|
||||
- **THEN** the system SHALL use the existing opencodePath
|
||||
- **AND** continue working in path mode with default arguments
|
||||
|
||||
### Requirement: Detailed error messages
|
||||
The system SHALL provide clear, actionable error messages when the server fails to start.
|
||||
|
||||
#### Scenario: Executable not found
|
||||
- **GIVEN** useCustomCommand is false
|
||||
- **AND** opencodePath is set to a non-existent absolute path
|
||||
- **WHEN** the server fails to start
|
||||
- **THEN** the error message SHALL be: "Executable not found at '<path>'. Check Settings → OpenCode path, or click 'Autodetect'"
|
||||
|
||||
#### Scenario: Executable exists but not executable
|
||||
- **GIVEN** useCustomCommand is false
|
||||
- **AND** opencodePath points to a file that exists but lacks execute permission
|
||||
- **WHEN** the server fails to start
|
||||
- **THEN** the error message SHALL be: "'<path>' exists but is not executable. Run: chmod +x <path>"
|
||||
|
||||
#### Scenario: Error displayed in Settings UI
|
||||
- **GIVEN** the server state is "error"
|
||||
- **AND** an error message is stored
|
||||
- **WHEN** the user views the Settings page
|
||||
- **THEN** the error message SHALL be displayed below the "Status: Error" badge
|
||||
|
||||
#### Scenario: Error displayed as toast notification
|
||||
- **GIVEN** the user attempts to start the server from the main UI (not Settings)
|
||||
- **AND** the server fails to start
|
||||
- **THEN** a toast notification SHALL display the error message
|
||||
- **AND** the toast SHALL remain visible for 10 seconds
|
||||
@@ -0,0 +1,77 @@
|
||||
## 1. Settings Schema Updates
|
||||
|
||||
- [x] 1.1 Add `customCommand` field to `OpenCodeSettings` interface in `src/types.ts`
|
||||
- [x] 1.2 Add `useCustomCommand` boolean field to `OpenCodeSettings` interface
|
||||
- [x] 1.3 Update `DEFAULT_SETTINGS` with new fields (empty string and false)
|
||||
|
||||
## 2. Executable Resolver Module
|
||||
|
||||
- [x] 2.1 Create `src/server/ExecutableResolver.ts` with cross-platform detection logic
|
||||
- [x] 2.2 Implement `resolveFromPath()` to check PATH for 'opencode' executable
|
||||
- [x] 2.3 Implement `resolve()` method with proper precedence:
|
||||
- Check if configured path is absolute and exists → return it
|
||||
- Extract basename from configured path (handle both "opencode" and "/path/to/opencode")
|
||||
- [x] 2.4 Implement platform-specific directory search:
|
||||
- Linux: ~/.local/bin/, ~/.opencode/bin/, ~/.bun/bin/, ~/.npm-global/bin/, ~/.nvm/versions/node/*/bin/, /usr/local/bin/, /usr/bin/
|
||||
- macOS: ~/.local/bin/, /opt/homebrew/bin/, /usr/local/bin/
|
||||
- Windows: %LOCALAPPDATA%\opencode\bin\, %USERPROFILE%\.bun\bin\, %USERPROFILE%\.local\bin\
|
||||
- [x] 2.5 Implement nvm wildcard expansion for ~/.nvm/versions/node/*/bin/
|
||||
- [x] 2.6 Ensure fallback: return configured path if search fails
|
||||
|
||||
## 3. ServerManager Updates
|
||||
|
||||
- [x] 3.1 Modify `ServerManager.start()` to check `useCustomCommand` setting
|
||||
- [x] 3.2 Implement path mode spawning (direct spawn with default args)
|
||||
- [x] 3.3 Implement custom command mode spawning (shell: true, no args appended)
|
||||
- [x] 3.4 Ensure working directory is set correctly for both modes
|
||||
- [x] 3.5 Add support for verifying path mode executable with `opencode --version`
|
||||
|
||||
## 4. Main Plugin Integration
|
||||
|
||||
- [x] 4.1 Add autodetect logic in `main.ts` `onload()` method
|
||||
- [x] 4.2 Implement autodetect trigger: when `opencodePath` is empty and `useCustomCommand` is false
|
||||
- [x] 4.3 On successful autodetect: save path to settings, show success Notice
|
||||
- [x] 4.4 On failed autodetect: show error Notice "Could not find opencode. Please check Settings"
|
||||
- [x] 4.5 Import and use `ExecutableResolver` from main plugin
|
||||
|
||||
## 5. Settings UI Updates
|
||||
|
||||
- [x] 5.1 Add toggle switch "Use custom command" in `SettingsTab.ts`
|
||||
- [x] 5.2 Conditionally show path input field when toggle is off
|
||||
- [x] 5.3 Conditionally show custom command textarea when toggle is on
|
||||
- [x] 5.4 Add "Autodetect" button next to path input
|
||||
- [x] 5.5 Implement autodetect button click handler that:
|
||||
- Calls `ExecutableResolver.resolve()`
|
||||
- Updates path input if found
|
||||
- Shows success/error Notice
|
||||
- [x] 5.6 Add descriptive text explaining custom command mode behavior
|
||||
- [x] 5.7 Ensure settings are saved when toggling between modes
|
||||
|
||||
## 6. Testing & Validation
|
||||
|
||||
- [x] 6.1 Test autodetect finds opencode in PATH
|
||||
- [x] 6.2 Test autodetect finds opencode in platform-specific location
|
||||
- [x] 6.3 Test autodetect shows error when not found
|
||||
- [x] 6.4 Test path mode spawns correctly with default args
|
||||
- [x] 6.5 Test custom command mode spawns via shell without extra args
|
||||
- [x] 6.6 Test custom command with environment variables works
|
||||
- [x] 6.7 Verify backward compatibility: existing opencodePath values still work
|
||||
- [x] 6.8 Test manual autodetect button in Settings
|
||||
- [x] 6.9 Test toggle saves and restores correctly
|
||||
|
||||
## 7. Error Message Improvements
|
||||
|
||||
- [x] 7.1 Enhance verifyCommand in PosixProcess.ts with detailed error messages
|
||||
- Distinguish between "not found" and "not executable"
|
||||
- Provide chmod instructions for permission errors
|
||||
- Include actionable guidance ("Check Settings → OpenCode path, or click 'Autodetect'")
|
||||
- [x] 7.2 Enhance verifyCommand in WindowsProcess.ts with detailed error messages
|
||||
- [x] 7.3 Display error details in Settings UI below "Status: Error" badge
|
||||
- [x] 7.4 Show toast notification with error message when activation fails from main UI
|
||||
- [x] 7.5 Add CSS styling for error message display in settings
|
||||
|
||||
## 8. Documentation
|
||||
|
||||
- [x] 8.1 Update README.md with new settings options
|
||||
- [x] 8.2 Document autodetect behavior and common installation locations
|
||||
- [x] 8.3 Add examples of custom command usage
|
||||
81
openspec/specs/release-management/spec.md
Normal file
81
openspec/specs/release-management/spec.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Purpose
|
||||
|
||||
Define the automated release management system for the Obsidian plugin, including version bumping, manifest synchronization, and GitHub release automation. This system follows Obsidian plugin best practices and naturally enables BRAT (Beta Reviewers Auto-update Tool) compatibility.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Version bump commands
|
||||
The system SHALL provide commands to bump patch, minor, and major versions.
|
||||
|
||||
#### Scenario: Patch version bump
|
||||
- **GIVEN** the current version is "0.1.0"
|
||||
- **WHEN** a developer runs `bun run version:patch`
|
||||
- **THEN** the package.json version SHALL be updated to "0.1.1"
|
||||
- **AND** a git commit SHALL be created with message "0.1.1" containing the updated files
|
||||
- **AND** a git tag "v0.1.1" SHALL be created
|
||||
- **AND** the commit and tag SHALL be pushed to origin
|
||||
|
||||
#### Scenario: Minor version bump
|
||||
- **GIVEN** the current version is "0.1.5"
|
||||
- **WHEN** a developer runs `bun run version:minor`
|
||||
- **THEN** the package.json version SHALL be updated to "0.2.0"
|
||||
- **AND** a git commit SHALL be created with message "0.2.0" containing the updated files
|
||||
- **AND** a git tag "v0.2.0" SHALL be created
|
||||
- **AND** the commit and tag SHALL be pushed to origin
|
||||
|
||||
#### Scenario: Major version bump
|
||||
- **GIVEN** the current version is "0.5.2"
|
||||
- **WHEN** a developer runs `bun run version:major`
|
||||
- **THEN** the package.json version SHALL be updated to "1.0.0"
|
||||
- **AND** a git commit SHALL be created with message "1.0.0" containing the updated files
|
||||
- **AND** a git tag "v1.0.0" SHALL be created
|
||||
- **AND** the commit and tag SHALL be pushed to origin
|
||||
|
||||
### Requirement: Manifest version synchronization
|
||||
During any version bump, the `version` field in `manifest.json` SHALL be automatically updated to match the new package.json version.
|
||||
|
||||
#### Scenario: Synchronized version bump
|
||||
- **GIVEN** package.json contains `"version": "0.1.0"`
|
||||
- **AND** manifest.json contains `"version": "0.1.0"`
|
||||
- **WHEN** a developer runs `bun run version:minor`
|
||||
- **THEN** package.json SHALL be updated to `"version": "0.2.0"`
|
||||
- **AND** manifest.json SHALL be updated to `"version": "0.2.0"`
|
||||
- **AND** both files SHALL be included in the same git commit
|
||||
|
||||
#### Scenario: Manifest structure preservation
|
||||
- **GIVEN** manifest.json contains fields: id, name, version, minAppVersion, description, author, isDesktopOnly
|
||||
- **WHEN** the version is updated during version bump
|
||||
- **THEN** all fields except `version` SHALL remain unchanged
|
||||
- **AND** the JSON structure and formatting SHALL be preserved
|
||||
|
||||
### Requirement: Clean working directory
|
||||
Version commands SHALL fail if the git working directory is not clean.
|
||||
|
||||
#### Scenario: Uncommitted changes prevent version bump
|
||||
- **GIVEN** there are uncommitted changes in the working directory
|
||||
- **WHEN** a developer runs any version command
|
||||
- **THEN** the command SHALL fail with an error message
|
||||
- **AND** no version bump, commit, or tag SHALL be created
|
||||
|
||||
### Requirement: Automated release workflow
|
||||
When a version tag is pushed, a GitHub Actions workflow SHALL automatically build the plugin and create a GitHub release.
|
||||
|
||||
#### Scenario: Tag push triggers workflow
|
||||
- **WHEN** a developer pushes a tag named `v0.1.0` to the repository
|
||||
- **THEN** the GitHub Actions release workflow SHALL start execution within 1 minute
|
||||
|
||||
#### Scenario: Workflow builds and releases
|
||||
- **GIVEN** the release workflow executes
|
||||
- **WHEN** the build succeeds
|
||||
- **THEN** a GitHub release SHALL be created with name matching the git tag (e.g., "0.1.0" for tag "v0.1.0")
|
||||
- **AND** the release SHALL be marked as a pre-release
|
||||
- **AND** the release SHALL include the required assets: `manifest.json`, `main.js`, `styles.css`
|
||||
- **AND** the released manifest.json SHALL contain the correct version matching the tag
|
||||
|
||||
#### Scenario: Build failure prevents release
|
||||
- **WHEN** the build command fails
|
||||
- **THEN** the workflow SHALL fail without creating a GitHub release
|
||||
- **AND** an error notification SHALL be visible in the Actions tab
|
||||
|
||||
### Note: BRAT Compatibility
|
||||
This release management system follows standard Obsidian plugin conventions (semantic versioning, proper manifest.json, version-tagged releases with required assets). As a result, the plugin is naturally compatible with BRAT (Beta Reviewers Auto-update Tool) without requiring any BRAT-specific implementation.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsidian-opencode",
|
||||
"version": "0.0.0",
|
||||
"version": "0.2.1",
|
||||
"description": "Embed OpenCode AI assistant in Obsidian",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
||||
209
src/context/ContextManager.ts
Normal file
209
src/context/ContextManager.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { App, EventRef, MarkdownView, WorkspaceLeaf } from "obsidian";
|
||||
import { OpenCodeSettings, OPENCODE_VIEW_TYPE } from "../types";
|
||||
import { OpenCodeClient } from "../client/OpenCodeClient";
|
||||
import { WorkspaceContext } from "./WorkspaceContext";
|
||||
import { OpenCodeView } from "../ui/OpenCodeView";
|
||||
import { ServerState } from "../server/types";
|
||||
|
||||
type ContextManagerDeps = {
|
||||
app: App;
|
||||
settings: OpenCodeSettings;
|
||||
client: OpenCodeClient;
|
||||
getServerState: () => ServerState;
|
||||
getCachedIframeUrl: () => string | null;
|
||||
setCachedIframeUrl: (url: string | null) => void;
|
||||
registerEvent: (ref: EventRef) => void;
|
||||
};
|
||||
|
||||
export class ContextManager {
|
||||
private app: App;
|
||||
private settings: OpenCodeSettings;
|
||||
private client: OpenCodeClient;
|
||||
private workspaceContext: WorkspaceContext;
|
||||
private getServerState: () => ServerState;
|
||||
private getCachedIframeUrl: () => string | null;
|
||||
private setCachedIframeUrl: (url: string | null) => void;
|
||||
private registerEvent: (ref: EventRef) => void;
|
||||
|
||||
private contextEventRefs: EventRef[] = [];
|
||||
private contextRefreshTimer: number | null = null;
|
||||
|
||||
constructor(deps: ContextManagerDeps) {
|
||||
this.app = deps.app;
|
||||
this.settings = deps.settings;
|
||||
this.client = deps.client;
|
||||
this.workspaceContext = new WorkspaceContext(this.app);
|
||||
this.getServerState = deps.getServerState;
|
||||
this.getCachedIframeUrl = deps.getCachedIframeUrl;
|
||||
this.setCachedIframeUrl = deps.setCachedIframeUrl;
|
||||
this.registerEvent = deps.registerEvent;
|
||||
}
|
||||
|
||||
updateSettings(settings: OpenCodeSettings): void {
|
||||
this.settings = settings;
|
||||
this.updateListeners();
|
||||
}
|
||||
|
||||
private updateListeners(): void {
|
||||
if (!this.settings.injectWorkspaceContext) {
|
||||
this.clearListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.contextEventRefs.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeLeafRef = this.app.workspace.on("active-leaf-change", (leaf) => {
|
||||
if (leaf?.view instanceof MarkdownView) {
|
||||
this.workspaceContext.trackViewSelection(leaf.view);
|
||||
}
|
||||
this.scheduleRefresh(0);
|
||||
});
|
||||
const fileOpenRef = this.app.workspace.on("file-open", () => {
|
||||
this.scheduleRefresh();
|
||||
});
|
||||
const fileCloseRef = (this.app.workspace as any).on("file-close", () => {
|
||||
this.scheduleRefresh();
|
||||
});
|
||||
const layoutChangeRef = this.app.workspace.on("layout-change", () => {
|
||||
this.scheduleRefresh();
|
||||
});
|
||||
const editorChangeRef = this.app.workspace.on(
|
||||
"editor-change",
|
||||
(_editor, view) => {
|
||||
if (view instanceof MarkdownView) {
|
||||
this.workspaceContext.trackViewSelection(view);
|
||||
}
|
||||
this.scheduleRefresh(500);
|
||||
}
|
||||
);
|
||||
const selectionChangeRef = (this.app.workspace as any).on(
|
||||
"editor-selection-change",
|
||||
(_editor: unknown, view: unknown) => {
|
||||
if (view instanceof MarkdownView) {
|
||||
this.workspaceContext.trackViewSelection(view);
|
||||
}
|
||||
this.scheduleRefresh(200);
|
||||
}
|
||||
);
|
||||
|
||||
this.contextEventRefs = [
|
||||
activeLeafRef,
|
||||
fileOpenRef,
|
||||
fileCloseRef,
|
||||
layoutChangeRef,
|
||||
editorChangeRef,
|
||||
selectionChangeRef,
|
||||
];
|
||||
this.contextEventRefs.forEach((ref) => this.registerEvent(ref));
|
||||
}
|
||||
|
||||
private clearListeners(): void {
|
||||
for (const ref of this.contextEventRefs) {
|
||||
this.app.workspace.offref(ref);
|
||||
}
|
||||
this.contextEventRefs = [];
|
||||
if (this.contextRefreshTimer !== null) {
|
||||
window.clearTimeout(this.contextRefreshTimer);
|
||||
this.contextRefreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleRefresh(delayMs: number = 300): void {
|
||||
const leaf = this.getLeafForRefresh();
|
||||
if (!leaf) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.contextRefreshTimer !== null) {
|
||||
window.clearTimeout(this.contextRefreshTimer);
|
||||
}
|
||||
|
||||
this.contextRefreshTimer = window.setTimeout(() => {
|
||||
this.contextRefreshTimer = null;
|
||||
void this.refreshContext(leaf);
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
private getLeafForRefresh(): WorkspaceLeaf | null {
|
||||
const activeLeaf = this.app.workspace.activeLeaf;
|
||||
if (activeLeaf?.view.getViewType() === OPENCODE_VIEW_TYPE) {
|
||||
return activeLeaf;
|
||||
}
|
||||
|
||||
return this.getVisibleSidebarLeaf();
|
||||
}
|
||||
|
||||
private getVisibleSidebarLeaf(): WorkspaceLeaf | null {
|
||||
const leaves = this.app.workspace.getLeavesOfType(OPENCODE_VIEW_TYPE);
|
||||
if (leaves.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rightSplit = this.app.workspace.rightSplit;
|
||||
if (!rightSplit || rightSplit.collapsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const leaf = leaves[0];
|
||||
return leaf.getRoot() === rightSplit ? leaf : null;
|
||||
}
|
||||
|
||||
async handleServerRunning(): Promise<void> {
|
||||
const activeLeaf = this.app.workspace.activeLeaf;
|
||||
if (activeLeaf?.view.getViewType() === OPENCODE_VIEW_TYPE) {
|
||||
await this.refreshContext(activeLeaf);
|
||||
}
|
||||
}
|
||||
|
||||
async refreshContextForView(view: OpenCodeView): Promise<void> {
|
||||
if (!this.settings.injectWorkspaceContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
const leaf = this.getLeafForRefresh();
|
||||
if (!leaf) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.refreshContext(leaf);
|
||||
}
|
||||
|
||||
private async refreshContext(leaf: WorkspaceLeaf): Promise<void> {
|
||||
if (!this.settings.injectWorkspaceContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.getServerState() !== "running") {
|
||||
return;
|
||||
}
|
||||
|
||||
const view = leaf.view instanceof OpenCodeView ? leaf.view : null;
|
||||
const iframeUrl = this.getCachedIframeUrl() ?? view?.getIframeUrl();
|
||||
if (!iframeUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = this.client.resolveSessionId(iframeUrl);
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setCachedIframeUrl(iframeUrl);
|
||||
|
||||
const { contextText } = this.workspaceContext.gatherContext(
|
||||
this.settings.maxNotesInContext,
|
||||
this.settings.maxSelectionLength
|
||||
);
|
||||
|
||||
await this.client.updateContext({
|
||||
sessionId,
|
||||
contextText,
|
||||
});
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.clearListeners();
|
||||
}
|
||||
}
|
||||
393
src/main.ts
393
src/main.ts
@@ -1,22 +1,23 @@
|
||||
import { Plugin, WorkspaceLeaf, Notice, EventRef, MarkdownView } from "obsidian";
|
||||
import { OpenCodeSettings, DEFAULT_SETTINGS, OPENCODE_VIEW_TYPE } from "./types";
|
||||
import { OpenCodeView } from "./OpenCodeView";
|
||||
import { OpenCodeSettingTab } from "./SettingsTab";
|
||||
import { ProcessManager, ProcessState } from "./ProcessManager";
|
||||
import { OpenCodeView } from "./ui/OpenCodeView";
|
||||
import { ViewManager } from "./ui/ViewManager";
|
||||
import { OpenCodeSettingTab } from "./settings/SettingsTab";
|
||||
import { ServerManager, ServerState } from "./server/ServerManager";
|
||||
import { registerOpenCodeIcons, OPENCODE_ICON_NAME } from "./icons";
|
||||
import { OpenCodeClient } from "./OpenCodeClient";
|
||||
import { WorkspaceContext } from "./WorkspaceContext";
|
||||
import { OpenCodeClient } from "./client/OpenCodeClient";
|
||||
import { ContextManager } from "./context/ContextManager";
|
||||
import { ExecutableResolver } from "./server/ExecutableResolver";
|
||||
|
||||
export default class OpenCodePlugin extends Plugin {
|
||||
settings: OpenCodeSettings = DEFAULT_SETTINGS;
|
||||
private processManager: ProcessManager;
|
||||
private stateChangeCallbacks: Array<(state: ProcessState) => void> = [];
|
||||
private processManager: ServerManager;
|
||||
private stateChangeCallbacks: Array<(state: ServerState) => void> = [];
|
||||
private openCodeClient: OpenCodeClient;
|
||||
private workspaceContext: WorkspaceContext;
|
||||
private contextManager: ContextManager;
|
||||
private viewManager: ViewManager;
|
||||
private cachedIframeUrl: string | null = null;
|
||||
private lastBaseUrl: string | null = null;
|
||||
private contextEventRefs: EventRef[] = [];
|
||||
private contextRefreshTimer: number | null = null;
|
||||
|
||||
async onload(): Promise<void> {
|
||||
console.log("Loading OpenCode plugin");
|
||||
@@ -25,32 +26,84 @@ export default class OpenCodePlugin extends Plugin {
|
||||
|
||||
await this.loadSettings();
|
||||
|
||||
// Attempt autodetect if opencodePath is empty and not using custom command
|
||||
await this.attemptAutodetect();
|
||||
|
||||
const projectDirectory = this.getProjectDirectory();
|
||||
|
||||
this.processManager = new ProcessManager(
|
||||
this.settings,
|
||||
projectDirectory,
|
||||
(state) => this.notifyStateChange(state)
|
||||
);
|
||||
this.processManager = new ServerManager(this.settings, projectDirectory);
|
||||
this.processManager.on("stateChange", (state: ServerState) => {
|
||||
this.notifyStateChange(state);
|
||||
});
|
||||
|
||||
this.openCodeClient = new OpenCodeClient(this.getApiBaseUrl(), this.getServerUrl(), projectDirectory);
|
||||
this.workspaceContext = new WorkspaceContext(this.app);
|
||||
// Listen for project directory changes and coordinate response
|
||||
this.processManager.on("projectDirectoryChanged", async (newDirectory: string) => {
|
||||
this.settings.projectDirectory = newDirectory;
|
||||
await this.saveData(this.settings);
|
||||
this.refreshClientState();
|
||||
if (this.getServerState() === "running") {
|
||||
await this.stopServer();
|
||||
await this.startServer();
|
||||
}
|
||||
});
|
||||
|
||||
this.openCodeClient = new OpenCodeClient(
|
||||
this.getApiBaseUrl(),
|
||||
this.getServerUrl(),
|
||||
projectDirectory
|
||||
);
|
||||
this.lastBaseUrl = this.getServerUrl();
|
||||
|
||||
console.log("[OpenCode] Configured with project directory:", projectDirectory);
|
||||
this.contextManager = new ContextManager({
|
||||
app: this.app,
|
||||
settings: this.settings,
|
||||
client: this.openCodeClient,
|
||||
getServerState: () => this.getServerState(),
|
||||
getCachedIframeUrl: () => this.cachedIframeUrl,
|
||||
setCachedIframeUrl: (url) => {
|
||||
this.cachedIframeUrl = url;
|
||||
},
|
||||
registerEvent: (ref) => this.registerEvent(ref),
|
||||
});
|
||||
|
||||
this.registerView(OPENCODE_VIEW_TYPE, (leaf) => new OpenCodeView(leaf, this));
|
||||
this.addSettingTab(new OpenCodeSettingTab(this.app, this));
|
||||
this.viewManager = new ViewManager({
|
||||
app: this.app,
|
||||
settings: this.settings,
|
||||
client: this.openCodeClient,
|
||||
contextManager: this.contextManager,
|
||||
getCachedIframeUrl: () => this.cachedIframeUrl,
|
||||
setCachedIframeUrl: (url) => {
|
||||
this.cachedIframeUrl = url;
|
||||
},
|
||||
getServerState: () => this.getServerState(),
|
||||
});
|
||||
|
||||
console.log(
|
||||
"[OpenCode] Configured with project directory:",
|
||||
projectDirectory
|
||||
);
|
||||
|
||||
this.registerView(
|
||||
OPENCODE_VIEW_TYPE,
|
||||
(leaf) => new OpenCodeView(leaf, this)
|
||||
);
|
||||
this.addSettingTab(new OpenCodeSettingTab(
|
||||
this.app,
|
||||
this,
|
||||
this.settings,
|
||||
this.processManager,
|
||||
() => this.saveSettings()
|
||||
));
|
||||
|
||||
this.addRibbonIcon(OPENCODE_ICON_NAME, "OpenCode", () => {
|
||||
this.activateView();
|
||||
void this.viewManager.activateView();
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: "toggle-opencode-view",
|
||||
name: "Toggle OpenCode panel",
|
||||
callback: () => {
|
||||
this.toggleView();
|
||||
void this.viewManager.toggleView();
|
||||
},
|
||||
hotkeys: [
|
||||
{
|
||||
@@ -82,20 +135,20 @@ export default class OpenCodePlugin extends Plugin {
|
||||
});
|
||||
}
|
||||
|
||||
this.updateContextListeners();
|
||||
this.onProcessStateChange((state) => {
|
||||
this.contextManager.updateSettings(this.settings);
|
||||
this.processManager.on("stateChange", (state: ServerState) => {
|
||||
if (state === "running") {
|
||||
void this.handleServerRunning();
|
||||
void this.contextManager.handleServerRunning();
|
||||
}
|
||||
});
|
||||
|
||||
// Register cleanup handlers for when Obsidian quits
|
||||
this.registerCleanupHandlers();
|
||||
|
||||
console.log("OpenCode plugin loaded");
|
||||
}
|
||||
|
||||
async onunload(): Promise<void> {
|
||||
this.contextManager.destroy();
|
||||
await this.stopServer();
|
||||
this.app.workspace.detachLeavesOfType(OPENCODE_VIEW_TYPE);
|
||||
}
|
||||
@@ -104,85 +157,51 @@ export default class OpenCodePlugin extends Plugin {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to autodetect opencode executable on startup
|
||||
* Triggers when opencodePath is empty and useCustomCommand is false
|
||||
*/
|
||||
private async attemptAutodetect(): Promise<void> {
|
||||
// Only autodetect if path is empty and not using custom command mode
|
||||
if (this.settings.opencodePath || this.settings.useCustomCommand) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[OpenCode] Attempting to autodetect opencode executable...");
|
||||
|
||||
const detectedPath = ExecutableResolver.resolve("opencode");
|
||||
|
||||
// Check if a different path was found (not the fallback)
|
||||
if (detectedPath && detectedPath !== "opencode") {
|
||||
console.log("[OpenCode] Autodetected opencode at:", detectedPath);
|
||||
this.settings.opencodePath = detectedPath;
|
||||
await this.saveData(this.settings);
|
||||
new Notice(`OpenCode executable found at ${detectedPath}`);
|
||||
} else {
|
||||
console.log("[OpenCode] Could not autodetect opencode executable");
|
||||
new Notice("Could not find opencode. Please check Settings");
|
||||
}
|
||||
}
|
||||
|
||||
async saveSettings(): Promise<void> {
|
||||
await this.saveData(this.settings);
|
||||
this.processManager.updateSettings(this.settings);
|
||||
this.refreshClientState();
|
||||
this.updateContextListeners();
|
||||
}
|
||||
|
||||
// Update project directory and restart server if running
|
||||
async updateProjectDirectory(directory: string): Promise<void> {
|
||||
this.settings.projectDirectory = directory;
|
||||
await this.saveData(this.settings);
|
||||
|
||||
this.processManager.updateProjectDirectory(this.getProjectDirectory());
|
||||
this.refreshClientState();
|
||||
|
||||
if (this.getProcessState() === "running") {
|
||||
this.stopServer();
|
||||
await this.startServer();
|
||||
}
|
||||
}
|
||||
|
||||
private getExistingLeaf(): WorkspaceLeaf | null {
|
||||
const leaves = this.app.workspace.getLeavesOfType(OPENCODE_VIEW_TYPE);
|
||||
return leaves.length > 0 ? leaves[0] : null;
|
||||
}
|
||||
|
||||
async activateView(): Promise<void> {
|
||||
const existingLeaf = this.getExistingLeaf();
|
||||
|
||||
if (existingLeaf) {
|
||||
this.app.workspace.revealLeaf(existingLeaf);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new leaf based on defaultViewLocation setting
|
||||
let leaf: WorkspaceLeaf | null = null;
|
||||
if (this.settings.defaultViewLocation === "main") {
|
||||
leaf = this.app.workspace.getLeaf("tab");
|
||||
} else {
|
||||
leaf = this.app.workspace.getRightLeaf(false);
|
||||
}
|
||||
|
||||
if (leaf) {
|
||||
await leaf.setViewState({
|
||||
type: OPENCODE_VIEW_TYPE,
|
||||
active: true,
|
||||
});
|
||||
this.app.workspace.revealLeaf(leaf);
|
||||
}
|
||||
}
|
||||
|
||||
async toggleView(): Promise<void> {
|
||||
const existingLeaf = this.getExistingLeaf();
|
||||
|
||||
if (existingLeaf) {
|
||||
// Check if the view is in the sidebar or main area
|
||||
const isInSidebar = existingLeaf.getRoot() === this.app.workspace.rightSplit;
|
||||
|
||||
if (isInSidebar) {
|
||||
// For sidebar views, check if sidebar is collapsed
|
||||
const rightSplit = this.app.workspace.rightSplit;
|
||||
if (rightSplit && !rightSplit.collapsed) {
|
||||
existingLeaf.detach();
|
||||
} else {
|
||||
this.app.workspace.revealLeaf(existingLeaf);
|
||||
}
|
||||
} else {
|
||||
// For main area views, just detach (close the tab)
|
||||
existingLeaf.detach();
|
||||
}
|
||||
} else {
|
||||
await this.activateView();
|
||||
}
|
||||
this.contextManager.updateSettings(this.settings);
|
||||
this.viewManager.updateSettings(this.settings);
|
||||
}
|
||||
|
||||
async startServer(): Promise<boolean> {
|
||||
const success = await this.processManager.start();
|
||||
if (success) {
|
||||
new Notice("OpenCode server started");
|
||||
} else {
|
||||
const error = this.processManager.getLastError();
|
||||
if (error) {
|
||||
new Notice(`OpenCode failed to start: ${error}`, 10000); // Show for 10 seconds
|
||||
} else {
|
||||
new Notice("OpenCode failed to start. Check Settings for details.", 5000);
|
||||
}
|
||||
}
|
||||
return success;
|
||||
}
|
||||
@@ -192,8 +211,8 @@ export default class OpenCodePlugin extends Plugin {
|
||||
new Notice("OpenCode server stopped");
|
||||
}
|
||||
|
||||
getProcessState(): ProcessState {
|
||||
return this.processManager?.getState() ?? "stopped";
|
||||
getServerState(): ServerState {
|
||||
return this.processManager.getState() ?? "stopped";
|
||||
}
|
||||
|
||||
getLastError(): string | null {
|
||||
@@ -216,40 +235,7 @@ export default class OpenCodePlugin extends Plugin {
|
||||
this.cachedIframeUrl = url;
|
||||
}
|
||||
|
||||
async ensureSessionUrl(view: OpenCodeView): Promise<void> {
|
||||
if (this.getProcessState() !== "running") {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingUrl = this.cachedIframeUrl ?? view.getIframeUrl();
|
||||
if (existingUrl && this.openCodeClient.resolveSessionId(existingUrl)) {
|
||||
this.cachedIframeUrl = existingUrl;
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = await this.openCodeClient.createSession();
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionUrl = this.openCodeClient.getSessionUrl(sessionId);
|
||||
this.cachedIframeUrl = sessionUrl;
|
||||
view.setIframeUrl(sessionUrl);
|
||||
|
||||
if (this.app.workspace.activeLeaf === view.leaf) {
|
||||
await this.updateOpenCodeContext(view.leaf);
|
||||
}
|
||||
}
|
||||
|
||||
refreshContextForView(view: OpenCodeView): void {
|
||||
if (!this.settings.injectWorkspaceContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
void this.updateOpenCodeContext(view.leaf);
|
||||
}
|
||||
|
||||
onProcessStateChange(callback: (state: ProcessState) => void): () => void {
|
||||
onServerStateChange(callback: (state: ServerState) => void): () => void {
|
||||
this.stateChangeCallbacks.push(callback);
|
||||
return () => {
|
||||
const index = this.stateChangeCallbacks.indexOf(callback);
|
||||
@@ -259,7 +245,7 @@ export default class OpenCodePlugin extends Plugin {
|
||||
};
|
||||
}
|
||||
|
||||
private notifyStateChange(state: ProcessState): void {
|
||||
private notifyStateChange(state: ServerState): void {
|
||||
for (const callback of this.stateChangeCallbacks) {
|
||||
callback(state);
|
||||
}
|
||||
@@ -278,147 +264,12 @@ export default class OpenCodePlugin extends Plugin {
|
||||
this.lastBaseUrl = nextUiBaseUrl;
|
||||
}
|
||||
|
||||
private updateContextListeners(): void {
|
||||
if (!this.settings.injectWorkspaceContext) {
|
||||
this.clearContextListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.contextEventRefs.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeLeafRef = this.app.workspace.on("active-leaf-change", (leaf) => {
|
||||
if (leaf?.view instanceof MarkdownView) {
|
||||
this.workspaceContext.trackViewSelection(leaf.view);
|
||||
}
|
||||
this.scheduleContextRefresh(0);
|
||||
});
|
||||
const fileOpenRef = this.app.workspace.on("file-open", () => {
|
||||
this.scheduleContextRefresh();
|
||||
});
|
||||
const fileCloseRef = (this.app.workspace as any).on("file-close", () => {
|
||||
this.scheduleContextRefresh();
|
||||
});
|
||||
const layoutChangeRef = this.app.workspace.on("layout-change", () => {
|
||||
this.scheduleContextRefresh();
|
||||
});
|
||||
const editorChangeRef = this.app.workspace.on("editor-change", (_editor, view) => {
|
||||
if (view instanceof MarkdownView) {
|
||||
this.workspaceContext.trackViewSelection(view);
|
||||
}
|
||||
this.scheduleContextRefresh(500);
|
||||
});
|
||||
const selectionChangeRef = (this.app.workspace as any).on(
|
||||
"editor-selection-change",
|
||||
(_editor: unknown, view: unknown) => {
|
||||
if (view instanceof MarkdownView) {
|
||||
this.workspaceContext.trackViewSelection(view);
|
||||
}
|
||||
this.scheduleContextRefresh(200);
|
||||
}
|
||||
);
|
||||
|
||||
this.contextEventRefs = [
|
||||
activeLeafRef,
|
||||
fileOpenRef,
|
||||
fileCloseRef,
|
||||
layoutChangeRef,
|
||||
editorChangeRef,
|
||||
selectionChangeRef,
|
||||
];
|
||||
this.contextEventRefs.forEach((ref) => this.registerEvent(ref));
|
||||
refreshContextForView(view: OpenCodeView): void {
|
||||
void this.contextManager.refreshContextForView(view);
|
||||
}
|
||||
|
||||
private clearContextListeners(): void {
|
||||
for (const ref of this.contextEventRefs) {
|
||||
this.app.workspace.offref(ref);
|
||||
}
|
||||
this.contextEventRefs = [];
|
||||
if (this.contextRefreshTimer !== null) {
|
||||
window.clearTimeout(this.contextRefreshTimer);
|
||||
this.contextRefreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleContextRefresh(delayMs: number = 300): void {
|
||||
const leaf = this.getOpenCodeLeafForRefresh();
|
||||
if (!leaf) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.contextRefreshTimer !== null) {
|
||||
window.clearTimeout(this.contextRefreshTimer);
|
||||
}
|
||||
|
||||
this.contextRefreshTimer = window.setTimeout(() => {
|
||||
this.contextRefreshTimer = null;
|
||||
void this.updateOpenCodeContext(leaf);
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
private getOpenCodeLeafForRefresh(): WorkspaceLeaf | null {
|
||||
const activeLeaf = this.app.workspace.activeLeaf;
|
||||
if (activeLeaf?.view.getViewType() === OPENCODE_VIEW_TYPE) {
|
||||
return activeLeaf;
|
||||
}
|
||||
|
||||
return this.getVisibleSidebarOpenCodeLeaf();
|
||||
}
|
||||
|
||||
private getVisibleSidebarOpenCodeLeaf(): WorkspaceLeaf | null {
|
||||
const leaves = this.app.workspace.getLeavesOfType(OPENCODE_VIEW_TYPE);
|
||||
if (leaves.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rightSplit = this.app.workspace.rightSplit;
|
||||
if (!rightSplit || rightSplit.collapsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const leaf = leaves[0];
|
||||
return leaf.getRoot() === rightSplit ? leaf : null;
|
||||
}
|
||||
|
||||
private async handleServerRunning(): Promise<void> {
|
||||
const activeLeaf = this.app.workspace.activeLeaf;
|
||||
if (activeLeaf?.view.getViewType() === OPENCODE_VIEW_TYPE) {
|
||||
await this.updateOpenCodeContext(activeLeaf);
|
||||
}
|
||||
}
|
||||
|
||||
private async updateOpenCodeContext(leaf: WorkspaceLeaf): Promise<void> {
|
||||
if (!this.settings.injectWorkspaceContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.getProcessState() !== "running") {
|
||||
return;
|
||||
}
|
||||
|
||||
const view = leaf.view instanceof OpenCodeView ? leaf.view : null;
|
||||
const iframeUrl = this.cachedIframeUrl ?? view?.getIframeUrl();
|
||||
if (!iframeUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = this.openCodeClient.resolveSessionId(iframeUrl);
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cachedIframeUrl = iframeUrl;
|
||||
|
||||
const { contextText } = this.workspaceContext.gatherContext(
|
||||
this.settings.maxNotesInContext,
|
||||
this.settings.maxSelectionLength
|
||||
);
|
||||
|
||||
await this.openCodeClient.updateContext({
|
||||
sessionId,
|
||||
contextText,
|
||||
});
|
||||
async ensureSessionUrl(view: OpenCodeView): Promise<void> {
|
||||
await this.viewManager.ensureSessionUrl(view);
|
||||
}
|
||||
|
||||
getProjectDirectory(): string {
|
||||
|
||||
133
src/server/ExecutableResolver.ts
Normal file
133
src/server/ExecutableResolver.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { existsSync } from "fs";
|
||||
import { homedir, platform } from "os";
|
||||
import { join, basename, isAbsolute } from "path";
|
||||
import { execSync } from "child_process";
|
||||
|
||||
/**
|
||||
* Resolves the opencode executable path across different platforms.
|
||||
* Follows the search algorithm:
|
||||
* 1. If configured path is absolute and exists, return it directly
|
||||
* 2. Extract basename from configured path
|
||||
* 3. Search platform-specific locations for that basename
|
||||
* 4. If found, return full path; if not found, return configured path as fallback
|
||||
*/
|
||||
export class ExecutableResolver {
|
||||
/**
|
||||
* Resolve the executable path based on configuration and platform
|
||||
* @param configuredPath The path configured in settings (e.g., "opencode" or "/path/to/opencode")
|
||||
* @returns The resolved full path or the configured path as fallback
|
||||
*/
|
||||
static resolve(configuredPath: string): string {
|
||||
// If configured path is absolute and exists, use it directly
|
||||
if (isAbsolute(configuredPath) && existsSync(configuredPath)) {
|
||||
return configuredPath;
|
||||
}
|
||||
|
||||
// Extract basename (e.g., "opencode" from "/path/to/opencode" or just "opencode")
|
||||
const execName = basename(configuredPath) || configuredPath;
|
||||
|
||||
// Get search directories for current platform
|
||||
const searchDirs = this.getSearchDirectories();
|
||||
|
||||
// Search for executable in platform directories
|
||||
for (const dir of searchDirs) {
|
||||
const fullPath = join(dir, execName);
|
||||
if (existsSync(fullPath)) {
|
||||
console.log("[OpenCode] Found executable at:", fullPath);
|
||||
return fullPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: return configured path (let spawn fail naturally if not found)
|
||||
console.log("[OpenCode] Executable not found in common paths, using configured:", configuredPath);
|
||||
return configuredPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if executable exists in PATH
|
||||
* @param execName Name of executable to search for
|
||||
* @returns Full path if found in PATH, null otherwise
|
||||
*/
|
||||
static resolveFromPath(execName: string): string | null {
|
||||
try {
|
||||
// Use 'which' on Unix systems, 'where' on Windows
|
||||
const command = platform() === "win32" ? "where" : "which";
|
||||
const result = execSync(`${command} "${execName}"`, { encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] });
|
||||
const path = result.trim().split("\n")[0];
|
||||
if (path && existsSync(path)) {
|
||||
return path;
|
||||
}
|
||||
} catch {
|
||||
// Command not found in PATH
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get platform-specific directories to search for executables
|
||||
*/
|
||||
private static getSearchDirectories(): string[] {
|
||||
const currentPlatform = platform();
|
||||
const homeDir = homedir();
|
||||
const searchDirs: string[] = [];
|
||||
|
||||
if (currentPlatform === "linux" || currentPlatform === "darwin") {
|
||||
// User directories
|
||||
searchDirs.push(
|
||||
join(homeDir, ".local", "bin"),
|
||||
join(homeDir, ".opencode", "bin"),
|
||||
join(homeDir, ".bun", "bin"),
|
||||
join(homeDir, ".npm-global", "bin")
|
||||
);
|
||||
|
||||
// nvm directories (expand wildcard)
|
||||
const nvmDirs = this.expandNvmDirectories(homeDir);
|
||||
searchDirs.push(...nvmDirs);
|
||||
|
||||
// System directories
|
||||
searchDirs.push("/usr/local/bin", "/usr/bin");
|
||||
|
||||
// macOS-specific directories
|
||||
if (currentPlatform === "darwin") {
|
||||
searchDirs.push("/opt/homebrew/bin");
|
||||
}
|
||||
} else if (currentPlatform === "win32") {
|
||||
// Windows directories with environment variable expansion
|
||||
const localAppData = process.env.LOCALAPPDATA || join(homeDir, "AppData", "Local");
|
||||
const userProfile = process.env.USERPROFILE || homeDir;
|
||||
|
||||
searchDirs.push(
|
||||
join(localAppData, "opencode", "bin"),
|
||||
join(userProfile, ".bun", "bin"),
|
||||
join(userProfile, ".local", "bin")
|
||||
);
|
||||
}
|
||||
|
||||
return searchDirs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand nvm wildcard directories
|
||||
* Searches ~/.nvm/versions/node/ for installed versions
|
||||
*/
|
||||
private static expandNvmDirectories(homeDir: string): string[] {
|
||||
const nvmBaseDir = join(homeDir, ".nvm", "versions", "node");
|
||||
const nvmDirs: string[] = [];
|
||||
|
||||
try {
|
||||
if (existsSync(nvmBaseDir)) {
|
||||
const { readdirSync } = require("fs");
|
||||
const versions = readdirSync(nvmBaseDir, { withFileTypes: true });
|
||||
for (const version of versions) {
|
||||
if (version.isDirectory()) {
|
||||
nvmDirs.push(join(nvmBaseDir, version.name, "bin"));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// nvm directory doesn't exist or is not accessible
|
||||
}
|
||||
|
||||
return nvmDirs;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,29 @@
|
||||
import { spawn, ChildProcess } from "child_process";
|
||||
import { OpenCodeSettings } from "./types";
|
||||
import { ChildProcess, SpawnOptions } from "child_process";
|
||||
import { EventEmitter } from "events";
|
||||
import { OpenCodeSettings } from "../types";
|
||||
import { ServerState } from "./types";
|
||||
import { OpenCodeProcess } from "./process/OpenCodeProcess";
|
||||
import { WindowsProcess } from "./process/WindowsProcess";
|
||||
import { PosixProcess } from "./process/PosixProcess";
|
||||
import { ExecutableResolver } from "./ExecutableResolver";
|
||||
|
||||
export type ProcessState = "stopped" | "starting" | "running" | "error";
|
||||
export type { ServerState } from "./types";
|
||||
|
||||
export class ProcessManager {
|
||||
export class ServerManager extends EventEmitter {
|
||||
private process: ChildProcess | null = null;
|
||||
private state: ProcessState = "stopped";
|
||||
private state: ServerState = "stopped";
|
||||
private lastError: string | null = null;
|
||||
private earlyExitCode: number | null = null;
|
||||
private settings: OpenCodeSettings;
|
||||
private projectDirectory: string;
|
||||
private onStateChange: (state: ProcessState) => void;
|
||||
private processImpl: OpenCodeProcess;
|
||||
|
||||
constructor(
|
||||
settings: OpenCodeSettings,
|
||||
projectDirectory: string,
|
||||
onStateChange: (state: ProcessState) => void
|
||||
) {
|
||||
constructor(settings: OpenCodeSettings, projectDirectory: string) {
|
||||
super();
|
||||
this.settings = settings;
|
||||
this.projectDirectory = projectDirectory;
|
||||
this.onStateChange = onStateChange;
|
||||
this.processImpl =
|
||||
process.platform === "win32" ? new WindowsProcess() : new PosixProcess();
|
||||
}
|
||||
|
||||
updateSettings(settings: OpenCodeSettings): void {
|
||||
@@ -28,9 +32,10 @@ export class ProcessManager {
|
||||
|
||||
updateProjectDirectory(directory: string): void {
|
||||
this.projectDirectory = directory;
|
||||
this.emit("projectDirectoryChanged", directory);
|
||||
}
|
||||
|
||||
getState(): ProcessState {
|
||||
getState(): ServerState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
@@ -39,7 +44,7 @@ export class ProcessManager {
|
||||
}
|
||||
|
||||
getUrl(): string {
|
||||
const encodedPath = btoa(this.projectDirectory);
|
||||
const encodedPath = Buffer.from(this.projectDirectory).toString('base64');
|
||||
return `http://${this.settings.hostname}:${this.settings.port}/${encodedPath}`;
|
||||
}
|
||||
|
||||
@@ -56,38 +61,77 @@ export class ProcessManager {
|
||||
return this.setError("Project directory (vault) not configured");
|
||||
}
|
||||
|
||||
// Determine execution mode and resolve executable path
|
||||
let executablePath: string;
|
||||
let spawnOptions: SpawnOptions;
|
||||
|
||||
if (this.settings.useCustomCommand) {
|
||||
// Custom command mode: use custom command directly with shell
|
||||
executablePath = this.settings.customCommand;
|
||||
spawnOptions = {
|
||||
cwd: this.projectDirectory,
|
||||
env: { ...process.env, NODE_USE_SYSTEM_CA: "1" },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
shell: true,
|
||||
};
|
||||
} else {
|
||||
// Path mode: resolve executable and verify
|
||||
executablePath = ExecutableResolver.resolve(this.settings.opencodePath);
|
||||
|
||||
// Pre-flight check: verify executable exists (only for path mode)
|
||||
const commandError = await this.processImpl.verifyCommand(executablePath);
|
||||
if (commandError) {
|
||||
return this.setError(commandError);
|
||||
}
|
||||
|
||||
spawnOptions = {
|
||||
cwd: this.projectDirectory,
|
||||
env: { ...process.env, NODE_USE_SYSTEM_CA: "1" },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
};
|
||||
}
|
||||
|
||||
if (await this.checkServerHealth()) {
|
||||
console.log("[OpenCode] Server already running on port", this.settings.port);
|
||||
console.log(
|
||||
"[OpenCode] Server already running on port",
|
||||
this.settings.port
|
||||
);
|
||||
this.setState("running");
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log("[OpenCode] Starting server:", {
|
||||
opencodePath: this.settings.opencodePath,
|
||||
mode: this.settings.useCustomCommand ? "custom" : "path",
|
||||
command: executablePath,
|
||||
port: this.settings.port,
|
||||
hostname: this.settings.hostname,
|
||||
cwd: this.projectDirectory,
|
||||
projectDirectory: this.projectDirectory,
|
||||
});
|
||||
|
||||
this.process = spawn(
|
||||
this.settings.opencodePath,
|
||||
[
|
||||
"serve",
|
||||
"--port",
|
||||
this.settings.port.toString(),
|
||||
"--hostname",
|
||||
this.settings.hostname,
|
||||
"--cors",
|
||||
"app://obsidian.md",
|
||||
],
|
||||
{
|
||||
cwd: this.projectDirectory,
|
||||
env: { ...process.env, NODE_USE_SYSTEM_CA: "1" },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: true,
|
||||
}
|
||||
);
|
||||
if (this.settings.useCustomCommand) {
|
||||
// Custom command mode: spawn with shell, no args appended
|
||||
this.process = this.processImpl.start(
|
||||
executablePath,
|
||||
[], // User controls all arguments in custom command
|
||||
spawnOptions
|
||||
);
|
||||
} else {
|
||||
// Path mode: spawn with default arguments
|
||||
this.process = this.processImpl.start(
|
||||
executablePath,
|
||||
[
|
||||
"serve",
|
||||
"--port",
|
||||
this.settings.port.toString(),
|
||||
"--hostname",
|
||||
this.settings.hostname,
|
||||
"--cors",
|
||||
"app://obsidian.md",
|
||||
],
|
||||
spawnOptions
|
||||
);
|
||||
}
|
||||
|
||||
console.log("[OpenCode] Process spawned with PID:", this.process.pid);
|
||||
|
||||
@@ -100,7 +144,9 @@ export class ProcessManager {
|
||||
});
|
||||
|
||||
this.process.on("exit", (code, signal) => {
|
||||
console.log(`[OpenCode] Process exited with code ${code}, signal ${signal}`);
|
||||
console.log(
|
||||
`[OpenCode] Process exited with code ${code}, signal ${signal}`
|
||||
);
|
||||
this.process = null;
|
||||
|
||||
if (this.state === "starting" && code !== null && code !== 0) {
|
||||
@@ -117,7 +163,12 @@ export class ProcessManager {
|
||||
this.process = null;
|
||||
|
||||
if (err.code === "ENOENT") {
|
||||
this.setError(`Executable not found at '${this.settings.opencodePath}'`);
|
||||
const command = this.settings.useCustomCommand
|
||||
? this.settings.customCommand
|
||||
: this.settings.opencodePath;
|
||||
this.setError(
|
||||
`Executable not found: '${command}'`
|
||||
);
|
||||
} else {
|
||||
this.setError(`Failed to start: ${err.message}`);
|
||||
}
|
||||
@@ -135,7 +186,9 @@ export class ProcessManager {
|
||||
|
||||
await this.stop();
|
||||
if (this.earlyExitCode !== null) {
|
||||
return this.setError(`Process exited unexpectedly (exit code ${this.earlyExitCode})`);
|
||||
return this.setError(
|
||||
`Process exited unexpectedly (exit code ${this.earlyExitCode})`
|
||||
);
|
||||
}
|
||||
if (!this.process) {
|
||||
return this.setError("Process exited before server became ready");
|
||||
@@ -149,101 +202,17 @@ export class ProcessManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const pid = this.process.pid;
|
||||
const proc = this.process;
|
||||
|
||||
if (!pid) {
|
||||
console.log("[OpenCode] No PID available, cleaning up state");
|
||||
this.setState("stopped");
|
||||
this.process = null;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[OpenCode] Stopping server process tree, PID:", pid);
|
||||
|
||||
this.setState("stopped");
|
||||
this.process = null;
|
||||
|
||||
await this.killProcessTree(pid, "SIGTERM");
|
||||
|
||||
const gracefulExited = await this.waitForProcessExit(proc, 2000);
|
||||
|
||||
if (gracefulExited) {
|
||||
console.log("[OpenCode] Server stopped gracefully");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[OpenCode] Process didn't exit gracefully, sending SIGKILL");
|
||||
|
||||
await this.killProcessTree(pid, "SIGKILL");
|
||||
|
||||
// Step 4: Wait for force kill (up to 3 more seconds)
|
||||
const forceExited = await this.waitForProcessExit(proc, 3000);
|
||||
|
||||
if (forceExited) {
|
||||
console.log("[OpenCode] Server stopped with SIGKILL");
|
||||
} else {
|
||||
console.error("[OpenCode] Failed to stop server within timeout");
|
||||
}
|
||||
await this.processImpl.stop(proc);
|
||||
}
|
||||
|
||||
private async killProcessTree(pid: number, signal: "SIGTERM" | "SIGKILL"): Promise<void> {
|
||||
const platform = process.platform;
|
||||
|
||||
if (platform === "win32") {
|
||||
// Windows: Use taskkill with /T flag to kill process tree
|
||||
await this.execAsync(`taskkill /T /F /PID ${pid}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Unix: Try process group kill (negative PID)
|
||||
process.kill(-pid, signal);
|
||||
return;
|
||||
}
|
||||
|
||||
private async waitForProcessExit(proc: ChildProcess, timeoutMs: number): Promise<boolean> {
|
||||
if (proc.exitCode !== null || proc.signalCode !== null) {
|
||||
return true; // Already exited
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
resolve(false);
|
||||
}, timeoutMs);
|
||||
|
||||
const onExit = () => {
|
||||
cleanup();
|
||||
resolve(true);
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
proc.off("exit", onExit);
|
||||
proc.off("error", onExit);
|
||||
};
|
||||
|
||||
proc.once("exit", onExit);
|
||||
proc.once("error", onExit);
|
||||
});
|
||||
}
|
||||
|
||||
private execAsync(command: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { exec } = require("child_process");
|
||||
exec(command, (error: Error | null) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private setState(state: ProcessState): void {
|
||||
private setState(state: ServerState): void {
|
||||
this.state = state;
|
||||
this.onStateChange(state);
|
||||
this.emit("stateChange", state);
|
||||
}
|
||||
|
||||
private setError(message: string): false {
|
||||
18
src/server/process/OpenCodeProcess.ts
Normal file
18
src/server/process/OpenCodeProcess.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ChildProcess, SpawnOptions } from "child_process";
|
||||
|
||||
export interface OpenCodeProcess {
|
||||
/** Start the process. Returns a handle to listen for events. */
|
||||
start(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: SpawnOptions
|
||||
): ChildProcess;
|
||||
|
||||
/** Stop the process gracefully, then forcefully if needed.
|
||||
* Resolves when process has exited.
|
||||
* Handles all PID/process tree logic internally. */
|
||||
stop(process: ChildProcess): Promise<void>;
|
||||
|
||||
/** Verify that command exists and is executable. Returns error message or null if OK. */
|
||||
verifyCommand(command: string): Promise<string | null>;
|
||||
}
|
||||
108
src/server/process/PosixProcess.ts
Normal file
108
src/server/process/PosixProcess.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { ChildProcess, spawn, SpawnOptions } from "child_process";
|
||||
import { existsSync } from "fs";
|
||||
import { OpenCodeProcess } from "./OpenCodeProcess";
|
||||
|
||||
export class PosixProcess implements OpenCodeProcess {
|
||||
start(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: SpawnOptions
|
||||
): ChildProcess {
|
||||
return spawn(command, args, {
|
||||
...options,
|
||||
detached: true, // Creates a new process group
|
||||
});
|
||||
}
|
||||
|
||||
async stop(process: ChildProcess): Promise<void> {
|
||||
const pid = process.pid;
|
||||
if (!pid) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[OpenCode] Stopping server process tree, PID:", pid);
|
||||
|
||||
// Try graceful termination first
|
||||
await this.killProcessGroup(pid, "SIGTERM");
|
||||
const gracefulExited = await this.waitForExit(process, 2000);
|
||||
|
||||
if (gracefulExited) {
|
||||
console.log("[OpenCode] Server stopped gracefully");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[OpenCode] Process didn't exit gracefully, sending SIGKILL");
|
||||
|
||||
// Force kill
|
||||
await this.killProcessGroup(pid, "SIGKILL");
|
||||
const forceExited = await this.waitForExit(process, 3000);
|
||||
|
||||
if (forceExited) {
|
||||
console.log("[OpenCode] Server stopped with SIGKILL");
|
||||
} else {
|
||||
console.error("[OpenCode] Failed to stop server within timeout");
|
||||
}
|
||||
}
|
||||
|
||||
async verifyCommand(command: string): Promise<string | null> {
|
||||
// Check if command is absolute path - verify it exists and is executable
|
||||
if (command.startsWith('/') || command.startsWith('./')) {
|
||||
const fs = require('fs');
|
||||
try {
|
||||
fs.accessSync(command, fs.constants.X_OK);
|
||||
return null;
|
||||
} catch (err: any) {
|
||||
// Check if file exists but isn't executable
|
||||
if (existsSync(command)) {
|
||||
return `'${command}' exists but is not executable. Run: chmod +x ${command}`;
|
||||
}
|
||||
return `Executable not found at '${command}'. Check Settings → OpenCode path, or click "Autodetect"`;
|
||||
}
|
||||
}
|
||||
// For non-absolute paths, let spawn handle it (will fire ENOENT if not found)
|
||||
return null;
|
||||
}
|
||||
|
||||
private async killProcessGroup(
|
||||
pid: number,
|
||||
signal: "SIGTERM" | "SIGKILL"
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Negative PID kills the entire process group
|
||||
process.kill(-pid, signal);
|
||||
} catch (error) {
|
||||
// Process may already be gone
|
||||
console.log(`[OpenCode] Signal ${signal} failed (process may already be gone)`);
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForExit(
|
||||
process: ChildProcess,
|
||||
timeoutMs: number
|
||||
): Promise<boolean> {
|
||||
if (process.exitCode !== null || process.signalCode !== null) {
|
||||
return true; // Already exited
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
resolve(false);
|
||||
}, timeoutMs);
|
||||
|
||||
const onExit = () => {
|
||||
cleanup();
|
||||
resolve(true);
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
process.off("exit", onExit);
|
||||
process.off("error", onExit);
|
||||
};
|
||||
|
||||
process.once("exit", onExit);
|
||||
process.once("error", onExit);
|
||||
});
|
||||
}
|
||||
}
|
||||
181
src/server/process/WindowsProcess.ts
Normal file
181
src/server/process/WindowsProcess.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { ChildProcess, spawn, SpawnOptions } from "child_process";
|
||||
import { OpenCodeProcess } from "./OpenCodeProcess";
|
||||
|
||||
export class WindowsProcess implements OpenCodeProcess {
|
||||
// Static state to track the current process for cleanup
|
||||
private static currentProcess: ChildProcess | null = null;
|
||||
private static cleanupHandlerRegistered = false;
|
||||
|
||||
start(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: SpawnOptions
|
||||
): ChildProcess {
|
||||
const process = spawn(command, args, {
|
||||
...options,
|
||||
shell: true,
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
// Store process for cleanup
|
||||
WindowsProcess.currentProcess = process;
|
||||
WindowsProcess.registerCleanupHandler();
|
||||
|
||||
return process;
|
||||
}
|
||||
|
||||
async stop(process: ChildProcess): Promise<void> {
|
||||
const pid = process.pid;
|
||||
if (!pid) {
|
||||
WindowsProcess.currentProcess = null;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[OpenCode] Stopping server process tree, PID:", pid);
|
||||
|
||||
// Method 1: Find and kill child processes (actual node.exe) using PowerShell
|
||||
// This is necessary because shell: true spawns cmd.exe -> node.exe, and
|
||||
// killing cmd.exe leaves node.exe orphaned
|
||||
try {
|
||||
const { execSync } = require("child_process");
|
||||
const output = execSync(
|
||||
`powershell -Command "Get-CimInstance Win32_Process -Filter \\"ParentProcessId=${pid}\\" | Select-Object ProcessId"`,
|
||||
{ encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] }
|
||||
);
|
||||
|
||||
const lines = output.split("\n").slice(3); // Skip headers
|
||||
for (const line of lines) {
|
||||
const childPid = line.trim();
|
||||
if (childPid && !isNaN(parseInt(childPid))) {
|
||||
try {
|
||||
execSync(`taskkill /F /PID ${childPid}`, { stdio: "ignore" });
|
||||
} catch {
|
||||
// Child may already be gone
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// PowerShell lookup failed, continue to other methods
|
||||
}
|
||||
|
||||
// Method 2: Kill the parent process (cmd.exe)
|
||||
try {
|
||||
await this.execAsync(`taskkill /F /PID ${pid}`);
|
||||
} catch {
|
||||
// Parent may already be gone
|
||||
}
|
||||
|
||||
// Clear stored process
|
||||
WindowsProcess.currentProcess = null;
|
||||
|
||||
// Wait for process to exit
|
||||
await this.waitForExit(process, 5000);
|
||||
}
|
||||
|
||||
private static registerCleanupHandler(): void {
|
||||
if (WindowsProcess.cleanupHandlerRegistered) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Register beforeunload handler for window close cleanup
|
||||
// Skip in CI/test environments to avoid interfering with test lifecycle
|
||||
if (typeof window !== "undefined" && !process.env.CI) {
|
||||
window.addEventListener("beforeunload", () => {
|
||||
if (WindowsProcess.currentProcess?.pid) {
|
||||
WindowsProcess.killProcessSync(WindowsProcess.currentProcess.pid);
|
||||
}
|
||||
});
|
||||
WindowsProcess.cleanupHandlerRegistered = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static killProcessSync(pid: number): void {
|
||||
try {
|
||||
const { execSync } = require("child_process");
|
||||
|
||||
// Method 1: Kill child processes using PowerShell
|
||||
try {
|
||||
const output = execSync(
|
||||
`powershell -Command "Get-CimInstance Win32_Process -Filter \\"ParentProcessId=${pid}\\" | Select-Object ProcessId"`,
|
||||
{ encoding: "utf8", stdio: ["pipe", "pipe", "ignore"] }
|
||||
);
|
||||
|
||||
const lines = output.split("\n").slice(3);
|
||||
for (const line of lines) {
|
||||
const childPid = line.trim();
|
||||
if (childPid && !isNaN(parseInt(childPid))) {
|
||||
try {
|
||||
execSync(`taskkill /F /PID ${childPid}`, { stdio: "ignore" });
|
||||
} catch {
|
||||
// Child may already be gone
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// PowerShell lookup failed
|
||||
}
|
||||
|
||||
// Method 2: Kill parent process
|
||||
try {
|
||||
execSync(`taskkill /F /PID ${pid}`, { stdio: "ignore" });
|
||||
} catch {
|
||||
// Parent may already be gone
|
||||
}
|
||||
} catch {
|
||||
// Process may already be gone
|
||||
}
|
||||
}
|
||||
|
||||
async verifyCommand(command: string): Promise<string | null> {
|
||||
// Use 'where' command to check if executable exists in PATH
|
||||
try {
|
||||
await this.execAsync(`where "${command}"`);
|
||||
return null;
|
||||
} catch {
|
||||
return `Executable not found at '${command}'. Check Settings → OpenCode path, or click "Autodetect"`;
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForExit(
|
||||
process: ChildProcess,
|
||||
timeoutMs: number
|
||||
): Promise<void> {
|
||||
if (process.exitCode !== null || process.signalCode !== null) {
|
||||
return; // Already exited
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
resolve();
|
||||
}, timeoutMs);
|
||||
|
||||
const onExit = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
process.off("exit", onExit);
|
||||
process.off("error", onExit);
|
||||
};
|
||||
|
||||
process.once("exit", onExit);
|
||||
process.once("error", onExit);
|
||||
});
|
||||
}
|
||||
|
||||
private execAsync(command: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { exec } = require("child_process");
|
||||
exec(command, (error: Error | null) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
1
src/server/types.ts
Normal file
1
src/server/types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type ServerState = "stopped" | "starting" | "running" | "error";
|
||||
@@ -1,8 +1,9 @@
|
||||
import { App, PluginSettingTab, Setting, Notice } from "obsidian";
|
||||
import { App, Plugin, PluginSettingTab, Setting, Notice } from "obsidian";
|
||||
import { existsSync, statSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import type OpenCodePlugin from "./main";
|
||||
import type { ViewLocation } from "./types";
|
||||
import { OpenCodeSettings, ViewLocation } from "../types";
|
||||
import { ServerManager } from "../server/ServerManager";
|
||||
import { ExecutableResolver } from "../server/ExecutableResolver";
|
||||
|
||||
function expandTilde(path: string): string {
|
||||
if (path === "~") {
|
||||
@@ -15,12 +16,16 @@ function expandTilde(path: string): string {
|
||||
}
|
||||
|
||||
export class OpenCodeSettingTab extends PluginSettingTab {
|
||||
plugin: OpenCodePlugin;
|
||||
private validateTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(app: App, plugin: OpenCodePlugin) {
|
||||
constructor(
|
||||
app: App,
|
||||
plugin: Plugin,
|
||||
private settings: OpenCodeSettings,
|
||||
private serverManager: ServerManager,
|
||||
private onSettingsChange: () => Promise<void>
|
||||
) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
display(): void {
|
||||
@@ -35,12 +40,12 @@ export class OpenCodeSettingTab extends PluginSettingTab {
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("14096")
|
||||
.setValue(this.plugin.settings.port.toString())
|
||||
.setValue(this.settings.port.toString())
|
||||
.onChange(async (value) => {
|
||||
const port = parseInt(value, 10);
|
||||
if (!isNaN(port) && port > 0 && port < 65536) {
|
||||
this.plugin.settings.port = port;
|
||||
await this.plugin.saveSettings();
|
||||
this.settings.port = port;
|
||||
await this.onSettingsChange();
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -51,37 +56,94 @@ export class OpenCodeSettingTab extends PluginSettingTab {
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("127.0.0.1")
|
||||
.setValue(this.plugin.settings.hostname)
|
||||
.setValue(this.settings.hostname)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.hostname = value || "127.0.0.1";
|
||||
await this.plugin.saveSettings();
|
||||
this.settings.hostname = value || "127.0.0.1";
|
||||
await this.onSettingsChange();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("OpenCode path")
|
||||
.setDesc(
|
||||
"Path to the OpenCode executable. Leave as 'opencode' if it's in your PATH."
|
||||
)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("opencode")
|
||||
.setValue(this.plugin.settings.opencodePath)
|
||||
const customCmdSetting = new Setting(containerEl)
|
||||
.setName("Use custom command")
|
||||
.setDesc("Enable to use a custom shell command instead of the executable path")
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.settings.useCustomCommand)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.opencodePath = value || "opencode";
|
||||
await this.plugin.saveSettings();
|
||||
this.settings.useCustomCommand = value;
|
||||
await this.onSettingsChange();
|
||||
// Re-render to show/hide appropriate fields
|
||||
this.display();
|
||||
})
|
||||
);
|
||||
|
||||
const descEl = customCmdSetting.descEl;
|
||||
descEl.createEl("br");
|
||||
const linkEl = descEl.createEl("a", {
|
||||
text: "Learn more",
|
||||
href: "https://github.com/mtymek/opencode-obsidian#custom-command-mode"
|
||||
});
|
||||
linkEl.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
window.open(linkEl.href, "_blank");
|
||||
});
|
||||
|
||||
if (this.settings.useCustomCommand) {
|
||||
new Setting(containerEl)
|
||||
.setName("Custom command")
|
||||
.setDesc("Custom shell command to start OpenCode.")
|
||||
.addTextArea((text) => {
|
||||
text
|
||||
.setPlaceholder("opencode serve --port 14096 --hostname 127.0.0.1 --cors app://obsidian.md")
|
||||
.setValue(this.settings.customCommand)
|
||||
.onChange(async (value) => {
|
||||
this.settings.customCommand = value;
|
||||
await this.onSettingsChange();
|
||||
});
|
||||
text.inputEl.rows = 3;
|
||||
text.inputEl.style.width = "100%";
|
||||
return text;
|
||||
});
|
||||
} else {
|
||||
const pathSetting = new Setting(containerEl)
|
||||
.setName("OpenCode executable path")
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("opencode")
|
||||
.setValue(this.settings.opencodePath)
|
||||
.onChange(async (value) => {
|
||||
this.settings.opencodePath = value;
|
||||
await this.onSettingsChange();
|
||||
})
|
||||
);
|
||||
|
||||
pathSetting.addButton((button) => {
|
||||
button
|
||||
.setButtonText("Autodetect")
|
||||
.onClick(async () => {
|
||||
const detectedPath = ExecutableResolver.resolve("opencode");
|
||||
if (detectedPath && detectedPath !== "opencode") {
|
||||
this.settings.opencodePath = detectedPath;
|
||||
await this.onSettingsChange();
|
||||
// Refresh the text input
|
||||
this.display();
|
||||
new Notice(`OpenCode executable found at ${detectedPath}`);
|
||||
} else {
|
||||
new Notice("Could not find opencode. Please check your installation.");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Project directory")
|
||||
.setDesc(
|
||||
"Override the starting directory for OpenCode. Leave empty to use the vault root. Supports ~ for home directory."
|
||||
"Override the starting directory for OpenCode. Leave empty to use the vault root."
|
||||
)
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("/path/to/project or ~/project")
|
||||
.setValue(this.plugin.settings.projectDirectory)
|
||||
.setValue(this.settings.projectDirectory)
|
||||
.onChange((value) => {
|
||||
// Debounce validation to avoid spamming notices on every keypress
|
||||
if (this.validateTimeout) {
|
||||
@@ -102,10 +164,10 @@ export class OpenCodeSettingTab extends PluginSettingTab {
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.plugin.settings.autoStart)
|
||||
.setValue(this.settings.autoStart)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.autoStart = value;
|
||||
await this.plugin.saveSettings();
|
||||
this.settings.autoStart = value;
|
||||
await this.onSettingsChange();
|
||||
})
|
||||
);
|
||||
|
||||
@@ -118,10 +180,10 @@ export class OpenCodeSettingTab extends PluginSettingTab {
|
||||
dropdown
|
||||
.addOption("sidebar", "Sidebar")
|
||||
.addOption("main", "Main window")
|
||||
.setValue(this.plugin.settings.defaultViewLocation)
|
||||
.setValue(this.settings.defaultViewLocation)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.defaultViewLocation = value as ViewLocation;
|
||||
await this.plugin.saveSettings();
|
||||
this.settings.defaultViewLocation = value as ViewLocation;
|
||||
await this.onSettingsChange();
|
||||
})
|
||||
);
|
||||
|
||||
@@ -134,10 +196,10 @@ export class OpenCodeSettingTab extends PluginSettingTab {
|
||||
)
|
||||
.addToggle((toggle) =>
|
||||
toggle
|
||||
.setValue(this.plugin.settings.injectWorkspaceContext)
|
||||
.setValue(this.settings.injectWorkspaceContext)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.injectWorkspaceContext = value;
|
||||
await this.plugin.saveSettings();
|
||||
this.settings.injectWorkspaceContext = value;
|
||||
await this.onSettingsChange();
|
||||
})
|
||||
);
|
||||
|
||||
@@ -147,11 +209,11 @@ export class OpenCodeSettingTab extends PluginSettingTab {
|
||||
.addSlider((slider) =>
|
||||
slider
|
||||
.setLimits(1, 50, 1)
|
||||
.setValue(this.plugin.settings.maxNotesInContext)
|
||||
.setValue(this.settings.maxNotesInContext)
|
||||
.setDynamicTooltip()
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.maxNotesInContext = value;
|
||||
await this.plugin.saveSettings();
|
||||
this.settings.maxNotesInContext = value;
|
||||
await this.onSettingsChange();
|
||||
})
|
||||
);
|
||||
|
||||
@@ -161,11 +223,11 @@ export class OpenCodeSettingTab extends PluginSettingTab {
|
||||
.addSlider((slider) =>
|
||||
slider
|
||||
.setLimits(500, 5000, 100)
|
||||
.setValue(this.plugin.settings.maxSelectionLength)
|
||||
.setValue(this.settings.maxSelectionLength)
|
||||
.setDynamicTooltip()
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.maxSelectionLength = value;
|
||||
await this.plugin.saveSettings();
|
||||
this.settings.maxSelectionLength = value;
|
||||
await this.onSettingsChange();
|
||||
})
|
||||
);
|
||||
|
||||
@@ -180,7 +242,8 @@ export class OpenCodeSettingTab extends PluginSettingTab {
|
||||
|
||||
// Empty value is valid - means use vault root
|
||||
if (!trimmed) {
|
||||
await this.plugin.updateProjectDirectory("");
|
||||
this.serverManager.updateProjectDirectory("");
|
||||
await this.onSettingsChange();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -207,13 +270,14 @@ export class OpenCodeSettingTab extends PluginSettingTab {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.plugin.updateProjectDirectory(expanded);
|
||||
this.serverManager.updateProjectDirectory(expanded);
|
||||
await this.onSettingsChange();
|
||||
}
|
||||
|
||||
private renderServerStatus(container: HTMLElement): void {
|
||||
container.empty();
|
||||
|
||||
const state = this.plugin.getProcessState();
|
||||
const state = this.serverManager.getState();
|
||||
const statusText = {
|
||||
stopped: "Stopped",
|
||||
starting: "Starting...",
|
||||
@@ -235,16 +299,28 @@ export class OpenCodeSettingTab extends PluginSettingTab {
|
||||
cls: `opencode-status-badge ${statusClass[state]}`,
|
||||
});
|
||||
|
||||
if (state === "error") {
|
||||
const errorMsg = this.serverManager.getLastError();
|
||||
if (errorMsg) {
|
||||
const errorEl = container.createDiv({ cls: "opencode-error-details" });
|
||||
errorEl.createEl("div", {
|
||||
text: errorMsg,
|
||||
cls: "opencode-error-text"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (state === "running") {
|
||||
const urlEl = container.createDiv({ cls: "opencode-status-line" });
|
||||
urlEl.createSpan({ text: "URL: " });
|
||||
const serverUrl = this.serverManager.getUrl();
|
||||
const linkEl = urlEl.createEl("a", {
|
||||
text: this.plugin.getServerUrl(),
|
||||
href: this.plugin.getServerUrl(),
|
||||
text: serverUrl,
|
||||
href: serverUrl,
|
||||
});
|
||||
linkEl.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
window.open(this.plugin.getServerUrl(), "_blank");
|
||||
window.open(serverUrl, "_blank");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -256,7 +332,7 @@ export class OpenCodeSettingTab extends PluginSettingTab {
|
||||
cls: "mod-cta",
|
||||
});
|
||||
startButton.addEventListener("click", async () => {
|
||||
await this.plugin.startServer();
|
||||
await this.serverManager.start();
|
||||
this.renderServerStatus(container);
|
||||
});
|
||||
}
|
||||
@@ -266,7 +342,7 @@ export class OpenCodeSettingTab extends PluginSettingTab {
|
||||
text: "Stop Server",
|
||||
});
|
||||
stopButton.addEventListener("click", () => {
|
||||
this.plugin.stopServer();
|
||||
this.serverManager.stop();
|
||||
this.renderServerStatus(container);
|
||||
});
|
||||
|
||||
@@ -275,8 +351,8 @@ export class OpenCodeSettingTab extends PluginSettingTab {
|
||||
cls: "mod-warning",
|
||||
});
|
||||
restartButton.addEventListener("click", async () => {
|
||||
this.plugin.stopServer();
|
||||
await this.plugin.startServer();
|
||||
this.serverManager.stop();
|
||||
await this.serverManager.start();
|
||||
this.renderServerStatus(container);
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,8 @@ export interface OpenCodeSettings {
|
||||
injectWorkspaceContext: boolean;
|
||||
maxNotesInContext: number;
|
||||
maxSelectionLength: number;
|
||||
customCommand: string;
|
||||
useCustomCommand: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: OpenCodeSettings = {
|
||||
@@ -19,11 +21,13 @@ export const DEFAULT_SETTINGS: OpenCodeSettings = {
|
||||
autoStart: false,
|
||||
opencodePath: "opencode",
|
||||
projectDirectory: "",
|
||||
startupTimeout: 15000,
|
||||
startupTimeout: 45000,
|
||||
defaultViewLocation: "sidebar",
|
||||
injectWorkspaceContext: true,
|
||||
injectWorkspaceContext: false,
|
||||
maxNotesInContext: 20,
|
||||
maxSelectionLength: 2000,
|
||||
customCommand: "",
|
||||
useCustomCommand: false,
|
||||
};
|
||||
|
||||
export const OPENCODE_VIEW_TYPE = "opencode-view";
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { ItemView, WorkspaceLeaf, setIcon } from "obsidian";
|
||||
import { OPENCODE_VIEW_TYPE } from "./types";
|
||||
import { OPENCODE_ICON_NAME } from "./icons";
|
||||
import type OpenCodePlugin from "./main";
|
||||
import { ProcessState } from "./ProcessManager";
|
||||
import { OPENCODE_VIEW_TYPE } from "../types";
|
||||
import { OPENCODE_ICON_NAME } from "../icons";
|
||||
import type OpenCodePlugin from "../main";
|
||||
import type { ServerState } from "../server/types";
|
||||
|
||||
export class OpenCodeView extends ItemView {
|
||||
plugin: OpenCodePlugin;
|
||||
private iframeEl: HTMLIFrameElement | null = null;
|
||||
private currentState: ProcessState = "stopped";
|
||||
private currentState: ServerState = "stopped";
|
||||
private unsubscribeStateChange: (() => void) | null = null;
|
||||
|
||||
constructor(leaf: WorkspaceLeaf, plugin: OpenCodePlugin) {
|
||||
@@ -32,13 +32,13 @@ export class OpenCodeView extends ItemView {
|
||||
this.contentEl.addClass("opencode-container");
|
||||
|
||||
// Subscribe to state changes
|
||||
this.unsubscribeStateChange = this.plugin.onProcessStateChange((state) => {
|
||||
this.unsubscribeStateChange = this.plugin.onServerStateChange((state: ServerState) => {
|
||||
this.currentState = state;
|
||||
this.updateView();
|
||||
});
|
||||
|
||||
// Initial render
|
||||
this.currentState = this.plugin.getProcessState();
|
||||
this.currentState = this.plugin.getServerState();
|
||||
this.updateView();
|
||||
|
||||
// Start server if not running (lazy start) - don't await to avoid blocking view open
|
||||
120
src/ui/ViewManager.ts
Normal file
120
src/ui/ViewManager.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { App, WorkspaceLeaf } from "obsidian";
|
||||
import { OPENCODE_VIEW_TYPE, OpenCodeSettings } from "../types";
|
||||
import { OpenCodeView } from "./OpenCodeView";
|
||||
import { OpenCodeClient } from "../client/OpenCodeClient";
|
||||
import { ContextManager } from "../context/ContextManager";
|
||||
import { ServerState } from "../server/types";
|
||||
|
||||
type ViewManagerDeps = {
|
||||
app: App;
|
||||
settings: OpenCodeSettings;
|
||||
client: OpenCodeClient;
|
||||
contextManager: ContextManager;
|
||||
getCachedIframeUrl: () => string | null;
|
||||
setCachedIframeUrl: (url: string | null) => void;
|
||||
getServerState: () => ServerState;
|
||||
};
|
||||
|
||||
export class ViewManager {
|
||||
private app: App;
|
||||
private settings: OpenCodeSettings;
|
||||
private client: OpenCodeClient;
|
||||
private contextManager: ContextManager;
|
||||
private getCachedIframeUrl: () => string | null;
|
||||
private setCachedIframeUrl: (url: string | null) => void;
|
||||
private getServerState: () => string;
|
||||
|
||||
constructor(deps: ViewManagerDeps) {
|
||||
this.app = deps.app;
|
||||
this.settings = deps.settings;
|
||||
this.client = deps.client;
|
||||
this.contextManager = deps.contextManager;
|
||||
this.getCachedIframeUrl = deps.getCachedIframeUrl;
|
||||
this.setCachedIframeUrl = deps.setCachedIframeUrl;
|
||||
this.getServerState = deps.getServerState;
|
||||
}
|
||||
|
||||
updateSettings(settings: OpenCodeSettings): void {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
private getExistingLeaf(): WorkspaceLeaf | null {
|
||||
const leaves = this.app.workspace.getLeavesOfType(OPENCODE_VIEW_TYPE);
|
||||
return leaves.length > 0 ? leaves[0] : null;
|
||||
}
|
||||
|
||||
async activateView(): Promise<void> {
|
||||
const existingLeaf = this.getExistingLeaf();
|
||||
|
||||
if (existingLeaf) {
|
||||
this.app.workspace.revealLeaf(existingLeaf);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new leaf based on defaultViewLocation setting
|
||||
let leaf: WorkspaceLeaf | null = null;
|
||||
if (this.settings.defaultViewLocation === "main") {
|
||||
leaf = this.app.workspace.getLeaf("tab");
|
||||
} else {
|
||||
leaf = this.app.workspace.getRightLeaf(false);
|
||||
}
|
||||
|
||||
if (leaf) {
|
||||
await leaf.setViewState({
|
||||
type: OPENCODE_VIEW_TYPE,
|
||||
active: true,
|
||||
});
|
||||
this.app.workspace.revealLeaf(leaf);
|
||||
}
|
||||
}
|
||||
|
||||
async toggleView(): Promise<void> {
|
||||
const existingLeaf = this.getExistingLeaf();
|
||||
|
||||
if (existingLeaf) {
|
||||
// Check if the view is in the sidebar or main area
|
||||
const isInSidebar = existingLeaf.getRoot() === this.app.workspace.rightSplit;
|
||||
|
||||
if (isInSidebar) {
|
||||
// For sidebar views, check if sidebar is collapsed
|
||||
const rightSplit = this.app.workspace.rightSplit;
|
||||
if (rightSplit && !rightSplit.collapsed) {
|
||||
existingLeaf.detach();
|
||||
} else {
|
||||
this.app.workspace.revealLeaf(existingLeaf);
|
||||
}
|
||||
} else {
|
||||
// For main area views, just detach (close the tab)
|
||||
existingLeaf.detach();
|
||||
}
|
||||
} else {
|
||||
await this.activateView();
|
||||
}
|
||||
}
|
||||
|
||||
async ensureSessionUrl(view: OpenCodeView): Promise<void> {
|
||||
if (this.getServerState() !== "running") {
|
||||
return;
|
||||
}
|
||||
|
||||
const cachedUrl = this.getCachedIframeUrl();
|
||||
const existingUrl = cachedUrl ?? view.getIframeUrl();
|
||||
if (existingUrl && this.client.resolveSessionId(existingUrl)) {
|
||||
this.setCachedIframeUrl(existingUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = await this.client.createSession();
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionUrl = this.client.getSessionUrl(sessionId);
|
||||
this.setCachedIframeUrl(sessionUrl);
|
||||
view.setIframeUrl(sessionUrl);
|
||||
|
||||
if (this.app.workspace.activeLeaf === view.leaf) {
|
||||
await this.contextManager.refreshContextForView(view);
|
||||
}
|
||||
}
|
||||
}
|
||||
15
styles.css
15
styles.css
@@ -191,3 +191,18 @@
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Error details in settings */
|
||||
.opencode-error-details {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
background-color: var(--background-secondary);
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid var(--text-error);
|
||||
}
|
||||
|
||||
.opencode-error-text {
|
||||
color: var(--text-error);
|
||||
font-size: 0.9em;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect, beforeAll, afterEach } from "bun:test";
|
||||
import { ProcessManager, ProcessState } from "../src/ProcessManager";
|
||||
import { ServerManager, ServerState } from "../src/server/ServerManager";
|
||||
import { OpenCodeSettings } from "../src/types";
|
||||
|
||||
// Test configuration
|
||||
@@ -20,16 +20,18 @@ function createTestSettings(port: number): OpenCodeSettings {
|
||||
autoStart: false,
|
||||
opencodePath: "opencode",
|
||||
projectDirectory: "",
|
||||
startupTimeout: TEST_TIMEOUT_MS,
|
||||
startupTimeout: process.platform === "win32" ? 15000 : TEST_TIMEOUT_MS,
|
||||
defaultViewLocation: "sidebar",
|
||||
injectWorkspaceContext: true,
|
||||
maxNotesInContext: 20,
|
||||
maxSelectionLength: 2000,
|
||||
customCommand: "",
|
||||
useCustomCommand: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Track current manager for cleanup
|
||||
let currentManager: ProcessManager | null = null;
|
||||
let currentManager: ServerManager | null = null;
|
||||
|
||||
// Verify opencode binary is available before running tests
|
||||
beforeAll(async () => {
|
||||
@@ -57,18 +59,17 @@ afterEach(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("ProcessManager", () => {
|
||||
describe("ServerManager", () => {
|
||||
describe("happy path", () => {
|
||||
test("starts server and transitions to running state", async () => {
|
||||
const port = getNextPort();
|
||||
const settings = createTestSettings(port);
|
||||
const stateHistory: ProcessState[] = [];
|
||||
const stateHistory: ServerState[] = [];
|
||||
|
||||
currentManager = new ProcessManager(
|
||||
settings,
|
||||
PROJECT_DIR,
|
||||
(state) => stateHistory.push(state)
|
||||
);
|
||||
currentManager = new ServerManager(settings, PROJECT_DIR);
|
||||
currentManager.on("stateChange", (state: ServerState) => {
|
||||
stateHistory.push(state);
|
||||
});
|
||||
|
||||
expect(currentManager.getState()).toBe("stopped");
|
||||
|
||||
@@ -78,21 +79,17 @@ describe("ProcessManager", () => {
|
||||
expect(currentManager.getState()).toBe("running");
|
||||
expect(stateHistory).toContain("starting");
|
||||
expect(stateHistory).toContain("running");
|
||||
});
|
||||
}, 30000); // Increased timeout for database migration on first run
|
||||
|
||||
test("reports correct server URL with encoded project directory", async () => {
|
||||
const port = getNextPort();
|
||||
const settings = createTestSettings(port);
|
||||
|
||||
currentManager = new ProcessManager(
|
||||
settings,
|
||||
PROJECT_DIR,
|
||||
() => {}
|
||||
);
|
||||
currentManager = new ServerManager(settings, PROJECT_DIR);
|
||||
|
||||
const url = currentManager.getUrl();
|
||||
const expectedBase = `http://127.0.0.1:${port}`;
|
||||
const expectedPath = btoa(PROJECT_DIR);
|
||||
const expectedPath = Buffer.from(PROJECT_DIR).toString('base64');
|
||||
|
||||
expect(url).toBe(`${expectedBase}/${expectedPath}`);
|
||||
});
|
||||
@@ -100,13 +97,12 @@ describe("ProcessManager", () => {
|
||||
test("stops server gracefully and transitions to stopped state", async () => {
|
||||
const port = getNextPort();
|
||||
const settings = createTestSettings(port);
|
||||
const stateHistory: ProcessState[] = [];
|
||||
const stateHistory: ServerState[] = [];
|
||||
|
||||
currentManager = new ProcessManager(
|
||||
settings,
|
||||
PROJECT_DIR,
|
||||
(state) => stateHistory.push(state)
|
||||
);
|
||||
currentManager = new ServerManager(settings, PROJECT_DIR);
|
||||
currentManager.on("stateChange", (state: ServerState) => {
|
||||
stateHistory.push(state);
|
||||
});
|
||||
|
||||
await currentManager.start();
|
||||
expect(currentManager.getState()).toBe("running");
|
||||
@@ -120,13 +116,12 @@ describe("ProcessManager", () => {
|
||||
test("state callbacks fire in correct order: starting -> running", async () => {
|
||||
const port = getNextPort();
|
||||
const settings = createTestSettings(port);
|
||||
const stateHistory: ProcessState[] = [];
|
||||
const stateHistory: ServerState[] = [];
|
||||
|
||||
currentManager = new ProcessManager(
|
||||
settings,
|
||||
PROJECT_DIR,
|
||||
(state) => stateHistory.push(state)
|
||||
);
|
||||
currentManager = new ServerManager(settings, PROJECT_DIR);
|
||||
currentManager.on("stateChange", (state: ServerState) => {
|
||||
stateHistory.push(state);
|
||||
});
|
||||
|
||||
await currentManager.start();
|
||||
|
||||
@@ -142,11 +137,7 @@ describe("ProcessManager", () => {
|
||||
const port = getNextPort();
|
||||
const settings = createTestSettings(port);
|
||||
|
||||
currentManager = new ProcessManager(
|
||||
settings,
|
||||
PROJECT_DIR,
|
||||
() => {}
|
||||
);
|
||||
currentManager = new ServerManager(settings, PROJECT_DIR);
|
||||
|
||||
// First start
|
||||
const firstStart = await currentManager.start();
|
||||
@@ -170,23 +161,18 @@ describe("ProcessManager", () => {
|
||||
const port = getNextPort();
|
||||
const settings = createTestSettings(port);
|
||||
|
||||
currentManager = new ProcessManager(
|
||||
settings,
|
||||
PROJECT_DIR,
|
||||
() => {}
|
||||
);
|
||||
currentManager = new ServerManager(settings, PROJECT_DIR);
|
||||
|
||||
// First start
|
||||
await currentManager.start();
|
||||
expect(currentManager.getState()).toBe("running");
|
||||
|
||||
// Second start should return true immediately without state changes
|
||||
const stateHistory: ProcessState[] = [];
|
||||
const originalOnStateChange = (currentManager as any).onStateChange;
|
||||
(currentManager as any).onStateChange = (state: ProcessState) => {
|
||||
const stateHistory: ServerState[] = [];
|
||||
const onStateChange = (state: ServerState) => {
|
||||
stateHistory.push(state);
|
||||
originalOnStateChange(state);
|
||||
};
|
||||
currentManager.on("stateChange", onStateChange);
|
||||
|
||||
const result = await currentManager.start();
|
||||
|
||||
@@ -200,11 +186,7 @@ describe("ProcessManager", () => {
|
||||
const port = getNextPort();
|
||||
const settings = createTestSettings(port);
|
||||
|
||||
currentManager = new ProcessManager(
|
||||
settings,
|
||||
PROJECT_DIR,
|
||||
() => {}
|
||||
);
|
||||
currentManager = new ServerManager(settings, PROJECT_DIR);
|
||||
|
||||
await currentManager.start();
|
||||
|
||||
@@ -224,13 +206,12 @@ describe("ProcessManager", () => {
|
||||
test("stop returns immediately when no process", async () => {
|
||||
const port = getNextPort();
|
||||
const settings = createTestSettings(port);
|
||||
const stateHistory: ProcessState[] = [];
|
||||
const stateHistory: ServerState[] = [];
|
||||
|
||||
currentManager = new ProcessManager(
|
||||
settings,
|
||||
PROJECT_DIR,
|
||||
(state) => stateHistory.push(state)
|
||||
);
|
||||
currentManager = new ServerManager(settings, PROJECT_DIR);
|
||||
currentManager.on("stateChange", (state: ServerState) => {
|
||||
stateHistory.push(state);
|
||||
});
|
||||
|
||||
// Stop without starting - should not throw and set state
|
||||
await currentManager.stop();
|
||||
@@ -242,11 +223,7 @@ describe("ProcessManager", () => {
|
||||
const port = getNextPort();
|
||||
const settings = createTestSettings(port);
|
||||
|
||||
currentManager = new ProcessManager(
|
||||
settings,
|
||||
PROJECT_DIR,
|
||||
() => {}
|
||||
);
|
||||
currentManager = new ServerManager(settings, PROJECT_DIR);
|
||||
|
||||
await currentManager.start();
|
||||
expect(currentManager.getState()).toBe("running");
|
||||
@@ -265,11 +242,7 @@ describe("ProcessManager", () => {
|
||||
const port = getNextPort();
|
||||
const settings = createTestSettings(port);
|
||||
|
||||
currentManager = new ProcessManager(
|
||||
settings,
|
||||
PROJECT_DIR,
|
||||
() => {}
|
||||
);
|
||||
currentManager = new ServerManager(settings, PROJECT_DIR);
|
||||
|
||||
await currentManager.start();
|
||||
|
||||
@@ -294,33 +267,11 @@ describe("ProcessManager", () => {
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
test("handles missing executable gracefully", async () => {
|
||||
const port = getNextPort();
|
||||
const settings = createTestSettings(port);
|
||||
settings.opencodePath = "/nonexistent/path/to/opencode";
|
||||
|
||||
currentManager = new ProcessManager(
|
||||
settings,
|
||||
PROJECT_DIR,
|
||||
() => {}
|
||||
);
|
||||
|
||||
const success = await currentManager.start();
|
||||
|
||||
expect(success).toBe(false);
|
||||
expect(currentManager.getState()).toBe("error");
|
||||
expect(currentManager.getLastError()).toContain("not found");
|
||||
});
|
||||
|
||||
test("handles double stop gracefully", async () => {
|
||||
const port = getNextPort();
|
||||
const settings = createTestSettings(port);
|
||||
|
||||
currentManager = new ProcessManager(
|
||||
settings,
|
||||
PROJECT_DIR,
|
||||
() => {}
|
||||
);
|
||||
currentManager = new ServerManager(settings, PROJECT_DIR);
|
||||
|
||||
await currentManager.start();
|
||||
expect(currentManager.getState()).toBe("running");
|
||||
@@ -334,4 +285,37 @@ describe("ProcessManager", () => {
|
||||
expect(currentManager.getState()).toBe("stopped");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Unicode path support", () => {
|
||||
test("getUrl handles Chinese characters in project directory", () => {
|
||||
const settings = createTestSettings(getNextPort());
|
||||
const chinesePath = "C:/用户/Notes";
|
||||
const manager = new ServerManager(settings, chinesePath);
|
||||
|
||||
const url = manager.getUrl();
|
||||
|
||||
expect(url).toContain("http://127.0.0.1:");
|
||||
expect(url).toContain(Buffer.from(chinesePath).toString('base64'));
|
||||
});
|
||||
|
||||
test("getUrl handles Japanese characters in project directory", () => {
|
||||
const settings = createTestSettings(getNextPort());
|
||||
const japanesePath = "/home/ユーザー/ノート";
|
||||
const manager = new ServerManager(settings, japanesePath);
|
||||
|
||||
const url = manager.getUrl();
|
||||
|
||||
expect(url).toContain(Buffer.from(japanesePath).toString('base64'));
|
||||
});
|
||||
|
||||
test("getUrl handles emoji in project directory", () => {
|
||||
const settings = createTestSettings(getNextPort());
|
||||
const emojiPath = "/home/user/📁Notes";
|
||||
const manager = new ServerManager(settings, emojiPath);
|
||||
|
||||
const url = manager.getUrl();
|
||||
|
||||
expect(url).toContain(Buffer.from(emojiPath).toString('base64'));
|
||||
});
|
||||
});
|
||||
});
|
||||
36
tests/process/PosixProcess.test.ts
Normal file
36
tests/process/PosixProcess.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { PosixProcess } from "../../src/server/process/PosixProcess";
|
||||
|
||||
describe.skipIf(process.platform === "win32")("PosixProcess", () => {
|
||||
const processImpl = new PosixProcess();
|
||||
|
||||
describe("verifyCommand", () => {
|
||||
test("returns null for non-absolute commands", async () => {
|
||||
// Non-absolute paths should return null (let spawn handle it)
|
||||
const result = await processImpl.verifyCommand("ls");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for existing absolute path", async () => {
|
||||
// Use a binary that exists on most Unix systems
|
||||
const existingBinary = "/bin/ls";
|
||||
const result = await processImpl.verifyCommand(existingBinary);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns error message for non-existent absolute path", async () => {
|
||||
const nonExistentPath = "/nonexistent/path/to/executable";
|
||||
const result = await processImpl.verifyCommand(nonExistentPath);
|
||||
expect(result).toContain("Executable not found");
|
||||
expect(result).toContain(nonExistentPath);
|
||||
});
|
||||
|
||||
test("returns error for non-executable file", async () => {
|
||||
// Test with a regular file that's not executable
|
||||
const result = await processImpl.verifyCommand("/etc/passwd");
|
||||
// When file exists but is not executable, should return helpful chmod message
|
||||
expect(result).toContain("not executable");
|
||||
expect(result).toContain("chmod +x");
|
||||
});
|
||||
});
|
||||
});
|
||||
26
tests/process/WindowsProcess.test.ts
Normal file
26
tests/process/WindowsProcess.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { WindowsProcess } from "../../src/server/process/WindowsProcess";
|
||||
|
||||
describe.skipIf(process.platform !== "win32")("WindowsProcess", () => {
|
||||
const processImpl = new WindowsProcess();
|
||||
|
||||
describe("verifyCommand", () => {
|
||||
test("returns null for existing executable in PATH", async () => {
|
||||
// 'cmd' should exist on all Windows systems
|
||||
const result = await processImpl.verifyCommand("cmd");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns error message for non-existent executable", async () => {
|
||||
const nonExistentPath = "C:\\nonexistent\\path\\to\\executable.exe";
|
||||
const result = await processImpl.verifyCommand(nonExistentPath);
|
||||
expect(result).toContain("Executable not found");
|
||||
expect(result).toContain(nonExistentPath);
|
||||
});
|
||||
|
||||
test("returns error for non-existent command in PATH", async () => {
|
||||
const result = await processImpl.verifyCommand("definitely-not-a-real-command-12345");
|
||||
expect(result).toContain("Executable not found");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user