chore: import upstream snapshot with attribution
Docker Publish / docker (web, apps/web/Dockerfile, web) (push) Failing after 0s
Docker Publish / docker (api, apps/api/Dockerfile, api) (push) Failing after 1s
Publish Extension / detect-version (push) Has been skipped
Publish Extension / submit (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:24:41 +08:00
commit d18ada4ee7
567 changed files with 118275 additions and 0 deletions
@@ -0,0 +1,46 @@
---
name: orpc-contract-first
description: Guide for implementing oRPC contract-first API patterns in Dify frontend. Triggers when creating new API contracts, adding service endpoints, integrating TanStack Query with typed contracts, or migrating legacy service calls to oRPC. Use for all API layer work in web/contract and web/service directories.
---
# oRPC Contract-First Development
## Project Structure
```
web/contract/
├── base.ts # Base contract (inputStructure: 'detailed')
├── router.ts # Router composition & type exports
├── marketplace.ts # Marketplace contracts
└── console/ # Console contracts by domain
├── system.ts
└── billing.ts
```
## Workflow
1. **Create contract** in `web/contract/console/{domain}.ts`
- Import `base` from `../base` and `type` from `@orpc/contract`
- Define route with `path`, `method`, `input`, `output`
2. **Register in router** at `web/contract/router.ts`
- Import directly from domain file (no barrel files)
- Nest by API prefix: `billing: { invoices, bindPartnerStack }`
3. **Create hooks** in `web/service/use-{domain}.ts`
- Use `consoleQuery.{group}.{contract}.queryKey()` for query keys
- Use `consoleClient.{group}.{contract}()` for API calls
## Key Rules
- **Input structure**: Always use `{ params, query?, body? }` format
- **Path params**: Use `{paramName}` in path, match in `params` object
- **Router nesting**: Group by API prefix (e.g., `/billing/*``billing: {}`)
- **No barrel files**: Import directly from specific files
- **Types**: Import from `@/types/`, use `type<T>()` helper
## Type Export
```typescript
export type ConsoleInputs = InferContractRouterInputs<typeof consoleRouterContract>
```
+500
View File
@@ -0,0 +1,500 @@
---
name: release-pro-max
description: Universal release workflow. Auto-detects version files and changelogs. Supports Node.js, Python, Rust, Claude Plugin, and generic projects. Use when user says "release", "发布", "new version", "bump version", "push", "推送".
---
# Release Skills
Universal release workflow supporting any project type with multi-language changelog.
## Quick Start
Just run `/release-pro-max` - auto-detects your project configuration.
## Supported Projects
| Project Type | Version File | Auto-Detected |
|--------------|--------------|---------------|
| Node.js | package.json | ✓ |
| Python | pyproject.toml | ✓ |
| Rust | Cargo.toml | ✓ |
| Claude Plugin | marketplace.json | ✓ |
| Generic | VERSION / version.txt | ✓ |
## Options
| Flag | Description |
|------|-------------|
| `--dry-run` | Preview changes without executing |
| `--major` | Force major version bump |
| `--minor` | Force minor version bump |
| `--patch` | Force patch version bump |
## Workflow
### Step 1: Detect Project Configuration
1. Check for `.releaserc.yml` (optional config override)
2. Auto-detect version file by scanning (priority order):
- `package.json` (Node.js)
- `pyproject.toml` (Python)
- `Cargo.toml` (Rust)
- `marketplace.json` or `.claude-plugin/marketplace.json` (Claude Plugin)
- `VERSION` or `version.txt` (Generic)
3. Scan for changelog files using glob patterns:
- `CHANGELOG*.md`
- `HISTORY*.md`
- `CHANGES*.md`
4. Identify language of each changelog by filename suffix
5. Display detected configuration
**Language Detection Rules**:
Changelog files follow the pattern `CHANGELOG_{LANG}.md` or `CHANGELOG.{lang}.md`, where `{lang}` / `{LANG}` is a language or region code.
| Pattern | Example | Language |
|---------|---------|----------|
| No suffix | `CHANGELOG.md` | en (default) |
| `_{LANG}` (uppercase) | `CHANGELOG_CN.md`, `CHANGELOG_JP.md` | Corresponding language |
| `.{lang}` (lowercase) | `CHANGELOG.zh.md`, `CHANGELOG.ja.md` | Corresponding language |
| `.{lang-region}` | `CHANGELOG.zh-CN.md` | Corresponding region variant |
Common language codes: `zh` (Chinese), `ja` (Japanese), `ko` (Korean), `de` (German), `fr` (French), `es` (Spanish).
**Output Example**:
```
Project detected:
Version file: package.json (1.2.3)
Changelogs:
- CHANGELOG.md (en)
- CHANGELOG.zh.md (zh)
- CHANGELOG.ja.md (ja)
```
### Step 2: Analyze Changes Since Last Tag
```bash
LAST_TAG=$(git tag --sort=-v:refname | head -1)
git log ${LAST_TAG}..HEAD --oneline
git diff ${LAST_TAG}..HEAD --stat
```
Categorize by conventional commit types:
| Type | Description |
|------|-------------|
| feat | New features |
| fix | Bug fixes |
| docs | Documentation |
| refactor | Code refactoring |
| perf | Performance improvements |
| test | Test changes |
| style | Formatting, styling |
| chore | Maintenance (skip in changelog) |
> **Note**: This categorization is for internal analysis only. When writing changelog entries (Step 4), ALL descriptions must be rewritten in user-facing language. See Step 4 writing guidelines.
**Breaking Change Detection**:
- Commit message starts with `BREAKING CHANGE`
- Commit body/footer contains `BREAKING CHANGE:`
- Removed public APIs, renamed exports, changed interfaces
If breaking changes detected, warn user: "Breaking changes detected. Consider major version bump (--major flag)."
### Step 3: Determine Version Bump
Rules (in priority order):
1. User flag `--major/--minor/--patch` → Use specified
2. BREAKING CHANGE detected → Major bump (1.x.x → 2.0.0)
3. `feat:` commits present → Minor bump (1.2.x → 1.3.0)
4. Otherwise → Patch bump (1.2.3 → 1.2.4)
Display version change: `1.2.3 → 1.3.0`
### Step 4: Generate Multi-language Changelogs
For each detected changelog file:
1. **Identify language** from filename suffix
2. **Detect third-party contributors**:
- Check merge commits: `git log ${LAST_TAG}..HEAD --merges --pretty=format:"%H %s"`
- For each merged PR, identify the PR author via `gh pr view <number> --json author --jq '.author.login'`
- Compare against repo owner (`gh repo view --json owner --jq '.owner.login'`)
- If PR author ≠ repo owner → third-party contributor
3. **Generate content in that language**:
- Section titles in target language
- Change descriptions written naturally in target language (not translated)
- Date format: YYYY-MM-DD (universal)
- **Third-party contributions**: Append contributor attribution `(by @username)` to the changelog entry
4. **Insert at file head** (preserve existing content)
**⚠️ CRITICAL: User-Facing Writing Guidelines**
Changelog is written for **end users**, NOT developers. Every entry must describe what changed **from the user's perspective** and highlight the value it brings. Follow these rules strictly:
- **DO NOT** include any technical/programming terms: no "refactor", "component", "module", "API", "SDK", "runtime", "middleware", "state management", "IPC", "store", "hook", "cache invalidation", "dependency injection", etc.
- **DO NOT** mention internal code structure: no file names, function names, class names, variable names, database tables, or architecture details.
- **DO NOT** include engineering process items: no "code cleanup", "refactoring", "migration", "dependency update", "CI/CD", "build optimization", "type safety improvement", etc.
- **DO** describe what the user can now do, see, or experience differently.
- **DO** use plain, everyday language that any non-technical person can understand.
- **DO** focus on user benefits and outcomes, not implementation details.
**How to transform technical commits into user-facing entries**:
| Technical commit | User-facing entry |
|-----------------|-------------------|
| `refactor: reorganize desktop component structure` | *(skip — no user-visible change)* |
| `feat: add OAuth2 authentication module` | 支持使用第三方账号登录(如 Google、GitHub |
| `fix: fix memory leak in connection pool` | 修复了长时间使用后应用变卡的问题 |
| `perf: optimize image loading pipeline` | 图片加载速度更快了 |
| `feat: improve document import and library actions` | 导入文档更方便,书库管理操作更顺手 |
| `fix: update sign-in and sign-up form components` | 优化了登录和注册页面的体验 |
| `refactor: improve IPC flow and store sync` | *(skip — no user-visible change)* |
**Filtering rules**:
- **Skip entirely**: commits that are purely internal (refactor, code cleanup, test-only, CI/CD, dependency updates) with NO user-visible effect.
- **Rewrite**: commits that have user-visible effects but are described technically — rewrite them in user language.
- Only `feat` and `fix` type changes typically produce user-facing entries. `perf` entries are included only when the improvement is noticeable to users.
- If after filtering, a section would be empty, omit that section entirely.
**Section Title Translations** (built-in, user-facing only):
| Type | en | zh | ja | ko | de | fr | es |
|------|----|----|----|----|----|----|-----|
| feat | What's New | 新增功能 | 新機能 | 새로운 기능 | Neuigkeiten | Nouveautés | Novedades |
| fix | Improvements | 改进与修复 | 改善 | 개선 사항 | Verbesserungen | Améliorations | Mejoras |
| perf | Faster & Smoother | 更快更流畅 | パフォーマンス向上 | 더 빠르게 | Schneller & Besser | Plus rapide | Más rápido |
| breaking | Important Changes | 重要变更 | 重要な変更 | 중요 변경사항 | Wichtige Änderungen | Changements importants | Cambios importantes |
> **Note**: `docs`, `refactor`, `test`, `style`, `chore` types are **excluded** from the changelog. They are internal engineering concerns with no direct user value.
**Changelog Format**:
```markdown
## {VERSION} - {YYYY-MM-DD}
### What's New
- User-facing description of what they can now do (by @username)
### Improvements
- User-facing description of what got better
```
Only include sections that have changes. Omit empty sections. Remember: every line must be understandable by a non-technical user.
**Third-Party Attribution Rules**:
- Only add `(by @username)` for contributors who are NOT the repo owner
- Use GitHub username with `@` prefix
- Place at the end of the changelog entry line
- Apply to all languages consistently (always use `(by @username)` format, not translated)
**Multi-language Example**:
English (CHANGELOG.md):
```markdown
## 1.3.0 - 2026-01-22
### What's New
- Sign in with your Google or GitHub account (by @contributor1)
- Link your existing account with third-party login
### Improvements
- Fixed an issue where the app would slow down after extended use
```
Chinese (CHANGELOG.zh.md):
```markdown
## 1.3.0 - 2026-01-22
### 新增功能
- 支持使用 Google 或 GitHub 账号登录 (by @contributor1)
- 可以将已有账号与第三方登录方式绑定
### 改进与修复
- 修复了长时间使用后应用变卡的问题
```
Japanese (CHANGELOG.ja.md):
```markdown
## 1.3.0 - 2026-01-22
### 新機能
- GoogleやGitHubアカウントでログインできるようになりました (by @contributor1)
- 既存アカウントとサードパーティログインの連携が可能に
### 改善
- 長時間使用時にアプリが遅くなる問題を修正
```
### Step 5: Group Changes by Skill/Module
Analyze commits since last tag and group by affected skill/module:
1. **Identify changed files** per commit
2. **Group by skill/module**:
- `skills/<skill-name>/*` → Group under that skill
- Root files (CLAUDE.md, etc.) → Group as "project"
- Multiple skills in one commit → Split into multiple groups
3. **For each group**, identify related README updates needed
**Example Grouping**:
```
baoyu-cover-image:
- feat: add new style options
- fix: handle transparent backgrounds
→ README updates: options table
baoyu-comic:
- refactor: improve panel layout algorithm
→ No README updates needed
project:
- docs: update CLAUDE.md architecture section
```
### Step 6: Commit Each Skill/Module Separately
For each skill/module group (in order of changes):
1. **Check README updates needed**:
- Scan `README*.md` for mentions of this skill/module
- Verify options/flags documented correctly
- Update usage examples if syntax changed
- Update feature descriptions if behavior changed
2. **Stage and commit**:
```bash
git add skills/<skill-name>/*
git add README.md README.zh.md # If updated for this skill
git commit -m "<type>(<skill-name>): <meaningful description>"
```
3. **Commit message format**:
- Use conventional commit format: `<type>(<scope>): <description>`
- `<type>`: feat, fix, refactor, docs, perf, etc.
- `<scope>`: skill name or "project"
- `<description>`: Clear, meaningful description of changes
**Example Commits**:
```bash
git commit -m "feat(baoyu-cover-image): add watercolor and minimalist styles"
git commit -m "fix(baoyu-comic): improve panel layout for long dialogues"
git commit -m "docs(project): update architecture documentation"
```
**Common README Updates Needed**:
| Change Type | README Section to Check |
|-------------|------------------------|
| New options/flags | Options table, usage examples |
| Renamed options | Options table, usage examples |
| New features | Feature description, examples |
| Breaking changes | Migration notes, deprecation warnings |
| Restructured internals | Architecture section (if exposed to users) |
### Step 7: Generate Changelog and Update Version
1. **Generate multi-language changelogs** (as described in Step 4)
2. **Update version file**:
- Read version file (JSON/TOML/text)
- Update version number
- Write back (preserve formatting)
**Version Paths by File Type**:
| File | Path |
|------|------|
| package.json | `$.version` |
| pyproject.toml | `project.version` |
| Cargo.toml | `package.version` |
| marketplace.json | `$.metadata.version` |
| VERSION / version.txt | Direct content |
### Step 8: User Confirmation
Before creating the release commit, ask user to confirm:
**Use AskUserQuestion with two questions**:
1. **Version bump** (single select):
- Show recommended version based on Step 3 analysis
- Options: recommended (with label), other semver options
- Example: `1.2.3 → 1.3.0 (Recommended)`, `1.2.3 → 1.2.4`, `1.2.3 → 2.0.0`
2. **Push to remote** (single select):
- Options: "Yes, push after commit", "No, keep local only"
**Example Output Before Confirmation**:
```
Commits created:
1. feat(baoyu-cover-image): add watercolor and minimalist styles
2. fix(baoyu-comic): improve panel layout for long dialogues
3. docs(project): update architecture documentation
Changelog preview (en):
## 1.3.0 - 2026-01-22
### What's New
- New cover styles available: watercolor and minimalist
### Improvements
- Comics with longer conversations now display more cleanly
Ready to create release commit and tag.
```
### Step 9: Create Release Commit and Tag
After user confirmation:
1. **Stage version and changelog files**:
```bash
git add <version-file>
git add CHANGELOG*.md
```
2. **Create release commit**:
```bash
git commit -m "chore: release v{VERSION}"
```
3. **Create tag**:
```bash
git tag v{VERSION}
```
4. **Push if user confirmed** (Step 8):
```bash
git push origin main
git push origin v{VERSION}
```
**Note**: Do NOT add Co-Authored-By line. This is a release commit, not a code contribution.
**Post-Release Output**:
```
Release v1.3.0 created.
Commits:
1. feat(baoyu-cover-image): add watercolor and minimalist styles
2. fix(baoyu-comic): improve panel layout for long dialogues
3. docs(project): update architecture documentation
4. chore: release v1.3.0
Tag: v1.3.0
Status: Pushed to origin # or "Local only - run git push when ready"
```
## Configuration (.releaserc.yml)
Optional config file in project root to override defaults:
```yaml
# .releaserc.yml - Optional configuration
# Version file (auto-detected if not specified)
version:
file: package.json
path: $.version # JSONPath for JSON, dotted path for TOML
# Changelog files (auto-detected if not specified)
changelog:
files:
- path: CHANGELOG.md
lang: en
- path: CHANGELOG.zh.md
lang: zh
- path: CHANGELOG.ja.md
lang: ja
# Section mapping (conventional commit type → changelog section)
# Use null to skip a type in changelog
# Sections should use user-facing names, not technical terms
sections:
feat: "What's New"
fix: Improvements
perf: "Faster & Smoother"
docs: null # Skip — internal only
refactor: null # Skip — internal only
test: null # Skip — internal only
chore: null # Skip — internal only
# Commit message format
commit:
message: "chore: release v{version}"
# Tag format
tag:
prefix: v # Results in v1.0.0
sign: false
# Additional files to include in release commit
include:
- README.md
- package.json
```
## Dry-Run Mode
When `--dry-run` is specified:
```
=== DRY RUN MODE ===
Project detected:
Version file: package.json (1.2.3)
Changelogs: CHANGELOG.md (en), CHANGELOG.zh.md (zh)
Last tag: v1.2.3
Proposed version: v1.3.0
Changes grouped by skill/module:
baoyu-cover-image:
- feat: add watercolor style
- feat: add minimalist style
→ Commit: feat(baoyu-cover-image): add watercolor and minimalist styles
→ README updates: options table
baoyu-comic:
- fix: panel layout for long dialogues
→ Commit: fix(baoyu-comic): improve panel layout for long dialogues
→ No README updates
Changelog preview (en):
## 1.3.0 - 2026-01-22
### What's New
- New cover styles available: watercolor and minimalist
### Improvements
- Comics with longer conversations now display more cleanly
Changelog preview (zh):
## 1.3.0 - 2026-01-22
### 新增功能
- 新增封面风格:水彩和极简
### 改进与修复
- 长对话的漫画排版更加美观
Commits to create:
1. feat(baoyu-cover-image): add watercolor and minimalist styles
2. fix(baoyu-comic): improve panel layout for long dialogues
3. chore: release v1.3.0
No changes made. Run without --dry-run to execute.
```
## Example Usage
```
/release-pro-max # Auto-detect version bump
/release-pro-max --dry-run # Preview only
/release-pro-max --minor # Force minor bump
/release-pro-max --patch # Force patch bump
/release-pro-max --major # Force major bump (with confirmation)
```
## When to Use
Trigger this skill when user requests:
- "release", "发布", "create release", "new version", "新版本"
- "bump version", "update version", "更新版本"
- "prepare release"
- "push to remote" (with uncommitted changes)
**Important**: If user says "just push" or "直接 push" with uncommitted changes, STILL follow all steps above first.
+123
View File
@@ -0,0 +1,123 @@
# Ultracite Code Standards
This project uses **Ultracite**, a zero-config preset that enforces strict code quality standards through automated formatting and linting.
## Quick Reference
- **Format code**: `pnpm dlx ultracite fix`
- **Check for issues**: `pnpm dlx ultracite check`
- **Diagnose setup**: `pnpm dlx ultracite doctor`
Biome (the underlying engine) provides robust linting and formatting. Most issues are automatically fixable.
---
## Core Principles
Write code that is **accessible, performant, type-safe, and maintainable**. Focus on clarity and explicit intent over brevity.
### Type Safety & Explicitness
- Use explicit types for function parameters and return values when they enhance clarity
- Prefer `unknown` over `any` when the type is genuinely unknown
- Use const assertions (`as const`) for immutable values and literal types
- Leverage TypeScript's type narrowing instead of type assertions
- Use meaningful variable names instead of magic numbers - extract constants with descriptive names
### Modern JavaScript/TypeScript
- Use arrow functions for callbacks and short functions
- Prefer `for...of` loops over `.forEach()` and indexed `for` loops
- Use optional chaining (`?.`) and nullish coalescing (`??`) for safer property access
- Prefer template literals over string concatenation
- Use destructuring for object and array assignments
- Use `const` by default, `let` only when reassignment is needed, never `var`
### Async & Promises
- Always `await` promises in async functions - don't forget to use the return value
- Use `async/await` syntax instead of promise chains for better readability
- Handle errors appropriately in async code with try-catch blocks
- Don't use async functions as Promise executors
### React & JSX
- Use function components over class components
- Call hooks at the top level only, never conditionally
- Specify all dependencies in hook dependency arrays correctly
- Use the `key` prop for elements in iterables (prefer unique IDs over array indices)
- Nest children between opening and closing tags instead of passing as props
- Don't define components inside other components
- Use semantic HTML and ARIA attributes for accessibility:
- Provide meaningful alt text for images
- Use proper heading hierarchy
- Add labels for form inputs
- Include keyboard event handlers alongside mouse events
- Use semantic elements (`<button>`, `<nav>`, etc.) instead of divs with roles
### Error Handling & Debugging
- Remove `console.log`, `debugger`, and `alert` statements from production code
- Throw `Error` objects with descriptive messages, not strings or other values
- Use `try-catch` blocks meaningfully - don't catch errors just to rethrow them
- Prefer early returns over nested conditionals for error cases
### Code Organization
- Keep functions focused and under reasonable cognitive complexity limits
- Extract complex conditions into well-named boolean variables
- Use early returns to reduce nesting
- Prefer simple conditionals over nested ternary operators
- Group related code together and separate concerns
### Security
- Add `rel="noopener"` when using `target="_blank"` on links
- Avoid `dangerouslySetInnerHTML` unless absolutely necessary
- Don't use `eval()` or assign directly to `document.cookie`
- Validate and sanitize user input
### Performance
- Avoid spread syntax in accumulators within loops
- Use top-level regex literals instead of creating them in loops
- Prefer specific imports over namespace imports
- Avoid barrel files (index files that re-export everything)
- Use proper image components (e.g., Next.js `<Image>`) over `<img>` tags
### Framework-Specific Guidance
**Next.js:**
- Use Next.js `<Image>` component for images
- Use `next/head` or App Router metadata API for head elements
- Use Server Components for async data fetching instead of async Client Components
**React 19+:**
- Use ref as a prop instead of `React.forwardRef`
**Solid/Svelte/Vue/Qwik:**
- Use `class` and `for` attributes (not `className` or `htmlFor`)
---
## Testing
- Write assertions inside `it()` or `test()` blocks
- Avoid done callbacks in async tests - use async/await instead
- Don't use `.only` or `.skip` in committed code
- Keep test suites reasonably flat - avoid excessive `describe` nesting
## When Biome Can't Help
Biome's linter will catch most issues automatically. Focus your attention on:
1. **Business logic correctness** - Biome can't validate your algorithms
2. **Meaningful naming** - Use descriptive names for functions, variables, and types
3. **Architecture decisions** - Component structure, data flow, and API design
4. **Edge cases** - Handle boundary conditions and error states
5. **User experience** - Accessibility, performance, and usability considerations
6. **Documentation** - Add comments for complex logic, but prefer self-documenting code
---
Most formatting and common issues are automatically fixed by Biome. Run `pnpm dlx ultracite fix` before committing to ensure compliance.
+10
View File
@@ -0,0 +1,10 @@
{
"version": 1,
"hooks": {
"afterFileEdit": [
{
"command": "pnpm dlx ultracite fix"
}
]
}
}
+10
View File
@@ -0,0 +1,10 @@
node_modules
.git
.context
dist
out
build
**/node_modules
**/dist
**/out
**/.turbo
+10
View File
@@ -0,0 +1,10 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
+4
View File
@@ -0,0 +1,4 @@
* text=auto eol=lf
*.{cmd,[cC][mM][dD]} text eol=crlf
*.{bat,[bB][aA][tT]} text eol=crlf
+37
View File
@@ -0,0 +1,37 @@
name: Bug Report
description: Report a problem or regression
title: "[Bug]: "
labels:
- bug
body:
- type: textarea
id: actual
attributes:
label: Observed behavior and screenshots
placeholder: What actually happened
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs or screenshots
description: Paste relevant logs or add screenshots
placeholder: Attach files or paste logs here
validations:
required: false
- type: input
id: app_version
attributes:
label: App version
description: The VidBee version (e.g., 1.2.3)
placeholder: 1.2.3
validations:
required: true
- type: input
id: os_version
attributes:
label: OS version
description: Your operating system and version (e.g., macOS 14.2, Windows 11 23H2)
placeholder: macOS 14.2
validations:
required: true
@@ -0,0 +1,38 @@
name: Feature Request
description: Suggest an idea or improvement
title: "[Feature]: "
labels:
- enhancement
body:
- type: textarea
id: problem
attributes:
label: Problem to solve
description: What problem are you trying to solve?
placeholder: I want to...
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed solution
description: Describe the feature or change you want
placeholder: It would be great if...
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Other solutions or workarounds you considered
placeholder: I tried...
validations:
required: false
- type: textarea
id: extra
attributes:
label: Additional context
description: Add any other context or screenshots
placeholder: Links, screenshots, or related issues
validations:
required: false
+201
View File
@@ -0,0 +1,201 @@
name: Build
on:
workflow_call:
inputs:
upload_artifacts:
required: false
type: boolean
default: false
description: 'Whether to upload build artifacts'
secrets:
MAC_CERT_P12_BASE64:
required: false
MAC_CERT_P12_PASSWORD:
required: false
APPLE_API_KEY_ID:
required: false
APPLE_API_ISSUER:
required: false
APPLE_API_KEY_P8_BASE64:
required: false
VITE_GLITCHTIP_DSN:
required: false
SENTRY_URL:
required: false
SENTRY_ORG:
required: false
SENTRY_PROJECT:
required: false
SENTRY_AUTH_TOKEN:
required: false
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- platform: windows
os: windows-latest
build_script: pnpm run build:win
mac_ffmpeg_mode: native
- platform: macos
os: macos-latest
build_script: pnpm run build:mac
mac_ffmpeg_mode: universal
- platform: linux
os: ubuntu-latest
build_script: pnpm run build:linux
mac_ffmpeg_mode: native
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 11.1.2
- name: Install Dependencies
run: pnpm install --frozen-lockfile
- name: Lint and format check
run: pnpm run check && pnpm run typecheck
- name: Setup macOS signing
if: matrix.platform == 'macos'
shell: bash
env:
MAC_CERT_P12_BASE64: ${{ secrets.MAC_CERT_P12_BASE64 }}
MAC_CERT_P12_PASSWORD: ${{ secrets.MAC_CERT_P12_PASSWORD }}
APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
run: |
set -euo pipefail
echo "SIGNING_AVAILABLE=false" >> "$GITHUB_ENV"
# Check if all required secrets are present
if [[ -z "$MAC_CERT_P12_BASE64" ]] || [[ -z "$MAC_CERT_P12_PASSWORD" ]] || \
[[ -z "$APPLE_API_KEY_ID" ]] || [[ -z "$APPLE_API_ISSUER" ]] || \
[[ -z "$APPLE_API_KEY_P8_BASE64" ]]; then
echo "::notice::macOS signing secrets not available, skipping code signing setup"
exit 0
fi
CERT_PATH="$RUNNER_TEMP/mac_cert.p12"
KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain"
API_KEY_PATH="$RUNNER_TEMP/AuthKey.p8"
echo "$MAC_CERT_P12_BASE64" | base64 --decode > "$CERT_PATH"
echo "$APPLE_API_KEY_P8_BASE64" | base64 --decode > "$API_KEY_PATH"
security create-keychain -p "$MAC_CERT_P12_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$MAC_CERT_P12_PASSWORD" "$KEYCHAIN_PATH"
security import "$CERT_PATH" -k "$KEYCHAIN_PATH" -P "$MAC_CERT_P12_PASSWORD" -T /usr/bin/codesign -T /usr/bin/productbuild
security list-keychain -d user -s "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MAC_CERT_P12_PASSWORD" "$KEYCHAIN_PATH"
echo "CSC_KEYCHAIN=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
echo "CSC_KEY_PASSWORD=$MAC_CERT_P12_PASSWORD" >> "$GITHUB_ENV"
echo "APPLE_API_KEY=$API_KEY_PATH" >> "$GITHUB_ENV"
echo "APPLE_API_KEY_ID=$APPLE_API_KEY_ID" >> "$GITHUB_ENV"
echo "APPLE_API_ISSUER=$APPLE_API_ISSUER" >> "$GITHUB_ENV"
echo "SIGNING_AVAILABLE=true" >> "$GITHUB_ENV"
- name: Stamp release version and channel from tag
if: github.ref_type == 'tag'
shell: bash
# Stamp the tag version into package.json and, for prerelease tags, set the publish
# channel so electron-builder emits preview*.yml instead of latest*.yml (its GitHub
# provider does not derive the channel from the version on its own).
run: node apps/desktop/scripts/stamp-release.mjs "${GITHUB_REF_NAME}"
- name: Build application
env:
VIDBEE_MAC_FFMPEG_MODE: ${{ matrix.mac_ffmpeg_mode }}
VITE_GLITCHTIP_DSN: ${{ secrets.VITE_GLITCHTIP_DSN }}
VITE_GLITCHTIP_ENVIRONMENT: production
SENTRY_URL: ${{ secrets.SENTRY_URL }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: ${{ matrix.build_script }}
- name: Verify Windows release artifacts
if: matrix.platform == 'windows'
shell: pwsh
run: |
$dist = "apps/desktop/dist"
$requiredPatterns = @("*-setup.exe", "*-portable.exe", "*-windows-portable.zip")
foreach ($pattern in $requiredPatterns) {
$matches = Get-ChildItem -Path $dist -Filter $pattern -File
if ($matches.Count -eq 0) {
Write-Error "Missing Windows release artifact matching $pattern"
exit 1
}
$matches | ForEach-Object {
Write-Host "Found Windows release artifact: $($_.Name)"
}
}
- name: Verify macOS codesign and notarization
if: matrix.platform == 'macos' && env.SIGNING_AVAILABLE == 'true'
shell: bash
run: |
set -euo pipefail
apps_found=0
while IFS= read -r app; do
apps_found=1
echo "Verifying codesign for $app"
codesign --verify --deep --strict --verbose=2 "$app"
spctl -a -t exec -vv "$app"
echo "Validating notarization ticket for $app"
xcrun stapler validate "$app"
done < <(find apps/desktop/dist -type d -name "*.app" -prune -print)
if [[ "$apps_found" -eq 0 ]]; then
echo "::error::No .app bundles found in dist"
exit 1
fi
dmgs_found=0
while IFS= read -r dmg; do
dmgs_found=1
echo "Submitting DMG for notarization: $dmg"
xcrun notarytool submit "$dmg" --key "$APPLE_API_KEY" --key-id "$APPLE_API_KEY_ID" --issuer "$APPLE_API_ISSUER" --wait
echo "Stapling notarization ticket for $dmg"
xcrun stapler staple "$dmg"
echo "Validating notarization ticket for $dmg"
xcrun stapler validate "$dmg"
done < <(find apps/desktop/dist -type f -name "*.dmg" -print)
if [[ "$dmgs_found" -eq 0 ]]; then
echo "::notice::No DMG artifacts found to validate"
fi
- name: Upload build artifacts
if: inputs.upload_artifacts == true
uses: actions/upload-artifact@v4
with:
name: dist-${{ matrix.os }}
path: |
apps/desktop/dist/*.exe
apps/desktop/dist/*.zip
apps/desktop/dist/*.dmg
apps/desktop/dist/*.AppImage
apps/desktop/dist/*.snap
apps/desktop/dist/*.deb
apps/desktop/dist/*.rpm
apps/desktop/dist/*.tar.gz
apps/desktop/dist/*.yml
apps/desktop/dist/*.blockmap
retention-days: 1
if-no-files-found: error
+11
View File
@@ -0,0 +1,11 @@
name: CI
on:
pull_request:
branches: [ main ]
jobs:
build:
uses: ./.github/workflows/build.yml
with:
upload_artifacts: true
+122
View File
@@ -0,0 +1,122 @@
name: CLI Publish
# Publishes @vidbee/cli to npm when a `cli-vX.Y.Z` tag is pushed. Standalone
# from the Desktop release pipeline (NEX-148): the CLI ships independently
# so a `yt-dlp` probe-flag fix doesn't have to wait for a Desktop release.
on:
push:
tags:
- 'cli-v*'
workflow_dispatch:
inputs:
tag:
description: 'Tag to publish (cli-vX.Y.Z). When empty, builds from main but skips publish.'
required: false
default: ''
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Check out Git repository
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.tag || github.ref }}
- name: Resolve version from tag
id: meta
shell: bash
run: |
ref="${GITHUB_REF_NAME}"
if [[ -n "${{ github.event.inputs.tag }}" ]]; then
ref="${{ github.event.inputs.tag }}"
fi
if [[ "$ref" == cli-v* ]]; then
version="${ref#cli-v}"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "publish=true" >> "$GITHUB_OUTPUT"
if [[ "$version" == *-* ]]; then
echo "tag=next" >> "$GITHUB_OUTPUT"
else
echo "tag=latest" >> "$GITHUB_OUTPUT"
fi
else
echo "publish=false" >> "$GITHUB_OUTPUT"
echo "tag=latest" >> "$GITHUB_OUTPUT"
echo "version=" >> "$GITHUB_OUTPUT"
fi
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 22
registry-url: 'https://registry.npmjs.org'
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 11.1.2
- name: Install dependencies
run: pnpm install --filter ./apps/cli... --frozen-lockfile
- name: Verify package.json version matches tag
if: steps.meta.outputs.publish == 'true'
shell: bash
run: |
tag_version="${{ steps.meta.outputs.version }}"
pkg_version=$(node -p "require('./apps/cli/package.json').version")
if [[ "$pkg_version" != "$tag_version" ]]; then
echo "::error::Tag $tag_version does not match apps/cli/package.json version $pkg_version"
exit 1
fi
- name: Run CLI tests
run: pnpm --filter @vidbee/cli test
- name: Run CLI typecheck
run: pnpm --filter @vidbee/cli typecheck
- name: Build CLI bundle
run: pnpm --filter @vidbee/cli build
- name: Verify dist artifacts exist
run: pnpm --filter @vidbee/cli verify-dist
- name: Pack tarball (sanity-check files whitelist)
run: |
cd apps/cli
pnpm pack --pack-destination ../..
- name: Publish to npm
if: steps.meta.outputs.publish == 'true'
run: |
cd apps/cli
pnpm publish --no-git-checks --access public --tag ${{ steps.meta.outputs.tag }}
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Create GitHub Release
if: steps.meta.outputs.publish == 'true'
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ github.ref_name }}
name: '@vidbee/cli ${{ steps.meta.outputs.version }}'
body: |
`@vidbee/cli@${{ steps.meta.outputs.version }}` published to npm.
Install:
```sh
npm install -g @vidbee/cli@${{ steps.meta.outputs.version }}
pnpm add -g @vidbee/cli@${{ steps.meta.outputs.version }}
bun install -g @vidbee/cli@${{ steps.meta.outputs.version }}
```
See `apps/cli/CHANGELOG.md` for release notes.
files: |
*.tgz
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+71
View File
@@ -0,0 +1,71 @@
name: Docker Publish
on:
push:
branches:
- main
paths:
- 'apps/api/**'
- 'apps/desktop/resources/drizzle/**'
- 'apps/web/**'
- 'packages/**'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'package.json'
- '.github/workflows/docker-publish.yml'
workflow_dispatch:
jobs:
docker:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- app: api
dockerfile: apps/api/Dockerfile
image_suffix: api
- app: web
dockerfile: apps/web/Dockerfile
image_suffix: web
permissions:
contents: read
packages: write
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/vidbee-${{ matrix.image_suffix }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha,format=short
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
file: ${{ matrix.dockerfile }}
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VITE_API_URL=${{ vars.VITE_API_URL || 'http://localhost:3100' }}
+47
View File
@@ -0,0 +1,47 @@
name: Build Extension
on:
workflow_call:
inputs:
upload_artifacts:
required: false
type: boolean
default: false
description: 'Whether to upload build artifacts'
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 11.1.2
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
cache-dependency-path: pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --filter ./apps/extension...
- name: Build extension
run: pnpm --filter ./apps/extension build
- name: Build extension zip
run: pnpm --filter ./apps/extension zip
- name: Upload extension artifacts
if: inputs.upload_artifacts == true
uses: actions/upload-artifact@v4
with:
name: dist-extension
path: apps/extension/.output/*.zip
retention-days: 1
if-no-files-found: error
+84
View File
@@ -0,0 +1,84 @@
name: Publish Extension
on:
push:
branches: [ main ]
paths:
- apps/extension/package.json
jobs:
detect-version:
if: vars.ENABLE_EXTENSION_CI == 'true'
runs-on: ubuntu-latest
outputs:
changed: ${{ steps.version_check.outputs.changed }}
current_version: ${{ steps.version_check.outputs.current_version }}
previous_version: ${{ steps.version_check.outputs.previous_version }}
steps:
- name: Check out Git repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check if extension version changed
id: version_check
shell: bash
run: |
set -euo pipefail
before_sha="${{ github.event.before }}"
current_version=$(node -e "const fs = require('fs'); const v = JSON.parse(fs.readFileSync('apps/extension/package.json', 'utf8')).version; process.stdout.write(v);")
previous_version=""
if git cat-file -e "${before_sha}:apps/extension/package.json" 2>/dev/null; then
previous_version=$(git show "${before_sha}:apps/extension/package.json" | node -e "let data=''; process.stdin.on('data', d => data += d); process.stdin.on('end', () => { const v = JSON.parse(data).version; process.stdout.write(v); });")
fi
echo "current_version=${current_version}" >> "$GITHUB_OUTPUT"
echo "previous_version=${previous_version}" >> "$GITHUB_OUTPUT"
if [[ -n "${previous_version}" && "${current_version}" == "${previous_version}" ]]; then
echo "changed=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "changed=true" >> "$GITHUB_OUTPUT"
submit:
if: vars.ENABLE_EXTENSION_CI == 'true' && needs.detect-version.outputs.changed == 'true'
needs: detect-version
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 11.1.2
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
cache-dependency-path: pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --filter ./apps/extension...
- name: Build extension
run: pnpm --filter ./apps/extension build
- name: Zip extensions
run: pnpm --filter ./apps/extension zip
- name: Submit to stores
run: |
pnpm --dir apps/extension wxt submit \
--chrome-zip .output/*-chrome.zip
env:
CHROME_EXTENSION_ID: ${{ secrets.CHROME_EXTENSION_ID }}
CHROME_CLIENT_ID: ${{ secrets.CHROME_CLIENT_ID }}
CHROME_CLIENT_SECRET: ${{ secrets.CHROME_CLIENT_SECRET }}
CHROME_REFRESH_TOKEN: ${{ secrets.CHROME_REFRESH_TOKEN }}
CHROME_PUBLISH_TARGET: "default"
CHROME_SKIP_SUBMIT_REVIEW: false
+73
View File
@@ -0,0 +1,73 @@
name: Build and Release Electron App
on:
push:
tags:
- v*.*.*
workflow_dispatch:
# Never run two releases for the same ref at once, and never cancel an in-progress
# release (cancelling mid-publish could leave a half-uploaded GitHub release).
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
build:
uses: ./.github/workflows/build.yml
with:
upload_artifacts: true
secrets: inherit
release:
needs: [build]
runs-on: ubuntu-latest
# Only publish a GitHub release for tag pushes. A manual workflow_dispatch run
# has no tag, so it acts as a build-only smoke test (artifacts only, no release).
if: github.ref_type == 'tag'
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Detect release type
id: release_meta
shell: bash
run: |
if [[ "${GITHUB_REF_NAME}" == *-preview.* ]]; then
echo "is_preview=true" >> "$GITHUB_OUTPUT"
else
echo "is_preview=false" >> "$GITHUB_OUTPUT"
fi
- name: Download artifacts
uses: actions/download-artifact@v4
with:
pattern: dist-*
merge-multiple: true
path: dist/
- name: Release
uses: softprops/action-gh-release@v1
with:
generate_release_notes: true
prerelease: ${{ steps.release_meta.outputs.is_preview == 'true' }}
files: |
dist/*.exe
dist/*.zip
dist/*.dmg
dist/*.AppImage
dist/*.snap
dist/*.deb
dist/*.rpm
dist/*.tar.gz
dist/*.yml
dist/*.blockmap
env:
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
- name: Notify Cloudflare Pages
if: steps.release_meta.outputs.is_preview != 'true'
env:
CLOUDFLARE_WEBHOOK_URL: ${{ secrets.CLOUDFLARE_WEBHOOK_URL }}
run: |
curl -X POST "$CLOUDFLARE_WEBHOOK_URL"
+26
View File
@@ -0,0 +1,26 @@
name: 'translator'
on:
issues:
types: [opened, edited]
issue_comment:
types: [created, edited]
discussion:
types: [created, edited]
discussion_comment:
types: [created, edited]
jobs:
translate:
if: ${{ !github.event.issue.pull_request }}
permissions:
issues: write
discussions: write
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: lizheming/github-translate-action@c55aac477e98562d4faed9f77c54ab8306ae6ebf
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
IS_MODIFY_TITLE: true
+129
View File
@@ -0,0 +1,129 @@
name: Auto Release yt-dlp Patch
on:
schedule:
- cron: '17 */6 * * *'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ytdlp-auto-release
cancel-in-progress: false
jobs:
prepare:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
update_available: ${{ steps.check.outputs.update_available }}
release_version: ${{ steps.prepare.outputs.release_version }}
latest_ytdlp_version: ${{ steps.prepare.outputs.latest_ytdlp_version }}
steps:
- name: Check out Git repository
uses: actions/checkout@v4
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Check for yt-dlp updates
id: check
env:
YTDLP_RELEASE_TOKEN: ${{ github.token }}
run: node apps/desktop/scripts/ytdlp-auto-release.mjs check
- name: Prepare patch release
if: steps.check.outputs.update_available == 'true'
id: prepare
env:
YTDLP_RELEASE_TOKEN: ${{ github.token }}
run: node apps/desktop/scripts/ytdlp-auto-release.mjs prepare
- name: Install pnpm
if: steps.check.outputs.update_available == 'true'
uses: pnpm/action-setup@v4
with:
version: 11.1.2
- name: Install Dependencies
if: steps.check.outputs.update_available == 'true'
run: pnpm install --frozen-lockfile --ignore-scripts
- name: Validate release changes
if: steps.check.outputs.update_available == 'true'
run: pnpm run check
- name: Upload release changes
if: steps.check.outputs.update_available == 'true'
uses: actions/upload-artifact@v4
with:
name: ytdlp-release-changes
path: |
apps/desktop/package.json
apps/desktop/changelogs/CHANGELOG.md
apps/desktop/release-metadata.json
if-no-files-found: error
retention-days: 1
- name: Report up-to-date status
if: steps.check.outputs.update_available != 'true'
run: echo "yt-dlp is already up to date. No patch release is needed."
publish:
needs: prepare
if: needs.prepare.outputs.update_available == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
RELEASE_VERSION: ${{ needs.prepare.outputs.release_version }}
LATEST_YTDLP_VERSION: ${{ needs.prepare.outputs.latest_ytdlp_version }}
steps:
- name: Check release token
env:
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
run: |
if [ -z "${GH_TOKEN}" ]; then
echo "ACCESS_TOKEN secret is required to push the release commit and tag."
exit 1
fi
- name: Check out Git repository
uses: actions/checkout@v4
with:
token: ${{ secrets.ACCESS_TOKEN }}
ref: ${{ github.event.repository.default_branch }}
- name: Download release changes
uses: actions/download-artifact@v4
with:
name: ytdlp-release-changes
path: .ytdlp-release-changes
- name: Apply release changes
run: |
cp .ytdlp-release-changes/package.json apps/desktop/package.json
cp .ytdlp-release-changes/release-metadata.json apps/desktop/release-metadata.json
cp .ytdlp-release-changes/changelogs/CHANGELOG.md apps/desktop/changelogs/CHANGELOG.md
rm -rf .ytdlp-release-changes
- name: Commit release changes
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add apps/desktop/package.json apps/desktop/changelogs/CHANGELOG.md apps/desktop/release-metadata.json
git commit -m "chore(release): publish v${RELEASE_VERSION} with yt-dlp ${LATEST_YTDLP_VERSION}"
git tag "v${RELEASE_VERSION}"
- name: Push release commit and tag
run: |
git push origin HEAD:${DEFAULT_BRANCH}
git push origin "v${RELEASE_VERSION}"
+22
View File
@@ -0,0 +1,22 @@
node_modules
/dist
apps/desktop/dist
apps/web/dist
apps/api/.data/
out
.conductor/
.wxt
.output
.DS_Store
.eslintcache
*.log*
.cache/
.pnpm-home/
.pnpm-store/
.claude-plugin/
.superset/
config.json
labels/
statuses/
.env
+68
View File
@@ -0,0 +1,68 @@
#!/bin/sh
# Exit on any error
set -e
# Check if there are any staged files
if [ -z "$(git diff --cached --name-only)" ]; then
echo "No staged files to format"
exit 0
fi
# Store the hash of staged changes to detect modifications
STAGED_HASH=$(git diff --cached | sha256sum | cut -d' ' -f1)
# Save list of staged files (handling all file states)
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACMR)
PARTIALLY_STAGED=$(git diff --name-only)
# Stash unstaged changes to preserve working directory
# --keep-index keeps staged changes in working tree
git stash push --quiet --keep-index --message "pre-commit-stash" || true
STASHED=$?
# Run formatter on the staged files
pnpm --filter ./apps/desktop run fix
FORMAT_EXIT_CODE=$?
# Restore working directory state
if [ $STASHED -eq 0 ]; then
# Re-stage the formatted files
if [ -n "$STAGED_FILES" ]; then
echo "$STAGED_FILES" | while IFS= read -r file; do
if [ -f "$file" ]; then
git add "$file"
fi
done
fi
# Restore unstaged changes
git stash pop --quiet || true
# Restore partial staging if files were partially staged
if [ -n "$PARTIALLY_STAGED" ]; then
for file in $PARTIALLY_STAGED; do
if [ -f "$file" ] && echo "$STAGED_FILES" | grep -q "^$file$"; then
# File was partially staged - need to unstage the unstaged parts
git restore --staged "$file" 2>/dev/null || true
git add -p "$file" < /dev/null 2>/dev/null || git add "$file"
fi
done
fi
else
# No stash was created, just re-add the formatted files
if [ -n "$STAGED_FILES" ]; then
echo "$STAGED_FILES" | while IFS= read -r file; do
if [ -f "$file" ]; then
git add "$file"
fi
done
fi
fi
# Check if staged files actually changed
NEW_STAGED_HASH=$(git diff --cached | sha256sum | cut -d' ' -f1)
if [ "$STAGED_HASH" != "$NEW_STAGED_HASH" ]; then
echo "✨ Files formatted by Ultracite"
fi
exit $FORMAT_EXIT_CODE
+1
View File
@@ -0,0 +1 @@
22.22.3
+4
View File
@@ -0,0 +1,4 @@
electron_mirror=https://npmmirror.com/mirrors/electron/
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
shamefully-hoist=true
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["biomejs.biome", "bradlc.vscode-tailwindcss"]
}
+59
View File
@@ -0,0 +1,59 @@
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
},
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
},
"typescript.tsdk": "node_modules/typescript/lib",
"tailwindCSS.experimental.classRegex": [
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
],
"i18n-ally.localesPaths": ["apps/desktop/src/renderer/src/locales"],
"i18n-ally.keystyle": "nested",
"[css]": {
"editor.defaultFormatter": "biomejs.biome"
},
"editor.formatOnPaste": true,
"emmet.showExpandedAbbreviation": "never",
"[jsonc]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[html]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[vue]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[svelte]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[yaml]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[graphql]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[markdown]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[mdx]": {
"editor.defaultFormatter": "biomejs.biome"
}
}
+132
View File
@@ -0,0 +1,132 @@
1. use pnpm instead of npm
2. use pnpm run check after tasks to check code
3. Support i18n. When writing business logic, initially only translate the English version of en.json
4. use English for comments&console
5. Follow the ✅ KISS (Keep It Simple, Stupid) & ✅ YAGNI (You Aren't Gonna Need It) principles
6. Use Conventional Commits format for commit messages: `type(scope): subject`. Common types: feat, fix, docs, style, refactor, test, chore, perf, ci, build, revert. PR titles should also follow this format.
7. If there is an error when running `pnpm run check:i18n`, please complete the missing corresponding translation files and fields. Ensure the translation is done into the corresponding language, rather than directly copying the English version.
8. The UI/UX of the web and desktop versions in apps must be consistent, with the only difference being the platform. Try to share the UI and schema as much as possible
# Ultracite Code Standards
This project uses **Ultracite**, a zero-config preset that enforces strict code quality standards through automated formatting and linting.
## Quick Reference
- **Format code**: `pnpm dlx ultracite fix`
- **Check for issues**: `pnpm dlx ultracite check`
- **Diagnose setup**: `pnpm dlx ultracite doctor`
Biome (the underlying engine) provides robust linting and formatting. Most issues are automatically fixable.
---
## Core Principles
Write code that is **accessible, performant, type-safe, and maintainable**. Focus on clarity and explicit intent over brevity.
### Type Safety & Explicitness
- Use explicit types for function parameters and return values when they enhance clarity
- Prefer `unknown` over `any` when the type is genuinely unknown
- Use const assertions (`as const`) for immutable values and literal types
- Leverage TypeScript's type narrowing instead of type assertions
- Use meaningful variable names instead of magic numbers - extract constants with descriptive names
### Modern JavaScript/TypeScript
- Use arrow functions for callbacks and short functions
- Prefer `for...of` loops over `.forEach()` and indexed `for` loops
- Use optional chaining (`?.`) and nullish coalescing (`??`) for safer property access
- Prefer template literals over string concatenation
- Use destructuring for object and array assignments
- Use `const` by default, `let` only when reassignment is needed, never `var`
### Async & Promises
- Always `await` promises in async functions - don't forget to use the return value
- Use `async/await` syntax instead of promise chains for better readability
- Handle errors appropriately in async code with try-catch blocks
- Don't use async functions as Promise executors
### React & JSX
- Use function components over class components
- Call hooks at the top level only, never conditionally
- Specify all dependencies in hook dependency arrays correctly
- Use the `key` prop for elements in iterables (prefer unique IDs over array indices)
- Nest children between opening and closing tags instead of passing as props
- Don't define components inside other components
- Use semantic HTML and ARIA attributes for accessibility:
- Provide meaningful alt text for images
- Use proper heading hierarchy
- Add labels for form inputs
- Include keyboard event handlers alongside mouse events
- Use semantic elements (`<button>`, `<nav>`, etc.) instead of divs with roles
### Error Handling & Debugging
- Remove `console.log`, `debugger`, and `alert` statements from production code
- Throw `Error` objects with descriptive messages, not strings or other values
- Use `try-catch` blocks meaningfully - don't catch errors just to rethrow them
- Prefer early returns over nested conditionals for error cases
### Code Organization
- Keep functions focused and under reasonable cognitive complexity limits
- Extract complex conditions into well-named boolean variables
- Use early returns to reduce nesting
- Prefer simple conditionals over nested ternary operators
- Group related code together and separate concerns
### Security
- Add `rel="noopener"` when using `target="_blank"` on links
- Avoid `dangerouslySetInnerHTML` unless absolutely necessary
- Don't use `eval()` or assign directly to `document.cookie`
- Validate and sanitize user input
### Performance
- Avoid spread syntax in accumulators within loops
- Use top-level regex literals instead of creating them in loops
- Prefer specific imports over namespace imports
- Avoid barrel files (index files that re-export everything)
- Use proper image components (e.g., Next.js `<Image>`) over `<img>` tags
### Framework-Specific Guidance
**Next.js:**
- Use Next.js `<Image>` component for images
- Use `next/head` or App Router metadata API for head elements
- Use Server Components for async data fetching instead of async Client Components
**React 19+:**
- Use ref as a prop instead of `React.forwardRef`
**Solid/Svelte/Vue/Qwik:**
- Use `class` and `for` attributes (not `className` or `htmlFor`)
---
## Testing
- Write assertions inside `it()` or `test()` blocks
- Avoid done callbacks in async tests - use async/await instead
- Don't use `.only` or `.skip` in committed code
- Keep test suites reasonably flat - avoid excessive `describe` nesting
## When Biome Can't Help
Biome's linter will catch most issues automatically. Focus your attention on:
1. **Business logic correctness** - Biome can't validate your algorithms
2. **Meaningful naming** - Use descriptive names for functions, variables, and types
3. **Architecture decisions** - Component structure, data flow, and API design
4. **Edge cases** - Handle boundary conditions and error states
5. **User experience** - Accessibility, performance, and usability considerations
6. **Documentation** - Add comments for complex logic, but prefer self-documenting code
---
Most formatting and common issues are automatically fixed by Biome. Run `pnpm dlx ultracite fix` before committing to ensure compliance.
+105
View File
@@ -0,0 +1,105 @@
# Contributing to VidBee
Thank you for taking the time to improve VidBee. These notes keep the project maintainable and easy to review.
## Getting Ready
- Use Node.js 18+ and pnpm 8+.
- Install dependencies with `pnpm install`.
- Run `pnpm dev` to test changes locally.
## Tech Stack
- Runtime: Electron 38, electron-vite, electron-builder.
- Frontend: React 19, React Router, Jotai, React Hook Form, Tailwind CSS 4, shadcn/ui, Lucide icons.
- Tooling: TypeScript 5, pnpm, Biome, dayjs, electron-log, electron-store, electron-updater, i18next, next-themes.
## Local Development
- Use `pnpm install` to pull dependencies after cloning.
- Start the Electron and Vite development environment with `pnpm dev`; hot module replacement is already configured.
- Preview the production build locally with `pnpm start`.
## Useful Scripts
| Command | Purpose |
| --- | --- |
| `pnpm run typecheck` | Type-check the main and renderer projects. |
| `pnpm build` | Run type checks and produce production bundles. |
| `pnpm build:win` / `pnpm build:mac` / `pnpm build:linux` | Create platform-specific distributables. |
| `pnpm build:unpack` | Produce unpacked output directories for inspection. |
| `pnpm run check` | Format and lint the codebase with Biome. |
## Project Structure
```text
apps/desktop/src/
|-- main/ # Electron main process, IPC services, configuration
|-- preload/ # Context bridge and preload helpers
`-- renderer/
|-- src/
| |-- pages/ # Application routes (Home, Settings, Playlist, etc.)
| |-- components/ # UI components, download views, shared controls
| |-- data/ # Static datasets such as popularSites.ts
| |-- hooks/ # Custom hooks and global atoms
| |-- lib/ # Utilities shared across the renderer
| `-- assets/ # Global styles and icons
`-- index.html
```
## Internationalization
- i18next drives localization with English (`en`) and Simplified Chinese (`zh-CN`) namespaces.
- Only update strings in `apps/desktop/src/renderer/src/locales/en.json`; maintainers handle the other locales.
- Keep copy edits focused and avoid removing translation keys without discussion.
## Configuration and Storage
- Persistent settings are stored with `electron-store` and exposed through IPC helpers.
- User-facing preferences such as download paths and themes live in `apps/desktop/src/main/settings.ts` and related services.
- Logs are recorded with `electron-log` to simplify troubleshooting.
## Packaging
- Build production bundles with `pnpm build`.
- Create platform-specific artifacts with `pnpm build:win`, `pnpm build:mac`, or `pnpm build:linux`.
- Use `pnpm build:unpack` to generate unpacked directories under `apps/desktop/dist/` for manual inspection.
- Bundle `yt-dlp` under `apps/desktop/resources/` and `ffmpeg/ffprobe` under `apps/desktop/resources/ffmpeg/` before packaging so merges and audio extraction work out of the box.
## Releasing & Update Channels
VidBee ships two auto-update channels. Auto-updates use the GitHub provider
(`apps/desktop/electron-builder.yml`); electron-builder derives the channel from the
version's prerelease label, so the tag is the single source of truth.
| Channel | Lane | Command | Tag / version | electron-builder output | GitHub release |
| --- | --- | --- | --- | --- | --- |
| Stable | `latest` | `pnpm release` | `vX.Y.Z` | `latest*.yml` | normal release |
| Preview | `preview` | `pnpm release:preview` | `vX.Y.Z-preview.N` | `preview*.yml` | prerelease |
- Both commands run `pnpm run check` and then `bumpp`, which bumps `apps/desktop/package.json`,
commits, tags, and pushes. The tag push triggers `.github/workflows/release.yml`, which builds
every platform, publishes the GitHub release (marked **prerelease** for `-preview.` tags), and
notifies Cloudflare Pages for stable releases only.
- Before building, CI runs `apps/desktop/scripts/stamp-release.mjs` (see `build.yml`): it stamps
the tag's version into `package.json` and, for `-preview.` tags, sets the electron-builder
publish channel so the build emits `preview*.yml` instead of `latest*.yml`. (The GitHub
provider does not derive the channel from the version on its own, so this is required.)
- A manual `workflow_dispatch` run of the release workflow is a build-only smoke test: it
produces artifacts but does **not** publish a release (no tag).
Users opt into preview builds with the **Preview channel** switch on the in-app About page
(`betaProgram` setting). Turning it on moves the user to the `preview` channel and downloads the
next prerelease; turning it off returns them to `latest` but keeps their current build until the
next stable release catches up (no forced downgrade).
## Working on Changes
- Keep each pull request focused on a single problem or feature.
- Run `pnpm run check` before committing to ensure formatting and linting stay consistent.
- Write comments and console messages in English only.
- When updating copy in the app, adjust strings in `apps/desktop/src/renderer/src/locales/en.json`; other locale files are handled by maintainers.
## Opening Issues
- Search existing issues to avoid duplicates.
- Describe the problem clearly with steps to reproduce, expected behaviour, and screenshots or logs when useful.
## Submitting Pull Requests
- Explain the motivation and impact of the change in the description.
- Mention any user facing updates or migrations.
- Confirm that `pnpm run check` passes and note any follow-up work that is out of scope.
We appreciate every contribution that keeps VidBee simple and reliable.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 VidBee
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+161
View File
@@ -0,0 +1,161 @@
<div align="left">
<a href="https://github.com/nexmoe/VidBee">
<img src="apps/desktop/build/icon.png" alt="Logo" width="80" height="80">
</a>
<h3>VidBee</h3>
<p>
<a href="https://github.com/nexmoe/VidBee/stargazers"><img src="https://img.shields.io/github/stars/nexmoe/VidBee?color=ffcb47&labelColor=black&logo=github&label=Stars" /></a>
<a href="https://github.com/nexmoe/VidBee/graphs/contributors"><img src="https://img.shields.io/github/contributors/nexmoe/VidBee?ogo=github&label=Contributors&labelColor=black" /></a>
<a href="https://github.com/nexmoe/VidBee/releases"><img src="https://img.shields.io/github/downloads/nexmoe/VidBee/total?color=369eff&labelColor=black&logo=github&label=Downloads" /></a>
<a href="https://github.com/nexmoe/VidBee/releases/latest"><img src="https://img.shields.io/github/v/release/nexmoe/VidBee?color=369eff&labelColor=black&logo=github&label=Latest%20Release" /></a>
<a href="https://x.com/intent/follow?screen_name=nexmoex"><img src="https://img.shields.io/badge/Follow-blue?color=1d9bf0&logo=x&labelColor=black" /></a>
<a href="https://deepwiki.com/nexmoe/VidBee"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a>
<br />
<br />
<a href="https://github.com/nexmoe/VidBee/releases/latest" target="_blank"><img src="screenshots/main-interface.png" alt="VidBee Desktop" width="46%"/></a>
<a href="https://github.com/nexmoe/VidBee/releases/latest" target="_blank"><img src="screenshots/download-queue.png" alt="VidBee Download Queue" width="46%"/></a>
<br />
<br />
</p>
</div>
VidBee is a modern, open-source video downloader that lets you download videos and audios from 1000+ websites worldwide. Built with Electron and powered by yt-dlp, VidBee offers a clean, intuitive interface with powerful features for all your downloading needs, including RSS auto-download automation that automatically subscribes to feeds and downloads new videos from your favorite creators in the background.
## 👋🏻 Getting Started
VidBee is currently under active development, and feedback is welcome for any [issue](https://github.com/nexmoe/VidBee/issues) encountered.
[📥 Download VidBee](https://vidbee.org/download/) | [📚 Documentation](https://docs.vidbee.org)
> [!IMPORTANT]
>
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
<a href="https://next.ossinsight.io/widgets/official/compose-last-28-days-stats?repo_id=1081230042" target="_blank" style="display: block" align="left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://next.ossinsight.io/widgets/official/compose-last-28-days-stats/thumbnail.png?repo_id=1081230042&image_size=auto&color_scheme=dark" width="655" height="auto">
<img alt="Performance Stats of nexmoe/VidBee - Last 28 days" src="https://next.ossinsight.io/widgets/official/compose-last-28-days-stats/thumbnail.png?repo_id=1081230042&image_size=auto&color_scheme=light" width="655" height="auto">
</picture>
</a>
<!-- Made with [OSS Insight](https://ossinsight.io/) -->
## ✨ Features
### 🌍 Global Video Download Support
Download videos from almost any website worldwide through the powerful yt-dlp engine. Support for 1000+ sites including YouTube, TikTok, Instagram, Twitter, and many more.
![VidBee Main Interface](screenshots/main-interface.png)
### 🎨 Best-in-class UI Experience
Modern, clean interface with intuitive operations. One-click pause/resume/retry, real-time progress tracking, and comprehensive download queue management.
![VidBee Download Queue](screenshots/download-queue.png)
### 📡 RSS Auto Download
Automatically subscribe to RSS feeds and auto-download new videos in the background from your favorite creators across YouTube, TikTok, and more. Set up RSS subscriptions once, and VidBee will automatically download new uploads without manual intervention, perfect for keeping up with your favorite channels and creators.
### 🎞️ Configurable Output Containers
Pick between **Auto (MP4/MKV)**, **MP4**, **MKV**, **WebM**, or yt-dlp's **Original** defaults for one-click downloads — no more wondering whether you'll get an MP4 or a WebM. See [Formats & Containers](https://docs.vidbee.org/formats) for the full breakdown.
## 🌐 Supported Sites
VidBee supports 1000+ video and audio platforms through yt-dlp. For the complete list of supported sites, visit [https://vidbee.org/supported-sites/](https://vidbee.org/supported-sites/)
## 🧱 Web + API (Docker-ready)
This monorepo now includes:
- `packages/downloader-core`: Shared yt-dlp/ffmpeg download core
- `apps/api`: Fastify API server with oRPC and SSE events
- `apps/web`: TanStack Start web client using oRPC
Run locally:
```bash
pnpm run start:web
```
This command starts `apps/api` and `apps/web` together.
Run with Docker:
```bash
docker compose up -d --build
```
Run with GitHub Container Registry images:
```yaml
services:
api:
image: ghcr.io/nexmoe/vidbee-api:latest
environment:
VIDBEE_API_HOST: 0.0.0.0
VIDBEE_API_PORT: 3100
VIDBEE_DOWNLOAD_DIR: /data/downloads
VIDBEE_HISTORY_STORE_PATH: /data/vidbee/vidbee.db
ports:
- "3100:3100"
volumes:
- vidbee-downloads:/data/downloads
- vidbee-data:/data/vidbee
restart: unless-stopped
web:
image: ghcr.io/nexmoe/vidbee-web:latest
depends_on:
- api
ports:
- "3000:3000"
restart: unless-stopped
volumes:
vidbee-downloads:
vidbee-data:
```
Stop services:
```bash
docker compose down
```
Optional env vars (via `.env`):
```bash
VIDBEE_API_PORT=3100
VIDBEE_WEB_PORT=3000
VITE_API_URL=http://localhost:3100
```
## 🤝 Contributing
You are welcome to join the open source community to build together. For more details, check out:
- Monorepo apps:
- `apps/desktop`: VidBee desktop app (Electron)
- `apps/docs`: Documentation site (Fumapress)
- `apps/extension`: Browser extension (WXT)
- `apps/desktop/docs/glitchtip.md`: GlitchTip and `sentry-cli` setup for desktop monitoring
- [Contributing Guide](./CONTRIBUTING.md)
- [DeepWiki Documentation](https://deepwiki.com/nexmoe/VidBee)
## 📄 License
This project is distributed under the MIT License. See [`LICENSE`](LICENSE) for details.
## 🙏 Thanks
- [yt-dlp](https://github.com/yt-dlp/yt-dlp) - The powerful video downloader engine
- [FFmpeg](https://ffmpeg.org/) - The multimedia framework for video and audio processing
- [Electron](https://www.electronjs.org/) - Build cross-platform desktop apps
- [React](https://react.dev/) - The UI library
- [Vite](https://vitejs.dev/) - Next generation frontend tooling
- [Tailwind CSS](https://tailwindcss.com/) - Utility-first CSS framework
- [shadcn/ui](https://ui.shadcn.com/) - Beautifully designed components
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`nexmoe/VidBee`
- 原始仓库:https://github.com/nexmoe/VidBee
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+46
View File
@@ -0,0 +1,46 @@
FROM node:22-alpine
ENV PNPM_HOME=/pnpm
ENV PATH=${PNPM_HOME}:${PATH}
ENV YTDLP_PATH=/usr/bin/yt-dlp
ENV FFMPEG_PATH=/usr/bin/ffmpeg
RUN apk add --no-cache yt-dlp ffmpeg python3 make g++
RUN corepack enable
WORKDIR /app
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/api/package.json apps/api/package.json
COPY packages/db/package.json packages/db/package.json
COPY packages/downloader-core/package.json packages/downloader-core/package.json
COPY packages/task-queue/package.json packages/task-queue/package.json
COPY packages/subscriptions-core/package.json packages/subscriptions-core/package.json
RUN pnpm install --filter "{./apps/api}..." --frozen-lockfile
COPY apps/api apps/api
COPY apps/desktop/resources/drizzle apps/desktop/resources/drizzle
COPY packages/db packages/db
COPY packages/downloader-core packages/downloader-core
COPY packages/task-queue packages/task-queue
COPY packages/subscriptions-core packages/subscriptions-core
EXPOSE 3100
ENV VIDBEE_API_HOST=0.0.0.0
ENV VIDBEE_API_PORT=3100
ENV VIDBEE_DOWNLOAD_DIR=/data/downloads
# Persistence is enabled by default so the `before-start` history migration
# (legacy download_history → tasks) is actually visible at runtime, and so
# crash-recovery works across container restarts. Mount /data/downloads as
# a persistent volume in production (the Dockerfile already declares it).
# To revert to the old stateless behavior set VIDBEE_PERSIST_QUEUE=0.
ENV VIDBEE_PERSIST_QUEUE=1
VOLUME ["/data/downloads"]
# `before-start` runs migrate-history.ts (legacy download_history → tasks)
# and then boots the API. The script is idempotent so re-runs are safe.
CMD ["pnpm", "--filter", "./apps/api", "run", "before-start"]
+34
View File
@@ -0,0 +1,34 @@
{
"name": "api",
"private": true,
"type": "module",
"scripts": {
"dev": "node scripts/ensure-node-sqlite.mjs && tsx watch src/index.ts",
"start": "node scripts/ensure-node-sqlite.mjs && tsx src/index.ts",
"migrate-history": "tsx scripts/migrate-history.ts",
"before-start": "node scripts/ensure-node-sqlite.mjs && tsx scripts/migrate-history.ts && tsx src/index.ts",
"typecheck": "tsc --noEmit",
"check": "ultracite check && pnpm run typecheck"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
"@orpc/openapi": "^1.13.5",
"@orpc/server": "^1.13.5",
"@orpc/zod": "^1.13.5",
"@vidbee/db": "workspace:*",
"@vidbee/downloader-core": "workspace:*",
"@vidbee/subscriptions-core": "workspace:*",
"@vidbee/task-queue": "workspace:*",
"better-sqlite3": "^12.4.1",
"drizzle-orm": "^0.44.7",
"fastify": "^5.7.4"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.18.6",
"drizzle-kit": "0.31.7",
"tsx": "^4.21.0",
"typescript": "^5.9.2",
"ultracite": "7.1.5"
}
}
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env node
import { execSync } from 'node:child_process'
// GitHub issue #368: the desktop app rebuilds the shared, pnpm-hoisted
// better-sqlite3 for Electron's ABI (NODE_MODULE_VERSION 139). The API runs on
// plain Node (ABI 137) and then crashes loading the same binary. This mirrors
// apps/desktop/scripts/ensure-native-deps.mjs for the Node side: detect the ABI
// mismatch and rebuild better-sqlite3 for the current Node before the API boots.
const checkScript =
"const Database=require('better-sqlite3');const db=new Database(':memory:');db.close()"
function canLoadBetterSqlite3WithNode() {
try {
// Run in a child node so a failed native load cannot crash this process.
execSync(`node -e "${checkScript}"`, { stdio: 'pipe' })
return true
} catch (error) {
const details = error.stderr?.toString().trim() || error.message
console.warn(`[native-deps] better-sqlite3 (Node) check failed: ${details}`)
return false
}
}
if (canLoadBetterSqlite3WithNode()) {
process.exit(0)
}
console.log('[native-deps] Rebuilding better-sqlite3 for Node...')
execSync('pnpm rebuild better-sqlite3', { stdio: 'inherit' })
if (!canLoadBetterSqlite3WithNode()) {
throw new Error('[native-deps] better-sqlite3 is still unavailable for Node after rebuild')
}
console.log('[native-deps] better-sqlite3 is ready for Node')
+361
View File
@@ -0,0 +1,361 @@
#!/usr/bin/env tsx
/**
* One-shot migration: copy every row from the legacy `download_history`
* table into the new task-queue `tasks` table as a terminal record with
* `status_reason = 'legacy-import'`.
*
* Idempotent: re-running it is a no-op for rows that have already been
* imported (matched by id).
*
* Containerized hosts wire this into a `before_start` hook so a fresh
* container picks up legacy history transparently. On migration failure
* the legacy DB is left untouched and the new task-queue DB rolls back
* to the pre-migration state.
*
* Usage:
* tsx apps/api/scripts/migrate-history.ts
*
* Env vars (all optional):
* VIDBEE_HISTORY_STORE_PATH legacy sqlite db path (default uses
* $VIDBEE_DOWNLOAD_DIR/.vidbee/vidbee.db)
* VIDBEE_TASK_QUEUE_DB task-queue sqlite db path (default uses
* $VIDBEE_DOWNLOAD_DIR/.vidbee/task-queue.db)
* VIDBEE_DOWNLOAD_DIR default download directory
*/
import fs from 'node:fs'
import { createRequire } from 'node:module'
import os from 'node:os'
import path from 'node:path'
import { TASK_QUEUE_DDL_V1 } from '@vidbee/db/task-queue'
import {
PRIORITY_BACKGROUND,
type Task,
type TaskOutput,
type TaskStatus
} from '@vidbee/task-queue'
const require = createRequire(import.meta.url)
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3') as typeof import('better-sqlite3')
const trim = (v?: string | null): string | undefined => {
const t = v?.trim()
return t && t.length > 0 ? t : undefined
}
const DEFAULT_DOWNLOAD_DIR = path.join(os.homedir(), 'Downloads', 'VidBee')
const downloadDir =
trim(process.env.VIDBEE_DOWNLOAD_DIR) ??
trim(process.env.DOWNLOAD_DIR) ??
DEFAULT_DOWNLOAD_DIR
const legacyDbPath =
trim(process.env.VIDBEE_HISTORY_STORE_PATH) ?? path.join(downloadDir, '.vidbee', 'vidbee.db')
const taskQueueDbPath =
trim(process.env.VIDBEE_TASK_QUEUE_DB) ?? path.join(downloadDir, '.vidbee', 'task-queue.db')
interface LegacyHistoryRow {
id: string
url: string
title: string
thumbnail: string | null
type: string // 'video' | 'audio'
status: string // 'completed' | 'error' | 'cancelled'
download_path: string | null
saved_file_name: string | null
file_size: number | null
duration: number | null
downloaded_at: number
completed_at: number | null
sort_key: number
error: string | null
yt_dlp_command: string | null
yt_dlp_log: string | null
description: string | null
channel: string | null
uploader: string | null
view_count: number | null
tags: string | null
origin: string | null
subscription_id: string | null
selected_format: string | null
playlist_id: string | null
playlist_title: string | null
playlist_index: number | null
playlist_size: number | null
}
const TERMINAL_LEGACY: Record<string, TaskStatus | undefined> = {
completed: 'completed',
error: 'failed',
cancelled: 'cancelled'
}
function rowToTask(row: LegacyHistoryRow): Task | null {
const status = TERMINAL_LEGACY[row.status]
if (!status) return null
const filePath =
row.download_path && row.saved_file_name
? path.join(row.download_path, row.saved_file_name)
: (row.download_path ?? '')
const output: TaskOutput | null =
status === 'completed'
? {
filePath,
size: row.file_size ?? 0,
durationMs: row.duration != null ? row.duration * 1000 : null,
sha256: null
}
: null
const completedAt = row.completed_at ?? row.downloaded_at
return {
id: row.id,
kind: row.type === 'audio' ? 'audio' : 'video',
parentId: null,
input: {
url: row.url,
kind: row.type === 'audio' ? 'audio' : 'video',
title: row.title,
thumbnail: row.thumbnail ?? undefined,
subscriptionId: row.subscription_id ?? undefined,
playlistId: row.playlist_id ?? undefined,
playlistIndex: row.playlist_index ?? undefined,
options: {
type: row.type === 'audio' ? 'audio' : 'video',
title: row.title,
thumbnail: row.thumbnail ?? undefined,
description: row.description ?? undefined,
channel: row.channel ?? undefined,
uploader: row.uploader ?? undefined,
viewCount: row.view_count ?? undefined,
tags: parseTags(row.tags),
duration: row.duration ?? undefined,
playlistTitle: row.playlist_title ?? undefined,
playlistSize: row.playlist_size ?? undefined,
downloadPath: row.download_path ?? undefined,
fileSize: row.file_size ?? undefined,
startedAt: row.downloaded_at,
completedAt
}
},
priority: PRIORITY_BACKGROUND,
groupKey: row.subscription_id ? `sub:${row.subscription_id}` : safeHost(row.url),
status,
prevStatus: null,
statusReason: 'legacy-import',
enteredStatusAt: completedAt,
attempt: 0,
maxAttempts: 0,
nextRetryAt: null,
progress: {
percent: status === 'completed' ? 1 : null,
bytesDownloaded: row.file_size ?? null,
bytesTotal: row.file_size ?? null,
speedBps: null,
etaMs: null,
ticks: 0
},
output,
lastError:
status === 'failed'
? {
category: 'unknown',
exitCode: null,
rawMessage: row.error ?? 'legacy-import',
uiMessageKey: 'errors.legacy_import',
uiActionHints: [],
retryable: false,
suggestedRetryAfterMs: null
}
: null,
pid: null,
pidStartedAt: null,
createdAt: row.downloaded_at,
updatedAt: completedAt
}
}
function parseTags(value: string | null): string[] | undefined {
if (!value) return undefined
const tags = value
.split('\n')
.map((s) => s.trim())
.filter((s) => s.length > 0)
return tags.length > 0 ? tags : undefined
}
function safeHost(url: string): string {
try {
return new URL(url).host || 'unknown'
} catch {
return 'unknown'
}
}
function bindTask(task: Task): unknown[] {
return [
task.id,
task.kind,
task.parentId,
task.status,
task.prevStatus,
task.statusReason,
task.enteredStatusAt,
task.priority,
task.groupKey,
task.attempt,
task.maxAttempts,
task.nextRetryAt,
task.pid,
task.pidStartedAt,
task.createdAt,
task.updatedAt,
JSON.stringify(task.input),
JSON.stringify(task.progress),
task.output ? JSON.stringify(task.output) : null,
task.lastError ? JSON.stringify(task.lastError) : null
]
}
async function main(): Promise<void> {
if (!fs.existsSync(legacyDbPath)) {
// eslint-disable-next-line no-console
console.log(`[migrate-history] no legacy DB at ${legacyDbPath}; nothing to do.`)
return
}
// Warn loudly if persistence is disabled — without it, the runtime API uses
// an in-memory persist adapter and never reads the SQLite file we are about
// to populate. The migration would silently appear to "succeed" but users
// would see zero rows in /rpc/history/list. See bug #5 in NEX-124 review.
const persistFlag = (process.env.VIDBEE_PERSIST_QUEUE ?? '').trim()
const persistEnabled = persistFlag === '1' || persistFlag.toLowerCase() === 'true'
if (!persistEnabled) {
// eslint-disable-next-line no-console
console.warn(
'[migrate-history] WARNING: VIDBEE_PERSIST_QUEUE is not enabled. The ' +
'migrated rows will not be visible at runtime because the API will use ' +
'an in-memory queue. Set VIDBEE_PERSIST_QUEUE=1 (default in the bundled ' +
'Dockerfile) to read this database back.'
)
}
fs.mkdirSync(path.dirname(taskQueueDbPath), { recursive: true })
const legacyDb = new Database(legacyDbPath, { readonly: true, fileMustExist: true })
// Do NOT set journal_mode on a readonly handle — it's a write op and fails
// with SQLITE_READONLY. The legacy DB is opened read-only by design so we
// never mutate user data during migration.
const queueDb = new Database(taskQueueDbPath, { timeout: 5000 })
queueDb.pragma('journal_mode = WAL')
queueDb.pragma('foreign_keys = ON')
queueDb.exec(TASK_QUEUE_DDL_V1)
// Mark in-flight migration so callers can detect partial state.
queueDb
.prepare(
`INSERT INTO schema_meta (key, value) VALUES ('migration_in_progress','api-v1')
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
)
.run()
const tableCheck = legacyDb
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='download_history'")
.get() as { name?: string } | undefined
if (!tableCheck?.name) {
// eslint-disable-next-line no-console
console.log('[migrate-history] legacy download_history table not present; nothing to do.')
queueDb
.prepare("DELETE FROM schema_meta WHERE key = 'migration_in_progress'")
.run()
legacyDb.close()
queueDb.close()
return
}
const rows = legacyDb
.prepare('SELECT * FROM download_history ORDER BY sort_key ASC')
.all() as LegacyHistoryRow[]
const insertOrSkip = queueDb.prepare(`
INSERT INTO tasks (
id, kind, parent_id, status, prev_status, status_reason, entered_status_at,
priority, group_key, attempt, max_attempts, next_retry_at, pid,
pid_started_at, created_at, updated_at, input_json, progress_json,
output_json, last_error_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO NOTHING
`)
const tx = queueDb.transaction((tasks: Task[]) => {
let imported = 0
for (const task of tasks) {
const result = insertOrSkip.run(...bindTask(task))
if (result.changes > 0) imported += 1
}
return imported
})
let mappedTasks = 0
let skippedRows = 0
const tasks: Task[] = []
for (const row of rows) {
const task = rowToTask(row)
if (task) {
tasks.push(task)
mappedTasks += 1
} else {
skippedRows += 1
}
}
let imported = 0
try {
imported = tx(tasks) as number
} catch (err) {
// Roll back the migration marker; leave already-imported rows alone (the
// unique-id INSERT is idempotent and re-running picks up where we stopped).
queueDb
.prepare("DELETE FROM schema_meta WHERE key = 'migration_in_progress'")
.run()
legacyDb.close()
queueDb.close()
throw err
}
queueDb
.prepare("DELETE FROM schema_meta WHERE key = 'migration_in_progress'")
.run()
// Best-effort rename to mark the legacy DB as migrated. Container hosts
// can pre-bind the mount as read-only; ignore EROFS.
const migratedPath = `${legacyDbPath}.migrated`
try {
if (!fs.existsSync(migratedPath)) {
fs.copyFileSync(legacyDbPath, migratedPath)
}
} catch (err) {
// eslint-disable-next-line no-console
console.warn('[migrate-history] could not stamp legacy DB as migrated:', err)
}
legacyDb.close()
queueDb.close()
// eslint-disable-next-line no-console
console.log(
`[migrate-history] legacy rows: ${rows.length}, mapped: ${mappedTasks}, skipped: ${skippedRows}, imported: ${imported} (already-present skipped)`
)
}
main().catch((err) => {
// eslint-disable-next-line no-console
console.error('[migrate-history] failed:', err)
process.exit(1)
})
+28
View File
@@ -0,0 +1,28 @@
import { createApiServer } from './server'
const host = process.env.VIDBEE_API_HOST?.trim() || '0.0.0.0'
const portValue = Number(process.env.VIDBEE_API_PORT ?? '')
const port = Number.isInteger(portValue) && portValue > 0 ? portValue : 3100
const server = await createApiServer()
try {
await server.listen({ host, port })
server.log.info(`VidBee API server listening on http://${host}:${port}`)
} catch (error) {
server.log.error(error)
process.exit(1)
}
const shutdown = async (signal: string) => {
server.log.info(`Received ${signal}, shutting down API server`)
await server.close()
process.exit(0)
}
process.on('SIGINT', () => {
void shutdown('SIGINT')
})
process.on('SIGTERM', () => {
void shutdown('SIGTERM')
})
+14
View File
@@ -0,0 +1,14 @@
import { existsSync } from 'node:fs'
import path from 'node:path'
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
import { migrate } from 'drizzle-orm/better-sqlite3/migrator'
const MIGRATIONS_FOLDER = path.resolve(import.meta.dirname, '../../../desktop/resources/drizzle')
export const runDatabaseMigrations = (database: BetterSQLite3Database): void => {
if (!existsSync(MIGRATIONS_FOLDER)) {
throw new Error(`API migrations folder not found: ${MIGRATIONS_FOLDER}`)
}
migrate(database, { migrationsFolder: MIGRATIONS_FOLDER })
}
+19
View File
@@ -0,0 +1,19 @@
/**
* Public surface for the rpc-router and server bootstrap. Re-exports the
* TaskQueueAPI singleton (and lifecycle hooks) that replaces the previous
* `DownloaderCore` instance after NEX-131.
*
* The legacy `DownloaderCore` class is no longer instantiated by the API;
* routes that need yt-dlp metadata (videoInfo / playlist.info) use the
* stateless `fetchVideoInfo` / `fetchPlaylistInfo` helpers in
* `./yt-dlp-info.ts`.
*/
export {
apiDefaultDownloadDir as downloadDir,
apiMaxConcurrent as maxConcurrent,
isTaskQueuePersistent,
startTaskQueue,
stopTaskQueue,
taskQueue,
taskQueueExecutor
} from './task-queue-host'
+58
View File
@@ -0,0 +1,58 @@
/**
* Narrow @vidbee/task-queue's host-neutral LegacyTaskProjection into the
* concrete `DownloadTask` shape that the public downloaderContract returns.
* Any field absent from DownloadTask is dropped here, not in the shared
* projection — Desktop carries some of the same fields but renders them
* differently.
*/
import { projectTaskToLegacy } from '@vidbee/task-queue'
import type { Task } from '@vidbee/task-queue'
import type { DownloadTask } from '@vidbee/downloader-core'
export function projectTaskForApi(task: Readonly<Task>): DownloadTask {
const proj = projectTaskToLegacy(task)
const out: DownloadTask = {
id: proj.id,
url: proj.url,
title: proj.title,
thumbnail: proj.thumbnail,
type: proj.type,
status: proj.status,
createdAt: proj.createdAt,
startedAt: proj.startedAt,
completedAt: proj.completedAt,
duration: proj.duration,
fileSize: proj.fileSize,
speed: proj.speed,
downloadPath: proj.downloadPath,
savedFileName: proj.savedFileName,
description: proj.description,
channel: proj.channel,
uploader: proj.uploader,
viewCount: proj.viewCount,
tags: proj.tags,
playlistId: proj.playlistId,
playlistTitle: proj.playlistTitle,
playlistIndex: proj.playlistIndex,
playlistSize: proj.playlistSize,
error: proj.error,
internalStatus: proj.internalStatus,
subStatus: proj.subStatus,
statusReason: proj.statusReason,
errorCategory: proj.errorCategory,
uiMessageKey: proj.uiMessageKey,
nextRetryAt: proj.nextRetryAt,
attempt: proj.attempt,
maxAttempts: proj.maxAttempts
}
if (proj.progress) {
out.progress = {
percent: proj.progress.percent,
currentSpeed: proj.progress.currentSpeed,
eta: proj.progress.eta,
downloaded: proj.progress.downloaded,
total: proj.progress.total
}
}
return out
}
+591
View File
@@ -0,0 +1,591 @@
import { spawn } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { constants as fsConstants } from 'node:fs'
import { access, mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { implement, ORPCError } from '@orpc/server'
import { downloaderContract } from '@vidbee/downloader-core'
import type { DownloadTask } from '@vidbee/downloader-core'
import type { Task, TaskStatus } from '@vidbee/task-queue'
import { projectTaskForApi } from './projection'
import { taskQueue, taskQueueExecutor } from './downloader'
import { webSettingsStore } from './web-settings-store'
import { fetchPlaylistInfo, fetchVideoInfo } from './yt-dlp-info'
const os = implement(downloaderContract)
const WEB_SETTINGS_FILES_DIR = path.resolve(process.cwd(), '.data', 'web-settings-files')
const MAX_WEB_SETTINGS_FILE_BYTES = 1_000_000
const MANAGED_SETTINGS_FILE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
const SAFE_FILE_NAME_REGEX = /[^A-Za-z0-9._-]+/g
type ManagedSettingsFileKind = 'cookies' | 'config'
const TERMINAL_TASK_STATUSES = new Set<TaskStatus>(['completed', 'failed', 'cancelled'])
const NON_TERMINAL_TASK_STATUSES = new Set<TaskStatus>([
'queued',
'running',
'processing',
'paused',
'retry-scheduled'
])
const toErrorMessage = (error: unknown, fallbackMessage: string): string => {
if (error instanceof Error && error.message.trim().length > 0) {
return error.message
}
return fallbackMessage
}
const runProcess = (command: string, args: string[]): Promise<boolean> =>
new Promise((resolve) => {
const child = spawn(command, args, {
stdio: 'ignore',
windowsHide: true
})
child.on('error', () => resolve(false))
child.on('close', (code) => resolve(code === 0))
})
const pathExists = async (targetPath: string): Promise<boolean> => {
try {
await access(targetPath, fsConstants.F_OK)
return true
} catch {
return false
}
}
const isPathWithinBase = (basePath: string, targetPath: string): boolean => {
const normalizedBase = path.resolve(basePath)
const normalizedTarget = path.resolve(targetPath)
const relativePath = path.relative(normalizedBase, normalizedTarget)
return relativePath !== '' && !relativePath.startsWith('..') && !path.isAbsolute(relativePath)
}
const openFileWithSystem = async (targetPath: string): Promise<boolean> => {
if (process.platform === 'darwin') return runProcess('open', [targetPath])
if (process.platform === 'win32') return runProcess('cmd', ['/c', 'start', '', targetPath])
return runProcess('xdg-open', [targetPath])
}
const openFileLocationWithSystem = async (targetPath: string): Promise<boolean> => {
if (process.platform === 'darwin') return runProcess('open', ['-R', targetPath])
if (process.platform === 'win32') return runProcess('explorer', [`/select,${targetPath}`])
return runProcess('xdg-open', [path.dirname(targetPath)])
}
const copyFileToClipboardWithSystem = async (targetPath: string): Promise<boolean> => {
if (process.platform === 'darwin') {
const escapedPath = targetPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
return runProcess('osascript', ['-e', `set the clipboard to (POSIX file "${escapedPath}")`])
}
if (process.platform === 'win32') {
const escapedPath = targetPath.replace(/'/g, "''")
return runProcess('powershell', [
'-NoProfile',
'-Command',
`Set-Clipboard -Path '${escapedPath}'`
])
}
return false
}
const listServerDirectories = async (
rawPath: string | undefined
): Promise<{
currentPath: string
parentPath: string | null
directories: { name: string; path: string }[]
}> => {
const requestedPath = rawPath?.trim()
const candidatePath = requestedPath && requestedPath.length > 0 ? requestedPath : process.cwd()
const currentPath = path.resolve(candidatePath)
const pathInfo = await stat(currentPath)
if (!pathInfo.isDirectory()) {
throw new Error('Path is not a directory.')
}
const entries = await readdir(currentPath, { withFileTypes: true })
const directories = entries
.filter((entry) => entry.isDirectory())
.map((entry) => ({
name: entry.name,
path: path.join(currentPath, entry.name)
}))
.sort((a, b) => a.name.localeCompare(b.name))
const parsed = path.parse(currentPath)
const parentPath = currentPath === parsed.root ? null : path.dirname(currentPath)
return { currentPath, parentPath, directories }
}
const sanitizeUploadedFileName = (fileName: string, fallbackFileName: string): string => {
const normalized = path
.basename(fileName.trim())
.replace(SAFE_FILE_NAME_REGEX, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
if (!normalized) return fallbackFileName
return normalized.slice(0, 120)
}
const storeWebSettingsFile = async (
kind: 'cookies' | 'config',
fileName: string,
content: string
): Promise<string> => {
const contentBuffer = Buffer.from(content, 'utf-8')
if (contentBuffer.byteLength > MAX_WEB_SETTINGS_FILE_BYTES) {
throw new Error('Uploaded file is too large.')
}
const destinationDir = path.join(WEB_SETTINGS_FILES_DIR, kind)
await mkdir(destinationDir, { recursive: true })
const fallbackFileName = kind === 'cookies' ? 'cookies.txt' : 'config.txt'
const safeFileName = sanitizeUploadedFileName(fileName, fallbackFileName)
const storedFileName = `${Date.now()}-${randomUUID()}-${safeFileName}`
const destinationPath = path.join(destinationDir, storedFileName)
await writeFile(destinationPath, contentBuffer)
return destinationPath
}
const resolveManagedSettingsFilePath = (
rawPath: string,
kind: ManagedSettingsFileKind
): string | null => {
const trimmedPath = rawPath.trim()
if (!trimmedPath) return null
const resolvedPath = path.resolve(trimmedPath)
const managedDirectory = path.join(WEB_SETTINGS_FILES_DIR, kind)
if (!isPathWithinBase(managedDirectory, resolvedPath)) return null
return resolvedPath
}
const pruneManagedSettingsFiles = async (
kind: ManagedSettingsFileKind,
referencedPaths: string[]
): Promise<void> => {
const managedDirectory = path.join(WEB_SETTINGS_FILES_DIR, kind)
const keepPaths = new Set<string>()
for (const rawPath of referencedPaths) {
const managedPath = resolveManagedSettingsFilePath(rawPath, kind)
if (managedPath) keepPaths.add(managedPath)
}
let entries: { isFile: () => boolean; name: string }[] = []
try {
entries = await readdir(managedDirectory, { withFileTypes: true })
} catch {
return
}
const now = Date.now()
for (const entry of entries) {
if (!entry.isFile()) continue
const candidatePath = path.resolve(path.join(managedDirectory, entry.name))
if (keepPaths.has(candidatePath)) continue
try {
const candidateInfo = await stat(candidatePath)
if (now - candidateInfo.mtimeMs < MANAGED_SETTINGS_FILE_RETENTION_MS) continue
await rm(candidatePath, { force: true })
} catch {
// Ignore cleanup errors to keep upload and settings updates resilient.
}
}
}
const triggerManagedSettingsFilePrune = (
kind: ManagedSettingsFileKind,
newlyUploadedPath: string
): void => {
void (async () => {
try {
const settings = await webSettingsStore.get()
const currentSettingsPath = kind === 'cookies' ? settings.cookiesPath : settings.configPath
await pruneManagedSettingsFiles(kind, [newlyUploadedPath, currentSettingsPath])
} catch {
// Ignore cleanup errors to keep upload and settings updates resilient.
}
})()
}
const PLAYLIST_GROUP_PREFIX = 'playlist_group_'
const projectTask = (task: Readonly<Task>): DownloadTask => projectTaskForApi(task)
const listTasksByStatuses = (statuses: ReadonlySet<TaskStatus>): DownloadTask[] => {
const tasks: Task[] = []
let cursor: string | null = null
do {
const page = taskQueue.list({ limit: 200, cursor })
for (const t of page.tasks) {
if (statuses.has(t.status)) tasks.push(t)
}
cursor = page.nextCursor
} while (cursor)
return tasks
.sort((a, b) => b.createdAt - a.createdAt)
.map(projectTask)
}
export const rpcRouter = os.router({
status: os.status.handler(() => {
const stats = taskQueue.stats()
return {
ok: true,
version: '1.0.0',
active: stats.running,
pending: stats.queued
}
}),
videoInfo: os.videoInfo.handler(async ({ input }) => {
try {
const video = await fetchVideoInfo(input.url, input.settings)
return { video }
} catch (error) {
throw new ORPCError('INTERNAL_SERVER_ERROR', {
message: toErrorMessage(error, 'Failed to fetch video info.')
})
}
}),
playlist: {
info: os.playlist.info.handler(async ({ input }) => {
try {
const playlist = await fetchPlaylistInfo(input.url, input.settings)
return { playlist }
} catch (error) {
throw new ORPCError('INTERNAL_SERVER_ERROR', {
message: toErrorMessage(error, 'Failed to fetch playlist info.')
})
}
}),
download: os.playlist.download.handler(async ({ input }) => {
try {
const playlist = await fetchPlaylistInfo(input.url, input.settings)
const groupId = `${PLAYLIST_GROUP_PREFIX}${Date.now()}_${randomUUID().slice(0, 8)}`
if (playlist.entryCount === 0) {
return {
result: {
groupId,
playlistId: playlist.id,
playlistTitle: playlist.title,
type: input.type,
totalCount: 0,
startIndex: 0,
endIndex: 0,
entries: []
}
}
}
let selected = playlist.entries
if (input.entryIds && input.entryIds.length > 0) {
const ids = new Set(input.entryIds)
selected = playlist.entries.filter((e) => ids.has(e.id))
} else {
const requestedStart = Math.max((input.startIndex ?? 1) - 1, 0)
const requestedEnd = input.endIndex
? Math.min(input.endIndex - 1, playlist.entryCount - 1)
: playlist.entryCount - 1
const rangeStart = Math.min(requestedStart, requestedEnd)
const rangeEnd = Math.max(requestedStart, requestedEnd)
selected = playlist.entries.slice(rangeStart, rangeEnd + 1)
}
const created: Array<{
downloadId: string
entryId: string
title: string
url: string
index: number
}> = []
for (const entry of selected) {
const result = await taskQueue.add({
input: {
url: entry.url,
kind: input.type === 'audio' ? 'audio' : 'video',
title: entry.title,
thumbnail: entry.thumbnail,
playlistId: groupId,
playlistIndex: entry.index,
options: {
type: input.type,
format: input.format,
audioFormat: input.audioFormat,
audioFormatIds: input.audioFormatIds,
customDownloadPath: input.customDownloadPath,
customFilenameTemplate: input.customFilenameTemplate,
containerFormat: input.containerFormat,
settings: input.settings,
title: entry.title,
thumbnail: entry.thumbnail,
playlistTitle: playlist.title,
playlistSize: selected.length
}
},
groupKey: `playlist:${groupId}`
})
created.push({
downloadId: result.id,
entryId: entry.id,
title: entry.title,
url: entry.url,
index: entry.index
})
}
return {
result: {
groupId,
playlistId: playlist.id,
playlistTitle: playlist.title,
type: input.type,
totalCount: selected.length,
startIndex: selected[0]?.index ?? 0,
endIndex: selected.at(-1)?.index ?? 0,
entries: created
}
}
} catch (error) {
throw new ORPCError('INTERNAL_SERVER_ERROR', {
message: toErrorMessage(error, 'Failed to start playlist download.')
})
}
})
},
downloads: {
create: os.downloads.create.handler(async ({ input }) => {
try {
const result = await taskQueue.add({
input: {
url: input.url,
kind: input.type === 'audio' ? 'audio' : 'video',
title: input.title,
thumbnail: input.thumbnail,
playlistId: input.playlistId,
playlistIndex: input.playlistIndex,
options: {
type: input.type,
format: input.format,
audioFormat: input.audioFormat,
audioFormatIds: input.audioFormatIds,
startTime: input.startTime,
endTime: input.endTime,
customDownloadPath: input.customDownloadPath,
customFilenameTemplate: input.customFilenameTemplate,
containerFormat: input.containerFormat,
settings: input.settings,
title: input.title,
thumbnail: input.thumbnail,
description: input.description,
channel: input.channel,
uploader: input.uploader,
viewCount: input.viewCount,
tags: input.tags ? [...input.tags] : undefined,
duration: input.duration,
playlistTitle: input.playlistTitle,
playlistSize: input.playlistSize
}
}
})
const created = taskQueue.get(result.id)
if (!created) {
throw new Error('Failed to read back created task.')
}
return { download: projectTask(created) }
} catch (error) {
throw new ORPCError('INTERNAL_SERVER_ERROR', {
message: toErrorMessage(error, 'Failed to create download.')
})
}
}),
list: os.downloads.list.handler(() => {
return { downloads: listTasksByStatuses(NON_TERMINAL_TASK_STATUSES) }
}),
cancel: os.downloads.cancel.handler(async ({ input }) => {
try {
const task = taskQueue.get(input.id)
if (!task) return { cancelled: false }
await taskQueue.cancel(input.id)
return { cancelled: true }
} catch (error) {
throw new ORPCError('INTERNAL_SERVER_ERROR', {
message: toErrorMessage(error, 'Failed to cancel download.')
})
}
})
},
history: {
list: os.history.list.handler(() => {
return { history: listTasksByStatuses(TERMINAL_TASK_STATUSES) }
}),
removeItems: os.history.removeItems.handler(async ({ input }) => {
let removed = 0
for (const rawId of input.ids) {
const id = rawId.trim()
if (!id) continue
const task = taskQueue.get(id)
if (!task) continue
try {
await taskQueue.removeFromHistory(id)
removed += 1
} catch {
// Non-terminal tasks throw; skip them quietly to match legacy behavior.
}
}
return { removed }
}),
removeByPlaylist: os.history.removeByPlaylist.handler(async ({ input }) => {
const playlistId = input.playlistId.trim()
if (!playlistId) return { removed: 0 }
let removed = 0
let cursor: string | null = null
do {
const page = taskQueue.list({ limit: 200, cursor })
for (const t of page.tasks) {
if (t.input.playlistId === playlistId && TERMINAL_TASK_STATUSES.has(t.status)) {
try {
await taskQueue.removeFromHistory(t.id)
removed += 1
} catch {
/* skip */
}
}
}
cursor = page.nextCursor
} while (cursor)
return { removed }
})
},
files: {
exists: os.files.exists.handler(async ({ input }) => {
try {
const resolvedPath = path.resolve(input.path)
return { exists: await pathExists(resolvedPath) }
} catch (error) {
throw new ORPCError('INTERNAL_SERVER_ERROR', {
message: toErrorMessage(error, 'Failed to check file existence.')
})
}
}),
listDirectories: os.files.listDirectories.handler(async ({ input }) => {
try {
return await listServerDirectories(input.path)
} catch (error) {
throw new ORPCError('INTERNAL_SERVER_ERROR', {
message: toErrorMessage(error, 'Failed to list server directories.')
})
}
}),
openFile: os.files.openFile.handler(async ({ input }) => {
try {
const resolvedPath = path.resolve(input.path)
const exists = await pathExists(resolvedPath)
if (!exists) return { success: false }
return { success: await openFileWithSystem(resolvedPath) }
} catch (error) {
throw new ORPCError('INTERNAL_SERVER_ERROR', {
message: toErrorMessage(error, 'Failed to open file.')
})
}
}),
openFileLocation: os.files.openFileLocation.handler(async ({ input }) => {
try {
const resolvedPath = path.resolve(input.path)
const exists = await pathExists(resolvedPath)
if (!exists) return { success: false }
return { success: await openFileLocationWithSystem(resolvedPath) }
} catch (error) {
throw new ORPCError('INTERNAL_SERVER_ERROR', {
message: toErrorMessage(error, 'Failed to open file location.')
})
}
}),
copyFileToClipboard: os.files.copyFileToClipboard.handler(async ({ input }) => {
try {
const resolvedPath = path.resolve(input.path)
const exists = await pathExists(resolvedPath)
if (!exists) return { success: false }
return { success: await copyFileToClipboardWithSystem(resolvedPath) }
} catch (error) {
throw new ORPCError('INTERNAL_SERVER_ERROR', {
message: toErrorMessage(error, 'Failed to copy file to clipboard.')
})
}
}),
deleteFile: os.files.deleteFile.handler(async ({ input }) => {
try {
const settings = await webSettingsStore.get()
const managedDownloadPath = settings.downloadPath.trim()
if (!managedDownloadPath) {
throw new ORPCError('FORBIDDEN', {
message: 'Deleting files is disabled until a download path is configured.'
})
}
const resolvedPath = path.resolve(input.path)
if (!isPathWithinBase(managedDownloadPath, resolvedPath)) {
throw new ORPCError('FORBIDDEN', {
message: 'Refusing to delete files outside the managed download directory.'
})
}
const exists = await pathExists(resolvedPath)
if (!exists) return { success: false }
await rm(resolvedPath)
return { success: true }
} catch (error) {
throw new ORPCError('INTERNAL_SERVER_ERROR', {
message: toErrorMessage(error, 'Failed to delete file.')
})
}
}),
uploadSettingsFile: os.files.uploadSettingsFile.handler(async ({ input }) => {
try {
const storedPath = await storeWebSettingsFile(input.kind, input.fileName, input.content)
triggerManagedSettingsFilePrune(input.kind, storedPath)
return { path: storedPath }
} catch (error) {
throw new ORPCError('INTERNAL_SERVER_ERROR', {
message: toErrorMessage(error, 'Failed to upload settings file.')
})
}
})
},
settings: {
get: os.settings.get.handler(async () => {
try {
const settings = await webSettingsStore.get()
return { settings }
} catch (error) {
throw new ORPCError('INTERNAL_SERVER_ERROR', {
message: toErrorMessage(error, 'Failed to read settings.')
})
}
}),
set: os.settings.set.handler(async ({ input }) => {
try {
const settings = await webSettingsStore.set(input.settings)
return { settings }
} catch (error) {
throw new ORPCError('INTERNAL_SERVER_ERROR', {
message: toErrorMessage(error, 'Failed to save settings.')
})
}
})
}
})
// `taskQueueExecutor` is intentionally re-exported from this module so the
// integration test (packages/task-queue/__integration__/three-host-equivalence)
// can introspect host wiring without reaching into apps/api directly.
export { taskQueueExecutor }
+62
View File
@@ -0,0 +1,62 @@
import type { ServerResponse } from 'node:http'
const HEARTBEAT_INTERVAL_MS = 15_000
export class SseHub {
private readonly clients = new Set<ServerResponse>()
private heartbeatTimer: NodeJS.Timeout | null = null
addClient(client: ServerResponse): void {
this.clients.add(client)
client.write('event: connected\ndata: {"ok":true}\n\n')
this.ensureHeartbeatTimer()
}
removeClient(client: ServerResponse): void {
this.clients.delete(client)
if (this.clients.size === 0) {
this.clearHeartbeatTimer()
}
}
publish(event: string, payload: unknown): void {
if (this.clients.size === 0) {
return
}
const data = JSON.stringify(payload)
const message = `event: ${event}\ndata: ${data}\n\n`
for (const client of this.clients) {
client.write(message)
}
}
closeAll(): void {
for (const client of this.clients) {
client.end()
}
this.clients.clear()
this.clearHeartbeatTimer()
}
private ensureHeartbeatTimer(): void {
if (this.heartbeatTimer) {
return
}
this.heartbeatTimer = setInterval(() => {
for (const client of this.clients) {
client.write(': heartbeat\n\n')
}
}, HEARTBEAT_INTERVAL_MS)
}
private clearHeartbeatTimer(): void {
if (!this.heartbeatTimer) {
return
}
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
}
+161
View File
@@ -0,0 +1,161 @@
/**
* apps/api host for @vidbee/subscriptions-core (NEX-132 Phase B · Web/API).
*
* Owns the single `SubscriptionsApi` the `/rpc/subscriptions/*` routes feed
* into, and bridges subscription items into the shared task-queue (priority
* 10, group-keyed by subscription id, kind 'subscription-item').
*
* Storage: a dedicated `subscriptions.db` (sibling of `task-queue.db`).
* Sharing the SQLite file with Desktop is what makes the leader-election
* meaningful; on standalone API deployments the API simply always wins the
* lease and runs feed-checks itself.
*
* Operational env vars:
* VIDBEE_SUBSCRIPTIONS_DB override the default subscriptions.db path
*
* Existing task-queue env vars (`VIDBEE_DOWNLOAD_DIR`,
* `VIDBEE_PERSIST_QUEUE`, …) are reused via the shared host wiring.
*/
import fs from 'node:fs'
import { createRequire } from 'node:module'
import path from 'node:path'
import { SUBSCRIPTIONS_DDL_V1 } from '@vidbee/db/subscriptions'
import {
RssParserFeedFetcher,
SubscriptionsApi,
createSqliteMetaStore,
createSqliteSubscriptionsStore
} from '@vidbee/subscriptions-core'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import { apiDefaultDownloadDir, taskQueue } from './task-queue-host'
const require = createRequire(import.meta.url)
const trimEnv = (name: string): string | undefined => {
const v = process.env[name]?.trim()
return v && v.length > 0 ? v : undefined
}
const resolveDbPath = (): string => {
const override = trimEnv('VIDBEE_SUBSCRIPTIONS_DB')
if (override) return override
return path.join(apiDefaultDownloadDir, '.vidbee', 'subscriptions.db')
}
let api: SubscriptionsApi | null = null
let started = false
/**
* Open (or reuse) the singleton SubscriptionsApi for the API host.
*
* The drizzle handle is intentionally held inside the closure: we want a
* single long-lived better-sqlite3 connection so leader-election CAS runs
* inside one process's transaction, not split across re-opened handles.
*/
export const getApiSubscriptions = (): SubscriptionsApi => {
if (api) return api
const dbPath = resolveDbPath()
fs.mkdirSync(path.dirname(dbPath), { recursive: true })
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3') as typeof import('better-sqlite3')
const sqlite = new Database(dbPath, { timeout: 5000 })
sqlite.pragma('journal_mode = WAL')
sqlite.pragma('foreign_keys = ON')
sqlite.exec(SUBSCRIPTIONS_DDL_V1)
const db = drizzle(sqlite)
const store = createSqliteSubscriptionsStore({ db })
const metaStore = createSqliteMetaStore({ db })
api = new SubscriptionsApi({
kind: 'api',
pid: process.pid,
store,
metaStore,
fetcher: new RssParserFeedFetcher(),
enqueueItem: async ({ subscription, item }) => {
const tags = Array.from(new Set([subscription.platform, ...subscription.tags]))
const result = await taskQueue.add({
input: {
url: item.url,
kind: 'subscription-item',
title: item.title,
...(item.thumbnail !== undefined ? { thumbnail: item.thumbnail } : {}),
subscriptionId: subscription.id,
options: {
origin: 'subscription',
subscriptionId: subscription.id,
itemId: item.id,
...(subscription.downloadDirectory
? { customDownloadPath: subscription.downloadDirectory }
: {}),
...(subscription.namingTemplate
? { customFilenameTemplate: subscription.namingTemplate }
: {}),
tags
}
},
priority: 10,
groupKey: subscription.id
})
return result.id
},
log: (level, msg, meta) => {
// The API uses fastify's logger via stdout; emit through console here
// so the message lands in the same stream without binding to fastify.
const line = meta === undefined ? msg : `${msg} ${JSON.stringify(meta)}`
if (level === 'error') console.error(`subscriptions: ${line}`)
else if (level === 'warn') console.warn(`subscriptions: ${line}`)
else console.info(`subscriptions: ${line}`)
}
})
return api
}
export const startApiSubscriptions = async (): Promise<void> => {
if (started) return
await getApiSubscriptions().start()
started = true
}
export const stopApiSubscriptions = async (): Promise<void> => {
if (!started) return
await api?.stop()
started = false
}
/**
* Remove a subscription and cancel any non-terminal tasks the queue still
* holds for it (NEX-132 regression checklist: "用户暂停 / 删除订阅后,已经
* 入队但未开始的 subscription-item 任务被自动取消").
*
* Iterates the task list and cancels every queued/running/processing/paused
* task whose `groupKey` matches the subscription id. Errors on individual
* cancellations are swallowed so a stuck task can't block subscription
* removal.
*/
export const removeApiSubscription = async (id: string): Promise<void> => {
await getApiSubscriptions().remove({ id })
let cursor: string | null = null
do {
const page = taskQueue.list({ groupKey: id, limit: 200, cursor })
for (const task of page.tasks) {
if (
task.status === 'queued' ||
task.status === 'running' ||
task.status === 'processing' ||
task.status === 'paused' ||
task.status === 'retry-scheduled'
) {
try {
await taskQueue.cancel(task.id)
} catch {
// Best-effort: ignore tasks that already moved to a terminal state.
}
}
}
cursor = page.nextCursor
} while (cursor)
}
+112
View File
@@ -0,0 +1,112 @@
/**
* oRPC router that mounts `subscriptionContract` on the API server.
*
* Each route is a 1:1 forward into the singleton `SubscriptionsApi`. The
* server.ts wiring exposes this router under `/rpc/subscriptions/*`.
*/
import { ORPCError, implement } from '@orpc/server'
import {
SUBSCRIPTION_DUPLICATE_FEED_ERROR,
subscriptionContract
} from '@vidbee/subscriptions-core'
import { getApiSubscriptions, removeApiSubscription } from './subscriptions-host'
const os = implement(subscriptionContract)
const toErrorMessage = (error: unknown, fallback: string): string => {
if (error instanceof Error && error.message.trim().length > 0) {
return error.message
}
return fallback
}
const isDuplicateFeedError = (error: unknown): boolean =>
error instanceof Error && error.message === SUBSCRIPTION_DUPLICATE_FEED_ERROR
export const subscriptionsRouter = os.router({
list: os.list.handler(async () => {
return getApiSubscriptions().list()
}),
get: os.get.handler(async ({ input }) => {
try {
return await getApiSubscriptions().get(input)
} catch (error) {
throw new ORPCError('NOT_FOUND', {
message: toErrorMessage(error, 'Subscription not found.')
})
}
}),
resolve: os.resolve.handler(async ({ input }) => {
return getApiSubscriptions().resolve(input)
}),
add: os.add.handler(async ({ input }) => {
try {
return await getApiSubscriptions().add(input)
} catch (error) {
if (isDuplicateFeedError(error)) {
throw new ORPCError('CONFLICT', {
message: SUBSCRIPTION_DUPLICATE_FEED_ERROR
})
}
throw new ORPCError('INTERNAL_SERVER_ERROR', {
message: toErrorMessage(error, 'Failed to create subscription.')
})
}
}),
update: os.update.handler(async ({ input }) => {
try {
return await getApiSubscriptions().update(input)
} catch (error) {
if (isDuplicateFeedError(error)) {
throw new ORPCError('CONFLICT', {
message: SUBSCRIPTION_DUPLICATE_FEED_ERROR
})
}
throw new ORPCError('INTERNAL_SERVER_ERROR', {
message: toErrorMessage(error, 'Failed to update subscription.')
})
}
}),
remove: os.remove.handler(async ({ input }) => {
await removeApiSubscription(input.id)
return {}
}),
refresh: os.refresh.handler(async ({ input }) => {
try {
return await getApiSubscriptions().refresh(input)
} catch (error) {
throw new ORPCError('INTERNAL_SERVER_ERROR', {
message: toErrorMessage(error, 'Failed to refresh subscription.')
})
}
}),
itemsList: os.itemsList.handler(async ({ input }) => {
try {
return await getApiSubscriptions().itemsList(input)
} catch (error) {
throw new ORPCError('NOT_FOUND', {
message: toErrorMessage(error, 'Subscription not found.')
})
}
}),
itemsQueue: os.itemsQueue.handler(async ({ input }) => {
try {
return await getApiSubscriptions().itemsQueue(input)
} catch (error) {
throw new ORPCError('INTERNAL_SERVER_ERROR', {
message: toErrorMessage(error, 'Failed to queue subscription item.')
})
}
})
})
export type SubscriptionsRouter = typeof subscriptionsRouter
+158
View File
@@ -0,0 +1,158 @@
/**
* apps/api host for @vidbee/task-queue.
*
* Constructs the single TaskQueueAPI used by /rpc/* and /events for the
* Web/API surface, plus a thin yt-dlp metadata client used by `videoInfo`
* and `playlist.info` (those calls are stateless and bypass the queue).
*
* Operational env vars (preserved from the pre-NEX-131 surface):
* VIDBEE_DOWNLOAD_DIR default download dir for new tasks
* VIDBEE_MAX_CONCURRENT Scheduler.maxConcurrency
* VIDBEE_HISTORY_STORE_PATH legacy history sqlite path; only used by
* scripts/migrate-history.ts now
* VIDBEE_PERSIST_QUEUE=1 switch from in-memory to SQLite-backed
* TaskQueue (matches Desktop crash recovery)
* VIDBEE_TASK_QUEUE_DB override task-queue sqlite path
* YTDLP_PATH / FFMPEG_PATH binary overrides (unchanged)
*/
import fs from 'node:fs'
import { createRequire } from 'node:module'
import os from 'node:os'
import path from 'node:path'
import {
MemoryPersistAdapter,
SqlitePersistAdapter,
TaskQueueAPI
} from '@vidbee/task-queue'
import { TASK_QUEUE_DDL_V1 } from '@vidbee/db/task-queue'
import { YtDlpExecutor } from '@vidbee/downloader-core'
const require = createRequire(import.meta.url)
const DEFAULT_DOWNLOAD_DIR_FALLBACK = path.join(os.homedir(), 'Downloads', 'VidBee')
const trimEnv = (name: string): string | undefined => {
const v = process.env[name]?.trim()
return v && v.length > 0 ? v : undefined
}
export const apiDefaultDownloadDir =
trimEnv('VIDBEE_DOWNLOAD_DIR') ?? trimEnv('DOWNLOAD_DIR') ?? DEFAULT_DOWNLOAD_DIR_FALLBACK
const parsedMaxConcurrent = Number(trimEnv('VIDBEE_MAX_CONCURRENT') ?? '')
export const apiMaxConcurrent =
Number.isFinite(parsedMaxConcurrent) && parsedMaxConcurrent > 0 ? parsedMaxConcurrent : 4
const persistEnabled = trimEnv('VIDBEE_PERSIST_QUEUE') === '1'
const taskQueueDbPath =
trimEnv('VIDBEE_TASK_QUEUE_DB') ?? path.join(apiDefaultDownloadDir, '.vidbee', 'task-queue.db')
fs.mkdirSync(apiDefaultDownloadDir, { recursive: true })
let cachedYtDlpPath: string | null = null
const resolveYtDlpPath = (): string => {
if (cachedYtDlpPath && fs.existsSync(cachedYtDlpPath)) return cachedYtDlpPath
const envPath = trimEnv('YTDLP_PATH')
if (envPath && fs.existsSync(envPath)) {
cachedYtDlpPath = envPath
return envPath
}
// Fall back to PATH lookup via execSync `which yt-dlp` / `where yt-dlp`.
try {
const out = require('node:child_process')
.execSync(process.platform === 'win32' ? 'where yt-dlp' : 'which yt-dlp', {
stdio: ['ignore', 'pipe', 'ignore']
})
.toString()
.split(/\r?\n/)
.map((s: string) => s.trim())
.find((s: string) => s.length > 0)
if (out && fs.existsSync(out)) {
cachedYtDlpPath = out
return out
}
} catch {
/* noop */
}
throw new Error('yt-dlp binary not found. Set YTDLP_PATH or install yt-dlp in PATH.')
}
let cachedFfmpegLocation: string | null | undefined
const resolveFfmpegLocation = (): string | undefined => {
if (cachedFfmpegLocation !== undefined) return cachedFfmpegLocation ?? undefined
const envPath = trimEnv('FFMPEG_PATH')
if (envPath) {
try {
const stats = fs.statSync(envPath)
if (stats.isDirectory()) {
cachedFfmpegLocation = envPath
return envPath
}
const dir = path.dirname(envPath)
cachedFfmpegLocation = dir
return dir
} catch {
/* fall through */
}
}
for (const candidate of ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin']) {
if (fs.existsSync(path.join(candidate, 'ffmpeg'))) {
cachedFfmpegLocation = candidate
return candidate
}
}
cachedFfmpegLocation = null
return undefined
}
const executor = new YtDlpExecutor({
resolveYtDlpPath,
resolveFfmpegLocation,
defaultDownloadDir: apiDefaultDownloadDir
})
const buildPersistAdapter = () => {
if (!persistEnabled) return new MemoryPersistAdapter()
fs.mkdirSync(path.dirname(taskQueueDbPath), { recursive: true })
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3') as typeof import('better-sqlite3')
const db = new Database(taskQueueDbPath, { timeout: 5000 }) as unknown as {
exec: (sql: string) => void
prepare: (sql: string) => unknown
pragma: (sql: string, opts?: { simple?: boolean }) => unknown
transaction: (fn: (...args: unknown[]) => unknown) => unknown
close: () => void
}
db.exec(TASK_QUEUE_DDL_V1)
return new SqlitePersistAdapter({
// SqlitePersistAdapter accepts a structurally-typed db; better-sqlite3
// matches the shape.
db: db as unknown as ConstructorParameters<typeof SqlitePersistAdapter>[0]['db']
})
}
export const taskQueue = new TaskQueueAPI({
persist: buildPersistAdapter(),
executor,
maxConcurrency: apiMaxConcurrent
})
export const taskQueueExecutor = executor
let started = false
export const startTaskQueue = async (): Promise<void> => {
if (started) return
await taskQueue.start()
started = true
}
export const stopTaskQueue = async (): Promise<void> => {
if (!started) return
await taskQueue.stop()
started = false
}
export const isTaskQueuePersistent = persistEnabled
export const taskQueueDbFile = taskQueueDbPath
+73
View File
@@ -0,0 +1,73 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { WebAppSettingsSchema } from '@vidbee/downloader-core'
const STORAGE_DIR = path.resolve(process.cwd(), '.data')
const STORAGE_FILE = path.join(STORAGE_DIR, 'web-settings.json')
const defaultWebSettings = WebAppSettingsSchema.parse({
downloadPath: '',
maxConcurrentDownloads: 5,
browserForCookies: 'none',
cookiesPath: '',
proxy: '',
configPath: '',
betaProgram: false,
language: 'en',
theme: 'system',
oneClickDownload: false,
oneClickDownloadType: 'video',
oneClickQuality: 'best',
oneClickContainer: 'auto',
closeToTray: true,
autoUpdate: true,
subscriptionOnlyLatestDefault: true,
enableAnalytics: true,
embedSubs: true,
embedThumbnail: false,
embedMetadata: true,
embedChapters: true,
shareWatermark: false
})
type WebAppSettings = typeof defaultWebSettings
class WebSettingsStore {
private settings = defaultWebSettings
private initialized = false
private async ensureInitialized(): Promise<void> {
if (this.initialized) {
return
}
this.initialized = true
try {
const raw = await readFile(STORAGE_FILE, 'utf-8')
const parsed = JSON.parse(raw)
const result = WebAppSettingsSchema.safeParse(parsed)
if (result.success) {
this.settings = result.data
}
} catch {
this.settings = defaultWebSettings
}
}
async get(): Promise<WebAppSettings> {
await this.ensureInitialized()
return this.settings
}
async set(nextSettings: WebAppSettings): Promise<WebAppSettings> {
await this.ensureInitialized()
const validated = WebAppSettingsSchema.parse(nextSettings)
await mkdir(STORAGE_DIR, { recursive: true })
await writeFile(STORAGE_FILE, JSON.stringify(validated), 'utf-8')
this.settings = validated
return this.settings
}
}
export const webSettingsStore = new WebSettingsStore()
+237
View File
@@ -0,0 +1,237 @@
/**
* Stateless yt-dlp metadata client used by `videoInfo` and `playlist.info`.
* Replaces the equivalent calls on `DownloaderCore`, which the API layer
* no longer instantiates after NEX-131.
*/
import { execSync, spawn } from 'node:child_process'
import fs from 'node:fs'
import {
buildPlaylistInfoArgs,
buildVideoInfoArgs
} from '@vidbee/downloader-core'
import type {
DownloadRuntimeSettings,
PlaylistInfo,
VideoFormat,
VideoInfo
} from '@vidbee/downloader-core'
interface RawVideoInfo {
id?: string
title?: string
thumbnail?: string | null
duration?: number | null
extractor_key?: string | null
webpage_url?: string | null
description?: string | null
view_count?: number | null
uploader?: string | null
tags?: unknown
formats?: Array<{
format_id?: string | null
ext?: string | null
width?: number | null
height?: number | null
fps?: number | null
vcodec?: string | null
acodec?: string | null
filesize?: number | null
filesize_approx?: number | null
format_note?: string | null
tbr?: number | null
quality?: number | null
protocol?: string | null
language?: string | null
video_ext?: string | null
audio_ext?: string | null
}>
}
interface RawPlaylistEntry {
id?: string | null
title?: string | null
url?: string | null
webpage_url?: string | null
original_url?: string | null
ie_key?: string | null
thumbnail?: string | null
}
interface RawPlaylistInfo {
id?: string | null
title?: string | null
entries?: RawPlaylistEntry[]
}
const trim = (v?: string | null): string => v?.trim() ?? ''
const optString = (v: unknown): string | undefined => {
if (typeof v !== 'string') return undefined
const t = v.trim()
return t.length ? t : undefined
}
const optNumber = (v: unknown): number | undefined =>
typeof v === 'number' && !Number.isNaN(v) ? v : undefined
const optStringArray = (v: unknown): string[] | undefined => {
if (!Array.isArray(v)) return undefined
const list = v
.filter((e): e is string => typeof e === 'string')
.map((e) => e.trim())
.filter((e) => e.length > 0)
return list.length ? list : undefined
}
const isHttpUrl = (v?: string | null): boolean => {
if (!v) return false
try {
const u = new URL(v)
return u.protocol === 'http:' || u.protocol === 'https:'
} catch {
return false
}
}
const resolveEntryUrl = (entry: RawPlaylistEntry): string | undefined => {
if (isHttpUrl(entry.url)) return optString(entry.url)
if (isHttpUrl(entry.webpage_url)) return optString(entry.webpage_url)
if (isHttpUrl(entry.original_url)) return optString(entry.original_url)
if (entry.url) {
const id = entry.url.trim()
const ie = entry.ie_key?.toLowerCase() ?? ''
if (ie.includes('youtube')) return `https://www.youtube.com/watch?v=${id}`
if (ie.includes('youtubemusic')) return `https://music.youtube.com/watch?v=${id}`
}
return undefined
}
let cachedYtDlpPath: string | null = null
const resolveYtDlpPath = (): string => {
if (cachedYtDlpPath && fs.existsSync(cachedYtDlpPath)) return cachedYtDlpPath
const env = trim(process.env.YTDLP_PATH)
if (env && fs.existsSync(env)) {
cachedYtDlpPath = env
return env
}
try {
const out = execSync(process.platform === 'win32' ? 'where yt-dlp' : 'which yt-dlp', {
stdio: ['ignore', 'pipe', 'ignore']
})
.toString()
.split(/\r?\n/)
.map((s) => s.trim())
.find((s) => s.length > 0)
if (out && fs.existsSync(out)) {
cachedYtDlpPath = out
return out
}
} catch {
/* noop */
}
throw new Error('yt-dlp binary not found. Set YTDLP_PATH or install yt-dlp in PATH.')
}
const runYtDlp = (args: string[]): Promise<string> =>
new Promise((resolve, reject) => {
const ytDlp = resolveYtDlpPath()
const child = spawn(ytDlp, args, { stdio: ['ignore', 'pipe', 'pipe'] })
let stdout = ''
let stderr = ''
child.stdout.on('data', (c) => {
stdout += c.toString()
})
child.stderr.on('data', (c) => {
stderr += c.toString()
})
child.once('error', reject)
child.once('close', (code) => {
if (code === 0 && stdout.trim()) resolve(stdout)
else reject(new Error(stderr.trim() || `yt-dlp exited with code ${code ?? -1}`))
})
})
const parseVideoInfoPayload = (stdout: string): RawVideoInfo => {
try {
return JSON.parse(stdout) as RawVideoInfo
} catch (err) {
const firstLine = stdout
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.startsWith('{') || line.startsWith('['))
if (!firstLine) throw err
return JSON.parse(firstLine) as RawVideoInfo
}
}
export async function fetchVideoInfo(
url: string,
settings: DownloadRuntimeSettings = {}
): Promise<VideoInfo> {
const target = url.trim()
if (!target) throw new Error('URL is required.')
const args = buildVideoInfoArgs(target, settings)
const stdout = await runYtDlp(args)
const raw = parseVideoInfoPayload(stdout)
const formats: VideoFormat[] = (raw.formats ?? []).map((f) => ({
formatId: f.format_id ?? 'unknown',
ext: f.ext ?? 'unknown',
width: optNumber(f.width),
height: optNumber(f.height),
fps: optNumber(f.fps),
vcodec: optString(f.vcodec),
acodec: optString(f.acodec),
filesize: optNumber(f.filesize),
filesizeApprox: optNumber(f.filesize_approx),
formatNote: optString(f.format_note),
tbr: optNumber(f.tbr),
quality: optNumber(f.quality),
protocol: optString(f.protocol),
language: optString(f.language),
videoExt: optString(f.video_ext),
audioExt: optString(f.audio_ext)
}))
return {
id: raw.id ?? target,
title: raw.title ?? target,
thumbnail: optString(raw.thumbnail),
duration: optNumber(raw.duration),
extractorKey: optString(raw.extractor_key),
webpageUrl: optString(raw.webpage_url),
description: optString(raw.description),
viewCount: optNumber(raw.view_count),
uploader: optString(raw.uploader),
tags: optStringArray(raw.tags),
formats
}
}
export async function fetchPlaylistInfo(
url: string,
settings: DownloadRuntimeSettings = {}
): Promise<PlaylistInfo> {
const target = url.trim()
if (!target) throw new Error('URL is required.')
const args = buildPlaylistInfoArgs(target, settings)
const stdout = await runYtDlp(args)
const raw = JSON.parse(stdout) as RawPlaylistInfo
const rawEntries = Array.isArray(raw.entries) ? raw.entries : []
const entries = rawEntries
.map((entry, index) => {
const resolvedUrl = resolveEntryUrl(entry)
if (!resolvedUrl) return null
return {
id: optString(entry.id) ?? `${index + 1}`,
title: optString(entry.title) ?? `Entry ${index + 1}`,
url: resolvedUrl,
index: index + 1,
thumbnail: optString(entry.thumbnail)
}
})
.filter((e): e is NonNullable<typeof e> => Boolean(e))
return {
id: optString(raw.id) ?? target,
title: optString(raw.title) ?? 'Playlist',
entries,
entryCount: entries.length
}
}
+348
View File
@@ -0,0 +1,348 @@
import { lookup } from 'node:dns/promises'
import type { ServerResponse } from 'node:http'
import net from 'node:net'
import cors from '@fastify/cors'
import { OpenAPIHandler } from '@orpc/openapi/fastify'
import { OpenAPIReferencePlugin } from '@orpc/openapi/plugins'
import { RPCHandler } from '@orpc/server/fastify'
import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4'
import Fastify from 'fastify'
import { startTaskQueue, stopTaskQueue, taskQueue } from './lib/downloader'
import { projectTaskForApi } from './lib/projection'
import { rpcRouter } from './lib/rpc-router'
import { SseHub } from './lib/sse'
import { startApiSubscriptions, stopApiSubscriptions } from './lib/subscriptions-host'
import { subscriptionsRouter } from './lib/subscriptions-router'
const MAX_PROXY_IMAGE_BYTES = 10 * 1024 * 1024
const MAX_PROXY_REDIRECTS = 5
const isPrivateIpv4 = (ip: string): boolean => {
const octets = ip.split('.').map((value) => Number.parseInt(value, 10))
if (octets.length !== 4 || octets.some((value) => Number.isNaN(value))) {
return false
}
const [a, b] = octets
if (a === 10) {
return true
}
if (a === 127) {
return true
}
if (a === 169 && b === 254) {
return true
}
if (a === 172 && b >= 16 && b <= 31) {
return true
}
if (a === 192 && b === 168) {
return true
}
return false
}
const isPrivateIpv6 = (ip: string): boolean => {
const normalized = ip.toLowerCase()
if (normalized === '::1') {
return true
}
if (normalized.startsWith('fc') || normalized.startsWith('fd')) {
return true
}
if (normalized.startsWith('fe80:')) {
return true
}
return false
}
const isBlockedHost = async (url: URL): Promise<boolean> => {
const hostname = url.hostname.trim().toLowerCase()
if (!hostname) {
return true
}
if (hostname === 'localhost' || hostname.endsWith('.localhost') || hostname === '0.0.0.0') {
return true
}
if (net.isIP(hostname) === 4) {
return isPrivateIpv4(hostname)
}
if (net.isIP(hostname) === 6) {
return isPrivateIpv6(hostname)
}
try {
const records = await lookup(hostname, { all: true, verbatim: true })
if (records.length === 0) {
return true
}
for (const record of records) {
if (record.family === 4 && isPrivateIpv4(record.address)) {
return true
}
if (record.family === 6 && isPrivateIpv6(record.address)) {
return true
}
}
return false
} catch {
return true
}
}
const parseRemoteImageUrl = (value: string): URL | null => {
try {
const parsed = new URL(value)
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null
}
return parsed
} catch {
return null
}
}
export const createApiServer = async () => {
await startTaskQueue()
await startApiSubscriptions()
const isDev = process.env.NODE_ENV !== 'production'
const fastify = Fastify({
logger: true,
disableRequestLogging: isDev
})
await fastify.register(cors, {
origin: true,
methods: ['GET', 'POST', 'OPTIONS']
})
const rpcHandler = new RPCHandler(rpcRouter)
const subscriptionsRpcHandler = new RPCHandler(subscriptionsRouter)
const openApiHandler = new OpenAPIHandler(rpcRouter, {
plugins: [
new OpenAPIReferencePlugin({
schemaConverters: [new ZodToJsonSchemaConverter()],
docsProvider: 'swagger',
docsPath: '/docs',
specPath: '/openapi.json',
docsTitle: 'VidBee API Reference',
specGenerateOptions: {
info: {
title: 'VidBee API',
version: '1.0.0'
},
servers: [{ url: '/openapi' }]
}
})
]
})
const sseHub = new SseHub()
// Bridge TaskQueue events → /events SSE. The web client speaks the legacy
// task-updated / queue-updated payload shape; we project from internal
// Task → DownloadTask using the shared projection so both Desktop IPC and
// API SSE present the same fields for the same task.
const NON_TERMINAL = new Set(['queued', 'running', 'processing', 'paused', 'retry-scheduled'])
const publishQueueUpdated = (): void => {
const downloads = taskQueue
.list({ limit: 200 })
.tasks.filter((t) => NON_TERMINAL.has(t.status))
.sort((a, b) => b.createdAt - a.createdAt)
.map(projectTaskForApi)
sseHub.publish('queue-updated', { downloads })
}
taskQueue.on('snapshot-changed', (e) => {
sseHub.publish('task-updated', { task: projectTaskForApi(e.task) })
publishQueueUpdated()
})
taskQueue.on('progress', (e) => {
const t = taskQueue.get(e.taskId)
if (t) sseHub.publish('task-updated', { task: projectTaskForApi(t) })
})
taskQueue.on('transition', (e) => {
if (e.to === 'queued' || e.to === 'cancelled' || e.to === 'completed' || e.to === 'failed') {
publishQueueUpdated()
}
})
fastify.get('/health', async () => {
return { ok: true }
})
fastify.get<{ Querystring: { url?: string } }>('/images/proxy', async (request, reply) => {
const sourceUrl = request.query.url?.trim()
if (!sourceUrl) {
return reply.code(400).send({ message: 'Missing url query parameter.' })
}
const parsedUrl = parseRemoteImageUrl(sourceUrl)
if (!parsedUrl) {
return reply.code(400).send({ message: 'Invalid remote image URL.' })
}
let response: Response | null = null
let currentUrl = parsedUrl
for (let redirectCount = 0; redirectCount <= MAX_PROXY_REDIRECTS; redirectCount++) {
if (await isBlockedHost(currentUrl)) {
return reply.code(400).send({ message: 'Remote host is not allowed.' })
}
try {
response = await fetch(currentUrl.toString(), {
signal: AbortSignal.timeout(15_000),
redirect: 'manual'
})
} catch {
return reply.code(502).send({ message: 'Failed to fetch remote image.' })
}
const locationHeader = response.headers.get('location')
const isRedirect =
response.status >= 300 &&
response.status < 400 &&
typeof locationHeader === 'string' &&
locationHeader.length > 0
if (!isRedirect) {
break
}
currentUrl = new URL(locationHeader, currentUrl)
response.body?.cancel()
}
if (!response) {
return reply.code(502).send({ message: 'Failed to fetch remote image.' })
}
if (!response.ok) {
return reply.code(502).send({
message: `Remote image request failed with status ${response.status}.`
})
}
const contentType = response.headers.get('content-type')?.toLowerCase() ?? ''
if (!contentType.startsWith('image/')) {
return reply.code(415).send({ message: 'Remote resource is not an image.' })
}
const contentLengthHeader = response.headers.get('content-length')
if (contentLengthHeader) {
const declaredSize = Number.parseInt(contentLengthHeader, 10)
if (Number.isFinite(declaredSize) && declaredSize > MAX_PROXY_IMAGE_BYTES) {
return reply.code(413).send({ message: 'Remote image is too large.' })
}
}
if (!response.body) {
return reply.code(502).send({ message: 'Remote image response body is empty.' })
}
const reader = response.body.getReader()
const chunks: Buffer[] = []
let totalBytes = 0
while (true) {
const { done, value } = await reader.read()
if (done) {
break
}
if (!value) {
continue
}
totalBytes += value.byteLength
if (totalBytes > MAX_PROXY_IMAGE_BYTES) {
await reader.cancel()
return reply.code(413).send({ message: 'Remote image is too large.' })
}
chunks.push(Buffer.from(value))
}
const imageBuffer = Buffer.concat(chunks, totalBytes)
const cacheControl = response.headers.get('cache-control')
const etag = response.headers.get('etag')
const lastModified = response.headers.get('last-modified')
reply.header('Content-Type', contentType)
reply.header('Content-Length', imageBuffer.length.toString())
reply.header('Cache-Control', cacheControl ?? 'public, max-age=3600')
if (etag) {
reply.header('ETag', etag)
}
if (lastModified) {
reply.header('Last-Modified', lastModified)
}
return reply.send(imageBuffer)
})
fastify.get('/events', async (request, reply) => {
const requestOrigin = request.headers.origin?.trim()
const responseHeaders: Record<string, string> = {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
'Access-Control-Allow-Origin': requestOrigin || '*'
}
if (requestOrigin) {
responseHeaders.Vary = 'Origin'
}
reply.hijack()
reply.raw.writeHead(200, responseHeaders)
const response = reply.raw as ServerResponse
sseHub.addClient(response)
request.raw.on('close', () => {
sseHub.removeClient(response)
})
})
// Subscriptions live behind their own oRPC handler so the contract surface
// mirrors `subscriptionContract` 1:1 (NEX-132). The match runs before the
// generic `/rpc/*` handler because Fastify applies the most-specific route
// wins rule for `/rpc/subscriptions/*`.
fastify.all('/rpc/subscriptions/*', async (request, reply) => {
await subscriptionsRpcHandler.handle(request, reply, {
prefix: '/rpc/subscriptions'
})
})
fastify.all('/rpc/*', async (request, reply) => {
await rpcHandler.handle(request, reply, {
prefix: '/rpc'
})
})
fastify.all('/docs', async (request, reply) => {
await openApiHandler.handle(request, reply, {
prefix: '/'
})
})
fastify.all('/openapi.json', async (request, reply) => {
await openApiHandler.handle(request, reply, {
prefix: '/'
})
})
fastify.addHook('onClose', async () => {
sseHub.closeAll()
await stopApiSubscriptions()
await stopTaskQueue()
})
return fastify
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
},
"include": ["src/**/*"]
}
+29
View File
@@ -0,0 +1,29 @@
# Changelog
All notable changes to `@vidbee/cli` are documented in this file.
The CLI follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html), with versioning **decoupled from VidBee Desktop**. CLI bug-fixes and new yt-dlp probe-flag support ship on the CLI's own cadence; Desktop continues on `1.x`.
## [Unreleased]
## [0.1.0] - 2026-05-02
First standalone release of `@vidbee/cli`. Tracks NEX-133 (CLI full-stack) and NEX-148 (standalone-only distribution).
### Added
- argv parser that splits `--vidbee-*` flags + `:` subcommands from yt-dlp passthrough; unknown `--vidbee-*` tokens fail with exit 2 instead of being silently forwarded.
- Probe vs download mode detection covering every public yt-dlp probe-class flag and alias.
- `:status`, `:download list/status/logs/cancel/pause/resume/retry`, `:history list/remove` against the shared `taskQueueContract`.
- `:version` — prints CLI + contract version + changelog URL with no host contact.
- `:upgrade` — checks `https://registry.npmjs.org/@vidbee/cli/latest`, caches the result for 30 days, and prints install commands for npm / pnpm / bun / brew. Does not spawn `npm install` automatically.
- Three transports: Desktop loopback (with descriptor handshake + 1h token + autostart), `--vidbee-api <url>` (HTTPS required outside loopback / RFC1918), and `--vidbee-local` (in-process TaskQueueAPI + YtDlpExecutor, with optional SQLite for crash-recovery).
- `dist/` bundle distributed via npm (`pnpm publish`) under `@vidbee/cli`. Tarball contains only `dist/`, README, CHANGELOG, LICENSE.
- Sensitive-argument redaction (`--password`, `--video-password`, `--ap-password`, `--twofactor`, `Authorization:` headers, URL `token=` / `access_token=` / `signature=` / `policy=` query strings).
### Distribution
- Standalone npm publishing via [`cli-publish.yml`](../../.github/workflows/cli-publish.yml), triggered by `cli-v*` tags.
- Shell installer at [`scripts/cli-install.sh`](../../scripts/cli-install.sh) — installs the npm tarball into `~/.vidbee/cli` and writes a `vidbee` shim into `~/.local/bin`.
- Homebrew tap (`vidbee/homebrew-tap`) tracked as a follow-up; the formula will wrap the same npm tarball.
### Removed
- Bundling with VidBee Desktop. The Desktop installer no longer ships a CLI shim or rewrites your `PATH`. Install the CLI explicitly via npm / brew / shell installer. (NEX-148)
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 VidBee
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+201
View File
@@ -0,0 +1,201 @@
# `@vidbee/cli`
Standalone command-line interface to [VidBee](https://github.com/nexmoe/vidbee), built on the shared `@vidbee/task-queue` kernel. CLI invocations land in the same queue that the Desktop UI and Web/API host use; nothing bypasses the application.
The CLI is **distributed independently** of VidBee Desktop: you do not need to install or run the desktop app to use it. Common targets are CI runners, Docker images, agents, and headless Linux servers — places where shipping a 100 MB Electron bundle to invoke `yt-dlp` would be wrong.
> Reference designs: `docs/vidbee-desktop-first-cli-ytdlp-rss-design.md` (CLI surface), `docs/vidbee-task-queue-state-machine-design.md` (kernel).
---
## Install
Pick one. All three install the same `@vidbee/cli` Node package — there is no native binary today; we rely on Node ≥ 20 being available.
### npm / pnpm / bun
```sh
# npm (most common)
npm install -g @vidbee/cli
# pnpm
pnpm add -g @vidbee/cli
# bun
bun install -g @vidbee/cli
```
`npx @vidbee/cli ...` works without a global install.
### Homebrew (macOS / Linux)
```sh
brew install vidbee/tap/vidbee
```
> The tap (`vidbee/homebrew-tap`) is a separate repository tracked under NEX-148 follow-ups. The formula wraps the same npm tarball.
### Shell installer
```sh
curl -fsSL https://vidbee.dev/cli/install.sh | sh
```
The installer downloads the npm tarball into `~/.vidbee/cli` and writes a `vidbee` shim into `~/.local/bin`, picking up your shell from `$SHELL`. Source: [`scripts/cli-install.sh`](https://github.com/nexmoe/vidbee/blob/main/scripts/cli-install.sh).
### Windows
Use the npm channel (`npm install -g @vidbee/cli`) for now. The shell installer requires bash + tar; on Windows we recommend WSL or an `npm i -g` install from PowerShell. If your PowerShell execution policy blocks scripts, run `Set-ExecutionPolicy -Scope Process Bypass` once for the current shell.
### Need crash-recovery for `--vidbee-local`?
The in-process transport persists task state in SQLite when `better-sqlite3` is available. It is declared as an `optionalDependency`; install it explicitly if you want crash-recovery:
```sh
npm install -g @vidbee/cli better-sqlite3
```
---
## Three transports
The CLI talks to a `taskQueueContract` host. There are three transports, all wire-compatible:
- **Desktop loopback** (default when a Desktop install is detected): reads the per-user automation descriptor (`~/Library/Application Support/VidBee/automation.json` on macOS, `${XDG_CONFIG_HOME:-~/.config}/VidBee/automation.json` on Linux, `%APPDATA%\VidBee\automation.json` on Windows), handshakes for a 1h bearer token, and POSTs JSON to `http://127.0.0.1:<port>/automation/v1/*`.
- **Remote API** (`--vidbee-api <url>`): same wire format against any VidBee Web/API host. Plain HTTP is rejected unless the host is loopback or RFC1918 private. HTTPS is required everywhere else.
- **In-process** (`--vidbee-local`): instantiates `TaskQueueAPI` + `YtDlpExecutor` directly. No Desktop, no API server, no descriptor. Used for CI, Docker images, and the three-host equivalence test.
If the Desktop descriptor is missing or its PID is stale, the CLI tries to launch Desktop in background mode. Pass `--vidbee-no-autostart` to opt out and exit `3` instead. On a host with no Desktop installed, use `--vidbee-api` or `--vidbee-local`.
---
## Argv contract
A single rule splits argv:
- Tokens starting with `--vidbee-` are reserved (full list below). Unknown `--vidbee-*` tokens are treated as typos and rejected with exit code `2` — they are **not** silently passed through to yt-dlp.
- Tokens starting with `:` are VidBee subcommands (`:status`, `:download list`, `:version`, …).
- Everything else is order-preserved and forwarded to yt-dlp.
### `--vidbee-*` flags
| flag | meaning |
| --- | --- |
| `--vidbee-api <url>` | connect to a remote VidBee API instead of the local Desktop |
| `--vidbee-local` | run TaskQueueAPI in-process (CI / Docker) |
| `--vidbee-target <desktop\|api\|local>` | explicit transport selection |
| `--vidbee-pretty` | indent JSON output |
| `--vidbee-wait` | block until terminal status |
| `--vidbee-detach` | return immediately after enqueue (default) |
| `--vidbee-priority <user\|subscription\|background>` | set task priority (default: user) |
| `--vidbee-max-attempts <n>` | override outer retry cap (`0` disables) |
| `--vidbee-no-retry` | shortcut for `--vidbee-max-attempts 0` |
| `--vidbee-group-key <key>` | override per-group concurrency key |
| `--vidbee-timeout <ms>` | autostart wait budget (default `10000`) |
| `--vidbee-no-autostart` | do not launch Desktop in background mode |
| `--vidbee-token <token>` | skip handshake (for CI / smoke tests) |
### Probe vs download
The CLI inspects argv exactly once: if any of `-j / --dump-json / -J / --dump-single-json / -F / --list-formats / --list-formats-as-table / --list-formats-old / --print / --get-* / -s / --simulate / --skip-download / --list-subs / --list-extractors / --list-extractor-descriptions / --update / --version` is present, the run is a **probe**: yt-dlp is spawned directly, output is captured into the §4.4 envelope, and no task is enqueued. Otherwise the run is a **download** and goes through `taskQueueContract.add({ kind: 'yt-dlp-forward', … })`.
`-o -` (write yt-dlp output to stdout) is allowed in probe mode only.
---
## Subcommands
```sh
vidbee :status
vidbee :version
vidbee :upgrade [--force] [--cache <path>]
vidbee :download list [--status queued|running|...] [--limit N] [--cursor C]
vidbee :download status <id>
vidbee :download logs <id>
vidbee :download cancel <id>
vidbee :download pause <id> [--reason text]
vidbee :download resume <id>
vidbee :download retry <id>
vidbee :history list
vidbee :history remove <id...>
```
`:rss …` is owned by NEX-132 and shares the same dispatch.
### `:version`
Prints the installed CLI version + contract version + a link to the changelog. Does not contact any host — safe to run on a machine with no Desktop and no network.
```json
{
"ok": true,
"mode": "subcommand",
"subcommand": "version",
"result": {
"cli": "0.1.0",
"contract": "0.1.0",
"changelog": "https://github.com/nexmoe/vidbee/blob/main/apps/cli/CHANGELOG.md"
}
}
```
### `:upgrade`
Checks `https://registry.npmjs.org/@vidbee/cli/latest` and prints whether a newer version exists, along with the install command for each supported package manager. Does **not** auto-spawn `npm install`: global installs touch sudo / system PATH and are best left to the user.
The result is cached for 30 days under `~/Library/Caches/VidBee/cli-upgrade-check.json` (macOS), `${XDG_CACHE_HOME:-~/.cache}/vidbee/cli-upgrade-check.json` (Linux), or `%LOCALAPPDATA%\VidBee\cli-upgrade-check.json` (Windows). Pass `--force` to bypass the cache, or `--cache <path>` to redirect it.
```json
{
"ok": true,
"mode": "subcommand",
"subcommand": "upgrade",
"result": {
"current": "0.1.0",
"latest": "0.2.0",
"upToDate": false,
"cached": false,
"cachedAt": "2026-05-02T00:00:00.000Z",
"registryUrl": "https://registry.npmjs.org/@vidbee/cli/latest",
"installCommands": {
"npm": "npm install -g @vidbee/cli",
"pnpm": "pnpm add -g @vidbee/cli",
"bun": "bun install -g @vidbee/cli",
"brew": "brew upgrade vidbee/tap/vidbee"
}
}
}
```
---
## Output
Every invocation prints exactly one JSON document on stdout. Exit codes follow design §4.4:
| code | meaning |
| --- | --- |
| `0` | success (probe / detached enqueue / wait-mode terminal success) |
| `1` | wait-mode reached non-success (`failed`, `retry-scheduled`, `paused`, timeout) |
| `2` | argv parse error |
| `3` | Desktop / API unreachable |
| `4` | authentication failure |
| `5` | contract / version mismatch |
Sensitive arguments (`--password`, `--video-password`, `--ap-password`, `--twofactor`, `--add-headers Authorization:…`, URL query strings with `token=` / `access_token=` / `signature=` / `policy=`) are redacted to `<redacted>` before any envelope, persisted task row, or projection sees them.
---
## Versioning & releasing
CLI semver is **independent of VidBee Desktop**. The CLI starts at `0.1.0`; Desktop continues on `1.x`. We bump the CLI on its own cadence so a `yt-dlp` probe-flag fix doesn't have to wait for a Desktop release.
To cut a release:
1. Update `apps/cli/package.json` `version` and add a `CHANGELOG.md` entry.
2. Push a `cli-vX.Y.Z` tag. The [`cli-publish.yml`](https://github.com/nexmoe/vidbee/blob/main/.github/workflows/cli-publish.yml) workflow runs the build, runs the tests, and publishes to npm with `pnpm publish` using the `NPM_TOKEN` secret.
3. Verify on a clean machine: `npx @vidbee/cli@latest :version` should report the new version.
`pnpm --filter @vidbee/cli build` produces `dist/index.mjs` (single bundled ESM file) and `dist/bin/vidbee.mjs` (the npm `bin`). The published tarball includes only `dist/`, the README, the CHANGELOG, and the LICENSE — no source, no tests.
CI runs `pnpm --filter @vidbee/cli test` on every PR; the suite ships with 149+ unit tests covering parser, probe, envelope, transports, autostart, descriptor handshake, and the new local-info commands.
@@ -0,0 +1,157 @@
/**
* Three-host equivalence: CLI `--vidbee-local` slot.
*
* The kernel-level test in `packages/task-queue/__integration__/three-host-equivalence.test.ts`
* stands in for the CLI by spinning up a third TaskQueueAPI in-process.
* This file proves that `createLocalClient` (the production code path the
* CLI uses for `--vidbee-local`) wraps that same kernel without changing
* the resulting Task / projection state.
*
* Together they fulfil the design doc's three-host-equivalence acceptance
* for the CLI side (§10).
*/
import { describe, expect, it } from 'vitest'
import { TaskQueueAPI } from '@vidbee/task-queue'
import type {
Executor,
ExecutorEvents,
ExecutorRun,
Task
} from '@vidbee/task-queue'
import { MemoryPersistAdapter, projectTaskToLegacy } from '@vidbee/task-queue'
import { createLocalClient } from '../src/transport/local-client'
interface ScriptedExecutor extends Executor {}
const SUCCESS_SCRIPT = (filePath: string) => (events: ExecutorEvents, ctx: { taskId: string; attemptId: string }) => {
events.onSpawn({
taskId: ctx.taskId,
attemptId: ctx.attemptId,
pid: 4242,
pidStartedAt: 1000,
kind: 'yt-dlp',
spawnedAt: 1
})
events.onProgress({
taskId: ctx.taskId,
attemptId: ctx.attemptId,
progress: {
percent: 0.5,
bytesDownloaded: 50,
bytesTotal: 100,
speedBps: 100,
etaMs: 1000,
ticks: 1
},
enteredProcessing: false
})
events.onProgress({
taskId: ctx.taskId,
attemptId: ctx.attemptId,
progress: {
percent: 0.95,
bytesDownloaded: 95,
bytesTotal: 100,
speedBps: 100,
etaMs: 100,
ticks: 2
},
enteredProcessing: true
})
events.onFinish({
taskId: ctx.taskId,
attemptId: ctx.attemptId,
result: {
type: 'success',
output: { filePath, size: 1024, durationMs: null, sha256: null }
},
closedAt: 2,
stdoutTail: '',
stderrTail: ''
})
}
const makeScripted = (filePath: string): ScriptedExecutor => ({
run(ctx, events) {
queueMicrotask(() => SUCCESS_SCRIPT(filePath)(events, ctx))
return { cancel: async () => {}, pause: async () => {} } as ExecutorRun
}
})
const stripVolatile = (task: Readonly<Task>): unknown => {
const c = JSON.parse(JSON.stringify(task)) as Record<string, unknown>
delete c.id
delete c.createdAt
delete c.updatedAt
delete c.enteredStatusAt
delete c.pid
delete c.pidStartedAt
delete c.nextRetryAt
return c
}
const stripVolatileProjection = (task: Readonly<Task>): unknown => {
const p = projectTaskToLegacy(task) as unknown as Record<string, unknown>
delete p.id
delete p.createdAt
delete p.startedAt
delete p.completedAt
delete p.nextRetryAt
return p
}
const wait = async (predicate: () => boolean, timeoutMs = 2_000): Promise<void> => {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (predicate()) return
await new Promise((r) => setTimeout(r, 5))
}
throw new Error('wait: predicate did not hold within timeout')
}
describe('createLocalClient — three-host equivalence (CLI slot)', () => {
it('produces the same Task/projection as a bare TaskQueueAPI', async () => {
const filePath = '/tmp/sample.mp4'
// Reference host: a TaskQueueAPI configured exactly like the kernel's
// own three-host test does it.
const refExec = makeScripted(filePath)
const ref = new TaskQueueAPI({
persist: new MemoryPersistAdapter(),
executor: refExec,
maxConcurrency: 4,
rng: () => 0.5,
filePresent: () => true
})
await ref.start()
// CLI host: production createLocalClient with the same fake executor.
const cli = await createLocalClient({
persist: 'memory',
executor: makeScripted(filePath),
filePresent: () => true,
rng: () => 0.5,
maxConcurrency: 4
})
try {
const input = { url: 'https://example.com/v', kind: 'video' as const, title: 'sample' }
const refAdd = await ref.add({ input })
const cliAdd = await cli.add!({ input })
await wait(() => ref.get(refAdd.id)?.status === 'completed')
await wait(() => cli.api.get(cliAdd.id)?.status === 'completed')
const refTask = ref.get(refAdd.id)!
const cliTask = cli.api.get(cliAdd.id)!
expect(stripVolatile(cliTask)).toStrictEqual(stripVolatile(refTask))
expect(stripVolatileProjection(cliTask)).toStrictEqual(stripVolatileProjection(refTask))
} finally {
await ref.stop()
await cli.shutdown()
}
})
})
Vendored Executable
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env node
import('../index.mjs').catch((e) => {
process.stderr.write(JSON.stringify({ ok: false, code: 'UNKNOWN_ERROR', message: String(e?.message ?? e) }) + '\n')
process.exit(2)
})
+27548
View File
File diff suppressed because it is too large Load Diff
+80
View File
@@ -0,0 +1,80 @@
{
"name": "@vidbee/cli",
"version": "0.1.0",
"description": "VidBee CLI — standalone yt-dlp wrapper that talks to the shared VidBee TaskQueue. Works against a local Desktop, a remote API, or fully in-process for CI / Docker / agents.",
"homepage": "https://github.com/nexmoe/vidbee/tree/main/apps/cli#readme",
"bugs": {
"url": "https://github.com/nexmoe/vidbee/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/nexmoe/vidbee.git",
"directory": "apps/cli"
},
"license": "MIT",
"author": "VidBee Authors",
"type": "module",
"bin": {
"vidbee": "./dist/bin/vidbee.mjs"
},
"main": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts",
"default": "./dist/index.mjs"
},
"./parser": "./src/parser/index.ts",
"./envelope": "./src/envelope/index.ts"
},
"publishConfig": {
"exports": {
".": "./dist/index.mjs"
},
"main": "./dist/index.mjs",
"access": "public"
},
"files": [
"dist",
"README.md",
"CHANGELOG.md",
"LICENSE"
],
"keywords": [
"vidbee",
"yt-dlp",
"video",
"video-downloader",
"downloader",
"cli",
"agent",
"automation"
],
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"build": "node scripts/build.mjs",
"verify-dist": "node -e \"const fs=require('node:fs');for(const f of ['dist/index.mjs','dist/bin/vidbee.mjs']){if(!fs.existsSync(f)){console.error('missing build artifact: '+f);process.exit(1)}}\"",
"prepublishOnly": "pnpm run test && pnpm run build && pnpm run verify-dist"
},
"dependencies": {
"yt-dlp-wrap-plus": "^2.3.20"
},
"optionalDependencies": {
"better-sqlite3": "^12.9.0"
},
"devDependencies": {
"@types/node": "^22.18.6",
"@vidbee/db": "workspace:*",
"@vidbee/downloader-core": "workspace:*",
"@vidbee/task-queue": "workspace:*",
"esbuild": "^0.25.0",
"typescript": "^5.9.2",
"vitest": "^3.2.4"
},
"engines": {
"node": ">=20"
}
}
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env node
/**
* Bundles the CLI as a single ESM file (`dist/index.mjs`) plus the
* npm-bin shim (`dist/bin/vidbee.mjs`).
*
* The published artifact is the standalone npm tarball — Desktop no
* longer bundles the CLI (NEX-148). `--vidbee-local` requires the bundled
* `@vidbee/task-queue` and `@vidbee/downloader-core`; the bundler inlines
* them. `better-sqlite3` is left external so the npm consumer installs it
* as an `optionalDependency` only when crash-recovery is wanted.
*/
import { build } from 'esbuild'
import { mkdirSync, chmodSync, rmSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const root = resolve(__dirname, '..')
const dist = join(root, 'dist')
// Wipe `dist/` on every build so a previous build's artifacts (e.g. the
// pre-NEX-148 `dist/shim/` directory) can never sneak into the published
// tarball.
rmSync(dist, { recursive: true, force: true })
mkdirSync(dist, { recursive: true })
mkdirSync(join(dist, 'bin'), { recursive: true })
await build({
entryPoints: [join(root, 'src/bin.ts')],
bundle: true,
format: 'esm',
platform: 'node',
target: 'node20',
outfile: join(dist, 'index.mjs'),
external: ['better-sqlite3', 'electron', 'yt-dlp-wrap-plus', '@opentelemetry/api'],
legalComments: 'inline',
banner: {
js: '#!/usr/bin/env node\n// @vidbee/cli — standalone build (NEX-148). Built by scripts/build.mjs.'
}
})
// npm bin shim: thin wrapper that runs the bundled index.mjs.
const npmBin = `#!/usr/bin/env node
import('../index.mjs').catch((e) => {
process.stderr.write(JSON.stringify({ ok: false, code: 'UNKNOWN_ERROR', message: String(e?.message ?? e) }) + '\\n')
process.exit(2)
})
`
const npmBinPath = join(dist, 'bin', 'vidbee.mjs')
mkdirSync(dirname(npmBinPath), { recursive: true })
const fs = await import('node:fs/promises')
await fs.writeFile(npmBinPath, npmBin, 'utf-8')
chmodSync(npmBinPath, 0o755)
console.log('built', dist)
+28
View File
@@ -0,0 +1,28 @@
/**
* CLI entrypoint. Wires `process.argv` into the testable `run()` function.
* The shebang for the bundled output is added by `scripts/build.mjs`'s
* esbuild banner; we don't need one in source.
*
* Phase A: argv slice [2:], stdout / stderr passthrough, exit with run()'s
* computed code. The yt-dlp spawn path and the `--vidbee-local`
* production wiring (constructing TaskQueueAPI + YtDlpExecutor) land in
* Phase B once NEX-131 has merged A1 or A2.
*/
import { run } from './runtime'
async function main(): Promise<void> {
const argv = process.argv.slice(2)
const { exitCode } = await run(argv, {
stdout: (line) => process.stdout.write(`${line}\n`),
stderr: (line) => process.stderr.write(`${line}\n`)
})
process.exit(exitCode)
}
main().catch((err) => {
process.stderr.write(
`${JSON.stringify({ ok: false, code: 'UNKNOWN_ERROR', message: err instanceof Error ? err.message : String(err) })}\n`
)
process.exit(2)
})
+191
View File
@@ -0,0 +1,191 @@
/**
* Build a TaskInput for the `yt-dlp-forward` kind from raw argv. Reference:
* docs/vidbee-desktop-first-cli-ytdlp-rss-design.md §6.2 (ForwardedYtDlpTaskInput)
*
* Hosts run `YtDlpExecutor` whose `buildArgsFor` first checks
* `input.rawArgs` and uses it verbatim when present. We pack:
*
* - `rawArgs`: the unredacted argv (held in memory only by host adapter
* for the spawn; do NOT read this back from CLI side).
* - `options.sanitizedArgs`: the redacted argv used in envelopes / logs.
* - `options.commandPreview`: human-readable `yt-dlp <sanitizedArgs>`.
* - `options.outputHints`: best-effort parse of -o / --paths / `-o -`.
* - `options.source: 'cli'`.
*
* URL extraction is best-effort: yt-dlp accepts a URL anywhere in argv;
* we use the last bare positional that looks like a URL so the Task row
* has a meaningful `url`. The executor itself doesn't depend on this
* field when `rawArgs` is set, so a wrong guess is non-fatal.
*/
import type { TaskInput, TaskKind } from '@vidbee/task-queue'
import type { Flags } from '../parser'
import { redactArgs } from '../parser/redact'
export interface BuildForwardedInputOptions {
argv: readonly string[]
flags: Flags
}
export interface BuildForwardedInputResult {
input: TaskInput
commandPreview: string
redacted: boolean
}
const URL_REGEX = /^https?:\/\//i
export function buildForwardedInput(
opts: BuildForwardedInputOptions
): BuildForwardedInputResult {
const { args: sanitizedArgs, summary } = redactArgs(opts.argv)
const url = guessUrl(opts.argv) ?? ''
const outputHints = parseOutputHints(opts.argv)
const commandPreview = formatCommand(sanitizedArgs)
const kind: TaskKind = 'yt-dlp-forward'
const input: TaskInput = {
url,
kind,
rawArgs: [...opts.argv],
options: {
source: 'cli',
sanitizedArgs,
commandPreview,
outputHints,
vidbee: {
wait: opts.flags.wait,
...(opts.flags.maxAttempts !== undefined && {
maxAttempts: opts.flags.maxAttempts
}),
...(opts.flags.priority && { priority: opts.flags.priority }),
...(opts.flags.groupKey !== undefined && { groupKey: opts.flags.groupKey })
}
}
}
return { input, commandPreview, redacted: summary.redacted }
}
function formatCommand(argv: readonly string[]): string {
return ['yt-dlp', ...argv]
.map((tok) => (needsShellQuote(tok) ? quote(tok) : tok))
.join(' ')
}
function needsShellQuote(tok: string): boolean {
return /[\s'"\\$`]/.test(tok)
}
function quote(tok: string): string {
return `'${tok.replace(/'/g, "'\\''")}'`
}
function guessUrl(argv: readonly string[]): string | null {
// yt-dlp lets URLs appear at any position — we walk argv and pick the
// last bare positional that looks like an HTTP(S) URL. Bare = not the
// value of a flag like `--cookies <path>`.
let last: string | null = null
for (let i = 0; i < argv.length; i++) {
const tok = argv[i]
if (!tok || tok.startsWith('-')) continue
const prev = argv[i - 1]
if (prev !== undefined && prev.startsWith('-') && consumesValue(prev)) continue
if (URL_REGEX.test(tok)) last = tok
}
return last
}
/**
* The set of yt-dlp flags whose immediately-following positional is a
* value, not a URL. We don't need to be exhaustive — false negatives only
* mean a URL might be guessed wrong, which is recoverable (executor uses
* rawArgs verbatim regardless).
*/
const VALUE_CONSUMING_FLAGS = new Set<string>([
'-f',
'--format',
'-o',
'--output',
'--cookies',
'--cookies-from-browser',
'--proxy',
'--user-agent',
'--referer',
'--sleep-interval',
'-r',
'--limit-rate',
'--retries',
'--fragment-retries',
'--retry-sleep',
'-N',
'--concurrent-fragments',
'--throttled-rate',
'--add-headers',
'--add-header',
'--config-location',
'--paths',
'-P',
'--ffmpeg-location',
'--postprocessor-args',
'--user-agent',
'--print',
'--username',
'--password',
'--video-password',
'--ap-password',
'--twofactor',
'--audio-format',
'--audio-quality',
'--merge-output-format',
'--remux-video',
'--recode-video',
'--container'
])
function consumesValue(flag: string): boolean {
// Strip `=value` since `--foo=value` is one token and never consumes.
if (flag.includes('=')) return false
return VALUE_CONSUMING_FLAGS.has(flag)
}
function parseOutputHints(argv: readonly string[]): {
outputTemplate?: string
paths?: string[]
stdoutMode?: boolean
} {
let outputTemplate: string | undefined
const paths: string[] = []
let stdoutMode = false
for (let i = 0; i < argv.length; i++) {
const tok = argv[i]
if (tok === undefined) continue
if (tok === '-o' || tok === '--output') {
const v = argv[i + 1]
if (v === '-') stdoutMode = true
else if (v) outputTemplate = v
continue
}
if (tok === '-o-' || tok === '-o=-' || tok === '--output=-') {
stdoutMode = true
continue
}
if (tok.startsWith('-o=')) {
outputTemplate = tok.slice(3)
continue
}
if (tok.startsWith('--output=')) {
outputTemplate = tok.slice('--output='.length)
continue
}
if (tok === '-P' || tok === '--paths') {
const v = argv[i + 1]
if (v) paths.push(v)
}
}
const out: { outputTemplate?: string; paths?: string[]; stdoutMode?: boolean } = {}
if (outputTemplate !== undefined) out.outputTemplate = outputTemplate
if (paths.length > 0) out.paths = paths
if (stdoutMode) out.stdoutMode = true
return out
}
+75
View File
@@ -0,0 +1,75 @@
/**
* Download enqueue + optional wait-for-terminal-state. Reference:
* docs/vidbee-desktop-first-cli-ytdlp-rss-design.md §4.4, §6.1
*
* Behavior:
* - `--vidbee-detach` (default) returns immediately after `add()` with the
* queued task, exit 0.
* - `--vidbee-wait` polls `get(id)` until the task reaches a terminal
* state. Successful → exit 0; failed/retry-scheduled/cancelled → exit 1.
*
* Polling cadence: 200ms while running, 1s while paused/retry-scheduled.
* The contract doesn't yet expose a per-task event subscription that we
* could await directly, so we poll. Phase B+ work could swap this for the
* SSE stream available at `/automation/v1/events`.
*/
import type { Task, AddTaskRequest } from '@vidbee/task-queue'
import { TERMINAL_STATUSES } from '@vidbee/task-queue'
import type { ContractClient } from '../subcommands'
export interface EnqueueOptions {
client: ContractClient
request: AddTaskRequest
wait: boolean
/** Total wait budget; null = unlimited (until terminal). */
waitTimeoutMs?: number | null
/** Test seam. */
clock?: () => number
delay?: (ms: number) => Promise<void>
pollFastMs?: number
pollSlowMs?: number
}
export type EnqueueResult =
| { kind: 'detached'; task: Task }
| { kind: 'wait-success'; task: Task }
| { kind: 'wait-non-success'; task: Task; reason: 'failed' | 'cancelled' | 'retry-scheduled' | 'paused' | 'timeout' }
const defaultDelay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms))
export async function enqueueDownload(opts: EnqueueOptions): Promise<EnqueueResult> {
if (!opts.client.add) {
throw new Error('transport does not support add()')
}
const created = await opts.client.add(opts.request)
let task: Task = created.task
if (!opts.wait) return { kind: 'detached', task }
const clock = opts.clock ?? Date.now
const delay = opts.delay ?? defaultDelay
const fast = opts.pollFastMs ?? 200
const slow = opts.pollSlowMs ?? 1_000
const start = clock()
const budget = opts.waitTimeoutMs ?? null
while (true) {
if (TERMINAL_STATUSES.has(task.status)) {
return task.status === 'completed'
? { kind: 'wait-success', task }
: { kind: 'wait-non-success', task, reason: task.status as 'failed' | 'cancelled' }
}
// §4.4 example: --vidbee-wait surfaces retry-scheduled as exit 1
// immediately rather than waiting for the next attempt to fire.
if (task.status === 'retry-scheduled' || task.status === 'paused') {
return { kind: 'wait-non-success', task, reason: task.status }
}
if (budget !== null && clock() - start >= budget) {
return { kind: 'wait-non-success', task, reason: 'timeout' }
}
const interval = task.status === 'queued' ? slow : fast
await delay(interval)
task = await opts.client.get(task.id)
}
}
+161
View File
@@ -0,0 +1,161 @@
/**
* Probe-mode execution. Reference:
* docs/vidbee-desktop-first-cli-ytdlp-rss-design.md §4.3, §4.5, §8.3
*
* Probes do NOT enter the queue. They spawn yt-dlp directly with the raw
* argv, capture stdout/stderr, and surface the result wrapped in the §4.4
* envelope. The 32MB stdout cap is enforced; stderr is best-effort capped
* so a runaway log can't blow our memory budget either.
*
* The CLI bundles a yt-dlp binary path discovery — Desktop's
* `node_modules/yt-dlp-wrap-plus` ships one, but for the standalone
* `--vidbee-local` / `npx @vidbee/cli` flows we accept `YTDLP_PATH` from
* env or fall back to `yt-dlp` on PATH.
*/
import { spawn } from 'node:child_process'
import { errorEnvelope, type ErrorEnvelope } from '../envelope'
export interface ProbeOptions {
argv: readonly string[]
ytDlpPath?: string
/** Test seam — overrides spawn. */
spawner?: ProbeSpawner
/** Max stdout bytes; design = 32MB. */
stdoutMaxBytes?: number
/** stderr tail (default 64KB; same as attempts.stderr_tail). */
stderrTailBytes?: number
}
export interface ProbeSpawnHandle {
stdout: NodeJS.ReadableStream
stderr: NodeJS.ReadableStream
on(event: 'close', listener: (code: number | null) => void): void
on(event: 'error', listener: (err: Error) => void): void
kill: (signal?: NodeJS.Signals) => void
}
export type ProbeSpawner = (binary: string, args: readonly string[]) => ProbeSpawnHandle
export type ProbeResult =
| { kind: 'success'; stdout: string; stderr: string; exitCode: number; binary: string }
| { kind: 'error'; envelope: ErrorEnvelope }
const DEFAULT_STDOUT_MAX = 32 * 1024 * 1024
const DEFAULT_STDERR_TAIL = 64 * 1024
export async function runProbe(opts: ProbeOptions): Promise<ProbeResult> {
const binary = opts.ytDlpPath ?? resolveYtDlpBinary()
const stdoutMax = opts.stdoutMaxBytes ?? DEFAULT_STDOUT_MAX
const stderrTail = opts.stderrTailBytes ?? DEFAULT_STDERR_TAIL
const spawner = opts.spawner ?? defaultSpawner
let handle: ProbeSpawnHandle
try {
handle = spawner(binary, opts.argv)
} catch (err) {
return {
kind: 'error',
envelope: errorEnvelope(
'NOT_IMPLEMENTED',
`failed to spawn yt-dlp at ${binary}: ${err instanceof Error ? err.message : err}`
)
}
}
let stdoutBytes = 0
let stdoutOverflow = false
const stdoutChunks: Buffer[] = []
const stderrTailBuf = createTailBuffer(stderrTail)
handle.stdout.on('data', (chunk: Buffer) => {
if (stdoutOverflow) return
if (stdoutBytes + chunk.byteLength > stdoutMax) {
stdoutOverflow = true
handle.kill('SIGTERM')
return
}
stdoutBytes += chunk.byteLength
stdoutChunks.push(chunk)
})
handle.stderr.on('data', (chunk: Buffer) => {
stderrTailBuf.push(chunk)
})
return new Promise<ProbeResult>((resolve) => {
let settled = false
handle.on('error', (err) => {
if (settled) return
settled = true
resolve({
kind: 'error',
envelope: errorEnvelope(
'NOT_IMPLEMENTED',
`yt-dlp spawn error: ${err.message}`
)
})
})
handle.on('close', (code) => {
if (settled) return
settled = true
if (stdoutOverflow) {
resolve({
kind: 'error',
envelope: errorEnvelope(
'PROBE_OUTPUT_TOO_LARGE',
`probe stdout exceeded ${stdoutMax} bytes`
)
})
return
}
const stdout = Buffer.concat(stdoutChunks).toString('utf-8')
resolve({
kind: 'success',
stdout,
stderr: stderrTailBuf.read().toString('utf-8'),
exitCode: code ?? -1,
binary
})
})
})
}
function resolveYtDlpBinary(): string {
const env = process.env.YTDLP_PATH?.trim()
return env && env.length > 0 ? env : 'yt-dlp'
}
function defaultSpawner(binary: string, args: readonly string[]): ProbeSpawnHandle {
const child = spawn(binary, [...args], { stdio: ['ignore', 'pipe', 'pipe'] })
return {
stdout: child.stdout,
stderr: child.stderr,
on: (ev, l) => {
child.on(ev as 'close' | 'error', l as never)
},
kill: (signal) => {
try {
child.kill(signal)
} catch {
/* noop */
}
}
}
}
interface TailBuffer {
push: (chunk: Buffer) => void
read: () => Buffer
}
function createTailBuffer(maxBytes: number): TailBuffer {
let buf = Buffer.alloc(0)
return {
push(chunk: Buffer) {
const merged = Buffer.concat([buf, chunk])
buf = merged.byteLength > maxBytes ? merged.subarray(merged.byteLength - maxBytes) : merged
},
read: () => buf
}
}
+136
View File
@@ -0,0 +1,136 @@
/**
* JSON envelope shapes the CLI prints. Reference:
* docs/vidbee-desktop-first-cli-ytdlp-rss-design.md §4.4
*
* Every CLI invocation produces exactly one JSON document on stdout.
* Exit code is determined by ExitCode below; envelope and exit code
* MUST be consistent.
*/
import type { Task } from '@vidbee/task-queue'
export const ExitCode = {
/** probe success | detached download enqueue success | wait-mode success */
OK: 0,
/** wait-mode terminated non-success (failed | retry-scheduled) */
WAIT_NON_SUCCESS: 1,
/** argv parse error (incl. unknown --vidbee-*) */
ARG_ERROR: 2,
/** desktop / api unreachable: descriptor missing, conn refused, handshake failed */
HOST_UNREACHABLE: 3,
/** auth failure */
AUTH_FAILED: 4,
/** internal contract error (schema mismatch, version mismatch) */
CONTRACT_ERROR: 5
} as const
export type ExitCode = (typeof ExitCode)[keyof typeof ExitCode]
export interface ProbeSuccessEnvelope {
ok: true
mode: 'probe'
command: string
ytDlp: { exitCode: number; stdout: string; stderr: string }
}
export interface DownloadDetachedEnvelope {
ok: true
mode: 'download'
task: Pick<Task, 'id' | 'status' | 'attempt' | 'maxAttempts'> & {
command: string
}
}
export interface DownloadWaitSuccessEnvelope {
ok: true
mode: 'download'
task: Task
ytDlp: { exitCode: number; stdoutTail: string; stderrTail: string }
}
export interface DownloadWaitFailureEnvelope {
ok: false
mode: 'download'
task: Task
ytDlp: { exitCode: number; stdoutTail: string; stderrTail: string }
}
export type ErrorCode =
| 'PARSE_ERROR'
| 'UNKNOWN_VIDBEE_FLAG'
| 'INVALID_TARGET'
| 'INVALID_PRIORITY'
| 'INVALID_MAX_ATTEMPTS'
| 'INVALID_TIMEOUT'
| 'MISSING_VALUE'
| 'UNEXPECTED_VALUE'
| 'STDOUT_OUTPUT_DISALLOWED'
| 'PROBE_OUTPUT_TOO_LARGE'
| 'DESKTOP_NOT_READY'
| 'API_UNREACHABLE'
| 'HANDSHAKE_FAILED'
| 'TOKEN_EXPIRED'
| 'AUTH_FAILED'
| 'CONTRACT_VERSION_MISMATCH'
| 'CONTRACT_SCHEMA_MISMATCH'
| 'NOT_IMPLEMENTED'
| 'UNKNOWN_ERROR'
export interface ErrorEnvelope {
ok: false
code: ErrorCode
message: string
details?: Record<string, unknown>
}
export type Envelope =
| ProbeSuccessEnvelope
| DownloadDetachedEnvelope
| DownloadWaitSuccessEnvelope
| DownloadWaitFailureEnvelope
| ErrorEnvelope
| { ok: true; mode: 'subcommand'; subcommand: string; result: unknown }
export interface RenderOptions {
pretty?: boolean
}
export function renderEnvelope(env: Envelope, opts: RenderOptions = {}): string {
return opts.pretty ? JSON.stringify(env, null, 2) : JSON.stringify(env)
}
/**
* Map an ErrorCode to its corresponding exit code per §4.4. Anything not
* in the explicit map falls through to `ARG_ERROR` (2) so a bug here can
* never silently produce exit 0.
*/
export function exitCodeForError(code: ErrorCode): ExitCode {
switch (code) {
case 'DESKTOP_NOT_READY':
case 'API_UNREACHABLE':
case 'HANDSHAKE_FAILED':
return ExitCode.HOST_UNREACHABLE
case 'AUTH_FAILED':
case 'TOKEN_EXPIRED':
return ExitCode.AUTH_FAILED
case 'CONTRACT_VERSION_MISMATCH':
case 'CONTRACT_SCHEMA_MISMATCH':
return ExitCode.CONTRACT_ERROR
case 'PROBE_OUTPUT_TOO_LARGE':
return ExitCode.WAIT_NON_SUCCESS
case 'NOT_IMPLEMENTED':
return ExitCode.CONTRACT_ERROR
default:
return ExitCode.ARG_ERROR
}
}
export function errorEnvelope(
code: ErrorCode,
message: string,
details?: Record<string, unknown>
): ErrorEnvelope {
return details === undefined
? { ok: false, code, message }
: { ok: false, code, message, details }
}
+36
View File
@@ -0,0 +1,36 @@
// Public exports for in-process consumers (tests, host adapters, the
// three-host equivalence test). The CLI binary lives in ./bin.ts.
export * from './parser'
export * from './envelope'
export * from './subcommands'
export * from './transport'
export { connect } from './transport/connect'
export type { ConnectOptions, ConnectResult } from './transport/connect'
export { AutomationClient, AutomationHttpError } from './transport/automation-client'
export type { AutomationClientOptions, HandshakeResponse } from './transport/automation-client'
export { readDescriptor, resolveDescriptorPath, isPidAlive } from './transport/descriptor'
export type { DescriptorPayload, ReadDescriptorResult } from './transport/descriptor'
export { ensureDesktopReady } from './transport/autostart'
export type { AutostartOptions, AutostartResult } from './transport/autostart'
export { createLocalClient } from './transport/local-client'
export type { LocalClientOptions, LocalClientHandle } from './transport/local-client'
export { redactArgs, redactText, REDACTED_PLACEHOLDER } from './parser/redact'
export { buildForwardedInput } from './download/build-input'
export { enqueueDownload } from './download/enqueue'
export type { EnqueueOptions, EnqueueResult } from './download/enqueue'
export { runProbe } from './download/probe'
export type { ProbeOptions, ProbeResult, ProbeSpawner, ProbeSpawnHandle } from './download/probe'
export { run } from './runtime'
export type { RunIO, RunResult } from './runtime'
export {
readCliVersion,
checkUpgrade,
compareSemver,
defaultCachePath
} from './local-info'
export type {
CliVersionInfo,
UpgradeCheckInput,
UpgradeCheckResult
} from './local-info'
+8
View File
@@ -0,0 +1,8 @@
export { readCliVersion } from './version'
export type { CliVersionInfo } from './version'
export {
checkUpgrade,
compareSemver,
defaultCachePath
} from './upgrade'
export type { UpgradeCheckInput, UpgradeCheckResult } from './upgrade'
+181
View File
@@ -0,0 +1,181 @@
/**
* `vidbee :upgrade` — fetch the npm registry "latest" tag for
* `@vidbee/cli`, compare with the installed version, and tell the caller
* which package-manager command to run if newer.
*
* Design constraints (NEX-148 §3):
* - Do NOT auto-spawn `npm i -g` — sudo / global path / brew vs npm
* ownership differs by host; we hand the decision to the user.
* - Cache the registry response for ~30 days so we don't hammer npm on
* every CLI invocation. Cache file is platform-specific.
* - Output is JSON-friendly (Agent envelope).
*/
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { homedir, platform } from 'node:os'
import { dirname, join } from 'node:path'
const CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000 // 30 days
const REGISTRY_URL = 'https://registry.npmjs.org/@vidbee/cli/latest'
const FETCH_TIMEOUT_MS = 5_000
export interface UpgradeCheckInput {
current: string
/** Override for tests. */
fetchLatest?: () => Promise<{ version: string; fetchedAt: number }>
/** Override for tests; defaults to platform XDG cache. */
cachePath?: string
/** Force a fresh registry fetch even if cache is fresh. */
force?: boolean
/** Inject `Date.now()` for tests. */
now?: () => number
}
export interface UpgradeCheckResult {
current: string
latest: string
upToDate: boolean
cached: boolean
cachedAt: string | null
registryUrl: string
installCommands: {
npm: string
pnpm: string
bun: string
brew: string
}
}
export async function checkUpgrade(
input: UpgradeCheckInput
): Promise<UpgradeCheckResult> {
const now = input.now ?? Date.now
const cachePath = input.cachePath ?? defaultCachePath()
const fetcher = input.fetchLatest ?? defaultFetchLatest
let cachedAt: number | null = null
let latest: string | null = null
if (!input.force) {
const cached = readCache(cachePath)
if (cached && now() - cached.fetchedAt < CACHE_TTL_MS) {
latest = cached.version
cachedAt = cached.fetchedAt
}
}
let cachedFlag = latest !== null
if (latest === null) {
const fetched = await fetcher()
latest = fetched.version
cachedAt = fetched.fetchedAt
cachedFlag = false
writeCache(cachePath, { version: latest, fetchedAt: cachedAt })
}
const upToDate = compareSemver(input.current, latest) >= 0
return {
current: input.current,
latest,
upToDate,
cached: cachedFlag,
cachedAt: cachedAt === null ? null : new Date(cachedAt).toISOString(),
registryUrl: REGISTRY_URL,
installCommands: {
npm: 'npm install -g @vidbee/cli',
pnpm: 'pnpm add -g @vidbee/cli',
bun: 'bun install -g @vidbee/cli',
brew: 'brew upgrade vidbee/tap/vidbee'
}
}
}
async function defaultFetchLatest(): Promise<{
version: string
fetchedAt: number
}> {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS)
try {
const res = await fetch(REGISTRY_URL, {
headers: { accept: 'application/json' },
signal: ctrl.signal
})
if (!res.ok) {
throw new Error(`registry responded ${res.status}`)
}
const body = (await res.json()) as { version?: string }
if (typeof body.version !== 'string') {
throw new Error('registry response missing `version`')
}
return { version: body.version, fetchedAt: Date.now() }
} finally {
clearTimeout(timer)
}
}
function readCache(
path: string
): { version: string; fetchedAt: number } | null {
try {
const raw = readFileSync(path, 'utf-8')
const parsed = JSON.parse(raw) as {
version?: string
fetchedAt?: number
}
if (
typeof parsed.version === 'string' &&
typeof parsed.fetchedAt === 'number'
) {
return { version: parsed.version, fetchedAt: parsed.fetchedAt }
}
} catch {
// ignore: cache miss
}
return null
}
function writeCache(
path: string,
payload: { version: string; fetchedAt: number }
): void {
try {
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, JSON.stringify(payload), 'utf-8')
} catch {
// best-effort; the user can still rerun :upgrade.
}
}
export function defaultCachePath(): string {
const home = homedir()
if (platform() === 'win32') {
const localAppData = process.env.LOCALAPPDATA ?? join(home, 'AppData', 'Local')
return join(localAppData, 'VidBee', 'cli-upgrade-check.json')
}
if (platform() === 'darwin') {
return join(home, 'Library', 'Caches', 'VidBee', 'cli-upgrade-check.json')
}
const xdg = process.env.XDG_CACHE_HOME ?? join(home, '.cache')
return join(xdg, 'vidbee', 'cli-upgrade-check.json')
}
/**
* Minimal semver-ish compare so we don't pull in a runtime dep. Treats
* pre-release tags (`-rc.1`) as lower than the corresponding release.
* Returns negative if a<b, positive if a>b, 0 if equal.
*/
export function compareSemver(a: string, b: string): number {
const [aMain = '', aPre = ''] = a.split('-', 2)
const [bMain = '', bPre = ''] = b.split('-', 2)
const aParts = aMain.split('.').map((p) => Number.parseInt(p, 10) || 0)
const bParts = bMain.split('.').map((p) => Number.parseInt(p, 10) || 0)
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
const av = aParts[i] ?? 0
const bv = bParts[i] ?? 0
if (av !== bv) return av - bv
}
if (aPre === bPre) return 0
if (aPre === '') return 1 // 0.1.0 > 0.1.0-rc.1
if (bPre === '') return -1
return aPre < bPre ? -1 : 1
}
+72
View File
@@ -0,0 +1,72 @@
/**
* Resolve the installed CLI version by reading the package.json that ships
* alongside the bundle (or the source tree, in dev). We avoid hard-coding
* the version into a generated TS file so that a `pnpm version` bump in
* `package.json` is picked up without an extra build step.
*
* Reference: NEX-148 §3.
*/
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const FALLBACK_VERSION = '0.0.0-dev'
export interface CliVersionInfo {
cli: string
contract: string
changelog: string
}
/**
* The CLI and the shared `taskQueueContract` ship from the same monorepo;
* we surface the contract version as the same string until the package
* grows a separate semver. Hosts that pin a specific contract major use
* this string for compatibility checks.
*/
export function readCliVersion(
candidatePaths: readonly string[] = defaultCandidatePaths()
): CliVersionInfo {
for (const p of candidatePaths) {
try {
const pkg = JSON.parse(readFileSync(p, 'utf-8')) as {
name?: string
version?: string
}
if (pkg.name === '@vidbee/cli' && typeof pkg.version === 'string') {
return {
cli: pkg.version,
contract: pkg.version,
changelog:
'https://github.com/nexmoe/vidbee/blob/main/apps/cli/CHANGELOG.md'
}
}
} catch {
// try the next candidate
}
}
return {
cli: FALLBACK_VERSION,
contract: FALLBACK_VERSION,
changelog:
'https://github.com/nexmoe/vidbee/blob/main/apps/cli/CHANGELOG.md'
}
}
function defaultCandidatePaths(): string[] {
let here: string
try {
here = fileURLToPath(import.meta.url)
} catch {
return []
}
const dir = dirname(here)
// dist/index.mjs → ../package.json (npm tarball + Desktop bundle)
// src/local-info/version.ts → ../../package.json (dev / vitest)
return [
resolve(dir, '..', 'package.json'),
resolve(dir, '..', '..', 'package.json'),
resolve(dir, '..', '..', '..', 'package.json')
]
}
+296
View File
@@ -0,0 +1,296 @@
/**
* The CLI's only argv splitter. Reference:
* docs/vidbee-desktop-first-cli-ytdlp-rss-design.md §4.1, §4.2, §4.5
*
* Rules:
* - Tokens matching a known `--vidbee-*` flag are consumed (along with
* their value when `kind === 'value'`).
* - Tokens that look like `--vidbee-*` / `--vidbee:*` but are NOT in the
* reserved table cause an error with exit code 2 — never silently
* passed through to yt-dlp.
* - The first positional token starting with `:` selects a VidBee
* subcommand; subsequent positional tokens after a subcommand are the
* subcommand's own args (NOT yt-dlp args).
* - Everything else is order-preserved and handed to yt-dlp.
* - `--` is honored: every token after `--` is yt-dlp passthrough,
* including ones that would otherwise look like VidBee flags.
*/
import { findProbeFlag } from './probe-flags'
import {
findReservedFlag,
isVidbeePrefixed,
type ReservedFlag
} from './reserved-flags'
export interface VidbeeFlags {
api?: string
local: boolean
target?: 'desktop' | 'api' | 'local'
json: boolean
pretty: boolean
wait: boolean
detach: boolean
priority?: 'user' | 'subscription' | 'background'
maxAttempts?: number
noRetry: boolean
groupKey?: string
timeoutMs?: number
noAutostart: boolean
token?: string
}
export type ParsedArgv =
| { kind: 'subcommand'; flags: VidbeeFlags; subcommand: string; subArgs: readonly string[] }
| {
kind: 'ytdlp'
flags: VidbeeFlags
mode: 'probe' | 'download'
ytArgs: readonly string[]
probeFlag: string | null
}
export class ParseError extends Error {
readonly exitCode: 2
readonly code: string
constructor(code: string, message: string) {
super(message)
this.code = code
this.exitCode = 2
}
}
const PRIORITY_VALUES = new Set<VidbeeFlags['priority']>([
'user',
'subscription',
'background'
])
const TARGET_VALUES = new Set<VidbeeFlags['target']>(['desktop', 'api', 'local'])
export function parseArgv(argv: readonly string[]): ParsedArgv {
const flags = defaultFlags()
const yt: string[] = []
let subcommand: string | null = null
let passthroughOnly = false
for (let i = 0; i < argv.length; i++) {
const tok = argv[i]
if (tok === undefined) continue
if (passthroughOnly) {
yt.push(tok)
continue
}
if (tok === '--') {
passthroughOnly = true
yt.push(tok)
continue
}
// Subcommand: a `:` positional collects all remaining argv as its args.
// We do not try to mix subcommand args with yt-dlp args.
if (subcommand === null && tok.startsWith(':') && tok.length > 1) {
subcommand = tok.slice(1)
const subArgs = argv.slice(i + 1).filter((s): s is string => typeof s === 'string')
// We still need to honor --vidbee-* in the subcommand tail (e.g.
// `vidbee --vidbee-pretty :download list`); those have already been
// consumed by earlier iterations. Anything inside subArgs is owned by
// the subcommand handler — no further parsing here.
return { kind: 'subcommand', flags, subcommand, subArgs }
}
// VidBee-prefixed flags: must match the reserved table or we fail loud.
if (isVidbeePrefixed(tok)) {
const eq = tok.indexOf('=')
const name = eq === -1 ? tok : tok.slice(0, eq)
const inlineValue = eq === -1 ? null : tok.slice(eq + 1)
const def = findReservedFlag(name)
if (!def) {
throw new ParseError(
'UNKNOWN_VIDBEE_FLAG',
`unknown VidBee flag: ${name}`
)
}
const consumed = consumeReserved(flags, def, argv, i, inlineValue)
i += consumed
continue
}
// Anything else is yt-dlp territory.
yt.push(tok)
}
if (subcommand !== null) {
return { kind: 'subcommand', flags, subcommand, subArgs: [] }
}
// §4.5: -o - in non-probe modes is rejected up front. We don't pretend to
// understand more of -o than that single edge case.
const probeFlag = findProbeFlag(yt)
const mode: 'probe' | 'download' = probeFlag === null ? 'download' : 'probe'
if (mode === 'download' && hasStdoutOutput(yt)) {
throw new ParseError(
'STDOUT_OUTPUT_DISALLOWED',
'-o - is only allowed in probe mode'
)
}
return { kind: 'ytdlp', flags, mode, ytArgs: yt, probeFlag }
}
function defaultFlags(): VidbeeFlags {
return {
local: false,
json: true,
pretty: false,
wait: false,
detach: false,
noRetry: false,
noAutostart: false
}
}
function consumeReserved(
flags: VidbeeFlags,
def: ReservedFlag,
argv: readonly string[],
i: number,
inlineValue: string | null
): number {
if (def.kind === 'switch') {
if (inlineValue !== null) {
throw new ParseError(
'UNEXPECTED_VALUE',
`${def.name} does not take a value`
)
}
applySwitch(flags, def.name)
return 0
}
// value flag
let value: string
let consumed = 0
if (inlineValue !== null) {
value = inlineValue
} else {
const next = argv[i + 1]
if (next === undefined) {
throw new ParseError('MISSING_VALUE', `${def.name} requires a value`)
}
value = next
consumed = 1
}
applyValue(flags, def.name, value)
return consumed
}
function applySwitch(flags: VidbeeFlags, name: string): void {
switch (name) {
case '--vidbee-local':
flags.local = true
break
case '--vidbee-json':
flags.json = true
break
case '--vidbee-pretty':
flags.pretty = true
break
case '--vidbee-wait':
flags.wait = true
break
case '--vidbee-detach':
flags.detach = true
break
case '--vidbee-no-retry':
flags.noRetry = true
flags.maxAttempts = 0
break
case '--vidbee-no-autostart':
flags.noAutostart = true
break
default:
throw new ParseError(
'UNKNOWN_VIDBEE_FLAG',
`unknown VidBee switch: ${name}`
)
}
}
function applyValue(flags: VidbeeFlags, name: string, value: string): void {
switch (name) {
case '--vidbee-api':
flags.api = value
break
case '--vidbee-target':
if (!(TARGET_VALUES as Set<string>).has(value)) {
throw new ParseError(
'INVALID_TARGET',
`--vidbee-target must be one of desktop|api|local; got ${value}`
)
}
flags.target = value as VidbeeFlags['target']
break
case '--vidbee-priority':
if (!(PRIORITY_VALUES as Set<string>).has(value)) {
throw new ParseError(
'INVALID_PRIORITY',
`--vidbee-priority must be user|subscription|background; got ${value}`
)
}
flags.priority = value as VidbeeFlags['priority']
break
case '--vidbee-max-attempts': {
const n = Number.parseInt(value, 10)
if (!Number.isFinite(n) || n < 0 || `${n}` !== value) {
throw new ParseError(
'INVALID_MAX_ATTEMPTS',
`--vidbee-max-attempts must be a non-negative integer; got ${value}`
)
}
flags.maxAttempts = n
if (n === 0) flags.noRetry = true
break
}
case '--vidbee-group-key':
flags.groupKey = value
break
case '--vidbee-timeout': {
const n = Number.parseInt(value, 10)
if (!Number.isFinite(n) || n <= 0) {
throw new ParseError(
'INVALID_TIMEOUT',
`--vidbee-timeout must be a positive integer (ms); got ${value}`
)
}
flags.timeoutMs = n
break
}
case '--vidbee-token':
flags.token = value
break
default:
throw new ParseError(
'UNKNOWN_VIDBEE_FLAG',
`unknown VidBee value flag: ${name}`
)
}
}
/**
* §4.5: writing yt-dlp output to stdout (`-o -`) is only legal in probe
* mode. We detect it by `-o`-followed-by-`-` or `-o-`/`--output -`.
*/
function hasStdoutOutput(argv: readonly string[]): boolean {
for (let i = 0; i < argv.length; i++) {
const tok = argv[i]
if (tok === '-o' || tok === '--output') {
if (argv[i + 1] === '-') return true
} else if (tok === '-o-' || tok === '--output=-' || tok === '-o=-') {
return true
}
}
return false
}
export type { VidbeeFlags as Flags }
+87
View File
@@ -0,0 +1,87 @@
/**
* Authoritative yt-dlp probe-flag set. Reference:
* docs/vidbee-desktop-first-cli-ytdlp-rss-design.md §4.3
*
* If argv contains ANY of these (with no positional dependence), the CLI
* routes the run through ProcessRegistry as a probe — no task is enqueued
* and no history row is written. This is the ONLY place where the CLI
* inspects yt-dlp argv semantically; every other arg is order-preserved
* passthrough.
*/
const EXACT_FLAGS = new Set<string>([
'-j',
'--dump-json',
'-J',
'--dump-single-json',
'-F',
'--list-formats',
'--list-formats-as-table',
'--list-formats-old',
'-s',
'--simulate',
'--skip-download',
'--list-subs',
'--list-extractors',
'--list-extractor-descriptions'
])
const EXACT_GETTERS = new Set<string>([
'--get-id',
'--get-title',
'--get-thumbnail',
'--get-description',
'--get-duration',
'--get-filename',
'--get-format',
'--get-url'
])
/**
* yt-dlp meta commands that have no URL but should still go through the
* managed-forward path as probe — wrapped with envelope, exit code from
* yt-dlp.
*/
const META_FLAGS = new Set<string>(['--update', '--version'])
/**
* Returns true if the given argv contains a probe-class flag, including:
* - all exact aliases listed in §4.3
* - any --print (regardless of where / how many times)
* - any --get-* getter
* - meta commands (--update, --version)
*
* Both `--flag value` and `--flag=value` shapes are covered: the leading
* token (the part up to and including `=` or end-of-token) is what gets
* matched.
*/
export function isProbeArgv(argv: readonly string[]): boolean {
return findProbeFlag(argv) !== null
}
/**
* Returns the first probe-class flag found in argv, or null if none. Used
* by tests and diagnostics; the runtime only needs the boolean form.
*/
export function findProbeFlag(argv: readonly string[]): string | null {
for (const tok of argv) {
const head = headOf(tok)
if (EXACT_FLAGS.has(head)) return head
if (EXACT_GETTERS.has(head)) return head
if (META_FLAGS.has(head)) return head
if (head === '--print') return head
}
return null
}
function headOf(tok: string): string {
const eq = tok.indexOf('=')
return eq === -1 ? tok : tok.slice(0, eq)
}
export const PROBE_FLAG_REGISTRY = {
exact: EXACT_FLAGS,
getters: EXACT_GETTERS,
meta: META_FLAGS,
prefixed: ['--print'] as const
}
+199
View File
@@ -0,0 +1,199 @@
/**
* Sensitive-argument redaction. Reference:
* docs/vidbee-desktop-first-cli-ytdlp-rss-design.md §8.2
*
* `rawArgs` is held in memory only; before any envelope, persisted task
* row, attempt log tail, or projection sees the argv it MUST go through
* `redactArgs`. Same rules apply to URL query strings encountered in the
* argv (query tokens are scrubbed in place).
*
* The function is intentionally conservative — when in doubt, redact.
*/
const REDACTED = '<redacted>'
/** Flags whose immediately-following value is sensitive in full. */
const VALUE_FLAGS_FULL = new Set<string>([
'--username',
'--password',
'--video-password',
'--ap-password',
'--twofactor',
'--ap-username'
])
/**
* Header-shaped flags. Value form is `name:value`; if the header name is
* one of the sensitive ones, only the value half is replaced.
*/
const HEADER_FLAGS = new Set<string>(['--add-headers', '--add-header'])
const SENSITIVE_HEADER_NAMES = new Set<string>([
'authorization',
'cookie',
'set-cookie',
'token',
'x-token',
'bearer',
'x-auth',
'x-auth-token',
'proxy-authorization'
])
/** URL query parameters whose value should be scrubbed. */
const SENSITIVE_QUERY_KEYS = new Set<string>([
'token',
'access_token',
'auth_token',
'id_token',
'signature',
'sig',
'policy',
'key',
'secret',
'apikey',
'api_key',
'password',
'pass',
'pwd'
])
export interface RedactSummary {
/** True when at least one substitution was performed. */
redacted: boolean
}
export function redactArgs(
args: readonly string[]
): { args: string[]; summary: RedactSummary } {
const out: string[] = []
let redacted = false
for (let i = 0; i < args.length; i++) {
const tok = args[i]
if (tok === undefined) continue
const eq = tok.indexOf('=')
const head = eq === -1 ? tok : tok.slice(0, eq)
const inline = eq === -1 ? null : tok.slice(eq + 1)
if (VALUE_FLAGS_FULL.has(head)) {
if (inline !== null) {
out.push(`${head}=${REDACTED}`)
redacted = true
} else {
out.push(head)
if (i + 1 < args.length) {
out.push(REDACTED)
redacted = true
i += 1
}
}
continue
}
if (HEADER_FLAGS.has(head)) {
const consumed = handleHeader(head, inline, args, i, out)
if (consumed.redacted) redacted = true
i += consumed.skip
continue
}
// URL-shaped tokens — scrub query string
if (looksLikeUrl(tok)) {
const scrubbed = scrubUrl(tok)
if (scrubbed.changed) redacted = true
out.push(scrubbed.value)
continue
}
out.push(tok)
}
return { args: out, summary: { redacted } }
}
/**
* Best-effort scrub of free-form text (stdout / stderr tail). We only
* scrub things that have a structural shape — URL query strings and
* `Authorization:` style header lines. A fancier regex pass risks false
* positives in download progress output, so we keep it tight.
*/
export function redactText(text: string): string {
let out = text
out = out.replace(/(authorization|cookie|x-auth-token|bearer)\s*:\s*[^\n\r]+/gi, (m, name) => {
return `${name}: ${REDACTED}`
})
out = out.replace(/\bhttps?:\/\/[^\s'"]+/g, (url) => scrubUrl(url).value)
return out
}
function handleHeader(
head: string,
inline: string | null,
args: readonly string[],
i: number,
out: string[]
): { skip: number; redacted: boolean } {
let value: string | null
let skip = 0
if (inline !== null) {
value = inline
} else if (i + 1 < args.length) {
value = args[i + 1] ?? null
skip = 1
} else {
out.push(head)
return { skip: 0, redacted: false }
}
if (value === null) {
out.push(head)
return { skip: 0, redacted: false }
}
const colon = value.indexOf(':')
if (colon === -1) {
if (inline !== null) out.push(`${head}=${value}`)
else {
out.push(head)
out.push(value)
}
return { skip, redacted: false }
}
const name = value.slice(0, colon).trim().toLowerCase()
const rendered = SENSITIVE_HEADER_NAMES.has(name)
? `${value.slice(0, colon)}: ${REDACTED}`
: value
const changed = rendered !== value
if (inline !== null) out.push(`${head}=${rendered}`)
else {
out.push(head)
out.push(rendered)
}
return { skip, redacted: changed }
}
function looksLikeUrl(tok: string): boolean {
return /^https?:\/\//i.test(tok)
}
function scrubUrl(raw: string): { value: string; changed: boolean } {
let url: URL
try {
url = new URL(raw)
} catch {
return { value: raw, changed: false }
}
let changed = false
for (const key of Array.from(url.searchParams.keys())) {
if (SENSITIVE_QUERY_KEYS.has(key.toLowerCase())) {
url.searchParams.set(key, REDACTED)
changed = true
}
}
// Preserve the original token byte-for-byte when nothing was redacted —
// `new URL().toString()` normalizes the form (e.g. adds a trailing /,
// re-encodes punycode), and that normalization is undesirable for argv
// we're going to log or echo back to the user.
return { value: changed ? url.toString() : raw, changed }
}
export const REDACTED_PLACEHOLDER = REDACTED
+58
View File
@@ -0,0 +1,58 @@
/**
* The full set of `--vidbee-*` flags the CLI knows about. Reference:
* docs/vidbee-desktop-first-cli-ytdlp-rss-design.md §4.2
*
* `kind` controls how the parser splits argv:
* - 'switch' — boolean; presence sets value true
* - 'value' — consumes the next argv token, OR accepts `--flag=value`
*
* Any unknown `--vidbee-*` token causes the parser to reject argv with
* exit 2; we never silently forward unrecognized vidbee flags to yt-dlp.
*/
export type ReservedFlagKind = 'switch' | 'value'
export interface ReservedFlag {
name: string
kind: ReservedFlagKind
}
export const RESERVED_FLAGS: readonly ReservedFlag[] = [
{ name: '--vidbee-api', kind: 'value' },
{ name: '--vidbee-local', kind: 'switch' },
{ name: '--vidbee-target', kind: 'value' },
{ name: '--vidbee-json', kind: 'switch' },
{ name: '--vidbee-pretty', kind: 'switch' },
{ name: '--vidbee-wait', kind: 'switch' },
{ name: '--vidbee-detach', kind: 'switch' },
{ name: '--vidbee-priority', kind: 'value' },
{ name: '--vidbee-max-attempts', kind: 'value' },
{ name: '--vidbee-no-retry', kind: 'switch' },
{ name: '--vidbee-group-key', kind: 'value' },
{ name: '--vidbee-timeout', kind: 'value' },
{ name: '--vidbee-no-autostart', kind: 'switch' },
{ name: '--vidbee-token', kind: 'value' }
]
const RESERVED_BY_NAME = new Map<string, ReservedFlag>(
RESERVED_FLAGS.map((f) => [f.name, f])
)
export function findReservedFlag(name: string): ReservedFlag | undefined {
return RESERVED_BY_NAME.get(name)
}
/**
* True if `tok` looks like a VidBee flag. We deliberately match the loose
* prefix `--vidb` (which has no overlap with any yt-dlp option as of
* yt-dlp 2024.x — `--video-*` does not start with `--vidb`) so that typos
* like `--vidbe-wait` are caught and reported instead of being silently
* forwarded to yt-dlp. Reference: design doc §4.5 edge-case table.
*
* The strict membership check (against RESERVED_FLAGS) happens on the
* canonical `--vidbee-*` / `--vidbee:*` form; anything else with the
* `--vidb` prefix is rejected as an unknown VidBee flag.
*/
export function isVidbeePrefixed(tok: string): boolean {
return tok.startsWith('--vidb')
}
+364
View File
@@ -0,0 +1,364 @@
/**
* The CLI's `main(argv)`. Exported so unit tests can drive it without
* spawning a subprocess. Reference:
* docs/vidbee-desktop-first-cli-ytdlp-rss-design.md §4
*
* The runtime is split into the cold path (parse argv, choose transport,
* connect) and the hot paths (subcommand dispatch, yt-dlp probe, yt-dlp
* download enqueue). Each hot path is a separate function so tests can
* drive them in isolation.
*/
import { ParseError, parseArgv } from './parser'
import {
ExitCode,
errorEnvelope,
exitCodeForError,
renderEnvelope,
type Envelope,
type ErrorEnvelope
} from './envelope'
import {
dispatchSubcommand,
type ContractClient,
type SubcommandContext
} from './subcommands'
import { selectTransport, validateApiUrl } from './transport'
import { connect, type ConnectOptions, type ConnectResult } from './transport/connect'
import { buildForwardedInput } from './download/build-input'
import { enqueueDownload } from './download/enqueue'
import { runProbe, type ProbeOptions } from './download/probe'
import { redactArgs, redactText } from './parser/redact'
import {
checkUpgrade,
readCliVersion,
type CliVersionInfo,
type UpgradeCheckInput,
type UpgradeCheckResult
} from './local-info'
export interface RunIO {
stdout: (line: string) => void
stderr: (line: string) => void
/**
* Test seam — overrides the entire transport-selection / connect step.
* Production wiring uses `connect()` from ./transport/connect.
*/
connect?: (opts: ConnectOptions) => Promise<ConnectResult>
/** Test seam — overrides yt-dlp probe spawn. */
probe?: (opts: ProbeOptions) => ReturnType<typeof runProbe>
/** Test seam — overrides `:version` resolution. */
readVersion?: () => CliVersionInfo
/**
* Test seam — overrides `:upgrade` registry / cache I/O. Receives the
* resolved CLI version and any subcommand args (`--force`, `--no-cache`,
* `--cache <path>`).
*/
checkUpgrade?: (input: UpgradeCheckInput) => Promise<UpgradeCheckResult>
}
export interface RunResult {
exitCode: number
envelope: Envelope
}
export async function run(argv: readonly string[], io: RunIO): Promise<RunResult> {
let parsed: ReturnType<typeof parseArgv>
try {
parsed = parseArgv(argv)
} catch (err) {
if (err instanceof ParseError) {
const env = errorEnvelope(coerceErrorCode(err.code), err.message)
return emit(io, env, false, ExitCode.ARG_ERROR)
}
throw err
}
const pretty = parsed.flags.pretty
if (parsed.flags.api !== undefined) {
const bad = validateApiUrl(parsed.flags.api)
if (bad) return emit(io, bad, pretty, ExitCode.HOST_UNREACHABLE)
}
const connectImpl = io.connect ?? connect
if (parsed.kind === 'ytdlp') {
if (parsed.mode === 'probe') {
return await runProbeMode(parsed, io, pretty)
}
return await runDownloadMode(parsed, io, pretty, connectImpl)
}
// Local-only subcommands run without contacting Desktop / API. They are
// dispatched here so a missing automation descriptor doesn't make
// `vidbee :version` fail with HOST_UNREACHABLE.
if (parsed.subcommand === 'version' || parsed.subcommand === 'upgrade') {
return await runLocalSubcommand(parsed.subcommand, parsed.subArgs, io, pretty)
}
// Subcommand path
const conn = await connectImpl({ flags: parsed.flags })
if (conn.kind === 'error') {
return emit(io, conn.envelope, pretty, exitCodeForError(conn.envelope.code))
}
const ctx: SubcommandContext = { client: conn.client }
try {
const result = await dispatchSubcommand(parsed.subcommand, parsed.subArgs, ctx)
if (result.kind === 'error') {
return emit(io, result.envelope, pretty, exitCodeForError(result.envelope.code))
}
const env: Envelope = {
ok: true,
mode: 'subcommand',
subcommand: parsed.subcommand,
result: result.value
}
return emit(io, env, pretty, ExitCode.OK)
} catch (err) {
const env = errorEnvelope(
'UNKNOWN_ERROR',
err instanceof Error ? err.message : String(err)
)
return emit(io, env, pretty, ExitCode.ARG_ERROR)
} finally {
await safeTeardown(conn)
}
}
type YtdlpParsed = Extract<ReturnType<typeof parseArgv>, { kind: 'ytdlp' }>
async function runLocalSubcommand(
subcommand: 'version' | 'upgrade',
subArgs: readonly string[],
io: RunIO,
pretty: boolean
): Promise<RunResult> {
const versionInfo = (io.readVersion ?? readCliVersion)()
if (subcommand === 'version') {
const env: Envelope = {
ok: true,
mode: 'subcommand',
subcommand: 'version',
result: versionInfo
}
return emit(io, env, pretty, ExitCode.OK)
}
// :upgrade
const upgradeImpl = io.checkUpgrade ?? checkUpgrade
const opts = parseUpgradeArgs(subArgs)
if (opts.kind === 'error') return emit(io, opts.envelope, pretty, ExitCode.ARG_ERROR)
try {
const result = await upgradeImpl({
current: versionInfo.cli,
...(opts.force ? { force: true } : {}),
...(opts.cachePath !== undefined ? { cachePath: opts.cachePath } : {})
})
const env: Envelope = {
ok: true,
mode: 'subcommand',
subcommand: 'upgrade',
result
}
return emit(io, env, pretty, ExitCode.OK)
} catch (err) {
const env = errorEnvelope(
'API_UNREACHABLE',
err instanceof Error ? err.message : String(err),
{ registry: 'https://registry.npmjs.org/@vidbee/cli/latest' }
)
return emit(io, env, pretty, ExitCode.HOST_UNREACHABLE)
}
}
interface UpgradeArgs {
kind: 'ok'
force: boolean
cachePath?: string
}
function parseUpgradeArgs(
subArgs: readonly string[]
): UpgradeArgs | { kind: 'error'; envelope: ErrorEnvelope } {
const out: UpgradeArgs = { kind: 'ok', force: false }
for (let i = 0; i < subArgs.length; i++) {
const tok = subArgs[i]
if (tok === undefined) continue
if (tok === '--force') {
out.force = true
continue
}
if (tok === '--cache') {
const next = subArgs[i + 1]
if (next === undefined) {
return {
kind: 'error',
envelope: errorEnvelope('MISSING_VALUE', '--cache requires a path')
}
}
out.cachePath = next
i += 1
continue
}
if (tok.startsWith('--cache=')) {
out.cachePath = tok.slice('--cache='.length)
continue
}
return {
kind: 'error',
envelope: errorEnvelope('PARSE_ERROR', `unknown :upgrade flag: ${tok}`)
}
}
return out
}
async function runProbeMode(
parsed: YtdlpParsed,
io: RunIO,
pretty: boolean
): Promise<RunResult> {
const probeImpl = io.probe ?? runProbe
const result = await probeImpl({ argv: parsed.ytArgs })
if (result.kind === 'error') {
return emit(
io,
result.envelope,
pretty,
exitCodeForError(result.envelope.code)
)
}
const { args: sanitized } = redactArgs(parsed.ytArgs)
const env: Envelope = {
ok: true,
mode: 'probe',
command: ['yt-dlp', ...sanitized].join(' '),
ytDlp: {
exitCode: result.exitCode,
stdout: result.stdout,
stderr: redactText(result.stderr)
}
}
return emit(io, env, pretty, result.exitCode === 0 ? ExitCode.OK : ExitCode.WAIT_NON_SUCCESS)
}
async function runDownloadMode(
parsed: YtdlpParsed,
io: RunIO,
pretty: boolean,
connectImpl: NonNullable<RunIO['connect']>
): Promise<RunResult> {
const conn = await connectImpl({ flags: parsed.flags })
if (conn.kind === 'error') {
return emit(io, conn.envelope, pretty, exitCodeForError(conn.envelope.code))
}
try {
const built = buildForwardedInput({ argv: parsed.ytArgs, flags: parsed.flags })
const request: Parameters<NonNullable<ContractClient['add']>>[0] = {
input: built.input
}
if (parsed.flags.priority) {
request.priority = priorityToCode(parsed.flags.priority)
}
if (parsed.flags.groupKey !== undefined) request.groupKey = parsed.flags.groupKey
if (parsed.flags.maxAttempts !== undefined) request.maxAttempts = parsed.flags.maxAttempts
const result = await enqueueDownload({
client: conn.client,
request,
wait: parsed.flags.wait
})
if (result.kind === 'detached') {
const env: Envelope = {
ok: true,
mode: 'download',
task: {
id: result.task.id,
status: result.task.status,
attempt: result.task.attempt,
maxAttempts: result.task.maxAttempts,
command: built.commandPreview
}
}
return emit(io, env, pretty, ExitCode.OK)
}
if (result.kind === 'wait-success') {
const env: Envelope = {
ok: true,
mode: 'download',
task: result.task,
ytDlp: {
exitCode: 0,
stdoutTail: '',
stderrTail: ''
}
}
return emit(io, env, pretty, ExitCode.OK)
}
// wait-non-success
const env: Envelope = {
ok: false,
mode: 'download',
task: result.task,
ytDlp: {
exitCode: 1,
stdoutTail: '',
stderrTail: redactText(
(result.task.lastError as { stderrTail?: string } | null)?.stderrTail ?? ''
)
}
}
return emit(io, env, pretty, ExitCode.WAIT_NON_SUCCESS)
} catch (err) {
const env = errorEnvelope(
'UNKNOWN_ERROR',
err instanceof Error ? err.message : String(err)
)
return emit(io, env, pretty, ExitCode.ARG_ERROR)
} finally {
await safeTeardown(conn)
}
}
function priorityToCode(p: 'user' | 'subscription' | 'background'): 0 | 10 | 20 {
if (p === 'user') return 0
if (p === 'subscription') return 10
return 20
}
async function safeTeardown(conn: ConnectResult): Promise<void> {
if (conn.kind !== 'connected') return
if (!conn.teardown) return
try {
await conn.teardown()
} catch {
/* noop */
}
}
function emit(
io: RunIO,
env: Envelope,
pretty: boolean,
exitCode: number
): RunResult {
io.stdout(renderEnvelope(env, { pretty }))
return { exitCode, envelope: env }
}
function coerceErrorCode(code: string): ErrorEnvelope['code'] {
switch (code) {
case 'UNKNOWN_VIDBEE_FLAG':
case 'INVALID_TARGET':
case 'INVALID_PRIORITY':
case 'INVALID_MAX_ATTEMPTS':
case 'INVALID_TIMEOUT':
case 'MISSING_VALUE':
case 'UNEXPECTED_VALUE':
case 'STDOUT_OUTPUT_DISALLOWED':
return code
default:
return 'PARSE_ERROR'
}
}
+297
View File
@@ -0,0 +1,297 @@
/**
* Subcommand dispatch for `vidbee :…` invocations. Reference:
* docs/vidbee-desktop-first-cli-ytdlp-rss-design.md §4.1
*
* Phase A delivered the read-only commands; Phase B fills in the write
* verbs under `:download` (cancel/pause/resume/retry/logs).
*/
import type { AddTaskRequest, Task, TaskQueueAPI, TaskStatus } from '@vidbee/task-queue'
import { errorEnvelope, type ErrorEnvelope } from '../envelope'
export interface ContractClient {
/**
* Minimal surface of `taskQueueContract` the CLI needs. Phase A only
* required readers; Phase B added writers (add/cancel/pause/resume/retry).
* The transport layer (loopback HTTP / remote HTTPS / `--vidbee-local`)
* supplies an implementation.
*/
list: (input: ListInput) => Promise<{ items: Task[]; nextCursor: string | null }>
get: (id: string) => Promise<Task>
stats: () => Promise<unknown>
removeFromHistory: (id: string) => Promise<void>
add?: (req: AddTaskRequest) => Promise<{ id: string; task: Task }>
cancel?: (id: string) => Promise<void>
pause?: (id: string, reason?: string) => Promise<void>
resume?: (id: string) => Promise<void>
retry?: (id: string) => Promise<void>
}
export interface ListInput {
status?: TaskStatus
groupKey?: string
parentId?: string
limit?: number
cursor?: string | null
}
export interface SubcommandContext {
client: ContractClient
/**
* `--vidbee-local` instantiates this directly. Read-only commands don't
* need it but `:download logs <id>` reaches into attempt rows that the
* orchestrator owns; future work will add a route to the contract.
*/
api?: TaskQueueAPI
}
export type SubcommandResult =
| { kind: 'value'; value: unknown }
| { kind: 'error'; envelope: ErrorEnvelope }
export async function dispatchSubcommand(
subcommand: string,
args: readonly string[],
ctx: SubcommandContext
): Promise<SubcommandResult> {
const path = subcommand.split('/').filter(Boolean)
switch (path[0]) {
case 'status':
return ok(await ctx.client.stats())
case 'download':
return await handleDownload(path.slice(1), args, ctx)
case 'history':
return await handleHistory(path.slice(1), args, ctx)
case 'rss':
return notImplemented('rss', 'NEX-132 owns :rss subcommands')
default:
return {
kind: 'error',
envelope: errorEnvelope(
'PARSE_ERROR',
`unknown subcommand: :${subcommand}`,
{ subcommand }
)
}
}
}
async function handleDownload(
rest: readonly string[],
args: readonly string[],
ctx: SubcommandContext
): Promise<SubcommandResult> {
const verb = rest[0] ?? args[0]
const tail = (rest.length > 0 ? args : args.slice(1)).filter(
(s): s is string => typeof s === 'string'
)
switch (verb) {
case 'list': {
const input = parseListArgs(tail)
return ok(await ctx.client.list(input))
}
case 'status': {
const id = tail[0]
if (!id) return missingArg(':download status <id>', 'id')
return ok(await ctx.client.get(id))
}
case 'logs': {
const id = tail[0]
if (!id) return missingArg(':download logs <id>', 'id')
// The contract doesn't expose a per-attempt logs route. We surface
// the task object (which includes `lastError.stderrTail`) so callers
// can inspect failures without a separate round-trip. A dedicated
// logs route is tracked as a follow-up that needs new contract
// schema work in @vidbee/task-queue.
const task = await ctx.client.get(id)
const stderrTail =
(task.lastError as { stderrTail?: string } | null)?.stderrTail ?? null
return ok({ task, logs: { stderrTail } })
}
case 'cancel': {
const id = tail[0]
if (!id) return missingArg(':download cancel <id>', 'id')
if (!ctx.client.cancel) return capabilityError('cancel')
await ctx.client.cancel(id)
return ok({ id, status: 'cancel-requested' })
}
case 'pause': {
const id = tail[0]
if (!id) return missingArg(':download pause <id> [--reason text]', 'id')
const reason = readNamedArg(tail, '--reason')
if (!ctx.client.pause) return capabilityError('pause')
await ctx.client.pause(id, reason)
return ok({ id, status: 'pause-requested' })
}
case 'resume': {
const id = tail[0]
if (!id) return missingArg(':download resume <id>', 'id')
if (!ctx.client.resume) return capabilityError('resume')
await ctx.client.resume(id)
return ok({ id, status: 'resume-requested' })
}
case 'retry': {
const id = tail[0]
if (!id) return missingArg(':download retry <id>', 'id')
if (!ctx.client.retry) return capabilityError('retry')
await ctx.client.retry(id)
return ok({ id, status: 'retry-requested' })
}
default:
return {
kind: 'error',
envelope: errorEnvelope(
'PARSE_ERROR',
`unknown :download verb: ${verb ?? '(missing)'}`
)
}
}
}
async function handleHistory(
rest: readonly string[],
args: readonly string[],
ctx: SubcommandContext
): Promise<SubcommandResult> {
const verb = rest[0] ?? args[0]
const tail = (rest.length > 0 ? args : args.slice(1)).filter(
(s): s is string => typeof s === 'string'
)
switch (verb) {
case 'list': {
const input = parseListArgs(tail)
// History view is a list filtered to terminal statuses; the contract
// doesn't expose a separate history route — the projection happens
// host-side. Here we just forward.
return ok(await ctx.client.list(input))
}
case 'remove': {
if (tail.length === 0) return missingArg(':history remove <id...>', 'id')
const removed: string[] = []
for (const id of tail) {
await ctx.client.removeFromHistory(id)
removed.push(id)
}
return ok({ removed })
}
default:
return {
kind: 'error',
envelope: errorEnvelope(
'PARSE_ERROR',
`unknown :history verb: ${verb ?? '(missing)'}`
)
}
}
}
const KNOWN_STATUSES: ReadonlySet<TaskStatus> = new Set<TaskStatus>([
'queued',
'running',
'processing',
'paused',
'retry-scheduled',
'completed',
'failed',
'cancelled'
])
export function parseListArgs(args: readonly string[]): ListInput {
const input: ListInput = {}
for (let i = 0; i < args.length; i++) {
const tok = args[i]
if (tok === undefined) continue
const eq = tok.indexOf('=')
const name = eq === -1 ? tok : tok.slice(0, eq)
const inline = eq === -1 ? null : tok.slice(eq + 1)
const consume = (): string => {
if (inline !== null) return inline
const next = args[i + 1]
if (next === undefined) {
throw Object.assign(new Error(`${name} requires a value`), {
code: 'MISSING_VALUE'
})
}
i += 1
return next
}
switch (name) {
case '--status': {
const v = consume()
if (!(KNOWN_STATUSES as Set<string>).has(v)) {
throw Object.assign(new Error(`unknown status: ${v}`), {
code: 'INVALID_STATUS'
})
}
input.status = v as TaskStatus
break
}
case '--group':
case '--group-key':
input.groupKey = consume()
break
case '--parent':
input.parentId = consume()
break
case '--limit': {
const v = consume()
const n = Number.parseInt(v, 10)
if (!Number.isFinite(n) || n <= 0) {
throw Object.assign(new Error(`invalid limit: ${v}`), {
code: 'INVALID_LIMIT'
})
}
input.limit = n
break
}
case '--cursor':
input.cursor = consume()
break
default:
throw Object.assign(new Error(`unknown flag: ${name}`), {
code: 'UNKNOWN_FLAG'
})
}
}
return input
}
function ok(value: unknown): SubcommandResult {
return { kind: 'value', value }
}
function notImplemented(what: string, why: string): SubcommandResult {
return {
kind: 'error',
envelope: errorEnvelope('NOT_IMPLEMENTED', `${what} not implemented`, {
reason: why
})
}
}
function missingArg(usage: string, argName: string): SubcommandResult {
return {
kind: 'error',
envelope: errorEnvelope('PARSE_ERROR', `missing ${argName}; usage: ${usage}`)
}
}
function capabilityError(op: string): SubcommandResult {
return {
kind: 'error',
envelope: errorEnvelope(
'CONTRACT_VERSION_MISMATCH',
`transport does not support ${op}; upgrade Desktop or API host`
)
}
}
function readNamedArg(args: readonly string[], name: string): string | undefined {
for (let i = 0; i < args.length; i++) {
const tok = args[i]
if (tok === undefined) continue
if (tok === name) return args[i + 1]
if (tok.startsWith(`${name}=`)) return tok.slice(name.length + 1)
}
return undefined
}
+312
View File
@@ -0,0 +1,312 @@
/**
* Talks to the `/automation/v1/*` HTTP surface used by both Desktop
* loopback (NEX-131 A1) and the remote Web/API host. Reference:
* docs/vidbee-desktop-first-cli-ytdlp-rss-design.md §5.1, §5.3, §8.1
*
* Wire format (POST JSON, GET for stats / health / events):
* POST /automation/v1/handshake -> { token, expiresAt, ttlMs }
* POST /automation/v1/add -> AddOutput
* POST /automation/v1/get -> { task, projection } | null
* POST /automation/v1/list -> { tasks, nextCursor }
* POST /automation/v1/cancel|pause|resume -> { ok: true }
* POST /automation/v1/retry -> { ok: true }
* POST /automation/v1/setMaxConcurrency -> { ok: true }
* POST /automation/v1/setMaxPerGroup -> { ok: true }
* POST /automation/v1/removeFromHistory -> { ok: true }
* GET /automation/v1/stats -> stats
* GET /automation/v1/health -> health (no auth)
* GET /automation/v1/events -> SSE
*
* The client owns a short-lived bearer token. It re-handshakes on:
* - first call (no token cached)
* - explicit 401 from the server
* - token within `expiresAtSlackMs` of expiry
*/
import type { Task, AddTaskRequest } from '@vidbee/task-queue'
import type { ContractClient, ListInput } from '../subcommands'
export interface AutomationClientOptions {
/** Base URL such as `http://127.0.0.1:27100` (no trailing slash, no path). */
baseUrl: string
/** Pre-supplied bearer token (e.g. from `--vidbee-token` or env). */
token?: string
/** Skip handshake; only legal when `token` is provided. */
skipHandshake?: boolean
/** Request timeout per call (ms). Default 30s. */
requestTimeoutMs?: number
/** Renew when fewer than this many ms remain on the token. Default 60_000. */
expiresAtSlackMs?: number
/**
* fetch override for tests. Defaults to global fetch.
*/
fetch?: typeof fetch
/** Test seam — defaults to Date.now. */
clock?: () => number
}
export interface HandshakeResponse {
token: string
expiresAt: number
ttlMs: number
schemaVersion: string
}
export class AutomationHttpError extends Error {
readonly status: number
readonly body: string
readonly code: 'AUTH_FAILED' | 'API_UNREACHABLE' | 'CONTRACT_ERROR' | 'UNKNOWN'
constructor(
code: AutomationHttpError['code'],
status: number,
message: string,
body = ''
) {
super(message)
this.code = code
this.status = status
this.body = body
}
}
export class AutomationClient implements ContractClient {
private readonly baseUrl: string
private readonly fetchImpl: typeof fetch
private readonly clock: () => number
private readonly requestTimeoutMs: number
private readonly expiresAtSlackMs: number
private readonly skipHandshake: boolean
private token: string | null
private tokenExpiresAt: number | null
constructor(opts: AutomationClientOptions) {
this.baseUrl = stripTrailingSlash(opts.baseUrl)
this.fetchImpl = opts.fetch ?? globalThis.fetch
this.clock = opts.clock ?? Date.now
this.requestTimeoutMs = opts.requestTimeoutMs ?? 30_000
this.expiresAtSlackMs = opts.expiresAtSlackMs ?? 60_000
this.skipHandshake = opts.skipHandshake ?? false
this.token = opts.token ?? null
this.tokenExpiresAt = opts.token ? Number.POSITIVE_INFINITY : null
}
// ───────────── Handshake ─────────────
async handshake(): Promise<HandshakeResponse> {
const res = await this.rawFetch('POST', '/automation/v1/handshake', {})
if (!res.ok) {
throw new AutomationHttpError(
'API_UNREACHABLE',
res.status,
`handshake failed: ${res.status} ${res.statusText}`,
res.bodyText
)
}
const body = res.json as HandshakeResponse
if (!body || typeof body.token !== 'string' || typeof body.expiresAt !== 'number') {
throw new AutomationHttpError(
'CONTRACT_ERROR',
res.status,
'handshake response missing token/expiresAt',
res.bodyText
)
}
this.token = body.token
this.tokenExpiresAt = body.expiresAt
return body
}
async ensureToken(): Promise<string> {
if (this.skipHandshake) {
if (!this.token) {
throw new AutomationHttpError(
'AUTH_FAILED',
0,
'skipHandshake set but no token provided'
)
}
return this.token
}
if (this.token && this.tokenExpiresAt !== null) {
if (this.tokenExpiresAt - this.clock() > this.expiresAtSlackMs) {
return this.token
}
}
const res = await this.handshake()
return res.token
}
// ───────────── ContractClient ─────────────
async list(input: ListInput): Promise<{ items: Task[]; nextCursor: string | null }> {
const body = await this.callPost<{
tasks: { task: Task; projection: unknown }[] | Task[]
nextCursor: string | null
}>('list', input)
return {
items: Array.isArray(body.tasks)
? body.tasks.map((t) => ('task' in (t as Record<string, unknown>) ? (t as { task: Task }).task : (t as Task)))
: [],
nextCursor: body.nextCursor ?? null
}
}
async get(id: string): Promise<Task> {
const body = await this.callPost<{ task: Task; projection: unknown } | Task | null>(
'get',
{ id }
)
if (!body) {
throw new AutomationHttpError(
'CONTRACT_ERROR',
404,
`task ${id} not found`
)
}
if ('task' in (body as Record<string, unknown>)) return (body as { task: Task }).task
return body as Task
}
async stats(): Promise<unknown> {
await this.ensureToken()
const res = await this.rawFetch('GET', '/automation/v1/stats')
if (res.status === 401) {
this.token = null
await this.ensureToken()
const retry = await this.rawFetch('GET', '/automation/v1/stats')
if (!retry.ok) {
throw new AutomationHttpError('UNKNOWN', retry.status, 'stats failed', retry.bodyText)
}
return retry.json
}
if (!res.ok) {
throw new AutomationHttpError('UNKNOWN', res.status, 'stats failed', res.bodyText)
}
return res.json
}
async removeFromHistory(id: string): Promise<void> {
await this.callPost<{ ok: true }>('removeFromHistory', { id })
}
// Phase B writers
async add(req: AddTaskRequest): Promise<{ id: string; task: Task }> {
const { id } = await this.callPost<{ id: string }>('add', req)
const task = await this.get(id)
return { id, task }
}
async cancel(id: string): Promise<void> {
await this.callPost<{ ok: true }>('cancel', { id })
}
async pause(id: string, reason?: string): Promise<void> {
await this.callPost<{ ok: true }>('pause', { id, reason })
}
async resume(id: string): Promise<void> {
await this.callPost<{ ok: true }>('resume', { id })
}
async retry(id: string): Promise<void> {
await this.callPost<{ ok: true }>('retry', { id })
}
// ───────────── Internals ─────────────
private async callPost<T>(op: string, body: unknown): Promise<T> {
await this.ensureToken()
const res = await this.rawFetch('POST', `/automation/v1/${op}`, body)
if (res.status === 401) {
this.token = null
await this.ensureToken()
const retry = await this.rawFetch('POST', `/automation/v1/${op}`, body)
if (!retry.ok) {
throw httpErrorOf(retry, op)
}
return retry.json as T
}
if (!res.ok) {
throw httpErrorOf(res, op)
}
return res.json as T
}
private async rawFetch(
method: 'GET' | 'POST',
path: string,
body?: unknown
): Promise<{ ok: boolean; status: number; statusText: string; json: unknown; bodyText: string }> {
const ctrl = new AbortController()
const timer = setTimeout(() => ctrl.abort(), this.requestTimeoutMs)
try {
const headers: Record<string, string> = {
Accept: 'application/json'
}
if (this.token) headers.Authorization = `Bearer ${this.token}`
if (method === 'POST') headers['Content-Type'] = 'application/json'
const init: RequestInit = {
method,
headers,
signal: ctrl.signal
}
if (method === 'POST') init.body = JSON.stringify(body ?? {})
const res = await this.fetchImpl(`${this.baseUrl}${path}`, init)
const text = await res.text()
let json: unknown = null
if (text.length > 0) {
try {
json = JSON.parse(text)
} catch {
/* leave as null; bodyText preserved for diagnostics */
}
}
return {
ok: res.ok,
status: res.status,
statusText: res.statusText,
json,
bodyText: text
}
} catch (err) {
const cause = err instanceof Error ? err.message : String(err)
throw new AutomationHttpError(
'API_UNREACHABLE',
0,
`automation request failed: ${cause}`
)
} finally {
clearTimeout(timer)
}
}
}
function stripTrailingSlash(url: string): string {
return url.endsWith('/') ? url.slice(0, -1) : url
}
function httpErrorOf(
res: { status: number; statusText: string; bodyText: string },
op: string
): AutomationHttpError {
if (res.status === 401 || res.status === 403) {
return new AutomationHttpError(
'AUTH_FAILED',
res.status,
`automation ${op} unauthorized`,
res.bodyText
)
}
if (res.status === 0) {
return new AutomationHttpError(
'API_UNREACHABLE',
res.status,
`automation ${op} unreachable`,
res.bodyText
)
}
return new AutomationHttpError(
'UNKNOWN',
res.status,
`automation ${op} failed: ${res.status} ${res.statusText}`,
res.bodyText
)
}
+130
View File
@@ -0,0 +1,130 @@
/**
* Auto-launch VidBee Desktop in background mode when the descriptor is
* missing or stale. Reference:
* docs/vidbee-desktop-first-cli-ytdlp-rss-design.md §5.4
*
* The Desktop side adds `--background` / `--from-cli` flags (NEX-131
* commit 7de4e20) which keep the app tray-only, no main window. We spawn
* the platform-appropriate launcher and then poll for the descriptor to
* appear.
*/
import { spawn } from 'node:child_process'
import { isPidAlive, readDescriptor, type ResolveDescriptorOptions } from './descriptor'
export interface AutostartOptions {
/** Total wall-clock budget; defaults to 10s per design. */
timeoutMs?: number
/** Polling interval. */
pollIntervalMs?: number
/** Test seam — overrides spawn. */
spawnLauncher?: (cmd: string, args: readonly string[]) => void
/** Test seam — overrides Date.now. */
clock?: () => number
/** Test seam — overrides setTimeout-based wait. */
delay?: (ms: number) => Promise<void>
/** Test seam for descriptor lookup. */
descriptorOptions?: ResolveDescriptorOptions
platform?: NodeJS.Platform
}
export type AutostartResult =
| { kind: 'ready' }
| { kind: 'autostart-disabled' }
| { kind: 'unsupported-platform'; platform: NodeJS.Platform }
| { kind: 'timeout'; waitedMs: number }
| { kind: 'launch-failed'; reason: string }
const defaultDelay = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms))
/**
* Returns immediately when the descriptor already points at a live PID;
* otherwise spawns Desktop in background mode and polls until the
* descriptor reappears or the timeout expires.
*/
export async function ensureDesktopReady(
enabled: boolean,
opts: AutostartOptions = {}
): Promise<AutostartResult> {
const timeoutMs = opts.timeoutMs ?? 10_000
const poll = opts.pollIntervalMs ?? 200
const clock = opts.clock ?? Date.now
const delay = opts.delay ?? defaultDelay
const platform = opts.platform ?? process.platform
// First check: if we already have a fresh descriptor pointing at a live
// pid, no autostart needed.
if (descriptorIsReady(opts.descriptorOptions)) return { kind: 'ready' }
if (!enabled) return { kind: 'autostart-disabled' }
const launchSpec = launcherForPlatform(platform)
if (!launchSpec) return { kind: 'unsupported-platform', platform }
try {
if (opts.spawnLauncher) {
opts.spawnLauncher(launchSpec.cmd, launchSpec.args)
} else {
const child = spawn(launchSpec.cmd, [...launchSpec.args], {
stdio: 'ignore',
detached: true,
windowsHide: true
})
child.unref()
}
} catch (err) {
return {
kind: 'launch-failed',
reason: err instanceof Error ? err.message : String(err)
}
}
const start = clock()
while (clock() - start < timeoutMs) {
if (descriptorIsReady(opts.descriptorOptions)) return { kind: 'ready' }
await delay(poll)
}
return { kind: 'timeout', waitedMs: clock() - start }
}
function descriptorIsReady(descriptorOpts: ResolveDescriptorOptions | undefined): boolean {
const r = readDescriptor(descriptorOpts ?? {})
if (!r.ok) return false
return isPidAlive(r.descriptor.pid)
}
/**
* Per-platform launcher. The Desktop end accepts `--background` /
* `--from-cli`; both forms are forwarded for forward-compat.
*
* Some flow notes:
* - macOS: `open -ga VidBee --args --background --from-cli`
* - Linux: relies on `vidbee-desktop` desktop entry on PATH; the AppImage
* install instructions create a symlink to the AppImage, which we then
* invoke directly with `--background`.
* - Windows: `start "" VidBee --background` via cmd /c so it goes
* through the shell start verb (handles the .lnk in Start Menu).
*
* For Phase B we ship the macOS path as the well-tested reference; Linux
* and Windows commands are best-effort and can be tightened by user QA on
* those platforms.
*/
function launcherForPlatform(
platform: NodeJS.Platform
): { cmd: string; args: readonly string[] } | null {
if (platform === 'darwin') {
return { cmd: 'open', args: ['-ga', 'VidBee', '--args', '--background', '--from-cli'] }
}
if (platform === 'win32') {
return {
cmd: 'cmd',
args: ['/c', 'start', '""', 'VidBee', '--background', '--from-cli']
}
}
if (platform === 'linux') {
return { cmd: 'vidbee-desktop', args: ['--background', '--from-cli'] }
}
return null
}
+152
View File
@@ -0,0 +1,152 @@
/**
* Resolves a `ContractClient` according to CLI flags. Reference:
* docs/vidbee-desktop-first-cli-ytdlp-rss-design.md §3, §4.2, §5
*
* Three transports:
* - local: `--vidbee-local` → in-process TaskQueueAPI (createLocalClient)
* - api: `--vidbee-api <url>` → AutomationClient against that base
* - desktop: default → read descriptor → autostart if needed → handshake
*
* Each path can fail gracefully and returns an `ErrorEnvelope` instead of
* throwing, so the runtime can map to the right exit code.
*/
import { errorEnvelope, type ErrorEnvelope } from '../envelope'
import type { Flags } from '../parser'
import type { ContractClient } from '../subcommands'
import { AutomationClient } from './automation-client'
import { ensureDesktopReady, type AutostartResult } from './autostart'
import { isPidAlive, readDescriptor } from './descriptor'
import { createLocalClient, type LocalClientHandle } from './local-client'
export interface ConnectOptions {
flags: Flags
/** Test seam — overrides AutomationClient instantiation. */
buildAutomationClient?: (
baseUrl: string,
token: string | null
) => ContractClient
/** Test seam — overrides descriptor read. */
readDescriptorImpl?: typeof readDescriptor
/** Test seam — overrides autostart. */
ensureDesktopReadyImpl?: typeof ensureDesktopReady
/** Test seam — overrides createLocalClient. */
createLocalClientImpl?: typeof createLocalClient
}
export type ConnectResult =
| { kind: 'connected'; client: ContractClient; teardown?: () => Promise<void> }
| { kind: 'error'; envelope: ErrorEnvelope }
export async function connect(opts: ConnectOptions): Promise<ConnectResult> {
const { flags } = opts
if (flags.local || flags.target === 'local') {
const factory = opts.createLocalClientImpl ?? createLocalClient
try {
const handle: LocalClientHandle = await factory({})
return { kind: 'connected', client: handle, teardown: handle.shutdown }
} catch (err) {
return {
kind: 'error',
envelope: errorEnvelope(
'NOT_IMPLEMENTED',
`--vidbee-local failed to start: ${err instanceof Error ? err.message : err}`
)
}
}
}
if (flags.api !== undefined || flags.target === 'api') {
const url = flags.api
if (!url) {
return {
kind: 'error',
envelope: errorEnvelope(
'API_UNREACHABLE',
'--vidbee-target api requires --vidbee-api <url>'
)
}
}
const builder = opts.buildAutomationClient ?? defaultAutomationBuilder
const client = builder(url, flags.token ?? null)
return { kind: 'connected', client }
}
// Desktop default path
return await connectDesktop(opts)
}
async function connectDesktop(opts: ConnectOptions): Promise<ConnectResult> {
const { flags } = opts
const readImpl = opts.readDescriptorImpl ?? readDescriptor
const ensureImpl = opts.ensureDesktopReadyImpl ?? ensureDesktopReady
let descriptor = readImpl({})
let needsAutostart = false
if (!descriptor.ok) {
needsAutostart = true
} else if (!isPidAlive(descriptor.descriptor.pid)) {
needsAutostart = true
}
if (needsAutostart) {
const result: AutostartResult = await ensureImpl(!flags.noAutostart, {
...(flags.timeoutMs !== undefined ? { timeoutMs: flags.timeoutMs } : {})
})
if (result.kind === 'autostart-disabled') {
return {
kind: 'error',
envelope: errorEnvelope(
'DESKTOP_NOT_READY',
'Desktop is not running and --vidbee-no-autostart was set'
)
}
}
if (result.kind === 'unsupported-platform') {
return {
kind: 'error',
envelope: errorEnvelope(
'DESKTOP_NOT_READY',
`autostart not supported on platform ${result.platform}; pass --vidbee-api or run Desktop manually`
)
}
}
if (result.kind === 'launch-failed') {
return {
kind: 'error',
envelope: errorEnvelope(
'DESKTOP_NOT_READY',
`failed to launch Desktop: ${result.reason}`
)
}
}
if (result.kind === 'timeout') {
return {
kind: 'error',
envelope: errorEnvelope(
'DESKTOP_NOT_READY',
`Desktop did not become ready within ${result.waitedMs}ms`
)
}
}
descriptor = readImpl({})
if (!descriptor.ok) return { kind: 'error', envelope: descriptor.envelope }
}
if (!descriptor.ok) return { kind: 'error', envelope: descriptor.envelope }
const baseUrl = `http://${descriptor.descriptor.host}:${descriptor.descriptor.port}`
const builder = opts.buildAutomationClient ?? defaultAutomationBuilder
const client = builder(baseUrl, flags.token ?? null)
return { kind: 'connected', client }
}
function defaultAutomationBuilder(
baseUrl: string,
token: string | null
): ContractClient {
if (token) {
return new AutomationClient({ baseUrl, token, skipHandshake: true })
}
return new AutomationClient({ baseUrl })
}
+173
View File
@@ -0,0 +1,173 @@
/**
* Reads (and validates) the Desktop automation descriptor. Reference:
* docs/vidbee-desktop-first-cli-ytdlp-rss-design.md §5.2 / §5.3
*
* The descriptor lives at a per-platform path and is overrideable via
* `VIDBEE_AUTOMATION_DESCRIPTOR=/path`. It carries `tokenHash` only, never
* the plaintext token; the CLI obtains the plaintext via `handshake`.
*/
import { existsSync, readFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { errorEnvelope, type ErrorEnvelope } from '../envelope'
export interface DescriptorPayload {
version: 1
schemaVersion: string
kind: 'desktop'
host: string
port: number
tokenHash: string | null
tokenIssuedAt: number | null
tokenExpiresAt: number | null
pid: number
pidStartedAt: number
updatedAt: number
appVersion: string
}
export interface ResolveDescriptorOptions {
/** Test seam — overrides VIDBEE_AUTOMATION_DESCRIPTOR resolution. */
pathOverride?: string
envOverride?: string | null
platform?: NodeJS.Platform
homedir?: () => string
env?: NodeJS.ProcessEnv
/** Test seam — defaults to fs.existsSync. */
exists?: (p: string) => boolean
/** Test seam — defaults to fs.readFileSync(utf-8). */
readFile?: (p: string) => string
}
const DIRNAME = 'VidBee'
const DESCRIPTOR_FILE = 'automation.json'
export function resolveDescriptorPath(
opts: ResolveDescriptorOptions = {}
): string {
if (opts.pathOverride) return opts.pathOverride
const env = opts.env ?? process.env
const envOverride =
opts.envOverride !== undefined
? opts.envOverride
: env.VIDBEE_AUTOMATION_DESCRIPTOR?.trim()
if (envOverride && envOverride.length > 0) return envOverride
const platform = opts.platform ?? process.platform
const home = (opts.homedir ?? homedir)()
if (platform === 'darwin') {
return join(home, 'Library', 'Application Support', DIRNAME, DESCRIPTOR_FILE)
}
if (platform === 'win32') {
const appdata = env.APPDATA?.trim()
const base = appdata && appdata.length > 0 ? appdata : join(home, 'AppData', 'Roaming')
return join(base, DIRNAME, DESCRIPTOR_FILE)
}
// Linux + others
const xdg = env.XDG_CONFIG_HOME?.trim()
const base = xdg && xdg.length > 0 ? xdg : join(home, '.config')
return join(base, DIRNAME, DESCRIPTOR_FILE)
}
export type ReadDescriptorResult =
| { ok: true; descriptor: DescriptorPayload; path: string }
| { ok: false; envelope: ErrorEnvelope; path: string }
export function readDescriptor(
opts: ResolveDescriptorOptions = {}
): ReadDescriptorResult {
const path = resolveDescriptorPath(opts)
const exists = opts.exists ?? existsSync
const read = opts.readFile ?? ((p: string) => readFileSync(p, 'utf-8'))
if (!exists(path)) {
return {
ok: false,
path,
envelope: errorEnvelope(
'DESKTOP_NOT_READY',
`automation descriptor not found at ${path}`,
{ path }
)
}
}
let parsed: unknown
try {
parsed = JSON.parse(read(path))
} catch (err) {
return {
ok: false,
path,
envelope: errorEnvelope(
'DESKTOP_NOT_READY',
`automation descriptor is malformed: ${err instanceof Error ? err.message : err}`,
{ path }
)
}
}
const v = validateDescriptor(parsed)
if (!v.ok) {
return {
ok: false,
path,
envelope: errorEnvelope('DESKTOP_NOT_READY', v.reason, { path })
}
}
return { ok: true, descriptor: v.value, path }
}
function validateDescriptor(
value: unknown
):
| { ok: true; value: DescriptorPayload }
| { ok: false; reason: string } {
if (typeof value !== 'object' || value === null) {
return { ok: false, reason: 'descriptor is not a JSON object' }
}
const v = value as Record<string, unknown>
if (v.version !== 1) {
return { ok: false, reason: `unsupported descriptor version: ${String(v.version)}` }
}
if (typeof v.host !== 'string' || typeof v.port !== 'number') {
return { ok: false, reason: 'descriptor missing host/port' }
}
if (typeof v.pid !== 'number' || typeof v.pidStartedAt !== 'number') {
return { ok: false, reason: 'descriptor missing pid/pidStartedAt' }
}
return {
ok: true,
value: {
version: 1,
schemaVersion: typeof v.schemaVersion === 'string' ? v.schemaVersion : '1.0.0',
kind: 'desktop',
host: v.host,
port: v.port,
tokenHash: typeof v.tokenHash === 'string' ? v.tokenHash : null,
tokenIssuedAt: typeof v.tokenIssuedAt === 'number' ? v.tokenIssuedAt : null,
tokenExpiresAt: typeof v.tokenExpiresAt === 'number' ? v.tokenExpiresAt : null,
pid: v.pid,
pidStartedAt: v.pidStartedAt,
updatedAt: typeof v.updatedAt === 'number' ? v.updatedAt : 0,
appVersion: typeof v.appVersion === 'string' ? v.appVersion : '0.0.0'
}
}
}
/**
* §5.2: detect a stale descriptor by checking whether the recorded pid is
* still alive. We use `process.kill(pid, 0)` (no signal sent — only checks
* the OS-level existence). Errors map to "stale", which is the correct
* conservative answer.
*/
export function isPidAlive(
pid: number,
killProbe: (pid: number, signal: 0) => void = (p, s) => process.kill(p, s)
): boolean {
try {
killProbe(pid, 0)
return true
} catch {
return false
}
}
+101
View File
@@ -0,0 +1,101 @@
/**
* Transport selection for the CLI. Reference:
* docs/vidbee-desktop-first-cli-ytdlp-rss-design.md §3, §5, §8
*
* Three transports exist:
* - desktop loopback (default) — descriptor + handshake; Phase B
* - remote api (--vidbee-api) — HTTPS unless host is loopback / private
* - local (--vidbee-local) — in-process TaskQueueAPI for CI / headless
*
* Phase A delivers `local` only; the other two surface a clear
* `DESKTOP_NOT_READY` / `API_UNREACHABLE` error pointing at NEX-131.
*/
import type { Flags } from '../parser'
import type { ContractClient } from '../subcommands'
import { ExitCode, errorEnvelope, type ErrorEnvelope } from '../envelope'
export interface TransportSelection {
kind: 'local' | 'desktop' | 'api'
/** Filled by the caller when actually connected. */
client?: ContractClient
}
export function selectTransport(flags: Flags): TransportSelection {
if (flags.local || flags.target === 'local') return { kind: 'local' }
if (flags.api !== undefined || flags.target === 'api') return { kind: 'api' }
return { kind: 'desktop' }
}
/**
* §8.1: plaintext HTTP only allowed when host is loopback or private.
* Returns null when ok, an ErrorEnvelope when the URL fails the policy.
*/
export function validateApiUrl(url: string): ErrorEnvelope | null {
let parsed: URL
try {
parsed = new URL(url)
} catch {
return errorEnvelope('API_UNREACHABLE', `invalid --vidbee-api URL: ${url}`)
}
if (parsed.protocol === 'https:') return null
if (parsed.protocol !== 'http:') {
return errorEnvelope(
'API_UNREACHABLE',
`unsupported scheme for --vidbee-api: ${parsed.protocol}`
)
}
if (isLoopbackOrPrivateHost(parsed.hostname)) return null
return errorEnvelope(
'API_UNREACHABLE',
`plaintext HTTP --vidbee-api is only allowed for loopback / private hosts; got ${parsed.hostname}`,
{ url }
)
}
export function isLoopbackOrPrivateHost(host: string): boolean {
const lower = host.toLowerCase()
if (lower === 'localhost') return true
if (lower === '::1' || lower === '[::1]') return true
// ipv4 octet form
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(lower)
if (m) {
const a = Number(m[1])
const b = Number(m[2])
if (a === 127) return true
if (a === 10) return true
if (a === 192 && b === 168) return true
if (a === 172 && b >= 16 && b <= 31) return true
if (a === 169 && b === 254) return true
return false
}
// *.local (mDNS) is treated as private
if (lower.endsWith('.local')) return true
return false
}
/**
* Build the right error envelope when a Phase-B-only transport is asked
* for. The CLI shouldn't crash — it should print a coherent JSON envelope
* and exit 3.
*/
export function transportNotReady(
selection: TransportSelection
): { envelope: ErrorEnvelope; exitCode: typeof ExitCode.HOST_UNREACHABLE } {
if (selection.kind === 'desktop') {
return {
envelope: errorEnvelope(
'DESKTOP_NOT_READY',
'desktop loopback transport is not yet wired; tracked in NEX-131 A1.'
),
exitCode: ExitCode.HOST_UNREACHABLE
}
}
return {
envelope: errorEnvelope(
'API_UNREACHABLE',
'remote --vidbee-api transport is not yet wired; tracked in NEX-131 A2.'
),
exitCode: ExitCode.HOST_UNREACHABLE
}
}
+191
View File
@@ -0,0 +1,191 @@
/**
* `--vidbee-local` in-process transport. Reference:
* docs/vidbee-desktop-first-cli-ytdlp-rss-design.md §3, §4.1, §10
*
* Instantiates a TaskQueueAPI with a YtDlpExecutor in the calling process
* — no Desktop, no API server. Used for CI / Docker / `npx @vidbee/cli`
* and for the three-host equivalence test (`packages/task-queue/__integration__/three-host-equivalence.test.ts`).
*
* The persistence layer can be swapped between Memory and Sqlite via
* options. The default uses an in-memory adapter so the CLI exits
* cleanly without leftover SQLite files; pass `persist: 'sqlite'` to opt
* into crash-recovery.
*
* Imports of `@vidbee/task-queue` and `@vidbee/downloader-core` are kept
* as dynamic imports so the parser/probe path can run without those
* runtime dependencies resolved (e.g. an `npx @vidbee/cli` install that
* skipped optional yt-dlp deps).
*/
import { mkdtempSync, rmSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir, homedir } from 'node:os'
import { join } from 'node:path'
import type {
AddTaskRequest,
Executor,
Task,
TaskQueueAPI,
TaskStatus
} from '@vidbee/task-queue'
import type { ContractClient, ListInput } from '../subcommands'
const require = createRequire(import.meta.url)
export interface LocalClientOptions {
/** Default download directory. Defaults to `~/Downloads/VidBee`. */
defaultDownloadDir?: string
/** Persistence: in-memory (default) or sqlite (for crash-recovery tests). */
persist?: 'memory' | 'sqlite'
/** Sqlite path; default is a per-process temp file under `os.tmpdir()`. */
sqlitePath?: string
/** yt-dlp binary path (forwarded to YtDlpExecutor). */
ytDlpPath?: string
/** ffmpeg directory. */
ffmpegLocation?: string
/** Concurrency knob. */
maxConcurrency?: number
/**
* Test seam — supply a pre-built Executor (e.g. a scripted fake from the
* three-host equivalence harness). When set, the YtDlpExecutor is not
* instantiated and `ytDlpPath` / `ffmpegLocation` are ignored.
*/
executor?: Executor
/** Test seam — disable kernel `processing → completed` fs guard. */
filePresent?: (path: string) => boolean
/** Test seam — deterministic backoff jitter for tests. */
rng?: () => number
}
export interface LocalClientHandle extends ContractClient {
api: TaskQueueAPI
/** Tear down (stop API, close persist, remove temp files). */
shutdown: () => Promise<void>
}
/**
* Create the in-process client. Returns an object usable as both a
* ContractClient and a teardown handle.
*/
export async function createLocalClient(
opts: LocalClientOptions = {}
): Promise<LocalClientHandle> {
const taskQueue = await import('@vidbee/task-queue')
const downloaderCore = await import('@vidbee/downloader-core')
const tqDb = await import('@vidbee/db/task-queue')
const defaultDir =
opts.defaultDownloadDir ?? join(homedir(), 'Downloads', 'VidBee')
let tempDir: string | null = null
let sqliteDb: { close: () => void } | null = null
let persistAdapter: InstanceType<typeof taskQueue.MemoryPersistAdapter> | InstanceType<typeof taskQueue.SqlitePersistAdapter>
if ((opts.persist ?? 'memory') === 'sqlite') {
tempDir = mkdtempSync(join(tmpdir(), 'vidbee-cli-'))
const dbPath = opts.sqlitePath ?? join(tempDir, 'task-queue.db')
const Database = require('better-sqlite3') as typeof import('better-sqlite3')
const db = new Database(dbPath, { timeout: 5000 }) as unknown as {
exec: (sql: string) => void
prepare: (sql: string) => unknown
pragma: (sql: string, opts?: { simple?: boolean }) => unknown
transaction: (fn: (...args: unknown[]) => unknown) => unknown
close: () => void
}
db.exec(tqDb.TASK_QUEUE_DDL_V1)
sqliteDb = db
persistAdapter = new taskQueue.SqlitePersistAdapter({
db: db as unknown as ConstructorParameters<typeof taskQueue.SqlitePersistAdapter>[0]['db']
})
} else {
persistAdapter = new taskQueue.MemoryPersistAdapter()
}
const executor =
opts.executor ??
new downloaderCore.YtDlpExecutor({
resolveYtDlpPath: () => opts.ytDlpPath ?? process.env.YTDLP_PATH ?? 'yt-dlp',
resolveFfmpegLocation: () => opts.ffmpegLocation ?? process.env.FFMPEG_PATH,
defaultDownloadDir: defaultDir
})
const api = new taskQueue.TaskQueueAPI({
persist: persistAdapter,
executor,
maxConcurrency: opts.maxConcurrency ?? 4,
...(opts.filePresent !== undefined ? { filePresent: opts.filePresent } : {}),
...(opts.rng !== undefined ? { rng: opts.rng } : {})
})
await api.start()
let shuttingDown = false
const shutdown = async () => {
if (shuttingDown) return
shuttingDown = true
try {
await api.stop()
} catch {
/* noop */
}
if (sqliteDb) {
try {
sqliteDb.close()
} catch {
/* noop */
}
}
if (tempDir) {
try {
rmSync(tempDir, { recursive: true, force: true })
} catch {
/* noop */
}
}
}
const list = async (input: ListInput) => {
const opts: { status?: TaskStatus; groupKey?: string; parentId?: string; limit?: number; cursor: string | null } = {
cursor: input.cursor ?? null
}
if (input.status !== undefined) opts.status = input.status
if (input.groupKey !== undefined) opts.groupKey = input.groupKey
if (input.parentId !== undefined) opts.parentId = input.parentId
if (input.limit !== undefined) opts.limit = input.limit
const page = api.list(opts)
return { items: [...page.tasks] as Task[], nextCursor: page.nextCursor ?? null }
}
return {
api,
shutdown,
list,
get: async (id) => {
const task = api.get(id)
if (!task) throw new Error(`task ${id} not found`)
return task
},
stats: async () => api.stats(),
removeFromHistory: async (id) => {
await api.removeFromHistory(id)
},
add: async (req: AddTaskRequest) => {
const { id } = await api.add(req)
const task = api.get(id)
if (!task) throw new Error(`add() did not produce a task with id ${id}`)
return { id, task }
},
cancel: async (id) => {
await api.cancel(id)
},
pause: async (id, reason) => {
await api.pause(id, reason)
},
resume: async (id) => {
await api.resume(id)
},
retry: async (id) => {
await api.retryManual(id)
}
}
}
+117
View File
@@ -0,0 +1,117 @@
import { describe, expect, it } from 'vitest'
import { AutomationClient } from '../src/transport/automation-client'
interface FakeResponse {
status: number
body: unknown
}
function fakeFetch(routes: Record<string, FakeResponse | ((init: RequestInit) => FakeResponse)>): typeof fetch {
const calls: { url: string; init: RequestInit }[] = []
const f = (async (url: RequestInfo | URL, init: RequestInit = {}) => {
const u = String(url)
calls.push({ url: u, init })
const path = new URL(u).pathname
const route = routes[path]
const r = typeof route === 'function' ? route(init) : route
if (!r) {
return new Response('not found', { status: 404 })
}
const body = typeof r.body === 'string' ? r.body : JSON.stringify(r.body)
return new Response(body, {
status: r.status,
headers: { 'content-type': 'application/json' }
})
}) as typeof fetch
;(f as unknown as { calls: typeof calls }).calls = calls
return f
}
describe('AutomationClient handshake', () => {
it('handshakes once then reuses token', async () => {
let handshakes = 0
const fetchImpl = fakeFetch({
'/automation/v1/handshake': () => {
handshakes += 1
return {
status: 200,
body: { token: 'tok', expiresAt: Date.now() + 600_000, ttlMs: 600_000, schemaVersion: '1.0.0' }
}
},
'/automation/v1/stats': { status: 200, body: { running: 0 } }
})
const c = new AutomationClient({ baseUrl: 'http://127.0.0.1:27100', fetch: fetchImpl })
await c.stats()
await c.stats()
expect(handshakes).toBe(1)
})
it('re-handshakes on 401', async () => {
let handshakes = 0
let nextStats: 401 | 200 = 401
const fetchImpl = fakeFetch({
'/automation/v1/handshake': () => {
handshakes += 1
return { status: 200, body: { token: 't', expiresAt: Date.now() + 600_000, ttlMs: 600_000 } }
},
'/automation/v1/stats': () => {
if (nextStats === 401) {
nextStats = 200
return { status: 401, body: { error: 'unauthorized' } }
}
return { status: 200, body: { ok: true } }
}
})
const c = new AutomationClient({ baseUrl: 'http://127.0.0.1:27100', fetch: fetchImpl })
const result = await c.stats()
expect(result).toEqual({ ok: true })
expect(handshakes).toBe(2) // initial + re-handshake on 401
})
it('skipHandshake uses provided token', async () => {
const fetchImpl = fakeFetch({
'/automation/v1/stats': (init) => {
const auth = (init.headers as Record<string, string>).Authorization
return { status: auth === 'Bearer t' ? 200 : 401, body: { auth } }
}
})
const c = new AutomationClient({
baseUrl: 'http://127.0.0.1:27100',
fetch: fetchImpl,
token: 't',
skipHandshake: true
})
const r = await c.stats()
expect(r).toEqual({ auth: 'Bearer t' })
})
it('list maps {tasks} → {items}', async () => {
const fetchImpl = fakeFetch({
'/automation/v1/handshake': { status: 200, body: { token: 't', expiresAt: Date.now() + 600_000, ttlMs: 600_000 } },
'/automation/v1/list': {
status: 200,
body: {
tasks: [{ task: { id: 'a', status: 'queued' }, projection: {} }],
nextCursor: null
}
}
})
const c = new AutomationClient({ baseUrl: 'http://127.0.0.1:27100', fetch: fetchImpl })
const r = await c.list({})
expect(r.items).toHaveLength(1)
expect((r.items[0] as { id: string }).id).toBe('a')
})
it('add() chases get() to assemble {id, task}', async () => {
const fetchImpl = fakeFetch({
'/automation/v1/handshake': { status: 200, body: { token: 't', expiresAt: Date.now() + 600_000, ttlMs: 600_000 } },
'/automation/v1/add': { status: 200, body: { id: 'id1' } },
'/automation/v1/get': { status: 200, body: { task: { id: 'id1', status: 'queued' }, projection: {} } }
})
const c = new AutomationClient({ baseUrl: 'http://127.0.0.1:27100', fetch: fetchImpl })
const r = await c.add({ input: { url: 'https://x', kind: 'video' } })
expect(r.id).toBe('id1')
expect((r.task as { status: string }).status).toBe('queued')
})
})
+117
View File
@@ -0,0 +1,117 @@
import { describe, expect, it } from 'vitest'
import { ensureDesktopReady } from '../src/transport/autostart'
describe('ensureDesktopReady (§5.4)', () => {
it('returns ready when descriptor already points at live pid', async () => {
let attempts = 0
const r = await ensureDesktopReady(true, {
descriptorOptions: {
pathOverride: '/x',
exists: () => true,
readFile: () => {
attempts += 1
return JSON.stringify({
version: 1,
host: '127.0.0.1',
port: 27_100,
pid: process.pid, // live pid for this process
pidStartedAt: 0,
schemaVersion: '1.0.0',
kind: 'desktop',
tokenHash: null,
tokenIssuedAt: null,
tokenExpiresAt: null,
updatedAt: 0,
appVersion: '1.0.0'
})
}
}
})
expect(r.kind).toBe('ready')
expect(attempts).toBe(1) // no autostart polling needed
})
it('returns autostart-disabled when enabled=false and no descriptor', async () => {
const r = await ensureDesktopReady(false, {
descriptorOptions: { pathOverride: '/x', exists: () => false }
})
expect(r.kind).toBe('autostart-disabled')
})
it('spawns launcher and returns ready when descriptor appears', async () => {
let calls = 0
let descriptorPresent = false
let spawned = 0
const r = await ensureDesktopReady(true, {
timeoutMs: 1_000,
pollIntervalMs: 5,
delay: async () => {},
spawnLauncher: () => {
spawned += 1
descriptorPresent = true
},
descriptorOptions: {
pathOverride: '/x',
exists: () => descriptorPresent,
readFile: () => {
calls += 1
return JSON.stringify({
version: 1,
host: '127.0.0.1',
port: 27_100,
pid: process.pid,
pidStartedAt: 0,
schemaVersion: '1.0.0',
kind: 'desktop',
tokenHash: null,
tokenIssuedAt: null,
tokenExpiresAt: null,
updatedAt: 0,
appVersion: '1.0.0'
})
}
},
platform: 'darwin'
})
expect(r.kind).toBe('ready')
expect(spawned).toBe(1)
expect(calls).toBeGreaterThanOrEqual(1)
})
it('returns timeout when descriptor never appears', async () => {
let now = 0
const r = await ensureDesktopReady(true, {
timeoutMs: 100,
pollIntervalMs: 10,
clock: () => now,
delay: async (ms) => {
now += ms
},
spawnLauncher: () => {},
descriptorOptions: { pathOverride: '/x', exists: () => false },
platform: 'darwin'
})
expect(r.kind).toBe('timeout')
})
it('returns unsupported-platform on freebsd', async () => {
const r = await ensureDesktopReady(true, {
descriptorOptions: { pathOverride: '/x', exists: () => false },
platform: 'freebsd'
})
expect(r.kind).toBe('unsupported-platform')
})
it('returns launch-failed when spawnLauncher throws', async () => {
const r = await ensureDesktopReady(true, {
descriptorOptions: { pathOverride: '/x', exists: () => false },
platform: 'darwin',
spawnLauncher: () => {
throw new Error('ENOENT VidBee')
}
})
if (r.kind !== 'launch-failed') throw new Error('expected launch-failed')
expect(r.reason).toMatch(/VidBee/)
})
})
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest'
import { buildForwardedInput } from '../src/download/build-input'
import type { Flags } from '../src/parser'
const baseFlags: Flags = {
local: false,
json: true,
pretty: false,
wait: false,
detach: false,
noRetry: false,
noAutostart: false
}
describe('buildForwardedInput (§6.2)', () => {
it('preserves rawArgs and emits sanitizedArgs in options', () => {
const r = buildForwardedInput({
argv: ['--password', 'shh', '-f', 'best', 'https://x'],
flags: baseFlags
})
expect(r.input.kind).toBe('yt-dlp-forward')
expect(r.input.rawArgs).toEqual(['--password', 'shh', '-f', 'best', 'https://x'])
const opts = r.input.options as { sanitizedArgs: string[] }
expect(opts.sanitizedArgs[1]).toBe('<redacted>')
expect(r.redacted).toBe(true)
})
it('extracts URL from argv (last bare URL positional)', () => {
const r = buildForwardedInput({
argv: ['-f', 'best', 'https://a.com', 'https://b.com'],
flags: baseFlags
})
expect(r.input.url).toBe('https://b.com')
})
it('does not pick up URL-shaped value of a value-consuming flag', () => {
const r = buildForwardedInput({
argv: ['--cookies', '/tmp/c.txt', 'https://x'],
flags: baseFlags
})
expect(r.input.url).toBe('https://x')
})
it('parses output hints from -o / --paths / -o -', () => {
const r1 = buildForwardedInput({
argv: ['-j', '-o', '-', 'https://x'],
flags: baseFlags
})
expect((r1.input.options as { outputHints: { stdoutMode: boolean } }).outputHints.stdoutMode).toBe(
true
)
const r2 = buildForwardedInput({
argv: ['-o', 'video.mp4', 'https://x'],
flags: baseFlags
})
expect(
(r2.input.options as { outputHints: { outputTemplate: string } }).outputHints.outputTemplate
).toBe('video.mp4')
const r3 = buildForwardedInput({
argv: ['-P', '/tmp', 'https://x'],
flags: baseFlags
})
expect((r3.input.options as { outputHints: { paths: string[] } }).outputHints.paths).toEqual([
'/tmp'
])
})
it('packs vidbee.* metadata when flags are set', () => {
const r = buildForwardedInput({
argv: ['https://x'],
flags: { ...baseFlags, wait: true, priority: 'subscription', maxAttempts: 2 }
})
expect((r.input.options as { vidbee: { wait: boolean; priority: string; maxAttempts: number } }).vidbee).toEqual({
wait: true,
priority: 'subscription',
maxAttempts: 2
})
})
it('quotes whitespace tokens in command preview', () => {
const r = buildForwardedInput({
argv: ['-o', 'has space.mp4', 'https://x'],
flags: baseFlags
})
expect(r.commandPreview).toContain("'has space.mp4'")
})
})
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest'
import { connect } from '../src/transport/connect'
describe('connect (transport selection)', () => {
const baseFlags = {
local: false,
json: true,
pretty: false,
wait: false,
detach: false,
noRetry: false,
noAutostart: false
}
it('returns local client when --vidbee-local', async () => {
const fakeClient = {
list: async () => ({ items: [], nextCursor: null }),
get: async () => {
throw new Error('not used')
},
stats: async () => ({}),
removeFromHistory: async () => {},
shutdown: async () => {}
}
const r = await connect({
flags: { ...baseFlags, local: true },
createLocalClientImpl: async () => fakeClient as never
})
if (r.kind !== 'connected') throw new Error('expected connected')
expect(r.client.stats).toBeDefined()
expect(r.teardown).toBeDefined()
})
it('reports DESKTOP_NOT_READY when descriptor missing and autostart disabled', async () => {
const r = await connect({
flags: { ...baseFlags, noAutostart: true },
readDescriptorImpl: () => ({ ok: false, path: '/x', envelope: { ok: false, code: 'DESKTOP_NOT_READY', message: 'missing' } }),
ensureDesktopReadyImpl: async () => ({ kind: 'autostart-disabled' })
})
if (r.kind !== 'error') throw new Error('expected error')
expect(r.envelope.code).toBe('DESKTOP_NOT_READY')
})
it('uses descriptor + automation client when ready', async () => {
const r = await connect({
flags: { ...baseFlags },
readDescriptorImpl: () => ({
ok: true,
path: '/x',
descriptor: {
version: 1,
schemaVersion: '1.0.0',
kind: 'desktop',
host: '127.0.0.1',
port: 27_100,
tokenHash: null,
tokenIssuedAt: null,
tokenExpiresAt: null,
pid: process.pid,
pidStartedAt: 0,
updatedAt: 0,
appVersion: '1.0.0'
}
}),
buildAutomationClient: (baseUrl) => {
return {
list: async () => ({ items: [], nextCursor: null }),
get: async () => {
throw new Error('not used')
},
stats: async () => ({ baseUrl }),
removeFromHistory: async () => {}
}
}
})
if (r.kind !== 'connected') throw new Error('expected connected')
expect(await r.client.stats()).toEqual({ baseUrl: 'http://127.0.0.1:27100' })
})
it('reports API_UNREACHABLE when --vidbee-target api with no url', async () => {
const r = await connect({ flags: { ...baseFlags, target: 'api' } })
if (r.kind !== 'error') throw new Error('expected error')
expect(r.envelope.code).toBe('API_UNREACHABLE')
})
})
+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest'
import { isPidAlive, readDescriptor, resolveDescriptorPath } from '../src/transport/descriptor'
describe('resolveDescriptorPath (§5.2)', () => {
it('honors VIDBEE_AUTOMATION_DESCRIPTOR override', () => {
const p = resolveDescriptorPath({ envOverride: '/tmp/custom.json' })
expect(p).toBe('/tmp/custom.json')
})
it('falls back to per-platform paths', () => {
expect(
resolveDescriptorPath({
platform: 'darwin',
homedir: () => '/Users/u',
envOverride: null,
env: {}
})
).toBe('/Users/u/Library/Application Support/VidBee/automation.json')
expect(
resolveDescriptorPath({
platform: 'linux',
homedir: () => '/home/u',
envOverride: null,
env: {}
})
).toBe('/home/u/.config/VidBee/automation.json')
expect(
resolveDescriptorPath({
platform: 'win32',
homedir: () => 'C:\\Users\\u',
envOverride: null,
env: { APPDATA: 'C:\\Users\\u\\AppData\\Roaming' }
})
).toContain('VidBee')
})
it('XDG_CONFIG_HOME overrides ~/.config on linux', () => {
expect(
resolveDescriptorPath({
platform: 'linux',
homedir: () => '/home/u',
envOverride: null,
env: { XDG_CONFIG_HOME: '/cfg' }
})
).toBe('/cfg/VidBee/automation.json')
})
})
describe('readDescriptor', () => {
it('returns DESKTOP_NOT_READY when missing', () => {
const r = readDescriptor({ pathOverride: '/missing/path', exists: () => false })
if (r.ok) throw new Error('expected error')
expect(r.envelope.code).toBe('DESKTOP_NOT_READY')
})
it('returns DESKTOP_NOT_READY when malformed', () => {
const r = readDescriptor({
pathOverride: '/x',
exists: () => true,
readFile: () => 'not json'
})
if (r.ok) throw new Error('expected error')
expect(r.envelope.message).toMatch(/malformed/)
})
it('parses a valid descriptor', () => {
const payload = {
version: 1,
schemaVersion: '1.0.0',
kind: 'desktop',
host: '127.0.0.1',
port: 27_100,
tokenHash: 'sha256:abc',
tokenIssuedAt: 1,
tokenExpiresAt: 2,
pid: 12,
pidStartedAt: 100,
updatedAt: 100,
appVersion: '1.0.0'
}
const r = readDescriptor({
pathOverride: '/x',
exists: () => true,
readFile: () => JSON.stringify(payload)
})
if (!r.ok) throw new Error('expected ok')
expect(r.descriptor.host).toBe('127.0.0.1')
expect(r.descriptor.port).toBe(27_100)
expect(r.descriptor.tokenHash).toBe('sha256:abc')
})
it('rejects future schema versions', () => {
const r = readDescriptor({
pathOverride: '/x',
exists: () => true,
readFile: () => JSON.stringify({ version: 99 })
})
if (r.ok) throw new Error('expected error')
expect(r.envelope.message).toMatch(/version/)
})
})
describe('isPidAlive', () => {
it('returns true when probe succeeds', () => {
expect(isPidAlive(123, () => undefined)).toBe(true)
})
it('returns false when probe throws', () => {
expect(isPidAlive(123, () => {
throw new Error('ESRCH')
})).toBe(false)
})
})
+110
View File
@@ -0,0 +1,110 @@
import { describe, expect, it } from 'vitest'
import { enqueueDownload } from '../src/download/enqueue'
import type { ContractClient } from '../src/subcommands'
import type { Task } from '@vidbee/task-queue'
import { EMPTY_PROGRESS } from '@vidbee/task-queue'
function task(id: string, status: Task['status']): Task {
return {
id,
kind: 'yt-dlp-forward',
parentId: null,
input: { url: 'https://x', kind: 'yt-dlp-forward' },
priority: 0,
groupKey: 'x',
status,
prevStatus: null,
statusReason: null,
enteredStatusAt: 0,
attempt: 0,
maxAttempts: 5,
nextRetryAt: null,
progress: { ...EMPTY_PROGRESS },
output: null,
lastError: null,
pid: null,
pidStartedAt: null,
createdAt: 0,
updatedAt: 0
}
}
function client(initial: Task, transitions: Task[] = []): ContractClient {
let i = 0
return {
list: async () => ({ items: [], nextCursor: null }),
get: async () => transitions[Math.min(i++, transitions.length - 1)] ?? initial,
stats: async () => ({}),
removeFromHistory: async () => {},
add: async () => ({ id: initial.id, task: initial })
}
}
describe('enqueueDownload', () => {
it('detached returns immediately', async () => {
const t = task('a', 'queued')
const r = await enqueueDownload({
client: client(t),
request: { input: { url: 'https://x', kind: 'yt-dlp-forward' } },
wait: false
})
expect(r.kind).toBe('detached')
if (r.kind === 'detached') expect(r.task.id).toBe('a')
})
it('wait resolves to wait-success on completed', async () => {
const t0 = task('a', 'queued')
const t1 = task('a', 'running')
const t2 = task('a', 'completed')
const r = await enqueueDownload({
client: client(t0, [t1, t2]),
request: { input: { url: 'https://x', kind: 'yt-dlp-forward' } },
wait: true,
delay: async () => {}
})
expect(r.kind).toBe('wait-success')
})
it('wait reports retry-scheduled as wait-non-success', async () => {
const t0 = task('a', 'queued')
const t1 = task('a', 'retry-scheduled')
const r = await enqueueDownload({
client: client(t0, [t1]),
request: { input: { url: 'https://x', kind: 'yt-dlp-forward' } },
wait: true,
delay: async () => {}
})
if (r.kind !== 'wait-non-success') throw new Error('expected non-success')
expect(r.reason).toBe('retry-scheduled')
})
it('wait surfaces failed terminal status', async () => {
const t0 = task('a', 'queued')
const t1 = task('a', 'failed')
const r = await enqueueDownload({
client: client(t0, [t1]),
request: { input: { url: 'https://x', kind: 'yt-dlp-forward' } },
wait: true,
delay: async () => {}
})
if (r.kind !== 'wait-non-success') throw new Error('expected non-success')
expect(r.reason).toBe('failed')
})
it('throws when transport lacks add()', async () => {
const c: ContractClient = {
list: async () => ({ items: [], nextCursor: null }),
get: async () => task('a', 'queued'),
stats: async () => ({}),
removeFromHistory: async () => {}
}
await expect(
enqueueDownload({
client: c,
request: { input: { url: 'https://x', kind: 'yt-dlp-forward' } },
wait: false
})
).rejects.toThrow(/transport.*add/)
})
})
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import {
ExitCode,
errorEnvelope,
exitCodeForError,
renderEnvelope
} from '../src/envelope'
describe('exitCodeForError (§4.4 table)', () => {
it('maps host unreachability to 3', () => {
expect(exitCodeForError('DESKTOP_NOT_READY')).toBe(ExitCode.HOST_UNREACHABLE)
expect(exitCodeForError('API_UNREACHABLE')).toBe(ExitCode.HOST_UNREACHABLE)
expect(exitCodeForError('HANDSHAKE_FAILED')).toBe(ExitCode.HOST_UNREACHABLE)
})
it('maps auth to 4', () => {
expect(exitCodeForError('AUTH_FAILED')).toBe(ExitCode.AUTH_FAILED)
expect(exitCodeForError('TOKEN_EXPIRED')).toBe(ExitCode.AUTH_FAILED)
})
it('maps contract / not-implemented to 5', () => {
expect(exitCodeForError('CONTRACT_VERSION_MISMATCH')).toBe(ExitCode.CONTRACT_ERROR)
expect(exitCodeForError('CONTRACT_SCHEMA_MISMATCH')).toBe(ExitCode.CONTRACT_ERROR)
expect(exitCodeForError('NOT_IMPLEMENTED')).toBe(ExitCode.CONTRACT_ERROR)
})
it('maps probe overflow to 1', () => {
expect(exitCodeForError('PROBE_OUTPUT_TOO_LARGE')).toBe(ExitCode.WAIT_NON_SUCCESS)
})
it('falls back to 2 for parse-time codes', () => {
expect(exitCodeForError('UNKNOWN_VIDBEE_FLAG')).toBe(ExitCode.ARG_ERROR)
expect(exitCodeForError('PARSE_ERROR')).toBe(ExitCode.ARG_ERROR)
})
})
describe('renderEnvelope', () => {
it('emits compact JSON by default', () => {
const env = errorEnvelope('PARSE_ERROR', 'oops')
expect(renderEnvelope(env)).toBe('{"ok":false,"code":"PARSE_ERROR","message":"oops"}')
})
it('pretty-prints when asked', () => {
const env = errorEnvelope('PARSE_ERROR', 'oops')
expect(renderEnvelope(env, { pretty: true })).toContain('\n')
})
})
+156
View File
@@ -0,0 +1,156 @@
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
checkUpgrade,
compareSemver,
defaultCachePath,
readCliVersion
} from '../src/local-info'
describe('readCliVersion', () => {
it('returns the @vidbee/cli package.json version when found', () => {
const info = readCliVersion()
// The version comes from apps/cli/package.json
expect(info.cli).toMatch(/^\d+\.\d+\.\d+/)
expect(info.contract).toEqual(info.cli)
expect(info.changelog).toContain('CHANGELOG')
})
it('falls back to a marker when no candidate matches', () => {
const info = readCliVersion(['/path/that/definitely/does/not/exist.json'])
expect(info.cli).toBe('0.0.0-dev')
expect(info.contract).toBe('0.0.0-dev')
})
it('skips package.json files with the wrong name', () => {
const dir = mkdtempSync(join(tmpdir(), 'vidbee-cli-version-'))
const wrong = join(dir, 'wrong.json')
const right = join(dir, 'right.json')
writeFileSync(wrong, JSON.stringify({ name: 'something-else', version: '9.9.9' }))
writeFileSync(
right,
JSON.stringify({ name: '@vidbee/cli', version: '1.2.3' })
)
const info = readCliVersion([wrong, right])
expect(info.cli).toBe('1.2.3')
})
})
describe('compareSemver', () => {
it('orders by major/minor/patch', () => {
expect(compareSemver('0.1.0', '0.2.0')).toBeLessThan(0)
expect(compareSemver('1.0.0', '0.9.9')).toBeGreaterThan(0)
expect(compareSemver('1.2.3', '1.2.3')).toBe(0)
})
it('treats prerelease as smaller than release', () => {
expect(compareSemver('0.1.0-rc.1', '0.1.0')).toBeLessThan(0)
expect(compareSemver('0.1.0', '0.1.0-rc.1')).toBeGreaterThan(0)
})
})
describe('checkUpgrade', () => {
let cachePath: string
beforeEach(() => {
const dir = mkdtempSync(join(tmpdir(), 'vidbee-cli-upgrade-'))
cachePath = join(dir, 'cli-upgrade-check.json')
})
afterEach(() => {
// tmpdir gets cleaned by the OS; nothing else to do.
})
it('reports out-of-date when registry returns a newer version', async () => {
const result = await checkUpgrade({
current: '0.1.0',
cachePath,
fetchLatest: async () => ({ version: '0.2.0', fetchedAt: 1000 })
})
expect(result.upToDate).toBe(false)
expect(result.latest).toBe('0.2.0')
expect(result.cached).toBe(false)
expect(result.installCommands.npm).toContain('@vidbee/cli')
// Cache should be written
const cached = JSON.parse(readFileSync(cachePath, 'utf-8'))
expect(cached.version).toBe('0.2.0')
})
it('reports up-to-date when installed >= registry', async () => {
const result = await checkUpgrade({
current: '0.2.0',
cachePath,
fetchLatest: async () => ({ version: '0.2.0', fetchedAt: 1000 })
})
expect(result.upToDate).toBe(true)
})
it('uses a fresh cache and skips fetch', async () => {
writeFileSync(
cachePath,
JSON.stringify({ version: '0.5.0', fetchedAt: 1_000_000 })
)
let fetched = false
const result = await checkUpgrade({
current: '0.4.0',
cachePath,
now: () => 1_000_000 + 24 * 60 * 60 * 1000, // 1 day later
fetchLatest: async () => {
fetched = true
return { version: '0.6.0', fetchedAt: 2_000_000 }
}
})
expect(fetched).toBe(false)
expect(result.cached).toBe(true)
expect(result.latest).toBe('0.5.0')
expect(result.upToDate).toBe(false)
})
it('refreshes the cache after the TTL expires', async () => {
writeFileSync(
cachePath,
JSON.stringify({ version: '0.5.0', fetchedAt: 0 })
)
let fetched = false
const result = await checkUpgrade({
current: '0.4.0',
cachePath,
now: () => 31 * 24 * 60 * 60 * 1000, // 31 days later
fetchLatest: async () => {
fetched = true
return { version: '0.6.0', fetchedAt: 99 }
}
})
expect(fetched).toBe(true)
expect(result.cached).toBe(false)
expect(result.latest).toBe('0.6.0')
})
it('--force bypasses the cache', async () => {
writeFileSync(
cachePath,
JSON.stringify({ version: '0.5.0', fetchedAt: Date.now() })
)
let fetched = false
const result = await checkUpgrade({
current: '0.4.0',
cachePath,
force: true,
fetchLatest: async () => {
fetched = true
return { version: '0.7.0', fetchedAt: Date.now() }
}
})
expect(fetched).toBe(true)
expect(result.cached).toBe(false)
expect(result.latest).toBe('0.7.0')
})
})
describe('defaultCachePath', () => {
it('returns a non-empty platform-specific path', () => {
const p = defaultCachePath()
expect(p).toContain('cli-upgrade-check.json')
expect(p.length).toBeGreaterThan('cli-upgrade-check.json'.length)
})
})
+295
View File
@@ -0,0 +1,295 @@
import { describe, expect, it } from 'vitest'
import { ParseError, parseArgv } from '../src/parser'
describe('parseArgv — VidBee flag handling (§4.1, §4.2)', () => {
it('consumes --vidbee-wait switch and removes it from yt-dlp argv', () => {
const r = parseArgv(['--vidbee-wait', 'https://x'])
if (r.kind !== 'ytdlp') throw new Error('expected ytdlp')
expect(r.flags.wait).toBe(true)
expect(r.ytArgs).toEqual(['https://x'])
})
it('accepts --vidbee-api with separate value', () => {
const r = parseArgv(['--vidbee-api', 'http://10.0.0.5:3100', 'https://x'])
if (r.kind !== 'ytdlp') throw new Error('expected ytdlp')
expect(r.flags.api).toBe('http://10.0.0.5:3100')
expect(r.ytArgs).toEqual(['https://x'])
})
it('accepts --vidbee-api=value form', () => {
const r = parseArgv(['--vidbee-api=http://10.0.0.5:3100', 'https://x'])
if (r.kind !== 'ytdlp') throw new Error('expected ytdlp')
expect(r.flags.api).toBe('http://10.0.0.5:3100')
})
it('rejects unknown --vidbee-* with exit 2 (§4.2 typo guard)', () => {
expect(() => parseArgv(['--vidbe-wait', 'https://x'])).toThrow(ParseError)
try {
parseArgv(['--vidbe-wait', 'https://x'])
} catch (e) {
expect((e as ParseError).exitCode).toBe(2)
expect((e as ParseError).code).toBe('UNKNOWN_VIDBEE_FLAG')
}
})
it('rejects --vidbee-wait=anything (switch with value)', () => {
expect(() => parseArgv(['--vidbee-wait=true', 'https://x'])).toThrow(
ParseError
)
})
it('rejects --vidbee-api with no value', () => {
expect(() => parseArgv(['--vidbee-api'])).toThrow(ParseError)
})
it('validates --vidbee-target enum', () => {
expect(() => parseArgv(['--vidbee-target', 'lol', 'https://x'])).toThrow(
ParseError
)
const r = parseArgv(['--vidbee-target', 'local', 'https://x'])
if (r.kind !== 'ytdlp') throw new Error('expected ytdlp')
expect(r.flags.target).toBe('local')
})
it('validates --vidbee-priority enum', () => {
expect(() => parseArgv(['--vidbee-priority', 'critical', 'https://x'])).toThrow(
ParseError
)
const r = parseArgv(['--vidbee-priority', 'subscription', 'https://x'])
if (r.kind !== 'ytdlp') throw new Error('expected ytdlp')
expect(r.flags.priority).toBe('subscription')
})
it('validates --vidbee-max-attempts is non-negative integer', () => {
expect(() => parseArgv(['--vidbee-max-attempts', '-1', 'https://x'])).toThrow(
ParseError
)
expect(() => parseArgv(['--vidbee-max-attempts', '1.5', 'https://x'])).toThrow(
ParseError
)
const r = parseArgv(['--vidbee-max-attempts', '0', 'https://x'])
if (r.kind !== 'ytdlp') throw new Error('expected ytdlp')
expect(r.flags.maxAttempts).toBe(0)
expect(r.flags.noRetry).toBe(true)
})
it('validates --vidbee-timeout is positive integer', () => {
expect(() => parseArgv(['--vidbee-timeout', '0', 'https://x'])).toThrow(ParseError)
const r = parseArgv(['--vidbee-timeout', '5000', 'https://x'])
if (r.kind !== 'ytdlp') throw new Error('expected ytdlp')
expect(r.flags.timeoutMs).toBe(5000)
})
it('--vidbee-no-retry is equivalent to --vidbee-max-attempts 0 (§6.3)', () => {
const r = parseArgv(['--vidbee-no-retry', 'https://x'])
if (r.kind !== 'ytdlp') throw new Error('expected ytdlp')
expect(r.flags.noRetry).toBe(true)
expect(r.flags.maxAttempts).toBe(0)
})
})
describe('parseArgv — yt-dlp passthrough (§4.1)', () => {
it('preserves order of yt-dlp argv', () => {
const argv = [
'-f',
'bestvideo+bestaudio/best',
'-o',
'%(title)s.%(ext)s',
'https://x'
]
const r = parseArgv(argv)
if (r.kind !== 'ytdlp') throw new Error('expected ytdlp')
expect(r.ytArgs).toEqual(argv)
})
it('preserves order across interleaved --vidbee-* flags', () => {
const r = parseArgv([
'-f',
'best',
'--vidbee-wait',
'-o',
'./out',
'https://x',
'--vidbee-priority',
'background'
])
if (r.kind !== 'ytdlp') throw new Error('expected ytdlp')
expect(r.ytArgs).toEqual(['-f', 'best', '-o', './out', 'https://x'])
expect(r.flags.wait).toBe(true)
expect(r.flags.priority).toBe('background')
})
it('passes through after --', () => {
const r = parseArgv(['--', '--vidbee-wait', '--anything-goes'])
if (r.kind !== 'ytdlp') throw new Error('expected ytdlp')
expect(r.ytArgs).toEqual(['--', '--vidbee-wait', '--anything-goes'])
})
it('treats unknown yt-dlp flag as passthrough (§4.5)', () => {
const r = parseArgv(['--future-yt-dlp-flag', 'value', 'https://x'])
if (r.kind !== 'ytdlp') throw new Error('expected ytdlp')
expect(r.ytArgs).toEqual(['--future-yt-dlp-flag', 'value', 'https://x'])
})
})
describe('parseArgv — mode detection (§4.3, §4.5)', () => {
it('detects probe mode from any probe-class flag', () => {
const r = parseArgv(['-j', 'https://x'])
if (r.kind !== 'ytdlp') throw new Error('expected ytdlp')
expect(r.mode).toBe('probe')
expect(r.probeFlag).toBe('-j')
})
it('defaults to download mode when no probe flag is present', () => {
const r = parseArgv(['https://x'])
if (r.kind !== 'ytdlp') throw new Error('expected ytdlp')
expect(r.mode).toBe('download')
expect(r.probeFlag).toBe(null)
})
it('rejects -o - in download mode (§4.5)', () => {
expect(() => parseArgv(['-o', '-', 'https://x'])).toThrow(ParseError)
expect(() => parseArgv(['--output', '-', 'https://x'])).toThrow(ParseError)
expect(() => parseArgv(['-o-', 'https://x'])).toThrow(ParseError)
})
it('allows -o - in probe mode (§4.5)', () => {
const r = parseArgv(['-j', '-o', '-', 'https://x'])
if (r.kind !== 'ytdlp') throw new Error('expected ytdlp')
expect(r.mode).toBe('probe')
expect(r.ytArgs).toEqual(['-j', '-o', '-', 'https://x'])
})
})
describe('parseArgv — subcommands (§4.1)', () => {
it('routes :status as subcommand', () => {
const r = parseArgv([':status'])
if (r.kind !== 'subcommand') throw new Error('expected subcommand')
expect(r.subcommand).toBe('status')
expect(r.subArgs).toEqual([])
})
it('captures all tokens after :subcommand as its args', () => {
const r = parseArgv([':download', 'list', '--status', 'queued'])
if (r.kind !== 'subcommand') throw new Error('expected subcommand')
expect(r.subcommand).toBe('download')
expect(r.subArgs).toEqual(['list', '--status', 'queued'])
})
it('still consumes --vidbee-* before subcommand', () => {
const r = parseArgv(['--vidbee-pretty', ':download', 'list'])
if (r.kind !== 'subcommand') throw new Error('expected subcommand')
expect(r.flags.pretty).toBe(true)
expect(r.subcommand).toBe('download')
expect(r.subArgs).toEqual(['list'])
})
it('a bare ":" (length 1) is treated as yt-dlp argv passthrough', () => {
const r = parseArgv([':', 'https://x'])
expect(r.kind).toBe('ytdlp')
})
})
describe('parseArgv — fuzz / property tests (§4.1)', () => {
it('preserves yt-dlp argv order across 1000 random argvs', () => {
const rng = mulberry32(0xdead_beef)
const ytTokens = [
'https://example.com/v',
'-f',
'best',
'-o',
'%(title)s.%(ext)s',
'--continue',
'--no-overwrites',
'--newline',
'--cookies-from-browser',
'firefox',
'-N',
'4',
'--retries',
'10'
]
// Only single-token vidbee flags (switches and `--flag=value`) so a
// value flag can never be split from its value by another insertion.
const vidbeeTokens: string[][] = [
['--vidbee-wait'],
['--vidbee-detach'],
['--vidbee-priority=subscription'],
['--vidbee-priority=background'],
['--vidbee-max-attempts=3'],
['--vidbee-no-retry'],
['--vidbee-pretty'],
['--vidbee-group-key=host:example.com']
]
for (let i = 0; i < 1000; i++) {
const yt: string[] = []
const argv: string[] = []
const ytLen = Math.floor(rng() * ytTokens.length)
for (let j = 0; j < ytLen; j++) {
const idx = Math.floor(rng() * ytTokens.length)
const tok = ytTokens[idx] as string
yt.push(tok)
argv.push(tok)
}
// Sprinkle 0..3 vidbee flag groups at random positions.
const vidbeeCount = Math.floor(rng() * 4)
for (let j = 0; j < vidbeeCount; j++) {
const groupIdx = Math.floor(rng() * vidbeeTokens.length)
const group = vidbeeTokens[groupIdx] as string[]
const insertPos = Math.floor(rng() * (argv.length + 1))
argv.splice(insertPos, 0, ...group)
}
// -o - is only legal in probe; if argv contains "-o" "-" pair, ensure
// the test doesn't trip the §4.5 guard incorrectly.
if (containsStdoutOutput(argv) && !containsProbe(argv)) continue
const r = parseArgv(argv)
if (r.kind !== 'ytdlp') {
throw new Error(
`unexpected subcommand result for argv: ${JSON.stringify(argv)}`
)
}
expect(r.ytArgs).toEqual(yt)
}
})
})
function containsStdoutOutput(argv: readonly string[]): boolean {
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '-o' || argv[i] === '--output') {
if (argv[i + 1] === '-') return true
}
}
return false
}
function containsProbe(argv: readonly string[]): boolean {
return argv.some(
(t) =>
t === '-j' ||
t === '-J' ||
t === '-F' ||
t === '-s' ||
t.startsWith('--dump-') ||
t.startsWith('--list-') ||
t.startsWith('--get-') ||
t === '--simulate' ||
t === '--skip-download' ||
t === '--print' ||
t.startsWith('--print=') ||
t === '--update' ||
t === '--version'
)
}
// Deterministic PRNG so failures are reproducible.
function mulberry32(seed: number): () => number {
let s = seed >>> 0
return () => {
s = (s + 0x6d_2b_79_f5) >>> 0
let t = s
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296
}
}
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest'
import {
PROBE_FLAG_REGISTRY,
findProbeFlag,
isProbeArgv
} from '../src/parser/probe-flags'
describe('probe-flag detection (§4.3)', () => {
it('flags every documented exact alias', () => {
const aliases = [
'-j',
'--dump-json',
'-J',
'--dump-single-json',
'-F',
'--list-formats',
'--list-formats-as-table',
'--list-formats-old',
'-s',
'--simulate',
'--skip-download',
'--list-subs',
'--list-extractors',
'--list-extractor-descriptions'
]
for (const a of aliases) {
expect(isProbeArgv([a, 'https://x'])).toBe(true)
}
})
it('flags every documented --get-* alias', () => {
const getters = [
'--get-id',
'--get-title',
'--get-thumbnail',
'--get-description',
'--get-duration',
'--get-filename',
'--get-format',
'--get-url'
]
for (const g of getters) {
expect(isProbeArgv([g, 'https://x'])).toBe(true)
}
})
it('flags --print regardless of position', () => {
expect(isProbeArgv(['--print', 'title', 'https://x'])).toBe(true)
expect(isProbeArgv(['https://x', '--print', 'title'])).toBe(true)
expect(isProbeArgv(['--print', 'title', '--print', 'duration'])).toBe(true)
expect(isProbeArgv(['--print=title', 'https://x'])).toBe(true)
})
it('flags meta commands without URL', () => {
expect(isProbeArgv(['--update'])).toBe(true)
expect(isProbeArgv(['--version'])).toBe(true)
})
it('mixed argv with probe-class flag is still probe (§4.5)', () => {
expect(isProbeArgv(['-j', '-f', 'bestaudio', 'https://x'])).toBe(true)
expect(isProbeArgv(['--simulate', '-o', './out.%(ext)s'])).toBe(true)
// -o file present but probe flag wins
expect(isProbeArgv(['-F', '-o', 'video.mp4', 'https://x'])).toBe(true)
})
it('does not false-positive on download argv', () => {
expect(isProbeArgv(['https://x'])).toBe(false)
expect(
isProbeArgv(['-f', 'bestvideo+bestaudio/best', '-o', '%(title)s.%(ext)s', 'https://x'])
).toBe(false)
expect(isProbeArgv(['--continue', '--no-overwrites', 'https://x'])).toBe(false)
})
it('returns the matched flag for diagnostics', () => {
expect(findProbeFlag(['--dump-json', 'https://x'])).toBe('--dump-json')
expect(findProbeFlag(['https://x'])).toBe(null)
})
it('exposes the registry constants for cross-checking', () => {
expect(PROBE_FLAG_REGISTRY.exact.size).toBeGreaterThan(0)
expect(PROBE_FLAG_REGISTRY.getters.size).toBeGreaterThan(0)
expect(PROBE_FLAG_REGISTRY.prefixed).toContain('--print')
})
})
+93
View File
@@ -0,0 +1,93 @@
import { Readable } from 'node:stream'
import { describe, expect, it } from 'vitest'
import { runProbe, type ProbeSpawnHandle } from '../src/download/probe'
function fakeChild(spec: {
stdoutChunks?: Buffer[]
stderrChunks?: Buffer[]
exitCode?: number
}): ProbeSpawnHandle {
const stdout = Readable.from(spec.stdoutChunks ?? [])
const stderr = Readable.from(spec.stderrChunks ?? [])
let onClose: ((code: number | null) => void) | null = null
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let onError: ((err: Error) => void) | null = null
// when both streams end, fire close
let pending = 2
const tryClose = () => {
pending -= 1
if (pending === 0) onClose?.(spec.exitCode ?? 0)
}
stdout.on('end', tryClose)
stderr.on('end', tryClose)
return {
stdout,
stderr,
on(ev, listener) {
if (ev === 'close') onClose = listener as (code: number | null) => void
else if (ev === 'error') onError = listener as (err: Error) => void
},
kill: () => {
stdout.destroy()
stderr.destroy()
onClose?.(null)
}
}
}
describe('runProbe', () => {
it('captures stdout/stderr and exit code on success', async () => {
const r = await runProbe({
argv: ['-j', 'https://x'],
spawner: () =>
fakeChild({
stdoutChunks: [Buffer.from('{"a":1}\n')],
stderrChunks: [Buffer.from('warn\n')],
exitCode: 0
})
})
if (r.kind !== 'success') throw new Error(`expected success, got ${JSON.stringify(r)}`)
expect(r.stdout).toContain('"a":1')
expect(r.exitCode).toBe(0)
})
it('returns PROBE_OUTPUT_TOO_LARGE when stdout exceeds cap', async () => {
const r = await runProbe({
argv: ['-j', 'https://x'],
stdoutMaxBytes: 4,
spawner: () =>
fakeChild({
stdoutChunks: [Buffer.from('xxxxxxxxxx')]
})
})
if (r.kind !== 'error') throw new Error('expected error')
expect(r.envelope.code).toBe('PROBE_OUTPUT_TOO_LARGE')
})
it('returns NOT_IMPLEMENTED when spawner throws', async () => {
const r = await runProbe({
argv: ['-j', 'https://x'],
spawner: () => {
throw new Error('ENOENT')
}
})
if (r.kind !== 'error') throw new Error('expected error')
expect(r.envelope.message).toMatch(/ENOENT|spawn/i)
})
it('truncates stderr to tail bytes', async () => {
const big = Buffer.from('x'.repeat(200))
const r = await runProbe({
argv: ['-j', 'https://x'],
stderrTailBytes: 50,
spawner: () =>
fakeChild({
stdoutChunks: [Buffer.from('ok')],
stderrChunks: [big]
})
})
if (r.kind !== 'success') throw new Error('expected success')
expect(r.stderr.length).toBeLessThanOrEqual(50)
})
})
+78
View File
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'
import { redactArgs, redactText } from '../src/parser/redact'
describe('redactArgs (§8.2)', () => {
it('replaces --password value', () => {
const r = redactArgs(['--password', 'shh', 'https://x'])
expect(r.args).toEqual(['--password', '<redacted>', 'https://x'])
expect(r.summary.redacted).toBe(true)
})
it('replaces --password=value form', () => {
const r = redactArgs(['--password=shh', 'https://x'])
expect(r.args).toEqual(['--password=<redacted>', 'https://x'])
})
it('handles all sensitive value flags', () => {
for (const flag of [
'--username',
'--password',
'--video-password',
'--ap-password',
'--twofactor'
]) {
const r = redactArgs([flag, 'secret', 'https://x'])
expect(r.args[1]).toBe('<redacted>')
}
})
it('redacts Authorization in --add-headers', () => {
const r = redactArgs(['--add-headers', 'Authorization:Bearer abc'])
expect(r.args[1]).toMatch(/Authorization: <redacted>/i)
})
it('keeps non-sensitive headers verbatim', () => {
const r = redactArgs(['--add-headers', 'X-Foo:bar'])
expect(r.args[1]).toBe('X-Foo:bar')
expect(r.summary.redacted).toBe(false)
})
it('scrubs URL query token=', () => {
const r = redactArgs(['https://x.com/v?token=abc&other=keep'])
const url = new URL(r.args[0] as string)
expect(url.searchParams.get('token')).toBe('<redacted>')
expect(url.searchParams.get('other')).toBe('keep')
})
it('scrubs URL query access_token, signature, policy', () => {
const r = redactArgs(['https://x.com/v?access_token=a&signature=b&policy=c'])
const url = new URL(r.args[0] as string)
expect(url.searchParams.get('access_token')).toBe('<redacted>')
expect(url.searchParams.get('signature')).toBe('<redacted>')
expect(url.searchParams.get('policy')).toBe('<redacted>')
})
it('leaves unrelated argv untouched', () => {
const argv = ['-f', 'best', 'https://x.com/v', '-o', 'video.mp4']
const r = redactArgs(argv)
expect(r.args).toEqual(argv)
expect(r.summary.redacted).toBe(false)
})
})
describe('redactText (§8.2 stdout/stderr)', () => {
it('replaces Authorization: header line', () => {
const out = redactText('Sending Authorization: Bearer abc\nDone')
expect(out).toContain('<redacted>')
expect(out).not.toContain('Bearer abc')
})
it('scrubs URLs in free text', () => {
const out = redactText('Got https://x.com/v?token=foo')
// URL-encoded form is acceptable; the important thing is that the
// original token value is gone.
expect(out).not.toContain('token=foo')
expect(out).toMatch(/token=(%3C|<)redacted/i)
})
})
+480
View File
@@ -0,0 +1,480 @@
import { describe, expect, it } from 'vitest'
import { run } from '../src/runtime'
import type { ContractClient } from '../src/subcommands'
import type { ConnectOptions, ConnectResult } from '../src/transport/connect'
import type { Task } from '@vidbee/task-queue'
import { EMPTY_PROGRESS } from '@vidbee/task-queue'
function fakeTask(id: string, status: Task['status'] = 'queued'): Task {
return {
id,
kind: 'video',
parentId: null,
input: { url: 'https://e.com', kind: 'video' },
priority: 0,
groupKey: 'e.com',
status,
prevStatus: null,
statusReason: null,
enteredStatusAt: 0,
attempt: 0,
maxAttempts: 5,
nextRetryAt: null,
progress: { ...EMPTY_PROGRESS },
output: null,
lastError: null,
pid: null,
pidStartedAt: null,
createdAt: 0,
updatedAt: 0
}
}
function fakeClient(seeded: Task[] = [fakeTask('t1', 'queued'), fakeTask('t2', 'completed')]): ContractClient {
return {
list: async () => ({ items: seeded, nextCursor: null }),
get: async (id) => seeded.find((t) => t.id === id) ?? fakeTask(id),
stats: async () => ({ pending: 0, running: 0, completed: 1 }),
removeFromHistory: async () => {}
}
}
function fakeConnect(client: ContractClient) {
return async (_opts: ConnectOptions): Promise<ConnectResult> => ({
kind: 'connected',
client
})
}
function makeIO(client = fakeClient()) {
const out: string[] = []
const err: string[] = []
return {
out,
err,
io: {
stdout: (l: string) => out.push(l),
stderr: (l: string) => err.push(l),
connect: fakeConnect(client)
}
}
}
describe('run() — argv error path', () => {
it('exits 2 with error envelope on unknown --vidbee-*', async () => {
const { io, out } = makeIO()
const r = await run(['--vidbe-wait', 'https://x'], io)
expect(r.exitCode).toBe(2)
const env = JSON.parse(out[0] as string)
expect(env.ok).toBe(false)
expect(env.code).toBe('UNKNOWN_VIDBEE_FLAG')
})
it('exits 2 with STDOUT_OUTPUT_DISALLOWED on -o - in download mode', async () => {
const { io, out } = makeIO()
const r = await run(['-o', '-', 'https://x'], io)
expect(r.exitCode).toBe(2)
const env = JSON.parse(out[0] as string)
expect(env.code).toBe('STDOUT_OUTPUT_DISALLOWED')
})
})
describe('run() — read-only subcommands via fake transport', () => {
it(':status returns stats from the contract', async () => {
const { io, out } = makeIO()
const r = await run(['--vidbee-local', ':status'], io)
expect(r.exitCode).toBe(0)
const env = JSON.parse(out[0] as string)
expect(env.ok).toBe(true)
expect(env.mode).toBe('subcommand')
expect(env.subcommand).toBe('status')
expect(env.result).toEqual({ pending: 0, running: 0, completed: 1 })
})
it(':download list passes filters through to the contract', async () => {
const { io, out } = makeIO()
const r = await run(
['--vidbee-local', ':download', 'list', '--status', 'queued', '--limit', '10'],
io
)
expect(r.exitCode).toBe(0)
const env = JSON.parse(out[0] as string)
expect(env.result.items).toHaveLength(2)
})
it(':download status <id> returns the task', async () => {
const { io, out } = makeIO()
const r = await run(['--vidbee-local', ':download', 'status', 't1'], io)
expect(r.exitCode).toBe(0)
const env = JSON.parse(out[0] as string)
expect(env.result.id).toBe('t1')
})
it(':download status without id reports parse error', async () => {
const { io, out } = makeIO()
const r = await run(['--vidbee-local', ':download', 'status'], io)
expect(r.exitCode).toBe(2)
const env = JSON.parse(out[0] as string)
expect(env.code).toBe('PARSE_ERROR')
})
it(':history list forwards to the contract', async () => {
const { io, out } = makeIO()
const r = await run(['--vidbee-local', ':history', 'list'], io)
expect(r.exitCode).toBe(0)
const env = JSON.parse(out[0] as string)
expect(env.ok).toBe(true)
})
it(':history remove <id...> calls the contract per id', async () => {
const removed: string[] = []
const client: ContractClient = {
list: async () => ({ items: [], nextCursor: null }),
get: async (id) => fakeTask(id),
stats: async () => ({}),
removeFromHistory: async (id) => {
removed.push(id)
}
}
const out: string[] = []
const r = await run(['--vidbee-local', ':history', 'remove', 'a', 'b'], {
stdout: (l) => out.push(l),
stderr: () => {},
connect: fakeConnect(client)
})
expect(r.exitCode).toBe(0)
expect(removed).toEqual(['a', 'b'])
})
it('unknown subcommand exits 2', async () => {
const { io, out } = makeIO()
const r = await run(['--vidbee-local', ':bogus'], io)
expect(r.exitCode).toBe(2)
const env = JSON.parse(out[0] as string)
expect(env.code).toBe('PARSE_ERROR')
})
})
describe('run() — :download write verbs', () => {
it(':download cancel calls client.cancel', async () => {
const cancelled: string[] = []
const client: ContractClient = {
...fakeClient(),
cancel: async (id) => {
cancelled.push(id)
}
}
const out: string[] = []
const r = await run(['--vidbee-local', ':download', 'cancel', 't1'], {
stdout: (l) => out.push(l),
stderr: () => {},
connect: fakeConnect(client)
})
expect(r.exitCode).toBe(0)
expect(cancelled).toEqual(['t1'])
const env = JSON.parse(out[0] as string)
expect(env.result.status).toBe('cancel-requested')
})
it(':download pause forwards --reason', async () => {
const paused: { id: string; reason?: string }[] = []
const client: ContractClient = {
...fakeClient(),
pause: async (id, reason) => {
paused.push({ id, reason })
}
}
const out: string[] = []
await run(['--vidbee-local', ':download', 'pause', 't1', '--reason', 'manual'], {
stdout: (l) => out.push(l),
stderr: () => {},
connect: fakeConnect(client)
})
expect(paused).toEqual([{ id: 't1', reason: 'manual' }])
})
it(':download retry calls client.retry', async () => {
const retried: string[] = []
const client: ContractClient = {
...fakeClient(),
retry: async (id) => {
retried.push(id)
}
}
const out: string[] = []
const r = await run(['--vidbee-local', ':download', 'retry', 't1'], {
stdout: (l) => out.push(l),
stderr: () => {},
connect: fakeConnect(client)
})
expect(r.exitCode).toBe(0)
expect(retried).toEqual(['t1'])
})
it(':download cancel surfaces CONTRACT_VERSION_MISMATCH when transport lacks support', async () => {
const out: string[] = []
const r = await run(['--vidbee-local', ':download', 'cancel', 't1'], {
stdout: (l) => out.push(l),
stderr: () => {},
connect: fakeConnect(fakeClient())
})
expect(r.exitCode).toBe(5)
const env = JSON.parse(out[0] as string)
expect(env.code).toBe('CONTRACT_VERSION_MISMATCH')
})
it(':download logs returns task + stderrTail', async () => {
const t = fakeTask('t1', 'failed')
;(t as { lastError: unknown }).lastError = { stderrTail: 'boom' }
const client: ContractClient = {
...fakeClient([t]),
get: async () => t
}
const out: string[] = []
await run(['--vidbee-local', ':download', 'logs', 't1'], {
stdout: (l) => out.push(l),
stderr: () => {},
connect: fakeConnect(client)
})
const env = JSON.parse(out[0] as string)
expect(env.result.logs.stderrTail).toBe('boom')
})
})
describe('run() — yt-dlp probe (Phase B)', () => {
it('emits §4.4 probe envelope with sanitized command', async () => {
const out: string[] = []
const r = await run(['--vidbee-local', '-j', '--password', 'shh', 'https://x'], {
stdout: (l) => out.push(l),
stderr: () => {},
connect: fakeConnect(fakeClient()),
probe: async () => ({ kind: 'success', stdout: '{"title":"x"}', stderr: '', exitCode: 0, binary: 'yt-dlp' })
})
expect(r.exitCode).toBe(0)
const env = JSON.parse(out[0] as string)
expect(env.ok).toBe(true)
expect(env.mode).toBe('probe')
expect(env.command).toContain('<redacted>')
expect(env.ytDlp.exitCode).toBe(0)
})
it('reports PROBE_OUTPUT_TOO_LARGE when stdout exceeds 32MB', async () => {
const out: string[] = []
const r = await run(['-j', 'https://x'], {
stdout: (l) => out.push(l),
stderr: () => {},
connect: fakeConnect(fakeClient()),
probe: async () => ({
kind: 'error',
envelope: {
ok: false,
code: 'PROBE_OUTPUT_TOO_LARGE',
message: 'too big'
}
})
})
expect(r.exitCode).toBe(1)
const env = JSON.parse(out[0] as string)
expect(env.code).toBe('PROBE_OUTPUT_TOO_LARGE')
})
})
describe('run() — yt-dlp download enqueue (Phase B)', () => {
it('detached mode returns queued task and exit 0', async () => {
const queued = fakeTask('q1', 'queued')
const client: ContractClient = {
...fakeClient(),
add: async () => ({ id: 'q1', task: queued }),
get: async () => queued
}
const out: string[] = []
const r = await run(['--vidbee-local', 'https://x'], {
stdout: (l) => out.push(l),
stderr: () => {},
connect: fakeConnect(client)
})
expect(r.exitCode).toBe(0)
const env = JSON.parse(out[0] as string)
expect(env.mode).toBe('download')
expect(env.task.id).toBe('q1')
expect(env.task.status).toBe('queued')
expect(env.task.command).toContain('yt-dlp')
})
it('--vidbee-wait blocks then exits 0 on completed', async () => {
const completed = fakeTask('q1', 'completed')
const client: ContractClient = {
...fakeClient(),
add: async () => ({ id: 'q1', task: completed }),
get: async () => completed
}
const out: string[] = []
const r = await run(['--vidbee-local', '--vidbee-wait', 'https://x'], {
stdout: (l) => out.push(l),
stderr: () => {},
connect: fakeConnect(client)
})
expect(r.exitCode).toBe(0)
const env = JSON.parse(out[0] as string)
expect(env.task.status).toBe('completed')
})
it('--vidbee-wait exits 1 on retry-scheduled', async () => {
const retry = fakeTask('q1', 'retry-scheduled')
const client: ContractClient = {
...fakeClient(),
add: async () => ({ id: 'q1', task: retry }),
get: async () => retry
}
const out: string[] = []
const r = await run(['--vidbee-local', '--vidbee-wait', 'https://x'], {
stdout: (l) => out.push(l),
stderr: () => {},
connect: fakeConnect(client)
})
expect(r.exitCode).toBe(1)
const env = JSON.parse(out[0] as string)
expect(env.ok).toBe(false)
})
})
describe('run() — output formatting', () => {
it('--vidbee-pretty produces pretty-printed JSON', async () => {
const { io, out } = makeIO()
await run(['--vidbee-local', '--vidbee-pretty', ':status'], io)
expect(out[0]).toContain('\n')
})
})
describe('run() — :version', () => {
it('emits CLI/contract version + changelog without contacting any host', async () => {
const out: string[] = []
let connectCalled = false
const r = await run([':version'], {
stdout: (l) => out.push(l),
stderr: () => {},
connect: async () => {
connectCalled = true
return { kind: 'connected', client: fakeClient() }
},
readVersion: () => ({
cli: '1.2.3',
contract: '1.2.3',
changelog: 'https://example.test/changelog'
})
})
expect(r.exitCode).toBe(0)
expect(connectCalled).toBe(false)
const env = JSON.parse(out[0] as string)
expect(env.ok).toBe(true)
expect(env.mode).toBe('subcommand')
expect(env.subcommand).toBe('version')
expect(env.result.cli).toBe('1.2.3')
expect(env.result.contract).toBe('1.2.3')
expect(env.result.changelog).toContain('changelog')
})
})
describe('run() — :upgrade', () => {
it('reports up-to-date when current >= latest', async () => {
const out: string[] = []
const r = await run([':upgrade'], {
stdout: (l) => out.push(l),
stderr: () => {},
readVersion: () => ({
cli: '0.2.0',
contract: '0.2.0',
changelog: 'https://example.test/changelog'
}),
checkUpgrade: async (input) => ({
current: input.current,
latest: '0.2.0',
upToDate: true,
cached: false,
cachedAt: '2026-01-01T00:00:00.000Z',
registryUrl: 'https://registry.npmjs.org/@vidbee/cli/latest',
installCommands: {
npm: 'npm install -g @vidbee/cli',
pnpm: 'pnpm add -g @vidbee/cli',
bun: 'bun install -g @vidbee/cli',
brew: 'brew upgrade vidbee/tap/vidbee'
}
})
})
expect(r.exitCode).toBe(0)
const env = JSON.parse(out[0] as string)
expect(env.subcommand).toBe('upgrade')
expect(env.result.upToDate).toBe(true)
})
it('passes --force through to the upgrade checker', async () => {
let receivedForce = false
await run([':upgrade', '--force'], {
stdout: () => {},
stderr: () => {},
readVersion: () => ({ cli: '0.1.0', contract: '0.1.0', changelog: '' }),
checkUpgrade: async (input) => {
receivedForce = input.force === true
return {
current: input.current,
latest: '0.2.0',
upToDate: false,
cached: false,
cachedAt: null,
registryUrl: 'https://registry.npmjs.org/@vidbee/cli/latest',
installCommands: { npm: '', pnpm: '', bun: '', brew: '' }
}
}
})
expect(receivedForce).toBe(true)
})
it('passes --cache <path> through to the upgrade checker', async () => {
let receivedCache: string | undefined
await run([':upgrade', '--cache', '/tmp/x'], {
stdout: () => {},
stderr: () => {},
readVersion: () => ({ cli: '0.1.0', contract: '0.1.0', changelog: '' }),
checkUpgrade: async (input) => {
receivedCache = input.cachePath
return {
current: input.current,
latest: '0.1.0',
upToDate: true,
cached: false,
cachedAt: null,
registryUrl: 'https://registry.npmjs.org/@vidbee/cli/latest',
installCommands: { npm: '', pnpm: '', bun: '', brew: '' }
}
}
})
expect(receivedCache).toBe('/tmp/x')
})
it('rejects unknown :upgrade flags with exit 2', async () => {
const out: string[] = []
const r = await run([':upgrade', '--bogus'], {
stdout: (l) => out.push(l),
stderr: () => {},
readVersion: () => ({ cli: '0.1.0', contract: '0.1.0', changelog: '' })
})
expect(r.exitCode).toBe(2)
const env = JSON.parse(out[0] as string)
expect(env.code).toBe('PARSE_ERROR')
})
it('reports HOST_UNREACHABLE when the registry fetch fails', async () => {
const out: string[] = []
const r = await run([':upgrade'], {
stdout: (l) => out.push(l),
stderr: () => {},
readVersion: () => ({ cli: '0.1.0', contract: '0.1.0', changelog: '' }),
checkUpgrade: async () => {
throw new Error('econnrefused')
}
})
expect(r.exitCode).toBe(3)
const env = JSON.parse(out[0] as string)
expect(env.code).toBe('API_UNREACHABLE')
})
})
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import { dispatchSubcommand, parseListArgs, type ContractClient } from '../src/subcommands'
const stub: ContractClient = {
list: async () => ({ items: [], nextCursor: null }),
get: async () => {
throw new Error('not used')
},
stats: async () => ({ ok: true }),
removeFromHistory: async () => {}
}
describe('parseListArgs', () => {
it('parses --status / --limit / --cursor', () => {
expect(
parseListArgs(['--status', 'queued', '--limit', '20', '--cursor', 'abc'])
).toEqual({ status: 'queued', limit: 20, cursor: 'abc' })
})
it('accepts --flag=value form', () => {
expect(parseListArgs(['--limit=50'])).toEqual({ limit: 50 })
})
it('rejects unknown statuses', () => {
expect(() => parseListArgs(['--status', 'lol'])).toThrow()
})
it('rejects non-positive limit', () => {
expect(() => parseListArgs(['--limit', '0'])).toThrow()
})
it('rejects unknown flags', () => {
expect(() => parseListArgs(['--mystery'])).toThrow()
})
})
describe('dispatchSubcommand', () => {
it(':status -> stats', async () => {
const r = await dispatchSubcommand('status', [], { client: stub })
expect(r.kind).toBe('value')
if (r.kind === 'value') expect(r.value).toEqual({ ok: true })
})
it('unknown subcommand surfaces PARSE_ERROR', async () => {
const r = await dispatchSubcommand('teleport', [], { client: stub })
expect(r.kind).toBe('error')
if (r.kind === 'error') expect(r.envelope.code).toBe('PARSE_ERROR')
})
it(':rss surfaces NOT_IMPLEMENTED (NEX-132 owns it)', async () => {
const r = await dispatchSubcommand('rss', ['list'], { client: stub })
expect(r.kind).toBe('error')
if (r.kind === 'error') expect(r.envelope.code).toBe('NOT_IMPLEMENTED')
})
})
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest'
import {
isLoopbackOrPrivateHost,
selectTransport,
transportNotReady,
validateApiUrl
} from '../src/transport'
describe('isLoopbackOrPrivateHost (§8.1)', () => {
it('accepts loopback', () => {
expect(isLoopbackOrPrivateHost('localhost')).toBe(true)
expect(isLoopbackOrPrivateHost('127.0.0.1')).toBe(true)
expect(isLoopbackOrPrivateHost('127.5.5.5')).toBe(true)
expect(isLoopbackOrPrivateHost('::1')).toBe(true)
})
it('accepts RFC1918 private ranges', () => {
expect(isLoopbackOrPrivateHost('10.0.0.5')).toBe(true)
expect(isLoopbackOrPrivateHost('192.168.1.1')).toBe(true)
expect(isLoopbackOrPrivateHost('172.16.0.1')).toBe(true)
expect(isLoopbackOrPrivateHost('172.31.255.255')).toBe(true)
expect(isLoopbackOrPrivateHost('169.254.1.1')).toBe(true)
})
it('rejects public ranges', () => {
expect(isLoopbackOrPrivateHost('8.8.8.8')).toBe(false)
expect(isLoopbackOrPrivateHost('172.32.0.1')).toBe(false)
expect(isLoopbackOrPrivateHost('172.15.255.255')).toBe(false)
expect(isLoopbackOrPrivateHost('example.com')).toBe(false)
})
it('treats *.local as private (mDNS)', () => {
expect(isLoopbackOrPrivateHost('foo.local')).toBe(true)
})
})
describe('validateApiUrl (§8.1)', () => {
it('accepts https on any host', () => {
expect(validateApiUrl('https://example.com')).toBe(null)
})
it('accepts http on loopback / private', () => {
expect(validateApiUrl('http://127.0.0.1:3100')).toBe(null)
expect(validateApiUrl('http://10.0.0.5:3100')).toBe(null)
})
it('rejects http on public host', () => {
const env = validateApiUrl('http://example.com')
expect(env?.code).toBe('API_UNREACHABLE')
})
it('rejects unparseable URLs', () => {
expect(validateApiUrl('not a url')?.code).toBe('API_UNREACHABLE')
})
it('rejects unsupported schemes', () => {
expect(validateApiUrl('ftp://example.com')?.code).toBe('API_UNREACHABLE')
})
})
describe('selectTransport', () => {
it('selects local when --vidbee-local is set', () => {
expect(selectTransport({ local: true } as never).kind).toBe('local')
})
it('selects api when --vidbee-api is given', () => {
expect(
selectTransport({ local: false, api: 'https://x' } as never).kind
).toBe('api')
})
it('defaults to desktop', () => {
expect(selectTransport({ local: false } as never).kind).toBe('desktop')
})
it('honors --vidbee-target overrides', () => {
expect(selectTransport({ local: false, target: 'local' } as never).kind).toBe('local')
expect(selectTransport({ local: false, target: 'api' } as never).kind).toBe('api')
})
})
describe('transportNotReady (Phase B gate)', () => {
it('says DESKTOP_NOT_READY for desktop', () => {
const r = transportNotReady({ kind: 'desktop' })
expect(r.envelope.code).toBe('DESKTOP_NOT_READY')
expect(r.exitCode).toBe(3)
})
it('says API_UNREACHABLE for api', () => {
const r = transportNotReady({ kind: 'api' })
expect(r.envelope.code).toBe('API_UNREACHABLE')
expect(r.exitCode).toBe(3)
})
})
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"exactOptionalPropertyTypes": false,
"noUncheckedIndexedAccess": true,
"types": ["node"]
},
"include": ["src/**/*", "test/**/*", "__integration__/**/*"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
include: ['test/**/*.test.ts', '__integration__/**/*.test.ts'],
testTimeout: 10_000
}
})
+6
View File
@@ -0,0 +1,6 @@
VITE_GLITCHTIP_DSN=
VITE_GLITCHTIP_ENVIRONMENT=production
SENTRY_URL=https://glitchtip.example.com
SENTRY_ORG=your-org-slug
SENTRY_PROJECT=vidbee-desktop
SENTRY_AUTH_TOKEN=
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
set -e
# GitHub issue #378: Electron's SUID sandbox helper must be owned by root and
# carry the setuid bit (mode 4755), otherwise the app aborts on launch with
# "The SUID sandbox helper binary was found, but is not configured correctly."
# electron-builder does not reliably set this for .deb installs, so fix it here
# in the Debian postinst maintainer script. productName is "VidBee", so the
# install prefix is /opt/VidBee.
SANDBOX="/opt/VidBee/chrome-sandbox"
if [ -f "$SANDBOX" ]; then
chown root:root "$SANDBOX"
chmod 4755 "$SANDBOX"
fi
exit 0
+75
View File
@@ -0,0 +1,75 @@
const { execFileSync } = require('node:child_process')
const fs = require('node:fs')
const path = require('node:path')
const BINARIES = [
'yt-dlp_macos',
path.join('ffmpeg', 'ffmpeg'),
path.join('ffmpeg', 'ffprobe'),
'deno'
]
const findAppBundle = (appOutDir) => {
const entries = fs.readdirSync(appOutDir)
const app = entries.find((entry) => entry.endsWith('.app'))
return app ? path.join(appOutDir, app) : null
}
const resolveSigningIdentity = () =>
process.env.CSC_NAME || process.env.APPLE_SIGNING_IDENTITY || '-'
const signBinary = (targetPath, entitlementsPath) => {
const identity = resolveSigningIdentity()
const args = ['--force', '--sign', identity, '--entitlements', entitlementsPath]
if (identity !== '-') {
args.push('--options', 'runtime', '--timestamp')
}
args.push(targetPath)
execFileSync('codesign', args, { stdio: 'inherit' })
}
/**
* Resolves the packaged runtime binary directory for the app bundle.
*/
const resolveBinaryResourcesPath = (appBundle) => {
const contentsResourcesPath = path.join(appBundle, 'Contents', 'Resources')
const candidates = [
path.join(contentsResourcesPath, 'resources'),
path.join(contentsResourcesPath, 'app.asar.unpacked', 'resources')
]
return (
candidates.find((candidate) =>
BINARIES.some((binary) => fs.existsSync(path.join(candidate, binary)))
) || candidates[0]
)
}
exports.default = async function afterPack(context) {
if (context.electronPlatformName !== 'darwin') {
return
}
const appBundle = findAppBundle(context.appOutDir)
if (!appBundle) {
console.warn('afterPack: No .app bundle found, skipping tool signing.')
return
}
const resourcesPath = resolveBinaryResourcesPath(appBundle)
const entitlementsPath = path.resolve(__dirname, 'entitlements.mac.plist')
for (const binary of BINARIES) {
const targetPath = path.join(resourcesPath, binary)
if (!fs.existsSync(targetPath)) {
console.warn(`afterPack: Missing ${binary}, skipping.`)
continue
}
console.log(`afterPack: Signing ${binary} with entitlements.`)
signBinary(targetPath, entitlementsPath)
}
}
exports.resolveBinaryResourcesPath = resolveBinaryResourcesPath
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+303
View File
@@ -0,0 +1,303 @@
# Journal des modifications de VidBee
Cette page ne présente que les évolutions visibles par les utilisateurs, sans détails techniques.
Pour les notes de version complètes, consultez [GitHub Releases](https://github.com/nexmoe/VidBee/releases).
## [v1.3.10](https://github.com/nexmoe/VidBee/releases/tag/v1.3.10) - 2026-04-11
### Ameliorations
- Telechargement en un clic active par defaut pour demarrer plus vite des la premiere utilisation.
- Titres de la liste de telechargement alignes a gauche pour une lecture plus claire.
## [v1.3.9](https://github.com/nexmoe/VidBee/releases/tag/v1.3.9) - 2026-04-11
### Ameliorations
- Emplacement FFmpeg personnalise plus simple : chemin vers l'executable ou vers le dossier qui le contient.
- Messages plus clairs lors des verifications d'abonnement quand un flux est indisponible ou en erreur.
- Statut et conseils de telechargement plus lisibles pour corriger un reglage ou reessayer.
- Noms de fichiers tres longs raccourcis automatiquement sur macOS et Linux, comme sous Windows.
- La ou c'est pris en charge, raccourci clavier pour ajouter un lien aux telechargements plus vite.
## [v1.3.8](https://github.com/nexmoe/VidBee/releases/tag/v1.3.8) - 2026-04-06
### Corrections de bugs
- Migrations de base de donnees plus robustes pour les anciennes installations.
- Des outils optionnels comme FFmpeg ne bloquent plus le demarrage lorsqu'ils sont absents ou echouent a l'initialisation.
- Les verifications d'abonnement reduisent les rapports d'erreur bruyants pour les problemes reseau ou de format de flux courants.
### Ameliorations
- Affinage des retours GlitchTip et des details de rapport d'erreurs.
## [v1.3.7](https://github.com/nexmoe/VidBee/releases/tag/v1.3.7) - 2026-03-29
### Corrections de bugs
- Amelioration de la sanitisation des noms de fichiers pour gerer les caracteres Unicode speciaux et les codes de controle.
- Les telechargements Windows tronquent desormais automatiquement les noms de fichiers trop longs.
- La modification de la limite de telechargements simultanes prend effet immediatement sans redemarrage.
## [v1.3.6](https://github.com/nexmoe/VidBee/releases/tag/v1.3.6) - 2026-03-29
### Fonctionnalités
- Intégration de GlitchTip pour les rapports d'erreurs et les retours utilisateurs
- Ajout d'une notification de mise à jour lorsque la version est obsolète
- Intégration de l'analytique Rybbit
- Amélioration des détails de l'interface de téléchargement
## [v1.3.4](https://github.com/nexmoe/VidBee/releases/tag/v1.3.4) - 2026-03-14
### Corrections de bugs
- Utilisation de la source Electron par defaut pendant le packaging afin d'ameliorer la fiabilite des builds macOS de release.
## [v1.3.3](https://github.com/nexmoe/VidBee/releases/tag/v1.3.3) - 2026-03-14
### Mises a jour de fonctionnalites
- Amelioration du flux de publication afin de diffuser les builds preview separement des notifications de mise a jour en production.
### Corrections de bugs
- Reactivation de npm rebuild pendant le packaging Electron afin de preparer plus fiablement les dependances natives dans les builds de release.
- Amelioration du bundling desktop pour inclure plus regulierement les dependances partagees du workspace dans les versions publiees.
## [v1.3.3-preview.1](https://github.com/nexmoe/VidBee/releases/tag/v1.3.3-preview.1) - 2026-03-14
### Corrections de bugs
- Reactivation de npm rebuild pendant le packaging Electron pour preparer plus fiablement les dependances natives dans les builds de release.
## [v1.3.3-preview.0](https://github.com/nexmoe/VidBee/releases/tag/v1.3.3-preview.0) - 2026-03-14
### Mises a jour de fonctionnalites
- Ajout d'un canal de publication preview pour diffuser les builds de test sans declencher les mises a jour du site de production.
### Corrections de bugs
- Amelioration du bundling desktop afin d'inclure plus regulierement les dependances partagees du workspace dans les builds publies.
## [v1.3.2](https://github.com/nexmoe/VidBee/releases/tag/v1.3.2) - 2026-03-14
### Corrections de bugs
- Amelioration de la fiabilite du packaging desktop afin d'inclure plus regulierement les composants de telechargement partages.
## [v1.3.1](https://github.com/nexmoe/VidBee/releases/tag/v1.3.1) - 2026-03-14
### Mises a jour de fonctionnalites
- Ajout des editions Web et API, avec des capacites de telechargement partagees et un comportement des reglages harmonise.
- Ajout de la prise en charge de l'envoi de fichiers Cookie et de configuration depuis les reglages.
- Migration de l'historique des telechargements vers SQLite pour une meilleure fiabilite et une meilleure coherence multi-plateforme.
- Ajout d'un flux partage pour l'ajout d'URL dans les boites de dialogue de telechargement et amelioration de la visibilite du curseur en theme sombre.
### Corrections de bugs
- Amelioration de la robustesse et des diagnostics du processus d'initialisation des binaires embarques sur desktop.
- Correction du saut de curseur dans le champ de profil des reglages.
- Correction de la validation du dossier de telechargement sous Linux lors de la selection d'un dossier existant non vide.
- Amelioration de la coherence des localisations, y compris des corrections de traduction chinoise.
## [v1.3.0](https://github.com/nexmoe/VidBee/releases/tag/v1.3.0) - 2026-02-15
### Mises a jour de fonctionnalites
- Ajout de nouvelles actions en un clic pour coller un lien et lancer le téléchargement plus vite.
- Ajout de la prise en charge des langues française, russe et turque dans l'application.
- Le format de conteneur sélectionné est désormais respecté de manière plus cohérente.
### Corrections de bugs
- Amélioration de la compatibilité des téléchargements pour YouTube et les scénarios de repli de format.
- Les réglages et la documentation ont été améliorés, avec des indications plus claires pour les rapports de bug et le RSS.
## [v1.2.4](https://github.com/nexmoe/VidBee/releases/tag/v1.2.4) - 2026-01-24
### Mises a jour de fonctionnalites
- Le flux de téléchargement en un clic est plus direct et demande moins d'étapes.
- Un onglet Cookie dédié a été ajouté dans les réglages pour simplifier les actions liées au compte.
- Les points d'entrée FAQ sont plus clairs et les messages d'erreur sont plus faciles à comprendre.
### Corrections de bugs
- Les indications RSS sont plus claires, surtout pour les nouveaux utilisateurs.
## [v1.2.3](https://github.com/nexmoe/VidBee/releases/tag/v1.2.3) - 2026-01-23
### Mises a jour de fonctionnalites
- Le chargement des playlists est plus stable et ne compresse plus l'interface.
- Le guide d'utilisation des cookies inclut désormais des exemples plus clairs.
## [v1.2.2](https://github.com/nexmoe/VidBee/releases/tag/v1.2.2) - 2026-01-21
### Mises a jour de fonctionnalites
- Les actions liées au téléchargement sont plus faciles à trouver.
- Ajout d'une option pour inclure ou retirer le filigrane lors du partage.
- Les interactions de téléchargement sont plus cohérentes dans l'ensemble.
## [v1.2.1](https://github.com/nexmoe/VidBee/releases/tag/v1.2.1) - 2026-01-20
### Mises a jour de fonctionnalites
- Les éléments avec le même titre dans les playlists sont plus faciles à distinguer.
- Il est plus simple de trouver les journaux et les fichiers liés lors du dépannage.
### Corrections de bugs
- Les notifications de téléchargement sont moins intrusives.
- Les liens et indications des abonnements sont plus fiables.
## [v1.2.0](https://github.com/nexmoe/VidBee/releases/tag/v1.2.0) - 2026-01-17
### Mises a jour de fonctionnalites
- Ajout d'actions rapides pour tout sélectionner et vider l'historique des téléchargements.
- Le comportement lors de la réduction et de la réouverture est plus fluide.
- Les doublons dans les abonnements sont réduits.
- Les pages Playlist et Réglages sont plus simples à utiliser.
### Corrections de bugs
- La reprise après une interruption de téléchargement est plus fiable.
## [v1.1.12](https://github.com/nexmoe/VidBee/releases/tag/v1.1.12) - 2026-01-15
### Mises a jour de fonctionnalites
- Le comportement du dossier de téléchargement dans les réglages est plus prévisible.
### Corrections de bugs
- Les rapports de retour contiennent désormais des informations d'appui plus claires.
## [v1.1.11](https://github.com/nexmoe/VidBee/releases/tag/v1.1.11) - 2026-01-14
### Mises a jour de fonctionnalites
- Les flux de téléchargement et la mise en page sont plus clairs.
- La navigation des abonnements est plus fluide.
- Les réglages par défaut sont plus adaptés à un usage quotidien.
### Corrections de bugs
- Les messages d'erreur proposent des étapes suivantes plus claires.
## [v1.1.10](https://github.com/nexmoe/VidBee/releases/tag/v1.1.10) - 2026-01-12
### Mises a jour de fonctionnalites
- L'installation et la mise à jour sur macOS sont plus stables.
## [v1.1.8](https://github.com/nexmoe/VidBee/releases/tag/v1.1.8) - 2026-01-12
### Mises a jour de fonctionnalites
- Les détails de progression des téléchargements sont plus lisibles.
### Corrections de bugs
- Les notifications de mise à jour localisées sont plus claires.
## [v1.1.7](https://github.com/nexmoe/VidBee/releases/tag/v1.1.7) - 2026-01-11
### Mises a jour de fonctionnalites
- Davantage d'options de préférences de sortie média ont été ajoutées.
- La configuration initiale et l'utilisation quotidienne sont plus fluides.
## [v1.1.6](https://github.com/nexmoe/VidBee/releases/tag/v1.1.6) - 2026-01-11
### Mises a jour de fonctionnalites
- Les flux liés aux informations vidéo locales sont plus faciles à utiliser.
- La gestion des profils de cookies est plus stable et plus prévisible.
## [v1.1.5](https://github.com/nexmoe/VidBee/releases/tag/v1.1.5) - 2026-01-10
### Mises a jour de fonctionnalites
- Correction de problèmes connus dans les réglages avancés.
### Corrections de bugs
- Amélioration de la stabilité du chargement des couvertures distantes.
- La sélection des couvertures d'abonnement est plus fiable.
## [v1.1.4](https://github.com/nexmoe/VidBee/releases/tag/v1.1.4) - 2026-01-09
### Mises a jour de fonctionnalites
- Le comportement de la fenêtre au démarrage est plus naturel.
- Le comportement global des réglages est plus cohérent.
## [v1.1.3](https://github.com/nexmoe/VidBee/releases/tag/v1.1.3) - 2026-01-02
### Mises a jour de fonctionnalites
- Le statut de mise à jour est plus visible sur la page About.
### Corrections de bugs
- La sélection de format est plus fiable selon les scénarios.
## [v1.1.2](https://github.com/nexmoe/VidBee/releases/tag/v1.1.2) - 2025-12-26
### Mises a jour de fonctionnalites
- La disponibilité du téléchargement a été rétablie pour davantage de sites.
- Le flux de signalement des problèmes est plus simple.
## [v1.1.1](https://github.com/nexmoe/VidBee/releases/tag/v1.1.1) - 2025-12-26
### Mises a jour de fonctionnalites
- Les notifications de mise à jour sont moins perturbantes.
- Les textes et liens de la page About sont plus clairs.
- Les interactions du panneau de téléchargement sont plus fluides.
## [v1.1.0](https://github.com/nexmoe/VidBee/releases/tag/v1.1.0) - 2025-12-20
### Mises a jour de fonctionnalites
- Ajout d'actions groupées pour nettoyer l'historique des téléchargements.
- L'ouverture des liens de tâche de téléchargement est plus prévisible.
- Ajout de la prise en charge des dossiers de téléchargement personnalisés.
- La boîte de dialogue de configuration RSS est plus simple à comprendre et à remplir.
## [v1.0.2](https://github.com/nexmoe/VidBee/releases/tag/v1.0.2) - 2025-12-06
### Mises a jour de fonctionnalites
- La saisie des chemins est plus tolérante au quotidien.
### Corrections de bugs
- Ajout de plus d'options de compatibilité pour davantage de scénarios d'usage.
## [v1.0.1](https://github.com/nexmoe/VidBee/releases/tag/v1.0.1) - 2025-11-16
### Mises a jour de fonctionnalites
- Ajout de la prise en charge du lancement automatique.
- La prise en charge des langues a été encore élargie.
## [v1.0.0](https://github.com/nexmoe/VidBee/releases/tag/v1.0.0) - 2025-11-15
### Mises a jour de fonctionnalites
- Première version majeure stable de VidBee.
- Ajout des téléchargements via abonnements RSS.
- La navigation et le flux général de l'interface sont plus clairs.
- L'historique et l'aperçu des médias ont été améliorés.
## [v0.3.5](https://github.com/nexmoe/VidBee/releases/tag/v0.3.5) - 2025-11-08
### Mises a jour de fonctionnalites
- Les textes et messages du téléchargement en un clic sont plus faciles à comprendre.
- Le style visuel est plus cohérent.
## [v0.3.4](https://github.com/nexmoe/VidBee/releases/tag/v0.3.4) - 2025-11-03
### Mises a jour de fonctionnalites
- Les messages de mise à jour et l'affichage des options de téléchargement sont plus clairs.
## [v0.3.3](https://github.com/nexmoe/VidBee/releases/tag/v0.3.3) - 2025-11-02
### Corrections de bugs
- La stabilité du traitement des téléchargements a été améliorée dans davantage de scénarios.
## [v0.3.2](https://github.com/nexmoe/VidBee/releases/tag/v0.3.2) - 2025-10-31
### Mises a jour de fonctionnalites
- L'expérience de distribution multi-appareils a été améliorée.
## [v0.3.1](https://github.com/nexmoe/VidBee/releases/tag/v0.3.1) - 2025-10-30
### Mises a jour de fonctionnalites
- L'expérience Linux est plus conviviale.
- Ajout de notifications de nouvelles versions pour des mises à niveau plus rapides.
## [v0.3.0](https://github.com/nexmoe/VidBee/releases/tag/v0.3.0) - 2025-10-29
### Mises a jour de fonctionnalites
- Ajout de la prise en charge du téléchargement de playlists.
- Ajout de contrôles pour réduire les perturbations sur le bureau.
## [v0.2.2](https://github.com/nexmoe/VidBee/releases/tag/v0.2.2) - 2025-10-27
### Mises a jour de fonctionnalites
- Poursuite du peaufinage UX pendant la phase de préversion.
## [v0.2.1](https://github.com/nexmoe/VidBee/releases/tag/v0.2.1) - 2025-10-26
### Mises a jour de fonctionnalites
- Poursuite du peaufinage UX pendant la phase de préversion.
## [v0.2.0](https://github.com/nexmoe/VidBee/releases/tag/v0.2.0) - 2025-10-25
### Mises a jour de fonctionnalites
- Poursuite du peaufinage UX pendant la phase de préversion.
## [v0.1.8](https://github.com/nexmoe/VidBee/releases/tag/v0.1.8) - 2025-10-24
### Mises a jour de fonctionnalites
- Le programme de préversion publique a démarré.
## [v0.1.7](https://github.com/nexmoe/VidBee/releases/tag/v0.1.7) - 2025-10-24
### Mises a jour de fonctionnalites
- Ajout de la prise en charge de la mise a jour automatique et amélioration des consignes de publication dans la documentation.
- Amélioration de la documentation du projet, y compris les captures d'écran et le guide de contribution.
### Corrections de bugs
- Simplification de la gestion des chemins de téléchargement et suppression de la logique de chemin de sortie inutilisée.
## [v0.1.6](https://github.com/nexmoe/VidBee/releases/tag/v0.1.6) - 2025-10-23
### Corrections de bugs
- Suppression d'une étape inutile de création de dossier dans le workflow de publication.
## [v0.1.5](https://github.com/nexmoe/VidBee/releases/tag/v0.1.5) - 2025-10-23
### Corrections de bugs
- Amélioration du workflow de publication pour télécharger les binaires yt-dlp lors du packaging multiplateforme.
## [v0.1.4](https://github.com/nexmoe/VidBee/releases/tag/v0.1.4) - 2025-10-23
### Corrections de bugs
- Mise à jour du workflow de publication pour cibler uniquement les builds Windows.
## [v0.1.3](https://github.com/nexmoe/VidBee/releases/tag/v0.1.3) - 2025-10-23
### Corrections de bugs
- Simplification des étapes de build de publication et de la gestion des artefacts dans CI.
- Ajustement des déclencheurs CI: l'automatisation des pull requests ne s'exécute que pour `main`.
## [v0.1.2](https://github.com/nexmoe/VidBee/releases/tag/v0.1.2) - 2025-10-23
### Corrections de bugs
- Définition explicite du shell pour l'étape de build dans le workflow de publication.
## [v0.1.1](https://github.com/nexmoe/VidBee/releases/tag/v0.1.1) - 2025-10-23
### Mises a jour de fonctionnalites
- Itération de release précoce sans changement utilisateur supplémentaire documenté.
## [v0.1.0](https://github.com/nexmoe/VidBee/releases/tag/v0.1.0) - 2025-10-23
### Mises a jour de fonctionnalites
- Point de départ de la première release publique.

Some files were not shown because too many files have changed in this diff Show More