Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .devcontainer/devcontainer.json +15 -0
- .dockerignore +42 -0
- .gitattributes +4 -0
- .github/DEFINITION_OF_DONE.md +53 -0
- .github/DOCUMENTATION_REVIEW.md +398 -0
- .github/GOVERNANCE.md +85 -0
- .github/ISSUE_TEMPLATE/bug_report.md +31 -0
- .github/ISSUE_TEMPLATE/feature_request.md +20 -0
- .github/LABELING_GUIDE.md +246 -0
- .github/PULL_REQUEST_TEMPLATE.md +30 -0
- .github/RELEASING.md +44 -0
- .github/workflows/branch-naming.yml +50 -0
- .github/workflows/dashboard.yml +107 -0
- .github/workflows/docs-verify.yml +33 -0
- .github/workflows/e2e-recent.yml +317 -0
- .github/workflows/go-verify.yml +191 -0
- .github/workflows/npm-verify.yml +157 -0
- .github/workflows/publish-skill.yml +58 -0
- .github/workflows/release.yml +238 -0
- .gitignore +50 -0
- .goreleaser.yml +61 -0
- .hfignore +32 -0
- .markdownlint.json +26 -0
- .pre-commit-config.yaml +40 -0
- Agent.md +257 -0
- CODE_OF_CONDUCT.md +46 -0
- CONTRIBUTING.md +21 -0
- DEFINITION_OF_DONE.md +5 -0
- DEVELOPMENT.md +7 -0
- Dockerfile +70 -0
- LICENSE +21 -0
- README.md +371 -9
- RELEASE.md +224 -0
- SECURITY.md +27 -0
- TESTING.md +176 -0
- THIRD_PARTY_LICENSES.md +98 -0
- assets/docs-no-background-256.png +0 -0
- assets/favicon.png +0 -0
- assets/pinchtab-headless.png +3 -0
- cmd/pinchtab/build_test.go +111 -0
- cmd/pinchtab/cmd_bridge.go +47 -0
- cmd/pinchtab/cmd_bridge_test.go +36 -0
- cmd/pinchtab/cmd_cli.go +593 -0
- cmd/pinchtab/cmd_completion.go +69 -0
- cmd/pinchtab/cmd_config.go +437 -0
- cmd/pinchtab/cmd_config_test.go +50 -0
- cmd/pinchtab/cmd_daemon.go +700 -0
- cmd/pinchtab/cmd_daemon_test.go +288 -0
- cmd/pinchtab/cmd_mcp.go +53 -0
- cmd/pinchtab/cmd_security.go +474 -0
.devcontainer/devcontainer.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "PinchTab",
|
| 3 |
+
"image": "mcr.microsoft.com/devcontainers/go:1.23",
|
| 4 |
+
"features": {
|
| 5 |
+
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
|
| 6 |
+
},
|
| 7 |
+
"postCreateCommand": "docker compose up -d && go build -o pinchtab ./cmd/pinchtab && echo 'Done! Run: ./pinchtab health'",
|
| 8 |
+
"forwardPorts": [9867],
|
| 9 |
+
"portsAttributes": {
|
| 10 |
+
"9867": {
|
| 11 |
+
"label": "PinchTab",
|
| 12 |
+
"onAutoForward": "notify"
|
| 13 |
+
}
|
| 14 |
+
}
|
| 15 |
+
}
|
.dockerignore
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Binaries
|
| 2 |
+
pinchtab
|
| 3 |
+
browser-bridge
|
| 4 |
+
|
| 5 |
+
# Test coverage
|
| 6 |
+
coverage.out
|
| 7 |
+
*.test
|
| 8 |
+
|
| 9 |
+
# Git
|
| 10 |
+
.git/
|
| 11 |
+
.github/
|
| 12 |
+
|
| 13 |
+
# Tests (E2E fixtures, scenarios, results)
|
| 14 |
+
tests/
|
| 15 |
+
|
| 16 |
+
# Local state
|
| 17 |
+
.pinchtab/
|
| 18 |
+
.openclaw/
|
| 19 |
+
|
| 20 |
+
# macOS
|
| 21 |
+
.DS_Store
|
| 22 |
+
|
| 23 |
+
# Editor
|
| 24 |
+
.vscode/
|
| 25 |
+
*.swp
|
| 26 |
+
|
| 27 |
+
# Docs (not needed in image)
|
| 28 |
+
docs/
|
| 29 |
+
|
| 30 |
+
# Development
|
| 31 |
+
node_modules/
|
| 32 |
+
dashboard/node_modules/
|
| 33 |
+
*.log
|
| 34 |
+
|
| 35 |
+
# Build caches
|
| 36 |
+
.gocache/
|
| 37 |
+
.gomodcache/
|
| 38 |
+
tmp/
|
| 39 |
+
|
| 40 |
+
# Markdown (README, RELEASE, etc.)
|
| 41 |
+
*.md
|
| 42 |
+
!dashboard/**/*.md
|
.gitattributes
CHANGED
|
@@ -33,3 +33,7 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
assets/pinchtab-headless.png filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
docs/media/chart-redesign-preview.png filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
docs/media/dashboard-instances.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
docs/media/dashboard-settings.jpeg filter=lfs diff=lfs merge=lfs -text
|
.github/DEFINITION_OF_DONE.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Definition of Done (PR Checklist)
|
| 2 |
+
|
| 3 |
+
## Automated ✅ (CI/GitHub enforces these)
|
| 4 |
+
These run automatically via `ci.yml`. If your PR fails them, fix and re-push.
|
| 5 |
+
- [ ] Go formatting & linting passes (gofmt, vet, golangci-lint)
|
| 6 |
+
- [ ] Unit + E2E tests pass (`go test ./...` and `./dev e2e`)
|
| 7 |
+
- [ ] Build succeeds (`go build`)
|
| 8 |
+
- [ ] CodeQL security scan passes
|
| 9 |
+
- [ ] Branch naming follows convention
|
| 10 |
+
|
| 11 |
+
## Manual — Code Quality (Required)
|
| 12 |
+
- [ ] **Error handling explicit** — All errors wrapped with `%w`, no silent failures
|
| 13 |
+
- [ ] **No regressions** — Verify stealth, token efficiency, session persistence work (test locally if unsure)
|
| 14 |
+
- [ ] **SOLID principles** — Functions do one thing, testable, no unnecessary deps
|
| 15 |
+
- [ ] **No redundant comments** — Comments explain *why* or *context*, not *what* the code does
|
| 16 |
+
- ❌ Bad: `// Loop through items` above `for _, item := range items`
|
| 17 |
+
- ❌ Bad: `// Return error` above `return err`
|
| 18 |
+
- ✅ Good: `// Prioritize chromium-browser on ARM64 (Raspberry Pi default)`
|
| 19 |
+
- ✅ Good: `// Chrome may not be installed in CI, so empty result is valid`
|
| 20 |
+
|
| 21 |
+
## Manual — Testing (Required)
|
| 22 |
+
- [ ] **New/changed functionality has tests** — Same-package unit tests preferred
|
| 23 |
+
- [ ] **Docker E2E tests run locally** — If you modified handlers/bridge/tabs, run: `./dev e2e` (Docker curl E2E) and/or `./dev e2e cli` (Docker CLI E2E)
|
| 24 |
+
- [ ] **npm commands work** (if npm wrapper touched):
|
| 25 |
+
- `npm pack` in `/npm/` produces valid tarball
|
| 26 |
+
- `npm install -g pinchtab` (or from local tarball) succeeds
|
| 27 |
+
- `pinchtab --version` + basic commands work after install
|
| 28 |
+
|
| 29 |
+
## Manual — Documentation (Required)
|
| 30 |
+
- [ ] **README.md updated** — If user-facing changes (CLI, API, env vars, install)
|
| 31 |
+
- [ ] **/docs/ updated** — If API/architecture/perf changed (optional for small fixes)
|
| 32 |
+
|
| 33 |
+
## Manual — Review (Required)
|
| 34 |
+
- [ ] **PR description explains what + why** — Especially stealth/perf/compatibility impact
|
| 35 |
+
- [ ] **Commits are atomic** — Logical grouping, good messages
|
| 36 |
+
- [ ] **No breaking changes to npm** — Unless explicitly major version bump
|
| 37 |
+
|
| 38 |
+
## Conditional (Only if applicable)
|
| 39 |
+
- [ ] Headed-mode tested (if dashboard/UI changes)
|
| 40 |
+
- [ ] Breaking changes documented in PR description (if any)
|
| 41 |
+
|
| 42 |
+
---
|
| 43 |
+
|
| 44 |
+
## Quick Checklist (Copy/Paste for PRs)
|
| 45 |
+
```markdown
|
| 46 |
+
## Definition of Done
|
| 47 |
+
- [ ] Unit + Docker E2E tests added & passing
|
| 48 |
+
- [ ] Error handling explicit (wrapped with %w)
|
| 49 |
+
- [ ] No regressions in stealth/perf/persistence
|
| 50 |
+
- [ ] No redundant comments (explain why, not what)
|
| 51 |
+
- [ ] README/docs updated (if user-facing)
|
| 52 |
+
- [ ] npm install works (if npm changes)
|
| 53 |
+
```
|
.github/DOCUMENTATION_REVIEW.md
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Documentation Review Guide
|
| 2 |
+
|
| 3 |
+
## Purpose
|
| 4 |
+
|
| 5 |
+
Code is the source of truth. This guide helps agents audit, validate, and maintain documentation to ensure it stays in sync with the codebase.
|
| 6 |
+
|
| 7 |
+
Use this document when asking agents to review documentation for accuracy, completeness, and consistency.
|
| 8 |
+
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
## Quick Summary
|
| 12 |
+
|
| 13 |
+
When asked to review documentation, an agent should:
|
| 14 |
+
|
| 15 |
+
1. **Verify all examples match current code behavior**
|
| 16 |
+
2. **Check doc structure against actual codebase**
|
| 17 |
+
3. **Update or remove outdated content**
|
| 18 |
+
4. **Report findings and improvements**
|
| 19 |
+
|
| 20 |
+
---
|
| 21 |
+
|
| 22 |
+
## Detailed Review Process
|
| 23 |
+
|
| 24 |
+
### Phase 1: Code-to-Docs Validation
|
| 25 |
+
|
| 26 |
+
#### 1.1 Check All Examples
|
| 27 |
+
|
| 28 |
+
For **every code example** in `/docs`:
|
| 29 |
+
|
| 30 |
+
- [ ] **Run the example** (if it's executable)
|
| 31 |
+
- Does it produce the expected output?
|
| 32 |
+
- Does it work with current API/CLI?
|
| 33 |
+
- Are environment variables correct?
|
| 34 |
+
|
| 35 |
+
- [ ] **Verify API endpoints**
|
| 36 |
+
- Do endpoints still exist? (`GET /snapshot`, `POST /action`, etc.)
|
| 37 |
+
- Do request/response formats match?
|
| 38 |
+
- Are all parameters documented?
|
| 39 |
+
- Are deprecated endpoints removed?
|
| 40 |
+
|
| 41 |
+
- [ ] **Verify CLI commands**
|
| 42 |
+
- Do commands still exist? (`pinchtab config set`, `pinchtab health`, etc.)
|
| 43 |
+
- Do they work as documented?
|
| 44 |
+
- Are flags/options correct?
|
| 45 |
+
- Are deprecated commands removed?
|
| 46 |
+
|
| 47 |
+
- [ ] **Verify configuration values**
|
| 48 |
+
- Do env vars still exist? (`CHROME_EXTENSION_PATHS`, `BRIDGE_PORT`, etc.)
|
| 49 |
+
- Are default values correct?
|
| 50 |
+
- Are deprecated config options removed?
|
| 51 |
+
|
| 52 |
+
#### 1.2 Check Content Accuracy
|
| 53 |
+
|
| 54 |
+
For **every paragraph, section, and claim** in `/docs`:
|
| 55 |
+
|
| 56 |
+
- [ ] **API behavior** — Does it accurately describe current behavior?
|
| 57 |
+
- [ ] **Performance notes** — Are benchmarks/timings still valid?
|
| 58 |
+
- [ ] **Stealth/security claims** — Do they match current implementation?
|
| 59 |
+
- [ ] **Feature availability** — Is feature still implemented?
|
| 60 |
+
- [ ] **Limitations** — Are noted limitations still present?
|
| 61 |
+
- [ ] **Requirements** — Are tool versions, Chrome versions, etc. correct?
|
| 62 |
+
|
| 63 |
+
#### 1.3 Cross-Check Against Code
|
| 64 |
+
|
| 65 |
+
Use the code as reference:
|
| 66 |
+
|
| 67 |
+
```
|
| 68 |
+
For each doc section:
|
| 69 |
+
1. Find the corresponding code file(s)
|
| 70 |
+
2. Compare doc description with actual implementation
|
| 71 |
+
3. If different → docs are WRONG
|
| 72 |
+
4. If missing → docs are INCOMPLETE
|
| 73 |
+
5. If outdated → docs are STALE
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
**Example:**
|
| 77 |
+
- Doc says: "fill action with refs sets the input value"
|
| 78 |
+
- Code says: `FillByNodeID()` exists → docs are CORRECT ✅
|
| 79 |
+
- Code says: no `FillByNodeID()` → docs are WRONG ❌
|
| 80 |
+
|
| 81 |
+
---
|
| 82 |
+
|
| 83 |
+
### Phase 2: Structure Review
|
| 84 |
+
|
| 85 |
+
#### 2.1 Check Documentation Structure
|
| 86 |
+
|
| 87 |
+
**File structure should match codebase organization:**
|
| 88 |
+
|
| 89 |
+
```
|
| 90 |
+
docs/
|
| 91 |
+
├── api/
|
| 92 |
+
│ ├── endpoints/
|
| 93 |
+
│ │ ├── navigation.md
|
| 94 |
+
│ │ ├── snapshot.md
|
| 95 |
+
│ │ ├── actions.md
|
| 96 |
+
│ │ ├── find.md
|
| 97 |
+
│ │ ├── evaluate.md
|
| 98 |
+
│ │ └── ...
|
| 99 |
+
│ └── ...
|
| 100 |
+
├── cli/
|
| 101 |
+
│ ├── management.md
|
| 102 |
+
│ ├── config.md
|
| 103 |
+
│ └── ...
|
| 104 |
+
├── architecture/
|
| 105 |
+
│ └── ...
|
| 106 |
+
└── index.json
|
| 107 |
+
```
|
| 108 |
+
|
| 109 |
+
**Verify:**
|
| 110 |
+
- [ ] All major API endpoints documented?
|
| 111 |
+
- [ ] All CLI commands documented?
|
| 112 |
+
- [ ] Organization makes sense?
|
| 113 |
+
- [ ] Groups reflect actual functionality?
|
| 114 |
+
- [ ] No duplicate content?
|
| 115 |
+
- [ ] No orphaned docs (not linked from index)?
|
| 116 |
+
|
| 117 |
+
#### 2.2 Check index.json
|
| 118 |
+
|
| 119 |
+
**Verify index.json structure:**
|
| 120 |
+
|
| 121 |
+
```json
|
| 122 |
+
{
|
| 123 |
+
"sections": [
|
| 124 |
+
{
|
| 125 |
+
"title": "API",
|
| 126 |
+
"path": "api/",
|
| 127 |
+
"items": [
|
| 128 |
+
{
|
| 129 |
+
"title": "Endpoints",
|
| 130 |
+
"path": "api/endpoints/",
|
| 131 |
+
"items": [
|
| 132 |
+
{ "title": "Navigation", "path": "api/endpoints/navigation.md" },
|
| 133 |
+
{ "title": "Snapshot", "path": "api/endpoints/snapshot.md" },
|
| 134 |
+
...
|
| 135 |
+
]
|
| 136 |
+
}
|
| 137 |
+
]
|
| 138 |
+
}
|
| 139 |
+
]
|
| 140 |
+
}
|
| 141 |
+
```
|
| 142 |
+
|
| 143 |
+
**Checks:**
|
| 144 |
+
- [ ] All sections map to actual directories?
|
| 145 |
+
- [ ] All items map to actual files?
|
| 146 |
+
- [ ] No broken paths?
|
| 147 |
+
- [ ] Nesting depth reasonable (not too deep)?
|
| 148 |
+
- [ ] Titles are accurate and clear?
|
| 149 |
+
- [ ] Order makes sense (API before examples)?
|
| 150 |
+
|
| 151 |
+
---
|
| 152 |
+
|
| 153 |
+
### Phase 3: Report Findings
|
| 154 |
+
|
| 155 |
+
#### If Changes Were Made
|
| 156 |
+
|
| 157 |
+
**Create a PR with:**
|
| 158 |
+
|
| 159 |
+
1. **Summary of fixes**
|
| 160 |
+
```markdown
|
| 161 |
+
## Documentation Fixes
|
| 162 |
+
|
| 163 |
+
- Updated /docs/api/endpoints/fill.md to document FillByNodeID support (fixes #114)
|
| 164 |
+
- Removed deprecated /docs/api/endpoints/old-nav.md (no longer exists in code)
|
| 165 |
+
- Fixed /docs/cli/config.md examples to use new config set syntax
|
| 166 |
+
- Updated Chrome requirement from 144 to 145+ for extension support
|
| 167 |
+
```
|
| 168 |
+
|
| 169 |
+
2. **List of possible improvements** (as comments in PR)
|
| 170 |
+
```markdown
|
| 171 |
+
## Suggested Improvements
|
| 172 |
+
|
| 173 |
+
- [ ] /docs/architecture/ could use a "bridge lifecycle" diagram
|
| 174 |
+
- [ ] /docs/api/endpoints/actions.md could add more examples
|
| 175 |
+
- [ ] /docs/cli/config.md could show YAML format examples
|
| 176 |
+
```
|
| 177 |
+
|
| 178 |
+
3. **PR description**
|
| 179 |
+
```markdown
|
| 180 |
+
## Documentation Review — Code Alignment
|
| 181 |
+
|
| 182 |
+
Verified all examples, API endpoints, CLI commands, and config values against current code.
|
| 183 |
+
|
| 184 |
+
### Changes Made
|
| 185 |
+
- X examples updated to match current API
|
| 186 |
+
- X endpoints documented/updated
|
| 187 |
+
- X CLI commands verified
|
| 188 |
+
- X deprecated content removed
|
| 189 |
+
|
| 190 |
+
### No Changes Needed
|
| 191 |
+
- API behavior accurate
|
| 192 |
+
- Structure organized
|
| 193 |
+
- Examples working
|
| 194 |
+
|
| 195 |
+
### Improvements Suggested
|
| 196 |
+
See comments in PR for enhancement requests.
|
| 197 |
+
```
|
| 198 |
+
|
| 199 |
+
#### If No Changes Needed but Improvements Found
|
| 200 |
+
|
| 201 |
+
**Create a GitHub issue:**
|
| 202 |
+
|
| 203 |
+
```markdown
|
| 204 |
+
Title: docs: enhancement - add examples for [feature]
|
| 205 |
+
|
| 206 |
+
Body:
|
| 207 |
+
## Suggestion
|
| 208 |
+
|
| 209 |
+
The documentation could be improved by:
|
| 210 |
+
|
| 211 |
+
1. Adding example for feature X (current docs only show basic usage)
|
| 212 |
+
2. Adding "Common Patterns" section to endpoint Y
|
| 213 |
+
3. Adding troubleshooting section to CLI guide
|
| 214 |
+
|
| 215 |
+
## Reasoning
|
| 216 |
+
|
| 217 |
+
Users asking "how do I..." suggests these are common use cases.
|
| 218 |
+
|
| 219 |
+
## References
|
| 220 |
+
|
| 221 |
+
- See /docs/api/endpoints/[endpoint].md
|
| 222 |
+
- See /docs/cli/[command].md
|
| 223 |
+
```
|
| 224 |
+
|
| 225 |
+
---
|
| 226 |
+
|
| 227 |
+
## Checklist for Agents
|
| 228 |
+
|
| 229 |
+
When given the task "Review documentation", verify:
|
| 230 |
+
|
| 231 |
+
### Code Accuracy
|
| 232 |
+
- [ ] All examples are tested and working
|
| 233 |
+
- [ ] All API endpoints documented correctly
|
| 234 |
+
- [ ] All CLI commands documented correctly
|
| 235 |
+
- [ ] All config options documented correctly
|
| 236 |
+
- [ ] No deprecated content remains
|
| 237 |
+
- [ ] All current features documented
|
| 238 |
+
|
| 239 |
+
### Structure
|
| 240 |
+
- [ ] index.json paths are correct
|
| 241 |
+
- [ ] Directory structure makes sense
|
| 242 |
+
- [ ] No orphaned docs
|
| 243 |
+
- [ ] Grouping still makes sense
|
| 244 |
+
- [ ] Navigation depth reasonable
|
| 245 |
+
|
| 246 |
+
### Output
|
| 247 |
+
- [ ] If changes: Create PR with summary + improvements list
|
| 248 |
+
- [ ] If no changes but improvements: Create GitHub issue with enhancement request
|
| 249 |
+
- [ ] Always: Note which docs were spot-checked and results
|
| 250 |
+
|
| 251 |
+
---
|
| 252 |
+
|
| 253 |
+
## Common Documentation Mistakes to Fix
|
| 254 |
+
|
| 255 |
+
❌ **Outdated examples:**
|
| 256 |
+
```bash
|
| 257 |
+
# OLD (deprecated)
|
| 258 |
+
pinchtab nav https://pinchtab.com # ← This command was removed
|
| 259 |
+
|
| 260 |
+
# NEW (correct)
|
| 261 |
+
curl -X POST http://localhost:9867/navigate \
|
| 262 |
+
-d '{"url":"https://pinchtab.com"}'
|
| 263 |
+
```
|
| 264 |
+
|
| 265 |
+
❌ **Wrong API format:**
|
| 266 |
+
```javascript
|
| 267 |
+
// OLD (incorrect)
|
| 268 |
+
response = await fetch('/action', {
|
| 269 |
+
body: JSON.stringify({action: 'click', ref: 'e5'})
|
| 270 |
+
})
|
| 271 |
+
|
| 272 |
+
// NEW (correct)
|
| 273 |
+
response = await fetch('/action', {
|
| 274 |
+
body: JSON.stringify({kind: 'click', ref: 'e5'})
|
| 275 |
+
})
|
| 276 |
+
```
|
| 277 |
+
|
| 278 |
+
❌ **Missing new features:**
|
| 279 |
+
```markdown
|
| 280 |
+
<!-- Missing in docs -->
|
| 281 |
+
<!-- Code has FillByNodeID support for fills with refs -->
|
| 282 |
+
<!-- Docs should say: "fill supports both selectors and refs" -->
|
| 283 |
+
```
|
| 284 |
+
|
| 285 |
+
❌ **Incorrect config examples:**
|
| 286 |
+
```bash
|
| 287 |
+
# OLD (deprecated env var name)
|
| 288 |
+
export BRIDGE_NAV_TIMEOUT=30
|
| 289 |
+
|
| 290 |
+
# NEW (correct)
|
| 291 |
+
export PINCHTAB_NAVIGATE_TIMEOUT=30
|
| 292 |
+
```
|
| 293 |
+
|
| 294 |
+
---
|
| 295 |
+
|
| 296 |
+
## Documentation File Locations
|
| 297 |
+
|
| 298 |
+
**Root docs location:** `/docs`
|
| 299 |
+
|
| 300 |
+
**Key files to check:**
|
| 301 |
+
|
| 302 |
+
- `/docs/index.json` — Navigation/structure
|
| 303 |
+
- `/docs/README.md` — Overview
|
| 304 |
+
- `/docs/api/endpoints/*.md` — API documentation
|
| 305 |
+
- `/docs/cli/*.md` — CLI documentation
|
| 306 |
+
- `/docs/configuration.md` — Configuration reference
|
| 307 |
+
- `/docs/architecture/*.md` — Architecture/design docs
|
| 308 |
+
- `/docs/examples/*.md` — Usage examples
|
| 309 |
+
|
| 310 |
+
---
|
| 311 |
+
|
| 312 |
+
## When to Ask for Documentation Review
|
| 313 |
+
|
| 314 |
+
**Ask for review:**
|
| 315 |
+
- After major feature additions (new endpoints, CLI commands)
|
| 316 |
+
- After refactoring (config structure changes, API changes)
|
| 317 |
+
- After bug fixes affecting documented behavior
|
| 318 |
+
- Periodically (every quarter) for general sync-up
|
| 319 |
+
- Before major releases (ensure nothing is stale)
|
| 320 |
+
|
| 321 |
+
**Example requests:**
|
| 322 |
+
|
| 323 |
+
> "Review documentation against current code. Check all API examples, CLI commands, and config values. Update docs to match code or remove outdated content. Create a PR with a summary of changes."
|
| 324 |
+
|
| 325 |
+
> "Audit /docs for accuracy. Update any incorrect examples, remove deprecated features, fix broken index.json paths. Create an issue with improvement suggestions if no changes needed."
|
| 326 |
+
|
| 327 |
+
---
|
| 328 |
+
|
| 329 |
+
## Quality Gates
|
| 330 |
+
|
| 331 |
+
A documentation review is **complete** when:
|
| 332 |
+
|
| 333 |
+
✅ All examples are tested and working
|
| 334 |
+
✅ All API endpoints match code implementation
|
| 335 |
+
✅ All CLI commands match code implementation
|
| 336 |
+
✅ All config options match code implementation
|
| 337 |
+
✅ No deprecated content remains
|
| 338 |
+
✅ index.json structure is accurate
|
| 339 |
+
✅ Documentation structure reflects codebase
|
| 340 |
+
✅ Either: PR with fixes submitted, OR Issue with improvements created
|
| 341 |
+
|
| 342 |
+
---
|
| 343 |
+
|
| 344 |
+
## Example Review Output (PR)
|
| 345 |
+
|
| 346 |
+
```markdown
|
| 347 |
+
## Documentation Review — Code Alignment
|
| 348 |
+
|
| 349 |
+
All examples, endpoints, and CLI commands verified against current codebase.
|
| 350 |
+
|
| 351 |
+
### ✅ Changes Made (3 files)
|
| 352 |
+
|
| 353 |
+
1. **docs/api/endpoints/actions.md**
|
| 354 |
+
- Updated fill example to show ref support (new in PR #119)
|
| 355 |
+
- Added FillByNodeID code path documentation
|
| 356 |
+
|
| 357 |
+
2. **docs/cli/config.md**
|
| 358 |
+
- Fixed config set examples (new syntax with PR #120)
|
| 359 |
+
- Added YAML output format example
|
| 360 |
+
|
| 361 |
+
3. **docs/api/endpoints/find.md**
|
| 362 |
+
- Added Find endpoint documentation (new in feat/allocation-strategies)
|
| 363 |
+
|
| 364 |
+
### ✅ Verified (No Changes Needed)
|
| 365 |
+
|
| 366 |
+
- All navigate endpoint examples working
|
| 367 |
+
- All snapshot filters documented correctly
|
| 368 |
+
- All action kinds (click, type, press, etc.) verified
|
| 369 |
+
- Config sections (server, chrome, orchestrator) accurate
|
| 370 |
+
- Environment variable names current
|
| 371 |
+
|
| 372 |
+
### 💡 Suggested Improvements
|
| 373 |
+
|
| 374 |
+
See comments below for enhancement requests (low priority):
|
| 375 |
+
- [ ] Add "common patterns" section to actions.md
|
| 376 |
+
- [ ] Add troubleshooting FAQ to cli/config.md
|
| 377 |
+
- [ ] Create /docs/examples/multi-step-workflow.md
|
| 378 |
+
|
| 379 |
+
### Review Stats
|
| 380 |
+
|
| 381 |
+
- Examples tested: 12/12 ✅
|
| 382 |
+
- Endpoints checked: 8/8 ✅
|
| 383 |
+
- CLI commands checked: 5/5 ✅
|
| 384 |
+
- Config sections checked: 4/4 ✅
|
| 385 |
+
- Deprecated content found: 0 ✅
|
| 386 |
+
- Structure issues found: 0 ✅
|
| 387 |
+
```
|
| 388 |
+
|
| 389 |
+
---
|
| 390 |
+
|
| 391 |
+
## Related Documents
|
| 392 |
+
|
| 393 |
+
- [DEFINITION_OF_DONE.md](./DEFINITION_OF_DONE.md) — PR checklist
|
| 394 |
+
- [LABELING_GUIDE.md](./LABELING_GUIDE.md) — Issue labeling guide
|
| 395 |
+
|
| 396 |
+
---
|
| 397 |
+
|
| 398 |
+
Last updated: 2026-03-04
|
.github/GOVERNANCE.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# GitHub Governance Documents
|
| 2 |
+
|
| 3 |
+
This directory contains pinchtab's governance and workflow documents referenced by developers and automated agents.
|
| 4 |
+
|
| 5 |
+
## Documents
|
| 6 |
+
|
| 7 |
+
### [DEFINITION_OF_DONE.md](./DEFINITION_OF_DONE.md)
|
| 8 |
+
**Purpose:** PR checklist for code quality, testing, and documentation before merging.
|
| 9 |
+
|
| 10 |
+
**For:** All contributors submitting PRs
|
| 11 |
+
**How to use:** Check this list before submitting a PR, or ask agents to verify PRs against it.
|
| 12 |
+
|
| 13 |
+
**Key sections:**
|
| 14 |
+
- Automated checks (CI enforces)
|
| 15 |
+
- Manual code quality requirements
|
| 16 |
+
- Testing requirements
|
| 17 |
+
- Documentation requirements
|
| 18 |
+
- Quick checklist for copy/paste
|
| 19 |
+
|
| 20 |
+
---
|
| 21 |
+
|
| 22 |
+
### [LABELING_GUIDE.md](./LABELING_GUIDE.md)
|
| 23 |
+
**Purpose:** Reference guide for issue and PR labeling to maintain consistent triage.
|
| 24 |
+
|
| 25 |
+
**For:** Maintainers triaging issues, agents reviewing tickets
|
| 26 |
+
**How to use:** Point agents to this guide when asking them to review and label open issues.
|
| 27 |
+
|
| 28 |
+
**Key sections:**
|
| 29 |
+
- 3-tier labeling system (Type → Status → Priority)
|
| 30 |
+
- Decision tree for triage
|
| 31 |
+
- Guidelines for agents
|
| 32 |
+
- Examples of well-labeled issues
|
| 33 |
+
- Common mistakes to avoid
|
| 34 |
+
|
| 35 |
+
---
|
| 36 |
+
|
| 37 |
+
### [DOCUMENTATION_REVIEW.md](./DOCUMENTATION_REVIEW.md)
|
| 38 |
+
**Purpose:** Guide for auditing documentation to ensure it stays in sync with code (code is source of truth).
|
| 39 |
+
|
| 40 |
+
**For:** Agents maintaining documentation, quality assurance
|
| 41 |
+
**How to use:** Point agents to this guide when asking them to audit docs for accuracy and consistency.
|
| 42 |
+
|
| 43 |
+
**Key sections:**
|
| 44 |
+
- Code-to-docs validation (examples, endpoints, commands, config)
|
| 45 |
+
- Structure review (organization, index.json, grouping)
|
| 46 |
+
- Output requirements (PR with fixes or GitHub issue with improvements)
|
| 47 |
+
- Common documentation mistakes to fix
|
| 48 |
+
- Quality gates for completion
|
| 49 |
+
|
| 50 |
+
---
|
| 51 |
+
|
| 52 |
+
## Quick Reference
|
| 53 |
+
|
| 54 |
+
| Document | Audit For | Used By |
|
| 55 |
+
|----------|-----------|---------|
|
| 56 |
+
| DEFINITION_OF_DONE.md | PR quality before merge | Developers, agents, reviewers |
|
| 57 |
+
| LABELING_GUIDE.md | Consistent issue triage | Maintainers, agents |
|
| 58 |
+
| DOCUMENTATION_REVIEW.md | Documentation accuracy vs code | Agents, QA |
|
| 59 |
+
|
| 60 |
+
---
|
| 61 |
+
|
| 62 |
+
## How Agents Use These
|
| 63 |
+
|
| 64 |
+
When asking an agent to help with PRs, issues, or documentation:
|
| 65 |
+
|
| 66 |
+
**For PR review:**
|
| 67 |
+
> "Review this PR against `.github/DEFINITION_OF_DONE.md` and ensure it meets all requirements."
|
| 68 |
+
|
| 69 |
+
**For issue triage:**
|
| 70 |
+
> "Review open issues and apply labels according to `.github/LABELING_GUIDE.md`. Ensure Type + Status + Priority are consistent."
|
| 71 |
+
|
| 72 |
+
**For documentation audit:**
|
| 73 |
+
> "Review documentation against current code using `.github/DOCUMENTATION_REVIEW.md`. Verify all examples match code, update or remove outdated content, report changes in a PR or improvements in a GitHub issue."
|
| 74 |
+
|
| 75 |
+
---
|
| 76 |
+
|
| 77 |
+
## Maintenance
|
| 78 |
+
|
| 79 |
+
- **DEFINITION_OF_DONE.md** — Update when code quality standards change
|
| 80 |
+
- **LABELING_GUIDE.md** — Update when new labels are added or workflow changes
|
| 81 |
+
- This **README.md** — Update when new governance documents are added
|
| 82 |
+
|
| 83 |
+
---
|
| 84 |
+
|
| 85 |
+
Last updated: 2026-03-04
|
.github/ISSUE_TEMPLATE/bug_report.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: Bug report
|
| 3 |
+
about: Create a report to help us improve
|
| 4 |
+
title: ''
|
| 5 |
+
labels: bug
|
| 6 |
+
assignees: ''
|
| 7 |
+
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
**Describe the bug**
|
| 11 |
+
A clear and concise description of what the bug is.
|
| 12 |
+
|
| 13 |
+
**To Reproduce**
|
| 14 |
+
Steps to reproduce the behavior:
|
| 15 |
+
1. Start Pinchtab with `...`
|
| 16 |
+
2. Send request to `/...` with body `...`
|
| 17 |
+
3. See error
|
| 18 |
+
|
| 19 |
+
**Expected behavior**
|
| 20 |
+
A clear and concise description of what you expected to happen.
|
| 21 |
+
|
| 22 |
+
**Screenshots/Logs**
|
| 23 |
+
If applicable, add screenshots or console output to help explain your problem.
|
| 24 |
+
|
| 25 |
+
**Environment (please complete the following information):**
|
| 26 |
+
- OS: [e.g. macOS, Ubuntu]
|
| 27 |
+
- Browser Version: [e.g. Chrome 120]
|
| 28 |
+
- Go Version: [e.g. 1.25.0]
|
| 29 |
+
|
| 30 |
+
**Additional context**
|
| 31 |
+
Add any other context about the problem here.
|
.github/ISSUE_TEMPLATE/feature_request.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: Feature request
|
| 3 |
+
about: Suggest an idea for this project
|
| 4 |
+
title: ''
|
| 5 |
+
labels: enhancement
|
| 6 |
+
assignees: ''
|
| 7 |
+
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
**Is your feature request related to a problem? Please describe.**
|
| 11 |
+
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
| 12 |
+
|
| 13 |
+
**Describe the solution you'd like**
|
| 14 |
+
A clear and concise description of what you want to happen.
|
| 15 |
+
|
| 16 |
+
**Describe alternatives you've considered**
|
| 17 |
+
A clear and concise description of any alternative solutions or features you've considered.
|
| 18 |
+
|
| 19 |
+
**Additional context**
|
| 20 |
+
Add any other context or screenshots about the feature request here.
|
.github/LABELING_GUIDE.md
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Labeling Guide
|
| 2 |
+
|
| 3 |
+
This document describes the labeling system used for issues and PRs in pinchtab. Use this guide when triaging tickets or asking agents to review open issues.
|
| 4 |
+
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
## Quick Reference
|
| 8 |
+
|
| 9 |
+
Every issue should have:
|
| 10 |
+
1. **Exactly one Type label** (bug, enhancement, documentation, question, etc.)
|
| 11 |
+
2. **One Status label** (ready, in-progress, blocked, fixed-unreleased, needs-investigation)
|
| 12 |
+
3. **One Priority label** (high, medium, low) — optional for documentation or questions
|
| 13 |
+
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## Tier 1: Issue Type (Mandatory)
|
| 17 |
+
|
| 18 |
+
Choose **exactly one**:
|
| 19 |
+
|
| 20 |
+
| Label | Color | Usage |
|
| 21 |
+
|-------|-------|-------|
|
| 22 |
+
| `bug` | 🔴 Red | Something isn't working correctly |
|
| 23 |
+
| `enhancement` | 🔵 Cyan | New feature or capability request |
|
| 24 |
+
| `documentation` | 🔵 Blue | Improvements/additions to README, docs, or code comments |
|
| 25 |
+
| `question` | 🟣 Purple | Request for clarification or information |
|
| 26 |
+
| `good first issue` | 🟣 Purple | Good for newcomers (in addition to type label) |
|
| 27 |
+
| `help wanted` | 🟢 Green | Needs extra attention or expertise |
|
| 28 |
+
| `dependencies` | 🔵 Blue | Dependency updates (PR label) |
|
| 29 |
+
| `invalid` | 🟡 Yellow | Doesn't seem right; requires clarification |
|
| 30 |
+
| `duplicate` | ⚪ Gray | Already exists (close and reference original) |
|
| 31 |
+
| `wontfix` | ⚪ White | Deliberate decision not to fix |
|
| 32 |
+
|
| 33 |
+
### Examples
|
| 34 |
+
|
| 35 |
+
- **Bug:** "fill action silently no-ops when using refs" → `bug`
|
| 36 |
+
- **Enhancement:** "Add CHROME_EXTENSION_PATHS support" → `enhancement`
|
| 37 |
+
- **Documentation:** "Update README with new CLI commands" → `documentation`
|
| 38 |
+
- **Question:** "How do I configure stealth mode?" → `question`
|
| 39 |
+
|
| 40 |
+
---
|
| 41 |
+
|
| 42 |
+
## Tier 2: Status (Recommended)
|
| 43 |
+
|
| 44 |
+
Choose **one** to show current state:
|
| 45 |
+
|
| 46 |
+
| Label | Color | Meaning | When to Use |
|
| 47 |
+
|-------|-------|---------|------------|
|
| 48 |
+
| `status: ready` | 🔵 Blue | Ready to start work | Issue is fully understood, no blockers, waiting for someone to pick it up |
|
| 49 |
+
| `status: in-progress` | 🟡 Yellow | Actively being worked on | Someone has claimed the issue and is working on a fix/feature |
|
| 50 |
+
| `status: blocked` | 🔴 Red | Blocked by something | Waiting on external dependency, another issue, or decision |
|
| 51 |
+
| `status: fixed-unreleased` | 🟢 Green | Fix merged, not in release | PR is merged but feature/fix isn't in a released version yet |
|
| 52 |
+
| `status: needs-investigation` | 🔴 Red | Needs debugging/research | Not enough info; requires investigation before work can start |
|
| 53 |
+
|
| 54 |
+
### Workflow
|
| 55 |
+
|
| 56 |
+
```
|
| 57 |
+
New Issue
|
| 58 |
+
↓
|
| 59 |
+
[Triage] Add Type + Status
|
| 60 |
+
↓
|
| 61 |
+
status: needs-investigation (if unclear) or status: ready (if clear)
|
| 62 |
+
↓
|
| 63 |
+
Developer picks it up
|
| 64 |
+
↓
|
| 65 |
+
status: in-progress
|
| 66 |
+
↓
|
| 67 |
+
PR submitted
|
| 68 |
+
↓
|
| 69 |
+
PR merged
|
| 70 |
+
↓
|
| 71 |
+
status: fixed-unreleased (for bugs) or remove status (for features)
|
| 72 |
+
↓
|
| 73 |
+
Release cut
|
| 74 |
+
↓
|
| 75 |
+
Remove status label (feature is released)
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
---
|
| 79 |
+
|
| 80 |
+
## Tier 3: Priority (Optional, for bugs/enhancements)
|
| 81 |
+
|
| 82 |
+
Choose **one** to indicate urgency:
|
| 83 |
+
|
| 84 |
+
| Label | Color | Level | When to Use |
|
| 85 |
+
|-------|-------|-------|------------|
|
| 86 |
+
| `priority: high` | 🔴 Red | Critical; blocks users | Security vulnerability, critical bug, high-demand feature |
|
| 87 |
+
| `priority: medium` | 🟡 Yellow | Normal; important for roadmap | Important bug or feature; should be done soon |
|
| 88 |
+
| `priority: low` | 🟢 Green | Nice to have; can defer | Minor improvement, polish, or edge case |
|
| 89 |
+
|
| 90 |
+
### Examples
|
| 91 |
+
|
| 92 |
+
- **High:** "SafePath() fails to block path traversal" (security) → `bug` + `priority: high`
|
| 93 |
+
- **Medium:** "Snapshot doesn't work inside iframes" (affects users) → `bug` + `priority: medium`
|
| 94 |
+
- **Low:** "Consider migrating to PINCHTAB_* env vars" (nice refactor) → `enhancement` + `priority: low`
|
| 95 |
+
|
| 96 |
+
---
|
| 97 |
+
|
| 98 |
+
## Special Labels
|
| 99 |
+
|
| 100 |
+
### `good first issue`
|
| 101 |
+
Add this **in addition to the type label** for issues that are good entry points for new contributors.
|
| 102 |
+
|
| 103 |
+
**Criteria:**
|
| 104 |
+
- Clear problem statement
|
| 105 |
+
- Solution is straightforward
|
| 106 |
+
- Doesn't require deep knowledge of codebase
|
| 107 |
+
- Estimated effort: <4 hours
|
| 108 |
+
|
| 109 |
+
**Example:** "documentation: Add /find endpoint example to README" → `documentation` + `good first issue`
|
| 110 |
+
|
| 111 |
+
### `help wanted`
|
| 112 |
+
Indicates the issue needs expertise or has been stalled.
|
| 113 |
+
|
| 114 |
+
**When to use:**
|
| 115 |
+
- Needs specific expertise (e.g., "needs Windows testing")
|
| 116 |
+
- Issue has been open >2 weeks without progress
|
| 117 |
+
- Complex problem that benefits from external input
|
| 118 |
+
|
| 119 |
+
---
|
| 120 |
+
|
| 121 |
+
## Decision Tree for Triage
|
| 122 |
+
|
| 123 |
+
```
|
| 124 |
+
New issue arrives
|
| 125 |
+
↓
|
| 126 |
+
1. Is it a valid issue?
|
| 127 |
+
NO → `invalid` + close
|
| 128 |
+
YES ↓
|
| 129 |
+
2. What is it?
|
| 130 |
+
→ Bug? `bug` + assess severity → `priority: high|medium|low`
|
| 131 |
+
→ New feature? `enhancement` + assess importance → `priority: high|medium|low`
|
| 132 |
+
→ Docs missing? `documentation` + no priority needed
|
| 133 |
+
→ Unclear? `question` + no priority needed
|
| 134 |
+
↓
|
| 135 |
+
3. Can we start work immediately?
|
| 136 |
+
YES → `status: ready`
|
| 137 |
+
NO ↓
|
| 138 |
+
4. Why not?
|
| 139 |
+
→ Need info → `status: needs-investigation`
|
| 140 |
+
→ Waiting on something → `status: blocked`
|
| 141 |
+
→ Already fixed → `status: fixed-unreleased`
|
| 142 |
+
```
|
| 143 |
+
|
| 144 |
+
---
|
| 145 |
+
|
| 146 |
+
## Guidelines for Agents
|
| 147 |
+
|
| 148 |
+
When reviewing open issues, follow this checklist:
|
| 149 |
+
|
| 150 |
+
- [ ] **Each issue has exactly one Type label** (bug, enhancement, documentation, question)
|
| 151 |
+
- [ ] **Bugs have Priority labels** (high, medium, low)
|
| 152 |
+
- [ ] **Enhancements have Priority labels** (high, medium, low)
|
| 153 |
+
- [ ] **Each issue has a Status label** (ready, in-progress, blocked, fixed-unreleased, needs-investigation)
|
| 154 |
+
- [ ] **No duplicate labels** (e.g., two type labels on one issue)
|
| 155 |
+
- [ ] **Status matches reality** (e.g., `status: in-progress` has an active PR)
|
| 156 |
+
- [ ] **Stale issues reviewed** (e.g., `status: blocked` for >2 weeks should note why)
|
| 157 |
+
|
| 158 |
+
---
|
| 159 |
+
|
| 160 |
+
## Current Label Inventory
|
| 161 |
+
|
| 162 |
+
**Type labels (10):**
|
| 163 |
+
- bug, enhancement, documentation, question, good first issue, help wanted, dependencies, invalid, duplicate, wontfix
|
| 164 |
+
|
| 165 |
+
**Status labels (5):**
|
| 166 |
+
- status: ready, status: in-progress, status: blocked, status: fixed-unreleased, status: needs-investigation
|
| 167 |
+
|
| 168 |
+
**Priority labels (3):**
|
| 169 |
+
- priority: high, priority: medium, priority: low
|
| 170 |
+
|
| 171 |
+
**Code labels (1):**
|
| 172 |
+
- javascript
|
| 173 |
+
|
| 174 |
+
---
|
| 175 |
+
|
| 176 |
+
## Examples of Well-Labeled Issues
|
| 177 |
+
|
| 178 |
+
### Example 1: Security Bug
|
| 179 |
+
```
|
| 180 |
+
Title: SafePath() fails to block path traversal on Windows
|
| 181 |
+
Labels: bug, priority: high, status: ready
|
| 182 |
+
```
|
| 183 |
+
**Why:** Critical security issue, ready to work on, high priority.
|
| 184 |
+
|
| 185 |
+
### Example 2: Feature Request (Ready)
|
| 186 |
+
```
|
| 187 |
+
Title: feat: Resource Pool with pluggable allocation strategies
|
| 188 |
+
Labels: enhancement, priority: medium, status: ready
|
| 189 |
+
```
|
| 190 |
+
**Why:** Enhancement, medium priority (important for roadmap), ready to start.
|
| 191 |
+
|
| 192 |
+
### Example 3: Feature (Blocked)
|
| 193 |
+
```
|
| 194 |
+
Title: feat: Semantic Element Selection via NLP (/find endpoint)
|
| 195 |
+
Labels: enhancement, priority: medium, status: blocked
|
| 196 |
+
```
|
| 197 |
+
**Why:** Enhancement, medium priority, but blocked (perhaps waiting on design decision).
|
| 198 |
+
|
| 199 |
+
### Example 4: Bug (Not Ready)
|
| 200 |
+
```
|
| 201 |
+
Title: click and humanClick fail to trigger Bootstrap dropdown
|
| 202 |
+
Labels: bug, priority: medium, status: needs-investigation
|
| 203 |
+
```
|
| 204 |
+
**Why:** Bug, medium priority, but needs investigation (reproduction steps unclear).
|
| 205 |
+
|
| 206 |
+
### Example 5: Already Fixed
|
| 207 |
+
```
|
| 208 |
+
Title: Installation issue
|
| 209 |
+
Labels: bug, priority: high, status: fixed-unreleased
|
| 210 |
+
```
|
| 211 |
+
**Why:** Critical bug, but fix is already merged and waiting for release.
|
| 212 |
+
|
| 213 |
+
---
|
| 214 |
+
|
| 215 |
+
## Common Mistakes
|
| 216 |
+
|
| 217 |
+
❌ **Don't do this:**
|
| 218 |
+
|
| 219 |
+
| Mistake | Why | Fix |
|
| 220 |
+
|---------|-----|-----|
|
| 221 |
+
| Two Type labels on one issue | Ambiguous; what is it really? | Choose one type only |
|
| 222 |
+
| Status label on closed issue | Status should reflect open work | Remove status label when closing |
|
| 223 |
+
| No Priority on bugs | Can't triage effectively | Add priority: high/medium/low to all bugs |
|
| 224 |
+
| `status: in-progress` with no PR | Misleading; is someone actually working on it? | Only use when work is actively underway |
|
| 225 |
+
| Forgetting Status labels | No visibility into progress | Always add a status label during triage |
|
| 226 |
+
|
| 227 |
+
---
|
| 228 |
+
|
| 229 |
+
## Related Documents
|
| 230 |
+
|
| 231 |
+
- [DEFINITION_OF_DONE.md](./DEFINITION_OF_DONE.md) — Checklist for PRs before merge
|
| 232 |
+
- [CONTRIBUTING.md](../CONTRIBUTING.md) — Contribution guidelines (if present)
|
| 233 |
+
|
| 234 |
+
---
|
| 235 |
+
|
| 236 |
+
## Last Updated
|
| 237 |
+
|
| 238 |
+
2026-03-04
|
| 239 |
+
|
| 240 |
+
---
|
| 241 |
+
|
| 242 |
+
## Summary
|
| 243 |
+
|
| 244 |
+
**For agents:** Use this guide to understand and apply labels when triaging issues. Ensure every issue has Type + Status + Priority (if applicable).
|
| 245 |
+
|
| 246 |
+
**For maintainers:** Review labels during triage and ensure consistency. Use the Decision Tree above as a quick reference.
|
.github/PULL_REQUEST_TEMPLATE.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## What Changed?
|
| 2 |
+
<!-- Brief description of the change -->
|
| 3 |
+
|
| 4 |
+
## Why?
|
| 5 |
+
<!-- Motivation, issue being fixed, or feature request -->
|
| 6 |
+
|
| 7 |
+
## Testing
|
| 8 |
+
<!-- How did you test this? Include command examples if applicable -->
|
| 9 |
+
|
| 10 |
+
- [ ] Unit and/or Docker E2E tests added or updated
|
| 11 |
+
- [ ] Manual testing completed (describe below)
|
| 12 |
+
|
| 13 |
+
## Checklist
|
| 14 |
+
See [DEFINITION_OF_DONE.md](./DEFINITION_OF_DONE.md) for the full checklist.
|
| 15 |
+
|
| 16 |
+
**Automated (CI enforces):**
|
| 17 |
+
- [ ] gofmt + golangci-lint passes
|
| 18 |
+
- [ ] All tests pass
|
| 19 |
+
- [ ] Build succeeds
|
| 20 |
+
|
| 21 |
+
**Manual:**
|
| 22 |
+
- [ ] Error handling explicit (wrapped with `%w`)
|
| 23 |
+
- [ ] No regressions in stealth/performance/persistence
|
| 24 |
+
- [ ] README/CHANGELOG updated (if user-facing)
|
| 25 |
+
- [ ] npm install works (if npm changes)
|
| 26 |
+
|
| 27 |
+
## Impact
|
| 28 |
+
<!-- Any performance, compatibility, or breaking changes? -->
|
| 29 |
+
|
| 30 |
+
---
|
.github/RELEASING.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Releasing PinchTab
|
| 2 |
+
|
| 3 |
+
This is the release checklist for the GitHub tag-driven release pipeline.
|
| 4 |
+
|
| 5 |
+
## One-time setup
|
| 6 |
+
|
| 7 |
+
- Create the public tap repository `pinchtab/homebrew-tap`.
|
| 8 |
+
- Add the `HOMEBREW_TAP_GITHUB_TOKEN` Actions secret to `pinchtab/pinchtab`.
|
| 9 |
+
- The token must have write access to `pinchtab/homebrew-tap`.
|
| 10 |
+
|
| 11 |
+
## What the release workflow does
|
| 12 |
+
|
| 13 |
+
Pushing a tag like `v0.7.0` triggers [release.yml](/Users/luigi/dev/prj/giago/pt-bosch/.github/workflows/release.yml), which:
|
| 14 |
+
|
| 15 |
+
1. Builds release binaries and creates the GitHub release via GoReleaser.
|
| 16 |
+
2. Publishes the npm package.
|
| 17 |
+
3. Builds and publishes container images.
|
| 18 |
+
4. Generates a Homebrew formula PR against `pinchtab/homebrew-tap`.
|
| 19 |
+
|
| 20 |
+
## Release steps
|
| 21 |
+
|
| 22 |
+
1. Verify the branch you want to release is merged to `main`.
|
| 23 |
+
2. Push the release tag:
|
| 24 |
+
|
| 25 |
+
```bash
|
| 26 |
+
git tag v0.7.0
|
| 27 |
+
git push origin v0.7.0
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
3. Watch the `Release` workflow in GitHub Actions.
|
| 31 |
+
4. Confirm GoReleaser opens a PR in `pinchtab/homebrew-tap`.
|
| 32 |
+
5. Merge that PR.
|
| 33 |
+
|
| 34 |
+
After the tap PR is merged, users can install with:
|
| 35 |
+
|
| 36 |
+
```bash
|
| 37 |
+
brew install pinchtab/tap/pinchtab
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
## Notes
|
| 41 |
+
|
| 42 |
+
- The Homebrew formula is not published until the tap PR is merged.
|
| 43 |
+
- No extra automation is required in `homebrew-tap` unless you want auto-merge.
|
| 44 |
+
- The release workflow can also be run manually with `workflow_dispatch` and a tag input.
|
.github/workflows/branch-naming.yml
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Branch Naming
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
pull_request:
|
| 5 |
+
types: [opened, synchronize, reopened]
|
| 6 |
+
|
| 7 |
+
permissions:
|
| 8 |
+
contents: read
|
| 9 |
+
|
| 10 |
+
jobs:
|
| 11 |
+
check-branch-name:
|
| 12 |
+
name: Branch Name Convention
|
| 13 |
+
runs-on: ubuntu-latest
|
| 14 |
+
if: github.event.pull_request.draft == false
|
| 15 |
+
steps:
|
| 16 |
+
- name: Check branch name
|
| 17 |
+
run: |
|
| 18 |
+
BRANCH="${GITHUB_HEAD_REF}"
|
| 19 |
+
echo "Branch: $BRANCH"
|
| 20 |
+
|
| 21 |
+
# Allow long-running branches
|
| 22 |
+
if [[ "$BRANCH" =~ ^(main|develop|staging)$ ]]; then
|
| 23 |
+
echo "✅ Long-running branch: $BRANCH"
|
| 24 |
+
exit 0
|
| 25 |
+
fi
|
| 26 |
+
|
| 27 |
+
# Allow dependabot branches
|
| 28 |
+
if [[ "$BRANCH" =~ ^dependabot/ ]]; then
|
| 29 |
+
echo "✅ Dependabot branch: $BRANCH"
|
| 30 |
+
exit 0
|
| 31 |
+
fi
|
| 32 |
+
|
| 33 |
+
# Enforce prefix/description pattern
|
| 34 |
+
PATTERN="^(feature|feat|fix|hotfix|docs|chore|refactor|test)/[a-z0-9][a-z0-9-]*$"
|
| 35 |
+
if [[ ! "$BRANCH" =~ $PATTERN ]]; then
|
| 36 |
+
echo "❌ Branch name '$BRANCH' doesn't match convention."
|
| 37 |
+
echo ""
|
| 38 |
+
echo "Expected format: <prefix>/<description>"
|
| 39 |
+
echo " Prefixes: feature, feat, fix, hotfix, docs, chore, refactor, test"
|
| 40 |
+
echo " Description: lowercase, hyphens, no special chars"
|
| 41 |
+
echo ""
|
| 42 |
+
echo "Examples:"
|
| 43 |
+
echo " feature/42-add-search-filter"
|
| 44 |
+
echo " fix/chrome-orphan-cleanup"
|
| 45 |
+
echo " hotfix/critical-api-fix"
|
| 46 |
+
echo " docs/update-docker-guide"
|
| 47 |
+
exit 1
|
| 48 |
+
fi
|
| 49 |
+
|
| 50 |
+
echo "✅ Branch name '$BRANCH' follows convention."
|
.github/workflows/dashboard.yml
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Dashboard
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [main]
|
| 6 |
+
paths:
|
| 7 |
+
- 'dashboard/**'
|
| 8 |
+
- 'internal/api/types/**'
|
| 9 |
+
- 'scripts/build-dashboard.sh'
|
| 10 |
+
- '.github/workflows/dashboard.yml'
|
| 11 |
+
pull_request:
|
| 12 |
+
paths:
|
| 13 |
+
- 'dashboard/**'
|
| 14 |
+
- 'internal/api/types/**'
|
| 15 |
+
- 'scripts/build-dashboard.sh'
|
| 16 |
+
- '.github/workflows/dashboard.yml'
|
| 17 |
+
workflow_dispatch:
|
| 18 |
+
|
| 19 |
+
env:
|
| 20 |
+
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
| 21 |
+
|
| 22 |
+
permissions:
|
| 23 |
+
contents: read
|
| 24 |
+
|
| 25 |
+
defaults:
|
| 26 |
+
run:
|
| 27 |
+
working-directory: dashboard
|
| 28 |
+
|
| 29 |
+
jobs:
|
| 30 |
+
check:
|
| 31 |
+
name: Lint, Type Check & Test
|
| 32 |
+
runs-on: ubuntu-latest
|
| 33 |
+
if: github.event.pull_request.draft == false
|
| 34 |
+
steps:
|
| 35 |
+
- uses: actions/checkout@v5
|
| 36 |
+
|
| 37 |
+
- name: Setup Go
|
| 38 |
+
uses: actions/setup-go@v6
|
| 39 |
+
with:
|
| 40 |
+
go-version: '1.26'
|
| 41 |
+
|
| 42 |
+
- name: Setup Bun
|
| 43 |
+
uses: oven-sh/setup-bun@v2
|
| 44 |
+
with:
|
| 45 |
+
bun-version: latest
|
| 46 |
+
|
| 47 |
+
- name: Install tygo
|
| 48 |
+
run: go install github.com/gzuidhof/tygo@latest
|
| 49 |
+
|
| 50 |
+
- name: Install dependencies
|
| 51 |
+
run: bun install --frozen-lockfile
|
| 52 |
+
|
| 53 |
+
- name: Generate dashboard types
|
| 54 |
+
run: |
|
| 55 |
+
$HOME/go/bin/tygo generate
|
| 56 |
+
npx prettier --write src/generated/types.ts
|
| 57 |
+
git diff --exit-code -- src/generated/types.ts
|
| 58 |
+
|
| 59 |
+
- name: TypeScript check
|
| 60 |
+
run: bun run typecheck
|
| 61 |
+
|
| 62 |
+
- name: ESLint
|
| 63 |
+
run: bun run lint
|
| 64 |
+
|
| 65 |
+
- name: Prettier check
|
| 66 |
+
run: bun run format:check
|
| 67 |
+
|
| 68 |
+
- name: Run tests
|
| 69 |
+
run: bun run test:run
|
| 70 |
+
|
| 71 |
+
build:
|
| 72 |
+
name: Build
|
| 73 |
+
runs-on: ubuntu-latest
|
| 74 |
+
needs: check
|
| 75 |
+
steps:
|
| 76 |
+
- uses: actions/checkout@v5
|
| 77 |
+
|
| 78 |
+
- name: Setup Go
|
| 79 |
+
uses: actions/setup-go@v6
|
| 80 |
+
with:
|
| 81 |
+
go-version: '1.26'
|
| 82 |
+
|
| 83 |
+
- name: Setup Bun
|
| 84 |
+
uses: oven-sh/setup-bun@v2
|
| 85 |
+
with:
|
| 86 |
+
bun-version: latest
|
| 87 |
+
|
| 88 |
+
- name: Install tygo
|
| 89 |
+
run: go install github.com/gzuidhof/tygo@latest
|
| 90 |
+
|
| 91 |
+
- name: Install dependencies
|
| 92 |
+
run: bun install --frozen-lockfile
|
| 93 |
+
|
| 94 |
+
- name: Generate dashboard types
|
| 95 |
+
run: |
|
| 96 |
+
$HOME/go/bin/tygo generate
|
| 97 |
+
npx prettier --write src/generated/types.ts
|
| 98 |
+
|
| 99 |
+
- name: Build
|
| 100 |
+
run: bun run build
|
| 101 |
+
|
| 102 |
+
- name: Upload build artifacts
|
| 103 |
+
uses: actions/upload-artifact@v4
|
| 104 |
+
with:
|
| 105 |
+
name: dashboard-dist
|
| 106 |
+
path: dashboard/dist/
|
| 107 |
+
retention-days: 7
|
.github/workflows/docs-verify.yml
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Documentation Verification
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [main]
|
| 6 |
+
paths:
|
| 7 |
+
- 'docs/**'
|
| 8 |
+
- '.github/workflows/docs-verify.yml'
|
| 9 |
+
pull_request:
|
| 10 |
+
branches: [main]
|
| 11 |
+
paths:
|
| 12 |
+
- 'docs/**'
|
| 13 |
+
- '.github/workflows/docs-verify.yml'
|
| 14 |
+
|
| 15 |
+
env:
|
| 16 |
+
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
| 17 |
+
|
| 18 |
+
permissions:
|
| 19 |
+
contents: read
|
| 20 |
+
|
| 21 |
+
jobs:
|
| 22 |
+
docs-validate:
|
| 23 |
+
name: Validate Documentation
|
| 24 |
+
runs-on: ubuntu-latest
|
| 25 |
+
if: github.event.pull_request.draft == false
|
| 26 |
+
steps:
|
| 27 |
+
- uses: actions/checkout@v5
|
| 28 |
+
|
| 29 |
+
- name: Check docs.json references
|
| 30 |
+
run: |
|
| 31 |
+
echo "🔍 Validating docs.json..."
|
| 32 |
+
./scripts/check-docs-json.sh
|
| 33 |
+
shell: bash
|
.github/workflows/e2e-recent.yml
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: E2E Recent Tests
|
| 2 |
+
|
| 3 |
+
permissions:
|
| 4 |
+
contents: read
|
| 5 |
+
|
| 6 |
+
on:
|
| 7 |
+
push:
|
| 8 |
+
branches: [main]
|
| 9 |
+
paths-ignore:
|
| 10 |
+
- '**.md'
|
| 11 |
+
- 'docs/**'
|
| 12 |
+
- 'LICENSE'
|
| 13 |
+
- '.gitignore'
|
| 14 |
+
- 'skill/**'
|
| 15 |
+
- 'plugin/**'
|
| 16 |
+
pull_request:
|
| 17 |
+
branches: [main]
|
| 18 |
+
paths-ignore:
|
| 19 |
+
- '**.md'
|
| 20 |
+
- 'docs/**'
|
| 21 |
+
- 'LICENSE'
|
| 22 |
+
- '.gitignore'
|
| 23 |
+
- 'skill/**'
|
| 24 |
+
- 'plugin/**'
|
| 25 |
+
workflow_dispatch:
|
| 26 |
+
|
| 27 |
+
env:
|
| 28 |
+
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
| 29 |
+
|
| 30 |
+
jobs:
|
| 31 |
+
recent:
|
| 32 |
+
name: E2E Recent Tests
|
| 33 |
+
runs-on: ubuntu-latest
|
| 34 |
+
if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
|
| 35 |
+
timeout-minutes: 10
|
| 36 |
+
|
| 37 |
+
steps:
|
| 38 |
+
- name: Checkout
|
| 39 |
+
uses: actions/checkout@v5
|
| 40 |
+
|
| 41 |
+
- name: Set up Go
|
| 42 |
+
uses: actions/setup-go@v6
|
| 43 |
+
with:
|
| 44 |
+
go-version-file: go.mod
|
| 45 |
+
cache: true
|
| 46 |
+
|
| 47 |
+
- name: Set up Docker Buildx
|
| 48 |
+
uses: docker/setup-buildx-action@v3
|
| 49 |
+
|
| 50 |
+
- name: Run recent E2E tests
|
| 51 |
+
id: e2e
|
| 52 |
+
run: |
|
| 53 |
+
set +e
|
| 54 |
+
mkdir -p tests/e2e/results
|
| 55 |
+
|
| 56 |
+
./dev e2e recent 2>&1 | tee e2e-output.log
|
| 57 |
+
EXIT_CODE=${PIPESTATUS[0]}
|
| 58 |
+
|
| 59 |
+
PASSED=$(grep -oP 'Passed:\s*\K\d+' e2e-output.log | tail -1 || true)
|
| 60 |
+
FAILED=$(grep -oP 'Failed:\s*\K\d+' e2e-output.log | tail -1 || true)
|
| 61 |
+
FAILURES=$(grep -E '✗.*failed' e2e-output.log | sed 's/.*✗ /- /' | head -10 || true)
|
| 62 |
+
PASSED=${PASSED:-0}
|
| 63 |
+
FAILED=${FAILED:-0}
|
| 64 |
+
|
| 65 |
+
echo "passed=$PASSED" >> $GITHUB_OUTPUT
|
| 66 |
+
echo "failed=$FAILED" >> $GITHUB_OUTPUT
|
| 67 |
+
|
| 68 |
+
{
|
| 69 |
+
echo 'failures<<EOF'
|
| 70 |
+
echo "$FAILURES"
|
| 71 |
+
echo 'EOF'
|
| 72 |
+
} >> $GITHUB_OUTPUT
|
| 73 |
+
|
| 74 |
+
exit $EXIT_CODE
|
| 75 |
+
env:
|
| 76 |
+
DOCKER_BUILDKIT: 1
|
| 77 |
+
COMPOSE_DOCKER_CLI_BUILD: 1
|
| 78 |
+
|
| 79 |
+
- name: Dump pinchtab logs on failure
|
| 80 |
+
if: failure()
|
| 81 |
+
run: |
|
| 82 |
+
echo "==== pinchtab logs ===="
|
| 83 |
+
docker compose -f tests/e2e/docker-compose.yml logs pinchtab || true
|
| 84 |
+
echo ""
|
| 85 |
+
echo "==== filtered Chrome/extension lines ===="
|
| 86 |
+
docker compose -f tests/e2e/docker-compose.yml logs pinchtab 2>/dev/null | grep -Ei 'chrome|extension|devtools|load-extension|disable-extensions|headless|warning|error' || true
|
| 87 |
+
echo ""
|
| 88 |
+
echo "==== instance inventory ===="
|
| 89 |
+
INSTANCES_JSON=$(curl -sf http://localhost:9999/instances || true)
|
| 90 |
+
if [ -n "$INSTANCES_JSON" ]; then
|
| 91 |
+
echo "$INSTANCES_JSON"
|
| 92 |
+
if command -v jq >/dev/null 2>&1; then
|
| 93 |
+
echo "$INSTANCES_JSON" | jq -r '.[].id' | while read -r inst_id; do
|
| 94 |
+
[ -z "$inst_id" ] && continue
|
| 95 |
+
echo ""
|
| 96 |
+
echo "==== logs for $inst_id ===="
|
| 97 |
+
curl -sf "http://localhost:9999/instances/$inst_id/logs" || true
|
| 98 |
+
done
|
| 99 |
+
fi
|
| 100 |
+
else
|
| 101 |
+
echo "unable to fetch /instances from localhost:9999"
|
| 102 |
+
fi
|
| 103 |
+
|
| 104 |
+
- name: Post summary
|
| 105 |
+
if: always()
|
| 106 |
+
run: |
|
| 107 |
+
PASSED="${{ steps.e2e.outputs.passed }}"
|
| 108 |
+
FAILED="${{ steps.e2e.outputs.failed }}"
|
| 109 |
+
PASSED="${PASSED:-0}"
|
| 110 |
+
FAILED="${FAILED:-0}"
|
| 111 |
+
|
| 112 |
+
if [ "${{ steps.e2e.outcome }}" = "success" ]; then
|
| 113 |
+
echo "## ✅ E2E Recent Tests Passed" >> $GITHUB_STEP_SUMMARY
|
| 114 |
+
if [ "$PASSED" = "0" ]; then
|
| 115 |
+
echo "All tests passed" >> $GITHUB_STEP_SUMMARY
|
| 116 |
+
else
|
| 117 |
+
echo "**$PASSED** tests passed" >> $GITHUB_STEP_SUMMARY
|
| 118 |
+
fi
|
| 119 |
+
else
|
| 120 |
+
echo "## ❌ E2E Recent Tests Failed" >> $GITHUB_STEP_SUMMARY
|
| 121 |
+
echo "**Passed:** $PASSED" >> $GITHUB_STEP_SUMMARY
|
| 122 |
+
echo "**Failed:** $FAILED" >> $GITHUB_STEP_SUMMARY
|
| 123 |
+
echo '```' >> $GITHUB_STEP_SUMMARY
|
| 124 |
+
echo "${{ steps.e2e.outputs.failures }}" >> $GITHUB_STEP_SUMMARY
|
| 125 |
+
echo '```' >> $GITHUB_STEP_SUMMARY
|
| 126 |
+
fi
|
| 127 |
+
|
| 128 |
+
- name: Report check annotation
|
| 129 |
+
if: always()
|
| 130 |
+
run: |
|
| 131 |
+
PASSED="${{ steps.e2e.outputs.passed }}"
|
| 132 |
+
FAILED="${{ steps.e2e.outputs.failed }}"
|
| 133 |
+
PASSED="${PASSED:-0}"
|
| 134 |
+
FAILED="${FAILED:-0}"
|
| 135 |
+
|
| 136 |
+
if [ "${{ steps.e2e.outcome }}" = "success" ]; then
|
| 137 |
+
if [ "$PASSED" = "0" ]; then
|
| 138 |
+
echo "::notice title=E2E Recent Tests::✅ All tests passed"
|
| 139 |
+
else
|
| 140 |
+
echo "::notice title=E2E Recent Tests::✅ ${PASSED} tests passed"
|
| 141 |
+
fi
|
| 142 |
+
else
|
| 143 |
+
echo "::error title=E2E Recent Tests::❌ ${FAILED} failed, ${PASSED} passed"
|
| 144 |
+
fi
|
| 145 |
+
|
| 146 |
+
e2e-curl:
|
| 147 |
+
name: E2E Curl Tests
|
| 148 |
+
needs: recent
|
| 149 |
+
runs-on: ubuntu-latest
|
| 150 |
+
timeout-minutes: 15
|
| 151 |
+
|
| 152 |
+
steps:
|
| 153 |
+
- name: Checkout
|
| 154 |
+
uses: actions/checkout@v5
|
| 155 |
+
|
| 156 |
+
- name: Set up Go
|
| 157 |
+
uses: actions/setup-go@v6
|
| 158 |
+
with:
|
| 159 |
+
go-version-file: go.mod
|
| 160 |
+
cache: true
|
| 161 |
+
|
| 162 |
+
- name: Set up Docker Buildx
|
| 163 |
+
uses: docker/setup-buildx-action@v3
|
| 164 |
+
|
| 165 |
+
- name: Run E2E curl tests
|
| 166 |
+
id: e2e
|
| 167 |
+
run: |
|
| 168 |
+
set +e
|
| 169 |
+
mkdir -p tests/e2e/results
|
| 170 |
+
./dev e2e curl 2>&1 | tee e2e-output.log
|
| 171 |
+
EXIT_CODE=${PIPESTATUS[0]}
|
| 172 |
+
|
| 173 |
+
PASSED=$(grep -oP 'Passed:\s*\K\d+' e2e-output.log | tail -1 || true)
|
| 174 |
+
FAILED=$(grep -oP 'Failed:\s*\K\d+' e2e-output.log | tail -1 || true)
|
| 175 |
+
FAILURES=$(grep -E '✗.*failed' e2e-output.log | sed 's/.*✗ /- /' | head -10 || true)
|
| 176 |
+
PASSED=${PASSED:-0}
|
| 177 |
+
FAILED=${FAILED:-0}
|
| 178 |
+
|
| 179 |
+
echo "passed=$PASSED" >> $GITHUB_OUTPUT
|
| 180 |
+
echo "failed=$FAILED" >> $GITHUB_OUTPUT
|
| 181 |
+
{
|
| 182 |
+
echo 'failures<<EOF'
|
| 183 |
+
echo "$FAILURES"
|
| 184 |
+
echo 'EOF'
|
| 185 |
+
} >> $GITHUB_OUTPUT
|
| 186 |
+
|
| 187 |
+
exit $EXIT_CODE
|
| 188 |
+
env:
|
| 189 |
+
DOCKER_BUILDKIT: 1
|
| 190 |
+
COMPOSE_DOCKER_CLI_BUILD: 1
|
| 191 |
+
|
| 192 |
+
- name: Post summary
|
| 193 |
+
if: always()
|
| 194 |
+
run: |
|
| 195 |
+
PASSED="${{ steps.e2e.outputs.passed }}"
|
| 196 |
+
FAILED="${{ steps.e2e.outputs.failed }}"
|
| 197 |
+
PASSED="${PASSED:-0}"
|
| 198 |
+
FAILED="${FAILED:-0}"
|
| 199 |
+
|
| 200 |
+
if [ "${{ steps.e2e.outcome }}" = "success" ]; then
|
| 201 |
+
echo "## ✅ E2E Curl Tests Passed" >> $GITHUB_STEP_SUMMARY
|
| 202 |
+
if [ "$PASSED" = "0" ]; then
|
| 203 |
+
echo "All tests passed" >> $GITHUB_STEP_SUMMARY
|
| 204 |
+
else
|
| 205 |
+
echo "**$PASSED** tests passed" >> $GITHUB_STEP_SUMMARY
|
| 206 |
+
fi
|
| 207 |
+
else
|
| 208 |
+
echo "## ❌ E2E Curl Tests Failed" >> $GITHUB_STEP_SUMMARY
|
| 209 |
+
echo "**Passed:** $PASSED" >> $GITHUB_STEP_SUMMARY
|
| 210 |
+
echo "**Failed:** $FAILED" >> $GITHUB_STEP_SUMMARY
|
| 211 |
+
echo '```' >> $GITHUB_STEP_SUMMARY
|
| 212 |
+
echo "${{ steps.e2e.outputs.failures }}" >> $GITHUB_STEP_SUMMARY
|
| 213 |
+
echo '```' >> $GITHUB_STEP_SUMMARY
|
| 214 |
+
fi
|
| 215 |
+
|
| 216 |
+
- name: Report check annotation
|
| 217 |
+
if: always()
|
| 218 |
+
run: |
|
| 219 |
+
PASSED="${{ steps.e2e.outputs.passed }}"
|
| 220 |
+
FAILED="${{ steps.e2e.outputs.failed }}"
|
| 221 |
+
PASSED="${PASSED:-0}"
|
| 222 |
+
FAILED="${FAILED:-0}"
|
| 223 |
+
if [ "${{ steps.e2e.outcome }}" = "success" ]; then
|
| 224 |
+
if [ "$PASSED" = "0" ]; then
|
| 225 |
+
echo "::notice title=E2E Curl Tests::✅ All tests passed"
|
| 226 |
+
else
|
| 227 |
+
echo "::notice title=E2E Curl Tests::✅ ${PASSED} tests passed"
|
| 228 |
+
fi
|
| 229 |
+
else
|
| 230 |
+
echo "::error title=E2E Curl Tests::❌ ${FAILED} failed, ${PASSED} passed"
|
| 231 |
+
fi
|
| 232 |
+
|
| 233 |
+
e2e-cli:
|
| 234 |
+
name: E2E CLI Tests
|
| 235 |
+
needs: recent
|
| 236 |
+
runs-on: ubuntu-latest
|
| 237 |
+
timeout-minutes: 15
|
| 238 |
+
|
| 239 |
+
steps:
|
| 240 |
+
- name: Checkout
|
| 241 |
+
uses: actions/checkout@v5
|
| 242 |
+
|
| 243 |
+
- name: Set up Go
|
| 244 |
+
uses: actions/setup-go@v6
|
| 245 |
+
with:
|
| 246 |
+
go-version-file: go.mod
|
| 247 |
+
cache: true
|
| 248 |
+
|
| 249 |
+
- name: Set up Docker Buildx
|
| 250 |
+
uses: docker/setup-buildx-action@v3
|
| 251 |
+
|
| 252 |
+
- name: Run CLI E2E tests
|
| 253 |
+
id: e2e
|
| 254 |
+
run: |
|
| 255 |
+
set +e
|
| 256 |
+
./dev e2e cli 2>&1 | tee e2e-output.log
|
| 257 |
+
EXIT_CODE=${PIPESTATUS[0]}
|
| 258 |
+
|
| 259 |
+
PASSED=$(grep -oP 'Passed:\s*\K\d+' e2e-output.log | tail -1 || true)
|
| 260 |
+
FAILED=$(grep -oP 'Failed:\s*\K\d+' e2e-output.log | tail -1 || true)
|
| 261 |
+
FAILURES=$(grep -E '✗.*failed' e2e-output.log | sed 's/.*✗ /- /' | head -10 || true)
|
| 262 |
+
PASSED=${PASSED:-0}
|
| 263 |
+
FAILED=${FAILED:-0}
|
| 264 |
+
|
| 265 |
+
echo "passed=$PASSED" >> $GITHUB_OUTPUT
|
| 266 |
+
echo "failed=$FAILED" >> $GITHUB_OUTPUT
|
| 267 |
+
{
|
| 268 |
+
echo 'failures<<EOF'
|
| 269 |
+
echo "$FAILURES"
|
| 270 |
+
echo 'EOF'
|
| 271 |
+
} >> $GITHUB_OUTPUT
|
| 272 |
+
|
| 273 |
+
exit $EXIT_CODE
|
| 274 |
+
env:
|
| 275 |
+
DOCKER_BUILDKIT: 1
|
| 276 |
+
COMPOSE_DOCKER_CLI_BUILD: 1
|
| 277 |
+
|
| 278 |
+
- name: Post summary
|
| 279 |
+
if: always()
|
| 280 |
+
run: |
|
| 281 |
+
PASSED="${{ steps.e2e.outputs.passed }}"
|
| 282 |
+
FAILED="${{ steps.e2e.outputs.failed }}"
|
| 283 |
+
PASSED="${PASSED:-0}"
|
| 284 |
+
FAILED="${FAILED:-0}"
|
| 285 |
+
|
| 286 |
+
if [ "${{ steps.e2e.outcome }}" = "success" ]; then
|
| 287 |
+
echo "## ✅ E2E CLI Tests Passed" >> $GITHUB_STEP_SUMMARY
|
| 288 |
+
if [ "$PASSED" = "0" ]; then
|
| 289 |
+
echo "All tests passed" >> $GITHUB_STEP_SUMMARY
|
| 290 |
+
else
|
| 291 |
+
echo "**$PASSED** tests passed" >> $GITHUB_STEP_SUMMARY
|
| 292 |
+
fi
|
| 293 |
+
else
|
| 294 |
+
echo "## ❌ E2E CLI Tests Failed" >> $GITHUB_STEP_SUMMARY
|
| 295 |
+
echo "**Passed:** $PASSED" >> $GITHUB_STEP_SUMMARY
|
| 296 |
+
echo "**Failed:** $FAILED" >> $GITHUB_STEP_SUMMARY
|
| 297 |
+
echo '```' >> $GITHUB_STEP_SUMMARY
|
| 298 |
+
echo "${{ steps.e2e.outputs.failures }}" >> $GITHUB_STEP_SUMMARY
|
| 299 |
+
echo '```' >> $GITHUB_STEP_SUMMARY
|
| 300 |
+
fi
|
| 301 |
+
|
| 302 |
+
- name: Report check annotation
|
| 303 |
+
if: always()
|
| 304 |
+
run: |
|
| 305 |
+
PASSED="${{ steps.e2e.outputs.passed }}"
|
| 306 |
+
FAILED="${{ steps.e2e.outputs.failed }}"
|
| 307 |
+
PASSED="${PASSED:-0}"
|
| 308 |
+
FAILED="${FAILED:-0}"
|
| 309 |
+
if [ "${{ steps.e2e.outcome }}" = "success" ]; then
|
| 310 |
+
if [ "$PASSED" = "0" ]; then
|
| 311 |
+
echo "::notice title=E2E CLI Tests::✅ All tests passed"
|
| 312 |
+
else
|
| 313 |
+
echo "::notice title=E2E CLI Tests::✅ ${PASSED} tests passed"
|
| 314 |
+
fi
|
| 315 |
+
else
|
| 316 |
+
echo "::error title=E2E CLI Tests::❌ ${FAILED} failed, ${PASSED} passed"
|
| 317 |
+
fi
|
.github/workflows/go-verify.yml
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Go Verification
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [main]
|
| 6 |
+
paths-ignore:
|
| 7 |
+
- '**.md'
|
| 8 |
+
- 'docs/**'
|
| 9 |
+
- 'LICENSE'
|
| 10 |
+
- '.gitignore'
|
| 11 |
+
- 'skill/**'
|
| 12 |
+
- 'plugin/**'
|
| 13 |
+
pull_request:
|
| 14 |
+
branches: [main]
|
| 15 |
+
paths-ignore:
|
| 16 |
+
- '**.md'
|
| 17 |
+
- 'docs/**'
|
| 18 |
+
- 'LICENSE'
|
| 19 |
+
- '.gitignore'
|
| 20 |
+
- 'skill/**'
|
| 21 |
+
- 'plugin/**'
|
| 22 |
+
|
| 23 |
+
env:
|
| 24 |
+
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
| 25 |
+
|
| 26 |
+
permissions:
|
| 27 |
+
contents: read
|
| 28 |
+
|
| 29 |
+
jobs:
|
| 30 |
+
# Check which files changed to conditionally run jobs
|
| 31 |
+
changes:
|
| 32 |
+
name: Detect Changes
|
| 33 |
+
runs-on: ubuntu-latest
|
| 34 |
+
if: github.event.pull_request.draft == false
|
| 35 |
+
outputs:
|
| 36 |
+
go: ${{ steps.filter.outputs.go }}
|
| 37 |
+
steps:
|
| 38 |
+
- uses: actions/checkout@v5
|
| 39 |
+
- uses: dorny/paths-filter@v3
|
| 40 |
+
id: filter
|
| 41 |
+
with:
|
| 42 |
+
filters: |
|
| 43 |
+
go:
|
| 44 |
+
- '**.go'
|
| 45 |
+
- 'go.mod'
|
| 46 |
+
- 'go.sum'
|
| 47 |
+
- 'cmd/**'
|
| 48 |
+
- 'internal/**'
|
| 49 |
+
|
| 50 |
+
build:
|
| 51 |
+
name: Build & Vet
|
| 52 |
+
needs: changes
|
| 53 |
+
runs-on: ubuntu-latest
|
| 54 |
+
steps:
|
| 55 |
+
- name: Check for Go changes
|
| 56 |
+
id: check
|
| 57 |
+
run: |
|
| 58 |
+
if [ "${{ needs.changes.outputs.go }}" = "true" ]; then
|
| 59 |
+
echo "Go files detected"
|
| 60 |
+
echo "has_go_changes=true" >> $GITHUB_OUTPUT
|
| 61 |
+
else
|
| 62 |
+
echo "⏭️ No Go files changed - docs/workflow-only changes detected"
|
| 63 |
+
echo "has_go_changes=false" >> $GITHUB_OUTPUT
|
| 64 |
+
fi
|
| 65 |
+
|
| 66 |
+
- uses: actions/checkout@v5
|
| 67 |
+
|
| 68 |
+
- uses: actions/setup-go@v6
|
| 69 |
+
if: steps.check.outputs.has_go_changes == 'true'
|
| 70 |
+
with:
|
| 71 |
+
go-version: '1.26'
|
| 72 |
+
|
| 73 |
+
- name: Download deps
|
| 74 |
+
if: steps.check.outputs.has_go_changes == 'true'
|
| 75 |
+
run: go mod download
|
| 76 |
+
|
| 77 |
+
- name: Format check
|
| 78 |
+
if: steps.check.outputs.has_go_changes == 'true'
|
| 79 |
+
run: |
|
| 80 |
+
unformatted=$(gofmt -l .)
|
| 81 |
+
if [ -n "$unformatted" ]; then
|
| 82 |
+
echo "Files not formatted:"
|
| 83 |
+
echo "$unformatted"
|
| 84 |
+
exit 1
|
| 85 |
+
fi
|
| 86 |
+
|
| 87 |
+
- name: Vet
|
| 88 |
+
if: steps.check.outputs.has_go_changes == 'true'
|
| 89 |
+
run: go vet ./...
|
| 90 |
+
|
| 91 |
+
- name: Build
|
| 92 |
+
if: steps.check.outputs.has_go_changes == 'true'
|
| 93 |
+
run: go build -o pinchtab ./cmd/pinchtab
|
| 94 |
+
|
| 95 |
+
- name: Test with coverage
|
| 96 |
+
if: steps.check.outputs.has_go_changes == 'true'
|
| 97 |
+
run: go test ./... -v -count=1 -coverprofile=coverage.out -covermode=atomic
|
| 98 |
+
|
| 99 |
+
- name: Coverage summary
|
| 100 |
+
if: steps.check.outputs.has_go_changes == 'true'
|
| 101 |
+
run: |
|
| 102 |
+
coverage=$(go tool cover -func=coverage.out | tail -1)
|
| 103 |
+
echo "### Coverage" >> $GITHUB_STEP_SUMMARY
|
| 104 |
+
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
| 105 |
+
echo "$coverage" >> $GITHUB_STEP_SUMMARY
|
| 106 |
+
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
| 107 |
+
|
| 108 |
+
lint:
|
| 109 |
+
name: Lint
|
| 110 |
+
needs: changes
|
| 111 |
+
runs-on: ubuntu-latest
|
| 112 |
+
steps:
|
| 113 |
+
- name: Check for Go changes
|
| 114 |
+
id: check
|
| 115 |
+
run: |
|
| 116 |
+
if [ "${{ needs.changes.outputs.go }}" = "true" ]; then
|
| 117 |
+
echo "has_go_changes=true" >> $GITHUB_OUTPUT
|
| 118 |
+
else
|
| 119 |
+
echo "⏭️ No Go files changed, skipping lint"
|
| 120 |
+
echo "has_go_changes=false" >> $GITHUB_OUTPUT
|
| 121 |
+
fi
|
| 122 |
+
|
| 123 |
+
- uses: actions/checkout@v5
|
| 124 |
+
|
| 125 |
+
- uses: actions/setup-go@v6
|
| 126 |
+
if: steps.check.outputs.has_go_changes == 'true'
|
| 127 |
+
with:
|
| 128 |
+
go-version: '1.26'
|
| 129 |
+
|
| 130 |
+
- uses: oven-sh/setup-bun@v2
|
| 131 |
+
if: needs.changes.outputs.go == 'true'
|
| 132 |
+
with:
|
| 133 |
+
bun-version: latest
|
| 134 |
+
|
| 135 |
+
- name: Install tygo
|
| 136 |
+
if: needs.changes.outputs.go == 'true'
|
| 137 |
+
run: go install github.com/gzuidhof/tygo@latest
|
| 138 |
+
|
| 139 |
+
- name: Build dashboard
|
| 140 |
+
if: needs.changes.outputs.go == 'true'
|
| 141 |
+
run: |
|
| 142 |
+
cd dashboard && bun install --frozen-lockfile && cd ..
|
| 143 |
+
./scripts/build-dashboard.sh
|
| 144 |
+
|
| 145 |
+
- name: golangci-lint
|
| 146 |
+
if: steps.check.outputs.has_go_changes == 'true'
|
| 147 |
+
uses: golangci/golangci-lint-action@v7
|
| 148 |
+
with:
|
| 149 |
+
version: v2.9.0
|
| 150 |
+
|
| 151 |
+
security:
|
| 152 |
+
name: Security Scan
|
| 153 |
+
needs: changes
|
| 154 |
+
runs-on: ubuntu-latest
|
| 155 |
+
steps:
|
| 156 |
+
- name: Check for Go changes
|
| 157 |
+
id: check
|
| 158 |
+
run: |
|
| 159 |
+
if [ "${{ needs.changes.outputs.go }}" = "true" ]; then
|
| 160 |
+
echo "has_go_changes=true" >> $GITHUB_OUTPUT
|
| 161 |
+
else
|
| 162 |
+
echo "⏭️ No Go files changed, skipping security scan"
|
| 163 |
+
echo "has_go_changes=false" >> $GITHUB_OUTPUT
|
| 164 |
+
fi
|
| 165 |
+
|
| 166 |
+
- uses: actions/checkout@v5
|
| 167 |
+
|
| 168 |
+
- uses: actions/setup-go@v6
|
| 169 |
+
if: steps.check.outputs.has_go_changes == 'true'
|
| 170 |
+
with:
|
| 171 |
+
go-version: '1.26'
|
| 172 |
+
|
| 173 |
+
- name: Install gosec
|
| 174 |
+
if: steps.check.outputs.has_go_changes == 'true'
|
| 175 |
+
run: go install github.com/securego/gosec/v2/cmd/gosec@latest
|
| 176 |
+
|
| 177 |
+
- name: Run gosec
|
| 178 |
+
if: steps.check.outputs.has_go_changes == 'true'
|
| 179 |
+
run: gosec -exclude=G301,G302,G304,G306,G404,G107,G115,G703,G704,G705,G706 -fmt=json -out=gosec-results.json ./... || true
|
| 180 |
+
|
| 181 |
+
- name: Check critical findings
|
| 182 |
+
if: steps.check.outputs.has_go_changes == 'true'
|
| 183 |
+
run: |
|
| 184 |
+
# Fail on G112 (Slowloris), G204 (command injection)
|
| 185 |
+
ISSUES=$(cat gosec-results.json | jq '[.Issues[] | select(.rule_id == "G112" or .rule_id == "G204")] | length')
|
| 186 |
+
echo "Critical issues: $ISSUES"
|
| 187 |
+
cat gosec-results.json | jq '.Stats'
|
| 188 |
+
if [ "$ISSUES" -gt 0 ]; then
|
| 189 |
+
cat gosec-results.json | jq '.Issues[] | select(.rule_id == "G112" or .rule_id == "G204")'
|
| 190 |
+
exit 1
|
| 191 |
+
fi
|
.github/workflows/npm-verify.yml
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: npm Package Verification
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
pull_request:
|
| 5 |
+
paths:
|
| 6 |
+
- 'npm/**'
|
| 7 |
+
- '.github/workflows/npm-verify.yml'
|
| 8 |
+
push:
|
| 9 |
+
tags:
|
| 10 |
+
- 'v*'
|
| 11 |
+
|
| 12 |
+
env:
|
| 13 |
+
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
| 14 |
+
|
| 15 |
+
permissions:
|
| 16 |
+
contents: read
|
| 17 |
+
|
| 18 |
+
jobs:
|
| 19 |
+
verify:
|
| 20 |
+
name: Verify npm Package
|
| 21 |
+
runs-on: ubuntu-latest
|
| 22 |
+
steps:
|
| 23 |
+
- uses: actions/checkout@v5
|
| 24 |
+
|
| 25 |
+
- uses: actions/setup-node@v5
|
| 26 |
+
with:
|
| 27 |
+
node-version: '22'
|
| 28 |
+
|
| 29 |
+
- name: Install dependencies
|
| 30 |
+
working-directory: npm
|
| 31 |
+
run: npm ci
|
| 32 |
+
|
| 33 |
+
- name: Lint (ESLint)
|
| 34 |
+
working-directory: npm
|
| 35 |
+
run: npm run lint
|
| 36 |
+
|
| 37 |
+
- name: Format Check (Prettier)
|
| 38 |
+
working-directory: npm
|
| 39 |
+
run: npm run format:check
|
| 40 |
+
|
| 41 |
+
- name: Build TypeScript
|
| 42 |
+
working-directory: npm
|
| 43 |
+
run: tsc
|
| 44 |
+
|
| 45 |
+
- name: Run tests
|
| 46 |
+
working-directory: npm
|
| 47 |
+
run: npm test
|
| 48 |
+
|
| 49 |
+
- name: Check for source .ts files in package
|
| 50 |
+
working-directory: npm
|
| 51 |
+
run: |
|
| 52 |
+
# Create the tarball and check its actual contents
|
| 53 |
+
npm pack > /dev/null 2>&1
|
| 54 |
+
TARBALL=$(ls -t pinchtab-*.tgz | head -1)
|
| 55 |
+
SOURCES=$(tar -tzf "$TARBALL" | grep -E "\.ts$" | grep -v "\.d\.ts" || true)
|
| 56 |
+
if [ -n "$SOURCES" ]; then
|
| 57 |
+
echo "❌ ERROR: Source .ts files in package:"
|
| 58 |
+
echo "$SOURCES"
|
| 59 |
+
exit 1
|
| 60 |
+
fi
|
| 61 |
+
echo "✅ No source .ts files in tarball"
|
| 62 |
+
rm "$TARBALL"
|
| 63 |
+
|
| 64 |
+
- name: Check for test files in package
|
| 65 |
+
working-directory: npm
|
| 66 |
+
run: |
|
| 67 |
+
TESTS=$(npm pack --dry-run 2>&1 | grep "dist/tests" || true)
|
| 68 |
+
if [ -n "$TESTS" ]; then
|
| 69 |
+
echo "❌ ERROR: Test files in package:"
|
| 70 |
+
echo "$TESTS"
|
| 71 |
+
exit 1
|
| 72 |
+
fi
|
| 73 |
+
echo "✅ No test files in package"
|
| 74 |
+
|
| 75 |
+
- name: Check for source maps in package
|
| 76 |
+
working-directory: npm
|
| 77 |
+
run: |
|
| 78 |
+
MAPS=$(npm pack --dry-run 2>&1 | grep "\.map$" || true)
|
| 79 |
+
if [ -n "$MAPS" ]; then
|
| 80 |
+
echo "❌ ERROR: Source maps in package:"
|
| 81 |
+
echo "$MAPS"
|
| 82 |
+
exit 1
|
| 83 |
+
fi
|
| 84 |
+
echo "✅ No source maps in package"
|
| 85 |
+
|
| 86 |
+
- name: Verify required files exist
|
| 87 |
+
working-directory: npm
|
| 88 |
+
run: |
|
| 89 |
+
REQUIRED=(
|
| 90 |
+
"dist/src/index.js"
|
| 91 |
+
"dist/src/index.d.ts"
|
| 92 |
+
"scripts/postinstall.js"
|
| 93 |
+
"bin/pinchtab"
|
| 94 |
+
"LICENSE"
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
PACK_OUTPUT=$(npm pack --dry-run 2>&1)
|
| 98 |
+
|
| 99 |
+
for file in "${REQUIRED[@]}"; do
|
| 100 |
+
if echo "$PACK_OUTPUT" | grep -q "$file"; then
|
| 101 |
+
echo "✅ $file"
|
| 102 |
+
else
|
| 103 |
+
echo "❌ Missing: $file"
|
| 104 |
+
exit 1
|
| 105 |
+
fi
|
| 106 |
+
done
|
| 107 |
+
|
| 108 |
+
- name: Check postinstall.js syntax
|
| 109 |
+
working-directory: npm
|
| 110 |
+
run: |
|
| 111 |
+
node -c scripts/postinstall.js
|
| 112 |
+
echo "✅ postinstall.js valid JavaScript"
|
| 113 |
+
|
| 114 |
+
- name: Verify package metadata
|
| 115 |
+
working-directory: npm
|
| 116 |
+
run: |
|
| 117 |
+
node -e "
|
| 118 |
+
const pkg = require('./package.json');
|
| 119 |
+
const checks = [
|
| 120 |
+
{ name: 'name', value: pkg.name === 'pinchtab' },
|
| 121 |
+
{ name: 'version', value: !!pkg.version },
|
| 122 |
+
{ name: 'files array', value: Array.isArray(pkg.files) && pkg.files.length > 0 },
|
| 123 |
+
{ name: 'postinstall script', value: !!pkg.scripts.postinstall },
|
| 124 |
+
{ name: 'bin.pinchtab', value: !!pkg.bin.pinchtab }
|
| 125 |
+
];
|
| 126 |
+
|
| 127 |
+
let failed = false;
|
| 128 |
+
checks.forEach(check => {
|
| 129 |
+
if (check.value) {
|
| 130 |
+
console.log('✅', check.name);
|
| 131 |
+
} else {
|
| 132 |
+
console.log('❌', check.name, 'missing or invalid');
|
| 133 |
+
failed = true;
|
| 134 |
+
}
|
| 135 |
+
});
|
| 136 |
+
|
| 137 |
+
if (failed) process.exit(1);
|
| 138 |
+
"
|
| 139 |
+
|
| 140 |
+
- name: Check package size
|
| 141 |
+
working-directory: npm
|
| 142 |
+
run: |
|
| 143 |
+
SIZE=$(npm pack --dry-run 2>&1 | tail -1 | awk '{print $NF}' | tr -d 'B')
|
| 144 |
+
echo "📦 Package size: ${SIZE}B"
|
| 145 |
+
if [ "$SIZE" -gt 100000 ]; then
|
| 146 |
+
echo "⚠️ Warning: Package is large (>100KB)"
|
| 147 |
+
fi
|
| 148 |
+
|
| 149 |
+
- name: Security audit
|
| 150 |
+
working-directory: npm
|
| 151 |
+
run: npm audit --audit-level=moderate || true
|
| 152 |
+
|
| 153 |
+
- name: List package contents
|
| 154 |
+
working-directory: npm
|
| 155 |
+
run: |
|
| 156 |
+
echo "📦 Package will contain:"
|
| 157 |
+
npm pack --dry-run 2>&1 | grep "notice" | awk '{print " " $3 " (" $2 ")"}'
|
.github/workflows/publish-skill.yml
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Publish Skill
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
workflow_call:
|
| 5 |
+
inputs:
|
| 6 |
+
version:
|
| 7 |
+
required: true
|
| 8 |
+
type: string
|
| 9 |
+
secrets:
|
| 10 |
+
CLAWHUB_TOKEN:
|
| 11 |
+
required: true
|
| 12 |
+
workflow_dispatch:
|
| 13 |
+
inputs:
|
| 14 |
+
version:
|
| 15 |
+
description: 'Skill version (e.g. 0.2.0, without v prefix)'
|
| 16 |
+
required: true
|
| 17 |
+
|
| 18 |
+
env:
|
| 19 |
+
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
| 20 |
+
|
| 21 |
+
permissions:
|
| 22 |
+
contents: read
|
| 23 |
+
|
| 24 |
+
jobs:
|
| 25 |
+
publish:
|
| 26 |
+
name: Publish to ClawHub
|
| 27 |
+
runs-on: ubuntu-latest
|
| 28 |
+
steps:
|
| 29 |
+
- uses: actions/checkout@v5
|
| 30 |
+
|
| 31 |
+
- uses: actions/setup-node@v5
|
| 32 |
+
with:
|
| 33 |
+
node-version: '22'
|
| 34 |
+
|
| 35 |
+
- name: Install ClawHub CLI
|
| 36 |
+
run: npm i -g clawhub
|
| 37 |
+
|
| 38 |
+
- name: Authenticate
|
| 39 |
+
run: clawhub login --token "$CLAWHUB_TOKEN" --no-browser
|
| 40 |
+
env:
|
| 41 |
+
CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }}
|
| 42 |
+
|
| 43 |
+
- name: Publish skill
|
| 44 |
+
run: |
|
| 45 |
+
if [ -n "${{ inputs.version }}" ]; then
|
| 46 |
+
VERSION="${{ inputs.version }}"
|
| 47 |
+
elif [ -n "${{ github.event.inputs.version }}" ]; then
|
| 48 |
+
VERSION="${{ github.event.inputs.version }}"
|
| 49 |
+
else
|
| 50 |
+
VERSION=${GITHUB_REF_NAME#v}
|
| 51 |
+
fi
|
| 52 |
+
VERSION=${VERSION#v}
|
| 53 |
+
clawhub publish skills/pinchtab \
|
| 54 |
+
--slug pinchtab \
|
| 55 |
+
--name "Pinchtab" \
|
| 56 |
+
--version "$VERSION" \
|
| 57 |
+
--changelog "Release v$VERSION" \
|
| 58 |
+
--tags latest
|
.github/workflows/release.yml
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Release
|
| 2 |
+
|
| 3 |
+
# Automatic release pipeline triggered on tag push (v0.7.0, etc.)
|
| 4 |
+
#
|
| 5 |
+
# Pipeline:
|
| 6 |
+
# 1. Checkout tag
|
| 7 |
+
# 2. Build Go binary + create GitHub release (goreleaser)
|
| 8 |
+
# 3. Publish npm package (with postinstall binary download support)
|
| 9 |
+
# 4. Build & push Docker images
|
| 10 |
+
# See .github/RELEASING.md for the operational checklist.
|
| 11 |
+
#
|
| 12 |
+
# Secrets required:
|
| 13 |
+
# - NPM_TOKEN: npm authentication (https://docs.npmjs.com/creating-and-viewing-access-tokens)
|
| 14 |
+
# - DOCKERHUB_USER / DOCKERHUB_TOKEN: Docker Hub credentials
|
| 15 |
+
# - GITHUB_TOKEN: Automatic (GitHub Actions)
|
| 16 |
+
# - HOMEBREW_TAP_GITHUB_TOKEN: PAT with write access to pinchtab/homebrew-tap
|
| 17 |
+
#
|
| 18 |
+
# To create a release:
|
| 19 |
+
# git tag v0.7.0
|
| 20 |
+
# git push origin v0.7.0
|
| 21 |
+
|
| 22 |
+
on:
|
| 23 |
+
push:
|
| 24 |
+
tags: ['v*']
|
| 25 |
+
workflow_dispatch:
|
| 26 |
+
inputs:
|
| 27 |
+
tag:
|
| 28 |
+
description: 'Tag to release (e.g. v0.2.0). Leave empty for dry-run from ref.'
|
| 29 |
+
required: false
|
| 30 |
+
ref:
|
| 31 |
+
description: 'Git ref for dry-run testing (branch, tag, or SHA). Defaults to main.'
|
| 32 |
+
required: false
|
| 33 |
+
default: 'main'
|
| 34 |
+
dry_run:
|
| 35 |
+
description: 'Build release artifacts without publishing them.'
|
| 36 |
+
required: false
|
| 37 |
+
type: boolean
|
| 38 |
+
default: false
|
| 39 |
+
|
| 40 |
+
env:
|
| 41 |
+
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
| 42 |
+
GORELEASER_VERSION: v2.14.3
|
| 43 |
+
|
| 44 |
+
permissions:
|
| 45 |
+
contents: write
|
| 46 |
+
|
| 47 |
+
jobs:
|
| 48 |
+
release:
|
| 49 |
+
name: Release Binaries
|
| 50 |
+
runs-on: ubuntu-latest
|
| 51 |
+
permissions:
|
| 52 |
+
contents: write
|
| 53 |
+
steps:
|
| 54 |
+
- name: Validate workflow inputs
|
| 55 |
+
if: ${{ github.event_name == 'workflow_dispatch' }}
|
| 56 |
+
run: |
|
| 57 |
+
if [ "${{ inputs.dry_run }}" != "true" ] && [ -z "${{ github.event.inputs.tag }}" ]; then
|
| 58 |
+
echo "tag is required unless dry_run=true" >&2
|
| 59 |
+
exit 1
|
| 60 |
+
fi
|
| 61 |
+
|
| 62 |
+
- uses: actions/checkout@v5
|
| 63 |
+
with:
|
| 64 |
+
fetch-depth: 0
|
| 65 |
+
ref: ${{ github.event.inputs.tag || github.event.inputs.ref || github.ref }}
|
| 66 |
+
|
| 67 |
+
- uses: actions/setup-go@v6
|
| 68 |
+
with:
|
| 69 |
+
go-version: '1.26'
|
| 70 |
+
|
| 71 |
+
- uses: oven-sh/setup-bun@v2
|
| 72 |
+
with:
|
| 73 |
+
bun-version: latest
|
| 74 |
+
|
| 75 |
+
- name: Install dashboard dependencies
|
| 76 |
+
working-directory: dashboard
|
| 77 |
+
run: bun install --frozen-lockfile
|
| 78 |
+
|
| 79 |
+
- name: Run GoReleaser
|
| 80 |
+
run: |
|
| 81 |
+
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.dry_run }}" = "true" ]; then
|
| 82 |
+
ARGS="release --clean --snapshot"
|
| 83 |
+
else
|
| 84 |
+
ARGS="release --clean"
|
| 85 |
+
fi
|
| 86 |
+
|
| 87 |
+
curl -sfL https://goreleaser.com/static/run | VERSION="$GORELEASER_VERSION" bash -s -- $ARGS
|
| 88 |
+
env:
|
| 89 |
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
| 90 |
+
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
|
| 91 |
+
|
| 92 |
+
npm:
|
| 93 |
+
name: Publish to npm
|
| 94 |
+
runs-on: ubuntu-latest
|
| 95 |
+
needs: release
|
| 96 |
+
permissions:
|
| 97 |
+
contents: read
|
| 98 |
+
steps:
|
| 99 |
+
- uses: actions/checkout@v5
|
| 100 |
+
with:
|
| 101 |
+
ref: ${{ github.event.inputs.tag || github.event.inputs.ref || github.ref }}
|
| 102 |
+
|
| 103 |
+
- uses: actions/setup-node@v5
|
| 104 |
+
with:
|
| 105 |
+
node-version: '22'
|
| 106 |
+
registry-url: 'https://registry.npmjs.org'
|
| 107 |
+
|
| 108 |
+
- name: Extract version
|
| 109 |
+
id: version
|
| 110 |
+
run: |
|
| 111 |
+
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.dry_run }}" = "true" ] && [ -z "${{ github.event.inputs.tag }}" ]; then
|
| 112 |
+
VERSION=$(jq -r .version npm/package.json)
|
| 113 |
+
else
|
| 114 |
+
TAG="${{ github.event.inputs.tag || github.ref_name }}"
|
| 115 |
+
VERSION=${TAG#v} # Remove 'v' prefix
|
| 116 |
+
fi
|
| 117 |
+
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
| 118 |
+
|
| 119 |
+
- name: Update npm package version
|
| 120 |
+
if: ${{ github.event_name != 'workflow_dispatch' || !inputs.dry_run }}
|
| 121 |
+
working-directory: npm
|
| 122 |
+
run: |
|
| 123 |
+
CURRENT=$(jq -r .version package.json)
|
| 124 |
+
DESIRED=${{ steps.version.outputs.version }}
|
| 125 |
+
if [ "$CURRENT" != "$DESIRED" ]; then
|
| 126 |
+
npm version "$DESIRED" --no-git-tag-version
|
| 127 |
+
else
|
| 128 |
+
echo "Version already correct: $CURRENT"
|
| 129 |
+
fi
|
| 130 |
+
|
| 131 |
+
- name: Install dependencies
|
| 132 |
+
working-directory: npm
|
| 133 |
+
run: npm ci --ignore-scripts
|
| 134 |
+
|
| 135 |
+
- name: Build TypeScript
|
| 136 |
+
working-directory: npm
|
| 137 |
+
run: npm run build
|
| 138 |
+
|
| 139 |
+
- name: Dry-run npm publish
|
| 140 |
+
if: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
| 141 |
+
working-directory: npm
|
| 142 |
+
run: npm publish --dry-run
|
| 143 |
+
env:
|
| 144 |
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
| 145 |
+
|
| 146 |
+
- name: Publish to npm
|
| 147 |
+
if: ${{ github.event_name != 'workflow_dispatch' || !inputs.dry_run }}
|
| 148 |
+
working-directory: npm
|
| 149 |
+
run: npm publish
|
| 150 |
+
env:
|
| 151 |
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
| 152 |
+
|
| 153 |
+
docker:
|
| 154 |
+
name: Docker Image
|
| 155 |
+
runs-on: ubuntu-latest
|
| 156 |
+
needs: release
|
| 157 |
+
permissions:
|
| 158 |
+
contents: read
|
| 159 |
+
packages: write
|
| 160 |
+
steps:
|
| 161 |
+
- uses: actions/checkout@v5
|
| 162 |
+
with:
|
| 163 |
+
ref: ${{ github.event.inputs.tag || github.event.inputs.ref || github.ref }}
|
| 164 |
+
|
| 165 |
+
- name: Extract Docker metadata
|
| 166 |
+
id: meta
|
| 167 |
+
run: |
|
| 168 |
+
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.dry_run }}" = "true" ]; then
|
| 169 |
+
if [ -n "${{ github.event.inputs.tag }}" ]; then
|
| 170 |
+
TAG="${{ github.event.inputs.tag }}"
|
| 171 |
+
VERSION="${TAG#v}-dryrun-${GITHUB_SHA::7}"
|
| 172 |
+
else
|
| 173 |
+
TAG="dry-run-${GITHUB_SHA::7}"
|
| 174 |
+
VERSION="$(jq -r .version npm/package.json)-dryrun-${GITHUB_SHA::7}"
|
| 175 |
+
fi
|
| 176 |
+
PUSH="false"
|
| 177 |
+
else
|
| 178 |
+
TAG="${{ github.event.inputs.tag || github.ref_name }}"
|
| 179 |
+
VERSION="${TAG#v}"
|
| 180 |
+
PUSH="true"
|
| 181 |
+
fi
|
| 182 |
+
|
| 183 |
+
{
|
| 184 |
+
echo "tag=$TAG"
|
| 185 |
+
echo "version=$VERSION"
|
| 186 |
+
echo "push=$PUSH"
|
| 187 |
+
} >> "$GITHUB_OUTPUT"
|
| 188 |
+
|
| 189 |
+
- name: Docker dry-run note
|
| 190 |
+
if: ${{ steps.meta.outputs.push != 'true' }}
|
| 191 |
+
run: |
|
| 192 |
+
echo "Running a multi-arch Docker build without pushing to GHCR or Docker Hub." >> "$GITHUB_STEP_SUMMARY"
|
| 193 |
+
|
| 194 |
+
- uses: docker/setup-qemu-action@v3
|
| 195 |
+
- uses: docker/setup-buildx-action@v3
|
| 196 |
+
|
| 197 |
+
- uses: docker/login-action@v3
|
| 198 |
+
if: ${{ steps.meta.outputs.push == 'true' }}
|
| 199 |
+
with:
|
| 200 |
+
username: ${{ secrets.DOCKERHUB_USER }}
|
| 201 |
+
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
| 202 |
+
|
| 203 |
+
- uses: docker/login-action@v3
|
| 204 |
+
if: ${{ steps.meta.outputs.push == 'true' }}
|
| 205 |
+
with:
|
| 206 |
+
registry: ghcr.io
|
| 207 |
+
username: ${{ github.actor }}
|
| 208 |
+
password: ${{ secrets.GITHUB_TOKEN }}
|
| 209 |
+
|
| 210 |
+
- uses: docker/build-push-action@v6
|
| 211 |
+
with:
|
| 212 |
+
context: .
|
| 213 |
+
file: ./Dockerfile
|
| 214 |
+
platforms: linux/amd64,linux/arm64
|
| 215 |
+
push: ${{ steps.meta.outputs.push == 'true' }}
|
| 216 |
+
provenance: false
|
| 217 |
+
labels: |
|
| 218 |
+
org.opencontainers.image.source=https://github.com/pinchtab/pinchtab
|
| 219 |
+
org.opencontainers.image.version=${{ steps.meta.outputs.version }}
|
| 220 |
+
tags: |
|
| 221 |
+
ghcr.io/pinchtab/pinchtab:latest
|
| 222 |
+
ghcr.io/pinchtab/pinchtab:${{ steps.meta.outputs.tag }}
|
| 223 |
+
ghcr.io/pinchtab/pinchtab:${{ steps.meta.outputs.version }}
|
| 224 |
+
pinchtab/pinchtab:latest
|
| 225 |
+
pinchtab/pinchtab:${{ steps.meta.outputs.tag }}
|
| 226 |
+
pinchtab/pinchtab:${{ steps.meta.outputs.version }}
|
| 227 |
+
|
| 228 |
+
skill:
|
| 229 |
+
name: Publish Skill
|
| 230 |
+
needs:
|
| 231 |
+
- npm
|
| 232 |
+
- docker
|
| 233 |
+
if: ${{ github.event_name != 'workflow_dispatch' || !inputs.dry_run }}
|
| 234 |
+
uses: ./.github/workflows/publish-skill.yml
|
| 235 |
+
with:
|
| 236 |
+
version: ${{ github.event.inputs.tag || github.ref_name }}
|
| 237 |
+
secrets:
|
| 238 |
+
CLAWHUB_TOKEN: ${{ secrets.CLAWHUB_TOKEN }}
|
.gitignore
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules/
|
| 2 |
+
.venv/
|
| 3 |
+
plugins/*/.venv/
|
| 4 |
+
__pycache__/
|
| 5 |
+
*.pyc
|
| 6 |
+
browser-bridge
|
| 7 |
+
/pinchtab
|
| 8 |
+
coverage.out
|
| 9 |
+
|
| 10 |
+
# Build artifacts and test output
|
| 11 |
+
dist/
|
| 12 |
+
*.js
|
| 13 |
+
!scripts/**/*.js
|
| 14 |
+
!dashboard/eslint.config.js
|
| 15 |
+
!dashboard/vite.config.ts
|
| 16 |
+
!npm/bin/pinchtab
|
| 17 |
+
# React dashboard build output (generated by scripts/build-dashboard.sh)
|
| 18 |
+
# Go code serves a built-in fallback when these files are absent.
|
| 19 |
+
internal/dashboard/dashboard/assets/
|
| 20 |
+
internal/dashboard/dashboard/dashboard.html
|
| 21 |
+
|
| 22 |
+
# npm package
|
| 23 |
+
npm/node_modules/
|
| 24 |
+
# package-lock.json is committed for reproducible installs via npm ci
|
| 25 |
+
|
| 26 |
+
# OpenClaw workspace files (should never be committed)
|
| 27 |
+
.openclaw/
|
| 28 |
+
SOUL.md
|
| 29 |
+
HEARTBEAT.md
|
| 30 |
+
IDENTITY.md
|
| 31 |
+
USER.md
|
| 32 |
+
BOOTSTRAP.md
|
| 33 |
+
TOOLS.md
|
| 34 |
+
MEMORY.md
|
| 35 |
+
AGENTS.md
|
| 36 |
+
*.test
|
| 37 |
+
memory/
|
| 38 |
+
pinchtab-small
|
| 39 |
+
|
| 40 |
+
# Local development files
|
| 41 |
+
MY_PROJECT_NOTES.md
|
| 42 |
+
debug.log
|
| 43 |
+
pinchtab.exe
|
| 44 |
+
|
| 45 |
+
.gomodcache/
|
| 46 |
+
.gocache
|
| 47 |
+
.DS_Store
|
| 48 |
+
# E2E test results
|
| 49 |
+
tests/e2e/results/*
|
| 50 |
+
!tests/e2e/results/.gitkeep
|
.goreleaser.yml
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: 2
|
| 2 |
+
|
| 3 |
+
before:
|
| 4 |
+
hooks:
|
| 5 |
+
- ./scripts/build-dashboard.sh
|
| 6 |
+
|
| 7 |
+
builds:
|
| 8 |
+
- main: ./cmd/pinchtab
|
| 9 |
+
ldflags:
|
| 10 |
+
- -s -w -X main.version={{.Version}}
|
| 11 |
+
goos:
|
| 12 |
+
- linux
|
| 13 |
+
- darwin
|
| 14 |
+
- windows
|
| 15 |
+
goarch:
|
| 16 |
+
- amd64
|
| 17 |
+
- arm64
|
| 18 |
+
|
| 19 |
+
archives:
|
| 20 |
+
# Output bare binaries directly (no archives)
|
| 21 |
+
# Note: goreleaser auto-adds .exe for Windows, so don't include it in the template
|
| 22 |
+
- format: binary
|
| 23 |
+
name_template: "pinchtab-{{ .Os }}-{{ .Arch }}"
|
| 24 |
+
|
| 25 |
+
checksum:
|
| 26 |
+
name_template: checksums.txt
|
| 27 |
+
|
| 28 |
+
brews:
|
| 29 |
+
- name: pinchtab
|
| 30 |
+
repository:
|
| 31 |
+
owner: pinchtab
|
| 32 |
+
name: homebrew-tap
|
| 33 |
+
branch: "brew-{{ .ProjectName }}-{{ .Version }}"
|
| 34 |
+
token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
|
| 35 |
+
pull_request:
|
| 36 |
+
enabled: true
|
| 37 |
+
base:
|
| 38 |
+
owner: pinchtab
|
| 39 |
+
name: homebrew-tap
|
| 40 |
+
branch: main
|
| 41 |
+
directory: Formula
|
| 42 |
+
homepage: "https://pinchtab.com"
|
| 43 |
+
description: "High-performance browser automation bridge and multi-instance orchestrator for AI agents"
|
| 44 |
+
license: "MIT"
|
| 45 |
+
test: |
|
| 46 |
+
system "#{bin}/pinchtab version"
|
| 47 |
+
install: |
|
| 48 |
+
bin.install Dir["pinchtab*"].first => "pinchtab"
|
| 49 |
+
|
| 50 |
+
changelog:
|
| 51 |
+
sort: asc
|
| 52 |
+
filters:
|
| 53 |
+
exclude:
|
| 54 |
+
- '^docs:'
|
| 55 |
+
- '^ci:'
|
| 56 |
+
- '^chore:'
|
| 57 |
+
|
| 58 |
+
release:
|
| 59 |
+
github:
|
| 60 |
+
owner: pinchtab
|
| 61 |
+
name: pinchtab
|
.hfignore
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git/
|
| 2 |
+
.github/
|
| 3 |
+
.devcontainer/
|
| 4 |
+
tests/
|
| 5 |
+
scripts/
|
| 6 |
+
docs/
|
| 7 |
+
npm/
|
| 8 |
+
plugins/
|
| 9 |
+
skills/
|
| 10 |
+
/assets/
|
| 11 |
+
.goreleaser.yml
|
| 12 |
+
.markdownlint.json
|
| 13 |
+
.pre-commit-config.yaml
|
| 14 |
+
CODE_OF_CONDUCT.md
|
| 15 |
+
CONTRIBUTING.md
|
| 16 |
+
DEFINITION_OF_DONE.md
|
| 17 |
+
DEVELOPMENT.md
|
| 18 |
+
RELEASE.md
|
| 19 |
+
SECURITY.md
|
| 20 |
+
TESTING.md
|
| 21 |
+
THIRD_PARTY_LICENSES.md
|
| 22 |
+
dev
|
| 23 |
+
docker-compose.yml
|
| 24 |
+
docker-entrypoint-release.sh
|
| 25 |
+
install.sh
|
| 26 |
+
node_modules/
|
| 27 |
+
.gocache/
|
| 28 |
+
.gomodcache/
|
| 29 |
+
tmp/
|
| 30 |
+
dist/
|
| 31 |
+
dashboard/node_modules/
|
| 32 |
+
dashboard/dist/
|
.markdownlint.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"default": true,
|
| 3 |
+
"line-length": {
|
| 4 |
+
"line_length": 200,
|
| 5 |
+
"heading_line_length": 200,
|
| 6 |
+
"code_block_line_length": 200,
|
| 7 |
+
"code_blocks": false
|
| 8 |
+
},
|
| 9 |
+
"no-hard-tabs": false,
|
| 10 |
+
"no-bare-urls": false,
|
| 11 |
+
"no-inline-html": false,
|
| 12 |
+
"blanks-around-headings": false,
|
| 13 |
+
"blanks-around-lists": false,
|
| 14 |
+
"blanks-around-fences": false,
|
| 15 |
+
"no-emphasis-as-heading": false,
|
| 16 |
+
"no-multiple-blank-lines": {
|
| 17 |
+
"maximum": 2
|
| 18 |
+
},
|
| 19 |
+
"first-line-heading": false,
|
| 20 |
+
"heading-start-left": true,
|
| 21 |
+
"heading-increment": true,
|
| 22 |
+
"no-duplicate-heading": {
|
| 23 |
+
"allow_different_nesting": true
|
| 24 |
+
},
|
| 25 |
+
"fenced-code-language": false
|
| 26 |
+
}
|
.pre-commit-config.yaml
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
repos:
|
| 2 |
+
# General file checks
|
| 3 |
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
| 4 |
+
rev: v4.5.0
|
| 5 |
+
hooks:
|
| 6 |
+
- id: trailing-whitespace
|
| 7 |
+
- id: end-of-file-fixer
|
| 8 |
+
- id: check-yaml
|
| 9 |
+
- id: check-json
|
| 10 |
+
exclude: 'tsconfig\..+\.json$'
|
| 11 |
+
- id: check-merge-conflict
|
| 12 |
+
- id: check-added-large-files
|
| 13 |
+
args: ['--maxkb=500']
|
| 14 |
+
|
| 15 |
+
# Go formatting
|
| 16 |
+
- repo: https://github.com/golangci/golangci-lint
|
| 17 |
+
rev: v2.1.6
|
| 18 |
+
hooks:
|
| 19 |
+
- id: golangci-lint
|
| 20 |
+
args: ['--timeout=5m']
|
| 21 |
+
|
| 22 |
+
# Markdown linting (relaxed for docs)
|
| 23 |
+
- repo: https://github.com/igorshubovych/markdownlint-cli
|
| 24 |
+
rev: v0.37.0
|
| 25 |
+
hooks:
|
| 26 |
+
- id: markdownlint
|
| 27 |
+
args: ['--config', '.markdownlint.json']
|
| 28 |
+
# Exclude test files, auto-generated docs, repo root, and skill docs
|
| 29 |
+
exclude: |
|
| 30 |
+
(?x)^(
|
| 31 |
+
tests/.*\.md|
|
| 32 |
+
\.github/.*\.md|
|
| 33 |
+
skill/.*\.md|
|
| 34 |
+
docs/references/.*\.json|
|
| 35 |
+
README\.md|
|
| 36 |
+
CONTRIBUTING\.md|
|
| 37 |
+
CODE_OF_CONDUCT\.md|
|
| 38 |
+
SECURITY\.md|
|
| 39 |
+
RELEASE\.md
|
| 40 |
+
)$
|
Agent.md
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Agent.md
|
| 2 |
+
|
| 3 |
+
## 1. Deployment Configuration
|
| 4 |
+
|
| 5 |
+
### Target Space
|
| 6 |
+
- **Profile:** `AUXteam`
|
| 7 |
+
- **Space:** `WitNote`
|
| 8 |
+
- **Full Identifier:** `AUXteam/WitNote`
|
| 9 |
+
- **Frontend Port:** `7860` (mandatory for all Hugging Face Spaces)
|
| 10 |
+
|
| 11 |
+
### Deployment Method
|
| 12 |
+
Choose the correct SDK based on the app type based on the codebase language:
|
| 13 |
+
|
| 14 |
+
- **Gradio SDK** — for Gradio applications
|
| 15 |
+
- **Streamlit SDK** — for Streamlit applications
|
| 16 |
+
- **Docker SDK** — for all other applications (recommended default for flexibility)
|
| 17 |
+
|
| 18 |
+
### HF Token
|
| 19 |
+
- The environment variable **`HF_TOKEN` will always be provided at execution time**.
|
| 20 |
+
- Never hardcode the token. Always read it from the environment.
|
| 21 |
+
- All monitoring and log‑streaming commands rely on `$HF_TOKEN`.
|
| 22 |
+
|
| 23 |
+
### Required Files
|
| 24 |
+
- `Dockerfile` (or `app.py` for Gradio/Streamlit SDKs)
|
| 25 |
+
- `README.md` with Hugging Face YAML frontmatter:
|
| 26 |
+
```yaml
|
| 27 |
+
---
|
| 28 |
+
title: <APP NAME>
|
| 29 |
+
sdk: docker | gradio | streamlit
|
| 30 |
+
app_port: 7860
|
| 31 |
+
---
|
| 32 |
+
```
|
| 33 |
+
- `.hfignore` to exclude unnecessary files
|
| 34 |
+
- This `Agent.md` file (must be committed before deployment)
|
| 35 |
+
|
| 36 |
+
---
|
| 37 |
+
|
| 38 |
+
## 2. API Exposure and Documentation
|
| 39 |
+
|
| 40 |
+
### Mandatory Endpoints
|
| 41 |
+
Every deployment **must** expose:
|
| 42 |
+
|
| 43 |
+
- **`/health`**
|
| 44 |
+
- Returns HTTP 200 when the app is ready.
|
| 45 |
+
- Required for Hugging Face to transition the Space from *starting* → *running*.
|
| 46 |
+
|
| 47 |
+
- **`/api-docs`**
|
| 48 |
+
- Documents **all** available API endpoints.
|
| 49 |
+
- Must be reachable at:
|
| 50 |
+
`https://HF_PROFILE-WitNote.hf.space/api-docs`
|
| 51 |
+
|
| 52 |
+
### Functional Endpoints
|
| 53 |
+
|
| 54 |
+
### /navigate
|
| 55 |
+
- Method: POST
|
| 56 |
+
- Purpose: Navigate the current tab to a specified URL.
|
| 57 |
+
- Request Example:
|
| 58 |
+
```json
|
| 59 |
+
{
|
| 60 |
+
"url": "https://example.com"
|
| 61 |
+
}
|
| 62 |
+
```
|
| 63 |
+
- Response Example:
|
| 64 |
+
```json
|
| 65 |
+
{
|
| 66 |
+
"status": "ok",
|
| 67 |
+
"url": "https://example.com"
|
| 68 |
+
}
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
### /text
|
| 72 |
+
- Method: GET
|
| 73 |
+
- Purpose: Extract structured text from the current page.
|
| 74 |
+
- Request Example: `?maxChars=1000&format=markdown`
|
| 75 |
+
- Response Example:
|
| 76 |
+
```json
|
| 77 |
+
{
|
| 78 |
+
"text": "Extracted text content..."
|
| 79 |
+
}
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
### /action
|
| 83 |
+
- Method: POST
|
| 84 |
+
- Purpose: Perform a single action on the page (e.g., click, type).
|
| 85 |
+
- Request Example:
|
| 86 |
+
```json
|
| 87 |
+
{
|
| 88 |
+
"action": "click",
|
| 89 |
+
"selector": "#submit-btn"
|
| 90 |
+
}
|
| 91 |
+
```
|
| 92 |
+
- Response Example:
|
| 93 |
+
```json
|
| 94 |
+
{
|
| 95 |
+
"status": "ok"
|
| 96 |
+
}
|
| 97 |
+
```
|
| 98 |
+
|
| 99 |
+
### /snapshot
|
| 100 |
+
- Method: GET
|
| 101 |
+
- Purpose: Get an accessibility snapshot of the current page.
|
| 102 |
+
- Request Example: (no body)
|
| 103 |
+
- Response Example:
|
| 104 |
+
```json
|
| 105 |
+
{
|
| 106 |
+
"snapshot": [ ... ]
|
| 107 |
+
}
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
### /evaluate
|
| 111 |
+
- Method: POST
|
| 112 |
+
- Purpose: Run JavaScript in the current tab.
|
| 113 |
+
- Request Example:
|
| 114 |
+
```json
|
| 115 |
+
{
|
| 116 |
+
"expression": "document.title"
|
| 117 |
+
}
|
| 118 |
+
```
|
| 119 |
+
- Response Example:
|
| 120 |
+
```json
|
| 121 |
+
{
|
| 122 |
+
"result": "Example Domain"
|
| 123 |
+
}
|
| 124 |
+
```
|
| 125 |
+
|
| 126 |
+
### /macro
|
| 127 |
+
- Method: POST
|
| 128 |
+
- Purpose: Execute a macro action pipeline.
|
| 129 |
+
- Request Example:
|
| 130 |
+
```json
|
| 131 |
+
{
|
| 132 |
+
"actions": [
|
| 133 |
+
{"action": "click", "selector": "#btn"}
|
| 134 |
+
]
|
| 135 |
+
}
|
| 136 |
+
```
|
| 137 |
+
- Response Example:
|
| 138 |
+
```json
|
| 139 |
+
{
|
| 140 |
+
"status": "ok"
|
| 141 |
+
}
|
| 142 |
+
```
|
| 143 |
+
|
| 144 |
+
All endpoints listed here **must** appear in `/api-docs`.
|
| 145 |
+
|
| 146 |
+
---
|
| 147 |
+
|
| 148 |
+
## 3. Deployment Workflow
|
| 149 |
+
|
| 150 |
+
### Standard Deployment Command
|
| 151 |
+
After any code change, run:
|
| 152 |
+
|
| 153 |
+
```bash
|
| 154 |
+
hf upload AUXteam/WitNote --repo-type=space
|
| 155 |
+
```
|
| 156 |
+
|
| 157 |
+
This command must be executed **after updating and committing Agent.md**.
|
| 158 |
+
|
| 159 |
+
### Deployment Steps
|
| 160 |
+
1. Ensure all code changes are committed.
|
| 161 |
+
2. Ensure `Agent.md` is updated and committed.
|
| 162 |
+
3. Run the upload command.
|
| 163 |
+
4. Wait for the Space to build.
|
| 164 |
+
5. Monitor logs (see next section).
|
| 165 |
+
6. When the Space is running, execute all test cases.
|
| 166 |
+
|
| 167 |
+
### Continuous Deployment Rule
|
| 168 |
+
After **every** relevant edit (logic, dependencies, API changes):
|
| 169 |
+
|
| 170 |
+
- Update `Agent.md`
|
| 171 |
+
- Redeploy using the upload command
|
| 172 |
+
- Re-run all test cases
|
| 173 |
+
- Confirm `/health` and `/api-docs` are functional
|
| 174 |
+
|
| 175 |
+
This applies even for long-running projects.
|
| 176 |
+
|
| 177 |
+
---
|
| 178 |
+
|
| 179 |
+
## 4. Monitoring and Logs
|
| 180 |
+
|
| 181 |
+
### Build Logs (SSE)
|
| 182 |
+
```bash
|
| 183 |
+
curl -N \
|
| 184 |
+
-H "Authorization: Bearer $HF_TOKEN" \
|
| 185 |
+
"https://huggingface.co/api/spaces/AUXteam/WitNote/logs/build"
|
| 186 |
+
```
|
| 187 |
+
|
| 188 |
+
### Run Logs (SSE)
|
| 189 |
+
```bash
|
| 190 |
+
curl -N \
|
| 191 |
+
-H "Authorization: Bearer $HF_TOKEN" \
|
| 192 |
+
"https://huggingface.co/api/spaces/AUXteam/WitNote/logs/run"
|
| 193 |
+
```
|
| 194 |
+
|
| 195 |
+
### Notes
|
| 196 |
+
- If the Space stays in *starting* for too long, `/health` is usually failing.
|
| 197 |
+
- If the Space times out after ~30 minutes, check logs immediately.
|
| 198 |
+
- Fix issues, commit changes, redeploy.
|
| 199 |
+
|
| 200 |
+
---
|
| 201 |
+
|
| 202 |
+
## 5. Test Run Cases (Mandatory After Every Deployment)
|
| 203 |
+
|
| 204 |
+
These tests ensure the agentic system can verify the deployment automatically.
|
| 205 |
+
|
| 206 |
+
### 1. Health Check
|
| 207 |
+
```
|
| 208 |
+
GET https://HF_PROFILE-WitNote.hf.space/health
|
| 209 |
+
Expected: HTTP 200, body: {"status": "ok"} or similar
|
| 210 |
+
```
|
| 211 |
+
|
| 212 |
+
### 2. API Docs Check
|
| 213 |
+
```
|
| 214 |
+
GET https://HF_PROFILE-WitNote.hf.space/api-docs
|
| 215 |
+
Expected: HTTP 200, valid documentation UI or JSON spec
|
| 216 |
+
```
|
| 217 |
+
|
| 218 |
+
### 3. Functional Endpoint Tests
|
| 219 |
+
For each endpoint documented above, define:
|
| 220 |
+
|
| 221 |
+
- Example request
|
| 222 |
+
- Expected response structure
|
| 223 |
+
- Validation criteria (e.g., non-empty output, valid JSON)
|
| 224 |
+
|
| 225 |
+
Example:
|
| 226 |
+
|
| 227 |
+
```
|
| 228 |
+
POST https://HF_PROFILE-WitNote.hf.space/predict
|
| 229 |
+
Payload:
|
| 230 |
+
{
|
| 231 |
+
"text": "test"
|
| 232 |
+
}
|
| 233 |
+
Expected:
|
| 234 |
+
- HTTP 200
|
| 235 |
+
- JSON with key "prediction"
|
| 236 |
+
- No error fields
|
| 237 |
+
```
|
| 238 |
+
|
| 239 |
+
### 4. End-to-End Behaviour
|
| 240 |
+
- Confirm the UI loads (if applicable)
|
| 241 |
+
- Confirm API endpoints respond within reasonable time
|
| 242 |
+
- Confirm no errors appear in run logs
|
| 243 |
+
|
| 244 |
+
---
|
| 245 |
+
|
| 246 |
+
## 6. Maintenance Rules
|
| 247 |
+
|
| 248 |
+
- `Agent.md` must always reflect the **current** deployment configuration, API surface, and test cases.
|
| 249 |
+
- Any change to:
|
| 250 |
+
- API routes
|
| 251 |
+
- Dockerfile
|
| 252 |
+
- Dependencies
|
| 253 |
+
- App logic
|
| 254 |
+
- Deployment method
|
| 255 |
+
requires updating this file.
|
| 256 |
+
- This file must be committed **before** every deployment.
|
| 257 |
+
- This file is the operational contract for autonomous agents interacting with the project.
|
CODE_OF_CONDUCT.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Contributor Covenant Code of Conduct
|
| 2 |
+
|
| 3 |
+
## Our Pledge
|
| 4 |
+
|
| 5 |
+
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
| 6 |
+
|
| 7 |
+
## Our Standards
|
| 8 |
+
|
| 9 |
+
Examples of behavior that contributes to creating a positive environment include:
|
| 10 |
+
|
| 11 |
+
* Using welcoming and inclusive language
|
| 12 |
+
* Being respectful of differing viewpoints and experiences
|
| 13 |
+
* Gracefully accepting constructive criticism
|
| 14 |
+
* Focusing on what is best for the community
|
| 15 |
+
* Showing empathy towards other community members
|
| 16 |
+
|
| 17 |
+
Examples of unacceptable behavior by participants include:
|
| 18 |
+
|
| 19 |
+
* The use of sexualized language or imagery and unwelcome sexual attention or advances
|
| 20 |
+
* Trolling, insulting/derogatory comments, and personal or political attacks
|
| 21 |
+
* Public or private harassment
|
| 22 |
+
* Publishing others' private information, such as a physical or electronic address, without explicit permission
|
| 23 |
+
* Other conduct which could reasonably be considered inappropriate in a professional setting
|
| 24 |
+
|
| 25 |
+
## Our Responsibilities
|
| 26 |
+
|
| 27 |
+
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
|
| 28 |
+
|
| 29 |
+
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
|
| 30 |
+
|
| 31 |
+
## Scope
|
| 32 |
+
|
| 33 |
+
This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
|
| 34 |
+
|
| 35 |
+
## Enforcement
|
| 36 |
+
|
| 37 |
+
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at admin@pinchtab.com. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
|
| 38 |
+
|
| 39 |
+
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
|
| 40 |
+
|
| 41 |
+
## Attribution
|
| 42 |
+
|
| 43 |
+
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
|
| 44 |
+
|
| 45 |
+
[homepage]: http://contributor-covenant.org
|
| 46 |
+
[version]: http://contributor-covenant.org/version/1/4
|
CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Contributing to PinchTab
|
| 2 |
+
|
| 3 |
+
This repository keeps one detailed contributor guide:
|
| 4 |
+
|
| 5 |
+
- [docs/guides/contributing.md](docs/guides/contributing.md)
|
| 6 |
+
|
| 7 |
+
Use that guide for setup, build commands, tests, hooks, and the day-to-day development workflow.
|
| 8 |
+
|
| 9 |
+
## Quick Start
|
| 10 |
+
|
| 11 |
+
```bash
|
| 12 |
+
git clone https://github.com/pinchtab/pinchtab.git
|
| 13 |
+
cd pinchtab
|
| 14 |
+
./dev doctor
|
| 15 |
+
./dev check
|
| 16 |
+
./dev test unit
|
| 17 |
+
```
|
| 18 |
+
|
| 19 |
+
## Pull Requests
|
| 20 |
+
|
| 21 |
+
Please keep GitHub's "Allow edits from maintainers" option enabled on your PRs. It makes small fixes, rebases, and merge-conflict resolution much faster.
|
DEFINITION_OF_DONE.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Definition of Done
|
| 2 |
+
|
| 3 |
+
Canonical file: [`.github/DEFINITION_OF_DONE.md`](./.github/DEFINITION_OF_DONE.md)
|
| 4 |
+
|
| 5 |
+
Use the `.github` copy as the source of truth for PR review, templates, and agent instructions.
|
DEVELOPMENT.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Development Setup
|
| 2 |
+
|
| 3 |
+
This document has been consolidated into the main contributor guide:
|
| 4 |
+
|
| 5 |
+
- [docs/guides/contributing.md](docs/guides/contributing.md)
|
| 6 |
+
|
| 7 |
+
Use that page as the source of truth for environment setup, building, testing, hooks, and contributor workflow.
|
Dockerfile
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Stage 1: Build the React dashboard with Bun.
|
| 2 |
+
# The compiled assets are copied into the Go embed directory in stage 2.
|
| 3 |
+
FROM oven/bun:1 AS dashboard
|
| 4 |
+
WORKDIR /build
|
| 5 |
+
COPY dashboard/package.json dashboard/bun.lock ./
|
| 6 |
+
RUN bun install --frozen-lockfile
|
| 7 |
+
COPY dashboard/ .
|
| 8 |
+
RUN bun run build
|
| 9 |
+
|
| 10 |
+
# Stage 2: Compile the Go binary.
|
| 11 |
+
# Dashboard dist is embedded via Go's embed package.
|
| 12 |
+
# Vite always outputs index.html; rename to dashboard.html so it doesn't
|
| 13 |
+
# collide with http.FileServer's automatic index.html handling at /dashboard/.
|
| 14 |
+
FROM golang:1.26-alpine AS builder
|
| 15 |
+
RUN apk add --no-cache git
|
| 16 |
+
WORKDIR /build
|
| 17 |
+
COPY go.mod go.sum ./
|
| 18 |
+
RUN go mod download
|
| 19 |
+
COPY . .
|
| 20 |
+
COPY --from=dashboard /build/dist/ internal/dashboard/dashboard/
|
| 21 |
+
RUN mv internal/dashboard/dashboard/index.html internal/dashboard/dashboard/dashboard.html
|
| 22 |
+
RUN go build -ldflags="-s -w" -o pinchtab ./cmd/pinchtab
|
| 23 |
+
|
| 24 |
+
# Stage 3: Minimal runtime image with Chromium.
|
| 25 |
+
# Only the compiled binary and entrypoint script are copied in.
|
| 26 |
+
#
|
| 27 |
+
# Security model:
|
| 28 |
+
# - Chrome runs with --no-sandbox (set by entrypoint) because containers don't
|
| 29 |
+
# have user namespaces for sandboxing
|
| 30 |
+
# - Container provides isolation via cgroups, seccomp, dropped capabilities,
|
| 31 |
+
# read-only filesystem, and non-root user
|
| 32 |
+
# - This matches best practices for headless Chrome in containerized environments
|
| 33 |
+
FROM alpine:3.21
|
| 34 |
+
|
| 35 |
+
LABEL org.opencontainers.image.source="https://github.com/pinchtab/pinchtab"
|
| 36 |
+
LABEL org.opencontainers.image.description="High-performance browser automation bridge"
|
| 37 |
+
|
| 38 |
+
# Chromium and its runtime dependencies for headless operation
|
| 39 |
+
RUN apk add --no-cache \
|
| 40 |
+
chromium \
|
| 41 |
+
nss \
|
| 42 |
+
freetype \
|
| 43 |
+
harfbuzz \
|
| 44 |
+
ca-certificates \
|
| 45 |
+
ttf-freefont \
|
| 46 |
+
dumb-init
|
| 47 |
+
|
| 48 |
+
# Non-root user; /data is the persistent volume mount point
|
| 49 |
+
RUN adduser -D -h /data -g '' pinchtab && \
|
| 50 |
+
mkdir -p /data && \
|
| 51 |
+
chown pinchtab:pinchtab /data
|
| 52 |
+
|
| 53 |
+
COPY --from=builder /build/pinchtab /usr/local/bin/pinchtab
|
| 54 |
+
COPY --chmod=0755 docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
| 55 |
+
|
| 56 |
+
USER pinchtab
|
| 57 |
+
WORKDIR /data
|
| 58 |
+
|
| 59 |
+
# HOME and XDG_CONFIG_HOME point into the persistent volume so config
|
| 60 |
+
# and Chrome profiles survive container restarts.
|
| 61 |
+
ENV HOME=/data \
|
| 62 |
+
XDG_CONFIG_HOME=/data/.config
|
| 63 |
+
|
| 64 |
+
EXPOSE 7860
|
| 65 |
+
|
| 66 |
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
| 67 |
+
CMD wget -q -O /dev/null http://localhost:7860/health || exit 1
|
| 68 |
+
|
| 69 |
+
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
|
| 70 |
+
CMD ["/usr/local/bin/docker-entrypoint.sh", "pinchtab"]
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Luigi Agosti
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
CHANGED
|
@@ -1,12 +1,374 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
colorTo: purple
|
| 6 |
-
sdk: gradio
|
| 7 |
-
sdk_version: 6.9.0
|
| 8 |
-
app_file: app.py
|
| 9 |
-
pinned: false
|
| 10 |
---
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: PinchTab
|
| 3 |
+
sdk: docker
|
| 4 |
+
app_port: 7860
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
---
|
| 6 |
+
<p align="center">
|
| 7 |
+
<img src="assets/pinchtab-headless.png" alt="PinchTab" width="200"/>
|
| 8 |
+
</p>
|
| 9 |
|
| 10 |
+
<p align="center">
|
| 11 |
+
<strong>PinchTab</strong><br/>
|
| 12 |
+
<strong>Browser control for AI agents</strong><br/>
|
| 13 |
+
12MB Go binary • HTTP API • Token-efficient
|
| 14 |
+
</p>
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
<table align="center">
|
| 18 |
+
<tr>
|
| 19 |
+
<td align="center" valign="middle">
|
| 20 |
+
<a href="https://pinchtab.com/docs"><img src="assets/docs-no-background-256.png" alt="Full Documentation" width="92"/></a>
|
| 21 |
+
</td>
|
| 22 |
+
<td align="left" valign="middle">
|
| 23 |
+
<a href="https://github.com/pinchtab/pinchtab/releases/latest"><img src="https://img.shields.io/github/v/release/pinchtab/pinchtab?style=flat-square&color=FFD700" alt="Release"/></a><br/>
|
| 24 |
+
<a href="https://github.com/pinchtab/pinchtab/actions/workflows/go-verify.yml"><img src="https://img.shields.io/github/actions/workflow/status/pinchtab/pinchtab/go-verify.yml?branch=main&style=flat-square&label=Build" alt="Build"/></a><br/>
|
| 25 |
+
<img src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat-square&logo=go&logoColor=white" alt="Go 1.25+"/><br/>
|
| 26 |
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue?style=flat-square" alt="License"/></a>
|
| 27 |
+
</td>
|
| 28 |
+
</tr>
|
| 29 |
+
</table>
|
| 30 |
+
|
| 31 |
+
---
|
| 32 |
+
|
| 33 |
+
## What is PinchTab?
|
| 34 |
+
|
| 35 |
+
PinchTab is a **standalone HTTP server** that gives AI agents direct control over Chrome.
|
| 36 |
+
|
| 37 |
+
For day-to-day local use, the server is typically installed as a user-level daemon, allowing agent tools to reuse the same browser control plane running in the background.
|
| 38 |
+
|
| 39 |
+
```bash
|
| 40 |
+
curl -fsSL https://pinchtab.com/install.sh | bash
|
| 41 |
+
# or
|
| 42 |
+
pinchtab daemon install
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
This installs the control-plane server and starts a default headless Chrome instance, ready to accept requests from agents or manual API calls.
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
If you prefer not to run a daemon, or if you're on Windows, you can instead run:
|
| 49 |
+
|
| 50 |
+
`pinchtab server` — runs the control-plane server directly
|
| 51 |
+
`pinchtab bridge` — runs a single browser instance as a lightweight runtime
|
| 52 |
+
|
| 53 |
+
PinchTab also provides a CLI with an interactive entry point for local setup and common tasks:
|
| 54 |
+
|
| 55 |
+
`pinchtab`
|
| 56 |
+
|
| 57 |
+
## Security
|
| 58 |
+
|
| 59 |
+
PinchTab defaults to a **local-first security posture**:
|
| 60 |
+
|
| 61 |
+
- `server.bind = 127.0.0.1`
|
| 62 |
+
- sensitive endpoint families are disabled by default
|
| 63 |
+
- `attach` is disabled by default
|
| 64 |
+
- IDPI is enabled with a **local-only website allowlist**
|
| 65 |
+
|
| 66 |
+
> [!CAUTION]
|
| 67 |
+
> By default, IDPI restricts browsing to **locally hosted websites only**.
|
| 68 |
+
> This prevents agents from navigating the public internet until you explicitly allow it.
|
| 69 |
+
> The restriction exists to make the security implications of browser automation clear before enabling wider access.
|
| 70 |
+
|
| 71 |
+
See the full guide: [docs/guides/security.md](docs/guides/security.md)
|
| 72 |
+
|
| 73 |
+
## What can you use it for
|
| 74 |
+
|
| 75 |
+
### Headless navigation
|
| 76 |
+
|
| 77 |
+
With the daemon installed and an agent skill configured, an agent can execute tasks like:
|
| 78 |
+
|
| 79 |
+
```
|
| 80 |
+
"What are the main news about aliens on news.com?"
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
PinchTab exposes browser tools that allow agents to navigate pages, extract structured content, and interact with the DOM without wasting tokens on raw HTML or images.
|
| 84 |
+
|
| 85 |
+
### Headed navigation
|
| 86 |
+
|
| 87 |
+
In addition to headless automation, PinchTab supports headed Chrome profiles.
|
| 88 |
+
|
| 89 |
+
You can create profiles configured with authentication, cookies, extensions, or specific environments. Each profile can have a name and description.
|
| 90 |
+
|
| 91 |
+
For example, an agent request like:
|
| 92 |
+
|
| 93 |
+
```
|
| 94 |
+
"Log into my work profile and download the weekly report"
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
can automatically select the appropriate profile and perform the action.
|
| 98 |
+
|
| 99 |
+
### Local container isolation
|
| 100 |
+
|
| 101 |
+
If you prefer stronger isolation, PinchTab can run inside Docker.
|
| 102 |
+
|
| 103 |
+
This allows agents to control browsers in a sandboxed environment, reducing risk when running automation tasks locally.
|
| 104 |
+
|
| 105 |
+
### Distributed automation
|
| 106 |
+
|
| 107 |
+
PinchTab can manage multiple Chrome instances (headless or headed) across containers or remote machines.
|
| 108 |
+
|
| 109 |
+
Typical use cases include:
|
| 110 |
+
|
| 111 |
+
- QA automation
|
| 112 |
+
- testing environments
|
| 113 |
+
- distributed browsing tasks
|
| 114 |
+
- development tooling
|
| 115 |
+
|
| 116 |
+
You can connect to multiple PinchTab servers, or attach to Chrome instances running in remote debug mode.
|
| 117 |
+
|
| 118 |
+
## Process Model
|
| 119 |
+
|
| 120 |
+
PinchTab is server-first:
|
| 121 |
+
1. install the daemon or run `pinchtab server` for the full control plane
|
| 122 |
+
2. let the server manage profiles and instances
|
| 123 |
+
3. let each managed instance run behind a lightweight `pinchtab bridge` runtime
|
| 124 |
+
|
| 125 |
+
In practice:
|
| 126 |
+
- Server — the main product entry point and control plane
|
| 127 |
+
- Bridge — the runtime that manages a single browser instance
|
| 128 |
+
- Attach — an advanced mode for registering externally managed Chrome instances
|
| 129 |
+
|
| 130 |
+
### Primary Usage
|
| 131 |
+
|
| 132 |
+
The primary user journey is:
|
| 133 |
+
|
| 134 |
+
1. install Pinchtab
|
| 135 |
+
2. install and start the daemon with `pinchtab daemon install`
|
| 136 |
+
3. point your agent or tool at `http://localhost:9867`
|
| 137 |
+
4. let PinchTab act as your local browser service
|
| 138 |
+
|
| 139 |
+
That is the default “replace the browser runtime” scenario.
|
| 140 |
+
Most users should not need to think about `pinchtab bridge` directly, and only need `pinchtab` when they want the local interactive menu.
|
| 141 |
+
|
| 142 |
+
### Key Features
|
| 143 |
+
|
| 144 |
+
- **CLI or Curl** — Control via command-line or HTTP API
|
| 145 |
+
- **Token-efficient** — 800 tokens/page with text extraction (5-13x cheaper than screenshots)
|
| 146 |
+
- **Headless or Headed** — Run without a window or with visible Chrome
|
| 147 |
+
- **Multi-instance** — Run multiple parallel Chrome processes with isolated profiles
|
| 148 |
+
- **Self-contained** — ~15MB binary, no external dependencies
|
| 149 |
+
- **Accessibility-first** — Stable element refs instead of fragile coordinates
|
| 150 |
+
- **ARM64-optimized** — First-class Raspberry Pi support with automatic Chromium detection
|
| 151 |
+
|
| 152 |
+
---
|
| 153 |
+
|
| 154 |
+
## Quick Start
|
| 155 |
+
|
| 156 |
+
### Installation
|
| 157 |
+
|
| 158 |
+
**macOS / Linux:**
|
| 159 |
+
```bash
|
| 160 |
+
curl -fsSL https://pinchtab.com/install.sh | bash
|
| 161 |
+
```
|
| 162 |
+
|
| 163 |
+
**Homebrew (macOS / Linux):**
|
| 164 |
+
```bash
|
| 165 |
+
brew install pinchtab/tap/pinchtab
|
| 166 |
+
```
|
| 167 |
+
|
| 168 |
+
**npm:**
|
| 169 |
+
```bash
|
| 170 |
+
npm install -g pinchtab
|
| 171 |
+
```
|
| 172 |
+
|
| 173 |
+
### Shell Completion
|
| 174 |
+
|
| 175 |
+
Generate and install shell completions after `pinchtab` is on your `PATH`:
|
| 176 |
+
|
| 177 |
+
```bash
|
| 178 |
+
# Generate and install zsh completions
|
| 179 |
+
pinchtab completion zsh > "${fpath[1]}/_pinchtab"
|
| 180 |
+
|
| 181 |
+
# Generate bash completions
|
| 182 |
+
pinchtab completion bash > /etc/bash_completion.d/pinchtab
|
| 183 |
+
|
| 184 |
+
# Generate fish completions
|
| 185 |
+
pinchtab completion fish > ~/.config/fish/completions/pinchtab.fish
|
| 186 |
+
```
|
| 187 |
+
|
| 188 |
+
**Docker:**
|
| 189 |
+
```bash
|
| 190 |
+
docker run -d \
|
| 191 |
+
--name pinchtab \
|
| 192 |
+
-p 127.0.0.1:9867:9867 \
|
| 193 |
+
-v pinchtab-data:/data \
|
| 194 |
+
--shm-size=2g \
|
| 195 |
+
pinchtab/pinchtab
|
| 196 |
+
```
|
| 197 |
+
|
| 198 |
+
The bundled container persists its managed config at `/data/.config/pinchtab/config.json`.
|
| 199 |
+
If you want to supply your own config file instead, mount it and point `PINCHTAB_CONFIG` at it:
|
| 200 |
+
|
| 201 |
+
```bash
|
| 202 |
+
docker run -d \
|
| 203 |
+
--name pinchtab \
|
| 204 |
+
-p 127.0.0.1:9867:9867 \
|
| 205 |
+
-e PINCHTAB_CONFIG=/config/config.json \
|
| 206 |
+
-v "$PWD/config.json:/config/config.json:ro" \
|
| 207 |
+
-v pinchtab-data:/data \
|
| 208 |
+
--shm-size=2g \
|
| 209 |
+
pinchtab/pinchtab
|
| 210 |
+
```
|
| 211 |
+
|
| 212 |
+
### Use It
|
| 213 |
+
|
| 214 |
+
**Terminal 1 — Start the server:**
|
| 215 |
+
```bash
|
| 216 |
+
pinchtab server
|
| 217 |
+
```
|
| 218 |
+
|
| 219 |
+
**Recommended for daily local use — install the daemon once:**
|
| 220 |
+
```bash
|
| 221 |
+
pinchtab daemon install
|
| 222 |
+
pinchtab daemon
|
| 223 |
+
```
|
| 224 |
+
|
| 225 |
+
That keeps PinchTab running in the background so your agent tools can reuse it without an open terminal.
|
| 226 |
+
|
| 227 |
+
**Terminal 2 — Control the browser:**
|
| 228 |
+
```bash
|
| 229 |
+
# Navigate
|
| 230 |
+
pinchtab nav https://pinchtab.com
|
| 231 |
+
|
| 232 |
+
# Get page structure
|
| 233 |
+
pinchtab snap -i -c
|
| 234 |
+
|
| 235 |
+
# Click an element
|
| 236 |
+
pinchtab click e5
|
| 237 |
+
|
| 238 |
+
# Extract text
|
| 239 |
+
pinchtab text
|
| 240 |
+
```
|
| 241 |
+
|
| 242 |
+
Or use the HTTP API directly:
|
| 243 |
+
```bash
|
| 244 |
+
# Create an instance (returns instance id)
|
| 245 |
+
INST=$(curl -s -X POST http://localhost:9867/instances/launch \
|
| 246 |
+
-H "Content-Type: application/json" \
|
| 247 |
+
-d '{"name":"work","mode":"headless"}' | jq -r '.id')
|
| 248 |
+
|
| 249 |
+
# Open a tab in that instance
|
| 250 |
+
TAB=$(curl -s -X POST http://localhost:9867/instances/$INST/tabs/open \
|
| 251 |
+
-H "Content-Type: application/json" \
|
| 252 |
+
-d '{"url":"https://pinchtab.com"}' | jq -r '.tabId')
|
| 253 |
+
|
| 254 |
+
# Get snapshot
|
| 255 |
+
curl "http://localhost:9867/tabs/$TAB/snapshot?filter=interactive"
|
| 256 |
+
|
| 257 |
+
# Click element
|
| 258 |
+
curl -X POST "http://localhost:9867/tabs/$TAB/action" \
|
| 259 |
+
-H "Content-Type: application/json" \
|
| 260 |
+
-d '{"kind":"click","ref":"e5"}'
|
| 261 |
+
```
|
| 262 |
+
|
| 263 |
+
---
|
| 264 |
+
|
| 265 |
+
## Core Concepts
|
| 266 |
+
|
| 267 |
+
**Server** — The main PinchTab process. It manages profiles, instances, routing, and the dashboard.
|
| 268 |
+
|
| 269 |
+
**Instance** — A running Chrome process. Each instance can have one profile.
|
| 270 |
+
|
| 271 |
+
**Profile** — Browser state (cookies, history, local storage). Log in once, stay logged in across restarts.
|
| 272 |
+
|
| 273 |
+
**Tab** — A single webpage. Each instance can have multiple tabs.
|
| 274 |
+
|
| 275 |
+
**Bridge** — The single-instance runtime behind a managed instance. Usually spawned by the server, not started manually.
|
| 276 |
+
|
| 277 |
+
Read more in the [Core Concepts](https://pinchtab.com/docs/core-concepts) guide.
|
| 278 |
+
|
| 279 |
+
---
|
| 280 |
+
|
| 281 |
+
## Why PinchTab?
|
| 282 |
+
|
| 283 |
+
| Aspect | PinchTab |
|
| 284 |
+
|--------|----------|
|
| 285 |
+
| **Tokens performance** | ✅ |
|
| 286 |
+
| **Headless and Headed** | ✅ |
|
| 287 |
+
| **Profile** | ✅ |
|
| 288 |
+
| **Advanced CDP control** | ✅ |
|
| 289 |
+
| **Persistent sessions** | ✅ |
|
| 290 |
+
| **Binary size** | ✅ |
|
| 291 |
+
| **Multi-instance** | ✅ |
|
| 292 |
+
| **External Chrome attach** | ✅ |
|
| 293 |
+
|
| 294 |
+
---
|
| 295 |
+
|
| 296 |
+
## Privacy
|
| 297 |
+
|
| 298 |
+
PinchTab is a fully open-source, local-only tool. No telemetry, no analytics, no outbound connections. The binary binds to `127.0.0.1` by default. Persistent profiles store browser sessions locally on your machine — similar to how a human reuses their browser. The single Go binary (~16 MB) is fully verifiable: build from source at [github.com/pinchtab/pinchtab](https://github.com/pinchtab/pinchtab).
|
| 299 |
+
|
| 300 |
+
---
|
| 301 |
+
|
| 302 |
+
## Documentation
|
| 303 |
+
|
| 304 |
+
Full docs at **[pinchtab.com/docs](https://pinchtab.com/docs)**
|
| 305 |
+
|
| 306 |
+
### MCP (SMCP) integration
|
| 307 |
+
|
| 308 |
+
An **SMCP plugin** in this repo lets AI agents control PinchTab via the [Model Context Protocol](https://github.com/sanctumos/smcp) (SMCP). One plugin exposes 15 tools (e.g. `pinchtab__navigate`, `pinchtab__snapshot`, `pinchtab__action`). No extra runtime deps (stdlib only). See **[plugins/README.md](plugins/README.md)** for setup (env vars and paths).
|
| 309 |
+
|
| 310 |
+
---
|
| 311 |
+
|
| 312 |
+
## Examples
|
| 313 |
+
|
| 314 |
+
### AI Agent Automation
|
| 315 |
+
|
| 316 |
+
```bash
|
| 317 |
+
# Your AI agent can:
|
| 318 |
+
pinchtab nav https://pinchtab.com
|
| 319 |
+
pinchtab snap -i # Get clickable elements
|
| 320 |
+
pinchtab click e5 # Click by ref
|
| 321 |
+
pinchtab fill e3 "user@pinchtab.com" # Fill input
|
| 322 |
+
pinchtab press e7 Enter # Submit form
|
| 323 |
+
```
|
| 324 |
+
|
| 325 |
+
### Data Extraction
|
| 326 |
+
|
| 327 |
+
```bash
|
| 328 |
+
# Extract text (token-efficient)
|
| 329 |
+
pinchtab nav https://pinchtab.com/article
|
| 330 |
+
pinchtab text # ~800 tokens instead of 10,000
|
| 331 |
+
```
|
| 332 |
+
|
| 333 |
+
### Multi-Instance Workflows
|
| 334 |
+
|
| 335 |
+
```bash
|
| 336 |
+
# Run multiple instances in parallel
|
| 337 |
+
curl -s -X POST http://localhost:9867/instances/start \
|
| 338 |
+
-H "Content-Type: application/json" \
|
| 339 |
+
-d '{"profileId":"alice","mode":"headless"}'
|
| 340 |
+
|
| 341 |
+
curl -s -X POST http://localhost:9867/instances/start \
|
| 342 |
+
-H "Content-Type: application/json" \
|
| 343 |
+
-d '{"profileId":"bob","mode":"headless"}'
|
| 344 |
+
|
| 345 |
+
# Each instance is isolated
|
| 346 |
+
curl http://localhost:9867/instances
|
| 347 |
+
```
|
| 348 |
+
|
| 349 |
+
See [chrome-files.md](chrome-files.md) for technical details on how PinchTab manages Chrome user data directories and ensures isolation between parallel instances.
|
| 350 |
+
|
| 351 |
+
---
|
| 352 |
+
|
| 353 |
+
## Development
|
| 354 |
+
|
| 355 |
+
Want to contribute? Start with [CONTRIBUTING.md](CONTRIBUTING.md).
|
| 356 |
+
The full setup and workflow guide lives at [docs/guides/contributing.md](docs/guides/contributing.md).
|
| 357 |
+
|
| 358 |
+
**Quick start:**
|
| 359 |
+
```bash
|
| 360 |
+
git clone https://github.com/pinchtab/pinchtab.git
|
| 361 |
+
cd pinchtab
|
| 362 |
+
./dev doctor # Verifies environment, offers hooks/deps setup
|
| 363 |
+
go build ./cmd/pinchtab # Build pinchtab binary
|
| 364 |
+
```
|
| 365 |
+
|
| 366 |
+
---
|
| 367 |
+
|
| 368 |
+
## License
|
| 369 |
+
|
| 370 |
+
MIT — Free and open source.
|
| 371 |
+
|
| 372 |
+
---
|
| 373 |
+
|
| 374 |
+
**Get started:** [pinchtab.com/docs](https://pinchtab.com/docs)
|
RELEASE.md
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Release Process
|
| 2 |
+
|
| 3 |
+
**⚠️ CRITICAL:** The npm package depends on Go binaries being in the GitHub release. The release pipeline will fail hard if goreleaser doesn't upload binaries — npm users won't get a working binary.
|
| 4 |
+
|
| 5 |
+
Pinchtab uses an automated CI/CD pipeline triggered by Git tags. When you push a tag like `v0.7.0`, GitHub Actions:
|
| 6 |
+
|
| 7 |
+
1. **Builds Go binaries** — darwin-arm64, darwin-x64, linux-x64, linux-arm64, windows-x64
|
| 8 |
+
2. **Creates GitHub release** — with checksums.txt for integrity verification
|
| 9 |
+
3. **Publishes to npm** — TypeScript SDK with auto-download postinstall script
|
| 10 |
+
4. **Builds Docker images** — linux/amd64, linux/arm64
|
| 11 |
+
|
| 12 |
+
## Prerequisites
|
| 13 |
+
|
| 14 |
+
### Secrets (configure once in GitHub)
|
| 15 |
+
|
| 16 |
+
Go to **Settings → Secrets and variables → Actions** and add:
|
| 17 |
+
|
| 18 |
+
- **NPM_TOKEN** — npm authentication token
|
| 19 |
+
- Create at https://npmjs.com/settings/~/tokens
|
| 20 |
+
- Scope: `automation` (publish + read)
|
| 21 |
+
|
| 22 |
+
- **DOCKERHUB_USER** — Docker Hub username (if using Docker Hub)
|
| 23 |
+
- **DOCKERHUB_TOKEN** — Docker Hub personal access token
|
| 24 |
+
|
| 25 |
+
### Local setup
|
| 26 |
+
|
| 27 |
+
```bash
|
| 28 |
+
# 1. Ensure main branch is up to date
|
| 29 |
+
git checkout main && git pull origin main
|
| 30 |
+
|
| 31 |
+
# 2. Merge feature branches
|
| 32 |
+
# (all features should be on main before tagging)
|
| 33 |
+
|
| 34 |
+
# 3. Verify version consistency
|
| 35 |
+
cat package.json | jq .version # npm package
|
| 36 |
+
cat go.mod | grep "module" # Go module
|
| 37 |
+
git describe --tags # latest tag
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
## Pre-Release Checklist
|
| 41 |
+
|
| 42 |
+
Goreleaser is already configured correctly. Verify before tagging:
|
| 43 |
+
|
| 44 |
+
```bash
|
| 45 |
+
# 1. Check builds section (compiles for all platforms)
|
| 46 |
+
grep -A 8 "^builds:" .goreleaser.yml
|
| 47 |
+
# Should output: linux/darwin/windows + amd64/arm64
|
| 48 |
+
|
| 49 |
+
# 2. Check archives use binary format (required for npm)
|
| 50 |
+
grep -A 5 "^archives:" .goreleaser.yml
|
| 51 |
+
# Should show: format: binary (not tar/zip)
|
| 52 |
+
|
| 53 |
+
# 3. Verify checksums enabled
|
| 54 |
+
grep "checksum:" .goreleaser.yml
|
| 55 |
+
# Should output: name_template: checksums.txt
|
| 56 |
+
|
| 57 |
+
# 4. Check release config
|
| 58 |
+
grep -A 2 "^release:" .goreleaser.yml
|
| 59 |
+
# Should output: GitHub owner/repo
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
**Configuration status:**
|
| 63 |
+
- ✅ `builds:` section compiles all platforms (darwin-arm64, darwin-amd64, linux-arm64, linux-amd64, windows-amd64, windows-arm64)
|
| 64 |
+
- ✅ `archives:` outputs binaries directly: `pinchtab-darwin-arm64`, `pinchtab-linux-x64`, etc.
|
| 65 |
+
- ✅ `checksum:` generates `checksums.txt` (used by npm postinstall verification)
|
| 66 |
+
- ✅ `release:` points to GitHub (uploads everything automatically)
|
| 67 |
+
|
| 68 |
+
**Ready to release.** Just tag and push.
|
| 69 |
+
|
| 70 |
+
## Releasing
|
| 71 |
+
|
| 72 |
+
### For patch/minor versions (recommended)
|
| 73 |
+
|
| 74 |
+
```bash
|
| 75 |
+
# 1. Bump version in all places
|
| 76 |
+
npm version patch # or minor, major
|
| 77 |
+
git push origin main
|
| 78 |
+
|
| 79 |
+
# 2. Create tag
|
| 80 |
+
git tag v0.7.1
|
| 81 |
+
git push origin v0.7.1
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
### For manual releases
|
| 85 |
+
|
| 86 |
+
```bash
|
| 87 |
+
# 1. Tag directly
|
| 88 |
+
git tag -a v0.7.1 -m "Release v0.7.1"
|
| 89 |
+
git push origin v0.7.1
|
| 90 |
+
|
| 91 |
+
# 2. Or via GitHub UI: Releases → Create from tag
|
| 92 |
+
# (workflow will auto-create if not present)
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
### Using workflow_dispatch (manual trigger)
|
| 96 |
+
|
| 97 |
+
If you need to re-release an existing tag:
|
| 98 |
+
|
| 99 |
+
1. Go to **Actions → Release**
|
| 100 |
+
2. **Run workflow**
|
| 101 |
+
3. Enter tag (e.g. `v0.7.1`)
|
| 102 |
+
|
| 103 |
+
For a no-side-effects dry run from `main` or any other ref:
|
| 104 |
+
|
| 105 |
+
1. Go to **Actions → Release**
|
| 106 |
+
2. **Run workflow**
|
| 107 |
+
3. Leave `tag` empty
|
| 108 |
+
4. Set `ref` to the branch, tag, or commit you want to test
|
| 109 |
+
5. Set `dry_run` to `true`
|
| 110 |
+
|
| 111 |
+
Dry-run behavior:
|
| 112 |
+
- GoReleaser runs in snapshot mode, so artifacts are built but not published
|
| 113 |
+
- npm runs `npm publish --dry-run`
|
| 114 |
+
- Docker runs a multi-arch `buildx` build with `push=false`
|
| 115 |
+
- ClawHub skill publishing is not part of this workflow and is therefore skipped
|
| 116 |
+
|
| 117 |
+
## Pipeline details
|
| 118 |
+
|
| 119 |
+
### 1. Goreleaser (Go binary) — CRITICAL for npm
|
| 120 |
+
|
| 121 |
+
Triggered on `v*` tag push. Builds binaries and creates GitHub release.
|
| 122 |
+
|
| 123 |
+
**What it does:**
|
| 124 |
+
- ✅ Compiles for all platforms (darwin-arm64, darwin-x64, linux-x64, linux-arm64, windows-x64)
|
| 125 |
+
- ✅ Generates `checksums.txt` (SHA256)
|
| 126 |
+
- ✅ **Uploads binaries to GitHub Releases** ← Required by npm postinstall!
|
| 127 |
+
- Also: Docker images, changelog, etc.
|
| 128 |
+
|
| 129 |
+
**⚠️ CRITICAL:** npm postinstall script downloads binaries from GitHub Releases. If the release doesn't have the binaries (e.g., only Docker images), `npm install pinchtab` will fail silently and the binary won't be available.
|
| 130 |
+
|
| 131 |
+
**Configured in:** `.goreleaser.yml`
|
| 132 |
+
|
| 133 |
+
**Verify release has binaries:**
|
| 134 |
+
```bash
|
| 135 |
+
curl -s https://api.github.com/repos/pinchtab/pinchtab/releases/v0.7.0 | jq '.assets[].name'
|
| 136 |
+
# Should output: pinchtab-darwin-arm64, pinchtab-darwin-x64, etc.
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
### 2. npm publish
|
| 140 |
+
|
| 141 |
+
Depends on: `release` job (waits for goreleaser to finish)
|
| 142 |
+
|
| 143 |
+
**What it does:**
|
| 144 |
+
- Syncs version from tag (v0.7.0 → 0.7.0)
|
| 145 |
+
- Builds TypeScript (`npm run build`)
|
| 146 |
+
- Publishes to npm registry
|
| 147 |
+
- Users get postinstall script that downloads binaries from GitHub Releases
|
| 148 |
+
|
| 149 |
+
**User flow on `npm install pinchtab`:**
|
| 150 |
+
```
|
| 151 |
+
1. npm downloads @pinchtab/cli package
|
| 152 |
+
2. Runs postinstall script
|
| 153 |
+
3. Script detects OS/arch (darwin-arm64, linux-x64, etc.)
|
| 154 |
+
4. Downloads binary from GitHub release
|
| 155 |
+
5. Verifies SHA256 checksum
|
| 156 |
+
6. Stores in ~/.pinchtab/bin/0.7.0/pinchtab-<os>-<arch>
|
| 157 |
+
7. Makes executable
|
| 158 |
+
8. If ANY STEP fails → npm install fails (exit 1)
|
| 159 |
+
```
|
| 160 |
+
|
| 161 |
+
**⚠️ REQUIRES:** Goreleaser must have successfully uploaded binaries to GitHub release. The npm postinstall will verify this and fail hard if binaries are missing.
|
| 162 |
+
|
| 163 |
+
### 3. Docker
|
| 164 |
+
|
| 165 |
+
GitHub Actions builds the release image directly from the tagged source with `docker buildx`.
|
| 166 |
+
The workflow pushes the same multi-arch build to both GHCR and Docker Hub, so Docker no longer depends on GoReleaser's temporary build context.
|
| 167 |
+
|
| 168 |
+
### 4. ClawHub skill
|
| 169 |
+
|
| 170 |
+
The main `Release` workflow publishes the Pinchtab skill to ClawHub only after both npm and Docker complete successfully.
|
| 171 |
+
The standalone `Publish Skill` workflow is manual-only and is intended for retries or one-off recovery publishes.
|
| 172 |
+
|
| 173 |
+
## Troubleshooting
|
| 174 |
+
|
| 175 |
+
### npm publish fails (403)
|
| 176 |
+
|
| 177 |
+
- Check **NPM_TOKEN** is set in secrets
|
| 178 |
+
- Verify token has `automation` scope
|
| 179 |
+
- Check you're not already published (can't overwrite existing version)
|
| 180 |
+
|
| 181 |
+
### Binary checksum mismatch
|
| 182 |
+
|
| 183 |
+
- goreleaser must generate `checksums.txt`
|
| 184 |
+
- Verify `.goreleaser.yml` has `checksum:` section
|
| 185 |
+
- Check GitHub release includes `checksums.txt`
|
| 186 |
+
|
| 187 |
+
### Docker push fails
|
| 188 |
+
|
| 189 |
+
- Verify DOCKERHUB_USER and DOCKERHUB_TOKEN
|
| 190 |
+
- Check token has permission to push
|
| 191 |
+
|
| 192 |
+
## Rolling back
|
| 193 |
+
|
| 194 |
+
If something goes wrong:
|
| 195 |
+
|
| 196 |
+
```bash
|
| 197 |
+
# Delete the tag locally and on GitHub
|
| 198 |
+
git tag -d v0.7.1
|
| 199 |
+
git push origin :refs/tags/v0.7.1
|
| 200 |
+
|
| 201 |
+
# Delete npm version (requires owner permission)
|
| 202 |
+
npm unpublish pinchtab@0.7.1
|
| 203 |
+
|
| 204 |
+
# Revert any commits
|
| 205 |
+
git revert <commit>
|
| 206 |
+
git push origin main
|
| 207 |
+
|
| 208 |
+
# Retag when ready
|
| 209 |
+
git tag v0.7.1
|
| 210 |
+
git push origin v0.7.1
|
| 211 |
+
```
|
| 212 |
+
|
| 213 |
+
## Version strategy
|
| 214 |
+
|
| 215 |
+
- Use **semantic versioning**: v0.7.0 (major.minor.patch)
|
| 216 |
+
- Tag on main branch only
|
| 217 |
+
- One tag = one release (all artifacts)
|
| 218 |
+
- npm version must match Go binary tag
|
| 219 |
+
|
| 220 |
+
## See also
|
| 221 |
+
|
| 222 |
+
- `.github/workflows/release.yml` — GitHub Actions workflow
|
| 223 |
+
- `.goreleaser.yml` — Go binary release config
|
| 224 |
+
- `npm/package.json` — npm package metadata
|
SECURITY.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Security Policy
|
| 2 |
+
|
| 3 |
+
## Supported Versions
|
| 4 |
+
|
| 5 |
+
We currently support the following versions of Pinchtab with security updates:
|
| 6 |
+
|
| 7 |
+
| Version | Supported |
|
| 8 |
+
| ------- | ------------------ |
|
| 9 |
+
| v0.7.x | :white_check_mark: |
|
| 10 |
+
| v0.6.x | :white_check_mark: |
|
| 11 |
+
| < v0.6 | :x: |
|
| 12 |
+
|
| 13 |
+
## Reporting a Vulnerability
|
| 14 |
+
|
| 15 |
+
We take the security of our browser automation bridge seriously. If you believe you have found a security vulnerability, please do not report it via a public GitHub issue.
|
| 16 |
+
|
| 17 |
+
Instead, please report vulnerabilities privately by:
|
| 18 |
+
|
| 19 |
+
1. Opening a **Private Vulnerability Report** on GitHub (if available for this repo).
|
| 20 |
+
2. Or emailing the maintainer directly at [INSERT EMAIL ADDRESS].
|
| 21 |
+
|
| 22 |
+
Please include:
|
| 23 |
+
- A description of the vulnerability.
|
| 24 |
+
- Steps to reproduce (proof of concept).
|
| 25 |
+
- Potential impact.
|
| 26 |
+
|
| 27 |
+
We will acknowledge receipt of your report within 48 hours and provide a timeline for a fix if necessary.
|
TESTING.md
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Testing
|
| 2 |
+
|
| 3 |
+
## Quick Start with dev
|
| 4 |
+
|
| 5 |
+
The `dev` developer toolkit is the easiest way to run checks and tests:
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
./dev # Interactive picker
|
| 9 |
+
./dev test # All tests (unit + E2E)
|
| 10 |
+
./dev test unit # Unit tests only
|
| 11 |
+
./dev e2e # E2E tests (both curl and CLI)
|
| 12 |
+
./dev e2e orchestrator # Orchestrator-heavy E2E tests only
|
| 13 |
+
./dev e2e curl # E2E curl tests only
|
| 14 |
+
./dev e2e cli # E2E CLI tests only
|
| 15 |
+
./dev check # All checks (format, vet, build, lint)
|
| 16 |
+
./dev check go # Go checks only
|
| 17 |
+
./dev check security # Gosec security scan
|
| 18 |
+
./dev format dashboard # Run Prettier on dashboard sources
|
| 19 |
+
./dev doctor # Setup dev environment
|
| 20 |
+
```
|
| 21 |
+
|
| 22 |
+
## Unit Tests
|
| 23 |
+
|
| 24 |
+
```bash
|
| 25 |
+
go test ./...
|
| 26 |
+
# or
|
| 27 |
+
./dev test unit
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
Unit tests are standard Go tests that validate individual packages and functions without launching a full server.
|
| 31 |
+
|
| 32 |
+
## E2E Tests
|
| 33 |
+
|
| 34 |
+
End-to-end tests launch a real pinchtab server with Chrome and run e2e-level tests against it.
|
| 35 |
+
|
| 36 |
+
### Curl Tests (HTTP API)
|
| 37 |
+
|
| 38 |
+
```bash
|
| 39 |
+
./dev e2e curl
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
Runs 183 HTTP-level tests using curl against the server. Tests the REST API, navigation, snapshots, and other HTTP endpoints.
|
| 43 |
+
|
| 44 |
+
### CLI Tests
|
| 45 |
+
|
| 46 |
+
```bash
|
| 47 |
+
./dev e2e cli
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
Runs CLI e2e tests. Tests the command-line interface directly.
|
| 51 |
+
|
| 52 |
+
### Both E2E Test Suites
|
| 53 |
+
|
| 54 |
+
```bash
|
| 55 |
+
./dev e2e
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
Runs all E2E tests (curl + CLI, 224 tests total).
|
| 59 |
+
|
| 60 |
+
### Orchestrator E2E Suite
|
| 61 |
+
|
| 62 |
+
```bash
|
| 63 |
+
./dev e2e orchestrator
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
Runs the orchestration-focused curl scenarios, including multi-instance flows and remote bridge attachment against the dedicated `pinchtab-bridge` Compose service.
|
| 67 |
+
|
| 68 |
+
## Environment Variables
|
| 69 |
+
|
| 70 |
+
| Variable | Default | Description |
|
| 71 |
+
|---|---|---|
|
| 72 |
+
| `CI` | _(unset)_ | Set to `true` for longer health check timeouts (60s vs 30s) |
|
| 73 |
+
|
| 74 |
+
### Temp Directory Layout
|
| 75 |
+
|
| 76 |
+
Each E2E test run creates a single temp directory under `/tmp/pinchtab-test-*/`:
|
| 77 |
+
|
| 78 |
+
```
|
| 79 |
+
/tmp/pinchtab-test-123456789/
|
| 80 |
+
├── pinchtab # Compiled test binary
|
| 81 |
+
├── state/ # Dashboard state (profiles, instances)
|
| 82 |
+
└── profiles/ # Chrome user-data directories
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
Everything is cleaned up automatically when tests finish.
|
| 86 |
+
|
| 87 |
+
## Test File Structure
|
| 88 |
+
|
| 89 |
+
E2E tests are organized in two directories:
|
| 90 |
+
|
| 91 |
+
- **`tests/e2e/scenarios/*.sh`** — HTTP curl-based tests (183 tests)
|
| 92 |
+
- Test the REST API directly
|
| 93 |
+
- Use Docker Compose: `tests/e2e/docker-compose.yml`
|
| 94 |
+
|
| 95 |
+
- **`tests/e2e/scenarios-orchestrator/*.sh`** — orchestration-heavy curl tests
|
| 96 |
+
- Test multi-instance flows and remote bridge attachment
|
| 97 |
+
- Use Docker Compose: `tests/e2e/docker-compose-orchestrator.yml`
|
| 98 |
+
|
| 99 |
+
- **`tests/e2e/scenarios-cli/*.sh`** — CLI e2e tests (41 tests)
|
| 100 |
+
- Test the command-line interface
|
| 101 |
+
- Use Docker Compose: `tests/e2e/docker-compose.cli.yml`
|
| 102 |
+
|
| 103 |
+
Each test is a standalone bash script that:
|
| 104 |
+
1. Starts the test server (or uses existing)
|
| 105 |
+
2. Runs curl or CLI commands
|
| 106 |
+
3. Asserts expected output or exit codes
|
| 107 |
+
4. Cleans up
|
| 108 |
+
|
| 109 |
+
## Writing New E2E Tests
|
| 110 |
+
|
| 111 |
+
Create a new bash script in `tests/e2e/scenarios/` (for curl tests) or `tests/e2e/scenarios-cli/` (for CLI tests):
|
| 112 |
+
|
| 113 |
+
### Example: Simple Curl Test
|
| 114 |
+
|
| 115 |
+
```bash
|
| 116 |
+
#!/bin/bash
|
| 117 |
+
|
| 118 |
+
# tests/e2e/scenarios/test-my-feature.sh
|
| 119 |
+
|
| 120 |
+
set -e # Exit on error
|
| 121 |
+
|
| 122 |
+
# Source helpers
|
| 123 |
+
. "$(dirname "$0")/../helpers.sh"
|
| 124 |
+
|
| 125 |
+
# Test setup
|
| 126 |
+
SERVER_URL="http://localhost:9867"
|
| 127 |
+
|
| 128 |
+
# Start server if needed
|
| 129 |
+
start_test_server
|
| 130 |
+
|
| 131 |
+
# Run test
|
| 132 |
+
echo "Testing my feature..."
|
| 133 |
+
RESPONSE=$(curl -s "$SERVER_URL/health")
|
| 134 |
+
|
| 135 |
+
if [ "$(echo "$RESPONSE" | jq -r '.status')" != "ok" ]; then
|
| 136 |
+
echo "❌ Health check failed"
|
| 137 |
+
exit 1
|
| 138 |
+
fi
|
| 139 |
+
|
| 140 |
+
echo "✅ Test passed"
|
| 141 |
+
```
|
| 142 |
+
|
| 143 |
+
### Example: CLI Test
|
| 144 |
+
|
| 145 |
+
```bash
|
| 146 |
+
#!/bin/bash
|
| 147 |
+
|
| 148 |
+
# tests/e2e/scenarios-cli/test-my-cli.sh
|
| 149 |
+
|
| 150 |
+
set -e
|
| 151 |
+
|
| 152 |
+
# Source helpers
|
| 153 |
+
. "$(dirname "$0")/../helpers.sh"
|
| 154 |
+
|
| 155 |
+
# Test the CLI
|
| 156 |
+
echo "Testing pinchtab CLI..."
|
| 157 |
+
OUTPUT=$($PINCHTAB_BIN --version)
|
| 158 |
+
|
| 159 |
+
if [[ ! "$OUTPUT" =~ pinchtab ]]; then
|
| 160 |
+
echo "❌ Version output incorrect"
|
| 161 |
+
exit 1
|
| 162 |
+
fi
|
| 163 |
+
|
| 164 |
+
echo "✅ CLI test passed"
|
| 165 |
+
```
|
| 166 |
+
|
| 167 |
+
## Coverage
|
| 168 |
+
|
| 169 |
+
Generate coverage for unit tests:
|
| 170 |
+
|
| 171 |
+
```bash
|
| 172 |
+
go test ./... -coverprofile=coverage.out
|
| 173 |
+
go tool cover -html=coverage.out
|
| 174 |
+
```
|
| 175 |
+
|
| 176 |
+
Note: E2E tests are black-box tests and don't contribute to code coverage metrics directly.
|
THIRD_PARTY_LICENSES.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Third-Party Licenses
|
| 2 |
+
|
| 3 |
+
Pinchtab depends on the following open-source packages. All are compatible with MIT licensing.
|
| 4 |
+
|
| 5 |
+
## Direct Dependencies
|
| 6 |
+
|
| 7 |
+
### chromedp/chromedp
|
| 8 |
+
- **License:** MIT
|
| 9 |
+
- **Copyright:** (c) 2016-2025 Kenneth Shaw
|
| 10 |
+
- **URL:** https://github.com/chromedp/chromedp
|
| 11 |
+
- **Purpose:** Chrome DevTools Protocol driver — launches and controls Chrome
|
| 12 |
+
|
| 13 |
+
### chromedp/cdproto
|
| 14 |
+
- **License:** MIT
|
| 15 |
+
- **Copyright:** (c) 2016-2025 Kenneth Shaw
|
| 16 |
+
- **URL:** https://github.com/chromedp/cdproto
|
| 17 |
+
- **Purpose:** Generated Go types for the Chrome DevTools Protocol
|
| 18 |
+
|
| 19 |
+
## Transitive Dependencies
|
| 20 |
+
|
| 21 |
+
### chromedp/sysutil
|
| 22 |
+
- **License:** MIT
|
| 23 |
+
- **Copyright:** (c) 2016-2017 Kenneth Shaw
|
| 24 |
+
- **URL:** https://github.com/chromedp/sysutil
|
| 25 |
+
- **Purpose:** System utilities for chromedp (finding Chrome binary)
|
| 26 |
+
|
| 27 |
+
### go-json-experiment/json
|
| 28 |
+
- **License:** BSD 3-Clause
|
| 29 |
+
- **Copyright:** (c) 2020 The Go Authors
|
| 30 |
+
- **URL:** https://github.com/go-json-experiment/json
|
| 31 |
+
- **Purpose:** Experimental JSON library used by cdproto
|
| 32 |
+
|
| 33 |
+
### gobwas/ws
|
| 34 |
+
- **License:** MIT
|
| 35 |
+
- **Copyright:** (c) 2017-2021 Sergey Kamardin
|
| 36 |
+
- **URL:** https://github.com/gobwas/ws
|
| 37 |
+
- **Purpose:** WebSocket implementation for CDP communication
|
| 38 |
+
|
| 39 |
+
### gobwas/httphead
|
| 40 |
+
- **License:** MIT
|
| 41 |
+
- **Copyright:** (c) 2017 Sergey Kamardin
|
| 42 |
+
- **URL:** https://github.com/gobwas/httphead
|
| 43 |
+
- **Purpose:** HTTP header parsing (ws dependency)
|
| 44 |
+
|
| 45 |
+
### gobwas/pool
|
| 46 |
+
- **License:** MIT
|
| 47 |
+
- **Copyright:** (c) 2017-2019 Sergey Kamardin
|
| 48 |
+
- **URL:** https://github.com/gobwas/pool
|
| 49 |
+
- **Purpose:** Pool utilities (ws dependency)
|
| 50 |
+
|
| 51 |
+
### golang.org/x/sys
|
| 52 |
+
- **License:** BSD 3-Clause
|
| 53 |
+
- **Copyright:** (c) 2009 The Go Authors
|
| 54 |
+
- **URL:** https://github.com/golang/sys
|
| 55 |
+
- **Purpose:** Go system call wrappers
|
| 56 |
+
|
| 57 |
+
### github.com/ledongthuc/pdf
|
| 58 |
+
- **License:** BSD 3-Clause
|
| 59 |
+
- **Copyright:** (c) 2009 The Go Authors
|
| 60 |
+
- **URL:** https://github.com/ledongthuc/pdf
|
| 61 |
+
- **Purpose:** Transitive dependency in module graph (test tooling chain)
|
| 62 |
+
|
| 63 |
+
### github.com/orisano/pixelmatch
|
| 64 |
+
- **License:** MIT
|
| 65 |
+
- **Copyright:** (c) 2022 orisano
|
| 66 |
+
- **URL:** https://github.com/orisano/pixelmatch
|
| 67 |
+
- **Purpose:** Transitive dependency in module graph (test tooling chain)
|
| 68 |
+
|
| 69 |
+
### gopkg.in/check.v1
|
| 70 |
+
- **License:** BSD-style
|
| 71 |
+
- **Copyright:** (c) 2010-2013 Gustavo Niemeyer
|
| 72 |
+
- **URL:** https://gopkg.in/check.v1
|
| 73 |
+
- **Purpose:** Transitive dependency in module graph (test tooling chain)
|
| 74 |
+
|
| 75 |
+
### gopkg.in/yaml.v3
|
| 76 |
+
- **License:** Apache 2.0 / MIT
|
| 77 |
+
- **Copyright:** (c) 2006-2011 Kirill Simonov, (c) 2011-2019 Canonical Ltd
|
| 78 |
+
- **URL:** https://github.com/go-yaml/yaml
|
| 79 |
+
- **Purpose:** YAML output format for snapshots
|
| 80 |
+
|
| 81 |
+
## Summary
|
| 82 |
+
|
| 83 |
+
| Package | License | Compatible |
|
| 84 |
+
|---------|---------|------------|
|
| 85 |
+
| chromedp/chromedp | MIT | ✅ |
|
| 86 |
+
| chromedp/cdproto | MIT | ✅ |
|
| 87 |
+
| chromedp/sysutil | MIT | ✅ |
|
| 88 |
+
| go-json-experiment/json | BSD 3-Clause | ✅ |
|
| 89 |
+
| gobwas/ws | MIT | ✅ |
|
| 90 |
+
| gobwas/httphead | MIT | ✅ |
|
| 91 |
+
| gobwas/pool | MIT | ✅ |
|
| 92 |
+
| golang.org/x/sys | BSD 3-Clause | ✅ |
|
| 93 |
+
| github.com/ledongthuc/pdf | BSD 3-Clause | ✅ |
|
| 94 |
+
| github.com/orisano/pixelmatch | MIT | ✅ |
|
| 95 |
+
| gopkg.in/check.v1 | BSD-style | ✅ |
|
| 96 |
+
| gopkg.in/yaml.v3 | Apache 2.0 / MIT | ✅ |
|
| 97 |
+
|
| 98 |
+
All dependencies are MIT, BSD-style, or Apache 2.0 licensed, compatible with Pinchtab's MIT license.
|
assets/docs-no-background-256.png
ADDED
|
assets/favicon.png
ADDED
|
|
assets/pinchtab-headless.png
ADDED
|
Git LFS Details
|
cmd/pinchtab/build_test.go
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package main
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"os"
|
| 5 |
+
"path/filepath"
|
| 6 |
+
"testing"
|
| 7 |
+
|
| 8 |
+
"gopkg.in/yaml.v3"
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
// GoReleaserConfig represents the minimal goreleaser config we care about
|
| 12 |
+
type GoReleaserConfig struct {
|
| 13 |
+
Builds []struct {
|
| 14 |
+
GOOS []string `yaml:"goos"`
|
| 15 |
+
GOARCH []string `yaml:"goarch"`
|
| 16 |
+
} `yaml:"builds"`
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
// TestBinaryPermutations verifies all expected binary permutations are configured in goreleaser
|
| 20 |
+
func TestBinaryPermutations(t *testing.T) {
|
| 21 |
+
// Find .goreleaser.yml in repo root (2 levels up from cmd/pinchtab/)
|
| 22 |
+
repoRoot := filepath.Join("..", "..", ".goreleaser.yml")
|
| 23 |
+
data, err := os.ReadFile(repoRoot)
|
| 24 |
+
if err != nil {
|
| 25 |
+
t.Fatalf("failed to read .goreleaser.yml at %s: %v", repoRoot, err)
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
var cfg GoReleaserConfig
|
| 29 |
+
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
| 30 |
+
t.Fatalf("failed to parse .goreleaser.yml: %v", err)
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
if len(cfg.Builds) == 0 {
|
| 34 |
+
t.Fatal("no builds configured in .goreleaser.yml")
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
build := cfg.Builds[0]
|
| 38 |
+
|
| 39 |
+
// Expected OS/arch combinations
|
| 40 |
+
expectedOS := map[string]bool{
|
| 41 |
+
"linux": true,
|
| 42 |
+
"darwin": true,
|
| 43 |
+
"windows": true,
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
expectedArch := map[string]bool{
|
| 47 |
+
"amd64": true,
|
| 48 |
+
"arm64": true,
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
// Verify all expected OS are configured
|
| 52 |
+
for os := range expectedOS {
|
| 53 |
+
found := false
|
| 54 |
+
for _, configOS := range build.GOOS {
|
| 55 |
+
if configOS == os {
|
| 56 |
+
found = true
|
| 57 |
+
break
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
if !found {
|
| 61 |
+
t.Errorf("OS %q not found in goreleaser config", os)
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
// Verify all expected architectures are configured
|
| 66 |
+
for arch := range expectedArch {
|
| 67 |
+
found := false
|
| 68 |
+
for _, configArch := range build.GOARCH {
|
| 69 |
+
if configArch == arch {
|
| 70 |
+
found = true
|
| 71 |
+
break
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
if !found {
|
| 75 |
+
t.Errorf("Architecture %q not found in goreleaser config", arch)
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
// Calculate expected binary count
|
| 80 |
+
totalExpected := len(expectedOS) * len(expectedArch)
|
| 81 |
+
totalConfigured := len(build.GOOS) * len(build.GOARCH)
|
| 82 |
+
|
| 83 |
+
if totalConfigured != totalExpected {
|
| 84 |
+
t.Errorf("expected %d binaries (3 OS × 2 arch), but config produces %d",
|
| 85 |
+
totalExpected, totalConfigured)
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
t.Logf("✓ Binary matrix verified: %d OS × %d arch = %d total binaries",
|
| 89 |
+
len(build.GOOS), len(build.GOARCH), totalConfigured)
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
// TestExpectedBinaryNames verifies correct naming for all permutations
|
| 93 |
+
func TestExpectedBinaryNames(t *testing.T) {
|
| 94 |
+
expectedBinaries := []string{
|
| 95 |
+
"pinchtab-linux-amd64",
|
| 96 |
+
"pinchtab-linux-arm64",
|
| 97 |
+
"pinchtab-darwin-amd64",
|
| 98 |
+
"pinchtab-darwin-arm64",
|
| 99 |
+
"pinchtab-windows-amd64.exe",
|
| 100 |
+
"pinchtab-windows-arm64.exe",
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
if len(expectedBinaries) != 6 {
|
| 104 |
+
t.Errorf("expected 6 binaries, got %d", len(expectedBinaries))
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
t.Logf("Expected binary names (%d total):", len(expectedBinaries))
|
| 108 |
+
for _, bin := range expectedBinaries {
|
| 109 |
+
t.Logf(" ✓ %s", bin)
|
| 110 |
+
}
|
| 111 |
+
}
|
cmd/pinchtab/cmd_bridge.go
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package main
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"fmt"
|
| 5 |
+
"strings"
|
| 6 |
+
|
| 7 |
+
"github.com/pinchtab/pinchtab/internal/config"
|
| 8 |
+
"github.com/pinchtab/pinchtab/internal/server"
|
| 9 |
+
"github.com/spf13/cobra"
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
var bridgeEngine string
|
| 13 |
+
|
| 14 |
+
var bridgeCmd = &cobra.Command{
|
| 15 |
+
Use: "bridge",
|
| 16 |
+
Short: "Start single-instance bridge-only server",
|
| 17 |
+
RunE: func(cmd *cobra.Command, args []string) error {
|
| 18 |
+
cfg := config.Load()
|
| 19 |
+
engineMode, err := resolveBridgeEngine(bridgeEngine, cfg.Engine)
|
| 20 |
+
if err != nil {
|
| 21 |
+
return err
|
| 22 |
+
}
|
| 23 |
+
cfg.Engine = engineMode
|
| 24 |
+
server.RunBridgeServer(cfg)
|
| 25 |
+
return nil
|
| 26 |
+
},
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
func resolveBridgeEngine(flagValue, configValue string) (string, error) {
|
| 30 |
+
engineMode := strings.ToLower(strings.TrimSpace(configValue))
|
| 31 |
+
if strings.TrimSpace(flagValue) != "" {
|
| 32 |
+
engineMode = strings.ToLower(strings.TrimSpace(flagValue))
|
| 33 |
+
}
|
| 34 |
+
if engineMode == "" {
|
| 35 |
+
engineMode = "chrome"
|
| 36 |
+
}
|
| 37 |
+
if engineMode != "chrome" && engineMode != "lite" && engineMode != "auto" {
|
| 38 |
+
return "", fmt.Errorf("invalid --engine %q (expected chrome, lite, or auto)", engineMode)
|
| 39 |
+
}
|
| 40 |
+
return engineMode, nil
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
func init() {
|
| 44 |
+
bridgeCmd.GroupID = "primary"
|
| 45 |
+
bridgeCmd.Flags().StringVar(&bridgeEngine, "engine", "", "Bridge engine: chrome, lite, or auto (overrides config)")
|
| 46 |
+
rootCmd.AddCommand(bridgeCmd)
|
| 47 |
+
}
|
cmd/pinchtab/cmd_bridge_test.go
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package main
|
| 2 |
+
|
| 3 |
+
import "testing"
|
| 4 |
+
|
| 5 |
+
func TestResolveBridgeEngine(t *testing.T) {
|
| 6 |
+
tests := []struct {
|
| 7 |
+
name string
|
| 8 |
+
flagValue string
|
| 9 |
+
cfgValue string
|
| 10 |
+
want string
|
| 11 |
+
wantErr bool
|
| 12 |
+
}{
|
| 13 |
+
{name: "config default", cfgValue: "lite", want: "lite"},
|
| 14 |
+
{name: "flag overrides config", flagValue: "auto", cfgValue: "chrome", want: "auto"},
|
| 15 |
+
{name: "empty falls back to chrome", want: "chrome"},
|
| 16 |
+
{name: "invalid", flagValue: "bogus", wantErr: true},
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
for _, tt := range tests {
|
| 20 |
+
t.Run(tt.name, func(t *testing.T) {
|
| 21 |
+
got, err := resolveBridgeEngine(tt.flagValue, tt.cfgValue)
|
| 22 |
+
if tt.wantErr {
|
| 23 |
+
if err == nil {
|
| 24 |
+
t.Fatal("expected error")
|
| 25 |
+
}
|
| 26 |
+
return
|
| 27 |
+
}
|
| 28 |
+
if err != nil {
|
| 29 |
+
t.Fatalf("unexpected error: %v", err)
|
| 30 |
+
}
|
| 31 |
+
if got != tt.want {
|
| 32 |
+
t.Fatalf("got %q want %q", got, tt.want)
|
| 33 |
+
}
|
| 34 |
+
})
|
| 35 |
+
}
|
| 36 |
+
}
|
cmd/pinchtab/cmd_cli.go
ADDED
|
@@ -0,0 +1,593 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package main
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"fmt"
|
| 5 |
+
"net/http"
|
| 6 |
+
"os"
|
| 7 |
+
"strings"
|
| 8 |
+
"time"
|
| 9 |
+
|
| 10 |
+
browseractions "github.com/pinchtab/pinchtab/internal/cli/actions"
|
| 11 |
+
"github.com/pinchtab/pinchtab/internal/config"
|
| 12 |
+
"github.com/pinchtab/pinchtab/internal/urlutil"
|
| 13 |
+
"github.com/spf13/cobra"
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
var quickCmd = &cobra.Command{
|
| 17 |
+
Use: "quick <url>",
|
| 18 |
+
Short: "Navigate + analyze page",
|
| 19 |
+
Args: cobra.ExactArgs(1),
|
| 20 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 21 |
+
args[0] = urlutil.Normalize(args[0])
|
| 22 |
+
cfg := config.Load()
|
| 23 |
+
runCLIWith(cfg, func(client *http.Client, base, token string) {
|
| 24 |
+
browseractions.Quick(client, base, token, args)
|
| 25 |
+
})
|
| 26 |
+
},
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
var navCmd = &cobra.Command{
|
| 30 |
+
Use: "nav <url>",
|
| 31 |
+
Aliases: []string{"goto", "navigate", "open"},
|
| 32 |
+
Short: "Navigate to URL",
|
| 33 |
+
Args: cobra.ExactArgs(1),
|
| 34 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 35 |
+
url := urlutil.Normalize(args[0])
|
| 36 |
+
cfg := config.Load()
|
| 37 |
+
runCLIWith(cfg, func(client *http.Client, base, token string) {
|
| 38 |
+
browseractions.Navigate(client, base, token, url, cmd)
|
| 39 |
+
})
|
| 40 |
+
},
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
var backCmd = &cobra.Command{
|
| 44 |
+
Use: "back",
|
| 45 |
+
Short: "Go back in browser history",
|
| 46 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 47 |
+
cfg := config.Load()
|
| 48 |
+
runCLIWith(cfg, func(client *http.Client, base, token string) {
|
| 49 |
+
browseractions.Back(client, base, token, cmd)
|
| 50 |
+
})
|
| 51 |
+
},
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
var forwardCmd = &cobra.Command{
|
| 55 |
+
Use: "forward",
|
| 56 |
+
Short: "Go forward in browser history",
|
| 57 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 58 |
+
cfg := config.Load()
|
| 59 |
+
runCLIWith(cfg, func(client *http.Client, base, token string) {
|
| 60 |
+
browseractions.Forward(client, base, token, cmd)
|
| 61 |
+
})
|
| 62 |
+
},
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
var reloadCmd = &cobra.Command{
|
| 66 |
+
Use: "reload",
|
| 67 |
+
Short: "Reload current page",
|
| 68 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 69 |
+
cfg := config.Load()
|
| 70 |
+
runCLIWith(cfg, func(client *http.Client, base, token string) {
|
| 71 |
+
browseractions.Reload(client, base, token, cmd)
|
| 72 |
+
})
|
| 73 |
+
},
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
var snapCmd = &cobra.Command{
|
| 77 |
+
Use: "snap",
|
| 78 |
+
Short: "Snapshot accessibility tree",
|
| 79 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 80 |
+
cfg := config.Load()
|
| 81 |
+
runCLIWith(cfg, func(client *http.Client, base, token string) {
|
| 82 |
+
browseractions.Snapshot(client, base, token, cmd)
|
| 83 |
+
})
|
| 84 |
+
},
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
var clickCmd = &cobra.Command{
|
| 88 |
+
Use: "click <ref>",
|
| 89 |
+
Short: "Click element",
|
| 90 |
+
Args: cobra.MaximumNArgs(1),
|
| 91 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 92 |
+
ref := ""
|
| 93 |
+
if len(args) > 0 {
|
| 94 |
+
ref = args[0]
|
| 95 |
+
}
|
| 96 |
+
cfg := config.Load()
|
| 97 |
+
runCLIWith(cfg, func(client *http.Client, base, token string) {
|
| 98 |
+
browseractions.Action(client, base, token, "click", ref, cmd)
|
| 99 |
+
})
|
| 100 |
+
},
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
var dblclickCmd = &cobra.Command{
|
| 104 |
+
Use: "dblclick <ref>",
|
| 105 |
+
Short: "Double-click element",
|
| 106 |
+
Args: cobra.MaximumNArgs(1),
|
| 107 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 108 |
+
ref := ""
|
| 109 |
+
if len(args) > 0 {
|
| 110 |
+
ref = args[0]
|
| 111 |
+
}
|
| 112 |
+
cfg := config.Load()
|
| 113 |
+
runCLIWith(cfg, func(client *http.Client, base, token string) {
|
| 114 |
+
browseractions.Action(client, base, token, "dblclick", ref, cmd)
|
| 115 |
+
})
|
| 116 |
+
},
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
var typeCmd = &cobra.Command{
|
| 120 |
+
Use: "type <ref> <text>",
|
| 121 |
+
Short: "Type into element",
|
| 122 |
+
Args: cobra.MinimumNArgs(2),
|
| 123 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 124 |
+
cfg := config.Load()
|
| 125 |
+
runCLIWith(cfg, func(client *http.Client, base, token string) {
|
| 126 |
+
browseractions.ActionSimple(client, base, token, "type", args, cmd)
|
| 127 |
+
})
|
| 128 |
+
},
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
var screenshotCmd = &cobra.Command{
|
| 132 |
+
Use: "screenshot",
|
| 133 |
+
Short: "Take a screenshot",
|
| 134 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 135 |
+
cfg := config.Load()
|
| 136 |
+
runCLIWith(cfg, func(client *http.Client, base, token string) {
|
| 137 |
+
browseractions.Screenshot(client, base, token, cmd)
|
| 138 |
+
})
|
| 139 |
+
},
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
var tabsCmd = &cobra.Command{
|
| 143 |
+
Use: "tab [id]",
|
| 144 |
+
Aliases: []string{"tabs"},
|
| 145 |
+
Short: "List tabs, or focus a tab by ID",
|
| 146 |
+
Args: cobra.MaximumNArgs(1),
|
| 147 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 148 |
+
cfg := config.Load()
|
| 149 |
+
runCLIWith(cfg, func(client *http.Client, base, token string) {
|
| 150 |
+
if len(args) == 0 {
|
| 151 |
+
browseractions.TabList(client, base, token)
|
| 152 |
+
} else {
|
| 153 |
+
browseractions.TabFocus(client, base, token, args[0])
|
| 154 |
+
}
|
| 155 |
+
})
|
| 156 |
+
},
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
var instancesCmd = &cobra.Command{
|
| 160 |
+
Use: "instances",
|
| 161 |
+
Short: "List or manage instances",
|
| 162 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 163 |
+
cfg := config.Load()
|
| 164 |
+
runCLIWith(cfg, func(client *http.Client, base, token string) {
|
| 165 |
+
browseractions.Instances(client, base, token)
|
| 166 |
+
})
|
| 167 |
+
},
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
var healthCmd = &cobra.Command{
|
| 171 |
+
Use: "health",
|
| 172 |
+
Short: "Check server health",
|
| 173 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 174 |
+
cfg := config.Load()
|
| 175 |
+
runCLIWith(cfg, func(client *http.Client, base, token string) {
|
| 176 |
+
browseractions.Health(client, base, token)
|
| 177 |
+
})
|
| 178 |
+
},
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
var pressCmd = &cobra.Command{
|
| 182 |
+
Use: "press <key>",
|
| 183 |
+
Short: "Press key (Enter, Tab, Escape...)",
|
| 184 |
+
Args: cobra.MinimumNArgs(1),
|
| 185 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 186 |
+
cfg := config.Load()
|
| 187 |
+
runCLIWith(cfg, func(client *http.Client, base, token string) {
|
| 188 |
+
browseractions.ActionSimple(client, base, token, "press", args, cmd)
|
| 189 |
+
})
|
| 190 |
+
},
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
var fillCmd = &cobra.Command{
|
| 194 |
+
Use: "fill <ref|selector> <text>",
|
| 195 |
+
Short: "Fill input directly",
|
| 196 |
+
Args: cobra.MinimumNArgs(2),
|
| 197 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 198 |
+
cfg := config.Load()
|
| 199 |
+
runCLIWith(cfg, func(client *http.Client, base, token string) {
|
| 200 |
+
browseractions.ActionSimple(client, base, token, "fill", args, cmd)
|
| 201 |
+
})
|
| 202 |
+
},
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
var hoverCmd = &cobra.Command{
|
| 206 |
+
Use: "hover <ref>",
|
| 207 |
+
Short: "Hover element",
|
| 208 |
+
Args: cobra.MaximumNArgs(1),
|
| 209 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 210 |
+
ref := ""
|
| 211 |
+
if len(args) > 0 {
|
| 212 |
+
ref = args[0]
|
| 213 |
+
}
|
| 214 |
+
cfg := config.Load()
|
| 215 |
+
runCLIWith(cfg, func(client *http.Client, base, token string) {
|
| 216 |
+
browseractions.Action(client, base, token, "hover", ref, cmd)
|
| 217 |
+
})
|
| 218 |
+
},
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
var scrollCmd = &cobra.Command{
|
| 222 |
+
Use: "scroll <ref|pixels>",
|
| 223 |
+
Short: "Scroll to element or by pixels",
|
| 224 |
+
Args: cobra.MinimumNArgs(1),
|
| 225 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 226 |
+
cfg := config.Load()
|
| 227 |
+
runCLIWith(cfg, func(client *http.Client, base, token string) {
|
| 228 |
+
browseractions.ActionSimple(client, base, token, "scroll", args, cmd)
|
| 229 |
+
})
|
| 230 |
+
},
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
var evalCmd = &cobra.Command{
|
| 234 |
+
Use: "eval <expression>",
|
| 235 |
+
Short: "Evaluate JavaScript",
|
| 236 |
+
Args: cobra.MinimumNArgs(1),
|
| 237 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 238 |
+
cfg := config.Load()
|
| 239 |
+
runCLIWith(cfg, func(client *http.Client, base, token string) {
|
| 240 |
+
browseractions.Evaluate(client, base, token, args, cmd)
|
| 241 |
+
})
|
| 242 |
+
},
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
var pdfCmd = &cobra.Command{
|
| 246 |
+
Use: "pdf",
|
| 247 |
+
Short: "Export the current page as PDF",
|
| 248 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 249 |
+
cfg := config.Load()
|
| 250 |
+
runCLIWith(cfg, func(client *http.Client, base, token string) {
|
| 251 |
+
browseractions.PDF(client, base, token, cmd)
|
| 252 |
+
})
|
| 253 |
+
},
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
var textCmd = &cobra.Command{
|
| 257 |
+
Use: "text",
|
| 258 |
+
Short: "Extract page text",
|
| 259 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 260 |
+
cfg := config.Load()
|
| 261 |
+
runCLIWith(cfg, func(client *http.Client, base, token string) {
|
| 262 |
+
browseractions.Text(client, base, token, cmd)
|
| 263 |
+
})
|
| 264 |
+
},
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
var downloadCmd = &cobra.Command{
|
| 268 |
+
Use: "download <url>",
|
| 269 |
+
Short: "Download a file via browser session",
|
| 270 |
+
Args: cobra.ExactArgs(1),
|
| 271 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 272 |
+
args[0] = urlutil.Normalize(args[0])
|
| 273 |
+
output, _ := cmd.Flags().GetString("output")
|
| 274 |
+
cfg := config.Load()
|
| 275 |
+
runCLIWith(cfg, func(client *http.Client, base, token string) {
|
| 276 |
+
browseractions.Download(client, base, token, args, output)
|
| 277 |
+
})
|
| 278 |
+
},
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
var uploadCmd = &cobra.Command{
|
| 282 |
+
Use: "upload <file-path>",
|
| 283 |
+
Short: "Upload a file to a file input element",
|
| 284 |
+
Args: cobra.MinimumNArgs(1),
|
| 285 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 286 |
+
selector, _ := cmd.Flags().GetString("selector")
|
| 287 |
+
cfg := config.Load()
|
| 288 |
+
runCLIWith(cfg, func(client *http.Client, base, token string) {
|
| 289 |
+
browseractions.Upload(client, base, token, args, selector)
|
| 290 |
+
})
|
| 291 |
+
},
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
var profilesCmd = &cobra.Command{
|
| 295 |
+
Use: "profiles",
|
| 296 |
+
Short: "List browser profiles",
|
| 297 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 298 |
+
cfg := config.Load()
|
| 299 |
+
runCLIWith(cfg, func(client *http.Client, base, token string) {
|
| 300 |
+
browseractions.Profiles(client, base, token)
|
| 301 |
+
})
|
| 302 |
+
},
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
var instanceCmd = &cobra.Command{
|
| 306 |
+
Use: "instance",
|
| 307 |
+
Short: "Manage browser instances",
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
var findCmd = &cobra.Command{
|
| 311 |
+
Use: "find <query>",
|
| 312 |
+
Short: "Find elements by natural language query",
|
| 313 |
+
Args: cobra.ExactArgs(1),
|
| 314 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 315 |
+
cfg := config.Load()
|
| 316 |
+
runCLIWith(cfg, func(client *http.Client, base, token string) {
|
| 317 |
+
browseractions.Find(client, base, token, args[0], cmd)
|
| 318 |
+
})
|
| 319 |
+
},
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
var selectCmd = &cobra.Command{
|
| 323 |
+
Use: "select <ref> <value>",
|
| 324 |
+
Short: "Select option in dropdown",
|
| 325 |
+
Args: cobra.MinimumNArgs(2),
|
| 326 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 327 |
+
cfg := config.Load()
|
| 328 |
+
runCLIWith(cfg, func(client *http.Client, base, token string) {
|
| 329 |
+
browseractions.ActionSimple(client, base, token, "select", args, cmd)
|
| 330 |
+
})
|
| 331 |
+
},
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
var checkCmd = &cobra.Command{
|
| 335 |
+
Use: "check <selector>",
|
| 336 |
+
Short: "Check a checkbox or radio",
|
| 337 |
+
Args: cobra.ExactArgs(1),
|
| 338 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 339 |
+
cfg := config.Load()
|
| 340 |
+
runCLIWith(cfg, func(client *http.Client, base, token string) {
|
| 341 |
+
browseractions.Action(client, base, token, "check", args[0], cmd)
|
| 342 |
+
})
|
| 343 |
+
},
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
var uncheckCmd = &cobra.Command{
|
| 347 |
+
Use: "uncheck <selector>",
|
| 348 |
+
Short: "Uncheck a checkbox or radio",
|
| 349 |
+
Args: cobra.ExactArgs(1),
|
| 350 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 351 |
+
cfg := config.Load()
|
| 352 |
+
runCLIWith(cfg, func(client *http.Client, base, token string) {
|
| 353 |
+
browseractions.Action(client, base, token, "uncheck", args[0], cmd)
|
| 354 |
+
})
|
| 355 |
+
},
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
func init() {
|
| 359 |
+
quickCmd.GroupID = "browser"
|
| 360 |
+
navCmd.GroupID = "browser"
|
| 361 |
+
backCmd.GroupID = "browser"
|
| 362 |
+
forwardCmd.GroupID = "browser"
|
| 363 |
+
reloadCmd.GroupID = "browser"
|
| 364 |
+
snapCmd.GroupID = "browser"
|
| 365 |
+
clickCmd.GroupID = "browser"
|
| 366 |
+
typeCmd.GroupID = "browser"
|
| 367 |
+
screenshotCmd.GroupID = "browser"
|
| 368 |
+
tabsCmd.GroupID = "browser"
|
| 369 |
+
instancesCmd.GroupID = "management"
|
| 370 |
+
healthCmd.GroupID = "management"
|
| 371 |
+
pressCmd.GroupID = "browser"
|
| 372 |
+
fillCmd.GroupID = "browser"
|
| 373 |
+
hoverCmd.GroupID = "browser"
|
| 374 |
+
scrollCmd.GroupID = "browser"
|
| 375 |
+
evalCmd.GroupID = "browser"
|
| 376 |
+
pdfCmd.GroupID = "browser"
|
| 377 |
+
textCmd.GroupID = "browser"
|
| 378 |
+
profilesCmd.GroupID = "management"
|
| 379 |
+
downloadCmd.GroupID = "browser"
|
| 380 |
+
uploadCmd.GroupID = "browser"
|
| 381 |
+
findCmd.GroupID = "browser"
|
| 382 |
+
selectCmd.GroupID = "browser"
|
| 383 |
+
checkCmd.GroupID = "browser"
|
| 384 |
+
uncheckCmd.GroupID = "browser"
|
| 385 |
+
|
| 386 |
+
tabsCmd.AddCommand(&cobra.Command{
|
| 387 |
+
Use: "new [url]",
|
| 388 |
+
Short: "Open a new tab",
|
| 389 |
+
Args: cobra.MaximumNArgs(1),
|
| 390 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 391 |
+
cfg := config.Load()
|
| 392 |
+
runCLIWith(cfg, func(client *http.Client, base, token string) {
|
| 393 |
+
body := map[string]any{"action": "new"}
|
| 394 |
+
if len(args) > 0 {
|
| 395 |
+
body["url"] = urlutil.Normalize(args[0])
|
| 396 |
+
}
|
| 397 |
+
browseractions.TabNew(client, base, token, body)
|
| 398 |
+
})
|
| 399 |
+
},
|
| 400 |
+
})
|
| 401 |
+
tabsCmd.AddCommand(&cobra.Command{
|
| 402 |
+
Use: "close <id>",
|
| 403 |
+
Short: "Close a tab by ID",
|
| 404 |
+
Args: cobra.ExactArgs(1),
|
| 405 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 406 |
+
cfg := config.Load()
|
| 407 |
+
runCLIWith(cfg, func(client *http.Client, base, token string) {
|
| 408 |
+
browseractions.TabClose(client, base, token, args[0])
|
| 409 |
+
})
|
| 410 |
+
},
|
| 411 |
+
})
|
| 412 |
+
|
| 413 |
+
uploadCmd.Flags().StringP("selector", "s", "", "CSS selector for file input")
|
| 414 |
+
downloadCmd.Flags().StringP("output", "o", "", "Save downloaded file to path")
|
| 415 |
+
|
| 416 |
+
clickCmd.Flags().String("css", "", "CSS selector instead of ref")
|
| 417 |
+
clickCmd.Flags().Float64("x", 0, "X coordinate for click")
|
| 418 |
+
clickCmd.Flags().Float64("y", 0, "Y coordinate for click")
|
| 419 |
+
clickCmd.Flags().Bool("wait-nav", false, "Wait for navigation after click")
|
| 420 |
+
dblclickCmd.Flags().String("css", "", "CSS selector instead of ref")
|
| 421 |
+
dblclickCmd.Flags().Float64("x", 0, "X coordinate for dblclick")
|
| 422 |
+
dblclickCmd.Flags().Float64("y", 0, "Y coordinate for dblclick")
|
| 423 |
+
hoverCmd.Flags().String("css", "", "CSS selector instead of ref")
|
| 424 |
+
hoverCmd.Flags().Float64("x", 0, "X coordinate for hover")
|
| 425 |
+
hoverCmd.Flags().Float64("y", 0, "Y coordinate for hover")
|
| 426 |
+
|
| 427 |
+
snapCmd.Flags().BoolP("interactive", "i", false, "Filter interactive elements only")
|
| 428 |
+
snapCmd.Flags().BoolP("compact", "c", false, "Compact output format")
|
| 429 |
+
snapCmd.Flags().Bool("text", false, "Text output format")
|
| 430 |
+
snapCmd.Flags().BoolP("diff", "d", false, "Show diff from previous snapshot")
|
| 431 |
+
snapCmd.Flags().StringP("selector", "s", "", "CSS selector to scope snapshot")
|
| 432 |
+
snapCmd.Flags().String("max-tokens", "", "Maximum token budget")
|
| 433 |
+
snapCmd.Flags().String("depth", "", "Tree depth limit")
|
| 434 |
+
snapCmd.Flags().String("tab", "", "Tab ID")
|
| 435 |
+
|
| 436 |
+
screenshotCmd.Flags().StringP("output", "o", "", "Save screenshot to file path")
|
| 437 |
+
screenshotCmd.Flags().StringP("quality", "q", "", "JPEG quality (0-100)")
|
| 438 |
+
screenshotCmd.Flags().String("tab", "", "Tab ID")
|
| 439 |
+
|
| 440 |
+
pdfCmd.Flags().StringP("output", "o", "", "Save PDF to file path")
|
| 441 |
+
pdfCmd.Flags().String("tab", "", "Tab ID")
|
| 442 |
+
pdfCmd.Flags().Bool("landscape", false, "Landscape orientation")
|
| 443 |
+
pdfCmd.Flags().String("scale", "", "Page scale (e.g. 0.5)")
|
| 444 |
+
pdfCmd.Flags().String("paper-width", "", "Paper width (inches)")
|
| 445 |
+
pdfCmd.Flags().String("paper-height", "", "Paper height (inches)")
|
| 446 |
+
pdfCmd.Flags().String("margin-top", "", "Top margin")
|
| 447 |
+
pdfCmd.Flags().String("margin-bottom", "", "Bottom margin")
|
| 448 |
+
pdfCmd.Flags().String("margin-left", "", "Left margin")
|
| 449 |
+
pdfCmd.Flags().String("margin-right", "", "Right margin")
|
| 450 |
+
pdfCmd.Flags().String("page-ranges", "", "Page ranges (e.g. 1-3)")
|
| 451 |
+
pdfCmd.Flags().Bool("prefer-css-page-size", false, "Use CSS page size")
|
| 452 |
+
pdfCmd.Flags().Bool("display-header-footer", false, "Show header/footer")
|
| 453 |
+
pdfCmd.Flags().String("header-template", "", "Header HTML template")
|
| 454 |
+
pdfCmd.Flags().String("footer-template", "", "Footer HTML template")
|
| 455 |
+
pdfCmd.Flags().Bool("generate-tagged-pdf", false, "Generate tagged PDF")
|
| 456 |
+
pdfCmd.Flags().Bool("generate-document-outline", false, "Generate document outline")
|
| 457 |
+
pdfCmd.Flags().Bool("file-output", false, "Use server-side file output")
|
| 458 |
+
pdfCmd.Flags().String("path", "", "Server-side output path")
|
| 459 |
+
|
| 460 |
+
findCmd.Flags().String("tab", "", "Tab ID")
|
| 461 |
+
findCmd.Flags().String("threshold", "", "Minimum similarity score (0-1)")
|
| 462 |
+
findCmd.Flags().Bool("explain", false, "Show score breakdown")
|
| 463 |
+
findCmd.Flags().Bool("ref-only", false, "Output just the element ref")
|
| 464 |
+
|
| 465 |
+
textCmd.Flags().Bool("raw", false, "Raw extraction mode")
|
| 466 |
+
textCmd.Flags().String("tab", "", "Tab ID")
|
| 467 |
+
|
| 468 |
+
navCmd.Flags().Bool("new-tab", false, "Open in new tab")
|
| 469 |
+
navCmd.Flags().Bool("block-images", false, "Block image loading")
|
| 470 |
+
navCmd.Flags().Bool("block-ads", false, "Block ads")
|
| 471 |
+
navCmd.Flags().String("tab", "", "Tab ID")
|
| 472 |
+
backCmd.Flags().String("tab", "", "Tab ID")
|
| 473 |
+
forwardCmd.Flags().String("tab", "", "Tab ID")
|
| 474 |
+
reloadCmd.Flags().String("tab", "", "Tab ID")
|
| 475 |
+
|
| 476 |
+
clickCmd.Flags().String("tab", "", "Tab ID")
|
| 477 |
+
dblclickCmd.Flags().String("tab", "", "Tab ID")
|
| 478 |
+
hoverCmd.Flags().String("tab", "", "Tab ID")
|
| 479 |
+
typeCmd.Flags().String("tab", "", "Tab ID")
|
| 480 |
+
pressCmd.Flags().String("tab", "", "Tab ID")
|
| 481 |
+
fillCmd.Flags().String("tab", "", "Tab ID")
|
| 482 |
+
scrollCmd.Flags().String("tab", "", "Tab ID")
|
| 483 |
+
selectCmd.Flags().String("tab", "", "Tab ID")
|
| 484 |
+
evalCmd.Flags().String("tab", "", "Tab ID")
|
| 485 |
+
checkCmd.Flags().String("tab", "", "Tab ID")
|
| 486 |
+
uncheckCmd.Flags().String("tab", "", "Tab ID")
|
| 487 |
+
|
| 488 |
+
rootCmd.AddCommand(quickCmd)
|
| 489 |
+
rootCmd.AddCommand(navCmd)
|
| 490 |
+
rootCmd.AddCommand(backCmd)
|
| 491 |
+
rootCmd.AddCommand(forwardCmd)
|
| 492 |
+
rootCmd.AddCommand(reloadCmd)
|
| 493 |
+
rootCmd.AddCommand(snapCmd)
|
| 494 |
+
rootCmd.AddCommand(clickCmd)
|
| 495 |
+
rootCmd.AddCommand(dblclickCmd)
|
| 496 |
+
rootCmd.AddCommand(typeCmd)
|
| 497 |
+
rootCmd.AddCommand(screenshotCmd)
|
| 498 |
+
rootCmd.AddCommand(tabsCmd)
|
| 499 |
+
rootCmd.AddCommand(instancesCmd)
|
| 500 |
+
rootCmd.AddCommand(healthCmd)
|
| 501 |
+
rootCmd.AddCommand(pressCmd)
|
| 502 |
+
rootCmd.AddCommand(fillCmd)
|
| 503 |
+
rootCmd.AddCommand(hoverCmd)
|
| 504 |
+
rootCmd.AddCommand(scrollCmd)
|
| 505 |
+
rootCmd.AddCommand(evalCmd)
|
| 506 |
+
rootCmd.AddCommand(pdfCmd)
|
| 507 |
+
rootCmd.AddCommand(textCmd)
|
| 508 |
+
rootCmd.AddCommand(profilesCmd)
|
| 509 |
+
rootCmd.AddCommand(downloadCmd)
|
| 510 |
+
rootCmd.AddCommand(uploadCmd)
|
| 511 |
+
rootCmd.AddCommand(findCmd)
|
| 512 |
+
rootCmd.AddCommand(selectCmd)
|
| 513 |
+
rootCmd.AddCommand(checkCmd)
|
| 514 |
+
rootCmd.AddCommand(uncheckCmd)
|
| 515 |
+
|
| 516 |
+
instanceCmd.GroupID = "management"
|
| 517 |
+
|
| 518 |
+
startInstanceCmd := &cobra.Command{
|
| 519 |
+
Use: "start",
|
| 520 |
+
Short: "Start a browser instance",
|
| 521 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 522 |
+
cfg := config.Load()
|
| 523 |
+
runCLIWith(cfg, func(client *http.Client, base, token string) {
|
| 524 |
+
browseractions.InstanceStart(client, base, token, cmd)
|
| 525 |
+
})
|
| 526 |
+
},
|
| 527 |
+
}
|
| 528 |
+
startInstanceCmd.Flags().String("profile", "", "Profile to use")
|
| 529 |
+
startInstanceCmd.Flags().String("mode", "", "Instance mode")
|
| 530 |
+
startInstanceCmd.Flags().String("port", "", "Port number")
|
| 531 |
+
startInstanceCmd.Flags().StringArray("extension", nil, "Load browser extension (repeatable)")
|
| 532 |
+
instanceCmd.AddCommand(startInstanceCmd)
|
| 533 |
+
|
| 534 |
+
instanceCmd.AddCommand(&cobra.Command{
|
| 535 |
+
Use: "navigate <id> <url>",
|
| 536 |
+
Short: "Navigate an instance to a URL",
|
| 537 |
+
Args: cobra.ExactArgs(2),
|
| 538 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 539 |
+
args[1] = urlutil.Normalize(args[1])
|
| 540 |
+
cfg := config.Load()
|
| 541 |
+
runCLIWith(cfg, func(client *http.Client, base, token string) {
|
| 542 |
+
browseractions.InstanceNavigate(client, base, token, args)
|
| 543 |
+
})
|
| 544 |
+
},
|
| 545 |
+
})
|
| 546 |
+
instanceCmd.AddCommand(&cobra.Command{
|
| 547 |
+
Use: "stop <id>",
|
| 548 |
+
Short: "Stop a browser instance",
|
| 549 |
+
Args: cobra.ExactArgs(1),
|
| 550 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 551 |
+
cfg := config.Load()
|
| 552 |
+
runCLIWith(cfg, func(client *http.Client, base, token string) {
|
| 553 |
+
browseractions.InstanceStop(client, base, token, args)
|
| 554 |
+
})
|
| 555 |
+
},
|
| 556 |
+
})
|
| 557 |
+
instanceCmd.AddCommand(&cobra.Command{
|
| 558 |
+
Use: "logs <id>",
|
| 559 |
+
Short: "Get instance logs",
|
| 560 |
+
Args: cobra.ExactArgs(1),
|
| 561 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 562 |
+
cfg := config.Load()
|
| 563 |
+
runCLIWith(cfg, func(client *http.Client, base, token string) {
|
| 564 |
+
browseractions.InstanceLogs(client, base, token, args)
|
| 565 |
+
})
|
| 566 |
+
},
|
| 567 |
+
})
|
| 568 |
+
rootCmd.AddCommand(instanceCmd)
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
func runCLIWith(cfg *config.RuntimeConfig, fn func(client *http.Client, base, token string)) {
|
| 572 |
+
client := &http.Client{Timeout: 60 * time.Second}
|
| 573 |
+
|
| 574 |
+
// Default: http://127.0.0.1:{port}
|
| 575 |
+
port := cfg.Port
|
| 576 |
+
if port == "" {
|
| 577 |
+
port = "9867"
|
| 578 |
+
}
|
| 579 |
+
base := fmt.Sprintf("http://127.0.0.1:%s", port)
|
| 580 |
+
|
| 581 |
+
// --server flag overrides
|
| 582 |
+
if serverURL != "" {
|
| 583 |
+
base = strings.TrimRight(serverURL, "/")
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
// Token from config, env var overrides
|
| 587 |
+
token := cfg.Token
|
| 588 |
+
if envToken := os.Getenv("PINCHTAB_TOKEN"); envToken != "" {
|
| 589 |
+
token = envToken
|
| 590 |
+
}
|
| 591 |
+
|
| 592 |
+
fn(client, base, token)
|
| 593 |
+
}
|
cmd/pinchtab/cmd_completion.go
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package main
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"os"
|
| 5 |
+
|
| 6 |
+
"github.com/spf13/cobra"
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
var completionCmd = &cobra.Command{
|
| 10 |
+
Use: "completion [bash|zsh|fish|powershell]",
|
| 11 |
+
Short: "Generate shell completion script",
|
| 12 |
+
Long: `Generate a shell completion script for pinchtab.
|
| 13 |
+
|
| 14 |
+
To load completions:
|
| 15 |
+
|
| 16 |
+
Bash:
|
| 17 |
+
$ source <(pinchtab completion bash)
|
| 18 |
+
|
| 19 |
+
# To load completions for each session, execute once:
|
| 20 |
+
# Linux:
|
| 21 |
+
$ pinchtab completion bash > /etc/bash_completion.d/pinchtab
|
| 22 |
+
# macOS:
|
| 23 |
+
$ pinchtab completion bash > $(brew --prefix)/etc/bash_completion.d/pinchtab
|
| 24 |
+
|
| 25 |
+
Zsh:
|
| 26 |
+
# If shell completion is not already enabled in your environment,
|
| 27 |
+
# you will need to enable it. You can execute the following once:
|
| 28 |
+
$ echo "autoload -U compinit; compinit" >> ~/.zshrc
|
| 29 |
+
|
| 30 |
+
# To load completions for each session, execute once:
|
| 31 |
+
$ pinchtab completion zsh > "${fpath[1]}/_pinchtab"
|
| 32 |
+
|
| 33 |
+
# You will need to start a new shell for this setup to take effect.
|
| 34 |
+
|
| 35 |
+
Fish:
|
| 36 |
+
$ pinchtab completion fish | source
|
| 37 |
+
|
| 38 |
+
# To load completions for each session, execute once:
|
| 39 |
+
$ pinchtab completion fish > ~/.config/fish/completions/pinchtab.fish
|
| 40 |
+
|
| 41 |
+
PowerShell:
|
| 42 |
+
PS> pinchtab completion powershell | Out-String | Invoke-Expression
|
| 43 |
+
|
| 44 |
+
# To load completions for every new session, add the output to your profile:
|
| 45 |
+
PS> pinchtab completion powershell > pinchtab.ps1
|
| 46 |
+
# and source this file from your PowerShell profile.
|
| 47 |
+
`,
|
| 48 |
+
DisableFlagsInUseLine: true,
|
| 49 |
+
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
|
| 50 |
+
Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
|
| 51 |
+
RunE: func(cmd *cobra.Command, args []string) error {
|
| 52 |
+
switch args[0] {
|
| 53 |
+
case "bash":
|
| 54 |
+
return rootCmd.GenBashCompletion(os.Stdout)
|
| 55 |
+
case "zsh":
|
| 56 |
+
return rootCmd.GenZshCompletion(os.Stdout)
|
| 57 |
+
case "fish":
|
| 58 |
+
return rootCmd.GenFishCompletion(os.Stdout, true)
|
| 59 |
+
case "powershell":
|
| 60 |
+
return rootCmd.GenPowerShellCompletionWithDesc(os.Stdout)
|
| 61 |
+
}
|
| 62 |
+
return nil
|
| 63 |
+
},
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
func init() {
|
| 67 |
+
completionCmd.GroupID = "config"
|
| 68 |
+
rootCmd.AddCommand(completionCmd)
|
| 69 |
+
}
|
cmd/pinchtab/cmd_config.go
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package main
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"encoding/json"
|
| 5 |
+
"fmt"
|
| 6 |
+
"os"
|
| 7 |
+
"os/exec"
|
| 8 |
+
"path/filepath"
|
| 9 |
+
"runtime"
|
| 10 |
+
"strings"
|
| 11 |
+
|
| 12 |
+
"github.com/pinchtab/pinchtab/internal/cli"
|
| 13 |
+
"github.com/pinchtab/pinchtab/internal/config"
|
| 14 |
+
"github.com/pinchtab/pinchtab/internal/server"
|
| 15 |
+
"github.com/spf13/cobra"
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
var clipboardExecCommand = exec.Command
|
| 19 |
+
|
| 20 |
+
var configCmd = &cobra.Command{
|
| 21 |
+
Use: "config",
|
| 22 |
+
Short: "Manage configuration",
|
| 23 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 24 |
+
handleConfigOverview(loadConfig())
|
| 25 |
+
},
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
func init() {
|
| 29 |
+
configCmd.GroupID = "config"
|
| 30 |
+
configCmd.AddCommand(&cobra.Command{
|
| 31 |
+
Use: "show",
|
| 32 |
+
Short: "Display current configuration",
|
| 33 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 34 |
+
cfg := config.Load()
|
| 35 |
+
cli.HandleConfigShow(cfg)
|
| 36 |
+
},
|
| 37 |
+
})
|
| 38 |
+
configCmd.AddCommand(&cobra.Command{
|
| 39 |
+
Use: "init",
|
| 40 |
+
Short: "Initialize a new config file",
|
| 41 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 42 |
+
handleConfigInit()
|
| 43 |
+
},
|
| 44 |
+
})
|
| 45 |
+
configCmd.AddCommand(&cobra.Command{
|
| 46 |
+
Use: "path",
|
| 47 |
+
Short: "Show config file path",
|
| 48 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 49 |
+
handleConfigPath()
|
| 50 |
+
},
|
| 51 |
+
})
|
| 52 |
+
configCmd.AddCommand(&cobra.Command{
|
| 53 |
+
Use: "validate",
|
| 54 |
+
Short: "Validate config file",
|
| 55 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 56 |
+
handleConfigValidate()
|
| 57 |
+
},
|
| 58 |
+
})
|
| 59 |
+
configCmd.AddCommand(&cobra.Command{
|
| 60 |
+
Use: "get <path>",
|
| 61 |
+
Short: "Get a config value (e.g., server.port)",
|
| 62 |
+
Args: cobra.ExactArgs(1),
|
| 63 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 64 |
+
handleConfigGet(args[0])
|
| 65 |
+
},
|
| 66 |
+
})
|
| 67 |
+
configCmd.AddCommand(&cobra.Command{
|
| 68 |
+
Use: "set <path> <val>",
|
| 69 |
+
Short: "Set a config value (e.g., server.port 8080)",
|
| 70 |
+
Args: cobra.ExactArgs(2),
|
| 71 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 72 |
+
handleConfigSet(args[0], args[1])
|
| 73 |
+
},
|
| 74 |
+
})
|
| 75 |
+
configCmd.AddCommand(&cobra.Command{
|
| 76 |
+
Use: "patch <json>",
|
| 77 |
+
Short: "Merge JSON into config",
|
| 78 |
+
Args: cobra.ExactArgs(1),
|
| 79 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 80 |
+
handleConfigPatch(args[0])
|
| 81 |
+
},
|
| 82 |
+
})
|
| 83 |
+
|
| 84 |
+
rootCmd.AddCommand(configCmd)
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
func handleConfigOverview(cfg *config.RuntimeConfig) {
|
| 88 |
+
_, configPath, err := config.LoadFileConfig()
|
| 89 |
+
if err != nil {
|
| 90 |
+
fmt.Fprintln(os.Stderr, cli.StyleStderr(cli.ErrorStyle, fmt.Sprintf("Error loading config path: %v", err)))
|
| 91 |
+
os.Exit(1)
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
dashPort := cfg.Port
|
| 95 |
+
if dashPort == "" {
|
| 96 |
+
dashPort = "9870"
|
| 97 |
+
}
|
| 98 |
+
dashboardURL := fmt.Sprintf("http://localhost:%s", dashPort)
|
| 99 |
+
running := server.CheckPinchTabRunning(dashPort, cfg.Token)
|
| 100 |
+
|
| 101 |
+
for {
|
| 102 |
+
fmt.Print(renderConfigOverview(cfg, configPath, dashboardURL, running))
|
| 103 |
+
|
| 104 |
+
if !isInteractiveTerminal() {
|
| 105 |
+
return
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
nextCfg, changed, done, err := promptConfigEdit(cfg)
|
| 109 |
+
if err != nil {
|
| 110 |
+
fmt.Fprintln(os.Stderr, cli.StyleStderr(cli.ErrorStyle, err.Error()))
|
| 111 |
+
fmt.Println()
|
| 112 |
+
continue
|
| 113 |
+
}
|
| 114 |
+
if done {
|
| 115 |
+
return
|
| 116 |
+
}
|
| 117 |
+
if !changed {
|
| 118 |
+
fmt.Println()
|
| 119 |
+
continue
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
cfg = nextCfg
|
| 123 |
+
dashPort = cfg.Port
|
| 124 |
+
if dashPort == "" {
|
| 125 |
+
dashPort = "9870"
|
| 126 |
+
}
|
| 127 |
+
dashboardURL = fmt.Sprintf("http://localhost:%s", dashPort)
|
| 128 |
+
running = server.CheckPinchTabRunning(dashPort, cfg.Token)
|
| 129 |
+
fmt.Println()
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
func renderConfigOverview(cfg *config.RuntimeConfig, configPath, dashboardURL string, running bool) string {
|
| 134 |
+
out := ""
|
| 135 |
+
out += cli.StyleStdout(cli.HeadingStyle, "Config") + "\n\n"
|
| 136 |
+
out += fmt.Sprintf(" 1. %-18s %s\n", "Strategy", cli.StyleStdout(cli.ValueStyle, cfg.Strategy))
|
| 137 |
+
out += fmt.Sprintf(" 2. %-18s %s\n", "Allocation policy", cli.StyleStdout(cli.ValueStyle, cfg.AllocationPolicy))
|
| 138 |
+
out += fmt.Sprintf(" 3. %-18s %s\n", "Stealth level", cli.StyleStdout(cli.ValueStyle, cfg.StealthLevel))
|
| 139 |
+
out += fmt.Sprintf(" 4. %-18s %s\n", "Tab eviction", cli.StyleStdout(cli.ValueStyle, cfg.TabEvictionPolicy))
|
| 140 |
+
out += fmt.Sprintf(" 5. %-18s %s\n", "Copy token", cli.StyleStdout(cli.MutedStyle, "clipboard"))
|
| 141 |
+
out += "\n"
|
| 142 |
+
out += cli.StyleStdout(cli.HeadingStyle, "More") + "\n\n"
|
| 143 |
+
out += fmt.Sprintf(" %s %s\n", cli.StyleStdout(cli.MutedStyle, "File:"), cli.StyleStdout(cli.ValueStyle, configPath))
|
| 144 |
+
out += fmt.Sprintf(" %s %s\n", cli.StyleStdout(cli.MutedStyle, "Token:"), cli.StyleStdout(cli.ValueStyle, config.MaskToken(cfg.Token)))
|
| 145 |
+
if running {
|
| 146 |
+
out += fmt.Sprintf(" %s %s\n", cli.StyleStdout(cli.MutedStyle, "Dashboard:"), cli.StyleStdout(cli.ValueStyle, dashboardURL))
|
| 147 |
+
} else {
|
| 148 |
+
out += fmt.Sprintf(" %s %s\n", cli.StyleStdout(cli.MutedStyle, "Dashboard:"), cli.StyleStdout(cli.MutedStyle, "not running"))
|
| 149 |
+
}
|
| 150 |
+
if isInteractiveTerminal() {
|
| 151 |
+
out += "\n"
|
| 152 |
+
out += cli.StyleStdout(cli.MutedStyle, "Edit item (1-5, blank to exit):") + " "
|
| 153 |
+
}
|
| 154 |
+
out += "\n"
|
| 155 |
+
return out
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
func promptConfigEdit(cfg *config.RuntimeConfig) (*config.RuntimeConfig, bool, bool, error) {
|
| 159 |
+
choice, err := promptInput("", "")
|
| 160 |
+
if err != nil {
|
| 161 |
+
return nil, false, false, err
|
| 162 |
+
}
|
| 163 |
+
choice = strings.TrimSpace(choice)
|
| 164 |
+
if choice == "" {
|
| 165 |
+
return nil, false, true, nil
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
switch choice {
|
| 169 |
+
case "1":
|
| 170 |
+
nextCfg, changed, err := editConfigSelection("Instance strategy", "multiInstance.strategy", cfg.Strategy, config.ValidStrategies())
|
| 171 |
+
return nextCfg, changed, false, err
|
| 172 |
+
case "2":
|
| 173 |
+
nextCfg, changed, err := editConfigSelection("Allocation policy", "multiInstance.allocationPolicy", cfg.AllocationPolicy, config.ValidAllocationPolicies())
|
| 174 |
+
return nextCfg, changed, false, err
|
| 175 |
+
case "3":
|
| 176 |
+
nextCfg, changed, err := editConfigSelection("Default stealth level", "instanceDefaults.stealthLevel", cfg.StealthLevel, config.ValidStealthLevels())
|
| 177 |
+
return nextCfg, changed, false, err
|
| 178 |
+
case "4":
|
| 179 |
+
nextCfg, changed, err := editConfigSelection("Default tab eviction", "instanceDefaults.tabEvictionPolicy", cfg.TabEvictionPolicy, config.ValidEvictionPolicies())
|
| 180 |
+
return nextCfg, changed, false, err
|
| 181 |
+
case "5":
|
| 182 |
+
if err := copyConfigToken(cfg.Token); err != nil {
|
| 183 |
+
return nil, false, false, err
|
| 184 |
+
}
|
| 185 |
+
return nil, false, false, nil
|
| 186 |
+
default:
|
| 187 |
+
return nil, false, false, fmt.Errorf("invalid selection %q", choice)
|
| 188 |
+
}
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
func editConfigSelection(title, path, current string, values []string) (*config.RuntimeConfig, bool, error) {
|
| 192 |
+
options := make([]menuOption, 0, len(values)+1)
|
| 193 |
+
for _, value := range values {
|
| 194 |
+
label := value
|
| 195 |
+
if value == current {
|
| 196 |
+
label += " (current)"
|
| 197 |
+
}
|
| 198 |
+
options = append(options, menuOption{label: label, value: value})
|
| 199 |
+
}
|
| 200 |
+
options = append(options, menuOption{label: "Cancel", value: "cancel"})
|
| 201 |
+
|
| 202 |
+
picked, err := promptSelect(title, options)
|
| 203 |
+
if err != nil {
|
| 204 |
+
return nil, false, err
|
| 205 |
+
}
|
| 206 |
+
if picked == "" || picked == "cancel" {
|
| 207 |
+
return nil, false, nil
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
nextCfg, changed, err := updateConfigValue(path, picked)
|
| 211 |
+
if err != nil {
|
| 212 |
+
return nil, false, err
|
| 213 |
+
}
|
| 214 |
+
if changed {
|
| 215 |
+
fmt.Println(cli.StyleStdout(cli.SuccessStyle, fmt.Sprintf("Updated %s to %s", path, picked)))
|
| 216 |
+
fmt.Println(cli.StyleStdout(cli.MutedStyle, "Restart PinchTab to apply file-based changes."))
|
| 217 |
+
}
|
| 218 |
+
return nextCfg, changed, nil
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
func copyConfigToken(token string) error {
|
| 222 |
+
if strings.TrimSpace(token) == "" {
|
| 223 |
+
return fmt.Errorf("server token is empty")
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
if err := copyToClipboard(token); err == nil {
|
| 227 |
+
fmt.Println(cli.StyleStdout(cli.SuccessStyle, "Token copied to clipboard."))
|
| 228 |
+
return nil
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
fmt.Println(cli.StyleStdout(cli.WarningStyle, "Clipboard unavailable; copy the token manually:"))
|
| 232 |
+
fmt.Println(cli.StyleStdout(cli.ValueStyle, token))
|
| 233 |
+
return nil
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
func copyToClipboard(text string) error {
|
| 237 |
+
candidates := clipboardCommands()
|
| 238 |
+
var lastErr error
|
| 239 |
+
|
| 240 |
+
for _, candidate := range candidates {
|
| 241 |
+
if _, err := exec.LookPath(candidate.name); err != nil {
|
| 242 |
+
lastErr = err
|
| 243 |
+
continue
|
| 244 |
+
}
|
| 245 |
+
cmd := clipboardExecCommand(candidate.name, candidate.args...)
|
| 246 |
+
cmd.Stdin = strings.NewReader(text)
|
| 247 |
+
if output, err := cmd.CombinedOutput(); err != nil {
|
| 248 |
+
if len(strings.TrimSpace(string(output))) > 0 {
|
| 249 |
+
lastErr = fmt.Errorf("%s: %s", err, strings.TrimSpace(string(output)))
|
| 250 |
+
} else {
|
| 251 |
+
lastErr = err
|
| 252 |
+
}
|
| 253 |
+
continue
|
| 254 |
+
}
|
| 255 |
+
return nil
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
if lastErr == nil {
|
| 259 |
+
return fmt.Errorf("no clipboard command available")
|
| 260 |
+
}
|
| 261 |
+
return lastErr
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
type clipboardCommand struct {
|
| 265 |
+
name string
|
| 266 |
+
args []string
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
func clipboardCommands() []clipboardCommand {
|
| 270 |
+
switch runtime.GOOS {
|
| 271 |
+
case "darwin":
|
| 272 |
+
return []clipboardCommand{{name: "pbcopy"}}
|
| 273 |
+
case "windows":
|
| 274 |
+
return []clipboardCommand{{name: "clip"}}
|
| 275 |
+
default:
|
| 276 |
+
return []clipboardCommand{
|
| 277 |
+
{name: "wl-copy"},
|
| 278 |
+
{name: "xclip", args: []string{"-selection", "clipboard"}},
|
| 279 |
+
{name: "xsel", args: []string{"--clipboard", "--input"}},
|
| 280 |
+
}
|
| 281 |
+
}
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
func handleConfigInit() {
|
| 285 |
+
configPath := os.Getenv("PINCHTAB_CONFIG")
|
| 286 |
+
if configPath == "" {
|
| 287 |
+
configPath = config.DefaultConfigPath()
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
if _, err := os.Stat(configPath); err == nil {
|
| 291 |
+
fmt.Printf("Config file already exists at %s\n", configPath)
|
| 292 |
+
fmt.Print("Overwrite? (y/N): ")
|
| 293 |
+
var response string
|
| 294 |
+
_, _ = fmt.Scanln(&response)
|
| 295 |
+
if response != "y" && response != "Y" {
|
| 296 |
+
return
|
| 297 |
+
}
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
| 301 |
+
fmt.Printf("Error creating directory: %v\n", err)
|
| 302 |
+
os.Exit(1)
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
fc := config.DefaultFileConfig()
|
| 306 |
+
token, err := config.GenerateAuthToken()
|
| 307 |
+
if err != nil {
|
| 308 |
+
fmt.Printf("Error generating auth token: %v\n", err)
|
| 309 |
+
os.Exit(1)
|
| 310 |
+
}
|
| 311 |
+
fc.Server.Token = token
|
| 312 |
+
|
| 313 |
+
if err := config.SaveFileConfig(&fc, configPath); err != nil {
|
| 314 |
+
fmt.Printf("Error writing config: %v\n", err)
|
| 315 |
+
os.Exit(1)
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
fmt.Printf("Config file created at %s\n", configPath)
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
func handleConfigPath() {
|
| 322 |
+
configPath := os.Getenv("PINCHTAB_CONFIG")
|
| 323 |
+
if configPath == "" {
|
| 324 |
+
configPath = config.DefaultConfigPath()
|
| 325 |
+
}
|
| 326 |
+
fmt.Println(configPath)
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
func handleConfigGet(path string) {
|
| 330 |
+
fc, _, err := config.LoadFileConfig()
|
| 331 |
+
if err != nil {
|
| 332 |
+
fmt.Printf("Error loading config: %v\n", err)
|
| 333 |
+
os.Exit(1)
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
value, err := config.GetConfigValue(fc, path)
|
| 337 |
+
if err != nil {
|
| 338 |
+
fmt.Printf("Error: %v\n", err)
|
| 339 |
+
os.Exit(1)
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
fmt.Println(value)
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
func handleConfigSet(path, value string) {
|
| 346 |
+
fc, configPath, err := config.LoadFileConfig()
|
| 347 |
+
if err != nil {
|
| 348 |
+
fmt.Printf("Error loading config: %v\n", err)
|
| 349 |
+
os.Exit(1)
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
if err := config.SetConfigValue(fc, path, value); err != nil {
|
| 353 |
+
fmt.Printf("Error: %v\n", err)
|
| 354 |
+
os.Exit(1)
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
if errs := config.ValidateFileConfig(fc); len(errs) > 0 {
|
| 358 |
+
fmt.Printf("Warning: new value causes validation error(s):\n")
|
| 359 |
+
for _, err := range errs {
|
| 360 |
+
fmt.Printf(" - %v\n", err)
|
| 361 |
+
}
|
| 362 |
+
fmt.Print("Save anyway? (y/N): ")
|
| 363 |
+
var response string
|
| 364 |
+
_, _ = fmt.Scanln(&response)
|
| 365 |
+
if response != "y" && response != "Y" {
|
| 366 |
+
return
|
| 367 |
+
}
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
if err := config.SaveFileConfig(fc, configPath); err != nil {
|
| 371 |
+
fmt.Printf("Error saving config: %v\n", err)
|
| 372 |
+
os.Exit(1)
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
fmt.Printf("Set %s = %s\n", path, value)
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
func handleConfigPatch(jsonPatch string) {
|
| 379 |
+
fc, configPath, err := config.LoadFileConfig()
|
| 380 |
+
if err != nil {
|
| 381 |
+
fmt.Printf("Error loading config: %v\n", err)
|
| 382 |
+
os.Exit(1)
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
if err := config.PatchConfigJSON(fc, jsonPatch); err != nil {
|
| 386 |
+
fmt.Printf("Error: %v\n", err)
|
| 387 |
+
os.Exit(1)
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
if errs := config.ValidateFileConfig(fc); len(errs) > 0 {
|
| 391 |
+
fmt.Printf("Warning: patch causes validation error(s):\n")
|
| 392 |
+
for _, err := range errs {
|
| 393 |
+
fmt.Printf(" - %v\n", err)
|
| 394 |
+
}
|
| 395 |
+
fmt.Print("Save anyway? (y/N): ")
|
| 396 |
+
var response string
|
| 397 |
+
_, _ = fmt.Scanln(&response)
|
| 398 |
+
if response != "y" && response != "Y" {
|
| 399 |
+
return
|
| 400 |
+
}
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
if err := config.SaveFileConfig(fc, configPath); err != nil {
|
| 404 |
+
fmt.Printf("Error saving config: %v\n", err)
|
| 405 |
+
os.Exit(1)
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
fmt.Println("Config patched successfully")
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
func handleConfigValidate() {
|
| 412 |
+
configPath := os.Getenv("PINCHTAB_CONFIG")
|
| 413 |
+
if configPath == "" {
|
| 414 |
+
configPath = config.DefaultConfigPath()
|
| 415 |
+
}
|
| 416 |
+
data, err := os.ReadFile(configPath)
|
| 417 |
+
if err != nil {
|
| 418 |
+
fmt.Printf("Error reading config file: %v\n", err)
|
| 419 |
+
os.Exit(1)
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
fc := &config.FileConfig{}
|
| 423 |
+
if err := json.Unmarshal(data, fc); err != nil {
|
| 424 |
+
fmt.Printf("Error parsing config: %v\n", err)
|
| 425 |
+
os.Exit(1)
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
if errs := config.ValidateFileConfig(fc); len(errs) > 0 {
|
| 429 |
+
fmt.Printf("Config file has %d error(s):\n", len(errs))
|
| 430 |
+
for _, err := range errs {
|
| 431 |
+
fmt.Printf(" - %v\n", err)
|
| 432 |
+
}
|
| 433 |
+
os.Exit(1)
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
fmt.Printf("Config file is valid: %s\n", configPath)
|
| 437 |
+
}
|
cmd/pinchtab/cmd_config_test.go
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package main
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"strings"
|
| 5 |
+
"testing"
|
| 6 |
+
|
| 7 |
+
"github.com/pinchtab/pinchtab/internal/config"
|
| 8 |
+
)
|
| 9 |
+
|
| 10 |
+
func TestRenderConfigOverview(t *testing.T) {
|
| 11 |
+
cfg := &config.RuntimeConfig{
|
| 12 |
+
Port: "9867",
|
| 13 |
+
Strategy: "simple",
|
| 14 |
+
AllocationPolicy: "fcfs",
|
| 15 |
+
StealthLevel: "light",
|
| 16 |
+
TabEvictionPolicy: "close_lru",
|
| 17 |
+
Token: "very-long-token-secret",
|
| 18 |
+
}
|
| 19 |
+
output := renderConfigOverview(cfg, "/tmp/pinchtab/config.json", "http://localhost:9867", false)
|
| 20 |
+
|
| 21 |
+
required := []string{
|
| 22 |
+
"Config",
|
| 23 |
+
"Strategy",
|
| 24 |
+
"Allocation policy",
|
| 25 |
+
"Stealth level",
|
| 26 |
+
"Tab eviction",
|
| 27 |
+
"Copy token",
|
| 28 |
+
"More",
|
| 29 |
+
"/tmp/pinchtab/config.json",
|
| 30 |
+
"very...cret",
|
| 31 |
+
"Dashboard:",
|
| 32 |
+
}
|
| 33 |
+
for _, needle := range required {
|
| 34 |
+
if !strings.Contains(output, needle) {
|
| 35 |
+
t.Fatalf("expected config overview to contain %q\n%s", needle, output)
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
func TestClipboardCommands(t *testing.T) {
|
| 41 |
+
commands := clipboardCommands()
|
| 42 |
+
if len(commands) == 0 {
|
| 43 |
+
t.Fatal("expected clipboard commands")
|
| 44 |
+
}
|
| 45 |
+
for _, command := range commands {
|
| 46 |
+
if command.name == "" {
|
| 47 |
+
t.Fatalf("clipboard command missing name: %+v", command)
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
}
|
cmd/pinchtab/cmd_daemon.go
ADDED
|
@@ -0,0 +1,700 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package main
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"errors"
|
| 5 |
+
"fmt"
|
| 6 |
+
"os"
|
| 7 |
+
"os/exec"
|
| 8 |
+
"os/user"
|
| 9 |
+
"path/filepath"
|
| 10 |
+
"runtime"
|
| 11 |
+
"strings"
|
| 12 |
+
|
| 13 |
+
"github.com/pinchtab/pinchtab/internal/cli"
|
| 14 |
+
"github.com/pinchtab/pinchtab/internal/config"
|
| 15 |
+
"github.com/spf13/cobra"
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
const (
|
| 19 |
+
pinchtabDaemonUnitName = "pinchtab.service"
|
| 20 |
+
pinchtabLaunchdLabel = "com.pinchtab.pinchtab"
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
var daemonCmd = &cobra.Command{
|
| 24 |
+
Use: "daemon [action]",
|
| 25 |
+
Short: "Manage the background service",
|
| 26 |
+
Long: "Start, stop, install, or check the status of the PinchTab background service.",
|
| 27 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 28 |
+
cfg := config.Load()
|
| 29 |
+
sub := ""
|
| 30 |
+
if len(args) > 0 {
|
| 31 |
+
sub = args[0]
|
| 32 |
+
}
|
| 33 |
+
handleDaemonCommand(cfg, sub)
|
| 34 |
+
},
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
func init() {
|
| 38 |
+
daemonCmd.GroupID = "primary"
|
| 39 |
+
rootCmd.AddCommand(daemonCmd)
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
func handleDaemonCommand(_ *config.RuntimeConfig, subcommand string) {
|
| 43 |
+
if subcommand == "" || subcommand == "help" || subcommand == "--help" || subcommand == "-h" {
|
| 44 |
+
printDaemonStatusSummary()
|
| 45 |
+
|
| 46 |
+
if subcommand == "" && isInteractiveTerminal() {
|
| 47 |
+
picked, err := promptSelect("Daemon Actions", daemonMenuOptions(cli.IsDaemonInstalled(), cli.IsDaemonRunning()))
|
| 48 |
+
if err != nil || picked == "exit" || picked == "" {
|
| 49 |
+
os.Exit(0)
|
| 50 |
+
}
|
| 51 |
+
subcommand = picked
|
| 52 |
+
} else {
|
| 53 |
+
daemonUsage()
|
| 54 |
+
if subcommand == "" {
|
| 55 |
+
os.Exit(0)
|
| 56 |
+
}
|
| 57 |
+
return
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
manager, err := currentDaemonManager()
|
| 62 |
+
if err != nil {
|
| 63 |
+
fmt.Fprintln(os.Stderr, cli.StyleStderr(cli.ErrorStyle, err.Error()))
|
| 64 |
+
os.Exit(1)
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
switch subcommand {
|
| 68 |
+
case "install":
|
| 69 |
+
configPath, fileCfg, _, err := ensureDaemonConfig(false)
|
| 70 |
+
if err != nil {
|
| 71 |
+
fmt.Fprintln(os.Stderr, cli.StyleStderr(cli.ErrorStyle, fmt.Sprintf("daemon install failed: %v", err)))
|
| 72 |
+
os.Exit(1)
|
| 73 |
+
}
|
| 74 |
+
// Run wizard if needed (first install or version upgrade)
|
| 75 |
+
if config.NeedsWizard(fileCfg) {
|
| 76 |
+
isNew := config.IsFirstRun(fileCfg)
|
| 77 |
+
runSecurityWizard(fileCfg, configPath, isNew)
|
| 78 |
+
}
|
| 79 |
+
if err := manager.Preflight(); err != nil {
|
| 80 |
+
fmt.Fprintln(os.Stderr, cli.StyleStderr(cli.ErrorStyle, fmt.Sprintf("daemon install unavailable: %v", err)))
|
| 81 |
+
os.Exit(1)
|
| 82 |
+
}
|
| 83 |
+
message, err := manager.Install(managerEnvironment(manager).execPath, configPath)
|
| 84 |
+
if err != nil {
|
| 85 |
+
fmt.Fprintln(os.Stderr, cli.StyleStderr(cli.ErrorStyle, fmt.Sprintf("daemon install failed: %v", err)))
|
| 86 |
+
fmt.Println()
|
| 87 |
+
fmt.Println(manager.ManualInstructions())
|
| 88 |
+
os.Exit(1)
|
| 89 |
+
}
|
| 90 |
+
fmt.Println(cli.StyleStdout(cli.SuccessStyle, " [ok] ") + message)
|
| 91 |
+
printDaemonFollowUp()
|
| 92 |
+
case "start":
|
| 93 |
+
printDaemonManagerResult(manager.Start())
|
| 94 |
+
case "restart":
|
| 95 |
+
printDaemonManagerResult(manager.Restart())
|
| 96 |
+
case "stop":
|
| 97 |
+
printDaemonManagerResult(manager.Stop())
|
| 98 |
+
case "uninstall":
|
| 99 |
+
message, err := manager.Uninstall()
|
| 100 |
+
if err != nil {
|
| 101 |
+
fmt.Fprintln(os.Stderr, cli.StyleStderr(cli.ErrorStyle, err.Error()))
|
| 102 |
+
fmt.Println()
|
| 103 |
+
fmt.Println(manager.ManualInstructions())
|
| 104 |
+
os.Exit(1)
|
| 105 |
+
}
|
| 106 |
+
fmt.Println(cli.StyleStdout(cli.SuccessStyle, " [ok] ") + message)
|
| 107 |
+
default:
|
| 108 |
+
fmt.Fprintln(os.Stderr, cli.StyleStderr(cli.ErrorStyle, fmt.Sprintf("unknown daemon command: %s", subcommand)))
|
| 109 |
+
daemonUsage()
|
| 110 |
+
os.Exit(2)
|
| 111 |
+
}
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
func daemonUsage() {
|
| 115 |
+
fmt.Println(cli.StyleStdout(cli.HeadingStyle, "Usage:") + " " + cli.StyleStdout(cli.CommandStyle, "pinchtab daemon <install|start|restart|stop|uninstall>"))
|
| 116 |
+
fmt.Println()
|
| 117 |
+
fmt.Println(cli.StyleStdout(cli.MutedStyle, "Manage the PinchTab user-level background service."))
|
| 118 |
+
fmt.Println()
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
func daemonMenuOptions(installed, running bool) []menuOption {
|
| 122 |
+
options := make([]menuOption, 0, 4)
|
| 123 |
+
switch {
|
| 124 |
+
case !installed:
|
| 125 |
+
options = append(options, menuOption{label: "Install service", value: "install"})
|
| 126 |
+
case running:
|
| 127 |
+
options = append(options,
|
| 128 |
+
menuOption{label: "Stop service", value: "stop"},
|
| 129 |
+
menuOption{label: "Restart service", value: "restart"},
|
| 130 |
+
menuOption{label: "Uninstall service", value: "uninstall"},
|
| 131 |
+
)
|
| 132 |
+
default:
|
| 133 |
+
options = append(options,
|
| 134 |
+
menuOption{label: "Start service", value: "start"},
|
| 135 |
+
menuOption{label: "Uninstall service", value: "uninstall"},
|
| 136 |
+
)
|
| 137 |
+
}
|
| 138 |
+
options = append(options, menuOption{label: "Exit", value: "exit"})
|
| 139 |
+
return options
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
func printDaemonStatusSummary() {
|
| 143 |
+
manager, err := currentDaemonManager()
|
| 144 |
+
if err != nil {
|
| 145 |
+
fmt.Println(cli.StyleStdout(cli.ErrorStyle, " Error: ") + err.Error())
|
| 146 |
+
return
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
installed := cli.IsDaemonInstalled()
|
| 150 |
+
running := cli.IsDaemonRunning()
|
| 151 |
+
|
| 152 |
+
fmt.Println(cli.StyleStdout(cli.HeadingStyle, "Daemon status:"))
|
| 153 |
+
|
| 154 |
+
status := cli.StyleStdout(cli.WarningStyle, "not installed")
|
| 155 |
+
if installed {
|
| 156 |
+
status = cli.StyleStdout(cli.SuccessStyle, "installed")
|
| 157 |
+
}
|
| 158 |
+
fmt.Printf(" %-12s %s\n", cli.StyleStdout(cli.MutedStyle, "Service:"), status)
|
| 159 |
+
|
| 160 |
+
state := cli.StyleStdout(cli.MutedStyle, "stopped")
|
| 161 |
+
if running {
|
| 162 |
+
state = cli.StyleStdout(cli.SuccessStyle, "active (running)")
|
| 163 |
+
}
|
| 164 |
+
fmt.Printf(" %-12s %s\n", cli.StyleStdout(cli.MutedStyle, "State:"), state)
|
| 165 |
+
|
| 166 |
+
if running {
|
| 167 |
+
pid, _ := manager.Pid()
|
| 168 |
+
if pid != "" {
|
| 169 |
+
fmt.Printf(" %-12s %s\n", cli.StyleStdout(cli.MutedStyle, "PID:"), cli.StyleStdout(cli.ValueStyle, pid))
|
| 170 |
+
}
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
if installed {
|
| 174 |
+
fmt.Printf(" %-12s %s\n", cli.StyleStdout(cli.MutedStyle, "Path:"), cli.StyleStdout(cli.ValueStyle, manager.ServicePath()))
|
| 175 |
+
}
|
| 176 |
+
if err := manager.Preflight(); err != nil {
|
| 177 |
+
fmt.Printf(" %-12s %s\n", cli.StyleStdout(cli.MutedStyle, "Environment:"), cli.StyleStdout(cli.WarningStyle, err.Error()))
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
if installed {
|
| 181 |
+
logs, err := manager.Logs(5)
|
| 182 |
+
if err == nil && strings.TrimSpace(logs) != "" {
|
| 183 |
+
fmt.Println()
|
| 184 |
+
fmt.Println(cli.StyleStdout(cli.HeadingStyle, "Recent logs:"))
|
| 185 |
+
lines := strings.Split(logs, "\n")
|
| 186 |
+
for _, line := range lines {
|
| 187 |
+
if strings.TrimSpace(line) != "" {
|
| 188 |
+
fmt.Printf(" %s\n", cli.StyleStdout(cli.MutedStyle, line))
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
}
|
| 192 |
+
}
|
| 193 |
+
fmt.Println()
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
func printDaemonManagerResult(message string, err error) {
|
| 197 |
+
if err != nil {
|
| 198 |
+
fmt.Fprintln(os.Stderr, cli.StyleStderr(cli.ErrorStyle, err.Error()))
|
| 199 |
+
os.Exit(1)
|
| 200 |
+
}
|
| 201 |
+
if strings.HasPrefix(message, "Installed") || strings.HasPrefix(message, "Pinchtab daemon") {
|
| 202 |
+
fmt.Println(cli.StyleStdout(cli.SuccessStyle, " [ok] ") + message)
|
| 203 |
+
} else {
|
| 204 |
+
// For status, it might be a block of text
|
| 205 |
+
fmt.Println(message)
|
| 206 |
+
}
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
func ensureDaemonConfig(force bool) (string, *config.FileConfig, configBootstrapStatus, error) {
|
| 210 |
+
_, configPath, err := config.LoadFileConfig()
|
| 211 |
+
if err != nil {
|
| 212 |
+
return "", nil, "", err
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
exists := fileExists(configPath)
|
| 216 |
+
if !exists || force {
|
| 217 |
+
defaults := config.DefaultFileConfig()
|
| 218 |
+
defaults.ConfigVersion = "" // Leave empty so wizard triggers on first install
|
| 219 |
+
token, err := config.GenerateAuthToken()
|
| 220 |
+
if err != nil {
|
| 221 |
+
return "", nil, "", err
|
| 222 |
+
}
|
| 223 |
+
defaults.Server.Token = token
|
| 224 |
+
if err := config.SaveFileConfig(&defaults, configPath); err != nil {
|
| 225 |
+
return "", nil, "", err
|
| 226 |
+
}
|
| 227 |
+
status := configCreated
|
| 228 |
+
if exists {
|
| 229 |
+
status = configRecovered
|
| 230 |
+
}
|
| 231 |
+
return configPath, &defaults, status, nil
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
// File exists — load it as-is, don't overwrite security settings.
|
| 235 |
+
// Security recovery is now handled by the wizard or `pinchtab security up`.
|
| 236 |
+
fileCfg, _, _ := config.LoadFileConfig()
|
| 237 |
+
if fileCfg == nil {
|
| 238 |
+
return configPath, nil, "", fmt.Errorf("failed to load existing config at %s", configPath)
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
// Only generate a token if one is missing (security essential)
|
| 242 |
+
if strings.TrimSpace(fileCfg.Server.Token) == "" {
|
| 243 |
+
token, err := config.GenerateAuthToken()
|
| 244 |
+
if err == nil {
|
| 245 |
+
fileCfg.Server.Token = token
|
| 246 |
+
_ = config.SaveFileConfig(fileCfg, configPath)
|
| 247 |
+
return configPath, fileCfg, configRecovered, nil
|
| 248 |
+
}
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
return configPath, fileCfg, configVerified, nil
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
type configBootstrapStatus string
|
| 255 |
+
|
| 256 |
+
const (
|
| 257 |
+
configCreated configBootstrapStatus = "created"
|
| 258 |
+
configRecovered configBootstrapStatus = "recovered"
|
| 259 |
+
configVerified configBootstrapStatus = "verified"
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
type commandRunner interface {
|
| 263 |
+
CombinedOutput(name string, arg ...string) ([]byte, error)
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
type osCommandRunner struct{}
|
| 267 |
+
|
| 268 |
+
func (r osCommandRunner) CombinedOutput(name string, arg ...string) ([]byte, error) {
|
| 269 |
+
return exec.Command(name, arg...).CombinedOutput() // #nosec G204 -- args are daemon manager controlled, not user input
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
type daemonEnvironment struct {
|
| 273 |
+
execPath string
|
| 274 |
+
homeDir string
|
| 275 |
+
osName string
|
| 276 |
+
userID string
|
| 277 |
+
xdgConfigHome string
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
type daemonManager interface {
|
| 281 |
+
Preflight() error
|
| 282 |
+
Install(execPath, configPath string) (string, error)
|
| 283 |
+
ServicePath() string
|
| 284 |
+
Start() (string, error)
|
| 285 |
+
Restart() (string, error)
|
| 286 |
+
Status() (string, error)
|
| 287 |
+
Stop() (string, error)
|
| 288 |
+
Uninstall() (string, error)
|
| 289 |
+
ManualInstructions() string
|
| 290 |
+
Pid() (string, error)
|
| 291 |
+
Logs(n int) (string, error)
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
type systemdUserManager struct {
|
| 295 |
+
env daemonEnvironment
|
| 296 |
+
runner commandRunner
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
type launchdManager struct {
|
| 300 |
+
env daemonEnvironment
|
| 301 |
+
runner commandRunner
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
func printDaemonFollowUp() {
|
| 305 |
+
fmt.Println()
|
| 306 |
+
fmt.Println(cli.StyleStdout(cli.HeadingStyle, "Follow-up commands:"))
|
| 307 |
+
fmt.Printf(" %s %s\n", cli.StyleStdout(cli.CommandStyle, "pinchtab daemon"), cli.StyleStdout(cli.MutedStyle, "# Check service health and logs"))
|
| 308 |
+
fmt.Printf(" %s %s\n", cli.StyleStdout(cli.CommandStyle, "pinchtab daemon restart"), cli.StyleStdout(cli.MutedStyle, "# Apply config changes"))
|
| 309 |
+
fmt.Printf(" %s %s\n", cli.StyleStdout(cli.CommandStyle, "pinchtab daemon stop"), cli.StyleStdout(cli.MutedStyle, "# Stop background service"))
|
| 310 |
+
fmt.Printf(" %s %s\n", cli.StyleStdout(cli.CommandStyle, "pinchtab daemon uninstall"), cli.StyleStdout(cli.MutedStyle, "# Remove service file"))
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
func currentDaemonManager() (daemonManager, error) {
|
| 314 |
+
env, err := currentDaemonEnvironment()
|
| 315 |
+
if err != nil {
|
| 316 |
+
return nil, err
|
| 317 |
+
}
|
| 318 |
+
return newDaemonManager(env, osCommandRunner{})
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
func currentDaemonEnvironment() (daemonEnvironment, error) {
|
| 322 |
+
execPath, err := os.Executable()
|
| 323 |
+
if err != nil {
|
| 324 |
+
return daemonEnvironment{}, fmt.Errorf("resolve executable path: %w", err)
|
| 325 |
+
}
|
| 326 |
+
homeDir, err := os.UserHomeDir()
|
| 327 |
+
if err != nil {
|
| 328 |
+
return daemonEnvironment{}, fmt.Errorf("resolve home directory: %w", err)
|
| 329 |
+
}
|
| 330 |
+
currentUser, err := user.Current()
|
| 331 |
+
if err != nil {
|
| 332 |
+
return daemonEnvironment{}, fmt.Errorf("resolve current user: %w", err)
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
return daemonEnvironment{
|
| 336 |
+
execPath: execPath,
|
| 337 |
+
homeDir: homeDir,
|
| 338 |
+
osName: runtime.GOOS,
|
| 339 |
+
userID: currentUser.Uid,
|
| 340 |
+
xdgConfigHome: os.Getenv("XDG_CONFIG_HOME"),
|
| 341 |
+
}, nil
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
func newDaemonManager(env daemonEnvironment, runner commandRunner) (daemonManager, error) {
|
| 345 |
+
switch env.osName {
|
| 346 |
+
case "linux":
|
| 347 |
+
return &systemdUserManager{env: env, runner: runner}, nil
|
| 348 |
+
case "darwin":
|
| 349 |
+
return &launchdManager{env: env, runner: runner}, nil
|
| 350 |
+
default:
|
| 351 |
+
return nil, fmt.Errorf("pinchtab daemon is supported on macOS and Linux; current OS is %s", env.osName)
|
| 352 |
+
}
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
func managerEnvironment(manager daemonManager) daemonEnvironment {
|
| 356 |
+
switch m := manager.(type) {
|
| 357 |
+
case *systemdUserManager:
|
| 358 |
+
return m.env
|
| 359 |
+
case *launchdManager:
|
| 360 |
+
return m.env
|
| 361 |
+
default:
|
| 362 |
+
return daemonEnvironment{}
|
| 363 |
+
}
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
func (m *systemdUserManager) ServicePath() string {
|
| 367 |
+
return filepath.Join(systemdUserConfigHome(m.env), "systemd", "user", pinchtabDaemonUnitName)
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
func (m *systemdUserManager) Preflight() error {
|
| 371 |
+
if _, err := runCommand(m.runner, "systemctl", "--user", "show-environment"); err != nil {
|
| 372 |
+
return fmt.Errorf("linux daemon install requires a working user systemd session (`systemctl --user`): %w", err)
|
| 373 |
+
}
|
| 374 |
+
return nil
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
func (m *systemdUserManager) Install(execPath, configPath string) (string, error) {
|
| 378 |
+
if err := os.MkdirAll(filepath.Dir(m.ServicePath()), 0755); err != nil {
|
| 379 |
+
return "", fmt.Errorf("create systemd user directory: %w", err)
|
| 380 |
+
}
|
| 381 |
+
if err := os.WriteFile(m.ServicePath(), []byte(renderSystemdUnit(execPath, configPath)), 0644); err != nil {
|
| 382 |
+
return "", fmt.Errorf("write systemd unit: %w", err)
|
| 383 |
+
}
|
| 384 |
+
if _, err := runCommand(m.runner, "systemctl", "--user", "daemon-reload"); err != nil {
|
| 385 |
+
return "", err
|
| 386 |
+
}
|
| 387 |
+
if _, err := runCommand(m.runner, "systemctl", "--user", "enable", "--now", pinchtabDaemonUnitName); err != nil {
|
| 388 |
+
return "", err
|
| 389 |
+
}
|
| 390 |
+
return fmt.Sprintf("Installed systemd user service at %s", m.ServicePath()), nil
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
func (m *systemdUserManager) Start() (string, error) {
|
| 394 |
+
if _, err := runCommand(m.runner, "systemctl", "--user", "start", pinchtabDaemonUnitName); err != nil {
|
| 395 |
+
return "", err
|
| 396 |
+
}
|
| 397 |
+
return "Pinchtab daemon started.", nil
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
func (m *systemdUserManager) Restart() (string, error) {
|
| 401 |
+
if _, err := runCommand(m.runner, "systemctl", "--user", "restart", pinchtabDaemonUnitName); err != nil {
|
| 402 |
+
return "", err
|
| 403 |
+
}
|
| 404 |
+
return "Pinchtab daemon restarted.", nil
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
func (m *systemdUserManager) Stop() (string, error) {
|
| 408 |
+
if _, err := runCommand(m.runner, "systemctl", "--user", "stop", pinchtabDaemonUnitName); err != nil {
|
| 409 |
+
return "", err
|
| 410 |
+
}
|
| 411 |
+
return "Pinchtab daemon stopped.", nil
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
func (m *systemdUserManager) Status() (string, error) {
|
| 415 |
+
output, err := runCommand(m.runner, "systemctl", "--user", "status", pinchtabDaemonUnitName, "--no-pager")
|
| 416 |
+
if err != nil {
|
| 417 |
+
return "", err
|
| 418 |
+
}
|
| 419 |
+
if strings.TrimSpace(output) == "" {
|
| 420 |
+
return "Pinchtab daemon status returned no output.", nil
|
| 421 |
+
}
|
| 422 |
+
return output, nil
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
func (m *systemdUserManager) Uninstall() (string, error) {
|
| 426 |
+
var errs []error
|
| 427 |
+
if _, err := runCommand(m.runner, "systemctl", "--user", "disable", "--now", pinchtabDaemonUnitName); err != nil {
|
| 428 |
+
errs = append(errs, err)
|
| 429 |
+
}
|
| 430 |
+
if err := os.Remove(m.ServicePath()); err != nil && !errors.Is(err, os.ErrNotExist) {
|
| 431 |
+
errs = append(errs, fmt.Errorf("remove unit file: %w", err))
|
| 432 |
+
}
|
| 433 |
+
if _, err := runCommand(m.runner, "systemctl", "--user", "daemon-reload"); err != nil {
|
| 434 |
+
errs = append(errs, err)
|
| 435 |
+
}
|
| 436 |
+
if len(errs) > 0 {
|
| 437 |
+
return "", errors.Join(errs...)
|
| 438 |
+
}
|
| 439 |
+
return "Pinchtab daemon uninstalled.", nil
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
func (m *systemdUserManager) Pid() (string, error) {
|
| 443 |
+
output, err := runCommand(m.runner, "systemctl", "--user", "show", pinchtabDaemonUnitName, "--property", "MainPID")
|
| 444 |
+
if err != nil {
|
| 445 |
+
return "", err
|
| 446 |
+
}
|
| 447 |
+
// Output is typically MainPID=1234
|
| 448 |
+
if parts := strings.Split(output, "="); len(parts) == 2 {
|
| 449 |
+
pid := strings.TrimSpace(parts[1])
|
| 450 |
+
if pid == "0" {
|
| 451 |
+
return "", nil // Not running
|
| 452 |
+
}
|
| 453 |
+
return pid, nil
|
| 454 |
+
}
|
| 455 |
+
return "", nil
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
func (m *systemdUserManager) Logs(n int) (string, error) {
|
| 459 |
+
return runCommand(m.runner, "journalctl", "--user", "-u", pinchtabDaemonUnitName, "-n", fmt.Sprintf("%d", n), "--no-pager")
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
func (m *systemdUserManager) ManualInstructions() string {
|
| 463 |
+
path := m.ServicePath()
|
| 464 |
+
var b strings.Builder
|
| 465 |
+
fmt.Fprintln(&b, cli.StyleStdout(cli.HeadingStyle, "Manual instructions (Linux/systemd):"))
|
| 466 |
+
fmt.Fprintln(&b, cli.StyleStdout(cli.MutedStyle, "To install manually:"))
|
| 467 |
+
fmt.Fprintf(&b, " 1. Create %s\n", cli.StyleStdout(cli.ValueStyle, path))
|
| 468 |
+
fmt.Fprintln(&b, " 2. Run: "+cli.StyleStdout(cli.CommandStyle, "systemctl --user daemon-reload"))
|
| 469 |
+
fmt.Fprintln(&b, " 3. Run: "+cli.StyleStdout(cli.CommandStyle, "systemctl --user enable --now pinchtab.service"))
|
| 470 |
+
fmt.Fprintln(&b)
|
| 471 |
+
fmt.Fprintln(&b, cli.StyleStdout(cli.MutedStyle, "To uninstall manually:"))
|
| 472 |
+
fmt.Fprintln(&b, " 1. Run: "+cli.StyleStdout(cli.CommandStyle, "systemctl --user disable --now pinchtab.service"))
|
| 473 |
+
fmt.Fprintf(&b, " 2. Remove: %s\n", cli.StyleStdout(cli.ValueStyle, path))
|
| 474 |
+
fmt.Fprintln(&b, " 3. Run: "+cli.StyleStdout(cli.CommandStyle, "systemctl --user daemon-reload"))
|
| 475 |
+
return b.String()
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
func renderSystemdUnit(execPath, configPath string) string {
|
| 479 |
+
return fmt.Sprintf(`[Unit]
|
| 480 |
+
Description=Pinchtab Browser Service
|
| 481 |
+
After=network.target
|
| 482 |
+
|
| 483 |
+
[Service]
|
| 484 |
+
Type=simple
|
| 485 |
+
ExecStart="%s" server
|
| 486 |
+
Environment="PINCHTAB_CONFIG=%s"
|
| 487 |
+
Restart=always
|
| 488 |
+
RestartSec=5
|
| 489 |
+
|
| 490 |
+
[Install]
|
| 491 |
+
WantedBy=default.target
|
| 492 |
+
`, execPath, configPath)
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
func (m *launchdManager) ServicePath() string {
|
| 496 |
+
return filepath.Join(m.env.homeDir, "Library", "LaunchAgents", pinchtabLaunchdLabel+".plist")
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
+
func (m *launchdManager) Preflight() error {
|
| 500 |
+
if strings.TrimSpace(m.env.userID) == "" {
|
| 501 |
+
return fmt.Errorf("macOS daemon install requires a logged-in user session with a launchd GUI domain")
|
| 502 |
+
}
|
| 503 |
+
if _, err := runCommand(m.runner, "launchctl", "print", launchdDomainTarget(m.env)); err != nil {
|
| 504 |
+
return fmt.Errorf("macOS daemon install requires an active launchd GUI session: %w", err)
|
| 505 |
+
}
|
| 506 |
+
return nil
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
func (m *launchdManager) Install(execPath, configPath string) (string, error) {
|
| 510 |
+
if err := os.MkdirAll(filepath.Dir(m.ServicePath()), 0755); err != nil {
|
| 511 |
+
return "", fmt.Errorf("create LaunchAgents directory: %w", err)
|
| 512 |
+
}
|
| 513 |
+
if err := os.WriteFile(m.ServicePath(), []byte(renderLaunchdPlist(execPath, configPath)), 0644); err != nil {
|
| 514 |
+
return "", fmt.Errorf("write launchd plist: %w", err)
|
| 515 |
+
}
|
| 516 |
+
_, _ = runCommand(m.runner, "launchctl", "bootout", launchdDomainTarget(m.env), m.ServicePath())
|
| 517 |
+
if _, err := runCommand(m.runner, "launchctl", "bootstrap", launchdDomainTarget(m.env), m.ServicePath()); err != nil {
|
| 518 |
+
return "", err
|
| 519 |
+
}
|
| 520 |
+
if _, err := runCommand(m.runner, "launchctl", "kickstart", "-k", launchdDomainTarget(m.env)+"/"+pinchtabLaunchdLabel); err != nil {
|
| 521 |
+
return "", err
|
| 522 |
+
}
|
| 523 |
+
return fmt.Sprintf("Installed launchd agent at %s", m.ServicePath()), nil
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
func (m *launchdManager) Start() (string, error) {
|
| 527 |
+
if _, err := runCommand(m.runner, "launchctl", "bootstrap", launchdDomainTarget(m.env), m.ServicePath()); err != nil && !strings.Contains(err.Error(), "already bootstrapped") {
|
| 528 |
+
return "", err
|
| 529 |
+
}
|
| 530 |
+
if _, err := runCommand(m.runner, "launchctl", "kickstart", launchdDomainTarget(m.env)+"/"+pinchtabLaunchdLabel); err != nil {
|
| 531 |
+
return "", err
|
| 532 |
+
}
|
| 533 |
+
return "Pinchtab daemon started.", nil
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
func (m *launchdManager) Restart() (string, error) {
|
| 537 |
+
if _, err := runCommand(m.runner, "launchctl", "kickstart", "-k", launchdDomainTarget(m.env)+"/"+pinchtabLaunchdLabel); err != nil {
|
| 538 |
+
return "", err
|
| 539 |
+
}
|
| 540 |
+
return "Pinchtab daemon restarted.", nil
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
func (m *launchdManager) Stop() (string, error) {
|
| 544 |
+
_, err := runCommand(m.runner, "launchctl", "bootout", launchdDomainTarget(m.env), m.ServicePath())
|
| 545 |
+
if err != nil && !isLaunchdIgnorableError(err) {
|
| 546 |
+
return "", err
|
| 547 |
+
}
|
| 548 |
+
return "Pinchtab daemon stopped.", nil
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
func (m *launchdManager) Status() (string, error) {
|
| 552 |
+
output, err := runCommand(m.runner, "launchctl", "print", launchdDomainTarget(m.env)+"/"+pinchtabLaunchdLabel)
|
| 553 |
+
if err != nil {
|
| 554 |
+
return "", err
|
| 555 |
+
}
|
| 556 |
+
if strings.TrimSpace(output) == "" {
|
| 557 |
+
return "Pinchtab daemon status returned no output.", nil
|
| 558 |
+
}
|
| 559 |
+
return output, nil
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
func (m *launchdManager) Uninstall() (string, error) {
|
| 563 |
+
var errs []error
|
| 564 |
+
_, err := runCommand(m.runner, "launchctl", "bootout", launchdDomainTarget(m.env), m.ServicePath())
|
| 565 |
+
if err != nil && !isLaunchdIgnorableError(err) {
|
| 566 |
+
errs = append(errs, err)
|
| 567 |
+
}
|
| 568 |
+
if err := os.Remove(m.ServicePath()); err != nil && !errors.Is(err, os.ErrNotExist) {
|
| 569 |
+
errs = append(errs, fmt.Errorf("remove launchd plist: %w", err))
|
| 570 |
+
}
|
| 571 |
+
if len(errs) > 0 {
|
| 572 |
+
return "", errors.Join(errs...)
|
| 573 |
+
}
|
| 574 |
+
return "Pinchtab daemon uninstalled.", nil
|
| 575 |
+
}
|
| 576 |
+
|
| 577 |
+
func (m *launchdManager) Pid() (string, error) {
|
| 578 |
+
output, err := runCommand(m.runner, "launchctl", "print", launchdDomainTarget(m.env)+"/"+pinchtabLaunchdLabel)
|
| 579 |
+
if err != nil {
|
| 580 |
+
return "", err
|
| 581 |
+
}
|
| 582 |
+
// Try to find pid = 1234
|
| 583 |
+
lines := strings.Split(output, "\n")
|
| 584 |
+
for _, line := range lines {
|
| 585 |
+
trimmed := strings.TrimSpace(line)
|
| 586 |
+
if strings.HasPrefix(trimmed, "pid = ") {
|
| 587 |
+
return strings.TrimPrefix(trimmed, "pid = "), nil
|
| 588 |
+
}
|
| 589 |
+
}
|
| 590 |
+
return "", nil
|
| 591 |
+
}
|
| 592 |
+
|
| 593 |
+
func (m *launchdManager) Logs(n int) (string, error) {
|
| 594 |
+
// macOS log paths we added to plist
|
| 595 |
+
logPath := "/tmp/pinchtab.err.log"
|
| 596 |
+
if _, err := os.Stat(logPath); err != nil {
|
| 597 |
+
return "No logs found at " + logPath, nil
|
| 598 |
+
}
|
| 599 |
+
return runCommand(m.runner, "tail", "-n", fmt.Sprintf("%d", n), logPath)
|
| 600 |
+
}
|
| 601 |
+
|
| 602 |
+
func (m *launchdManager) ManualInstructions() string {
|
| 603 |
+
path := m.ServicePath()
|
| 604 |
+
target := launchdDomainTarget(m.env)
|
| 605 |
+
var b strings.Builder
|
| 606 |
+
fmt.Fprintln(&b, cli.StyleStdout(cli.HeadingStyle, "Manual instructions (macOS/launchd):"))
|
| 607 |
+
fmt.Fprintln(&b, cli.StyleStdout(cli.MutedStyle, "To install manually:"))
|
| 608 |
+
fmt.Fprintf(&b, " 1. Create %s\n", cli.StyleStdout(cli.ValueStyle, path))
|
| 609 |
+
fmt.Fprintln(&b, " 2. Run: "+cli.StyleStdout(cli.CommandStyle, fmt.Sprintf("launchctl bootstrap %s %s", target, path)))
|
| 610 |
+
fmt.Fprintln(&b)
|
| 611 |
+
fmt.Fprintln(&b, cli.StyleStdout(cli.MutedStyle, "To uninstall manually:"))
|
| 612 |
+
fmt.Fprintln(&b, " 1. Run: "+cli.StyleStdout(cli.CommandStyle, fmt.Sprintf("launchctl bootout %s %s", target, path)))
|
| 613 |
+
fmt.Fprintf(&b, " 2. Remove: %s\n", cli.StyleStdout(cli.ValueStyle, path))
|
| 614 |
+
return b.String()
|
| 615 |
+
}
|
| 616 |
+
|
| 617 |
+
func isLaunchdIgnorableError(err error) bool {
|
| 618 |
+
if err == nil {
|
| 619 |
+
return true
|
| 620 |
+
}
|
| 621 |
+
msg := err.Error()
|
| 622 |
+
// Exit status 5 often means already booted out or path not found in domain
|
| 623 |
+
// "No such process" (status 3) or "No such file or directory" (status 2) or "Operation not permitted" (sometimes)
|
| 624 |
+
return strings.Contains(msg, "exit status 5") ||
|
| 625 |
+
strings.Contains(msg, "No such process") ||
|
| 626 |
+
strings.Contains(msg, "not found") ||
|
| 627 |
+
strings.Contains(msg, "already bootstrapped")
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
func renderLaunchdPlist(execPath, configPath string) string {
|
| 631 |
+
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
| 632 |
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
| 633 |
+
<plist version="1.0">
|
| 634 |
+
<dict>
|
| 635 |
+
<key>Label</key>
|
| 636 |
+
<string>%s</string>
|
| 637 |
+
<key>ProgramArguments</key>
|
| 638 |
+
<array>
|
| 639 |
+
<string>%s</string>
|
| 640 |
+
<string>server</string>
|
| 641 |
+
</array>
|
| 642 |
+
<key>RunAtLoad</key>
|
| 643 |
+
<true/>
|
| 644 |
+
<key>KeepAlive</key>
|
| 645 |
+
<true/>
|
| 646 |
+
<key>ExitTimeOut</key>
|
| 647 |
+
<integer>10</integer>
|
| 648 |
+
<key>EnvironmentVariables</key>
|
| 649 |
+
<dict>
|
| 650 |
+
<key>PINCHTAB_CONFIG</key>
|
| 651 |
+
<string>%s</string>
|
| 652 |
+
</dict>
|
| 653 |
+
<key>StandardOutPath</key>
|
| 654 |
+
<string>/tmp/pinchtab.out.log</string>
|
| 655 |
+
<key>StandardErrorPath</key>
|
| 656 |
+
<string>/tmp/pinchtab.err.log</string>
|
| 657 |
+
</dict>
|
| 658 |
+
</plist>
|
| 659 |
+
`, pinchtabLaunchdLabel, execPath, configPath)
|
| 660 |
+
}
|
| 661 |
+
|
| 662 |
+
func runCommand(runner commandRunner, name string, args ...string) (string, error) {
|
| 663 |
+
output, err := runner.CombinedOutput(name, args...)
|
| 664 |
+
trimmed := strings.TrimSpace(string(output))
|
| 665 |
+
if err == nil {
|
| 666 |
+
return trimmed, nil
|
| 667 |
+
}
|
| 668 |
+
if trimmed == "" {
|
| 669 |
+
return "", fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err)
|
| 670 |
+
}
|
| 671 |
+
return "", fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, trimmed)
|
| 672 |
+
}
|
| 673 |
+
|
| 674 |
+
func launchdDomainTarget(env daemonEnvironment) string {
|
| 675 |
+
return "gui/" + env.userID
|
| 676 |
+
}
|
| 677 |
+
|
| 678 |
+
func systemdUserConfigHome(env daemonEnvironment) string {
|
| 679 |
+
if strings.TrimSpace(env.xdgConfigHome) != "" {
|
| 680 |
+
return env.xdgConfigHome
|
| 681 |
+
}
|
| 682 |
+
return filepath.Join(env.homeDir, ".config")
|
| 683 |
+
}
|
| 684 |
+
|
| 685 |
+
func fileExists(path string) bool {
|
| 686 |
+
_, err := os.Stat(path)
|
| 687 |
+
return err == nil
|
| 688 |
+
}
|
| 689 |
+
|
| 690 |
+
func isInteractiveTerminal() bool {
|
| 691 |
+
in, err := os.Stdin.Stat()
|
| 692 |
+
if err != nil || (in.Mode()&os.ModeCharDevice) == 0 {
|
| 693 |
+
return false
|
| 694 |
+
}
|
| 695 |
+
out, err := os.Stdout.Stat()
|
| 696 |
+
if err != nil || (out.Mode()&os.ModeCharDevice) == 0 {
|
| 697 |
+
return false
|
| 698 |
+
}
|
| 699 |
+
return true
|
| 700 |
+
}
|
cmd/pinchtab/cmd_daemon_test.go
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package main
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"errors"
|
| 5 |
+
"os"
|
| 6 |
+
"path/filepath"
|
| 7 |
+
"strings"
|
| 8 |
+
"testing"
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
type fakeCommandRunner struct {
|
| 12 |
+
calls []string
|
| 13 |
+
outputs map[string]string
|
| 14 |
+
errors map[string]error
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
func (f *fakeCommandRunner) CombinedOutput(name string, args ...string) ([]byte, error) {
|
| 18 |
+
call := name + " " + strings.Join(args, " ")
|
| 19 |
+
f.calls = append(f.calls, call)
|
| 20 |
+
if out, ok := f.outputs[call]; ok {
|
| 21 |
+
return []byte(out), f.errors[call]
|
| 22 |
+
}
|
| 23 |
+
return nil, f.errors[call]
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
func TestEnsureOnboardConfigCreatesDefaultConfig(t *testing.T) {
|
| 27 |
+
configPath := filepath.Join(t.TempDir(), "pinchtab", "config.json")
|
| 28 |
+
t.Setenv("PINCHTAB_CONFIG", configPath)
|
| 29 |
+
t.Setenv("PINCHTAB_TOKEN", "")
|
| 30 |
+
t.Setenv("PINCHTAB_BIND", "")
|
| 31 |
+
|
| 32 |
+
gotPath, cfg, status, err := ensureDaemonConfig(false)
|
| 33 |
+
if err != nil {
|
| 34 |
+
t.Fatalf("ensureDaemonConfig returned error: %v", err)
|
| 35 |
+
}
|
| 36 |
+
if status != configCreated {
|
| 37 |
+
t.Fatalf("status = %q, want %q", status, configCreated)
|
| 38 |
+
}
|
| 39 |
+
if gotPath != configPath {
|
| 40 |
+
t.Fatalf("config path = %q, want %q", gotPath, configPath)
|
| 41 |
+
}
|
| 42 |
+
if cfg.Server.Bind != "127.0.0.1" {
|
| 43 |
+
t.Fatalf("bind = %q, want 127.0.0.1", cfg.Server.Bind)
|
| 44 |
+
}
|
| 45 |
+
if strings.TrimSpace(cfg.Server.Token) == "" {
|
| 46 |
+
t.Fatal("expected generated token to be set")
|
| 47 |
+
}
|
| 48 |
+
data, err := os.ReadFile(configPath)
|
| 49 |
+
if err != nil {
|
| 50 |
+
t.Fatalf("reading config file: %v", err)
|
| 51 |
+
}
|
| 52 |
+
content := string(data)
|
| 53 |
+
if !strings.Contains(content, `"bind": "127.0.0.1"`) {
|
| 54 |
+
t.Fatalf("expected config file to include bind, got %s", content)
|
| 55 |
+
}
|
| 56 |
+
if !strings.Contains(content, `"token": "`) {
|
| 57 |
+
t.Fatalf("expected config file to include token, got %s", content)
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
func TestEnsureOnboardConfigRecoversExistingSecuritySettings(t *testing.T) {
|
| 62 |
+
configPath := filepath.Join(t.TempDir(), "pinchtab", "config.json")
|
| 63 |
+
t.Setenv("PINCHTAB_CONFIG", configPath)
|
| 64 |
+
input := `{
|
| 65 |
+
"server": {
|
| 66 |
+
"bind": "0.0.0.0",
|
| 67 |
+
"port": "9999",
|
| 68 |
+
"token": ""
|
| 69 |
+
},
|
| 70 |
+
"browser": {
|
| 71 |
+
"binary": "/custom/chrome"
|
| 72 |
+
},
|
| 73 |
+
"security": {
|
| 74 |
+
"allowEvaluate": true
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
`
|
| 78 |
+
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
|
| 79 |
+
t.Fatalf("creating config dir: %v", err)
|
| 80 |
+
}
|
| 81 |
+
if err := os.WriteFile(configPath, []byte(input), 0644); err != nil {
|
| 82 |
+
t.Fatalf("writing config file: %v", err)
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
_, cfg, status, err := ensureDaemonConfig(false)
|
| 86 |
+
if err != nil {
|
| 87 |
+
t.Fatalf("ensureDaemonConfig returned error: %v", err)
|
| 88 |
+
}
|
| 89 |
+
// Only token recovery happens now — security settings are preserved for wizard
|
| 90 |
+
if status != configRecovered {
|
| 91 |
+
t.Fatalf("status = %q, want %q", status, configRecovered)
|
| 92 |
+
}
|
| 93 |
+
// Bind is preserved as-is (wizard handles security changes, not daemon config)
|
| 94 |
+
if cfg.Server.Bind != "0.0.0.0" {
|
| 95 |
+
t.Fatalf("bind = %q, want 0.0.0.0 (preserved)", cfg.Server.Bind)
|
| 96 |
+
}
|
| 97 |
+
if cfg.Server.Port != "9999" {
|
| 98 |
+
t.Fatalf("port = %q, want 9999", cfg.Server.Port)
|
| 99 |
+
}
|
| 100 |
+
if cfg.Browser.ChromeBinary != "/custom/chrome" {
|
| 101 |
+
t.Fatalf("chrome binary = %q, want /custom/chrome", cfg.Browser.ChromeBinary)
|
| 102 |
+
}
|
| 103 |
+
// Security settings preserved — not overwritten
|
| 104 |
+
if !boolPtrValue(cfg.Security.AllowEvaluate) {
|
| 105 |
+
t.Fatal("expected allowEvaluate to be preserved as true")
|
| 106 |
+
}
|
| 107 |
+
// Token should be generated (was empty)
|
| 108 |
+
if strings.TrimSpace(cfg.Server.Token) == "" {
|
| 109 |
+
t.Fatal("expected recovery to generate a token")
|
| 110 |
+
}
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
func TestSystemdUserManagerInstallWritesUnitAndEnablesService(t *testing.T) {
|
| 114 |
+
root := t.TempDir()
|
| 115 |
+
runner := &fakeCommandRunner{}
|
| 116 |
+
manager := &systemdUserManager{
|
| 117 |
+
env: daemonEnvironment{
|
| 118 |
+
homeDir: root,
|
| 119 |
+
osName: "linux",
|
| 120 |
+
execPath: "/usr/local/bin/pinchtab",
|
| 121 |
+
xdgConfigHome: filepath.Join(root, ".config"),
|
| 122 |
+
},
|
| 123 |
+
runner: runner,
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
message, err := manager.Install("/usr/local/bin/pinchtab", "/tmp/pinchtab/config.json")
|
| 127 |
+
if err != nil {
|
| 128 |
+
t.Fatalf("Install returned error: %v", err)
|
| 129 |
+
}
|
| 130 |
+
if !strings.Contains(message, manager.ServicePath()) {
|
| 131 |
+
t.Fatalf("install message = %q, want path %q", message, manager.ServicePath())
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
data, err := os.ReadFile(manager.ServicePath())
|
| 135 |
+
if err != nil {
|
| 136 |
+
t.Fatalf("reading service file: %v", err)
|
| 137 |
+
}
|
| 138 |
+
content := string(data)
|
| 139 |
+
if !strings.Contains(content, `ExecStart="/usr/local/bin/pinchtab" server`) {
|
| 140 |
+
t.Fatalf("unexpected unit content: %s", content)
|
| 141 |
+
}
|
| 142 |
+
if !strings.Contains(content, `Environment="PINCHTAB_CONFIG=/tmp/pinchtab/config.json"`) {
|
| 143 |
+
t.Fatalf("expected config env in unit content: %s", content)
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
expectedCalls := []string{
|
| 147 |
+
"systemctl --user daemon-reload",
|
| 148 |
+
"systemctl --user enable --now pinchtab.service",
|
| 149 |
+
}
|
| 150 |
+
if strings.Join(runner.calls, "\n") != strings.Join(expectedCalls, "\n") {
|
| 151 |
+
t.Fatalf("systemd calls = %v, want %v", runner.calls, expectedCalls)
|
| 152 |
+
}
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
func TestLaunchdManagerInstallWritesPlistAndBootstrapsAgent(t *testing.T) {
|
| 156 |
+
root := t.TempDir()
|
| 157 |
+
runner := &fakeCommandRunner{}
|
| 158 |
+
manager := &launchdManager{
|
| 159 |
+
env: daemonEnvironment{
|
| 160 |
+
homeDir: root,
|
| 161 |
+
osName: "darwin",
|
| 162 |
+
execPath: "/Applications/Pinchtab.app/Contents/MacOS/pinchtab",
|
| 163 |
+
userID: "501",
|
| 164 |
+
},
|
| 165 |
+
runner: runner,
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
message, err := manager.Install("/Applications/Pinchtab.app/Contents/MacOS/pinchtab", "/tmp/pinchtab/config.json")
|
| 169 |
+
if err != nil {
|
| 170 |
+
t.Fatalf("Install returned error: %v", err)
|
| 171 |
+
}
|
| 172 |
+
if !strings.Contains(message, manager.ServicePath()) {
|
| 173 |
+
t.Fatalf("install message = %q, want path %q", message, manager.ServicePath())
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
data, err := os.ReadFile(manager.ServicePath())
|
| 177 |
+
if err != nil {
|
| 178 |
+
t.Fatalf("reading launchd plist: %v", err)
|
| 179 |
+
}
|
| 180 |
+
content := string(data)
|
| 181 |
+
if !strings.Contains(content, "<string>com.pinchtab.pinchtab</string>") {
|
| 182 |
+
t.Fatalf("expected launchd label in plist: %s", content)
|
| 183 |
+
}
|
| 184 |
+
if !strings.Contains(content, "<string>/Applications/Pinchtab.app/Contents/MacOS/pinchtab</string>") {
|
| 185 |
+
t.Fatalf("expected executable path in plist: %s", content)
|
| 186 |
+
}
|
| 187 |
+
if !strings.Contains(content, "<string>/tmp/pinchtab/config.json</string>") {
|
| 188 |
+
t.Fatalf("expected config path in plist: %s", content)
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
expectedCalls := []string{
|
| 192 |
+
"launchctl bootout gui/501 " + manager.ServicePath(),
|
| 193 |
+
"launchctl bootstrap gui/501 " + manager.ServicePath(),
|
| 194 |
+
"launchctl kickstart -k gui/501/com.pinchtab.pinchtab",
|
| 195 |
+
}
|
| 196 |
+
if strings.Join(runner.calls, "\n") != strings.Join(expectedCalls, "\n") {
|
| 197 |
+
t.Fatalf("launchctl calls = %v, want %v", runner.calls, expectedCalls)
|
| 198 |
+
}
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
func TestSystemdUserManagerPreflightRequiresUserSession(t *testing.T) {
|
| 202 |
+
runner := &fakeCommandRunner{
|
| 203 |
+
errors: map[string]error{
|
| 204 |
+
"systemctl --user show-environment": errors.New("exit status 1"),
|
| 205 |
+
},
|
| 206 |
+
}
|
| 207 |
+
manager := &systemdUserManager{
|
| 208 |
+
env: daemonEnvironment{osName: "linux"},
|
| 209 |
+
runner: runner,
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
err := manager.Preflight()
|
| 213 |
+
if err == nil {
|
| 214 |
+
t.Fatal("expected preflight error")
|
| 215 |
+
}
|
| 216 |
+
if !strings.Contains(err.Error(), "working user systemd session") {
|
| 217 |
+
t.Fatalf("unexpected error: %v", err)
|
| 218 |
+
}
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
func TestLaunchdManagerPreflightRequiresGUIDomain(t *testing.T) {
|
| 222 |
+
runner := &fakeCommandRunner{
|
| 223 |
+
errors: map[string]error{
|
| 224 |
+
"launchctl print gui/501": errors.New("exit status 113"),
|
| 225 |
+
},
|
| 226 |
+
}
|
| 227 |
+
manager := &launchdManager{
|
| 228 |
+
env: daemonEnvironment{osName: "darwin", userID: "501"},
|
| 229 |
+
runner: runner,
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
err := manager.Preflight()
|
| 233 |
+
if err == nil {
|
| 234 |
+
t.Fatal("expected preflight error")
|
| 235 |
+
}
|
| 236 |
+
if !strings.Contains(err.Error(), "active launchd GUI session") {
|
| 237 |
+
t.Fatalf("unexpected error: %v", err)
|
| 238 |
+
}
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
func TestNewDaemonManagerRejectsUnsupportedOS(t *testing.T) {
|
| 242 |
+
_, err := newDaemonManager(daemonEnvironment{osName: "windows"}, &fakeCommandRunner{})
|
| 243 |
+
if err == nil {
|
| 244 |
+
t.Fatal("expected unsupported OS error")
|
| 245 |
+
}
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
func TestDaemonMenuOptions(t *testing.T) {
|
| 249 |
+
tests := []struct {
|
| 250 |
+
name string
|
| 251 |
+
installed bool
|
| 252 |
+
running bool
|
| 253 |
+
want []string
|
| 254 |
+
}{
|
| 255 |
+
{
|
| 256 |
+
name: "not installed",
|
| 257 |
+
installed: false,
|
| 258 |
+
running: false,
|
| 259 |
+
want: []string{"install", "exit"},
|
| 260 |
+
},
|
| 261 |
+
{
|
| 262 |
+
name: "installed stopped",
|
| 263 |
+
installed: true,
|
| 264 |
+
running: false,
|
| 265 |
+
want: []string{"start", "uninstall", "exit"},
|
| 266 |
+
},
|
| 267 |
+
{
|
| 268 |
+
name: "installed running",
|
| 269 |
+
installed: true,
|
| 270 |
+
running: true,
|
| 271 |
+
want: []string{"stop", "restart", "uninstall", "exit"},
|
| 272 |
+
},
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
for _, tt := range tests {
|
| 276 |
+
t.Run(tt.name, func(t *testing.T) {
|
| 277 |
+
got := daemonMenuOptions(tt.installed, tt.running)
|
| 278 |
+
if len(got) != len(tt.want) {
|
| 279 |
+
t.Fatalf("len(daemonMenuOptions()) = %d, want %d", len(got), len(tt.want))
|
| 280 |
+
}
|
| 281 |
+
for i, want := range tt.want {
|
| 282 |
+
if got[i].value != want {
|
| 283 |
+
t.Fatalf("daemonMenuOptions()[%d] = %q, want %q", i, got[i].value, want)
|
| 284 |
+
}
|
| 285 |
+
}
|
| 286 |
+
})
|
| 287 |
+
}
|
| 288 |
+
}
|
cmd/pinchtab/cmd_mcp.go
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package main
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"fmt"
|
| 5 |
+
"os"
|
| 6 |
+
"strings"
|
| 7 |
+
|
| 8 |
+
"github.com/pinchtab/pinchtab/internal/config"
|
| 9 |
+
"github.com/pinchtab/pinchtab/internal/mcp"
|
| 10 |
+
"github.com/spf13/cobra"
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
var mcpCmd = &cobra.Command{
|
| 14 |
+
Use: "mcp",
|
| 15 |
+
Short: "Start the MCP stdio server",
|
| 16 |
+
Long: "Start the Model Context Protocol stdio server and proxy browser actions to a running PinchTab instance.",
|
| 17 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 18 |
+
cfg := config.Load()
|
| 19 |
+
runMCP(cfg)
|
| 20 |
+
},
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
func init() {
|
| 24 |
+
mcpCmd.GroupID = "primary"
|
| 25 |
+
rootCmd.AddCommand(mcpCmd)
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
func runMCP(cfg *config.RuntimeConfig) {
|
| 29 |
+
// Default: http://127.0.0.1:{port}
|
| 30 |
+
port := cfg.Port
|
| 31 |
+
if port == "" {
|
| 32 |
+
port = "9867"
|
| 33 |
+
}
|
| 34 |
+
baseURL := "http://127.0.0.1:" + port
|
| 35 |
+
|
| 36 |
+
// --server flag overrides
|
| 37 |
+
if serverURL != "" {
|
| 38 |
+
baseURL = strings.TrimRight(serverURL, "/")
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
// Token from config, env var overrides
|
| 42 |
+
token := cfg.Token
|
| 43 |
+
if envToken := os.Getenv("PINCHTAB_TOKEN"); envToken != "" {
|
| 44 |
+
token = envToken
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
mcp.Version = version
|
| 48 |
+
|
| 49 |
+
if err := mcp.Serve(baseURL, token); err != nil {
|
| 50 |
+
fmt.Fprintf(os.Stderr, "mcp server error: %v\n", err)
|
| 51 |
+
os.Exit(1)
|
| 52 |
+
}
|
| 53 |
+
}
|
cmd/pinchtab/cmd_security.go
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package main
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"encoding/json"
|
| 5 |
+
"fmt"
|
| 6 |
+
"os"
|
| 7 |
+
"slices"
|
| 8 |
+
"strings"
|
| 9 |
+
|
| 10 |
+
"github.com/pinchtab/pinchtab/internal/cli"
|
| 11 |
+
"github.com/pinchtab/pinchtab/internal/config"
|
| 12 |
+
"github.com/spf13/cobra"
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
var securityCmd = &cobra.Command{
|
| 16 |
+
Use: "security",
|
| 17 |
+
Short: "Review runtime security posture",
|
| 18 |
+
Long: "Shows runtime security posture and offers to restore recommended security defaults.",
|
| 19 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 20 |
+
cfg := loadConfig()
|
| 21 |
+
handleSecurityCommand(cfg)
|
| 22 |
+
},
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
func init() {
|
| 26 |
+
securityCmd.GroupID = "config"
|
| 27 |
+
securityCmd.AddCommand(&cobra.Command{
|
| 28 |
+
Use: "up",
|
| 29 |
+
Short: "Apply recommended security defaults",
|
| 30 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 31 |
+
handleSecurityUpCommand()
|
| 32 |
+
},
|
| 33 |
+
})
|
| 34 |
+
securityCmd.AddCommand(&cobra.Command{
|
| 35 |
+
Use: "down",
|
| 36 |
+
Short: "Lower guards while keeping loopback bind and API auth enabled",
|
| 37 |
+
Run: func(cmd *cobra.Command, args []string) {
|
| 38 |
+
handleSecurityDownCommand()
|
| 39 |
+
},
|
| 40 |
+
})
|
| 41 |
+
rootCmd.AddCommand(securityCmd)
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
func handleSecurityCommand(cfg *config.RuntimeConfig) {
|
| 45 |
+
interactive := isInteractiveTerminal()
|
| 46 |
+
|
| 47 |
+
for {
|
| 48 |
+
posture := cli.AssessSecurityPosture(cfg)
|
| 49 |
+
warnings := cli.AssessSecurityWarnings(cfg)
|
| 50 |
+
recommended := cli.RecommendedSecurityDefaultLines(cfg)
|
| 51 |
+
|
| 52 |
+
printSecuritySummary(posture, interactive)
|
| 53 |
+
|
| 54 |
+
if len(warnings) > 0 {
|
| 55 |
+
fmt.Println()
|
| 56 |
+
fmt.Println(cli.StyleStdout(cli.HeadingStyle, "Warnings"))
|
| 57 |
+
fmt.Println()
|
| 58 |
+
for _, warning := range warnings {
|
| 59 |
+
fmt.Printf(" - %s\n", cli.StyleStdout(cli.WarningStyle, warning.Message))
|
| 60 |
+
for i := 0; i+1 < len(warning.Attrs); i += 2 {
|
| 61 |
+
key, ok := warning.Attrs[i].(string)
|
| 62 |
+
if !ok || key == "hint" {
|
| 63 |
+
continue
|
| 64 |
+
}
|
| 65 |
+
fmt.Printf(" %s: %s\n", cli.StyleStdout(cli.MutedStyle, key), cli.StyleStdout(cli.ValueStyle, formatSecurityValue(warning.Attrs[i+1])))
|
| 66 |
+
}
|
| 67 |
+
for i := 0; i+1 < len(warning.Attrs); i += 2 {
|
| 68 |
+
key, ok := warning.Attrs[i].(string)
|
| 69 |
+
if ok && key == "hint" {
|
| 70 |
+
fmt.Printf(" %s: %s\n", cli.StyleStdout(cli.MutedStyle, "hint"), cli.StyleStdout(cli.ValueStyle, formatSecurityValue(warning.Attrs[i+1])))
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
if len(recommended) == 0 && len(warnings) == 0 {
|
| 77 |
+
fmt.Println()
|
| 78 |
+
fmt.Println(" " + cli.StyleStdout(cli.SuccessStyle, "All recommended security defaults are active."))
|
| 79 |
+
} else if len(recommended) > 0 {
|
| 80 |
+
fmt.Println()
|
| 81 |
+
fmt.Println(cli.StyleStdout(cli.HeadingStyle, "Recommended defaults"))
|
| 82 |
+
fmt.Println()
|
| 83 |
+
printRecommendedSecurityDefaults(recommended)
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
if !interactive {
|
| 87 |
+
if len(recommended) > 0 {
|
| 88 |
+
fmt.Println()
|
| 89 |
+
fmt.Println(cli.StyleStdout(cli.MutedStyle, "Interactive editing skipped because stdin/stdout is not a terminal."))
|
| 90 |
+
}
|
| 91 |
+
return
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
nextCfg, changed, done, err := promptSecurityEdit(cfg, posture, len(recommended) > 0)
|
| 95 |
+
if err != nil {
|
| 96 |
+
fmt.Fprintln(os.Stderr, cli.StyleStderr(cli.ErrorStyle, err.Error()))
|
| 97 |
+
os.Exit(1)
|
| 98 |
+
}
|
| 99 |
+
if done {
|
| 100 |
+
return
|
| 101 |
+
}
|
| 102 |
+
if !changed {
|
| 103 |
+
fmt.Println()
|
| 104 |
+
fmt.Println(cli.StyleStdout(cli.MutedStyle, "No changes made."))
|
| 105 |
+
return
|
| 106 |
+
}
|
| 107 |
+
cfg = nextCfg
|
| 108 |
+
fmt.Println()
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
func formatSecurityValue(value any) string {
|
| 113 |
+
switch v := value.(type) {
|
| 114 |
+
case []string:
|
| 115 |
+
return strings.Join(v, ", ")
|
| 116 |
+
default:
|
| 117 |
+
return fmt.Sprint(v)
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
func printRecommendedSecurityDefaults(lines []string) {
|
| 122 |
+
for _, line := range lines {
|
| 123 |
+
fmt.Printf(" - %s\n", cli.StyleStdout(cli.ValueStyle, line))
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
func printSecuritySummary(posture cli.SecurityPosture, interactive bool) {
|
| 128 |
+
fmt.Println(cli.StyleStdout(cli.HeadingStyle, "Security"))
|
| 129 |
+
fmt.Println()
|
| 130 |
+
fmt.Printf(" %s %s\n", posture.Bar, cli.StyleStdout(cli.ValueStyle, posture.Level))
|
| 131 |
+
for i, check := range posture.Checks {
|
| 132 |
+
indicator := cli.StyleStdout(cli.WarningStyle, "!!")
|
| 133 |
+
if check.Passed {
|
| 134 |
+
indicator = cli.StyleStdout(cli.SuccessStyle, "ok")
|
| 135 |
+
}
|
| 136 |
+
if interactive {
|
| 137 |
+
fmt.Printf(" %d. %s %-20s %s\n", i+1, indicator, check.Label, check.Detail)
|
| 138 |
+
continue
|
| 139 |
+
}
|
| 140 |
+
fmt.Printf(" %s %-20s %s\n", indicator, check.Label, check.Detail)
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
func promptSecurityEdit(cfg *config.RuntimeConfig, posture cli.SecurityPosture, canRestoreDefaults bool) (*config.RuntimeConfig, bool, bool, error) {
|
| 145 |
+
fmt.Println()
|
| 146 |
+
prompt := "Edit item (1-8"
|
| 147 |
+
if canRestoreDefaults {
|
| 148 |
+
prompt += ", u = security up"
|
| 149 |
+
}
|
| 150 |
+
prompt += ", d = security down, blank to exit):"
|
| 151 |
+
|
| 152 |
+
choice, err := promptInput(cli.StyleStdout(cli.HeadingStyle, prompt), "")
|
| 153 |
+
if err != nil {
|
| 154 |
+
return nil, false, false, err
|
| 155 |
+
}
|
| 156 |
+
choice = strings.ToLower(strings.TrimSpace(choice))
|
| 157 |
+
if choice == "" {
|
| 158 |
+
return nil, false, true, nil
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
if (choice == "u" || choice == "up") && canRestoreDefaults {
|
| 162 |
+
nextCfg, changed, err := applySecurityUp()
|
| 163 |
+
return nextCfg, changed, false, err
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
if choice == "d" || choice == "down" {
|
| 167 |
+
nextCfg, changed, err := applySecurityDown()
|
| 168 |
+
return nextCfg, changed, false, err
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
index := strings.TrimSpace(choice)
|
| 172 |
+
for i, check := range posture.Checks {
|
| 173 |
+
if index == fmt.Sprint(i+1) {
|
| 174 |
+
nextCfg, changed, err := editSecurityCheck(cfg, check)
|
| 175 |
+
return nextCfg, changed, false, err
|
| 176 |
+
}
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
return nil, false, false, fmt.Errorf("invalid selection %q", choice)
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
func editSecurityCheck(cfg *config.RuntimeConfig, check cli.SecurityPostureCheck) (*config.RuntimeConfig, bool, error) {
|
| 183 |
+
switch check.ID {
|
| 184 |
+
case "bind_loopback":
|
| 185 |
+
value, err := promptInput("Set server.bind:", cfg.Bind)
|
| 186 |
+
if err != nil {
|
| 187 |
+
return nil, false, err
|
| 188 |
+
}
|
| 189 |
+
return updateConfigValue("server.bind", value)
|
| 190 |
+
case "api_auth_enabled":
|
| 191 |
+
picked, err := promptSelect("API authentication", []menuOption{
|
| 192 |
+
{label: "Generate new token (Recommended)", value: "generate"},
|
| 193 |
+
{label: "Set custom token", value: "custom"},
|
| 194 |
+
{label: "Disable token", value: "disable"},
|
| 195 |
+
{label: "Cancel", value: "cancel"},
|
| 196 |
+
})
|
| 197 |
+
if err != nil || picked == "" || picked == "cancel" {
|
| 198 |
+
return cfg, false, nil
|
| 199 |
+
}
|
| 200 |
+
switch picked {
|
| 201 |
+
case "generate":
|
| 202 |
+
token, err := config.GenerateAuthToken()
|
| 203 |
+
if err != nil {
|
| 204 |
+
return nil, false, err
|
| 205 |
+
}
|
| 206 |
+
return updateConfigValue("server.token", token)
|
| 207 |
+
case "custom":
|
| 208 |
+
token, err := promptInput("Set server.token:", cfg.Token)
|
| 209 |
+
if err != nil {
|
| 210 |
+
return nil, false, err
|
| 211 |
+
}
|
| 212 |
+
return updateConfigValue("server.token", token)
|
| 213 |
+
case "disable":
|
| 214 |
+
return updateConfigValue("server.token", "")
|
| 215 |
+
}
|
| 216 |
+
case "sensitive_endpoints_disabled":
|
| 217 |
+
current := strings.Join(cfg.EnabledSensitiveEndpoints(), ",")
|
| 218 |
+
value, err := promptInput("Enable sensitive endpoints (evaluate,macro,screencast,download,upload; blank = disable all):", current)
|
| 219 |
+
if err != nil {
|
| 220 |
+
return nil, false, err
|
| 221 |
+
}
|
| 222 |
+
return updateSensitiveEndpoints(value)
|
| 223 |
+
case "attach_disabled":
|
| 224 |
+
picked, err := promptSelect("Attach endpoint", []menuOption{
|
| 225 |
+
{label: "Disable (Recommended)", value: "disable"},
|
| 226 |
+
{label: "Enable", value: "enable"},
|
| 227 |
+
{label: "Cancel", value: "cancel"},
|
| 228 |
+
})
|
| 229 |
+
if err != nil || picked == "" || picked == "cancel" {
|
| 230 |
+
return cfg, false, nil
|
| 231 |
+
}
|
| 232 |
+
return updateConfigValue("security.attach.enabled", fmt.Sprintf("%t", picked == "enable"))
|
| 233 |
+
case "attach_local_only":
|
| 234 |
+
value, err := promptInput("Set security.attach.allowHosts (comma-separated):", strings.Join(cfg.AttachAllowHosts, ","))
|
| 235 |
+
if err != nil {
|
| 236 |
+
return nil, false, err
|
| 237 |
+
}
|
| 238 |
+
return updateConfigValue("security.attach.allowHosts", value)
|
| 239 |
+
case "idpi_whitelist_scoped":
|
| 240 |
+
value, err := promptInput("Set security.idpi.allowedDomains (comma-separated):", strings.Join(cfg.IDPI.AllowedDomains, ","))
|
| 241 |
+
if err != nil {
|
| 242 |
+
return nil, false, err
|
| 243 |
+
}
|
| 244 |
+
return updateConfigValue("security.idpi.allowedDomains", value)
|
| 245 |
+
case "idpi_strict_mode":
|
| 246 |
+
picked, err := promptSelect("IDPI strict mode", []menuOption{
|
| 247 |
+
{label: "Enforce (Recommended)", value: "true"},
|
| 248 |
+
{label: "Warn only", value: "false"},
|
| 249 |
+
{label: "Cancel", value: "cancel"},
|
| 250 |
+
})
|
| 251 |
+
if err != nil || picked == "" || picked == "cancel" {
|
| 252 |
+
return cfg, false, nil
|
| 253 |
+
}
|
| 254 |
+
return updateConfigValue("security.idpi.strictMode", picked)
|
| 255 |
+
case "idpi_content_protection":
|
| 256 |
+
picked, err := promptSelect("IDPI content guard", []menuOption{
|
| 257 |
+
{label: "Active: scan + wrap (Recommended)", value: "both"},
|
| 258 |
+
{label: "Scan only", value: "scan"},
|
| 259 |
+
{label: "Wrap only", value: "wrap"},
|
| 260 |
+
{label: "Disable", value: "off"},
|
| 261 |
+
{label: "Cancel", value: "cancel"},
|
| 262 |
+
})
|
| 263 |
+
if err != nil || picked == "" || picked == "cancel" {
|
| 264 |
+
return cfg, false, nil
|
| 265 |
+
}
|
| 266 |
+
return updateContentGuard(picked)
|
| 267 |
+
}
|
| 268 |
+
return cfg, false, nil
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
func updateConfigValue(path, value string) (*config.RuntimeConfig, bool, error) {
|
| 272 |
+
fc, configPath, err := config.LoadFileConfig()
|
| 273 |
+
if err != nil {
|
| 274 |
+
return nil, false, fmt.Errorf("load config: %w", err)
|
| 275 |
+
}
|
| 276 |
+
if err := config.SetConfigValue(fc, path, value); err != nil {
|
| 277 |
+
return nil, false, fmt.Errorf("set %s: %w", path, err)
|
| 278 |
+
}
|
| 279 |
+
if errs := config.ValidateFileConfig(fc); len(errs) > 0 {
|
| 280 |
+
return nil, false, errs[0]
|
| 281 |
+
}
|
| 282 |
+
if err := config.SaveFileConfig(fc, configPath); err != nil {
|
| 283 |
+
return nil, false, fmt.Errorf("save config: %w", err)
|
| 284 |
+
}
|
| 285 |
+
return config.Load(), true, nil
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
func updateSensitiveEndpoints(value string) (*config.RuntimeConfig, bool, error) {
|
| 289 |
+
fc, configPath, err := config.LoadFileConfig()
|
| 290 |
+
if err != nil {
|
| 291 |
+
return nil, false, fmt.Errorf("load config: %w", err)
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
selected := map[string]bool{}
|
| 295 |
+
for _, item := range splitCommaList(value) {
|
| 296 |
+
selected[item] = true
|
| 297 |
+
}
|
| 298 |
+
for endpoint, path := range map[string]string{
|
| 299 |
+
"evaluate": "security.allowEvaluate",
|
| 300 |
+
"macro": "security.allowMacro",
|
| 301 |
+
"screencast": "security.allowScreencast",
|
| 302 |
+
"download": "security.allowDownload",
|
| 303 |
+
"upload": "security.allowUpload",
|
| 304 |
+
} {
|
| 305 |
+
enabled := selected[endpoint]
|
| 306 |
+
if err := config.SetConfigValue(fc, path, fmt.Sprintf("%t", enabled)); err != nil {
|
| 307 |
+
return nil, false, fmt.Errorf("set %s: %w", endpoint, err)
|
| 308 |
+
}
|
| 309 |
+
}
|
| 310 |
+
if errs := config.ValidateFileConfig(fc); len(errs) > 0 {
|
| 311 |
+
return nil, false, errs[0]
|
| 312 |
+
}
|
| 313 |
+
if err := config.SaveFileConfig(fc, configPath); err != nil {
|
| 314 |
+
return nil, false, fmt.Errorf("save config: %w", err)
|
| 315 |
+
}
|
| 316 |
+
return config.Load(), true, nil
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
func updateContentGuard(mode string) (*config.RuntimeConfig, bool, error) {
|
| 320 |
+
fc, configPath, err := config.LoadFileConfig()
|
| 321 |
+
if err != nil {
|
| 322 |
+
return nil, false, fmt.Errorf("load config: %w", err)
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
scan := mode == "both" || mode == "scan"
|
| 326 |
+
wrap := mode == "both" || mode == "wrap"
|
| 327 |
+
for _, item := range []struct {
|
| 328 |
+
path string
|
| 329 |
+
value bool
|
| 330 |
+
}{
|
| 331 |
+
{path: "security.idpi.scanContent", value: scan},
|
| 332 |
+
{path: "security.idpi.wrapContent", value: wrap},
|
| 333 |
+
} {
|
| 334 |
+
if err := config.SetConfigValue(fc, item.path, fmt.Sprintf("%t", item.value)); err != nil {
|
| 335 |
+
return nil, false, fmt.Errorf("set %s: %w", item.path, err)
|
| 336 |
+
}
|
| 337 |
+
}
|
| 338 |
+
if errs := config.ValidateFileConfig(fc); len(errs) > 0 {
|
| 339 |
+
return nil, false, errs[0]
|
| 340 |
+
}
|
| 341 |
+
if err := config.SaveFileConfig(fc, configPath); err != nil {
|
| 342 |
+
return nil, false, fmt.Errorf("save config: %w", err)
|
| 343 |
+
}
|
| 344 |
+
return config.Load(), true, nil
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
func applyGuardsDownPreset() (*config.RuntimeConfig, string, bool, error) {
|
| 348 |
+
fc, configPath, err := config.LoadFileConfig()
|
| 349 |
+
if err != nil {
|
| 350 |
+
return nil, "", false, fmt.Errorf("load config: %w", err)
|
| 351 |
+
}
|
| 352 |
+
originalJSON, err := formatFileConfigJSON(fc)
|
| 353 |
+
if err != nil {
|
| 354 |
+
return nil, "", false, err
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
original, err := config.GetConfigValue(fc, "server.token")
|
| 358 |
+
if err != nil {
|
| 359 |
+
return nil, "", false, fmt.Errorf("read server.token: %w", err)
|
| 360 |
+
}
|
| 361 |
+
if strings.TrimSpace(original) == "" {
|
| 362 |
+
token, err := config.GenerateAuthToken()
|
| 363 |
+
if err != nil {
|
| 364 |
+
return nil, "", false, fmt.Errorf("generate token: %w", err)
|
| 365 |
+
}
|
| 366 |
+
if err := config.SetConfigValue(fc, "server.token", token); err != nil {
|
| 367 |
+
return nil, "", false, fmt.Errorf("set server.token: %w", err)
|
| 368 |
+
}
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
for _, item := range []struct {
|
| 372 |
+
path string
|
| 373 |
+
value string
|
| 374 |
+
}{
|
| 375 |
+
{path: "server.bind", value: "127.0.0.1"},
|
| 376 |
+
{path: "security.allowEvaluate", value: "true"},
|
| 377 |
+
{path: "security.allowMacro", value: "true"},
|
| 378 |
+
{path: "security.allowScreencast", value: "true"},
|
| 379 |
+
{path: "security.allowDownload", value: "true"},
|
| 380 |
+
{path: "security.allowUpload", value: "true"},
|
| 381 |
+
{path: "security.attach.enabled", value: "true"},
|
| 382 |
+
{path: "security.attach.allowHosts", value: "127.0.0.1,localhost,::1"},
|
| 383 |
+
{path: "security.attach.allowSchemes", value: "ws,wss"},
|
| 384 |
+
{path: "security.idpi.enabled", value: "false"},
|
| 385 |
+
{path: "security.idpi.strictMode", value: "false"},
|
| 386 |
+
{path: "security.idpi.scanContent", value: "false"},
|
| 387 |
+
{path: "security.idpi.wrapContent", value: "false"},
|
| 388 |
+
} {
|
| 389 |
+
if err := config.SetConfigValue(fc, item.path, item.value); err != nil {
|
| 390 |
+
return nil, "", false, fmt.Errorf("set %s: %w", item.path, err)
|
| 391 |
+
}
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
if errs := config.ValidateFileConfig(fc); len(errs) > 0 {
|
| 395 |
+
return nil, "", false, errs[0]
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
nextJSON, err := formatFileConfigJSON(fc)
|
| 399 |
+
if err != nil {
|
| 400 |
+
return nil, "", false, err
|
| 401 |
+
}
|
| 402 |
+
changed := originalJSON != nextJSON
|
| 403 |
+
if !changed {
|
| 404 |
+
return config.Load(), configPath, false, nil
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
if err := config.SaveFileConfig(fc, configPath); err != nil {
|
| 408 |
+
return nil, "", false, fmt.Errorf("save config: %w", err)
|
| 409 |
+
}
|
| 410 |
+
return config.Load(), configPath, true, nil
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
func applySecurityUp() (*config.RuntimeConfig, bool, error) {
|
| 414 |
+
configPath, changed, err := cli.RestoreSecurityDefaults()
|
| 415 |
+
if err != nil {
|
| 416 |
+
return nil, false, fmt.Errorf("restore defaults: %w", err)
|
| 417 |
+
}
|
| 418 |
+
if !changed {
|
| 419 |
+
fmt.Println(cli.StyleStdout(cli.MutedStyle, fmt.Sprintf("Security defaults already match %s", configPath)))
|
| 420 |
+
return config.Load(), false, nil
|
| 421 |
+
}
|
| 422 |
+
fmt.Println(cli.StyleStdout(cli.SuccessStyle, fmt.Sprintf("Security defaults restored in %s", configPath)))
|
| 423 |
+
fmt.Println(cli.StyleStdout(cli.MutedStyle, "Restart PinchTab to apply file-based changes."))
|
| 424 |
+
return config.Load(), true, nil
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
func applySecurityDown() (*config.RuntimeConfig, bool, error) {
|
| 428 |
+
nextCfg, configPath, changed, err := applyGuardsDownPreset()
|
| 429 |
+
if err != nil {
|
| 430 |
+
return nil, false, fmt.Errorf("guards down: %w", err)
|
| 431 |
+
}
|
| 432 |
+
if !changed {
|
| 433 |
+
fmt.Println(cli.StyleStdout(cli.MutedStyle, fmt.Sprintf("Guards down preset already matches %s", configPath)))
|
| 434 |
+
return nextCfg, false, nil
|
| 435 |
+
}
|
| 436 |
+
fmt.Println(cli.StyleStdout(cli.WarningStyle, fmt.Sprintf("Guards down preset applied in %s", configPath)))
|
| 437 |
+
fmt.Println(cli.StyleStdout(cli.MutedStyle, "Loopback bind and API auth remain enabled; sensitive endpoints and attach are enabled, IDPI is disabled."))
|
| 438 |
+
return nextCfg, true, nil
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
func handleSecurityUpCommand() {
|
| 442 |
+
if _, _, err := applySecurityUp(); err != nil {
|
| 443 |
+
fmt.Fprintln(os.Stderr, cli.StyleStderr(cli.ErrorStyle, err.Error()))
|
| 444 |
+
os.Exit(1)
|
| 445 |
+
}
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
func handleSecurityDownCommand() {
|
| 449 |
+
if _, _, err := applySecurityDown(); err != nil {
|
| 450 |
+
fmt.Fprintln(os.Stderr, cli.StyleStderr(cli.ErrorStyle, err.Error()))
|
| 451 |
+
os.Exit(1)
|
| 452 |
+
}
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
func formatFileConfigJSON(fc *config.FileConfig) (string, error) {
|
| 456 |
+
data, err := json.Marshal(fc)
|
| 457 |
+
if err != nil {
|
| 458 |
+
return "", fmt.Errorf("marshal config: %w", err)
|
| 459 |
+
}
|
| 460 |
+
return string(data), nil
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
func splitCommaList(value string) []string {
|
| 464 |
+
parts := strings.Split(value, ",")
|
| 465 |
+
items := make([]string, 0, len(parts))
|
| 466 |
+
for _, part := range parts {
|
| 467 |
+
trimmed := strings.TrimSpace(strings.ToLower(part))
|
| 468 |
+
if trimmed != "" {
|
| 469 |
+
items = append(items, trimmed)
|
| 470 |
+
}
|
| 471 |
+
}
|
| 472 |
+
slices.Sort(items)
|
| 473 |
+
return slices.Compact(items)
|
| 474 |
+
}
|