Clean release snapshot
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"versions": {
|
||||||
|
"node": "22",
|
||||||
|
"python": "3.12.9",
|
||||||
|
"electron": "35.0.0",
|
||||||
|
"cmake": "3.22",
|
||||||
|
"ubuntu": "22.04"
|
||||||
|
},
|
||||||
|
"external": {
|
||||||
|
"fluidsynth_windows": {
|
||||||
|
"version": "v2.5.4",
|
||||||
|
"url": "https://github.com/FluidSynth/fluidsynth/releases/download/v2.5.4/fluidsynth-v2.5.4-win10-x64-cpp11.zip"
|
||||||
|
},
|
||||||
|
"soundfont_general_user": {
|
||||||
|
"url": "https://github.com/slopsmith/slopsmith-desktop/releases/download/soundfonts-v1/GeneralUser-GS.sf2",
|
||||||
|
"sha256": "9575028c7a1f589f5770fccc8cff2734566af40cd26ed836944e9a5152688cfe"
|
||||||
|
},
|
||||||
|
"python_standalone_macos_arm64": {
|
||||||
|
"url": "https://github.com/astral-sh/python-build-standalone/releases/download/20250317/cpython-3.12.9+20250317-aarch64-apple-darwin-install_only_stripped.tar.gz",
|
||||||
|
"sha256": "0a4647b7df3c8eca11071d6cea68a14a4b102bd6fc6afae314e9852510654b7d"
|
||||||
|
},
|
||||||
|
"python_standalone_macos_x64": {
|
||||||
|
"url": "https://github.com/astral-sh/python-build-standalone/releases/download/20250317/cpython-3.12.9+20250317-x86_64-apple-darwin-install_only_stripped.tar.gz",
|
||||||
|
"sha256": "1a414bf392a7afe08c742502a82edd41893a1144ccbceb184dc5ee6ee9c069c0"
|
||||||
|
},
|
||||||
|
"python_standalone_linux_x64": {
|
||||||
|
"url": "https://github.com/astral-sh/python-build-standalone/releases/download/20250317/cpython-3.12.9+20250317-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz",
|
||||||
|
"sha256": "a36bc60c38fe146e908e2e71fc21266c8558b24a9407226b1d887212839437ef"
|
||||||
|
},
|
||||||
|
"ffmpeg_macos_arm64": {
|
||||||
|
"url": "https://www.osxexperts.net/ffmpeg81arm.zip",
|
||||||
|
"sha256": "ebb82529562b71170807bbc6b0e7eb4f0b13af8cbb0e085bb9e8f6fe709598ad"
|
||||||
|
},
|
||||||
|
"ffprobe_macos_arm64": {
|
||||||
|
"url": "https://www.osxexperts.net/ffprobe81arm.zip",
|
||||||
|
"sha256": "a6640a77d38a6f0527c5b597e599cb36a3427a6931444ed80bc62542421950a1"
|
||||||
|
},
|
||||||
|
"ffmpeg_macos_x64": {
|
||||||
|
"url": "https://evermeet.cx/ffmpeg/ffmpeg-8.1.1.zip",
|
||||||
|
"sha256": "4610988e2f54c243c50da73a09e4e2c36d9bb77546f9aa6c84cb328dcb1a98c1"
|
||||||
|
},
|
||||||
|
"ffprobe_macos_x64": {
|
||||||
|
"url": "https://evermeet.cx/ffmpeg/ffprobe-8.1.1.zip",
|
||||||
|
"sha256": "aeade29dee3c3844e9bcc974f4ae4b29cc4f87994177d77003a8589fa531009e"
|
||||||
|
},
|
||||||
|
"ffmpeg_macos_rubberband": {
|
||||||
|
"_comment": "Intel ffmpeg with --enable-librubberband, bundled as resources/bin/ffmpeg-rubberband on Apple Silicon ONLY (runs via Rosetta 2). The native arm64 static builds (osxexperts/martin-riedl) omit librubberband, so Retune's pitch-shift step has no rubberband filter there. Used solely by lib/retune.py; all other ffmpeg use stays on the native arm64 binary. Same evermeet build as ffmpeg_macos_x64.",
|
||||||
|
"url": "https://evermeet.cx/ffmpeg/ffmpeg-8.1.1.zip",
|
||||||
|
"sha256": "4610988e2f54c243c50da73a09e4e2c36d9bb77546f9aa6c84cb328dcb1a98c1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
---
|
||||||
|
name: "speckit-analyze"
|
||||||
|
description: "Perform a non-destructive cross-artifact consistency and quality analysis across spec.md, plan.md, and tasks.md after task generation."
|
||||||
|
argument-hint: "Optional focus areas for analysis"
|
||||||
|
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||||
|
metadata:
|
||||||
|
author: "github-spec-kit"
|
||||||
|
source: "templates/commands/analyze.md"
|
||||||
|
user-invocable: true
|
||||||
|
disable-model-invocation: false
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## User Input
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ARGUMENTS
|
||||||
|
```
|
||||||
|
|
||||||
|
You **MUST** consider the user input before proceeding (if not empty).
|
||||||
|
|
||||||
|
## Pre-Execution Checks
|
||||||
|
|
||||||
|
**Check for extension hooks (before analysis)**:
|
||||||
|
- Check if `.specify/extensions.yml` exists in the project root.
|
||||||
|
- If it exists, read it and look for entries under the `hooks.before_analyze` key
|
||||||
|
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||||
|
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||||
|
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||||
|
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||||
|
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||||
|
- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||||
|
- For each executable hook, output the following based on its `optional` flag:
|
||||||
|
- **Optional hook** (`optional: true`):
|
||||||
|
```
|
||||||
|
## Extension Hooks
|
||||||
|
|
||||||
|
**Optional Pre-Hook**: {extension}
|
||||||
|
Command: `/{command}`
|
||||||
|
Description: {description}
|
||||||
|
|
||||||
|
Prompt: {prompt}
|
||||||
|
To execute: `/{command}`
|
||||||
|
```
|
||||||
|
- **Mandatory hook** (`optional: false`):
|
||||||
|
```
|
||||||
|
## Extension Hooks
|
||||||
|
|
||||||
|
**Automatic Pre-Hook**: {extension}
|
||||||
|
Executing: `/{command}`
|
||||||
|
EXECUTE_COMMAND: {command}
|
||||||
|
|
||||||
|
Wait for the result of the hook command before proceeding to the Goal.
|
||||||
|
```
|
||||||
|
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Identify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. This command MUST run only after `/speckit-tasks` has successfully produced a complete `tasks.md`.
|
||||||
|
|
||||||
|
## Operating Constraints
|
||||||
|
|
||||||
|
**STRICTLY READ-ONLY**: Do **not** modify any files. Output a structured analysis report. Offer an optional remediation plan (user must explicitly approve before any follow-up editing commands would be invoked manually).
|
||||||
|
|
||||||
|
**Constitution Authority**: The project constitution (`.specify/memory/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks—not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `/speckit-analyze`.
|
||||||
|
|
||||||
|
## Execution Steps
|
||||||
|
|
||||||
|
### 1. Initialize Analysis Context
|
||||||
|
|
||||||
|
Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` once from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS. Derive absolute paths:
|
||||||
|
|
||||||
|
- SPEC = FEATURE_DIR/spec.md
|
||||||
|
- PLAN = FEATURE_DIR/plan.md
|
||||||
|
- TASKS = FEATURE_DIR/tasks.md
|
||||||
|
|
||||||
|
Abort with an error message if any required file is missing (instruct the user to run missing prerequisite command).
|
||||||
|
For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||||
|
|
||||||
|
### 2. Load Artifacts (Progressive Disclosure)
|
||||||
|
|
||||||
|
Load only the minimal necessary context from each artifact:
|
||||||
|
|
||||||
|
**From spec.md:**
|
||||||
|
|
||||||
|
- Overview/Context
|
||||||
|
- Functional Requirements
|
||||||
|
- Success Criteria (measurable outcomes — e.g., performance, security, availability, user success, business impact)
|
||||||
|
- User Stories
|
||||||
|
- Edge Cases (if present)
|
||||||
|
|
||||||
|
**From plan.md:**
|
||||||
|
|
||||||
|
- Architecture/stack choices
|
||||||
|
- Data Model references
|
||||||
|
- Phases
|
||||||
|
- Technical constraints
|
||||||
|
|
||||||
|
**From tasks.md:**
|
||||||
|
|
||||||
|
- Task IDs
|
||||||
|
- Descriptions
|
||||||
|
- Phase grouping
|
||||||
|
- Parallel markers [P]
|
||||||
|
- Referenced file paths
|
||||||
|
|
||||||
|
**From constitution:**
|
||||||
|
|
||||||
|
- Load `.specify/memory/constitution.md` for principle validation
|
||||||
|
|
||||||
|
### 3. Build Semantic Models
|
||||||
|
|
||||||
|
Create internal representations (do not include raw artifacts in output):
|
||||||
|
|
||||||
|
- **Requirements inventory**: For each Functional Requirement (FR-###) and Success Criterion (SC-###), record a stable key. Use the explicit FR-/SC- identifier as the primary key when present, and optionally also derive an imperative-phrase slug for readability (e.g., "User can upload file" → `user-can-upload-file`). Include only Success Criteria items that require buildable work (e.g., load-testing infrastructure, security audit tooling), and exclude post-launch outcome metrics and business KPIs (e.g., "Reduce support tickets by 50%").
|
||||||
|
- **User story/action inventory**: Discrete user actions with acceptance criteria
|
||||||
|
- **Task coverage mapping**: Map each task to one or more requirements or stories (inference by keyword / explicit reference patterns like IDs or key phrases)
|
||||||
|
- **Constitution rule set**: Extract principle names and MUST/SHOULD normative statements
|
||||||
|
|
||||||
|
### 4. Detection Passes (Token-Efficient Analysis)
|
||||||
|
|
||||||
|
Focus on high-signal findings. Limit to 50 findings total; aggregate remainder in overflow summary.
|
||||||
|
|
||||||
|
#### A. Duplication Detection
|
||||||
|
|
||||||
|
- Identify near-duplicate requirements
|
||||||
|
- Mark lower-quality phrasing for consolidation
|
||||||
|
|
||||||
|
#### B. Ambiguity Detection
|
||||||
|
|
||||||
|
- Flag vague adjectives (fast, scalable, secure, intuitive, robust) lacking measurable criteria
|
||||||
|
- Flag unresolved placeholders (TODO, TKTK, ???, `<placeholder>`, etc.)
|
||||||
|
|
||||||
|
#### C. Underspecification
|
||||||
|
|
||||||
|
- Requirements with verbs but missing object or measurable outcome
|
||||||
|
- User stories missing acceptance criteria alignment
|
||||||
|
- Tasks referencing files or components not defined in spec/plan
|
||||||
|
|
||||||
|
#### D. Constitution Alignment
|
||||||
|
|
||||||
|
- Any requirement or plan element conflicting with a MUST principle
|
||||||
|
- Missing mandated sections or quality gates from constitution
|
||||||
|
|
||||||
|
#### E. Coverage Gaps
|
||||||
|
|
||||||
|
- Requirements with zero associated tasks
|
||||||
|
- Tasks with no mapped requirement/story
|
||||||
|
- Success Criteria requiring buildable work (performance, security, availability) not reflected in tasks
|
||||||
|
|
||||||
|
#### F. Inconsistency
|
||||||
|
|
||||||
|
- Terminology drift (same concept named differently across files)
|
||||||
|
- Data entities referenced in plan but absent in spec (or vice versa)
|
||||||
|
- Task ordering contradictions (e.g., integration tasks before foundational setup tasks without dependency note)
|
||||||
|
- Conflicting requirements (e.g., one requires Next.js while other specifies Vue)
|
||||||
|
|
||||||
|
### 5. Severity Assignment
|
||||||
|
|
||||||
|
Use this heuristic to prioritize findings:
|
||||||
|
|
||||||
|
- **CRITICAL**: Violates constitution MUST, missing core spec artifact, or requirement with zero coverage that blocks baseline functionality
|
||||||
|
- **HIGH**: Duplicate or conflicting requirement, ambiguous security/performance attribute, untestable acceptance criterion
|
||||||
|
- **MEDIUM**: Terminology drift, missing non-functional task coverage, underspecified edge case
|
||||||
|
- **LOW**: Style/wording improvements, minor redundancy not affecting execution order
|
||||||
|
|
||||||
|
### 6. Produce Compact Analysis Report
|
||||||
|
|
||||||
|
Output a Markdown report (no file writes) with the following structure:
|
||||||
|
|
||||||
|
## Specification Analysis Report
|
||||||
|
|
||||||
|
| ID | Category | Severity | Location(s) | Summary | Recommendation |
|
||||||
|
|----|----------|----------|-------------|---------|----------------|
|
||||||
|
| A1 | Duplication | HIGH | spec.md:L120-134 | Two similar requirements ... | Merge phrasing; keep clearer version |
|
||||||
|
|
||||||
|
(Add one row per finding; generate stable IDs prefixed by category initial.)
|
||||||
|
|
||||||
|
**Coverage Summary Table:**
|
||||||
|
|
||||||
|
| Requirement Key | Has Task? | Task IDs | Notes |
|
||||||
|
|-----------------|-----------|----------|-------|
|
||||||
|
|
||||||
|
**Constitution Alignment Issues:** (if any)
|
||||||
|
|
||||||
|
**Unmapped Tasks:** (if any)
|
||||||
|
|
||||||
|
**Metrics:**
|
||||||
|
|
||||||
|
- Total Requirements
|
||||||
|
- Total Tasks
|
||||||
|
- Coverage % (requirements with >=1 task)
|
||||||
|
- Ambiguity Count
|
||||||
|
- Duplication Count
|
||||||
|
- Critical Issues Count
|
||||||
|
|
||||||
|
### 7. Provide Next Actions
|
||||||
|
|
||||||
|
At end of report, output a concise Next Actions block:
|
||||||
|
|
||||||
|
- If CRITICAL issues exist: Recommend resolving before `/speckit-implement`
|
||||||
|
- If only LOW/MEDIUM: User may proceed, but provide improvement suggestions
|
||||||
|
- Provide explicit command suggestions: e.g., "Run /speckit-specify with refinement", "Run /speckit-plan to adjust architecture", "Manually edit tasks.md to add coverage for 'performance-metrics'"
|
||||||
|
|
||||||
|
### 8. Offer Remediation
|
||||||
|
|
||||||
|
Ask the user: "Would you like me to suggest concrete remediation edits for the top N issues?" (Do NOT apply them automatically.)
|
||||||
|
|
||||||
|
### 9. Check for extension hooks
|
||||||
|
|
||||||
|
After reporting, check if `.specify/extensions.yml` exists in the project root.
|
||||||
|
- If it exists, read it and look for entries under the `hooks.after_analyze` key
|
||||||
|
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||||
|
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||||
|
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||||
|
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||||
|
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||||
|
- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||||
|
- For each executable hook, output the following based on its `optional` flag:
|
||||||
|
- **Optional hook** (`optional: true`):
|
||||||
|
```
|
||||||
|
## Extension Hooks
|
||||||
|
|
||||||
|
**Optional Hook**: {extension}
|
||||||
|
Command: `/{command}`
|
||||||
|
Description: {description}
|
||||||
|
|
||||||
|
Prompt: {prompt}
|
||||||
|
To execute: `/{command}`
|
||||||
|
```
|
||||||
|
- **Mandatory hook** (`optional: false`):
|
||||||
|
```
|
||||||
|
## Extension Hooks
|
||||||
|
|
||||||
|
**Automatic Hook**: {extension}
|
||||||
|
Executing: `/{command}`
|
||||||
|
EXECUTE_COMMAND: {command}
|
||||||
|
```
|
||||||
|
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||||
|
|
||||||
|
## Operating Principles
|
||||||
|
|
||||||
|
### Context Efficiency
|
||||||
|
|
||||||
|
- **Minimal high-signal tokens**: Focus on actionable findings, not exhaustive documentation
|
||||||
|
- **Progressive disclosure**: Load artifacts incrementally; don't dump all content into analysis
|
||||||
|
- **Token-efficient output**: Limit findings table to 50 rows; summarize overflow
|
||||||
|
- **Deterministic results**: Rerunning without changes should produce consistent IDs and counts
|
||||||
|
|
||||||
|
### Analysis Guidelines
|
||||||
|
|
||||||
|
- **NEVER modify files** (this is read-only analysis)
|
||||||
|
- **NEVER hallucinate missing sections** (if absent, report them accurately)
|
||||||
|
- **Prioritize constitution violations** (these are always CRITICAL)
|
||||||
|
- **Use examples over exhaustive rules** (cite specific instances, not generic patterns)
|
||||||
|
- **Report zero issues gracefully** (emit success report with coverage statistics)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
$ARGUMENTS
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
---
|
||||||
|
name: "speckit-checklist"
|
||||||
|
description: "Generate a custom checklist for the current feature based on user requirements."
|
||||||
|
argument-hint: "Domain or focus area for the checklist"
|
||||||
|
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||||
|
metadata:
|
||||||
|
author: "github-spec-kit"
|
||||||
|
source: "templates/commands/checklist.md"
|
||||||
|
user-invocable: true
|
||||||
|
disable-model-invocation: false
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Checklist Purpose: "Unit Tests for English"
|
||||||
|
|
||||||
|
**CRITICAL CONCEPT**: Checklists are **UNIT TESTS FOR REQUIREMENTS WRITING** - they validate the quality, clarity, and completeness of requirements in a given domain.
|
||||||
|
|
||||||
|
**NOT for verification/testing**:
|
||||||
|
|
||||||
|
- ❌ NOT "Verify the button clicks correctly"
|
||||||
|
- ❌ NOT "Test error handling works"
|
||||||
|
- ❌ NOT "Confirm the API returns 200"
|
||||||
|
- ❌ NOT checking if code/implementation matches the spec
|
||||||
|
|
||||||
|
**FOR requirements quality validation**:
|
||||||
|
|
||||||
|
- ✅ "Are visual hierarchy requirements defined for all card types?" (completeness)
|
||||||
|
- ✅ "Is 'prominent display' quantified with specific sizing/positioning?" (clarity)
|
||||||
|
- ✅ "Are hover state requirements consistent across all interactive elements?" (consistency)
|
||||||
|
- ✅ "Are accessibility requirements defined for keyboard navigation?" (coverage)
|
||||||
|
- ✅ "Does the spec define what happens when logo image fails to load?" (edge cases)
|
||||||
|
|
||||||
|
**Metaphor**: If your spec is code written in English, the checklist is its unit test suite. You're testing whether the requirements are well-written, complete, unambiguous, and ready for implementation - NOT whether the implementation works.
|
||||||
|
|
||||||
|
## User Input
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ARGUMENTS
|
||||||
|
```
|
||||||
|
|
||||||
|
You **MUST** consider the user input before proceeding (if not empty).
|
||||||
|
|
||||||
|
## Pre-Execution Checks
|
||||||
|
|
||||||
|
**Check for extension hooks (before checklist generation)**:
|
||||||
|
- Check if `.specify/extensions.yml` exists in the project root.
|
||||||
|
- If it exists, read it and look for entries under the `hooks.before_checklist` key
|
||||||
|
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||||
|
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||||
|
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||||
|
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||||
|
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||||
|
- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||||
|
- For each executable hook, output the following based on its `optional` flag:
|
||||||
|
- **Optional hook** (`optional: true`):
|
||||||
|
```
|
||||||
|
## Extension Hooks
|
||||||
|
|
||||||
|
**Optional Pre-Hook**: {extension}
|
||||||
|
Command: `/{command}`
|
||||||
|
Description: {description}
|
||||||
|
|
||||||
|
Prompt: {prompt}
|
||||||
|
To execute: `/{command}`
|
||||||
|
```
|
||||||
|
- **Mandatory hook** (`optional: false`):
|
||||||
|
```
|
||||||
|
## Extension Hooks
|
||||||
|
|
||||||
|
**Automatic Pre-Hook**: {extension}
|
||||||
|
Executing: `/{command}`
|
||||||
|
EXECUTE_COMMAND: {command}
|
||||||
|
|
||||||
|
Wait for the result of the hook command before proceeding to the Execution Steps.
|
||||||
|
```
|
||||||
|
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||||
|
|
||||||
|
## Execution Steps
|
||||||
|
|
||||||
|
1. **Setup**: Run `.specify/scripts/bash/check-prerequisites.sh --json` from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS list.
|
||||||
|
- All file paths must be absolute.
|
||||||
|
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||||
|
|
||||||
|
2. **Clarify intent (dynamic)**: Derive up to THREE initial contextual clarifying questions (no pre-baked catalog). They MUST:
|
||||||
|
- Be generated from the user's phrasing + extracted signals from spec/plan/tasks
|
||||||
|
- Only ask about information that materially changes checklist content
|
||||||
|
- Be skipped individually if already unambiguous in `$ARGUMENTS`
|
||||||
|
- Prefer precision over breadth
|
||||||
|
|
||||||
|
Generation algorithm:
|
||||||
|
1. Extract signals: feature domain keywords (e.g., auth, latency, UX, API), risk indicators ("critical", "must", "compliance"), stakeholder hints ("QA", "review", "security team"), and explicit deliverables ("a11y", "rollback", "contracts").
|
||||||
|
2. Cluster signals into candidate focus areas (max 4) ranked by relevance.
|
||||||
|
3. Identify probable audience & timing (author, reviewer, QA, release) if not explicit.
|
||||||
|
4. Detect missing dimensions: scope breadth, depth/rigor, risk emphasis, exclusion boundaries, measurable acceptance criteria.
|
||||||
|
5. Formulate questions chosen from these archetypes:
|
||||||
|
- Scope refinement (e.g., "Should this include integration touchpoints with X and Y or stay limited to local module correctness?")
|
||||||
|
- Risk prioritization (e.g., "Which of these potential risk areas should receive mandatory gating checks?")
|
||||||
|
- Depth calibration (e.g., "Is this a lightweight pre-commit sanity list or a formal release gate?")
|
||||||
|
- Audience framing (e.g., "Will this be used by the author only or peers during PR review?")
|
||||||
|
- Boundary exclusion (e.g., "Should we explicitly exclude performance tuning items this round?")
|
||||||
|
- Scenario class gap (e.g., "No recovery flows detected—are rollback / partial failure paths in scope?")
|
||||||
|
|
||||||
|
Question formatting rules:
|
||||||
|
- If presenting options, generate a compact table with columns: Option | Candidate | Why It Matters
|
||||||
|
- Limit to A–E options maximum; omit table if a free-form answer is clearer
|
||||||
|
- Never ask the user to restate what they already said
|
||||||
|
- Avoid speculative categories (no hallucination). If uncertain, ask explicitly: "Confirm whether X belongs in scope."
|
||||||
|
|
||||||
|
Defaults when interaction impossible:
|
||||||
|
- Depth: Standard
|
||||||
|
- Audience: Reviewer (PR) if code-related; Author otherwise
|
||||||
|
- Focus: Top 2 relevance clusters
|
||||||
|
|
||||||
|
Output the questions (label Q1/Q2/Q3). After answers: if ≥2 scenario classes (Alternate / Exception / Recovery / Non-Functional domain) remain unclear, you MAY ask up to TWO more targeted follow‑ups (Q4/Q5) with a one-line justification each (e.g., "Unresolved recovery path risk"). Do not exceed five total questions. Skip escalation if user explicitly declines more.
|
||||||
|
|
||||||
|
3. **Understand user request**: Combine `$ARGUMENTS` + clarifying answers:
|
||||||
|
- Derive checklist theme (e.g., security, review, deploy, ux)
|
||||||
|
- Consolidate explicit must-have items mentioned by user
|
||||||
|
- Map focus selections to category scaffolding
|
||||||
|
- Infer any missing context from spec/plan/tasks (do NOT hallucinate)
|
||||||
|
|
||||||
|
4. **Load feature context**: Read from FEATURE_DIR:
|
||||||
|
- spec.md: Feature requirements and scope
|
||||||
|
- plan.md (if exists): Technical details, dependencies
|
||||||
|
- tasks.md (if exists): Implementation tasks
|
||||||
|
|
||||||
|
**Context Loading Strategy**:
|
||||||
|
- Load only necessary portions relevant to active focus areas (avoid full-file dumping)
|
||||||
|
- Prefer summarizing long sections into concise scenario/requirement bullets
|
||||||
|
- Use progressive disclosure: add follow-on retrieval only if gaps detected
|
||||||
|
- If source docs are large, generate interim summary items instead of embedding raw text
|
||||||
|
|
||||||
|
5. **Generate checklist** - Create "Unit Tests for Requirements":
|
||||||
|
- Create `FEATURE_DIR/checklists/` directory if it doesn't exist
|
||||||
|
- Generate unique checklist filename:
|
||||||
|
- Use short, descriptive name based on domain (e.g., `ux.md`, `api.md`, `security.md`)
|
||||||
|
- Format: `[domain].md`
|
||||||
|
- File handling behavior:
|
||||||
|
- If file does NOT exist: Create new file and number items starting from CHK001
|
||||||
|
- If file exists: Append new items to existing file, continuing from the last CHK ID (e.g., if last item is CHK015, start new items at CHK016)
|
||||||
|
- Never delete or replace existing checklist content - always preserve and append
|
||||||
|
|
||||||
|
**CORE PRINCIPLE - Test the Requirements, Not the Implementation**:
|
||||||
|
Every checklist item MUST evaluate the REQUIREMENTS THEMSELVES for:
|
||||||
|
- **Completeness**: Are all necessary requirements present?
|
||||||
|
- **Clarity**: Are requirements unambiguous and specific?
|
||||||
|
- **Consistency**: Do requirements align with each other?
|
||||||
|
- **Measurability**: Can requirements be objectively verified?
|
||||||
|
- **Coverage**: Are all scenarios/edge cases addressed?
|
||||||
|
|
||||||
|
**Category Structure** - Group items by requirement quality dimensions:
|
||||||
|
- **Requirement Completeness** (Are all necessary requirements documented?)
|
||||||
|
- **Requirement Clarity** (Are requirements specific and unambiguous?)
|
||||||
|
- **Requirement Consistency** (Do requirements align without conflicts?)
|
||||||
|
- **Acceptance Criteria Quality** (Are success criteria measurable?)
|
||||||
|
- **Scenario Coverage** (Are all flows/cases addressed?)
|
||||||
|
- **Edge Case Coverage** (Are boundary conditions defined?)
|
||||||
|
- **Non-Functional Requirements** (Performance, Security, Accessibility, etc. - are they specified?)
|
||||||
|
- **Dependencies & Assumptions** (Are they documented and validated?)
|
||||||
|
- **Ambiguities & Conflicts** (What needs clarification?)
|
||||||
|
|
||||||
|
**HOW TO WRITE CHECKLIST ITEMS - "Unit Tests for English"**:
|
||||||
|
|
||||||
|
❌ **WRONG** (Testing implementation):
|
||||||
|
- "Verify landing page displays 3 episode cards"
|
||||||
|
- "Test hover states work on desktop"
|
||||||
|
- "Confirm logo click navigates home"
|
||||||
|
|
||||||
|
✅ **CORRECT** (Testing requirements quality):
|
||||||
|
- "Are the exact number and layout of featured episodes specified?" [Completeness]
|
||||||
|
- "Is 'prominent display' quantified with specific sizing/positioning?" [Clarity]
|
||||||
|
- "Are hover state requirements consistent across all interactive elements?" [Consistency]
|
||||||
|
- "Are keyboard navigation requirements defined for all interactive UI?" [Coverage]
|
||||||
|
- "Is the fallback behavior specified when logo image fails to load?" [Edge Cases]
|
||||||
|
- "Are loading states defined for asynchronous episode data?" [Completeness]
|
||||||
|
- "Does the spec define visual hierarchy for competing UI elements?" [Clarity]
|
||||||
|
|
||||||
|
**ITEM STRUCTURE**:
|
||||||
|
Each item should follow this pattern:
|
||||||
|
- Question format asking about requirement quality
|
||||||
|
- Focus on what's WRITTEN (or not written) in the spec/plan
|
||||||
|
- Include quality dimension in brackets [Completeness/Clarity/Consistency/etc.]
|
||||||
|
- Reference spec section `[Spec §X.Y]` when checking existing requirements
|
||||||
|
- Use `[Gap]` marker when checking for missing requirements
|
||||||
|
|
||||||
|
**EXAMPLES BY QUALITY DIMENSION**:
|
||||||
|
|
||||||
|
Completeness:
|
||||||
|
- "Are error handling requirements defined for all API failure modes? [Gap]"
|
||||||
|
- "Are accessibility requirements specified for all interactive elements? [Completeness]"
|
||||||
|
- "Are mobile breakpoint requirements defined for responsive layouts? [Gap]"
|
||||||
|
|
||||||
|
Clarity:
|
||||||
|
- "Is 'fast loading' quantified with specific timing thresholds? [Clarity, Spec §NFR-2]"
|
||||||
|
- "Are 'related episodes' selection criteria explicitly defined? [Clarity, Spec §FR-5]"
|
||||||
|
- "Is 'prominent' defined with measurable visual properties? [Ambiguity, Spec §FR-4]"
|
||||||
|
|
||||||
|
Consistency:
|
||||||
|
- "Do navigation requirements align across all pages? [Consistency, Spec §FR-10]"
|
||||||
|
- "Are card component requirements consistent between landing and detail pages? [Consistency]"
|
||||||
|
|
||||||
|
Coverage:
|
||||||
|
- "Are requirements defined for zero-state scenarios (no episodes)? [Coverage, Edge Case]"
|
||||||
|
- "Are concurrent user interaction scenarios addressed? [Coverage, Gap]"
|
||||||
|
- "Are requirements specified for partial data loading failures? [Coverage, Exception Flow]"
|
||||||
|
|
||||||
|
Measurability:
|
||||||
|
- "Are visual hierarchy requirements measurable/testable? [Acceptance Criteria, Spec §FR-1]"
|
||||||
|
- "Can 'balanced visual weight' be objectively verified? [Measurability, Spec §FR-2]"
|
||||||
|
|
||||||
|
**Scenario Classification & Coverage** (Requirements Quality Focus):
|
||||||
|
- Check if requirements exist for: Primary, Alternate, Exception/Error, Recovery, Non-Functional scenarios
|
||||||
|
- For each scenario class, ask: "Are [scenario type] requirements complete, clear, and consistent?"
|
||||||
|
- If scenario class missing: "Are [scenario type] requirements intentionally excluded or missing? [Gap]"
|
||||||
|
- Include resilience/rollback when state mutation occurs: "Are rollback requirements defined for migration failures? [Gap]"
|
||||||
|
|
||||||
|
**Traceability Requirements**:
|
||||||
|
- MINIMUM: ≥80% of items MUST include at least one traceability reference
|
||||||
|
- Each item should reference: spec section `[Spec §X.Y]`, or use markers: `[Gap]`, `[Ambiguity]`, `[Conflict]`, `[Assumption]`
|
||||||
|
- If no ID system exists: "Is a requirement & acceptance criteria ID scheme established? [Traceability]"
|
||||||
|
|
||||||
|
**Surface & Resolve Issues** (Requirements Quality Problems):
|
||||||
|
Ask questions about the requirements themselves:
|
||||||
|
- Ambiguities: "Is the term 'fast' quantified with specific metrics? [Ambiguity, Spec §NFR-1]"
|
||||||
|
- Conflicts: "Do navigation requirements conflict between §FR-10 and §FR-10a? [Conflict]"
|
||||||
|
- Assumptions: "Is the assumption of 'always available podcast API' validated? [Assumption]"
|
||||||
|
- Dependencies: "Are external podcast API requirements documented? [Dependency, Gap]"
|
||||||
|
- Missing definitions: "Is 'visual hierarchy' defined with measurable criteria? [Gap]"
|
||||||
|
|
||||||
|
**Content Consolidation**:
|
||||||
|
- Soft cap: If raw candidate items > 40, prioritize by risk/impact
|
||||||
|
- Merge near-duplicates checking the same requirement aspect
|
||||||
|
- If >5 low-impact edge cases, create one item: "Are edge cases X, Y, Z addressed in requirements? [Coverage]"
|
||||||
|
|
||||||
|
**🚫 ABSOLUTELY PROHIBITED** - These make it an implementation test, not a requirements test:
|
||||||
|
- ❌ Any item starting with "Verify", "Test", "Confirm", "Check" + implementation behavior
|
||||||
|
- ❌ References to code execution, user actions, system behavior
|
||||||
|
- ❌ "Displays correctly", "works properly", "functions as expected"
|
||||||
|
- ❌ "Click", "navigate", "render", "load", "execute"
|
||||||
|
- ❌ Test cases, test plans, QA procedures
|
||||||
|
- ❌ Implementation details (frameworks, APIs, algorithms)
|
||||||
|
|
||||||
|
**✅ REQUIRED PATTERNS** - These test requirements quality:
|
||||||
|
- ✅ "Are [requirement type] defined/specified/documented for [scenario]?"
|
||||||
|
- ✅ "Is [vague term] quantified/clarified with specific criteria?"
|
||||||
|
- ✅ "Are requirements consistent between [section A] and [section B]?"
|
||||||
|
- ✅ "Can [requirement] be objectively measured/verified?"
|
||||||
|
- ✅ "Are [edge cases/scenarios] addressed in requirements?"
|
||||||
|
- ✅ "Does the spec define [missing aspect]?"
|
||||||
|
|
||||||
|
6. **Structure Reference**: Generate the checklist following the canonical template in `.specify/templates/checklist-template.md` for title, meta section, category headings, and ID formatting. If template is unavailable, use: H1 title, purpose/created meta lines, `##` category sections containing `- [ ] CHK### <requirement item>` lines with globally incrementing IDs starting at CHK001.
|
||||||
|
|
||||||
|
7. **Report**: Output full path to checklist file, item count, and summarize whether the run created a new file or appended to an existing one. Summarize:
|
||||||
|
- Focus areas selected
|
||||||
|
- Depth level
|
||||||
|
- Actor/timing
|
||||||
|
- Any explicit user-specified must-have items incorporated
|
||||||
|
|
||||||
|
**Important**: Each `/speckit-checklist` command invocation uses a short, descriptive checklist filename and either creates a new file or appends to an existing one. This allows:
|
||||||
|
|
||||||
|
- Multiple checklists of different types (e.g., `ux.md`, `test.md`, `security.md`)
|
||||||
|
- Simple, memorable filenames that indicate checklist purpose
|
||||||
|
- Easy identification and navigation in the `checklists/` folder
|
||||||
|
|
||||||
|
To avoid clutter, use descriptive types and clean up obsolete checklists when done.
|
||||||
|
|
||||||
|
## Example Checklist Types & Sample Items
|
||||||
|
|
||||||
|
**UX Requirements Quality:** `ux.md`
|
||||||
|
|
||||||
|
Sample items (testing the requirements, NOT the implementation):
|
||||||
|
|
||||||
|
- "Are visual hierarchy requirements defined with measurable criteria? [Clarity, Spec §FR-1]"
|
||||||
|
- "Is the number and positioning of UI elements explicitly specified? [Completeness, Spec §FR-1]"
|
||||||
|
- "Are interaction state requirements (hover, focus, active) consistently defined? [Consistency]"
|
||||||
|
- "Are accessibility requirements specified for all interactive elements? [Coverage, Gap]"
|
||||||
|
- "Is fallback behavior defined when images fail to load? [Edge Case, Gap]"
|
||||||
|
- "Can 'prominent display' be objectively measured? [Measurability, Spec §FR-4]"
|
||||||
|
|
||||||
|
**API Requirements Quality:** `api.md`
|
||||||
|
|
||||||
|
Sample items:
|
||||||
|
|
||||||
|
- "Are error response formats specified for all failure scenarios? [Completeness]"
|
||||||
|
- "Are rate limiting requirements quantified with specific thresholds? [Clarity]"
|
||||||
|
- "Are authentication requirements consistent across all endpoints? [Consistency]"
|
||||||
|
- "Are retry/timeout requirements defined for external dependencies? [Coverage, Gap]"
|
||||||
|
- "Is versioning strategy documented in requirements? [Gap]"
|
||||||
|
|
||||||
|
**Performance Requirements Quality:** `performance.md`
|
||||||
|
|
||||||
|
Sample items:
|
||||||
|
|
||||||
|
- "Are performance requirements quantified with specific metrics? [Clarity]"
|
||||||
|
- "Are performance targets defined for all critical user journeys? [Coverage]"
|
||||||
|
- "Are performance requirements under different load conditions specified? [Completeness]"
|
||||||
|
- "Can performance requirements be objectively measured? [Measurability]"
|
||||||
|
- "Are degradation requirements defined for high-load scenarios? [Edge Case, Gap]"
|
||||||
|
|
||||||
|
**Security Requirements Quality:** `security.md`
|
||||||
|
|
||||||
|
Sample items:
|
||||||
|
|
||||||
|
- "Are authentication requirements specified for all protected resources? [Coverage]"
|
||||||
|
- "Are data protection requirements defined for sensitive information? [Completeness]"
|
||||||
|
- "Is the threat model documented and requirements aligned to it? [Traceability]"
|
||||||
|
- "Are security requirements consistent with compliance obligations? [Consistency]"
|
||||||
|
- "Are security failure/breach response requirements defined? [Gap, Exception Flow]"
|
||||||
|
|
||||||
|
## Anti-Examples: What NOT To Do
|
||||||
|
|
||||||
|
**❌ WRONG - These test implementation, not requirements:**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- [ ] CHK001 - Verify landing page displays 3 episode cards [Spec §FR-001]
|
||||||
|
- [ ] CHK002 - Test hover states work correctly on desktop [Spec §FR-003]
|
||||||
|
- [ ] CHK003 - Confirm logo click navigates to home page [Spec §FR-010]
|
||||||
|
- [ ] CHK004 - Check that related episodes section shows 3-5 items [Spec §FR-005]
|
||||||
|
```
|
||||||
|
|
||||||
|
**✅ CORRECT - These test requirements quality:**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- [ ] CHK001 - Are the number and layout of featured episodes explicitly specified? [Completeness, Spec §FR-001]
|
||||||
|
- [ ] CHK002 - Are hover state requirements consistently defined for all interactive elements? [Consistency, Spec §FR-003]
|
||||||
|
- [ ] CHK003 - Are navigation requirements clear for all clickable brand elements? [Clarity, Spec §FR-010]
|
||||||
|
- [ ] CHK004 - Is the selection criteria for related episodes documented? [Gap, Spec §FR-005]
|
||||||
|
- [ ] CHK005 - Are loading state requirements defined for asynchronous episode data? [Gap]
|
||||||
|
- [ ] CHK006 - Can "visual hierarchy" requirements be objectively measured? [Measurability, Spec §FR-001]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Differences:**
|
||||||
|
|
||||||
|
- Wrong: Tests if the system works correctly
|
||||||
|
- Correct: Tests if the requirements are written correctly
|
||||||
|
- Wrong: Verification of behavior
|
||||||
|
- Correct: Validation of requirement quality
|
||||||
|
- Wrong: "Does it do X?"
|
||||||
|
- Correct: "Is X clearly specified?"
|
||||||
|
|
||||||
|
## Post-Execution Checks
|
||||||
|
|
||||||
|
**Check for extension hooks (after checklist generation)**:
|
||||||
|
Check if `.specify/extensions.yml` exists in the project root.
|
||||||
|
- If it exists, read it and look for entries under the `hooks.after_checklist` key
|
||||||
|
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||||
|
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||||
|
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||||
|
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||||
|
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||||
|
- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||||
|
- For each executable hook, output the following based on its `optional` flag:
|
||||||
|
- **Optional hook** (`optional: true`):
|
||||||
|
```
|
||||||
|
## Extension Hooks
|
||||||
|
|
||||||
|
**Optional Hook**: {extension}
|
||||||
|
Command: `/{command}`
|
||||||
|
Description: {description}
|
||||||
|
|
||||||
|
Prompt: {prompt}
|
||||||
|
To execute: `/{command}`
|
||||||
|
```
|
||||||
|
- **Mandatory hook** (`optional: false`):
|
||||||
|
```
|
||||||
|
## Extension Hooks
|
||||||
|
|
||||||
|
**Automatic Hook**: {extension}
|
||||||
|
Executing: `/{command}`
|
||||||
|
EXECUTE_COMMAND: {command}
|
||||||
|
```
|
||||||
|
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
---
|
||||||
|
name: "speckit-clarify"
|
||||||
|
description: "Identify underspecified areas in the current feature spec by asking up to 5 highly targeted clarification questions and encoding answers back into the spec."
|
||||||
|
argument-hint: "Optional areas to clarify in the spec"
|
||||||
|
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||||
|
metadata:
|
||||||
|
author: "github-spec-kit"
|
||||||
|
source: "templates/commands/clarify.md"
|
||||||
|
user-invocable: true
|
||||||
|
disable-model-invocation: false
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## User Input
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ARGUMENTS
|
||||||
|
```
|
||||||
|
|
||||||
|
You **MUST** consider the user input before proceeding (if not empty).
|
||||||
|
|
||||||
|
## Pre-Execution Checks
|
||||||
|
|
||||||
|
**Check for extension hooks (before clarification)**:
|
||||||
|
- Check if `.specify/extensions.yml` exists in the project root.
|
||||||
|
- If it exists, read it and look for entries under the `hooks.before_clarify` key
|
||||||
|
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||||
|
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||||
|
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||||
|
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||||
|
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||||
|
- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||||
|
- For each executable hook, output the following based on its `optional` flag:
|
||||||
|
- **Optional hook** (`optional: true`):
|
||||||
|
```
|
||||||
|
## Extension Hooks
|
||||||
|
|
||||||
|
**Optional Pre-Hook**: {extension}
|
||||||
|
Command: `/{command}`
|
||||||
|
Description: {description}
|
||||||
|
|
||||||
|
Prompt: {prompt}
|
||||||
|
To execute: `/{command}`
|
||||||
|
```
|
||||||
|
- **Mandatory hook** (`optional: false`):
|
||||||
|
```
|
||||||
|
## Extension Hooks
|
||||||
|
|
||||||
|
**Automatic Pre-Hook**: {extension}
|
||||||
|
Executing: `/{command}`
|
||||||
|
EXECUTE_COMMAND: {command}
|
||||||
|
|
||||||
|
Wait for the result of the hook command before proceeding to the Outline.
|
||||||
|
```
|
||||||
|
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||||
|
|
||||||
|
## Outline
|
||||||
|
|
||||||
|
Goal: Detect and reduce ambiguity or missing decision points in the active feature specification and record the clarifications directly in the spec file.
|
||||||
|
|
||||||
|
Note: This clarification workflow is expected to run (and be completed) BEFORE invoking `/speckit-plan`. If the user explicitly states they are skipping clarification (e.g., exploratory spike), you may proceed, but must warn that downstream rework risk increases.
|
||||||
|
|
||||||
|
Execution steps:
|
||||||
|
|
||||||
|
1. Run `.specify/scripts/bash/check-prerequisites.sh --json --paths-only` from repo root **once** (combined `--json --paths-only` mode / `-Json -PathsOnly`). Parse minimal JSON payload fields:
|
||||||
|
- `FEATURE_DIR`
|
||||||
|
- `FEATURE_SPEC`
|
||||||
|
- (Optionally capture `IMPL_PLAN`, `TASKS` for future chained flows.)
|
||||||
|
- If JSON parsing fails, abort and instruct user to re-run `/speckit-specify` or verify feature branch environment.
|
||||||
|
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||||
|
|
||||||
|
2. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked).
|
||||||
|
|
||||||
|
Functional Scope & Behavior:
|
||||||
|
- Core user goals & success criteria
|
||||||
|
- Explicit out-of-scope declarations
|
||||||
|
- User roles / personas differentiation
|
||||||
|
|
||||||
|
Domain & Data Model:
|
||||||
|
- Entities, attributes, relationships
|
||||||
|
- Identity & uniqueness rules
|
||||||
|
- Lifecycle/state transitions
|
||||||
|
- Data volume / scale assumptions
|
||||||
|
|
||||||
|
Interaction & UX Flow:
|
||||||
|
- Critical user journeys / sequences
|
||||||
|
- Error/empty/loading states
|
||||||
|
- Accessibility or localization notes
|
||||||
|
|
||||||
|
Non-Functional Quality Attributes:
|
||||||
|
- Performance (latency, throughput targets)
|
||||||
|
- Scalability (horizontal/vertical, limits)
|
||||||
|
- Reliability & availability (uptime, recovery expectations)
|
||||||
|
- Observability (logging, metrics, tracing signals)
|
||||||
|
- Security & privacy (authN/Z, data protection, threat assumptions)
|
||||||
|
- Compliance / regulatory constraints (if any)
|
||||||
|
|
||||||
|
Integration & External Dependencies:
|
||||||
|
- External services/APIs and failure modes
|
||||||
|
- Data import/export formats
|
||||||
|
- Protocol/versioning assumptions
|
||||||
|
|
||||||
|
Edge Cases & Failure Handling:
|
||||||
|
- Negative scenarios
|
||||||
|
- Rate limiting / throttling
|
||||||
|
- Conflict resolution (e.g., concurrent edits)
|
||||||
|
|
||||||
|
Constraints & Tradeoffs:
|
||||||
|
- Technical constraints (language, storage, hosting)
|
||||||
|
- Explicit tradeoffs or rejected alternatives
|
||||||
|
|
||||||
|
Terminology & Consistency:
|
||||||
|
- Canonical glossary terms
|
||||||
|
- Avoided synonyms / deprecated terms
|
||||||
|
|
||||||
|
Completion Signals:
|
||||||
|
- Acceptance criteria testability
|
||||||
|
- Measurable Definition of Done style indicators
|
||||||
|
|
||||||
|
Misc / Placeholders:
|
||||||
|
- TODO markers / unresolved decisions
|
||||||
|
- Ambiguous adjectives ("robust", "intuitive") lacking quantification
|
||||||
|
|
||||||
|
For each category with Partial or Missing status, add a candidate question opportunity unless:
|
||||||
|
- Clarification would not materially change implementation or validation strategy
|
||||||
|
- Information is better deferred to planning phase (note internally)
|
||||||
|
|
||||||
|
3. Generate (internally) a prioritized queue of candidate clarification questions (maximum 5). Do NOT output them all at once. Apply these constraints:
|
||||||
|
- Maximum of 5 total questions across the whole session.
|
||||||
|
- Each question must be answerable with EITHER:
|
||||||
|
- A short multiple‑choice selection (2–5 distinct, mutually exclusive options), OR
|
||||||
|
- A one-word / short‑phrase answer (explicitly constrain: "Answer in <=5 words").
|
||||||
|
- Only include questions whose answers materially impact architecture, data modeling, task decomposition, test design, UX behavior, operational readiness, or compliance validation.
|
||||||
|
- Ensure category coverage balance: attempt to cover the highest impact unresolved categories first; avoid asking two low-impact questions when a single high-impact area (e.g., security posture) is unresolved.
|
||||||
|
- Exclude questions already answered, trivial stylistic preferences, or plan-level execution details (unless blocking correctness).
|
||||||
|
- Favor clarifications that reduce downstream rework risk or prevent misaligned acceptance tests.
|
||||||
|
- If more than 5 categories remain unresolved, select the top 5 by (Impact * Uncertainty) heuristic.
|
||||||
|
|
||||||
|
4. Sequential questioning loop (interactive):
|
||||||
|
- Present EXACTLY ONE question at a time.
|
||||||
|
- For multiple‑choice questions:
|
||||||
|
- **Analyze all options** and determine the **most suitable option** based on:
|
||||||
|
- Best practices for the project type
|
||||||
|
- Common patterns in similar implementations
|
||||||
|
- Risk reduction (security, performance, maintainability)
|
||||||
|
- Alignment with any explicit project goals or constraints visible in the spec
|
||||||
|
- Present your **recommended option prominently** at the top with clear reasoning (1-2 sentences explaining why this is the best choice).
|
||||||
|
- Format as: `**Recommended:** Option [X] - <reasoning>`
|
||||||
|
- Then render all options as a Markdown table:
|
||||||
|
|
||||||
|
| Option | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| A | <Option A description> |
|
||||||
|
| B | <Option B description> |
|
||||||
|
| C | <Option C description> (add D/E as needed up to 5) |
|
||||||
|
| Short | Provide a different short answer (<=5 words) (Include only if free-form alternative is appropriate) |
|
||||||
|
|
||||||
|
- After the table, add: `You can reply with the option letter (e.g., "A"), accept the recommendation by saying "yes" or "recommended", or provide your own short answer.`
|
||||||
|
- For short‑answer style (no meaningful discrete options):
|
||||||
|
- Provide your **suggested answer** based on best practices and context.
|
||||||
|
- Format as: `**Suggested:** <your proposed answer> - <brief reasoning>`
|
||||||
|
- Then output: `Format: Short answer (<=5 words). You can accept the suggestion by saying "yes" or "suggested", or provide your own answer.`
|
||||||
|
- After the user answers:
|
||||||
|
- If the user replies with "yes", "recommended", or "suggested", use your previously stated recommendation/suggestion as the answer.
|
||||||
|
- Otherwise, validate the answer maps to one option or fits the <=5 word constraint.
|
||||||
|
- If ambiguous, ask for a quick disambiguation (count still belongs to same question; do not advance).
|
||||||
|
- Once satisfactory, record it in working memory (do not yet write to disk) and move to the next queued question.
|
||||||
|
- Stop asking further questions when:
|
||||||
|
- All critical ambiguities resolved early (remaining queued items become unnecessary), OR
|
||||||
|
- User signals completion ("done", "good", "no more"), OR
|
||||||
|
- You reach 5 asked questions.
|
||||||
|
- Never reveal future queued questions in advance.
|
||||||
|
- If no valid questions exist at start, immediately report no critical ambiguities.
|
||||||
|
|
||||||
|
5. Integration after EACH accepted answer (incremental update approach):
|
||||||
|
- Maintain in-memory representation of the spec (loaded once at start) plus the raw file contents.
|
||||||
|
- For the first integrated answer in this session:
|
||||||
|
- Ensure a `## Clarifications` section exists (create it just after the highest-level contextual/overview section per the spec template if missing).
|
||||||
|
- Under it, create (if not present) a `### Session YYYY-MM-DD` subheading for today.
|
||||||
|
- Append a bullet line immediately after acceptance: `- Q: <question> → A: <final answer>`.
|
||||||
|
- Then immediately apply the clarification to the most appropriate section(s):
|
||||||
|
- Functional ambiguity → Update or add a bullet in Functional Requirements.
|
||||||
|
- User interaction / actor distinction → Update User Stories or Actors subsection (if present) with clarified role, constraint, or scenario.
|
||||||
|
- Data shape / entities → Update Data Model (add fields, types, relationships) preserving ordering; note added constraints succinctly.
|
||||||
|
- Non-functional constraint → Add/modify measurable criteria in Success Criteria > Measurable Outcomes (convert vague adjective to metric or explicit target).
|
||||||
|
- Edge case / negative flow → Add a new bullet under Edge Cases / Error Handling (or create such subsection if template provides placeholder for it).
|
||||||
|
- Terminology conflict → Normalize term across spec; retain original only if necessary by adding `(formerly referred to as "X")` once.
|
||||||
|
- If the clarification invalidates an earlier ambiguous statement, replace that statement instead of duplicating; leave no obsolete contradictory text.
|
||||||
|
- Save the spec file AFTER each integration to minimize risk of context loss (atomic overwrite).
|
||||||
|
- Preserve formatting: do not reorder unrelated sections; keep heading hierarchy intact.
|
||||||
|
- Keep each inserted clarification minimal and testable (avoid narrative drift).
|
||||||
|
|
||||||
|
6. Validation (performed after EACH write plus final pass):
|
||||||
|
- Clarifications session contains exactly one bullet per accepted answer (no duplicates).
|
||||||
|
- Total asked (accepted) questions ≤ 5.
|
||||||
|
- Updated sections contain no lingering vague placeholders the new answer was meant to resolve.
|
||||||
|
- No contradictory earlier statement remains (scan for now-invalid alternative choices removed).
|
||||||
|
- Markdown structure valid; only allowed new headings: `## Clarifications`, `### Session YYYY-MM-DD`.
|
||||||
|
- Terminology consistency: same canonical term used across all updated sections.
|
||||||
|
|
||||||
|
7. Write the updated spec back to `FEATURE_SPEC`.
|
||||||
|
|
||||||
|
8. Report completion (after questioning loop ends or early termination):
|
||||||
|
- Number of questions asked & answered.
|
||||||
|
- Path to updated spec.
|
||||||
|
- Sections touched (list names).
|
||||||
|
- Coverage summary table listing each taxonomy category with Status: Resolved (was Partial/Missing and addressed), Deferred (exceeds question quota or better suited for planning), Clear (already sufficient), Outstanding (still Partial/Missing but low impact).
|
||||||
|
- If any Outstanding or Deferred remain, recommend whether to proceed to `/speckit-plan` or run `/speckit-clarify` again later post-plan.
|
||||||
|
- Suggested next command.
|
||||||
|
|
||||||
|
Behavior rules:
|
||||||
|
|
||||||
|
- If no meaningful ambiguities found (or all potential questions would be low-impact), respond: "No critical ambiguities detected worth formal clarification." and suggest proceeding.
|
||||||
|
- If spec file missing, instruct user to run `/speckit-specify` first (do not create a new spec here).
|
||||||
|
- Never exceed 5 total asked questions (clarification retries for a single question do not count as new questions).
|
||||||
|
- Avoid speculative tech stack questions unless the absence blocks functional clarity.
|
||||||
|
- Respect user early termination signals ("stop", "done", "proceed").
|
||||||
|
- If no questions asked due to full coverage, output a compact coverage summary (all categories Clear) then suggest advancing.
|
||||||
|
- If quota reached with unresolved high-impact categories remaining, explicitly flag them under Deferred with rationale.
|
||||||
|
|
||||||
|
Context for prioritization: $ARGUMENTS
|
||||||
|
|
||||||
|
## Post-Execution Checks
|
||||||
|
|
||||||
|
**Check for extension hooks (after clarification)**:
|
||||||
|
Check if `.specify/extensions.yml` exists in the project root.
|
||||||
|
- If it exists, read it and look for entries under the `hooks.after_clarify` key
|
||||||
|
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||||
|
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||||
|
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||||
|
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||||
|
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||||
|
- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||||
|
- For each executable hook, output the following based on its `optional` flag:
|
||||||
|
- **Optional hook** (`optional: true`):
|
||||||
|
```
|
||||||
|
## Extension Hooks
|
||||||
|
|
||||||
|
**Optional Hook**: {extension}
|
||||||
|
Command: `/{command}`
|
||||||
|
Description: {description}
|
||||||
|
|
||||||
|
Prompt: {prompt}
|
||||||
|
To execute: `/{command}`
|
||||||
|
```
|
||||||
|
- **Mandatory hook** (`optional: false`):
|
||||||
|
```
|
||||||
|
## Extension Hooks
|
||||||
|
|
||||||
|
**Automatic Hook**: {extension}
|
||||||
|
Executing: `/{command}`
|
||||||
|
EXECUTE_COMMAND: {command}
|
||||||
|
```
|
||||||
|
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
---
|
||||||
|
name: "speckit-constitution"
|
||||||
|
description: "Create or update the project constitution from interactive or provided principle inputs, ensuring all dependent templates stay in sync."
|
||||||
|
argument-hint: "Principles or values for the project constitution"
|
||||||
|
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||||
|
metadata:
|
||||||
|
author: "github-spec-kit"
|
||||||
|
source: "templates/commands/constitution.md"
|
||||||
|
user-invocable: true
|
||||||
|
disable-model-invocation: false
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## User Input
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ARGUMENTS
|
||||||
|
```
|
||||||
|
|
||||||
|
You **MUST** consider the user input before proceeding (if not empty).
|
||||||
|
|
||||||
|
## Pre-Execution Checks
|
||||||
|
|
||||||
|
**Check for extension hooks (before constitution update)**:
|
||||||
|
- Check if `.specify/extensions.yml` exists in the project root.
|
||||||
|
- If it exists, read it and look for entries under the `hooks.before_constitution` key
|
||||||
|
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||||
|
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||||
|
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||||
|
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||||
|
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||||
|
- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||||
|
- For each executable hook, output the following based on its `optional` flag:
|
||||||
|
- **Optional hook** (`optional: true`):
|
||||||
|
```
|
||||||
|
## Extension Hooks
|
||||||
|
|
||||||
|
**Optional Pre-Hook**: {extension}
|
||||||
|
Command: `/{command}`
|
||||||
|
Description: {description}
|
||||||
|
|
||||||
|
Prompt: {prompt}
|
||||||
|
To execute: `/{command}`
|
||||||
|
```
|
||||||
|
- **Mandatory hook** (`optional: false`):
|
||||||
|
```
|
||||||
|
## Extension Hooks
|
||||||
|
|
||||||
|
**Automatic Pre-Hook**: {extension}
|
||||||
|
Executing: `/{command}`
|
||||||
|
EXECUTE_COMMAND: {command}
|
||||||
|
|
||||||
|
Wait for the result of the hook command before proceeding to the Outline.
|
||||||
|
```
|
||||||
|
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||||
|
|
||||||
|
## Outline
|
||||||
|
|
||||||
|
You are updating the project constitution at `.specify/memory/constitution.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values, (b) fill the template precisely, and (c) propagate any amendments across dependent artifacts.
|
||||||
|
|
||||||
|
**Note**: If `.specify/memory/constitution.md` does not exist yet, it should have been initialized from `.specify/templates/constitution-template.md` during project setup. If it's missing, copy the template first.
|
||||||
|
|
||||||
|
Follow this execution flow:
|
||||||
|
|
||||||
|
1. Load the existing constitution at `.specify/memory/constitution.md`.
|
||||||
|
- Identify every placeholder token of the form `[ALL_CAPS_IDENTIFIER]`.
|
||||||
|
**IMPORTANT**: The user might require less or more principles than the ones used in the template. If a number is specified, respect that - follow the general template. You will update the doc accordingly.
|
||||||
|
|
||||||
|
2. Collect/derive values for placeholders:
|
||||||
|
- If user input (conversation) supplies a value, use it.
|
||||||
|
- Otherwise infer from existing repo context (README, docs, prior constitution versions if embedded).
|
||||||
|
- For governance dates: `RATIFICATION_DATE` is the original adoption date (if unknown ask or mark TODO), `LAST_AMENDED_DATE` is today if changes are made, otherwise keep previous.
|
||||||
|
- `CONSTITUTION_VERSION` must increment according to semantic versioning rules:
|
||||||
|
- MAJOR: Backward incompatible governance/principle removals or redefinitions.
|
||||||
|
- MINOR: New principle/section added or materially expanded guidance.
|
||||||
|
- PATCH: Clarifications, wording, typo fixes, non-semantic refinements.
|
||||||
|
- If version bump type ambiguous, propose reasoning before finalizing.
|
||||||
|
|
||||||
|
3. Draft the updated constitution content:
|
||||||
|
- Replace every placeholder with concrete text (no bracketed tokens left except intentionally retained template slots that the project has chosen not to define yet—explicitly justify any left).
|
||||||
|
- Preserve heading hierarchy and comments can be removed once replaced unless they still add clarifying guidance.
|
||||||
|
- Ensure each Principle section: succinct name line, paragraph (or bullet list) capturing non‑negotiable rules, explicit rationale if not obvious.
|
||||||
|
- Ensure Governance section lists amendment procedure, versioning policy, and compliance review expectations.
|
||||||
|
|
||||||
|
4. Consistency propagation checklist (convert prior checklist into active validations):
|
||||||
|
- Read `.specify/templates/plan-template.md` and ensure any "Constitution Check" or rules align with updated principles.
|
||||||
|
- Read `.specify/templates/spec-template.md` for scope/requirements alignment—update if constitution adds/removes mandatory sections or constraints.
|
||||||
|
- Read `.specify/templates/tasks-template.md` and ensure task categorization reflects new or removed principle-driven task types (e.g., observability, versioning, testing discipline).
|
||||||
|
- Read each command file in `.specify/templates/commands/*.md` (including this one) to verify no outdated references (agent-specific names like CLAUDE only) remain when generic guidance is required.
|
||||||
|
- Read any runtime guidance docs (e.g., `README.md`, `docs/quickstart.md`, or agent-specific guidance files if present). Update references to principles changed.
|
||||||
|
|
||||||
|
5. Produce a Sync Impact Report (prepend as an HTML comment at top of the constitution file after update):
|
||||||
|
- Version change: old → new
|
||||||
|
- List of modified principles (old title → new title if renamed)
|
||||||
|
- Added sections
|
||||||
|
- Removed sections
|
||||||
|
- Templates requiring updates (✅ updated / ⚠ pending) with file paths
|
||||||
|
- Follow-up TODOs if any placeholders intentionally deferred.
|
||||||
|
|
||||||
|
6. Validation before final output:
|
||||||
|
- No remaining unexplained bracket tokens.
|
||||||
|
- Version line matches report.
|
||||||
|
- Dates ISO format YYYY-MM-DD.
|
||||||
|
- Principles are declarative, testable, and free of vague language ("should" → replace with MUST/SHOULD rationale where appropriate).
|
||||||
|
|
||||||
|
7. Write the completed constitution back to `.specify/memory/constitution.md` (overwrite).
|
||||||
|
|
||||||
|
8. Output a final summary to the user with:
|
||||||
|
- New version and bump rationale.
|
||||||
|
- Any files flagged for manual follow-up.
|
||||||
|
- Suggested commit message (e.g., `docs: amend constitution to vX.Y.Z (principle additions + governance update)`).
|
||||||
|
|
||||||
|
Formatting & Style Requirements:
|
||||||
|
|
||||||
|
- Use Markdown headings exactly as in the template (do not demote/promote levels).
|
||||||
|
- Wrap long rationale lines to keep readability (<100 chars ideally) but do not hard enforce with awkward breaks.
|
||||||
|
- Keep a single blank line between sections.
|
||||||
|
- Avoid trailing whitespace.
|
||||||
|
|
||||||
|
If the user supplies partial updates (e.g., only one principle revision), still perform validation and version decision steps.
|
||||||
|
|
||||||
|
If critical info missing (e.g., ratification date truly unknown), insert `TODO(<FIELD_NAME>): explanation` and include in the Sync Impact Report under deferred items.
|
||||||
|
|
||||||
|
Do not create a new template; always operate on the existing `.specify/memory/constitution.md` file.
|
||||||
|
|
||||||
|
## Post-Execution Checks
|
||||||
|
|
||||||
|
**Check for extension hooks (after constitution update)**:
|
||||||
|
Check if `.specify/extensions.yml` exists in the project root.
|
||||||
|
- If it exists, read it and look for entries under the `hooks.after_constitution` key
|
||||||
|
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||||
|
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||||
|
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||||
|
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||||
|
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||||
|
- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||||
|
- For each executable hook, output the following based on its `optional` flag:
|
||||||
|
- **Optional hook** (`optional: true`):
|
||||||
|
```
|
||||||
|
## Extension Hooks
|
||||||
|
|
||||||
|
**Optional Hook**: {extension}
|
||||||
|
Command: `/{command}`
|
||||||
|
Description: {description}
|
||||||
|
|
||||||
|
Prompt: {prompt}
|
||||||
|
To execute: `/{command}`
|
||||||
|
```
|
||||||
|
- **Mandatory hook** (`optional: false`):
|
||||||
|
```
|
||||||
|
## Extension Hooks
|
||||||
|
|
||||||
|
**Automatic Hook**: {extension}
|
||||||
|
Executing: `/{command}`
|
||||||
|
EXECUTE_COMMAND: {command}
|
||||||
|
```
|
||||||
|
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
---
|
||||||
|
name: "speckit-implement"
|
||||||
|
description: "Execute the implementation plan by processing and executing all tasks defined in tasks.md"
|
||||||
|
argument-hint: "Optional implementation guidance or task filter"
|
||||||
|
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||||
|
metadata:
|
||||||
|
author: "github-spec-kit"
|
||||||
|
source: "templates/commands/implement.md"
|
||||||
|
user-invocable: true
|
||||||
|
disable-model-invocation: false
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## User Input
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ARGUMENTS
|
||||||
|
```
|
||||||
|
|
||||||
|
You **MUST** consider the user input before proceeding (if not empty).
|
||||||
|
|
||||||
|
## Pre-Execution Checks
|
||||||
|
|
||||||
|
**Check for extension hooks (before implementation)**:
|
||||||
|
- Check if `.specify/extensions.yml` exists in the project root.
|
||||||
|
- If it exists, read it and look for entries under the `hooks.before_implement` key
|
||||||
|
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||||
|
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||||
|
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||||
|
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||||
|
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||||
|
- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||||
|
- For each executable hook, output the following based on its `optional` flag:
|
||||||
|
- **Optional hook** (`optional: true`):
|
||||||
|
```
|
||||||
|
## Extension Hooks
|
||||||
|
|
||||||
|
**Optional Pre-Hook**: {extension}
|
||||||
|
Command: `/{command}`
|
||||||
|
Description: {description}
|
||||||
|
|
||||||
|
Prompt: {prompt}
|
||||||
|
To execute: `/{command}`
|
||||||
|
```
|
||||||
|
- **Mandatory hook** (`optional: false`):
|
||||||
|
```
|
||||||
|
## Extension Hooks
|
||||||
|
|
||||||
|
**Automatic Pre-Hook**: {extension}
|
||||||
|
Executing: `/{command}`
|
||||||
|
EXECUTE_COMMAND: {command}
|
||||||
|
|
||||||
|
Wait for the result of the hook command before proceeding to the Outline.
|
||||||
|
```
|
||||||
|
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||||
|
|
||||||
|
## Outline
|
||||||
|
|
||||||
|
1. Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||||
|
|
||||||
|
2. **Check checklists status** (if FEATURE_DIR/checklists/ exists):
|
||||||
|
- Scan all checklist files in the checklists/ directory
|
||||||
|
- For each checklist, count:
|
||||||
|
- Total items: All lines matching `- [ ]` or `- [X]` or `- [x]`
|
||||||
|
- Completed items: Lines matching `- [X]` or `- [x]`
|
||||||
|
- Incomplete items: Lines matching `- [ ]`
|
||||||
|
- Create a status table:
|
||||||
|
|
||||||
|
```text
|
||||||
|
| Checklist | Total | Completed | Incomplete | Status |
|
||||||
|
|-----------|-------|-----------|------------|--------|
|
||||||
|
| ux.md | 12 | 12 | 0 | ✓ PASS |
|
||||||
|
| test.md | 8 | 5 | 3 | ✗ FAIL |
|
||||||
|
| security.md | 6 | 6 | 0 | ✓ PASS |
|
||||||
|
```
|
||||||
|
|
||||||
|
- Calculate overall status:
|
||||||
|
- **PASS**: All checklists have 0 incomplete items
|
||||||
|
- **FAIL**: One or more checklists have incomplete items
|
||||||
|
|
||||||
|
- **If any checklist is incomplete**:
|
||||||
|
- Display the table with incomplete item counts
|
||||||
|
- **STOP** and ask: "Some checklists are incomplete. Do you want to proceed with implementation anyway? (yes/no)"
|
||||||
|
- Wait for user response before continuing
|
||||||
|
- If user says "no" or "wait" or "stop", halt execution
|
||||||
|
- If user says "yes" or "proceed" or "continue", proceed to step 3
|
||||||
|
|
||||||
|
- **If all checklists are complete**:
|
||||||
|
- Display the table showing all checklists passed
|
||||||
|
- Automatically proceed to step 3
|
||||||
|
|
||||||
|
3. Load and analyze the implementation context:
|
||||||
|
- **REQUIRED**: Read tasks.md for the complete task list and execution plan
|
||||||
|
- **REQUIRED**: Read plan.md for tech stack, architecture, and file structure
|
||||||
|
- **IF EXISTS**: Read data-model.md for entities and relationships
|
||||||
|
- **IF EXISTS**: Read contracts/ for API specifications and test requirements
|
||||||
|
- **IF EXISTS**: Read research.md for technical decisions and constraints
|
||||||
|
- **IF EXISTS**: Read .specify/memory/constitution.md for governance constraints
|
||||||
|
- **IF EXISTS**: Read quickstart.md for integration scenarios
|
||||||
|
|
||||||
|
4. **Project Setup Verification**:
|
||||||
|
- **REQUIRED**: Create/verify ignore files based on actual project setup:
|
||||||
|
|
||||||
|
**Detection & Creation Logic**:
|
||||||
|
- Check if the following command succeeds to determine if the repository is a git repo (create/verify .gitignore if so):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git rev-parse --git-dir 2>/dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
- Check if Dockerfile* exists or Docker in plan.md → create/verify .dockerignore
|
||||||
|
- Check if .eslintrc* exists → create/verify .eslintignore
|
||||||
|
- Check if eslint.config.* exists → ensure the config's `ignores` entries cover required patterns
|
||||||
|
- Check if .prettierrc* exists → create/verify .prettierignore
|
||||||
|
- Check if .npmrc or package.json exists → create/verify .npmignore (if publishing)
|
||||||
|
- Check if terraform files (*.tf) exist → create/verify .terraformignore
|
||||||
|
- Check if .helmignore needed (helm charts present) → create/verify .helmignore
|
||||||
|
|
||||||
|
**If ignore file already exists**: Verify it contains essential patterns, append missing critical patterns only
|
||||||
|
**If ignore file missing**: Create with full pattern set for detected technology
|
||||||
|
|
||||||
|
**Common Patterns by Technology** (from plan.md tech stack):
|
||||||
|
- **Node.js/JavaScript/TypeScript**: `node_modules/`, `dist/`, `build/`, `*.log`, `.env*`
|
||||||
|
- **Python**: `__pycache__/`, `*.pyc`, `.venv/`, `venv/`, `dist/`, `*.egg-info/`
|
||||||
|
- **Java**: `target/`, `*.class`, `*.jar`, `.gradle/`, `build/`
|
||||||
|
- **C#/.NET**: `bin/`, `obj/`, `*.user`, `*.suo`, `packages/`
|
||||||
|
- **Go**: `*.exe`, `*.test`, `vendor/`, `*.out`
|
||||||
|
- **Ruby**: `.bundle/`, `log/`, `tmp/`, `*.gem`, `vendor/bundle/`
|
||||||
|
- **PHP**: `vendor/`, `*.log`, `*.cache`, `*.env`
|
||||||
|
- **Rust**: `target/`, `debug/`, `release/`, `*.rs.bk`, `*.rlib`, `*.prof*`, `.idea/`, `*.log`, `.env*`
|
||||||
|
- **Kotlin**: `build/`, `out/`, `.gradle/`, `.idea/`, `*.class`, `*.jar`, `*.iml`, `*.log`, `.env*`
|
||||||
|
- **C++**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.so`, `*.a`, `*.exe`, `*.dll`, `.idea/`, `*.log`, `.env*`
|
||||||
|
- **C**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.a`, `*.so`, `*.exe`, `*.dll`, `autom4te.cache/`, `config.status`, `config.log`, `.idea/`, `*.log`, `.env*`
|
||||||
|
- **Swift**: `.build/`, `DerivedData/`, `*.swiftpm/`, `Packages/`
|
||||||
|
- **R**: `.Rproj.user/`, `.Rhistory`, `.RData`, `.Ruserdata`, `*.Rproj`, `packrat/`, `renv/`
|
||||||
|
- **Universal**: `.DS_Store`, `Thumbs.db`, `*.tmp`, `*.swp`, `.vscode/`, `.idea/`
|
||||||
|
|
||||||
|
**Tool-Specific Patterns**:
|
||||||
|
- **Docker**: `node_modules/`, `.git/`, `Dockerfile*`, `.dockerignore`, `*.log*`, `.env*`, `coverage/`
|
||||||
|
- **ESLint**: `node_modules/`, `dist/`, `build/`, `coverage/`, `*.min.js`
|
||||||
|
- **Prettier**: `node_modules/`, `dist/`, `build/`, `coverage/`, `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`
|
||||||
|
- **Terraform**: `.terraform/`, `*.tfstate*`, `*.tfvars`, `.terraform.lock.hcl`
|
||||||
|
- **Kubernetes/k8s**: `*.secret.yaml`, `secrets/`, `.kube/`, `kubeconfig*`, `*.key`, `*.crt`
|
||||||
|
|
||||||
|
5. Parse tasks.md structure and extract:
|
||||||
|
- **Task phases**: Setup, Tests, Core, Integration, Polish
|
||||||
|
- **Task dependencies**: Sequential vs parallel execution rules
|
||||||
|
- **Task details**: ID, description, file paths, parallel markers [P]
|
||||||
|
- **Execution flow**: Order and dependency requirements
|
||||||
|
|
||||||
|
6. Execute implementation following the task plan:
|
||||||
|
- **Phase-by-phase execution**: Complete each phase before moving to the next
|
||||||
|
- **Respect dependencies**: Run sequential tasks in order, parallel tasks [P] can run together
|
||||||
|
- **Follow TDD approach**: Execute test tasks before their corresponding implementation tasks
|
||||||
|
- **File-based coordination**: Tasks affecting the same files must run sequentially
|
||||||
|
- **Validation checkpoints**: Verify each phase completion before proceeding
|
||||||
|
|
||||||
|
7. Implementation execution rules:
|
||||||
|
- **Setup first**: Initialize project structure, dependencies, configuration
|
||||||
|
- **Tests before code**: If you need to write tests for contracts, entities, and integration scenarios
|
||||||
|
- **Core development**: Implement models, services, CLI commands, endpoints
|
||||||
|
- **Integration work**: Database connections, middleware, logging, external services
|
||||||
|
- **Polish and validation**: Unit tests, performance optimization, documentation
|
||||||
|
|
||||||
|
8. Progress tracking and error handling:
|
||||||
|
- Report progress after each completed task
|
||||||
|
- Halt execution if any non-parallel task fails
|
||||||
|
- For parallel tasks [P], continue with successful tasks, report failed ones
|
||||||
|
- Provide clear error messages with context for debugging
|
||||||
|
- Suggest next steps if implementation cannot proceed
|
||||||
|
- **IMPORTANT** For completed tasks, make sure to mark the task off as [X] in the tasks file.
|
||||||
|
|
||||||
|
9. Completion validation:
|
||||||
|
- Verify all required tasks are completed
|
||||||
|
- Check that implemented features match the original specification
|
||||||
|
- Validate that tests pass and coverage meets requirements
|
||||||
|
- Confirm the implementation follows the technical plan
|
||||||
|
- Report final status with summary of completed work
|
||||||
|
|
||||||
|
Note: This command assumes a complete task breakdown exists in tasks.md. If tasks are incomplete or missing, suggest running `/speckit-tasks` first to regenerate the task list.
|
||||||
|
|
||||||
|
10. **Check for extension hooks**: After completion validation, check if `.specify/extensions.yml` exists in the project root.
|
||||||
|
- If it exists, read it and look for entries under the `hooks.after_implement` key
|
||||||
|
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||||
|
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||||
|
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||||
|
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||||
|
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||||
|
- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||||
|
- For each executable hook, output the following based on its `optional` flag:
|
||||||
|
- **Optional hook** (`optional: true`):
|
||||||
|
```
|
||||||
|
## Extension Hooks
|
||||||
|
|
||||||
|
**Optional Hook**: {extension}
|
||||||
|
Command: `/{command}`
|
||||||
|
Description: {description}
|
||||||
|
|
||||||
|
Prompt: {prompt}
|
||||||
|
To execute: `/{command}`
|
||||||
|
```
|
||||||
|
- **Mandatory hook** (`optional: false`):
|
||||||
|
```
|
||||||
|
## Extension Hooks
|
||||||
|
|
||||||
|
**Automatic Hook**: {extension}
|
||||||
|
Executing: `/{command}`
|
||||||
|
EXECUTE_COMMAND: {command}
|
||||||
|
```
|
||||||
|
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
---
|
||||||
|
name: "speckit-plan"
|
||||||
|
description: "Execute the implementation planning workflow using the plan template to generate design artifacts."
|
||||||
|
argument-hint: "Optional guidance for the planning phase"
|
||||||
|
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||||
|
metadata:
|
||||||
|
author: "github-spec-kit"
|
||||||
|
source: "templates/commands/plan.md"
|
||||||
|
user-invocable: true
|
||||||
|
disable-model-invocation: false
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## User Input
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ARGUMENTS
|
||||||
|
```
|
||||||
|
|
||||||
|
You **MUST** consider the user input before proceeding (if not empty).
|
||||||
|
|
||||||
|
## Pre-Execution Checks
|
||||||
|
|
||||||
|
**Check for extension hooks (before planning)**:
|
||||||
|
- Check if `.specify/extensions.yml` exists in the project root.
|
||||||
|
- If it exists, read it and look for entries under the `hooks.before_plan` key
|
||||||
|
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||||
|
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||||
|
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||||
|
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||||
|
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||||
|
- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||||
|
- For each executable hook, output the following based on its `optional` flag:
|
||||||
|
- **Optional hook** (`optional: true`):
|
||||||
|
```
|
||||||
|
## Extension Hooks
|
||||||
|
|
||||||
|
**Optional Pre-Hook**: {extension}
|
||||||
|
Command: `/{command}`
|
||||||
|
Description: {description}
|
||||||
|
|
||||||
|
Prompt: {prompt}
|
||||||
|
To execute: `/{command}`
|
||||||
|
```
|
||||||
|
- **Mandatory hook** (`optional: false`):
|
||||||
|
```
|
||||||
|
## Extension Hooks
|
||||||
|
|
||||||
|
**Automatic Pre-Hook**: {extension}
|
||||||
|
Executing: `/{command}`
|
||||||
|
EXECUTE_COMMAND: {command}
|
||||||
|
|
||||||
|
Wait for the result of the hook command before proceeding to the Outline.
|
||||||
|
```
|
||||||
|
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||||
|
|
||||||
|
## Outline
|
||||||
|
|
||||||
|
1. **Setup**: Run `.specify/scripts/bash/setup-plan.sh --json` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||||
|
|
||||||
|
2. **Load context**: Read FEATURE_SPEC and `.specify/memory/constitution.md`. Load IMPL_PLAN template (already copied).
|
||||||
|
|
||||||
|
3. **Execute plan workflow**: Follow the structure in IMPL_PLAN template to:
|
||||||
|
- Fill Technical Context (mark unknowns as "NEEDS CLARIFICATION")
|
||||||
|
- Fill Constitution Check section from constitution
|
||||||
|
- Evaluate gates (ERROR if violations unjustified)
|
||||||
|
- Phase 0: Generate research.md (resolve all NEEDS CLARIFICATION)
|
||||||
|
- Phase 1: Generate data-model.md, contracts/, quickstart.md
|
||||||
|
- Phase 1: Update agent context by running the agent script
|
||||||
|
- Re-evaluate Constitution Check post-design
|
||||||
|
|
||||||
|
4. **Stop and report**: Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generated artifacts.
|
||||||
|
|
||||||
|
5. **Check for extension hooks**: After reporting, check if `.specify/extensions.yml` exists in the project root.
|
||||||
|
- If it exists, read it and look for entries under the `hooks.after_plan` key
|
||||||
|
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||||
|
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||||
|
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||||
|
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||||
|
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||||
|
- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||||
|
- For each executable hook, output the following based on its `optional` flag:
|
||||||
|
- **Optional hook** (`optional: true`):
|
||||||
|
```
|
||||||
|
## Extension Hooks
|
||||||
|
|
||||||
|
**Optional Hook**: {extension}
|
||||||
|
Command: `/{command}`
|
||||||
|
Description: {description}
|
||||||
|
|
||||||
|
Prompt: {prompt}
|
||||||
|
To execute: `/{command}`
|
||||||
|
```
|
||||||
|
- **Mandatory hook** (`optional: false`):
|
||||||
|
```
|
||||||
|
## Extension Hooks
|
||||||
|
|
||||||
|
**Automatic Hook**: {extension}
|
||||||
|
Executing: `/{command}`
|
||||||
|
EXECUTE_COMMAND: {command}
|
||||||
|
```
|
||||||
|
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
### Phase 0: Outline & Research
|
||||||
|
|
||||||
|
1. **Extract unknowns from Technical Context** above:
|
||||||
|
- For each NEEDS CLARIFICATION → research task
|
||||||
|
- For each dependency → best practices task
|
||||||
|
- For each integration → patterns task
|
||||||
|
|
||||||
|
2. **Generate and dispatch research agents**:
|
||||||
|
|
||||||
|
```text
|
||||||
|
For each unknown in Technical Context:
|
||||||
|
Task: "Research {unknown} for {feature context}"
|
||||||
|
For each technology choice:
|
||||||
|
Task: "Find best practices for {tech} in {domain}"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Consolidate findings** in `research.md` using format:
|
||||||
|
- Decision: [what was chosen]
|
||||||
|
- Rationale: [why chosen]
|
||||||
|
- Alternatives considered: [what else evaluated]
|
||||||
|
|
||||||
|
**Output**: research.md with all NEEDS CLARIFICATION resolved
|
||||||
|
|
||||||
|
### Phase 1: Design & Contracts
|
||||||
|
|
||||||
|
**Prerequisites:** `research.md` complete
|
||||||
|
|
||||||
|
1. **Extract entities from feature spec** → `data-model.md`:
|
||||||
|
- Entity name, fields, relationships
|
||||||
|
- Validation rules from requirements
|
||||||
|
- State transitions if applicable
|
||||||
|
|
||||||
|
2. **Define interface contracts** (if project has external interfaces) → `/contracts/`:
|
||||||
|
- Identify what interfaces the project exposes to users or other systems
|
||||||
|
- Document the contract format appropriate for the project type
|
||||||
|
- Examples: public APIs for libraries, command schemas for CLI tools, endpoints for web services, grammars for parsers, UI contracts for applications
|
||||||
|
- Skip if project is purely internal (build scripts, one-off tools, etc.)
|
||||||
|
|
||||||
|
3. **Agent context update**:
|
||||||
|
- Update the plan reference between the `<!-- SPECKIT START -->` and `<!-- SPECKIT END -->` markers in `CLAUDE.md` to point to the plan file created in step 1 (the IMPL_PLAN path)
|
||||||
|
|
||||||
|
**Output**: data-model.md, /contracts/*, quickstart.md, updated agent context file
|
||||||
|
|
||||||
|
## Key rules
|
||||||
|
|
||||||
|
- Use absolute paths for filesystem operations; use project-relative paths for references in documentation and agent context files
|
||||||
|
- ERROR on gate failures or unresolved clarifications
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
---
|
||||||
|
name: "speckit-specify"
|
||||||
|
description: "Create or update the feature specification from a natural language feature description."
|
||||||
|
argument-hint: "Describe the feature you want to specify"
|
||||||
|
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||||
|
metadata:
|
||||||
|
author: "github-spec-kit"
|
||||||
|
source: "templates/commands/specify.md"
|
||||||
|
user-invocable: true
|
||||||
|
disable-model-invocation: false
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## User Input
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ARGUMENTS
|
||||||
|
```
|
||||||
|
|
||||||
|
You **MUST** consider the user input before proceeding (if not empty).
|
||||||
|
|
||||||
|
## Pre-Execution Checks
|
||||||
|
|
||||||
|
**Check for extension hooks (before specification)**:
|
||||||
|
- Check if `.specify/extensions.yml` exists in the project root.
|
||||||
|
- If it exists, read it and look for entries under the `hooks.before_specify` key
|
||||||
|
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||||
|
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||||
|
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||||
|
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||||
|
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||||
|
- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||||
|
- For each executable hook, output the following based on its `optional` flag:
|
||||||
|
- **Optional hook** (`optional: true`):
|
||||||
|
```
|
||||||
|
## Extension Hooks
|
||||||
|
|
||||||
|
**Optional Pre-Hook**: {extension}
|
||||||
|
Command: `/{command}`
|
||||||
|
Description: {description}
|
||||||
|
|
||||||
|
Prompt: {prompt}
|
||||||
|
To execute: `/{command}`
|
||||||
|
```
|
||||||
|
- **Mandatory hook** (`optional: false`):
|
||||||
|
```
|
||||||
|
## Extension Hooks
|
||||||
|
|
||||||
|
**Automatic Pre-Hook**: {extension}
|
||||||
|
Executing: `/{command}`
|
||||||
|
EXECUTE_COMMAND: {command}
|
||||||
|
|
||||||
|
Wait for the result of the hook command before proceeding to the Outline.
|
||||||
|
```
|
||||||
|
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||||
|
|
||||||
|
## Outline
|
||||||
|
|
||||||
|
The text the user typed after `/speckit-specify` in the triggering message **is** the feature description. Assume you always have it available in this conversation even if `$ARGUMENTS` appears literally below. Do not ask the user to repeat it unless they provided an empty command.
|
||||||
|
|
||||||
|
Given that feature description, do this:
|
||||||
|
|
||||||
|
1. **Generate a concise short name** (2-4 words) for the feature:
|
||||||
|
- Analyze the feature description and extract the most meaningful keywords
|
||||||
|
- Create a 2-4 word short name that captures the essence of the feature
|
||||||
|
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
|
||||||
|
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
|
||||||
|
- Keep it concise but descriptive enough to understand the feature at a glance
|
||||||
|
- Examples:
|
||||||
|
- "I want to add user authentication" → "user-auth"
|
||||||
|
- "Implement OAuth2 integration for the API" → "oauth2-api-integration"
|
||||||
|
- "Create a dashboard for analytics" → "analytics-dashboard"
|
||||||
|
- "Fix payment processing timeout bug" → "fix-payment-timeout"
|
||||||
|
|
||||||
|
2. **Branch creation** (optional, via hook):
|
||||||
|
|
||||||
|
If a `before_specify` hook ran successfully in the Pre-Execution Checks above, it will have created/switched to a git branch and output JSON containing `BRANCH_NAME` and `FEATURE_NUM`. Note these values for reference, but the branch name does **not** dictate the spec directory name.
|
||||||
|
|
||||||
|
If the user explicitly provided `GIT_BRANCH_NAME`, pass it through to the hook so the branch script uses the exact value as the branch name (bypassing all prefix/suffix generation).
|
||||||
|
|
||||||
|
3. **Create the spec feature directory**:
|
||||||
|
|
||||||
|
Specs live under the default `specs/` directory unless the user explicitly provides `SPECIFY_FEATURE_DIRECTORY`.
|
||||||
|
|
||||||
|
**Resolution order for `SPECIFY_FEATURE_DIRECTORY`**:
|
||||||
|
1. If the user explicitly provided `SPECIFY_FEATURE_DIRECTORY` (e.g., via environment variable, argument, or configuration), use it as-is
|
||||||
|
2. Otherwise, auto-generate it under `specs/`:
|
||||||
|
- Check `.specify/init-options.json` for `branch_numbering`
|
||||||
|
- If `"timestamp"`: prefix is `YYYYMMDD-HHMMSS` (current timestamp)
|
||||||
|
- If `"sequential"` or absent: prefix is `NNN` (next available 3-digit number after scanning existing directories in `specs/`)
|
||||||
|
- Construct the directory name: `<prefix>-<short-name>` (e.g., `003-user-auth` or `20260319-143022-user-auth`)
|
||||||
|
- Set `SPECIFY_FEATURE_DIRECTORY` to `specs/<directory-name>`
|
||||||
|
|
||||||
|
**Create the directory and spec file**:
|
||||||
|
- `mkdir -p SPECIFY_FEATURE_DIRECTORY`
|
||||||
|
- Copy `.specify/templates/spec-template.md` to `SPECIFY_FEATURE_DIRECTORY/spec.md` as the starting point
|
||||||
|
- Set `SPEC_FILE` to `SPECIFY_FEATURE_DIRECTORY/spec.md`
|
||||||
|
- Persist the resolved path to `.specify/feature.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"feature_directory": "<resolved feature dir>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Write the actual resolved directory path value (for example, `specs/003-user-auth`), not the literal string `SPECIFY_FEATURE_DIRECTORY`.
|
||||||
|
This allows downstream commands (`/speckit-plan`, `/speckit-tasks`, etc.) to locate the feature directory without relying on git branch name conventions.
|
||||||
|
|
||||||
|
**IMPORTANT**:
|
||||||
|
- You must only create one feature per `/speckit-specify` invocation
|
||||||
|
- The spec directory name and the git branch name are independent — they may be the same but that is the user's choice
|
||||||
|
- The spec directory and file are always created by this command, never by the hook
|
||||||
|
|
||||||
|
4. Load `.specify/templates/spec-template.md` to understand required sections.
|
||||||
|
|
||||||
|
5. Follow this execution flow:
|
||||||
|
1. Parse user description from arguments
|
||||||
|
If empty: ERROR "No feature description provided"
|
||||||
|
2. Extract key concepts from description
|
||||||
|
Identify: actors, actions, data, constraints
|
||||||
|
3. For unclear aspects:
|
||||||
|
- Make informed guesses based on context and industry standards
|
||||||
|
- Only mark with [NEEDS CLARIFICATION: specific question] if:
|
||||||
|
- The choice significantly impacts feature scope or user experience
|
||||||
|
- Multiple reasonable interpretations exist with different implications
|
||||||
|
- No reasonable default exists
|
||||||
|
- **LIMIT: Maximum 3 [NEEDS CLARIFICATION] markers total**
|
||||||
|
- Prioritize clarifications by impact: scope > security/privacy > user experience > technical details
|
||||||
|
4. Fill User Scenarios & Testing section
|
||||||
|
If no clear user flow: ERROR "Cannot determine user scenarios"
|
||||||
|
5. Generate Functional Requirements
|
||||||
|
Each requirement must be testable
|
||||||
|
Use reasonable defaults for unspecified details (document assumptions in Assumptions section)
|
||||||
|
6. Define Success Criteria
|
||||||
|
Create measurable, technology-agnostic outcomes
|
||||||
|
Include both quantitative metrics (time, performance, volume) and qualitative measures (user satisfaction, task completion)
|
||||||
|
Each criterion must be verifiable without implementation details
|
||||||
|
7. Identify Key Entities (if data involved)
|
||||||
|
8. Return: SUCCESS (spec ready for planning)
|
||||||
|
|
||||||
|
6. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings.
|
||||||
|
|
||||||
|
7. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria:
|
||||||
|
|
||||||
|
a. **Create Spec Quality Checklist**: Generate a checklist file at `SPECIFY_FEATURE_DIRECTORY/checklists/requirements.md` using the checklist template structure with these validation items:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Specification Quality Checklist: [FEATURE NAME]
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: [DATE]
|
||||||
|
**Feature**: [Link to spec.md]
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [ ] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [ ] Focused on user value and business needs
|
||||||
|
- [ ] Written for non-technical stakeholders
|
||||||
|
- [ ] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [ ] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [ ] Requirements are testable and unambiguous
|
||||||
|
- [ ] Success criteria are measurable
|
||||||
|
- [ ] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [ ] All acceptance scenarios are defined
|
||||||
|
- [ ] Edge cases are identified
|
||||||
|
- [ ] Scope is clearly bounded
|
||||||
|
- [ ] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [ ] All functional requirements have clear acceptance criteria
|
||||||
|
- [ ] User scenarios cover primary flows
|
||||||
|
- [ ] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [ ] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Items marked incomplete require spec updates before `/speckit-clarify` or `/speckit-plan`
|
||||||
|
```
|
||||||
|
|
||||||
|
b. **Run Validation Check**: Review the spec against each checklist item:
|
||||||
|
- For each item, determine if it passes or fails
|
||||||
|
- Document specific issues found (quote relevant spec sections)
|
||||||
|
|
||||||
|
c. **Handle Validation Results**:
|
||||||
|
|
||||||
|
- **If all items pass**: Mark checklist complete and proceed to step 8
|
||||||
|
|
||||||
|
- **If items fail (excluding [NEEDS CLARIFICATION])**:
|
||||||
|
1. List the failing items and specific issues
|
||||||
|
2. Update the spec to address each issue
|
||||||
|
3. Re-run validation until all items pass (max 3 iterations)
|
||||||
|
4. If still failing after 3 iterations, document remaining issues in checklist notes and warn user
|
||||||
|
|
||||||
|
- **If [NEEDS CLARIFICATION] markers remain**:
|
||||||
|
1. Extract all [NEEDS CLARIFICATION: ...] markers from the spec
|
||||||
|
2. **LIMIT CHECK**: If more than 3 markers exist, keep only the 3 most critical (by scope/security/UX impact) and make informed guesses for the rest
|
||||||
|
3. For each clarification needed (max 3), present options to user in this format:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Question [N]: [Topic]
|
||||||
|
|
||||||
|
**Context**: [Quote relevant spec section]
|
||||||
|
|
||||||
|
**What we need to know**: [Specific question from NEEDS CLARIFICATION marker]
|
||||||
|
|
||||||
|
**Suggested Answers**:
|
||||||
|
|
||||||
|
| Option | Answer | Implications |
|
||||||
|
|--------|--------|--------------|
|
||||||
|
| A | [First suggested answer] | [What this means for the feature] |
|
||||||
|
| B | [Second suggested answer] | [What this means for the feature] |
|
||||||
|
| C | [Third suggested answer] | [What this means for the feature] |
|
||||||
|
| Custom | Provide your own answer | [Explain how to provide custom input] |
|
||||||
|
|
||||||
|
**Your choice**: _[Wait for user response]_
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **CRITICAL - Table Formatting**: Ensure markdown tables are properly formatted:
|
||||||
|
- Use consistent spacing with pipes aligned
|
||||||
|
- Each cell should have spaces around content: `| Content |` not `|Content|`
|
||||||
|
- Header separator must have at least 3 dashes: `|--------|`
|
||||||
|
- Test that the table renders correctly in markdown preview
|
||||||
|
5. Number questions sequentially (Q1, Q2, Q3 - max 3 total)
|
||||||
|
6. Present all questions together before waiting for responses
|
||||||
|
7. Wait for user to respond with their choices for all questions (e.g., "Q1: A, Q2: Custom - [details], Q3: B")
|
||||||
|
8. Update the spec by replacing each [NEEDS CLARIFICATION] marker with the user's selected or provided answer
|
||||||
|
9. Re-run validation after all clarifications are resolved
|
||||||
|
|
||||||
|
d. **Update Checklist**: After each validation iteration, update the checklist file with current pass/fail status
|
||||||
|
|
||||||
|
8. **Report completion** to the user with:
|
||||||
|
- `SPECIFY_FEATURE_DIRECTORY` — the feature directory path
|
||||||
|
- `SPEC_FILE` — the spec file path
|
||||||
|
- Checklist results summary
|
||||||
|
- Readiness for the next phase (`/speckit-clarify` or `/speckit-plan`)
|
||||||
|
|
||||||
|
9. **Check for extension hooks**: After reporting completion, check if `.specify/extensions.yml` exists in the project root.
|
||||||
|
- If it exists, read it and look for entries under the `hooks.after_specify` key
|
||||||
|
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||||
|
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||||
|
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||||
|
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||||
|
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||||
|
- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||||
|
- For each executable hook, output the following based on its `optional` flag:
|
||||||
|
- **Optional hook** (`optional: true`):
|
||||||
|
```
|
||||||
|
## Extension Hooks
|
||||||
|
|
||||||
|
**Optional Hook**: {extension}
|
||||||
|
Command: `/{command}`
|
||||||
|
Description: {description}
|
||||||
|
|
||||||
|
Prompt: {prompt}
|
||||||
|
To execute: `/{command}`
|
||||||
|
```
|
||||||
|
- **Mandatory hook** (`optional: false`):
|
||||||
|
```
|
||||||
|
## Extension Hooks
|
||||||
|
|
||||||
|
**Automatic Hook**: {extension}
|
||||||
|
Executing: `/{command}`
|
||||||
|
EXECUTE_COMMAND: {command}
|
||||||
|
```
|
||||||
|
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||||
|
|
||||||
|
**NOTE:** Branch creation is handled by the `before_specify` hook (git extension). Spec directory and file creation are always handled by this core command.
|
||||||
|
|
||||||
|
## Quick Guidelines
|
||||||
|
|
||||||
|
- Focus on **WHAT** users need and **WHY**.
|
||||||
|
- Avoid HOW to implement (no tech stack, APIs, code structure).
|
||||||
|
- Written for business stakeholders, not developers.
|
||||||
|
- DO NOT create any checklists that are embedded in the spec. That will be a separate command.
|
||||||
|
|
||||||
|
### Section Requirements
|
||||||
|
|
||||||
|
- **Mandatory sections**: Must be completed for every feature
|
||||||
|
- **Optional sections**: Include only when relevant to the feature
|
||||||
|
- When a section doesn't apply, remove it entirely (don't leave as "N/A")
|
||||||
|
|
||||||
|
### For AI Generation
|
||||||
|
|
||||||
|
When creating this spec from a user prompt:
|
||||||
|
|
||||||
|
1. **Make informed guesses**: Use context, industry standards, and common patterns to fill gaps
|
||||||
|
2. **Document assumptions**: Record reasonable defaults in the Assumptions section
|
||||||
|
3. **Limit clarifications**: Maximum 3 [NEEDS CLARIFICATION] markers - use only for critical decisions that:
|
||||||
|
- Significantly impact feature scope or user experience
|
||||||
|
- Have multiple reasonable interpretations with different implications
|
||||||
|
- Lack any reasonable default
|
||||||
|
4. **Prioritize clarifications**: scope > security/privacy > user experience > technical details
|
||||||
|
5. **Think like a tester**: Every vague requirement should fail the "testable and unambiguous" checklist item
|
||||||
|
6. **Common areas needing clarification** (only if no reasonable default exists):
|
||||||
|
- Feature scope and boundaries (include/exclude specific use cases)
|
||||||
|
- User types and permissions (if multiple conflicting interpretations possible)
|
||||||
|
- Security/compliance requirements (when legally/financially significant)
|
||||||
|
|
||||||
|
**Examples of reasonable defaults** (don't ask about these):
|
||||||
|
|
||||||
|
- Data retention: Industry-standard practices for the domain
|
||||||
|
- Performance targets: Standard web/mobile app expectations unless specified
|
||||||
|
- Error handling: User-friendly messages with appropriate fallbacks
|
||||||
|
- Authentication method: Standard session-based or OAuth2 for web apps
|
||||||
|
- Integration patterns: Use project-appropriate patterns (REST/GraphQL for web services, function calls for libraries, CLI args for tools, etc.)
|
||||||
|
|
||||||
|
### Success Criteria Guidelines
|
||||||
|
|
||||||
|
Success criteria must be:
|
||||||
|
|
||||||
|
1. **Measurable**: Include specific metrics (time, percentage, count, rate)
|
||||||
|
2. **Technology-agnostic**: No mention of frameworks, languages, databases, or tools
|
||||||
|
3. **User-focused**: Describe outcomes from user/business perspective, not system internals
|
||||||
|
4. **Verifiable**: Can be tested/validated without knowing implementation details
|
||||||
|
|
||||||
|
**Good examples**:
|
||||||
|
|
||||||
|
- "Users can complete checkout in under 3 minutes"
|
||||||
|
- "System supports 10,000 concurrent users"
|
||||||
|
- "95% of searches return results in under 1 second"
|
||||||
|
- "Task completion rate improves by 40%"
|
||||||
|
|
||||||
|
**Bad examples** (implementation-focused):
|
||||||
|
|
||||||
|
- "API response time is under 200ms" (too technical, use "Users see results instantly")
|
||||||
|
- "Database can handle 1000 TPS" (implementation detail, use user-facing metric)
|
||||||
|
- "React components render efficiently" (framework-specific)
|
||||||
|
- "Redis cache hit rate above 80%" (technology-specific)
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
---
|
||||||
|
name: "speckit-tasks"
|
||||||
|
description: "Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts."
|
||||||
|
argument-hint: "Optional task generation constraints"
|
||||||
|
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||||
|
metadata:
|
||||||
|
author: "github-spec-kit"
|
||||||
|
source: "templates/commands/tasks.md"
|
||||||
|
user-invocable: true
|
||||||
|
disable-model-invocation: false
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## User Input
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ARGUMENTS
|
||||||
|
```
|
||||||
|
|
||||||
|
You **MUST** consider the user input before proceeding (if not empty).
|
||||||
|
|
||||||
|
## Pre-Execution Checks
|
||||||
|
|
||||||
|
**Check for extension hooks (before tasks generation)**:
|
||||||
|
- Check if `.specify/extensions.yml` exists in the project root.
|
||||||
|
- If it exists, read it and look for entries under the `hooks.before_tasks` key
|
||||||
|
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||||
|
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||||
|
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||||
|
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||||
|
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||||
|
- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||||
|
- For each executable hook, output the following based on its `optional` flag:
|
||||||
|
- **Optional hook** (`optional: true`):
|
||||||
|
```
|
||||||
|
## Extension Hooks
|
||||||
|
|
||||||
|
**Optional Pre-Hook**: {extension}
|
||||||
|
Command: `/{command}`
|
||||||
|
Description: {description}
|
||||||
|
|
||||||
|
Prompt: {prompt}
|
||||||
|
To execute: `/{command}`
|
||||||
|
```
|
||||||
|
- **Mandatory hook** (`optional: false`):
|
||||||
|
```
|
||||||
|
## Extension Hooks
|
||||||
|
|
||||||
|
**Automatic Pre-Hook**: {extension}
|
||||||
|
Executing: `/{command}`
|
||||||
|
EXECUTE_COMMAND: {command}
|
||||||
|
|
||||||
|
Wait for the result of the hook command before proceeding to the Outline.
|
||||||
|
```
|
||||||
|
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||||
|
|
||||||
|
## Outline
|
||||||
|
|
||||||
|
1. **Setup**: Run `.specify/scripts/bash/setup-tasks.sh --json` from repo root and parse FEATURE_DIR, TASKS_TEMPLATE, and AVAILABLE_DOCS list. `FEATURE_DIR` and `TASKS_TEMPLATE` must be absolute paths when provided. `AVAILABLE_DOCS` is a list of document names/relative paths available under `FEATURE_DIR` (for example `research.md` or `contracts/`). For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||||
|
|
||||||
|
2. **Load design documents**: Read from FEATURE_DIR:
|
||||||
|
- **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities)
|
||||||
|
- **Optional**: data-model.md (entities), contracts/ (interface contracts), research.md (decisions), quickstart.md (test scenarios)
|
||||||
|
- Note: Not all projects have all documents. Generate tasks based on what's available.
|
||||||
|
|
||||||
|
3. **Execute task generation workflow**:
|
||||||
|
- Load plan.md and extract tech stack, libraries, project structure
|
||||||
|
- Load spec.md and extract user stories with their priorities (P1, P2, P3, etc.)
|
||||||
|
- If data-model.md exists: Extract entities and map to user stories
|
||||||
|
- If contracts/ exists: Map interface contracts to user stories
|
||||||
|
- If research.md exists: Extract decisions for setup tasks
|
||||||
|
- Generate tasks organized by user story (see Task Generation Rules below)
|
||||||
|
- Generate dependency graph showing user story completion order
|
||||||
|
- Create parallel execution examples per user story
|
||||||
|
- Validate task completeness (each user story has all needed tasks, independently testable)
|
||||||
|
|
||||||
|
4. **Generate tasks.md**: Read the tasks template from TASKS_TEMPLATE (from the JSON output above) and use it as structure. If TASKS_TEMPLATE is empty, fall back to `.specify/templates/tasks-template.md`. Fill with:
|
||||||
|
- Correct feature name from plan.md
|
||||||
|
- Phase 1: Setup tasks (project initialization)
|
||||||
|
- Phase 2: Foundational tasks (blocking prerequisites for all user stories)
|
||||||
|
- Phase 3+: One phase per user story (in priority order from spec.md)
|
||||||
|
- Each phase includes: story goal, independent test criteria, tests (if requested), implementation tasks
|
||||||
|
- Final Phase: Polish & cross-cutting concerns
|
||||||
|
- All tasks must follow the strict checklist format (see Task Generation Rules below)
|
||||||
|
- Clear file paths for each task
|
||||||
|
- Dependencies section showing story completion order
|
||||||
|
- Parallel execution examples per story
|
||||||
|
- Implementation strategy section (MVP first, incremental delivery)
|
||||||
|
|
||||||
|
5. **Report**: Output path to generated tasks.md and summary:
|
||||||
|
- Total task count
|
||||||
|
- Task count per user story
|
||||||
|
- Parallel opportunities identified
|
||||||
|
- Independent test criteria for each story
|
||||||
|
- Suggested MVP scope (typically just User Story 1)
|
||||||
|
- Format validation: Confirm ALL tasks follow the checklist format (checkbox, ID, labels, file paths)
|
||||||
|
|
||||||
|
6. **Check for extension hooks**: After tasks.md is generated, check if `.specify/extensions.yml` exists in the project root.
|
||||||
|
- If it exists, read it and look for entries under the `hooks.after_tasks` key
|
||||||
|
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||||
|
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||||
|
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||||
|
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||||
|
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||||
|
- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||||
|
- For each executable hook, output the following based on its `optional` flag:
|
||||||
|
- **Optional hook** (`optional: true`):
|
||||||
|
```
|
||||||
|
## Extension Hooks
|
||||||
|
|
||||||
|
**Optional Hook**: {extension}
|
||||||
|
Command: `/{command}`
|
||||||
|
Description: {description}
|
||||||
|
|
||||||
|
Prompt: {prompt}
|
||||||
|
To execute: `/{command}`
|
||||||
|
```
|
||||||
|
- **Mandatory hook** (`optional: false`):
|
||||||
|
```
|
||||||
|
## Extension Hooks
|
||||||
|
|
||||||
|
**Automatic Hook**: {extension}
|
||||||
|
Executing: `/{command}`
|
||||||
|
EXECUTE_COMMAND: {command}
|
||||||
|
```
|
||||||
|
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||||
|
|
||||||
|
Context for task generation: $ARGUMENTS
|
||||||
|
|
||||||
|
The tasks.md should be immediately executable - each task must be specific enough that an LLM can complete it without additional context.
|
||||||
|
|
||||||
|
## Task Generation Rules
|
||||||
|
|
||||||
|
**CRITICAL**: Tasks MUST be organized by user story to enable independent implementation and testing.
|
||||||
|
|
||||||
|
**Tests are OPTIONAL**: Only generate test tasks if explicitly requested in the feature specification or if user requests TDD approach.
|
||||||
|
|
||||||
|
### Checklist Format (REQUIRED)
|
||||||
|
|
||||||
|
Every task MUST strictly follow this format:
|
||||||
|
|
||||||
|
```text
|
||||||
|
- [ ] [TaskID] [P?] [Story?] Description with file path
|
||||||
|
```
|
||||||
|
|
||||||
|
**Format Components**:
|
||||||
|
|
||||||
|
1. **Checkbox**: ALWAYS start with `- [ ]` (markdown checkbox)
|
||||||
|
2. **Task ID**: Sequential number (T001, T002, T003...) in execution order
|
||||||
|
3. **[P] marker**: Include ONLY if task is parallelizable (different files, no dependencies on incomplete tasks)
|
||||||
|
4. **[Story] label**: REQUIRED for user story phase tasks only
|
||||||
|
- Format: [US1], [US2], [US3], etc. (maps to user stories from spec.md)
|
||||||
|
- Setup phase: NO story label
|
||||||
|
- Foundational phase: NO story label
|
||||||
|
- User Story phases: MUST have story label
|
||||||
|
- Polish phase: NO story label
|
||||||
|
5. **Description**: Clear action with exact file path
|
||||||
|
|
||||||
|
**Examples**:
|
||||||
|
|
||||||
|
- ✅ CORRECT: `- [ ] T001 Create project structure per implementation plan`
|
||||||
|
- ✅ CORRECT: `- [ ] T005 [P] Implement authentication middleware in src/middleware/auth.py`
|
||||||
|
- ✅ CORRECT: `- [ ] T012 [P] [US1] Create User model in src/models/user.py`
|
||||||
|
- ✅ CORRECT: `- [ ] T014 [US1] Implement UserService in src/services/user_service.py`
|
||||||
|
- ❌ WRONG: `- [ ] Create User model` (missing ID and Story label)
|
||||||
|
- ❌ WRONG: `T001 [US1] Create model` (missing checkbox)
|
||||||
|
- ❌ WRONG: `- [ ] [US1] Create User model` (missing Task ID)
|
||||||
|
- ❌ WRONG: `- [ ] T001 [US1] Create model` (missing file path)
|
||||||
|
|
||||||
|
### Task Organization
|
||||||
|
|
||||||
|
1. **From User Stories (spec.md)** - PRIMARY ORGANIZATION:
|
||||||
|
- Each user story (P1, P2, P3...) gets its own phase
|
||||||
|
- Map all related components to their story:
|
||||||
|
- Models needed for that story
|
||||||
|
- Services needed for that story
|
||||||
|
- Interfaces/UI needed for that story
|
||||||
|
- If tests requested: Tests specific to that story
|
||||||
|
- Mark story dependencies (most stories should be independent)
|
||||||
|
|
||||||
|
2. **From Contracts**:
|
||||||
|
- Map each interface contract → to the user story it serves
|
||||||
|
- If tests requested: Each interface contract → contract test task [P] before implementation in that story's phase
|
||||||
|
|
||||||
|
3. **From Data Model**:
|
||||||
|
- Map each entity to the user story(ies) that need it
|
||||||
|
- If entity serves multiple stories: Put in earliest story or Setup phase
|
||||||
|
- Relationships → service layer tasks in appropriate story phase
|
||||||
|
|
||||||
|
4. **From Setup/Infrastructure**:
|
||||||
|
- Shared infrastructure → Setup phase (Phase 1)
|
||||||
|
- Foundational/blocking tasks → Foundational phase (Phase 2)
|
||||||
|
- Story-specific setup → within that story's phase
|
||||||
|
|
||||||
|
### Phase Structure
|
||||||
|
|
||||||
|
- **Phase 1**: Setup (project initialization)
|
||||||
|
- **Phase 2**: Foundational (blocking prerequisites - MUST complete before user stories)
|
||||||
|
- **Phase 3+**: User Stories in priority order (P1, P2, P3...)
|
||||||
|
- Within each story: Tests (if requested) → Models → Services → Endpoints → Integration
|
||||||
|
- Each phase should be a complete, independently testable increment
|
||||||
|
- **Final Phase**: Polish & Cross-Cutting Concerns
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
---
|
||||||
|
name: "speckit-taskstoissues"
|
||||||
|
description: "Convert existing tasks into actionable, dependency-ordered GitHub issues for the feature based on available design artifacts."
|
||||||
|
argument-hint: "Optional filter or label for GitHub issues"
|
||||||
|
compatibility: "Requires spec-kit project structure with .specify/ directory"
|
||||||
|
metadata:
|
||||||
|
author: "github-spec-kit"
|
||||||
|
source: "templates/commands/taskstoissues.md"
|
||||||
|
user-invocable: true
|
||||||
|
disable-model-invocation: false
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## User Input
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ARGUMENTS
|
||||||
|
```
|
||||||
|
|
||||||
|
You **MUST** consider the user input before proceeding (if not empty).
|
||||||
|
|
||||||
|
## Pre-Execution Checks
|
||||||
|
|
||||||
|
**Check for extension hooks (before tasks-to-issues conversion)**:
|
||||||
|
- Check if `.specify/extensions.yml` exists in the project root.
|
||||||
|
- If it exists, read it and look for entries under the `hooks.before_taskstoissues` key
|
||||||
|
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||||
|
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||||
|
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||||
|
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||||
|
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||||
|
- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||||
|
- For each executable hook, output the following based on its `optional` flag:
|
||||||
|
- **Optional hook** (`optional: true`):
|
||||||
|
```
|
||||||
|
## Extension Hooks
|
||||||
|
|
||||||
|
**Optional Pre-Hook**: {extension}
|
||||||
|
Command: `/{command}`
|
||||||
|
Description: {description}
|
||||||
|
|
||||||
|
Prompt: {prompt}
|
||||||
|
To execute: `/{command}`
|
||||||
|
```
|
||||||
|
- **Mandatory hook** (`optional: false`):
|
||||||
|
```
|
||||||
|
## Extension Hooks
|
||||||
|
|
||||||
|
**Automatic Pre-Hook**: {extension}
|
||||||
|
Executing: `/{command}`
|
||||||
|
EXECUTE_COMMAND: {command}
|
||||||
|
|
||||||
|
Wait for the result of the hook command before proceeding to the Outline.
|
||||||
|
```
|
||||||
|
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||||
|
|
||||||
|
## Outline
|
||||||
|
|
||||||
|
1. Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||||
|
1. From the executed script, extract the path to **tasks**.
|
||||||
|
1. Get the Git remote by running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git config --get remote.origin.url
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> ONLY PROCEED TO NEXT STEPS IF THE REMOTE IS A GITHUB URL
|
||||||
|
|
||||||
|
1. For each task in the list, use the GitHub MCP server to create a new issue in the repository that is representative of the Git remote.
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> UNDER NO CIRCUMSTANCES EVER CREATE ISSUES IN REPOSITORIES THAT DO NOT MATCH THE REMOTE URL
|
||||||
|
|
||||||
|
## Post-Execution Checks
|
||||||
|
|
||||||
|
**Check for extension hooks (after tasks-to-issues conversion)**:
|
||||||
|
Check if `.specify/extensions.yml` exists in the project root.
|
||||||
|
- If it exists, read it and look for entries under the `hooks.after_taskstoissues` key
|
||||||
|
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
|
||||||
|
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
|
||||||
|
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
|
||||||
|
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
|
||||||
|
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
|
||||||
|
- When constructing slash commands from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
|
||||||
|
- For each executable hook, output the following based on its `optional` flag:
|
||||||
|
- **Optional hook** (`optional: true`):
|
||||||
|
```
|
||||||
|
## Extension Hooks
|
||||||
|
|
||||||
|
**Optional Hook**: {extension}
|
||||||
|
Command: `/{command}`
|
||||||
|
Description: {description}
|
||||||
|
|
||||||
|
Prompt: {prompt}
|
||||||
|
To execute: `/{command}`
|
||||||
|
```
|
||||||
|
- **Mandatory hook** (`optional: false`):
|
||||||
|
```
|
||||||
|
## Extension Hooks
|
||||||
|
|
||||||
|
**Automatic Hook**: {extension}
|
||||||
|
Executing: `/{command}`
|
||||||
|
EXECUTE_COMMAND: {command}
|
||||||
|
```
|
||||||
|
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Dockerfile for reproducible Slopsmith Desktop builds.
|
||||||
|
# Mirrors the GitHub Actions ubuntu-22.04 runner environment. Versions
|
||||||
|
# are read from .build-config.json so CI and this container can't drift.
|
||||||
|
#
|
||||||
|
# Reproducibility caveat: the base tag `ubuntu-22.04` tracks the latest
|
||||||
|
# security-patched Microsoft devcontainer image. True byte-for-byte
|
||||||
|
# reproducibility would pin to an image digest; in practice a rolling
|
||||||
|
# `ubuntu-22.04` matches what the GitHub Actions runner ships.
|
||||||
|
FROM mcr.microsoft.com/devcontainers/base:ubuntu-22.04
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
# ── Build config ────────────────────────────────────────────────────────────
|
||||||
|
# Python is preinstalled on the base image; use it to read version pins
|
||||||
|
# from .build-config.json (avoids adding jq to the image).
|
||||||
|
COPY .build-config.json /tmp/.build-config.json
|
||||||
|
COPY scripts/parse-build-config.py /tmp/parse-build-config.py
|
||||||
|
|
||||||
|
RUN NODE_VERSION=$(python3 /tmp/parse-build-config.py /tmp/.build-config.json .versions.node) && \
|
||||||
|
PYTHON_VERSION=$(python3 /tmp/parse-build-config.py /tmp/.build-config.json .versions.python) && \
|
||||||
|
DOTNET_VERSION=$(python3 /tmp/parse-build-config.py /tmp/.build-config.json .versions.dotnet) && \
|
||||||
|
PYTHON_MAJOR_MINOR="${PYTHON_VERSION%.*}" && \
|
||||||
|
printf 'NODE_VERSION=%s\nPYTHON_VERSION=%s\nPYTHON_MAJOR_MINOR=%s\nDOTNET_VERSION=%s\n' \
|
||||||
|
"$NODE_VERSION" "$PYTHON_VERSION" "$PYTHON_MAJOR_MINOR" "$DOTNET_VERSION" > /etc/build.env
|
||||||
|
|
||||||
|
# ── System dependencies ─────────────────────────────────────────────────────
|
||||||
|
# Single source of truth: .packages/apt.txt (also consumed by CI).
|
||||||
|
COPY .packages /tmp/.packages
|
||||||
|
RUN apt-get update && \
|
||||||
|
PACKAGES=$(grep -v '^[[:space:]]*#' /tmp/.packages/apt.txt | grep -v '^[[:space:]]*$' | tr '\n' ' ') && \
|
||||||
|
apt-get install -y --no-install-recommends $PACKAGES && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# ── Node.js ─────────────────────────────────────────────────────────────────
|
||||||
|
RUN . /etc/build.env && \
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - && \
|
||||||
|
apt-get install -y nodejs && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# ── .NET ────────────────────────────────────────────────────────────────────
|
||||||
|
RUN . /etc/build.env && \
|
||||||
|
curl -fsSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh && \
|
||||||
|
chmod +x /tmp/dotnet-install.sh && \
|
||||||
|
/tmp/dotnet-install.sh --channel ${DOTNET_VERSION} --install-dir /usr/share/dotnet && \
|
||||||
|
ln -sf /usr/share/dotnet/dotnet /usr/local/bin/dotnet && \
|
||||||
|
rm /tmp/dotnet-install.sh
|
||||||
|
|
||||||
|
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1
|
||||||
|
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
|
||||||
|
|
||||||
|
# ── Workspace ───────────────────────────────────────────────────────────────
|
||||||
|
USER vscode
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
# Runtime verification. Workspace isn't mounted at image-build time —
|
||||||
|
# package.json only exists when the container is RUN against a mounted
|
||||||
|
# project, so the Electron version check runs at container start rather
|
||||||
|
# than build. Print what we CAN verify here.
|
||||||
|
RUN . /etc/build.env && \
|
||||||
|
echo "=== Slopsmith Desktop build environment ===" && \
|
||||||
|
echo "Node: $(node --version)" && \
|
||||||
|
echo "npm: $(npm --version)" && \
|
||||||
|
echo "Python: $(python3 --version)" && \
|
||||||
|
echo ".NET: $(dotnet --version)"
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# DevContainer build environment
|
||||||
|
|
||||||
|
Everything needed to build Slopsmith Desktop in a reproducible,
|
||||||
|
CI-identical environment using Docker.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Slopsmith Desktop compiles a native C++ audio engine (JUCE), bundles a
|
||||||
|
portable Python runtime, and packages via electron-builder — all of
|
||||||
|
which can produce subtly different artifacts on different distros
|
||||||
|
(glibc, system library versions, Node/Python provenance). This
|
||||||
|
container pins everything to what the GitHub Actions `ubuntu-22.04`
|
||||||
|
runner uses so local AppImage builds match CI.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `Dockerfile` | Ubuntu 22.04 base image; versions read from `../.build-config.json` |
|
||||||
|
| `devcontainer.json` | VS Code Dev Containers configuration |
|
||||||
|
| `README.md` | This file |
|
||||||
|
|
||||||
|
The Docker-wrapped one-shot build lives at
|
||||||
|
[`../scripts/build-linux-release.sh`](../scripts/build-linux-release.sh).
|
||||||
|
|
||||||
|
Versions of Node, Python, .NET, Electron, CMake, and the Ubuntu base
|
||||||
|
are defined in [`../.build-config.json`](../.build-config.json). CI and
|
||||||
|
this container both read from it, so no version drift.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- [Docker](https://docs.docker.com/get-docker/) installed and running
|
||||||
|
- The [Slopsmith](https://github.com/slopsmith/slopsmith) server
|
||||||
|
repository cloned **adjacent** to this one:
|
||||||
|
```
|
||||||
|
your-projects/
|
||||||
|
├── slopsmith/
|
||||||
|
└── slopsmith-desktop/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
### VS Code (recommended)
|
||||||
|
|
||||||
|
With the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers):
|
||||||
|
|
||||||
|
1. Open the project in VS Code
|
||||||
|
2. Run **Dev Containers: Reopen in Container**
|
||||||
|
3. Wait for first-time image build + `npm install` (via `postCreateCommand`)
|
||||||
|
4. Build the release: `npm run dist:linux`
|
||||||
|
5. Artifacts in `release/` are visible on the host
|
||||||
|
|
||||||
|
### Docker only
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/build-linux-release.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Builds the image, runs the build inside a container, and emits
|
||||||
|
`.AppImage` + `.deb` into `release/` on the host.
|
||||||
|
|
||||||
|
## Build output
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|---|---|
|
||||||
|
| `release/*.AppImage` | Portable Linux executable (self-contained) |
|
||||||
|
| `release/*.deb` | Debian/Ubuntu package |
|
||||||
|
|
||||||
|
Both appear on your host filesystem — test them immediately without
|
||||||
|
touching the container.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
### The image
|
||||||
|
|
||||||
|
The Dockerfile layers onto `mcr.microsoft.com/devcontainers/base:ubuntu-22.04`:
|
||||||
|
- **Node.js** from NodeSource, version per `.build-config.json`
|
||||||
|
- **Python 3.12** from the deadsnakes PPA (Ubuntu 22.04's main repos
|
||||||
|
only ship 3.10)
|
||||||
|
- **.NET** via the upstream `dot.net` install script
|
||||||
|
- **System libraries** from `.packages/apt.txt`
|
||||||
|
|
||||||
|
### Mounts
|
||||||
|
|
||||||
|
Two bind mounts:
|
||||||
|
|
||||||
|
1. `slopsmith-desktop` → `/workspace` — source code, node_modules, build
|
||||||
|
output. Edits are reflected immediately on the host.
|
||||||
|
2. `../slopsmith` → `/workspaces/slopsmith` — the Slopsmith server repo
|
||||||
|
that `bundle-slopsmith.sh` copies from at bundle time.
|
||||||
|
|
||||||
|
### Container lifecycle
|
||||||
|
|
||||||
|
The one-shot build container is **not** auto-removed (`--rm` is
|
||||||
|
deliberately omitted from `build-linux-release.sh`). This lets you
|
||||||
|
attach a shell after a failed build to diagnose:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it <container-name> /bin/bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Clean up when done:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker stop <container-name> && docker rm <container-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
The script prints the container name on completion or failure.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Slopsmith repository not found"
|
||||||
|
|
||||||
|
Clone the server repo adjacent:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ..
|
||||||
|
git clone https://github.com/slopsmith/slopsmith.git
|
||||||
|
cd slopsmith-desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
### Permission errors on `release/`
|
||||||
|
|
||||||
|
UID/GID mismatch between your host user and the container's `vscode`
|
||||||
|
user (UID 1000). If needed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo chown -R "$(id -u):$(id -g)" release/
|
||||||
|
```
|
||||||
|
|
||||||
|
### First build is slow
|
||||||
|
|
||||||
|
First build downloads Docker layers, installs apt packages, and
|
||||||
|
bootstraps node_modules. Subsequent builds use layer caching.
|
||||||
|
|
||||||
|
### Out of disk space
|
||||||
|
|
||||||
|
Electron builds are large (expect ~10 GB free). Clean old images:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker system prune -a
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI vs local container
|
||||||
|
|
||||||
|
Intentional differences:
|
||||||
|
- No publishing secrets (`--publish never`)
|
||||||
|
- Host architecture (GitHub Actions also builds for macOS/Windows)
|
||||||
|
- No release-draft creation
|
||||||
|
|
||||||
|
Everything else — system deps, Node/Python/.NET versions, the bundle
|
||||||
|
pipeline — is driven by the same `.build-config.json` + `.packages/`
|
||||||
|
files the CI workflow reads.
|
||||||
|
|
||||||
|
## Modifying the build environment
|
||||||
|
|
||||||
|
- **System packages**: edit `.packages/apt.txt` (`brew.txt`/`choco.txt`
|
||||||
|
for the other platforms)
|
||||||
|
- **Tool versions**: edit `.build-config.json`
|
||||||
|
- **Image-level changes**: edit `Dockerfile`; rebuild with
|
||||||
|
`./scripts/build-linux-release.sh` (or VS Code: **Dev Containers:
|
||||||
|
Rebuild Container**)
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [GitHub Actions workflow](../.github/workflows/build.yml) — what this
|
||||||
|
container replicates
|
||||||
|
- [Build architecture](../docs/BUILD_ARCHITECTURE.md) — full script
|
||||||
|
layout + conventions
|
||||||
|
- [`../package.json`](../package.json) — the npm scripts that drive the
|
||||||
|
build
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "Slopsmith Desktop Builder",
|
||||||
|
"build": {
|
||||||
|
"dockerfile": "Dockerfile",
|
||||||
|
"context": ".."
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"ghcr.io/devcontainers/features/git:1": {}
|
||||||
|
},
|
||||||
|
"mounts": [
|
||||||
|
{
|
||||||
|
"source": "${localWorkspaceFolder}/../slopsmith",
|
||||||
|
"target": "/workspaces/slopsmith",
|
||||||
|
"type": "bind"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"remoteUser": "vscode",
|
||||||
|
"postCreateCommand": "test -d /workspaces/slopsmith || (echo 'ERROR: Slopsmith repository not found at ../slopsmith — clone it adjacent to this repo: git clone https://github.com/slopsmith/slopsmith.git ../slopsmith' && exit 1) && python3 scripts/parse-build-config.py .build-config.json >/dev/null && git submodule update --init --recursive && npm install",
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"settings": {
|
||||||
|
"terminal.integrated.defaultProfile.linux": "bash"
|
||||||
|
},
|
||||||
|
"extensions": [
|
||||||
|
"ms-vscode.cpptools",
|
||||||
|
"ms-vscode.cmake-tools",
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"esbenp.prettier-vscode"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"hostRequirements": {
|
||||||
|
"cpus": 4,
|
||||||
|
"memory": "8gb",
|
||||||
|
"storage": "32gb"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Shows a "Sponsor" button on this repo linking to the pages below.
|
||||||
|
# This does not enrol the project in the GitHub Sponsors program.
|
||||||
|
patreon: Slopsmith
|
||||||
|
ko_fi: slopsmith
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
name: Addon CI
|
||||||
|
|
||||||
|
# Per-OS build + load smoke-test for the native audio addon
|
||||||
|
# (slopsmith_audio.node), on every pull request.
|
||||||
|
#
|
||||||
|
# Why this exists, separate from build.yml:
|
||||||
|
# build.yml runs the FULL electron-builder packaging/signing matrix and is
|
||||||
|
# deliberately gated off the pull_request event (every review commit would
|
||||||
|
# otherwise re-run a ~3x multi-GB release build). That left a gap: a PR that
|
||||||
|
# breaks the macOS/Linux *addon* compile or link was never built on those
|
||||||
|
# OSes until it had already merged to main. That is exactly how the #250
|
||||||
|
# regression shipped — an undefined symbol (typeinfo for
|
||||||
|
# slopsmith::sandbox::SandboxedProcessor) that links fine but fails at
|
||||||
|
# runtime dlopen, leaving AppImage/dmg users with empty audio device lists
|
||||||
|
# (issue #266, regression fixed in #263).
|
||||||
|
#
|
||||||
|
# This job is the cheap counterpart: it builds ONLY the addon
|
||||||
|
# (`npm run build:audio` — no Python, no electron-builder, no
|
||||||
|
# signing) on all three OSes, then loads the resulting .node and asserts the
|
||||||
|
# device-enumeration API is present. A plain build can't catch the #250 class
|
||||||
|
# of bug: Node native modules link with undefined symbols permitted
|
||||||
|
# (macOS uses -undefined dynamic_lookup; Linux has no -Wl,--no-undefined), so
|
||||||
|
# the compile and link succeed even with an unresolved symbol — the failure
|
||||||
|
# only surfaces at load time. The smoke-test load is what makes that fail CI.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Cancel an in-flight run when new commits land on the same PR/branch — no
|
||||||
|
# point burning three runners on a superseded commit during a review loop.
|
||||||
|
concurrency:
|
||||||
|
group: addon-ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
# Least privilege: this workflow only checks out code.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
addon:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: ubuntu-22.04
|
||||||
|
platform: linux
|
||||||
|
arch: x64
|
||||||
|
- os: macos-14
|
||||||
|
platform: mac
|
||||||
|
arch: arm64
|
||||||
|
- os: windows-latest
|
||||||
|
platform: win
|
||||||
|
arch: x64
|
||||||
|
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive # JUCE (and any vendored audio third_party)
|
||||||
|
# No step after checkout uses GITHUB_TOKEN (public repo, public
|
||||||
|
# submodules, no push), and this runs on pull_request — including
|
||||||
|
# from forks — so don't leave the token persisted in .git/config.
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
# Match the Node version the app ships with — the addon is built against
|
||||||
|
# the Electron ABI, but node-addon-api headers come from node_modules and
|
||||||
|
# the toolchain is pinned via .build-config.json (single source of truth,
|
||||||
|
# same as build.yml).
|
||||||
|
- name: Read build configuration
|
||||||
|
id: config
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
NODE_VERSION=$(node -p "require('./.build-config.json').versions.node")
|
||||||
|
echo "node=$NODE_VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Node ${NODE_VERSION}"
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ steps.config.outputs.node }}
|
||||||
|
# Pin the Node binary architecture per runner (arm64 on macos-14,
|
||||||
|
# x64 elsewhere) so the addon's node-addon-api headers and the
|
||||||
|
# toolchain match the OS the build targets.
|
||||||
|
architecture: ${{ matrix.arch }}
|
||||||
|
|
||||||
|
# Linux: the addon links juce_audio_devices (ALSA/JACK) and pulls in
|
||||||
|
# juce_audio_processors' transitive X11/freetype/fontconfig deps at build
|
||||||
|
# time. Install from .packages/apt.txt — the same canonical list
|
||||||
|
# build-linux-ubuntu.sh uses — so this stays in sync with the real build.
|
||||||
|
# xvfb is added on top (not in apt.txt) purely as insurance for the
|
||||||
|
# load-time smoke-test below.
|
||||||
|
- name: Install Linux build deps
|
||||||
|
if: matrix.platform == 'linux'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
PACKAGES=$(grep -v '^[[:space:]]*#' .packages/apt.txt | grep -v '^[[:space:]]*$' | tr '\n' ' ')
|
||||||
|
sudo apt-get install -y --no-install-recommends $PACKAGES xvfb
|
||||||
|
|
||||||
|
# cmake-js + node-addon-api come from node_modules. No package-lock.json
|
||||||
|
# in this repo, so `npm install` (not `npm ci`).
|
||||||
|
- name: Install npm dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: Build native audio addon
|
||||||
|
run: npm run build:audio
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
# Load the freshly built .node and assert the device-enumeration entry
|
||||||
|
# points are present. slopsmith_audio.node is an N-API addon (ABI-stable
|
||||||
|
# across runtimes), so plain `node` can load an Electron-built binary —
|
||||||
|
# this is the exact reproduction Byron confirmed locally for #250, where
|
||||||
|
# the load fails with "undefined symbol: ...SandboxedProcessor..." even
|
||||||
|
# though the build was green. typeof getDeviceTypes === 'function' also
|
||||||
|
# verifies the device API the empty-device-list regression was about.
|
||||||
|
- name: Smoke-test addon load (Linux)
|
||||||
|
if: matrix.platform == 'linux'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
xvfb-run -a node -e "const a=require('./build/Release/slopsmith_audio.node'); if(typeof a.getDeviceTypes!=='function'){console.error('FAIL: getDeviceTypes export missing'); process.exit(1);} console.log('addon loaded; device API present');"
|
||||||
|
|
||||||
|
- name: Smoke-test addon load (macOS / Windows)
|
||||||
|
if: matrix.platform != 'linux'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
node -e "const a=require('./build/Release/slopsmith_audio.node'); if(typeof a.getDeviceTypes!=='function'){console.error('FAIL: getDeviceTypes export missing'); process.exit(1);} console.log('addon loaded; device API present');"
|
||||||
@@ -0,0 +1,563 @@
|
|||||||
|
name: Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ['v*']
|
||||||
|
# PRs do not build automatically — every commit during review would
|
||||||
|
# otherwise re-run the full 3-platform matrix. Trigger the matrix build
|
||||||
|
# manually (Actions tab → Build → "Run workflow", pick the PR branch)
|
||||||
|
# once the review round is finished.
|
||||||
|
# Note: workflow_dispatch only allows selecting branches that exist in
|
||||||
|
# this repository; it cannot target branches from contributor forks.
|
||||||
|
# For fork PRs, the maintainer must merge to a local branch first.
|
||||||
|
#
|
||||||
|
# The slopsmith_ref input selects which slopsmith *core* ref to bundle.
|
||||||
|
# It defaults to `main` (matching the historical behaviour), but for a
|
||||||
|
# coherent release build the desktop release/vX.Y.Z branch should be
|
||||||
|
# built against the matching core branch — e.g. run this workflow on
|
||||||
|
# release/v0.3.0 with slopsmith_ref=release/v0.3.0.
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
slopsmith_ref:
|
||||||
|
description: "Slopsmith core ref (branch or tag) to bundle, e.g. release/v0.3.0. Defaults to main."
|
||||||
|
required: false
|
||||||
|
default: main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: ubuntu-22.04
|
||||||
|
platform: linux
|
||||||
|
arch: x64
|
||||||
|
- os: macos-14
|
||||||
|
platform: mac
|
||||||
|
arch: arm64
|
||||||
|
- os: windows-latest
|
||||||
|
platform: win
|
||||||
|
arch: x64
|
||||||
|
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
# Read build configuration to get version numbers for setup tools
|
||||||
|
- name: Read build configuration
|
||||||
|
id: config
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
NODE_VERSION=$(node -p "require('./.build-config.json').versions.node")
|
||||||
|
PYTHON_VERSION=$(node -p "require('./.build-config.json').versions.python")
|
||||||
|
DOTNET_VERSION=$(node -p "require('./.build-config.json').versions.dotnet")
|
||||||
|
echo "node=$NODE_VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "python=$PYTHON_VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "dotnet=$DOTNET_VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "Node ${NODE_VERSION}, Python ${PYTHON_VERSION}, .NET ${DOTNET_VERSION}"
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ steps.config.outputs.node }}
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ steps.config.outputs.python }}
|
||||||
|
|
||||||
|
- uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: ${{ steps.config.outputs.dotnet }}.x
|
||||||
|
|
||||||
|
# macOS only: import the Developer ID Application certificate into a
|
||||||
|
# temporary keychain so codesign + electron-builder + notarytool can
|
||||||
|
# find it. The keychain is wiped at the end of the job (see "Clean
|
||||||
|
# up signing keychain" below) so a re-run of the same job doesn't
|
||||||
|
# see stale state. The whole step is a no-op when the secret is
|
||||||
|
# absent — supports forks and PRs from contributors who can't
|
||||||
|
# access repository secrets.
|
||||||
|
# Step is unconditional on macOS so we always enter the shell, then
|
||||||
|
# bail out at runtime if the cert secret isn't present (forks /
|
||||||
|
# contributor PRs without secret access, etc.). step-level env: is
|
||||||
|
# NOT available in step-level if: expressions, and `secrets.*` is
|
||||||
|
# also not available there — runtime check is the only robust way
|
||||||
|
# to gate this on secret presence.
|
||||||
|
- name: Import Apple signing certificate
|
||||||
|
if: matrix.platform == 'mac'
|
||||||
|
env:
|
||||||
|
APPLE_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_CERTIFICATE_P12_BASE64 }}
|
||||||
|
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
|
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ -z "${APPLE_CERTIFICATE_P12_BASE64:-}" ]]; then
|
||||||
|
echo "APPLE_CERTIFICATE_P12_BASE64 not set — skipping (unsigned build)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
# printf '%s' rather than echo — echo would append a trailing
|
||||||
|
# newline that some toolchains accept and some reject when
|
||||||
|
# piped into base64 --decode, producing a subtly corrupted
|
||||||
|
# .p12 that fails security import with the unhelpful error
|
||||||
|
# "SecKeychainItemImport: One or more parameters passed to a
|
||||||
|
# function were not valid."
|
||||||
|
printf '%s' "$APPLE_CERTIFICATE_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12"
|
||||||
|
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||||
|
security default-keychain -s build.keychain
|
||||||
|
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||||
|
security set-keychain-settings -lut 21600 build.keychain
|
||||||
|
# Add build.keychain to the user search list — codesign and
|
||||||
|
# electron-builder look up identities via the search list, not
|
||||||
|
# the default-keychain pointer. Without this, identity-by-name
|
||||||
|
# lookups fall back to the unmodified login keychain and the
|
||||||
|
# imported cert is invisible. Keep login.keychain-db in the
|
||||||
|
# list so trust anchors etc. still resolve.
|
||||||
|
security list-keychains -d user -s build.keychain login.keychain-db
|
||||||
|
security import "$RUNNER_TEMP/cert.p12" -k build.keychain \
|
||||||
|
-P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
|
||||||
|
security set-key-partition-list \
|
||||||
|
-S apple-tool:,apple:,codesign: \
|
||||||
|
-s -k "$KEYCHAIN_PASSWORD" build.keychain
|
||||||
|
rm -f "$RUNNER_TEMP/cert.p12"
|
||||||
|
# Sanity check — verify the identity is actually present.
|
||||||
|
security find-identity -v -p codesigning build.keychain | grep -q "Developer ID Application"
|
||||||
|
|
||||||
|
# macOS only: inline the team ID into package.json so electron-builder's
|
||||||
|
# `notarize.teamId` field has a real value (it doesn't expand env vars).
|
||||||
|
# Build using the unified platform script
|
||||||
|
# All bundling and dependency installation is handled by the script.
|
||||||
|
# macOS-only env vars below are consumed by:
|
||||||
|
# APPLE_SIGNING_IDENTITY → sign-macos-binaries.sh (codesign --sign)
|
||||||
|
# APPLE_ID,
|
||||||
|
# APPLE_APP_SPECIFIC_PASSWORD,
|
||||||
|
# APPLE_TEAM_ID → electron-builder's notarytool path
|
||||||
|
# build-macos.sh derives CSC_NAME (which electron-builder uses to
|
||||||
|
# pick the signing identity) from APPLE_SIGNING_IDENTITY by
|
||||||
|
# stripping the "Developer ID Application:" prefix electron-
|
||||||
|
# builder rejects. On non-mac platforms these are empty / unused.
|
||||||
|
- name: Build
|
||||||
|
run: ./scripts/build-release.sh
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
# Which slopsmith core ref to clone + bundle. Empty on push/tag
|
||||||
|
# events (the inputs context only carries values on
|
||||||
|
# workflow_dispatch), so fall back to main — preserving the
|
||||||
|
# historical default-branch behaviour. clone_slopsmith() in
|
||||||
|
# build-common.sh also defaults to main as a second safety net.
|
||||||
|
SLOPSMITH_REF: ${{ inputs.slopsmith_ref || 'main' }}
|
||||||
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||||
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
|
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||||
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
# electron-builder defensively skips code signing when the
|
||||||
|
# triggering event is `pull_request` ("forks usually don't have
|
||||||
|
# signing secrets"). This workflow no longer runs on the
|
||||||
|
# pull_request event — it runs on push and workflow_dispatch —
|
||||||
|
# so this env var is currently a no-op. It is kept here as a
|
||||||
|
# safety net in case a pull_request trigger is ever re-added:
|
||||||
|
# without it the notary service would reject the submission with
|
||||||
|
# "signature of the binary is invalid" + "no secure timestamp"
|
||||||
|
# + "no hardened runtime" on every nested Electron Helper app.
|
||||||
|
CSC_FOR_PULL_REQUEST: "true"
|
||||||
|
|
||||||
|
# Start a debugging session — runs BEFORE keychain cleanup so a
|
||||||
|
# signing-related failure can be inspected with the keychain still
|
||||||
|
# populated.
|
||||||
|
- name: Setup upterm session
|
||||||
|
uses: owenthereal/action-upterm@v1
|
||||||
|
if: ${{ failure() }}
|
||||||
|
with:
|
||||||
|
wait-timeout-minutes: 5
|
||||||
|
|
||||||
|
# Tester builds (non-tag only): macOS is packaged for distribution by the
|
||||||
|
# Velopack steps below, which run ONLY on tag pushes. On a plain push to
|
||||||
|
# main or a manual workflow_dispatch, electron-builder's "dir" target
|
||||||
|
# leaves an unpackaged release/mac-*/Slopsmith.app that the artifact
|
||||||
|
# upload globs (AppImage/deb/exe/velopack) don't match — so testers got
|
||||||
|
# no macOS build. Zip the .app with ditto (preserves the bundle's
|
||||||
|
# internal symlinks + any signature/xattrs) so a tester-ready macOS
|
||||||
|
# archive ships from ordinary CI runs without cutting a release tag.
|
||||||
|
# Skipped on tags so it never races or duplicates the Velopack output.
|
||||||
|
# The archive is unsigned/un-notarized — testers clear Gatekeeper with
|
||||||
|
# xattr -dr com.apple.quarantine Slopsmith.app
|
||||||
|
- name: Zip macOS app for tester distribution (non-tag)
|
||||||
|
if: matrix.platform == 'mac' && !startsWith(github.ref, 'refs/tags/v')
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
app=$(ls -d release/mac*/Slopsmith.app 2>/dev/null | head -n1 || true)
|
||||||
|
if [[ -z "${app:-}" || ! -d "$app" ]]; then
|
||||||
|
echo "::error::No Slopsmith.app found under release/mac*/ — macOS build produced no bundle."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ditto -c -k --sequesterRsrc --keepParent "$app" "release/Slopsmith-macos-arm64.zip"
|
||||||
|
echo "Zipped $app -> release/Slopsmith-macos-arm64.zip"
|
||||||
|
ls -lh release/Slopsmith-macos-arm64.zip
|
||||||
|
|
||||||
|
# Velopack: derive update channel from tag name. Only runs for
|
||||||
|
# tag pushes (refs/tags/v*) on win + mac — Linux still ships via
|
||||||
|
# electron-builder AppImage/deb with no auto-update.
|
||||||
|
#
|
||||||
|
# Channel mapping (plan gotcha #4 — fail on ambiguous tags rather
|
||||||
|
# than silently defaulting to stable, which would publish a
|
||||||
|
# prerelease into the stable feed):
|
||||||
|
# v1.2.3 → stable
|
||||||
|
# v1.2.3-alpha.<n> → alpha
|
||||||
|
# v1.2.3-beta.<n> → beta
|
||||||
|
# v1.2.3-rc.<n> → rc
|
||||||
|
# anything else → job fails
|
||||||
|
- name: Derive Velopack channel
|
||||||
|
id: velopack_channel
|
||||||
|
# Tag builds: derive the release channel for win + mac (linux is
|
||||||
|
# electron-builder AppImage/deb, no Velopack). Manual dispatch: also
|
||||||
|
# pack a Velopack package for WINDOWS only, on a throwaway `dev`
|
||||||
|
# channel. Without this, dispatch (the documented tester-build path)
|
||||||
|
# produced no usable Windows artifact — win-unpacked/ isn't uploaded
|
||||||
|
# and Velopack only ran on tags, so testers got just .pdb symbols.
|
||||||
|
# macOS dispatch stays Velopack-free (it already uploads a tester .zip
|
||||||
|
# from the "Zip macOS app" step), so we don't add an unsigned-update
|
||||||
|
# mac pack here.
|
||||||
|
if: >-
|
||||||
|
(startsWith(github.ref, 'refs/tags/v') && matrix.platform != 'linux')
|
||||||
|
|| (github.event_name == 'workflow_dispatch' && matrix.platform == 'win')
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# Read tag / event / run number from runtime env vars rather than
|
||||||
|
# interpolating GitHub Actions workflow expressions into the script
|
||||||
|
# text — a tag containing shell syntax could otherwise execute
|
||||||
|
# (template injection).
|
||||||
|
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
|
||||||
|
# Strip any prerelease suffix from the app version (0.2.9-beta.1 ->
|
||||||
|
# 0.2.9), then tag a unique dev build so successive dispatch builds
|
||||||
|
# produce distinct Velopack versions. The `dev` channel keeps these
|
||||||
|
# out of the alpha/beta/rc/stable auto-update feeds.
|
||||||
|
base="$(node -p "require('./package.json').version")"
|
||||||
|
base="${base%%-*}"
|
||||||
|
channel=dev
|
||||||
|
version="${base}-dev.${GITHUB_RUN_NUMBER}"
|
||||||
|
echo "channel=$channel" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Manual dispatch -> channel=$channel version=$version"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
tag="${GITHUB_REF_NAME}"
|
||||||
|
if [[ "$tag" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)-alpha\.[0-9]+$ ]]; then
|
||||||
|
channel=alpha
|
||||||
|
version="${BASH_REMATCH[1]}-alpha.${tag##*-alpha.}"
|
||||||
|
elif [[ "$tag" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)-beta\.[0-9]+$ ]]; then
|
||||||
|
channel=beta
|
||||||
|
version="${BASH_REMATCH[1]}-beta.${tag##*-beta.}"
|
||||||
|
elif [[ "$tag" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)-rc\.[0-9]+$ ]]; then
|
||||||
|
channel=rc
|
||||||
|
version="${BASH_REMATCH[1]}-rc.${tag##*-rc.}"
|
||||||
|
elif [[ "$tag" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
|
||||||
|
channel=stable
|
||||||
|
version="${BASH_REMATCH[1]}"
|
||||||
|
else
|
||||||
|
echo "::error::Tag '$tag' does not match a known Velopack channel pattern (vX.Y.Z[-{alpha,beta,rc}.N]). Refusing to default to stable."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "channel=$channel" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Derived channel=$channel version=$version from tag $tag"
|
||||||
|
|
||||||
|
- name: Install Velopack CLI (vpk)
|
||||||
|
if: steps.velopack_channel.outputs.channel != ''
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# Pin vpk to the exact version of the velopack npm SDK used by the
|
||||||
|
# app (package.json) — Velopack ships the CLI and SDK in lockstep,
|
||||||
|
# and an unpinned install would pull a different latest on each run.
|
||||||
|
dotnet tool install -g vpk --version 0.0.1589-ga2c5a97
|
||||||
|
echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
# Windows: pack the electron-builder unpacked dir into Velopack
|
||||||
|
# release assets (per-machine MSI, *-full.nupkg, *-delta.nupkg,
|
||||||
|
# releases.<ch>.json). Unsigned for now — see plan gotcha #3.
|
||||||
|
#
|
||||||
|
# The channel is rid-scoped (win-x64-<track>): Velopack requires a
|
||||||
|
# unique channel per os/rid so the win + macOS releases.<ch>.json
|
||||||
|
# manifests don't collide when published to one GitHub release.
|
||||||
|
# The client's update-manager.ts builds the same name to match.
|
||||||
|
#
|
||||||
|
# --msi emits a machine-wide MSI bootstrap (validated decision #2 in
|
||||||
|
# the integration plan: install to %ProgramFiles%\Slopsmith). The flag
|
||||||
|
# is a bool — verified against vpk source at our pinned commit
|
||||||
|
# a2c5a97 (src/vpk/Velopack.Vpk/Commands/Packaging/WindowsPackCommand.cs):
|
||||||
|
# AddOption<bool>((v) => BuildMsi = v, "--msi")
|
||||||
|
#
|
||||||
|
# --instLocation defaults to "Either" (a dual-mode MSI where the user
|
||||||
|
# picks at install time). We force PerMachine so the MSI is always
|
||||||
|
# machine-wide, matching the plan.
|
||||||
|
#
|
||||||
|
# vpk also writes a per-user Setup.exe by default — we delete it
|
||||||
|
# right after packing so only ONE install method ships. Reasons:
|
||||||
|
# - Two install methods for the same app split the user base in
|
||||||
|
# half for every support question ("where is my app installed?").
|
||||||
|
# - The MSI is what the in-app NSIS migration banner downloads, so
|
||||||
|
# it has to ship anyway.
|
||||||
|
# The Velopack feed assets (.nupkg, releases.<channel>.json) drive
|
||||||
|
# auto-update for both install methods, so dropping Setup.exe does
|
||||||
|
# NOT break auto-update for users who installed via the MSI.
|
||||||
|
#
|
||||||
|
# Velopack's WindowsVelopackLocator falls back to %LOCALAPPDATA% for
|
||||||
|
# the packages staging dir when Program Files isn't writable, so the
|
||||||
|
# auto-updater works without elevation. Apply-step elevation is
|
||||||
|
# handled by Update.exe's manifest.
|
||||||
|
- name: Velopack pack (Windows)
|
||||||
|
if: matrix.platform == 'win' && steps.velopack_channel.outputs.channel != ''
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
vpk pack \
|
||||||
|
--packId Slopsmith \
|
||||||
|
--packVersion "${{ steps.velopack_channel.outputs.version }}" \
|
||||||
|
--channel "win-x64-${{ steps.velopack_channel.outputs.channel }}" \
|
||||||
|
--packDir release/win-unpacked \
|
||||||
|
--msi \
|
||||||
|
--instLocation PerMachine \
|
||||||
|
-o release/velopack
|
||||||
|
# Drop the per-user Setup.exe (default vpk output) so only the
|
||||||
|
# per-machine MSI ships. Match permissively — the exact name
|
||||||
|
# varies between Velopack versions (Slopsmith-win-Setup.exe vs
|
||||||
|
# Slopsmith-win-x64-Setup.exe etc.). nocaseglob so a casing
|
||||||
|
# change in vpk's output can't slip an installer past the glob.
|
||||||
|
shopt -s nullglob nocaseglob
|
||||||
|
removed=0
|
||||||
|
for f in release/velopack/*setup.exe; do
|
||||||
|
echo "Removing per-user installer: $f"
|
||||||
|
rm -f "$f"
|
||||||
|
removed=$((removed+1))
|
||||||
|
done
|
||||||
|
# Enforce the MSI-only policy: hard-fail if any setup installer
|
||||||
|
# survived cleanup (e.g. an rm failure) rather than publishing
|
||||||
|
# two installer types.
|
||||||
|
if compgen -G "release/velopack/*setup.exe" > /dev/null; then
|
||||||
|
echo "::error::Setup.exe artifact(s) remain after cleanup; refusing to publish dual installer types."
|
||||||
|
ls -1 release/velopack/*setup.exe
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ "$removed" -eq 0 ]]; then
|
||||||
|
# MSI-only is the goal — if vpk emits no Setup.exe at all that
|
||||||
|
# is fine, not a failure. Note it in case the naming drifted.
|
||||||
|
echo "::notice::No Setup.exe output from vpk; shipping MSI-only artifacts."
|
||||||
|
fi
|
||||||
|
# Sanity check: the MSI must exist or the release is meaningless.
|
||||||
|
# compgen -G, not `ls *.msi` — with nullglob on, an unmatched glob
|
||||||
|
# expands to nothing and `ls` would list the cwd and pass silently.
|
||||||
|
if ! compgen -G "release/velopack/*.msi" > /dev/null; then
|
||||||
|
echo "::error::vpk pack --msi x64 did not produce a .msi file in release/velopack/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# macOS: same, but pass Apple signing identity + notarization
|
||||||
|
# creds so Velopack codesigns + notarizes the bundle it generates
|
||||||
|
# (otherwise Gatekeeper blocks auto-applied updates — plan
|
||||||
|
# gotcha #2). vpk codesigns via --signAppIdentity and notarizes via
|
||||||
|
# --notaryProfile, which names a `xcrun notarytool` credential
|
||||||
|
# profile — vpk has no raw --apple-id/--password/--team-id flags,
|
||||||
|
# so the profile must be created with `notarytool store-credentials`
|
||||||
|
# before vpk pack runs.
|
||||||
|
# --packDir points directly at the .app bundle: Velopack uses a
|
||||||
|
# path ending in .app as a prebuilt bundle. A non-.app folder
|
||||||
|
# would instead trigger auto-bundle mode (which also needs
|
||||||
|
# --icon). electron-builder's "dir" target writes the bundle to
|
||||||
|
# release/mac-arm64/Slopsmith.app on the arm64 runner.
|
||||||
|
- name: Velopack pack (macOS)
|
||||||
|
if: matrix.platform == 'mac' && steps.velopack_channel.outputs.channel != ''
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||||
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
|
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||||
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# The signed/notarized branch needs ALL four Apple secrets — vpk
|
||||||
|
# would fail outright if handed an empty --notary* value. If any
|
||||||
|
# is missing (forks, partial secret configs) fall back to an
|
||||||
|
# unsigned pack, matching the all-four check in build-macos.sh.
|
||||||
|
if [[ -z "${APPLE_SIGNING_IDENTITY:-}" || -z "${APPLE_ID:-}" \
|
||||||
|
|| -z "${APPLE_APP_SPECIFIC_PASSWORD:-}" \
|
||||||
|
|| -z "${APPLE_TEAM_ID:-}" ]]; then
|
||||||
|
echo "::warning::Apple signing/notarization secrets incomplete — packing macOS Velopack release UNSIGNED. Gatekeeper will block auto-updates on user machines."
|
||||||
|
vpk pack \
|
||||||
|
--packId Slopsmith \
|
||||||
|
--packVersion "${{ steps.velopack_channel.outputs.version }}" \
|
||||||
|
--channel "osx-arm64-${{ steps.velopack_channel.outputs.channel }}" \
|
||||||
|
--packDir release/mac-arm64/Slopsmith.app \
|
||||||
|
-o release/velopack
|
||||||
|
else
|
||||||
|
# vpk notarizes through a notarytool *credential profile*, not
|
||||||
|
# raw Apple-ID flags (it shells out to `xcrun notarytool`, which
|
||||||
|
# takes --keychain-profile). Create the profile first, then hand
|
||||||
|
# vpk --notaryProfile. Do NOT pass --keychain: notarytool's
|
||||||
|
# --keychain wants a file *path* (not a name like
|
||||||
|
# "build.keychain"), and notarytool stores/reads credential
|
||||||
|
# profiles in the login keychain by default. vpk's own internal
|
||||||
|
# notarytool call also defaults to the login keychain, so
|
||||||
|
# leaving --keychain off keeps both sides pointing at the same
|
||||||
|
# store. (build.keychain only holds the signing cert, which
|
||||||
|
# codesign finds via the keychain search list.)
|
||||||
|
xcrun notarytool store-credentials "velopack-notary" \
|
||||||
|
--apple-id "$APPLE_ID" \
|
||||||
|
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
|
||||||
|
--team-id "$APPLE_TEAM_ID"
|
||||||
|
vpk pack \
|
||||||
|
--packId Slopsmith \
|
||||||
|
--packVersion "${{ steps.velopack_channel.outputs.version }}" \
|
||||||
|
--channel "osx-arm64-${{ steps.velopack_channel.outputs.channel }}" \
|
||||||
|
--packDir release/mac-arm64/Slopsmith.app \
|
||||||
|
-o release/velopack \
|
||||||
|
--signAppIdentity "$APPLE_SIGNING_IDENTITY" \
|
||||||
|
--notaryProfile "velopack-notary"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# macOS only: drop the temporary keychain so a re-run doesn't see
|
||||||
|
# leftover state. Placed AFTER the Velopack pack steps (which call
|
||||||
|
# codesign + notarytool and need the keychain present on tag builds).
|
||||||
|
# always() ensures cleanup even on Velopack pack failures.
|
||||||
|
- name: Clean up signing keychain
|
||||||
|
if: always() && matrix.platform == 'mac'
|
||||||
|
run: |
|
||||||
|
security delete-keychain build.keychain 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Upload artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: slopsmith-${{ matrix.platform }}-${{ matrix.arch }}
|
||||||
|
# release/win-unpacked/ is intentionally NOT uploaded: it is the
|
||||||
|
# uncompressed intermediate build tree (~1 GB on Windows) that
|
||||||
|
# Velopack packs into release/velopack/. Uploading it duplicated
|
||||||
|
# the whole app in the artifact and ballooned the Windows artifact
|
||||||
|
# ~6x (0.27 -> 1.58 GiB). The packed velopack/ output is all that
|
||||||
|
# downstream needs. See issue #185.
|
||||||
|
# build/Release/*.pdb: Windows debug symbols for the native addon and
|
||||||
|
# the sandbox host. Archived as a workflow artifact (it is NOT
|
||||||
|
# attached to the public Release) so a tester's crash minidump can be
|
||||||
|
# symbolised. Empty on Linux/macOS — if-no-files-found: warn covers it.
|
||||||
|
path: |
|
||||||
|
release/*.AppImage
|
||||||
|
release/*.deb
|
||||||
|
release/*.exe
|
||||||
|
release/velopack/**/*
|
||||||
|
build/Release/*.pdb
|
||||||
|
# Tester-only macOS archive from the "Zip macOS app" step above.
|
||||||
|
# Only produced on non-tag runs; the `release` job's globs below
|
||||||
|
# do NOT include *.zip, so it never leaks into a tagged Release.
|
||||||
|
release/*.zip
|
||||||
|
if-no-files-found: warn
|
||||||
|
# Default retention (90 days) snowballed the org Actions storage cap
|
||||||
|
# — a single matrix run uploads ~2.5 GB across the three platforms,
|
||||||
|
# and old builds piled up for months. Tag builds don't need long
|
||||||
|
# retention either: the `release` job downloads and attaches these
|
||||||
|
# to the GitHub Release within the same workflow run.
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
release:
|
||||||
|
needs: build
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: artifacts
|
||||||
|
|
||||||
|
# Every release is published with prerelease=false, but only
|
||||||
|
# stable (bare vX.Y.Z) tags get make_latest=true. Reason:
|
||||||
|
#
|
||||||
|
# The Velopack JS SDK at our pinned version 0.0.1589-ga2c5a97
|
||||||
|
# routes any github.com URL through AutoSource → GithubSource,
|
||||||
|
# and AutoSource hardcodes the third constructor arg
|
||||||
|
# (`prerelease: false`). UpdateOptions has no Prerelease field,
|
||||||
|
# so there is no SDK-side override. If we tagged alpha/beta/rc as
|
||||||
|
# prerelease=true on GitHub, the in-app updater on those channels
|
||||||
|
# would silently see zero updates (verified live during the
|
||||||
|
# v0.2.9-alpha.102 validation: toggling the prerelease flag off
|
||||||
|
# made the release visible to the running app).
|
||||||
|
#
|
||||||
|
# Using make_latest=false on alpha/beta/rc tags keeps the GitHub
|
||||||
|
# UI's "Latest release" badge pinned to the most recent stable
|
||||||
|
# tag — humans browsing the repo still see stable as latest, even
|
||||||
|
# though the underlying release rows are all prerelease=false.
|
||||||
|
# Per the action-gh-release@v2 docs:
|
||||||
|
# "Drafts and prereleases cannot be set as latest"
|
||||||
|
# so we can't use prerelease=true + make_latest=true together.
|
||||||
|
#
|
||||||
|
# Channel scoping for the auto-updater is independent of either
|
||||||
|
# flag — Velopack picks the right release by the channel-named
|
||||||
|
# manifest asset (releases.<channel>.json), iterating recent
|
||||||
|
# releases until it finds the matching channel.
|
||||||
|
#
|
||||||
|
# draft is intentionally NOT set — Velopack clients can't see
|
||||||
|
# draft releases either way.
|
||||||
|
- name: Create Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
# Explicitly select only real distributables:
|
||||||
|
# - Linux ships electron-builder .AppImage / .deb
|
||||||
|
# - Windows + macOS ship via Velopack (release/velopack/**):
|
||||||
|
# MSI, *-osx.zip, *.nupkg, releases.<channel>.json
|
||||||
|
# win-unpacked/** and a bare *.exe glob are deliberately omitted —
|
||||||
|
# win-unpacked/ is for CI inspection only.
|
||||||
|
# No electron-builder *.dmg / *.zip globs: macOS is Velopack-only,
|
||||||
|
# so they would match nothing or duplicate the Velopack *-osx.zip.
|
||||||
|
#
|
||||||
|
# The velopack glob is `artifacts/**/velopack/**/*`, NOT
|
||||||
|
# `artifacts/**/release/velopack/**/*`. actions/upload-artifact@v4
|
||||||
|
# strips the longest common path prefix from upload inputs before
|
||||||
|
# archiving, so when the build job uploads both
|
||||||
|
# `release/win-unpacked/**` and `release/velopack/**`, the common
|
||||||
|
# prefix `release/` is stripped and files inside the artifact are
|
||||||
|
# rooted at `velopack/...` / `win-unpacked/...`. The old
|
||||||
|
# `**/release/velopack/**` glob never matched anything, which is
|
||||||
|
# why prior tagged releases shipped only Linux assets and the
|
||||||
|
# Windows + macOS Velopack feeds were silently empty (the in-app
|
||||||
|
# updater would 404 on releases.<channel>.json and report "no
|
||||||
|
# updates available").
|
||||||
|
files: |
|
||||||
|
artifacts/**/*.AppImage
|
||||||
|
artifacts/**/*.deb
|
||||||
|
artifacts/**/velopack/**/*
|
||||||
|
generate_release_notes: true
|
||||||
|
# All tags non-prerelease (Velopack SDK limitation, see the
|
||||||
|
# long comment above). Only stable tags become the GitHub
|
||||||
|
# "Latest release" — alpha/beta/rc are still visible to the
|
||||||
|
# in-app updater but won't be promoted in the repo UI.
|
||||||
|
prerelease: false
|
||||||
|
make_latest: ${{ (contains(github.ref_name, '-alpha.') || contains(github.ref_name, '-beta.') || contains(github.ref_name, '-rc.')) && 'false' || 'true' }}
|
||||||
|
|
||||||
|
# Mirror release version to slopsmith core
|
||||||
|
# Requires SLOPSMITH_SYNC_TOKEN secret
|
||||||
|
notify-slopsmith:
|
||||||
|
needs: release
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Dispatch VERSION sync to slopsmith
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.SLOPSMITH_SYNC_TOKEN }}
|
||||||
|
REF_NAME: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
version="${REF_NAME#v}"
|
||||||
|
if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
echo "Tag $REF_NAME is not vX.Y.Z — skipping slopsmith VERSION sync."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ -z "${GH_TOKEN:-}" ]; then
|
||||||
|
echo "::error::SLOPSMITH_SYNC_TOKEN secret is missing; cannot dispatch."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
gh api -X POST repos/slopsmith/slopsmith/dispatches \
|
||||||
|
-f event_type=desktop-released \
|
||||||
|
-f "client_payload[version]=$version"
|
||||||
|
echo "Dispatched desktop-released event (version=$version) to slopsmith."
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
# Reusable CI checks (typecheck + npm audit). Invoked by ship-ci.yml's `CI`
|
||||||
|
# job for PRs into main and release/** so the check run is named "CI / check",
|
||||||
|
# matching the org rulesets' required context. It deliberately has no
|
||||||
|
# standalone pull_request trigger: a direct run would publish a bare "check"
|
||||||
|
# that the rulesets can't match.
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Read build configuration
|
||||||
|
id: config
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
NODE_VERSION=$(node -p "require('./.build-config.json').versions.node")
|
||||||
|
echo "node=$NODE_VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ steps.config.outputs.node }}
|
||||||
|
|
||||||
|
# No package-lock.json in this repo — npm ci is not available.
|
||||||
|
- name: Install npm dependencies
|
||||||
|
run: npm install
|
||||||
|
|
||||||
|
- name: TypeScript type check
|
||||||
|
run: npm run typecheck
|
||||||
|
|
||||||
|
- name: npm audit
|
||||||
|
run: npm audit --audit-level=high
|
||||||
|
continue-on-error: true
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
name: Nightly
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 2 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
setup:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
branch: ${{ steps.branch.outputs.branch }}
|
||||||
|
date: ${{ steps.date.outputs.date }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# Find the most recently created release/v* branch by sorting version
|
||||||
|
# strings. Falls back to main if no release branch exists (quiet window
|
||||||
|
# between a release shipping and the next branch being cut).
|
||||||
|
- name: Find active release branch
|
||||||
|
id: branch
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: |
|
||||||
|
branch=$(gh api "repos/${{ github.repository }}/git/matching-refs/heads/release/v" \
|
||||||
|
--jq '[.[].ref | ltrimstr("refs/heads/")] | sort | last // empty' 2>/dev/null || echo "")
|
||||||
|
if [[ -z "$branch" ]]; then
|
||||||
|
branch="main"
|
||||||
|
fi
|
||||||
|
echo "branch=$branch" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Active branch: $branch"
|
||||||
|
|
||||||
|
- name: Get date
|
||||||
|
id: date
|
||||||
|
run: echo "date=$(date -u +%Y%m%d)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
build-core:
|
||||||
|
needs: setup
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Clone core at active branch
|
||||||
|
run: |
|
||||||
|
git clone --depth 1 --branch "${{ needs.setup.outputs.branch }}" \
|
||||||
|
https://github.com/slopsmith/slopsmith.git core
|
||||||
|
echo "Cloned slopsmith @ $(git -C core rev-parse --short HEAD) (${{ needs.setup.outputs.branch }})"
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: core
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
ghcr.io/slopsmith/slopsmith:nightly
|
||||||
|
ghcr.io/slopsmith/slopsmith:nightly-${{ needs.setup.outputs.date }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
build:
|
||||||
|
needs: setup
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: ubuntu-22.04
|
||||||
|
platform: linux
|
||||||
|
arch: x64
|
||||||
|
- os: macos-14
|
||||||
|
platform: mac
|
||||||
|
arch: arm64
|
||||||
|
- os: windows-latest
|
||||||
|
platform: win
|
||||||
|
arch: x64
|
||||||
|
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ needs.setup.outputs.branch }}
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Read build configuration
|
||||||
|
id: config
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
NODE_VERSION=$(node -p "require('./.build-config.json').versions.node")
|
||||||
|
PYTHON_VERSION=$(node -p "require('./.build-config.json').versions.python")
|
||||||
|
echo "node=$NODE_VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "python=$PYTHON_VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "Node ${NODE_VERSION}, Python ${PYTHON_VERSION}"
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ steps.config.outputs.node }}
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ steps.config.outputs.python }}
|
||||||
|
|
||||||
|
# macOS: import signing cert so build-macos.sh can codesign the .app.
|
||||||
|
# The nightly .app is signed but not notarized — testers clear Gatekeeper with
|
||||||
|
# xattr -dr com.apple.quarantine Slopsmith.app
|
||||||
|
# Same graceful fallback as release.yml: no-op when secrets are absent.
|
||||||
|
- name: Import Apple signing certificate
|
||||||
|
if: matrix.platform == 'mac'
|
||||||
|
env:
|
||||||
|
APPLE_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_CERTIFICATE_P12_BASE64 }}
|
||||||
|
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
|
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ -z "${APPLE_CERTIFICATE_P12_BASE64:-}" ]]; then
|
||||||
|
echo "APPLE_CERTIFICATE_P12_BASE64 not set — skipping (unsigned build)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
printf '%s' "$APPLE_CERTIFICATE_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12"
|
||||||
|
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||||
|
security default-keychain -s build.keychain
|
||||||
|
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||||
|
security set-keychain-settings -lut 21600 build.keychain
|
||||||
|
security list-keychains -d user -s build.keychain login.keychain-db
|
||||||
|
security import "$RUNNER_TEMP/cert.p12" -k build.keychain \
|
||||||
|
-P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
|
||||||
|
security set-key-partition-list \
|
||||||
|
-S apple-tool:,apple:,codesign: \
|
||||||
|
-s -k "$KEYCHAIN_PASSWORD" build.keychain
|
||||||
|
rm -f "$RUNNER_TEMP/cert.p12"
|
||||||
|
security find-identity -v -p codesigning build.keychain | grep -q "Developer ID Application"
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: ./scripts/build-release.sh
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||||
|
# No notarization for nightlies — testers use xattr -dr com.apple.quarantine
|
||||||
|
CSC_FOR_PULL_REQUEST: "true"
|
||||||
|
|
||||||
|
- name: Setup upterm session
|
||||||
|
uses: owenthereal/action-upterm@v1
|
||||||
|
if: ${{ failure() }}
|
||||||
|
with:
|
||||||
|
wait-timeout-minutes: 5
|
||||||
|
|
||||||
|
# macOS: zip the .app for tester distribution (same as release.yml non-tag path).
|
||||||
|
- name: Zip macOS app
|
||||||
|
if: matrix.platform == 'mac'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
app=$(ls -d release/mac*/Slopsmith.app 2>/dev/null | head -n1 || true)
|
||||||
|
if [[ -z "${app:-}" || ! -d "$app" ]]; then
|
||||||
|
echo "::error::No Slopsmith.app found under release/mac*/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ditto -c -k --sequesterRsrc --keepParent "$app" "release/Slopsmith-macos-arm64.zip"
|
||||||
|
echo "Zipped $app -> release/Slopsmith-macos-arm64.zip"
|
||||||
|
|
||||||
|
# Windows: electron-builder's `dir` target leaves win-unpacked/ with no
|
||||||
|
# packaged installer (Velopack is skipped for nightlies). Zip the directory
|
||||||
|
# so testers can download and extract, then launch Slopsmith.exe directly.
|
||||||
|
- name: Zip win-unpacked
|
||||||
|
if: matrix.platform == 'win'
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
Compress-Archive -Path release\win-unpacked\* -DestinationPath release\Slopsmith-windows-x64.zip
|
||||||
|
Write-Host "Zipped win-unpacked -> release\Slopsmith-windows-x64.zip"
|
||||||
|
|
||||||
|
- name: Clean up signing keychain
|
||||||
|
if: always() && matrix.platform == 'mac'
|
||||||
|
run: |
|
||||||
|
security delete-keychain build.keychain 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Upload nightly artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: nightly-${{ needs.setup.outputs.date }}-${{ matrix.platform }}-${{ matrix.arch }}
|
||||||
|
path: |
|
||||||
|
release/*.AppImage
|
||||||
|
release/*.deb
|
||||||
|
release/*.zip
|
||||||
|
build/Release/*.pdb
|
||||||
|
if-no-files-found: warn
|
||||||
|
retention-days: 7
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['v*']
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: ubuntu-22.04
|
||||||
|
platform: linux
|
||||||
|
arch: x64
|
||||||
|
- os: macos-14
|
||||||
|
platform: mac
|
||||||
|
arch: arm64
|
||||||
|
- os: windows-latest
|
||||||
|
platform: win
|
||||||
|
arch: x64
|
||||||
|
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Read build configuration
|
||||||
|
id: config
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
NODE_VERSION=$(node -p "require('./.build-config.json').versions.node")
|
||||||
|
PYTHON_VERSION=$(node -p "require('./.build-config.json').versions.python")
|
||||||
|
DOTNET_VERSION=$(node -p "require('./.build-config.json').versions.dotnet")
|
||||||
|
echo "node=$NODE_VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "python=$PYTHON_VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "dotnet=$DOTNET_VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "Node ${NODE_VERSION}, Python ${PYTHON_VERSION}, .NET ${DOTNET_VERSION}"
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ steps.config.outputs.node }}
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ steps.config.outputs.python }}
|
||||||
|
|
||||||
|
- uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: ${{ steps.config.outputs.dotnet }}.x
|
||||||
|
|
||||||
|
- name: Import Apple signing certificate
|
||||||
|
if: matrix.platform == 'mac'
|
||||||
|
env:
|
||||||
|
APPLE_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_CERTIFICATE_P12_BASE64 }}
|
||||||
|
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
|
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ -z "${APPLE_CERTIFICATE_P12_BASE64:-}" ]]; then
|
||||||
|
echo "APPLE_CERTIFICATE_P12_BASE64 not set — skipping (unsigned build)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
printf '%s' "$APPLE_CERTIFICATE_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12"
|
||||||
|
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||||
|
security default-keychain -s build.keychain
|
||||||
|
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||||
|
security set-keychain-settings -lut 21600 build.keychain
|
||||||
|
security list-keychains -d user -s build.keychain login.keychain-db
|
||||||
|
security import "$RUNNER_TEMP/cert.p12" -k build.keychain \
|
||||||
|
-P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
|
||||||
|
security set-key-partition-list \
|
||||||
|
-S apple-tool:,apple:,codesign: \
|
||||||
|
-s -k "$KEYCHAIN_PASSWORD" build.keychain
|
||||||
|
rm -f "$RUNNER_TEMP/cert.p12"
|
||||||
|
security find-identity -v -p codesigning build.keychain | grep -q "Developer ID Application"
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: ./scripts/build-release.sh
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||||
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
|
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||||
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
CSC_FOR_PULL_REQUEST: "true"
|
||||||
|
|
||||||
|
- name: Setup upterm session
|
||||||
|
uses: owenthereal/action-upterm@v1
|
||||||
|
if: ${{ failure() }}
|
||||||
|
with:
|
||||||
|
wait-timeout-minutes: 5
|
||||||
|
|
||||||
|
- name: Zip macOS app for tester distribution (non-tag)
|
||||||
|
if: matrix.platform == 'mac' && !startsWith(github.ref, 'refs/tags/v')
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
app=$(ls -d release/mac*/Slopsmith.app 2>/dev/null | head -n1 || true)
|
||||||
|
if [[ -z "${app:-}" || ! -d "$app" ]]; then
|
||||||
|
echo "::error::No Slopsmith.app found under release/mac*/ — macOS build produced no bundle."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ditto -c -k --sequesterRsrc --keepParent "$app" "release/Slopsmith-macos-arm64.zip"
|
||||||
|
echo "Zipped $app -> release/Slopsmith-macos-arm64.zip"
|
||||||
|
ls -lh release/Slopsmith-macos-arm64.zip
|
||||||
|
|
||||||
|
- name: Derive Velopack channel
|
||||||
|
id: velopack_channel
|
||||||
|
if: >-
|
||||||
|
(startsWith(github.ref, 'refs/tags/v') && matrix.platform != 'linux')
|
||||||
|
|| (github.event_name == 'workflow_dispatch' && matrix.platform == 'win')
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
|
||||||
|
base="$(node -p "require('./package.json').version")"
|
||||||
|
base="${base%%-*}"
|
||||||
|
channel=dev
|
||||||
|
version="${base}-dev.${GITHUB_RUN_NUMBER}"
|
||||||
|
echo "channel=$channel" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Manual dispatch -> channel=$channel version=$version"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
tag="${GITHUB_REF_NAME}"
|
||||||
|
if [[ "$tag" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)-alpha\.[0-9]+$ ]]; then
|
||||||
|
channel=alpha
|
||||||
|
version="${BASH_REMATCH[1]}-alpha.${tag##*-alpha.}"
|
||||||
|
elif [[ "$tag" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)-beta\.[0-9]+$ ]]; then
|
||||||
|
channel=beta
|
||||||
|
version="${BASH_REMATCH[1]}-beta.${tag##*-beta.}"
|
||||||
|
elif [[ "$tag" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)-rc\.[0-9]+$ ]]; then
|
||||||
|
channel=rc
|
||||||
|
version="${BASH_REMATCH[1]}-rc.${tag##*-rc.}"
|
||||||
|
elif [[ "$tag" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
|
||||||
|
channel=stable
|
||||||
|
version="${BASH_REMATCH[1]}"
|
||||||
|
else
|
||||||
|
echo "::error::Tag '$tag' does not match a known Velopack channel pattern (vX.Y.Z[-{alpha,beta,rc}.N]). Refusing to default to stable."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "channel=$channel" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Derived channel=$channel version=$version from tag $tag"
|
||||||
|
|
||||||
|
- name: Install Velopack CLI (vpk)
|
||||||
|
if: steps.velopack_channel.outputs.channel != ''
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
dotnet tool install -g vpk --version 0.0.1589-ga2c5a97
|
||||||
|
echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
- name: Velopack pack (Windows)
|
||||||
|
if: matrix.platform == 'win' && steps.velopack_channel.outputs.channel != ''
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
vpk pack \
|
||||||
|
--packId Slopsmith \
|
||||||
|
--packVersion "${{ steps.velopack_channel.outputs.version }}" \
|
||||||
|
--channel "win-x64-${{ steps.velopack_channel.outputs.channel }}" \
|
||||||
|
--packDir release/win-unpacked \
|
||||||
|
--msi \
|
||||||
|
--instLocation PerMachine \
|
||||||
|
-o release/velopack
|
||||||
|
shopt -s nullglob nocaseglob
|
||||||
|
removed=0
|
||||||
|
for f in release/velopack/*setup.exe; do
|
||||||
|
echo "Removing per-user installer: $f"
|
||||||
|
rm -f "$f"
|
||||||
|
removed=$((removed+1))
|
||||||
|
done
|
||||||
|
if compgen -G "release/velopack/*setup.exe" > /dev/null; then
|
||||||
|
echo "::error::Setup.exe artifact(s) remain after cleanup; refusing to publish dual installer types."
|
||||||
|
ls -1 release/velopack/*setup.exe
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ "$removed" -eq 0 ]]; then
|
||||||
|
echo "::notice::No Setup.exe output from vpk; shipping MSI-only artifacts."
|
||||||
|
fi
|
||||||
|
if ! compgen -G "release/velopack/*.msi" > /dev/null; then
|
||||||
|
echo "::error::vpk pack --msi x64 did not produce a .msi file in release/velopack/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Velopack pack (macOS)
|
||||||
|
if: matrix.platform == 'mac' && steps.velopack_channel.outputs.channel != ''
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||||
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
|
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||||
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ -z "${APPLE_SIGNING_IDENTITY:-}" || -z "${APPLE_ID:-}" \
|
||||||
|
|| -z "${APPLE_APP_SPECIFIC_PASSWORD:-}" \
|
||||||
|
|| -z "${APPLE_TEAM_ID:-}" ]]; then
|
||||||
|
echo "::warning::Apple signing/notarization secrets incomplete — packing macOS Velopack release UNSIGNED. Gatekeeper will block auto-updates on user machines."
|
||||||
|
vpk pack \
|
||||||
|
--packId Slopsmith \
|
||||||
|
--packVersion "${{ steps.velopack_channel.outputs.version }}" \
|
||||||
|
--channel "osx-arm64-${{ steps.velopack_channel.outputs.channel }}" \
|
||||||
|
--packDir release/mac-arm64/Slopsmith.app \
|
||||||
|
-o release/velopack
|
||||||
|
else
|
||||||
|
xcrun notarytool store-credentials "velopack-notary" \
|
||||||
|
--apple-id "$APPLE_ID" \
|
||||||
|
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
|
||||||
|
--team-id "$APPLE_TEAM_ID"
|
||||||
|
vpk pack \
|
||||||
|
--packId Slopsmith \
|
||||||
|
--packVersion "${{ steps.velopack_channel.outputs.version }}" \
|
||||||
|
--channel "osx-arm64-${{ steps.velopack_channel.outputs.channel }}" \
|
||||||
|
--packDir release/mac-arm64/Slopsmith.app \
|
||||||
|
-o release/velopack \
|
||||||
|
--signAppIdentity "$APPLE_SIGNING_IDENTITY" \
|
||||||
|
--notaryProfile "velopack-notary"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Clean up signing keychain
|
||||||
|
if: always() && matrix.platform == 'mac'
|
||||||
|
run: |
|
||||||
|
security delete-keychain build.keychain 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Upload artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: slopsmith-${{ matrix.platform }}-${{ matrix.arch }}
|
||||||
|
path: |
|
||||||
|
release/*.AppImage
|
||||||
|
release/*.deb
|
||||||
|
release/*.exe
|
||||||
|
release/velopack/**/*
|
||||||
|
build/Release/*.pdb
|
||||||
|
release/*.zip
|
||||||
|
if-no-files-found: warn
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
release:
|
||||||
|
needs: build
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: artifacts
|
||||||
|
|
||||||
|
- name: Create Release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
artifacts/**/*.AppImage
|
||||||
|
artifacts/**/*.deb
|
||||||
|
artifacts/**/velopack/**/*
|
||||||
|
generate_release_notes: true
|
||||||
|
prerelease: false
|
||||||
|
make_latest: ${{ (contains(github.ref_name, '-alpha.') || contains(github.ref_name, '-beta.') || contains(github.ref_name, '-rc.')) && 'false' || 'true' }}
|
||||||
|
|
||||||
|
notify-slopsmith:
|
||||||
|
needs: release
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Dispatch VERSION sync to slopsmith
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.SLOPSMITH_SYNC_TOKEN }}
|
||||||
|
REF_NAME: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
version="${REF_NAME#v}"
|
||||||
|
if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
echo "Tag $REF_NAME is not vX.Y.Z — skipping slopsmith VERSION sync."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ -z "${GH_TOKEN:-}" ]; then
|
||||||
|
echo "::error::SLOPSMITH_SYNC_TOKEN secret is missing; cannot dispatch."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
gh api -X POST repos/slopsmith/slopsmith/dispatches \
|
||||||
|
-f event_type=desktop-released \
|
||||||
|
-f "client_payload[version]=$version"
|
||||||
|
echo "Dispatched desktop-released event (version=$version) to slopsmith."
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
name: Sandbox IPC tests
|
||||||
|
|
||||||
|
# Lightweight, fast-iterating job for the out-of-process plugin sandbox IPC
|
||||||
|
# layer (src/audio/Sandbox). Deliberately separate from the heavyweight Build
|
||||||
|
# matrix (build.yml): it needs only JUCE + a C++20 compiler — no cmake-js,
|
||||||
|
# node-addon-api, ONNX, Python, or electron-builder — so it can run on every
|
||||||
|
# push / PR that touches the sandbox without the full release toolchain.
|
||||||
|
#
|
||||||
|
# This is the iteration loop for the macOS sandbox port (issue #264): a
|
||||||
|
# Linux-only developer pushes a branch and gets the macOS compiler + test
|
||||||
|
# results they can't produce locally. The POSIX backend is shared between Linux
|
||||||
|
# and macOS, so Linux gives fast signal and macOS confirms the Darwin-specific
|
||||||
|
# paths (POSIX_SPAWN_CLOEXEC_DEFAULT, SO_NOSIGPIPE, the macOS shm/sem caveats).
|
||||||
|
#
|
||||||
|
# The TSan run on the threaded audio loopback is the highest-value artifact:
|
||||||
|
# it validates the release/acquire memory ordering on arm64 (macos-14) that a
|
||||||
|
# Linux-only dev cannot otherwise exercise.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- 'src/audio/Sandbox/**'
|
||||||
|
- 'src/vst-host/**'
|
||||||
|
- 'tests/sandbox/**'
|
||||||
|
- '.github/workflows/sandbox.yml'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'src/audio/Sandbox/**'
|
||||||
|
- 'src/vst-host/**'
|
||||||
|
- 'tests/sandbox/**'
|
||||||
|
- '.github/workflows/sandbox.yml'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Least privilege: these jobs only check out code and upload artifacts.
|
||||||
|
# actions/checkout needs contents:read; upload-artifact uses its own runtime
|
||||||
|
# token. Everything else defaults to none.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sandbox-tests:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: ubuntu-22.04
|
||||||
|
- os: macos-14
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive # JUCE
|
||||||
|
|
||||||
|
# JUCE on Linux needs the usual X11/ALSA dev headers even for juce_core's
|
||||||
|
# transitive config; install the minimal set. (No-op on macOS.)
|
||||||
|
- name: Install Linux deps
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --no-install-recommends \
|
||||||
|
ninja-build libasound2-dev libx11-dev libxext-dev libxinerama-dev \
|
||||||
|
libxrandr-dev libxcursor-dev libfreetype6-dev libfontconfig1-dev
|
||||||
|
|
||||||
|
- name: Install Ninja (macOS)
|
||||||
|
if: runner.os == 'macOS'
|
||||||
|
run: brew install ninja
|
||||||
|
|
||||||
|
# Build + run three ways: plain (Debug), ASan, TSan. Each is a fresh
|
||||||
|
# configure of the JUCE-only standalone harness, then ctest.
|
||||||
|
- name: Build + test (plain, ASan, TSan)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
for variant in plain address thread; do
|
||||||
|
echo "::group::$variant"
|
||||||
|
args=(-S tests/sandbox/standalone -B "build/sbx-$variant" -G Ninja \
|
||||||
|
-DCMAKE_BUILD_TYPE=Debug)
|
||||||
|
if [[ "$variant" != "plain" ]]; then
|
||||||
|
args+=(-DSLOPSMITH_SANITIZE="$variant")
|
||||||
|
fi
|
||||||
|
cmake "${args[@]}"
|
||||||
|
cmake --build "build/sbx-$variant"
|
||||||
|
# TSAN/ASAN: fail the job on any sanitizer diagnostic.
|
||||||
|
TSAN_OPTIONS="halt_on_error=1" ASAN_OPTIONS="halt_on_error=1" \
|
||||||
|
ctest --test-dir "build/sbx-$variant" --output-on-failure
|
||||||
|
echo "::endgroup::"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Upload sandbox child log on failure
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: sandbox-logs-${{ matrix.os }}
|
||||||
|
path: |
|
||||||
|
build/sbx-*/Testing/**/*.log
|
||||||
|
if-no-files-found: ignore
|
||||||
|
|
||||||
|
# Heavier end-to-end job: builds a passthrough VST3 + the real
|
||||||
|
# slopsmith-vst-host, spawns it from a host-side driver, and round-trips audio
|
||||||
|
# over the shm ring (posix_spawn + fd inheritance + handshake + process +
|
||||||
|
# state + shutdown). Separate from the unit job because it pulls in
|
||||||
|
# juce_audio_processors / juce_gui_basics / the VST3 plugin client.
|
||||||
|
sandbox-e2e:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: ubuntu-22.04
|
||||||
|
- os: macos-14
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
# Full JUCE Linux GUI/audio dep set (gui_basics + audio_processors).
|
||||||
|
- name: Install Linux deps
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --no-install-recommends \
|
||||||
|
ninja-build xvfb \
|
||||||
|
libasound2-dev libjack-jackd2-dev libfreetype6-dev libfontconfig1-dev \
|
||||||
|
libx11-dev libxcomposite-dev libxcursor-dev libxext-dev \
|
||||||
|
libxinerama-dev libxrandr-dev libxrender-dev libglu1-mesa-dev \
|
||||||
|
mesa-common-dev libcurl4-openssl-dev libwebkit2gtk-4.1-dev
|
||||||
|
|
||||||
|
- name: Install Ninja (macOS)
|
||||||
|
if: runner.os == 'macOS'
|
||||||
|
run: brew install ninja
|
||||||
|
|
||||||
|
- name: Build e2e harness
|
||||||
|
run: |
|
||||||
|
cmake -S tests/sandbox/e2e -B build/e2e -G Ninja -DCMAKE_BUILD_TYPE=Debug
|
||||||
|
cmake --build build/e2e
|
||||||
|
|
||||||
|
# The vst-host loads a no-editor VST3, so no display is strictly needed;
|
||||||
|
# run under xvfb on Linux anyway as insurance against any X11 touch in
|
||||||
|
# juce_gui_basics init. macOS has a window server.
|
||||||
|
- name: Run e2e (Linux, xvfb)
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: xvfb-run -a ctest --test-dir build/e2e --output-on-failure
|
||||||
|
|
||||||
|
- name: Run e2e (macOS)
|
||||||
|
if: runner.os == 'macOS'
|
||||||
|
run: ctest --test-dir build/e2e --output-on-failure
|
||||||
|
|
||||||
|
- name: Upload vst-host log on failure
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: sandbox-e2e-logs-${{ matrix.os }}
|
||||||
|
path: |
|
||||||
|
${{ runner.temp }}/slopsmith-vst-host-*.log
|
||||||
|
/tmp/slopsmith-vst-host-*.log
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 7
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
name: Ship CI
|
||||||
|
|
||||||
|
# PRs into both main and release/** run CI through this wrapper so the check
|
||||||
|
# run is named "CI / check" (reusable-workflow caller prefix from the `CI`
|
||||||
|
# job), matching the org rulesets' required context. ci.yml itself only
|
||||||
|
# triggers via workflow_call — it never runs standalone, which would emit an
|
||||||
|
# unprefixed "check" the rulesets can't match.
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main, 'release/**']
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ship-ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
CI:
|
||||||
|
uses: ./.github/workflows/ci.yml
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
release/
|
||||||
|
*.node
|
||||||
|
|
||||||
|
# JUCE build artifacts (submodule itself is tracked)
|
||||||
|
# JUCE/ — tracked as submodule
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.egg-info/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# NAM models (user downloads)
|
||||||
|
models/
|
||||||
|
# ...but the bundled Basic Pitch ML model IS shipped with the app
|
||||||
|
# (Constitution IV — offline first-run), so track resources/models/.
|
||||||
|
!resources/models/
|
||||||
|
!resources/models/**
|
||||||
|
|
||||||
|
# User config
|
||||||
|
config/
|
||||||
|
|
||||||
|
# Bundle artifacts (created by scripts/bundle.sh)
|
||||||
|
resources/slopsmith/
|
||||||
|
resources/python/
|
||||||
|
resources/bin/
|
||||||
|
# Downloaded soundfont (bundle-soundfont.sh pulls this on demand;
|
||||||
|
# resources/soundfonts/LICENSE is tracked).
|
||||||
|
resources/soundfonts/*.sf2
|
||||||
|
|
||||||
|
# AppImage extraction dir
|
||||||
|
squashfs-root/
|
||||||
|
|
||||||
|
# Lock files
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
# Venv
|
||||||
|
.venv/
|
||||||
|
.tmp/
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[submodule "JUCE"]
|
||||||
|
path = JUCE
|
||||||
|
url = https://github.com/juce-framework/JUCE.git
|
||||||
|
branch = master
|
||||||
|
[submodule "src/audio/third_party/RTNeural"]
|
||||||
|
path = src/audio/third_party/RTNeural
|
||||||
|
url = https://github.com/jatinchowdhury18/RTNeural.git
|
||||||
|
branch = main
|
||||||
|
[submodule "src/audio/third_party/NAM"]
|
||||||
|
path = src/audio/third_party/NAM
|
||||||
|
url = https://github.com/sdatkinson/NeuralAmpModelerCore.git
|
||||||
|
branch = main
|
||||||
|
[submodule "src/audio/third_party/signalsmith-stretch"]
|
||||||
|
path = src/audio/third_party/signalsmith-stretch
|
||||||
|
url = https://github.com/Signalsmith-Audio/signalsmith-stretch.git
|
||||||
|
branch = main
|
||||||
|
[submodule "src/audio/third_party/signalsmith-linear"]
|
||||||
|
path = src/audio/third_party/signalsmith-linear
|
||||||
|
url = https://github.com/Signalsmith-Audio/linear.git
|
||||||
|
branch = main
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Package lists
|
||||||
|
|
||||||
|
System packages and Python dependencies required to build Slopsmith Desktop.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `apt.txt` | Ubuntu/Debian system packages (apt) |
|
||||||
|
| `brew.txt` | macOS system packages (Homebrew) |
|
||||||
|
| `choco.txt` | Windows system packages (Chocolatey) |
|
||||||
|
| `python.txt` | Python packages (pip) - **shared across all platforms** |
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Single source of truth for dependencies across:
|
||||||
|
- **Local development** (via `.devcontainer/`)
|
||||||
|
- **GitHub Actions CI** (`.github/workflows/build.yml`)
|
||||||
|
- **Manual installation** by contributors
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### System packages
|
||||||
|
|
||||||
|
For system packages, filter out comments and blank lines before piping:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ubuntu/Debian
|
||||||
|
grep -v '^[[:space:]]*#' .packages/apt.txt | grep -v '^[[:space:]]*$' | xargs sudo apt-get install -y
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
grep -v '^[[:space:]]*#' .packages/brew.txt | grep -v '^[[:space:]]*$' | xargs brew install
|
||||||
|
|
||||||
|
# Windows (PowerShell)
|
||||||
|
Get-Content .packages/choco.txt | Where-Object { $_ -notmatch '^\s*#' -and $_ -notmatch '^\s*$' } | ForEach-Object { choco install $_ -y }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python packages
|
||||||
|
|
||||||
|
The `python.txt` file is used directly by pip during the bundle step:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r .packages/python.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
This file is referenced from:
|
||||||
|
- `scripts/build-windows.sh` (Windows)
|
||||||
|
- `scripts/build-macos.sh` (macOS)
|
||||||
|
- `scripts/bundle-python.sh` (Linux)
|
||||||
|
|
||||||
|
## Format
|
||||||
|
|
||||||
|
- One package per line
|
||||||
|
- Lines starting with `#` are comments
|
||||||
|
- Blank lines are ignored
|
||||||
|
- Standard `pip install -r` format for `python.txt`
|
||||||
|
|
||||||
|
## Updating
|
||||||
|
|
||||||
|
### Adding system dependencies
|
||||||
|
1. Add to the appropriate `.packages/*.txt` for each OS
|
||||||
|
2. Update `.devcontainer/` if applicable
|
||||||
|
3. Test locally
|
||||||
|
|
||||||
|
### Adding Python dependencies
|
||||||
|
1. Add to `.packages/python.txt` (one file for all platforms)
|
||||||
|
2. Ensure the package is available on all platforms
|
||||||
|
3. Test builds on all three platforms
|
||||||
|
|
||||||
|
Changes affect both local builds and CI, so verify end-to-end before merging.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Ubuntu/Debian packages for building Slopsmith Desktop.
|
||||||
|
# Kept in sync with .github/workflows/build.yml — change both places together.
|
||||||
|
|
||||||
|
# Audio libraries
|
||||||
|
libasound2-dev
|
||||||
|
libjack-jackd2-dev
|
||||||
|
|
||||||
|
# Graphics/X11 libraries (required by JUCE)
|
||||||
|
# fontconfig is a hard requirement of juce_graphics (declared linuxPackages:
|
||||||
|
# "freetype2 fontconfig"), pulled in once slopsmith-vst-host — which links
|
||||||
|
# juce_gui_basics/juce_graphics — builds on Linux. Without it CMake configure
|
||||||
|
# fails at pkg_check_modules(freetype2 fontconfig) on a clean machine.
|
||||||
|
libfreetype-dev
|
||||||
|
libfontconfig1-dev
|
||||||
|
libx11-dev
|
||||||
|
libxrandr-dev
|
||||||
|
libxcursor-dev
|
||||||
|
libxinerama-dev
|
||||||
|
libxext-dev
|
||||||
|
|
||||||
|
# Web/WebKit (required by Electron webview)
|
||||||
|
libcurl4-openssl-dev
|
||||||
|
libwebkit2gtk-4.1-dev
|
||||||
|
|
||||||
|
# Build tools
|
||||||
|
pkg-config
|
||||||
|
cmake
|
||||||
|
build-essential
|
||||||
|
# GCC 12+ for the NAM A2 (slimmable) sources, which use
|
||||||
|
# std::atomic<std::shared_ptr<...>> — a C++20 library feature libstdc++ only
|
||||||
|
# implements from GCC 12. ubuntu-22.04's default (build-essential) g++ is 11;
|
||||||
|
# build-audio.sh selects g++-12 when the default is older.
|
||||||
|
g++-12
|
||||||
|
|
||||||
|
# File operations (used by bundle-slopsmith.sh to resolve symlinked plugins)
|
||||||
|
rsync
|
||||||
|
|
||||||
|
# Binary patching (used by bundle-binaries.sh to point bundled fluidsynth
|
||||||
|
# at sibling shared libraries at runtime)
|
||||||
|
patchelf
|
||||||
|
|
||||||
|
# MIDI → audio renderer used by GP5 import (`gp2midi.py`). `fluidsynth`
|
||||||
|
# here is the CLI; bundle-binaries.sh copies its full dylib chain.
|
||||||
|
fluidsynth
|
||||||
|
|
||||||
|
# Media tools (for audio processing)
|
||||||
|
ffmpeg
|
||||||
|
|
||||||
|
# Version control
|
||||||
|
git
|
||||||
|
|
||||||
|
# Archive utilities (required by electron-builder)
|
||||||
|
xz-utils
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# macOS Homebrew packages for building Slopsmith Desktop.
|
||||||
|
# Kept in sync with .github/workflows/build.yml — change both places together.
|
||||||
|
|
||||||
|
# Build tools
|
||||||
|
cmake
|
||||||
|
pkg-config
|
||||||
|
|
||||||
|
# Media tools
|
||||||
|
#
|
||||||
|
# ffmpeg is installed for its *dylibs* (libavformat / libavcodec /
|
||||||
|
# libswresample / libavutil) — vgmstream-cli is linked against
|
||||||
|
# /opt/homebrew/opt/ffmpeg/lib/lib*.dylib, and dylibbundler hangs in a
|
||||||
|
# stdin-EOF prompt loop on CI if those files don't exist at bundle
|
||||||
|
# time. We do NOT bundle brew's ffmpeg *binary*: Homebrew's stock
|
||||||
|
# formula (8.1.1+) no longer ships --enable-libvorbis (sloppak's
|
||||||
|
# encoder of choice), so build-macos.sh overwrites resources/bin/ffmpeg
|
||||||
|
# with a static build from osxexperts.net (arm64) / evermeet.cx (x86_64)
|
||||||
|
# instead. See `.external.ffmpeg_macos_*` in `.build-config.json`.
|
||||||
|
ffmpeg
|
||||||
|
|
||||||
|
# MIDI → audio renderer used by GP5 import (`gp2midi.py`).
|
||||||
|
# Homebrew calls the formula `fluid-synth` (with a hyphen); the binary
|
||||||
|
# it installs is still named `fluidsynth`.
|
||||||
|
fluid-synth
|
||||||
|
|
||||||
|
# Audio codec library required by vgmstream-cli
|
||||||
|
speex
|
||||||
|
|
||||||
|
# vgmstream-cli decodes WEM audio. build-macos.sh copies the
|
||||||
|
# Homebrew-installed binary (`command -v vgmstream-cli`) so it matches the
|
||||||
|
# build host's architecture — on the macos-14 CI runner that's arm64; on an
|
||||||
|
# Intel host it's x86_64. (Previously a prebuilt vgmstream-mac.zip was
|
||||||
|
# downloaded, which handed Intel hosts an arm64 binary → "Bad CPU type".)
|
||||||
|
vgmstream
|
||||||
|
|
||||||
|
# Used by the CI / local build to rewrite Mach-O load paths on the
|
||||||
|
# bundled fluidsynth and vgmstream-cli binaries so they load their
|
||||||
|
# dylibs via @executable_path instead of /opt/homebrew/lib.
|
||||||
|
dylibbundler
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Windows Chocolatey packages for building Slopsmith Desktop.
|
||||||
|
# Kept in sync with .github/workflows/build.yml — change both places together.
|
||||||
|
#
|
||||||
|
# NOTE: cmake requires special install args in GitHub Actions:
|
||||||
|
# choco install cmake --installargs 'ADD_CMAKE_TO_PATH=System'
|
||||||
|
#
|
||||||
|
# Fluidsynth + vgmstream on Windows are NOT installed via choco; they're
|
||||||
|
# downloaded as pinned release archives in the CI workflow because the
|
||||||
|
# choco packages lag upstream and we need specific versions.
|
||||||
|
|
||||||
|
cmake
|
||||||
|
ffmpeg
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# Desktop-only Python extras for the bundled runtime.
|
||||||
|
#
|
||||||
|
# Slopsmith's runtime requirements are installed from
|
||||||
|
# $SLOPSMITH_DIR/requirements.txt by the platform build scripts
|
||||||
|
# (bundle-python.sh / build-macos.sh / build-windows.sh) BEFORE this
|
||||||
|
# file is processed. That's the single source of truth for server-
|
||||||
|
# side runtime deps — DO NOT duplicate slopsmith's requirements here.
|
||||||
|
#
|
||||||
|
# This file is reserved for packages the desktop bundle needs that
|
||||||
|
# are NOT in slopsmith's requirements.txt. Today that's:
|
||||||
|
#
|
||||||
|
# - `requests`: pulled by some desktop-side helpers and a handful
|
||||||
|
# of community plugins; keep it explicit in the bundle.
|
||||||
|
# - `certifi`: the desktop main process points SSL_CERT_FILE at
|
||||||
|
# certifi's bundle so stdlib urllib (Slopsmith's update checker,
|
||||||
|
# the slopsmith-update-manager plugin, etc.) can verify HTTPS
|
||||||
|
# without falling back to a missing system CA path.
|
||||||
|
requests
|
||||||
|
certifi
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"ai": "claude",
|
||||||
|
"ai_skills": true,
|
||||||
|
"branch_numbering": "sequential",
|
||||||
|
"context_file": "CLAUDE.md",
|
||||||
|
"here": true,
|
||||||
|
"integration": "claude",
|
||||||
|
"script": "sh",
|
||||||
|
"speckit_version": "0.8.8.dev0"
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"version": "0.8.8.dev0",
|
||||||
|
"integration_state_schema": 1,
|
||||||
|
"installed_integrations": [
|
||||||
|
"claude"
|
||||||
|
],
|
||||||
|
"integration_settings": {
|
||||||
|
"claude": {
|
||||||
|
"script": "sh",
|
||||||
|
"invoke_separator": "-"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"integration": "claude",
|
||||||
|
"default_integration": "claude"
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"integration": "claude",
|
||||||
|
"version": "0.8.8.dev0",
|
||||||
|
"installed_at": "2026-05-09T21:18:10.415538+00:00",
|
||||||
|
"files": {
|
||||||
|
".claude/skills/speckit-analyze/SKILL.md": "2eef0fbff6cad15c9d4714d8986192387811c971a82a1135ab0404f3db0c5e90",
|
||||||
|
".claude/skills/speckit-checklist/SKILL.md": "26419fc118dcd9c4e1e977460696a04b7757b8fb0a2d1ff9c64732669deb7977",
|
||||||
|
".claude/skills/speckit-clarify/SKILL.md": "f2560f9f2007b4e995130f0c42633f08837a76a35d94e84091713a6f39bb1064",
|
||||||
|
".claude/skills/speckit-constitution/SKILL.md": "c1a044aba243ca6aff627fb5e4404feb6f1108d4f7dd174631bee3ae477d6c15",
|
||||||
|
".claude/skills/speckit-implement/SKILL.md": "6029565c1a56de8919d1846b187cd644f734a0e30a6067a709803e6bc0d2abf7",
|
||||||
|
".claude/skills/speckit-plan/SKILL.md": "8141ebbce228ad0b422a84e3b995d2bd85de917b96eadd02b5fcb56fb23f2594",
|
||||||
|
".claude/skills/speckit-specify/SKILL.md": "caadc05119eca453709a0425ed88d253883f9c55da4c13a4898367653a859483",
|
||||||
|
".claude/skills/speckit-tasks/SKILL.md": "54c4665be61818ed50aa528bb4c51db3627079b2c67d47f2b01046268288c4a5",
|
||||||
|
".claude/skills/speckit-taskstoissues/SKILL.md": "99bf5ffd90dcb57b63007c7f659a5160a18ce6feb82889895808e2d277abe83b"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"integration": "speckit",
|
||||||
|
"version": "0.8.8.dev0",
|
||||||
|
"installed_at": "2026-05-09T21:18:10.426884+00:00",
|
||||||
|
"files": {
|
||||||
|
".specify/scripts/bash/create-new-feature.sh": "bcf4964ca0c6c78717bb42d9e66b8c7e5ee82779cd96afc5aa7b08b75abe5790",
|
||||||
|
".specify/scripts/bash/setup-plan.sh": "0d1d7a66de157b0be1385bb91aa71e5bf95550217abf47a73270dab0dc52895a",
|
||||||
|
".specify/scripts/bash/check-prerequisites.sh": "aff361639c504b95a2901493f5022788adc01a6792fd37f132de8f57782e4b80",
|
||||||
|
".specify/scripts/bash/common.sh": "dd638316259e699fd466542c77ef16af5eb198efe0447c081f86b890db414ba8",
|
||||||
|
".specify/scripts/bash/setup-tasks.sh": "e8d050c63c5afb664a8b671b0b0155513fb9cab0567b335e16b9eb035482aad2",
|
||||||
|
".specify/templates/tasks-template.md": "fb7a30a6e8e7319b7134bd52a26dd52fb7dd9106ab8fa08b6fb551d704dac498",
|
||||||
|
".specify/templates/checklist-template.md": "c37695297e5d3153d64f82c21223509940b13932046c7961c42d1d669516130c",
|
||||||
|
".specify/templates/spec-template.md": "785dc50d856dd92d6515eca0761e16dce0c9ba0a3cd07154fd33eae77932422a",
|
||||||
|
".specify/templates/plan-template.md": "5ad267630e370c73fe957dafa61bf76d633f3aea9d2f0b5195087d729cdd1e41",
|
||||||
|
".specify/templates/constitution-template.md": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Slopsmith Desktop Constitution
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
### I. Wrap, Don't Fork
|
||||||
|
|
||||||
|
The desktop app is a *shell* around upstream Slopsmith. It MUST clone
|
||||||
|
or bundle Slopsmith verbatim and run its `server.py` as a subprocess.
|
||||||
|
We MUST NOT fork the Slopsmith codebase or carry private patches: if
|
||||||
|
the desktop needs new behaviour from Slopsmith, the change goes
|
||||||
|
upstream (gated by an env var or feature flag) before landing here.
|
||||||
|
|
||||||
|
### II. Native Audio Is the Whole Point
|
||||||
|
|
||||||
|
The reason this app exists (vs. running Slopsmith in a browser) is
|
||||||
|
low-latency audio with VST/AU/LV2 hosting, NAM, IRs, and pitch
|
||||||
|
detection. The audio engine MUST be a JUCE C++ native addon
|
||||||
|
(`src/audio/`) compiled via cmake-js, exposed to Electron through
|
||||||
|
N-API (`src/audio/NodeAddon.cpp`) and bridged into the renderer via
|
||||||
|
`src/main/audio-bridge.ts`. Audio code MUST NOT live in JS/TS.
|
||||||
|
|
||||||
|
### III. Three-Process Architecture
|
||||||
|
|
||||||
|
The runtime has exactly three layers:
|
||||||
|
|
||||||
|
1. **Renderer** (Slopsmith UI in a webview, talks to localhost
|
||||||
|
Python and to Electron via preload IPC).
|
||||||
|
2. **Electron main** (`src/main/`) — window lifecycle, IPC, plugin
|
||||||
|
manager, Python supervisor, audio bridge.
|
||||||
|
3. **Python subprocess** (`src/main/python.ts` spawning
|
||||||
|
`slopsmith/server.py` on port 18000+).
|
||||||
|
|
||||||
|
New features MUST fit one of these layers. Audio engine work is
|
||||||
|
loaded from layer 2 via the native addon and MUST NOT bypass it.
|
||||||
|
|
||||||
|
### IV. Bundle Everything Required to Run Offline
|
||||||
|
|
||||||
|
A first-run install MUST work without an internet connection: the
|
||||||
|
Python interpreter, Slopsmith source tree, default IRs, and the
|
||||||
|
default soundfont MUST be packaged via `electron-builder`'s
|
||||||
|
`extraResources`. Plugins are the exception (installed on demand
|
||||||
|
into the user's config dir).
|
||||||
|
|
||||||
|
### V. Reproducible Linux Builds Via DevContainer
|
||||||
|
|
||||||
|
Linux distribution builds MUST be reproducible against the
|
||||||
|
`ubuntu-22.04` GitHub Actions image. The DevContainer
|
||||||
|
(`.devcontainer/`) and `scripts/build-linux-release.sh` are the
|
||||||
|
canonical build path; ad-hoc `npm run dist:linux` on a developer's
|
||||||
|
host is for development iteration only.
|
||||||
|
|
||||||
|
### VI. Plugin Isolation From the App
|
||||||
|
|
||||||
|
Plugins MUST live in the user's config directory
|
||||||
|
(`~/.config/slopsmith-desktop/plugins/`) and be managed via
|
||||||
|
`src/main/plugin-manager.ts`. Updating the desktop app MUST NOT
|
||||||
|
touch installed plugins; removing the desktop app MUST NOT delete
|
||||||
|
them by default. Plugins are git clones of public repos; the plugin
|
||||||
|
manager wraps `git`.
|
||||||
|
|
||||||
|
### VII. Fail Soft on Audio Engine Absence
|
||||||
|
|
||||||
|
If the native audio addon fails to load (missing build, missing
|
||||||
|
runtime libs, etc.), the rest of the app MUST keep working —
|
||||||
|
Slopsmith UI, plugins, library browsing, all still functional.
|
||||||
|
`src/main/audio-bridge.ts` already swallows load failures and
|
||||||
|
returns `audio:isAvailable === false`; new features MUST follow the
|
||||||
|
same pattern.
|
||||||
|
|
||||||
|
### VIII. Cross-Platform Means All Three (NON-NEGOTIABLE)
|
||||||
|
|
||||||
|
We support Windows 10+, macOS 12+, and Linux. Every feature MUST
|
||||||
|
ship working code paths for all three. Per-OS audio backends:
|
||||||
|
ASIO/CoreAudio/JACK+ALSA. VST3 everywhere; AU on macOS; LV2 on
|
||||||
|
Linux. Per-OS distribution targets: AppImage+deb, dmg+zip, NSIS exe.
|
||||||
|
|
||||||
|
## Operational Constraints
|
||||||
|
|
||||||
|
- **Stack**: Electron + TypeScript (main/renderer wiring), JUCE C++
|
||||||
|
(audio engine), Python 3.12 (Slopsmith subprocess), CMake 3.22+.
|
||||||
|
- **Native Modules**: `slopsmith_audio.node` built via cmake-js,
|
||||||
|
unpacked from asar at runtime (`asarUnpack` in
|
||||||
|
`package.json.build`).
|
||||||
|
- **Resources**: `resources/{slopsmith,python,bin,default-irs,
|
||||||
|
soundfonts}` are extra-resourced into the packaged app.
|
||||||
|
- **Server port range**: 18000+ (chosen to avoid colliding with a
|
||||||
|
Docker Slopsmith on 8000 — see `src/main/python.ts`).
|
||||||
|
- **App ID**: `com.byron.slopsmith-desktop`.
|
||||||
|
- **NAM**: NeuralAmpModelerCore vendored at
|
||||||
|
`src/audio/third_party/NAM/`; NAM support is conditional on the
|
||||||
|
vendored tree being present (CMakeLists.txt).
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
- `npm run dev` — TS rebuild + Electron launch (uses the system
|
||||||
|
Python, not the bundled one).
|
||||||
|
- `npm run build:audio` — JUCE native addon (Release).
|
||||||
|
- `npm run dist:{linux,mac,win}` — full bundle + electron-builder.
|
||||||
|
- New audio capabilities go through `SignalChain` and a new
|
||||||
|
`*Processor` C++ class; the addon API is in
|
||||||
|
`src/audio/NodeAddon.cpp`.
|
||||||
|
- Plugin manager UI changes go in `src/renderer/plugin-manager/`.
|
||||||
|
- Splash screen / startup polling lives in `src/main/main.ts` and
|
||||||
|
`src/main/splash*.ts`; respect the 5-minute startup deadline and
|
||||||
|
the 700 ms poll interval already documented there.
|
||||||
|
|
||||||
|
## Governance
|
||||||
|
|
||||||
|
This repo is one of several in the Slopsmith ecosystem. The shared
|
||||||
|
workspace at `~/Repositories/slopsmith-workspace/` coordinates
|
||||||
|
cross-repo work. Constitution amendments here MUST consider whether
|
||||||
|
the upstream Slopsmith repo needs a parallel change (Principle I).
|
||||||
|
The desktop app is a downstream consumer of upstream Slopsmith,
|
||||||
|
upstream `slopsmith-demucs-server` (optional, configured by the
|
||||||
|
user), and the per-feature plugin repos (optional, installed by the
|
||||||
|
user).
|
||||||
|
|
||||||
|
**Version**: 1.0.0 | **Ratified**: 2026-05-09 | **Last Amended**: 2026-05-09
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Consolidated prerequisite checking script
|
||||||
|
#
|
||||||
|
# This script provides unified prerequisite checking for Spec-Driven Development workflow.
|
||||||
|
# It replaces the functionality previously spread across multiple scripts.
|
||||||
|
#
|
||||||
|
# Usage: ./check-prerequisites.sh [OPTIONS]
|
||||||
|
#
|
||||||
|
# OPTIONS:
|
||||||
|
# --json Output in JSON format
|
||||||
|
# --require-tasks Require tasks.md to exist (for implementation phase)
|
||||||
|
# --include-tasks Include tasks.md in AVAILABLE_DOCS list
|
||||||
|
# --paths-only Only output path variables (no validation)
|
||||||
|
# --help, -h Show help message
|
||||||
|
#
|
||||||
|
# OUTPUTS:
|
||||||
|
# JSON mode: {"FEATURE_DIR":"...", "AVAILABLE_DOCS":["..."]}
|
||||||
|
# Text mode: FEATURE_DIR:... \n AVAILABLE_DOCS: \n ✓/✗ file.md
|
||||||
|
# Paths only: REPO_ROOT: ... \n BRANCH: ... \n FEATURE_DIR: ... etc.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Parse command line arguments
|
||||||
|
JSON_MODE=false
|
||||||
|
REQUIRE_TASKS=false
|
||||||
|
INCLUDE_TASKS=false
|
||||||
|
PATHS_ONLY=false
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--json)
|
||||||
|
JSON_MODE=true
|
||||||
|
;;
|
||||||
|
--require-tasks)
|
||||||
|
REQUIRE_TASKS=true
|
||||||
|
;;
|
||||||
|
--include-tasks)
|
||||||
|
INCLUDE_TASKS=true
|
||||||
|
;;
|
||||||
|
--paths-only)
|
||||||
|
PATHS_ONLY=true
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
cat << 'EOF'
|
||||||
|
Usage: check-prerequisites.sh [OPTIONS]
|
||||||
|
|
||||||
|
Consolidated prerequisite checking for Spec-Driven Development workflow.
|
||||||
|
|
||||||
|
OPTIONS:
|
||||||
|
--json Output in JSON format
|
||||||
|
--require-tasks Require tasks.md to exist (for implementation phase)
|
||||||
|
--include-tasks Include tasks.md in AVAILABLE_DOCS list
|
||||||
|
--paths-only Only output path variables (no prerequisite validation)
|
||||||
|
--help, -h Show this help message
|
||||||
|
|
||||||
|
EXAMPLES:
|
||||||
|
# Check task prerequisites (plan.md required)
|
||||||
|
./check-prerequisites.sh --json
|
||||||
|
|
||||||
|
# Check implementation prerequisites (plan.md + tasks.md required)
|
||||||
|
./check-prerequisites.sh --json --require-tasks --include-tasks
|
||||||
|
|
||||||
|
# Get feature paths only (no validation)
|
||||||
|
./check-prerequisites.sh --paths-only
|
||||||
|
|
||||||
|
EOF
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "ERROR: Unknown option '$arg'. Use --help for usage information." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Source common functions
|
||||||
|
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/common.sh"
|
||||||
|
|
||||||
|
# Get feature paths and validate branch
|
||||||
|
_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; }
|
||||||
|
eval "$_paths_output"
|
||||||
|
unset _paths_output
|
||||||
|
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||||
|
|
||||||
|
# If paths-only mode, output paths and exit (support JSON + paths-only combined)
|
||||||
|
if $PATHS_ONLY; then
|
||||||
|
if $JSON_MODE; then
|
||||||
|
# Minimal JSON paths payload (no validation performed)
|
||||||
|
if has_jq; then
|
||||||
|
jq -cn \
|
||||||
|
--arg repo_root "$REPO_ROOT" \
|
||||||
|
--arg branch "$CURRENT_BRANCH" \
|
||||||
|
--arg feature_dir "$FEATURE_DIR" \
|
||||||
|
--arg feature_spec "$FEATURE_SPEC" \
|
||||||
|
--arg impl_plan "$IMPL_PLAN" \
|
||||||
|
--arg tasks "$TASKS" \
|
||||||
|
'{REPO_ROOT:$repo_root,BRANCH:$branch,FEATURE_DIR:$feature_dir,FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,TASKS:$tasks}'
|
||||||
|
else
|
||||||
|
printf '{"REPO_ROOT":"%s","BRANCH":"%s","FEATURE_DIR":"%s","FEATURE_SPEC":"%s","IMPL_PLAN":"%s","TASKS":"%s"}\n' \
|
||||||
|
"$(json_escape "$REPO_ROOT")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$TASKS")"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "REPO_ROOT: $REPO_ROOT"
|
||||||
|
echo "BRANCH: $CURRENT_BRANCH"
|
||||||
|
echo "FEATURE_DIR: $FEATURE_DIR"
|
||||||
|
echo "FEATURE_SPEC: $FEATURE_SPEC"
|
||||||
|
echo "IMPL_PLAN: $IMPL_PLAN"
|
||||||
|
echo "TASKS: $TASKS"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Validate required directories and files
|
||||||
|
if [[ ! -d "$FEATURE_DIR" ]]; then
|
||||||
|
echo "ERROR: Feature directory not found: $FEATURE_DIR" >&2
|
||||||
|
echo "Run /speckit.specify first to create the feature structure." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$IMPL_PLAN" ]]; then
|
||||||
|
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
|
||||||
|
echo "Run /speckit.plan first to create the implementation plan." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check for tasks.md if required
|
||||||
|
if $REQUIRE_TASKS && [[ ! -f "$TASKS" ]]; then
|
||||||
|
echo "ERROR: tasks.md not found in $FEATURE_DIR" >&2
|
||||||
|
echo "Run /speckit.tasks first to create the task list." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build list of available documents
|
||||||
|
docs=()
|
||||||
|
|
||||||
|
# Always check these optional docs
|
||||||
|
[[ -f "$RESEARCH" ]] && docs+=("research.md")
|
||||||
|
[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md")
|
||||||
|
|
||||||
|
# Check contracts directory (only if it exists and has files)
|
||||||
|
if [[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then
|
||||||
|
docs+=("contracts/")
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md")
|
||||||
|
|
||||||
|
# Include tasks.md if requested and it exists
|
||||||
|
if $INCLUDE_TASKS && [[ -f "$TASKS" ]]; then
|
||||||
|
docs+=("tasks.md")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Output results
|
||||||
|
if $JSON_MODE; then
|
||||||
|
# Build JSON array of documents
|
||||||
|
if has_jq; then
|
||||||
|
if [[ ${#docs[@]} -eq 0 ]]; then
|
||||||
|
json_docs="[]"
|
||||||
|
else
|
||||||
|
json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .)
|
||||||
|
fi
|
||||||
|
jq -cn \
|
||||||
|
--arg feature_dir "$FEATURE_DIR" \
|
||||||
|
--argjson docs "$json_docs" \
|
||||||
|
'{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs}'
|
||||||
|
else
|
||||||
|
if [[ ${#docs[@]} -eq 0 ]]; then
|
||||||
|
json_docs="[]"
|
||||||
|
else
|
||||||
|
json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done)
|
||||||
|
json_docs="[${json_docs%,}]"
|
||||||
|
fi
|
||||||
|
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$(json_escape "$FEATURE_DIR")" "$json_docs"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Text output
|
||||||
|
echo "FEATURE_DIR:$FEATURE_DIR"
|
||||||
|
echo "AVAILABLE_DOCS:"
|
||||||
|
|
||||||
|
# Show status of each potential document
|
||||||
|
check_file "$RESEARCH" "research.md"
|
||||||
|
check_file "$DATA_MODEL" "data-model.md"
|
||||||
|
check_dir "$CONTRACTS_DIR" "contracts/"
|
||||||
|
check_file "$QUICKSTART" "quickstart.md"
|
||||||
|
|
||||||
|
if $INCLUDE_TASKS; then
|
||||||
|
check_file "$TASKS" "tasks.md"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
@@ -0,0 +1,645 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Common functions and variables for all scripts
|
||||||
|
|
||||||
|
# Find repository root by searching upward for .specify directory
|
||||||
|
# This is the primary marker for spec-kit projects
|
||||||
|
find_specify_root() {
|
||||||
|
local dir="${1:-$(pwd)}"
|
||||||
|
# Normalize to absolute path to prevent infinite loop with relative paths
|
||||||
|
# Use -- to handle paths starting with - (e.g., -P, -L)
|
||||||
|
dir="$(cd -- "$dir" 2>/dev/null && pwd)" || return 1
|
||||||
|
local prev_dir=""
|
||||||
|
while true; do
|
||||||
|
if [ -d "$dir/.specify" ]; then
|
||||||
|
echo "$dir"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
# Stop if we've reached filesystem root or dirname stops changing
|
||||||
|
if [ "$dir" = "/" ] || [ "$dir" = "$prev_dir" ]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
prev_dir="$dir"
|
||||||
|
dir="$(dirname "$dir")"
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get repository root, prioritizing .specify directory over git
|
||||||
|
# This prevents using a parent git repo when spec-kit is initialized in a subdirectory
|
||||||
|
get_repo_root() {
|
||||||
|
# First, look for .specify directory (spec-kit's own marker)
|
||||||
|
local specify_root
|
||||||
|
if specify_root=$(find_specify_root); then
|
||||||
|
echo "$specify_root"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fallback to git if no .specify found
|
||||||
|
if git rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||||
|
git rev-parse --show-toplevel
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Final fallback to script location for non-git repos
|
||||||
|
local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
(cd "$script_dir/../../.." && pwd)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get current branch, with fallback for non-git repositories
|
||||||
|
get_current_branch() {
|
||||||
|
# First check if SPECIFY_FEATURE environment variable is set
|
||||||
|
if [[ -n "${SPECIFY_FEATURE:-}" ]]; then
|
||||||
|
echo "$SPECIFY_FEATURE"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Then check git if available at the spec-kit root (not parent)
|
||||||
|
local repo_root=$(get_repo_root)
|
||||||
|
if has_git; then
|
||||||
|
git -C "$repo_root" rev-parse --abbrev-ref HEAD
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# For non-git repos, try to find the latest feature directory
|
||||||
|
local specs_dir="$repo_root/specs"
|
||||||
|
|
||||||
|
if [[ -d "$specs_dir" ]]; then
|
||||||
|
local latest_feature=""
|
||||||
|
local highest=0
|
||||||
|
local latest_timestamp=""
|
||||||
|
|
||||||
|
for dir in "$specs_dir"/*; do
|
||||||
|
if [[ -d "$dir" ]]; then
|
||||||
|
local dirname=$(basename "$dir")
|
||||||
|
if [[ "$dirname" =~ ^([0-9]{8}-[0-9]{6})- ]]; then
|
||||||
|
# Timestamp-based branch: compare lexicographically
|
||||||
|
local ts="${BASH_REMATCH[1]}"
|
||||||
|
if [[ "$ts" > "$latest_timestamp" ]]; then
|
||||||
|
latest_timestamp="$ts"
|
||||||
|
latest_feature=$dirname
|
||||||
|
fi
|
||||||
|
elif [[ "$dirname" =~ ^([0-9]{3,})- ]]; then
|
||||||
|
local number=${BASH_REMATCH[1]}
|
||||||
|
number=$((10#$number))
|
||||||
|
if [[ "$number" -gt "$highest" ]]; then
|
||||||
|
highest=$number
|
||||||
|
# Only update if no timestamp branch found yet
|
||||||
|
if [[ -z "$latest_timestamp" ]]; then
|
||||||
|
latest_feature=$dirname
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -n "$latest_feature" ]]; then
|
||||||
|
echo "$latest_feature"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "main" # Final fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if we have git available at the spec-kit root level
|
||||||
|
# Returns true only if git is installed and the repo root is inside a git work tree
|
||||||
|
# Handles both regular repos (.git directory) and worktrees/submodules (.git file)
|
||||||
|
has_git() {
|
||||||
|
# First check if git command is available (before calling get_repo_root which may use git)
|
||||||
|
command -v git >/dev/null 2>&1 || return 1
|
||||||
|
local repo_root=$(get_repo_root)
|
||||||
|
# Check if .git exists (directory or file for worktrees/submodules)
|
||||||
|
[ -e "$repo_root/.git" ] || return 1
|
||||||
|
# Verify it's actually a valid git work tree
|
||||||
|
git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
|
||||||
|
# Only when the full name is exactly two slash-free segments; otherwise returns the raw name.
|
||||||
|
spec_kit_effective_branch_name() {
|
||||||
|
local raw="$1"
|
||||||
|
if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then
|
||||||
|
printf '%s\n' "${BASH_REMATCH[2]}"
|
||||||
|
else
|
||||||
|
printf '%s\n' "$raw"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_feature_branch() {
|
||||||
|
local raw="$1"
|
||||||
|
local has_git_repo="$2"
|
||||||
|
|
||||||
|
# For non-git repos, we can't enforce branch naming but still provide output
|
||||||
|
if [[ "$has_git_repo" != "true" ]]; then
|
||||||
|
echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local branch
|
||||||
|
branch=$(spec_kit_effective_branch_name "$raw")
|
||||||
|
|
||||||
|
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
|
||||||
|
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
|
||||||
|
local is_sequential=false
|
||||||
|
if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
|
||||||
|
is_sequential=true
|
||||||
|
fi
|
||||||
|
if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
|
||||||
|
echo "ERROR: Not on a feature branch. Current branch: $raw" >&2
|
||||||
|
echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Safely read .specify/feature.json's "feature_directory" value.
|
||||||
|
# Prints the raw value (possibly relative) to stdout, or empty string if the file
|
||||||
|
# is missing, unparseable, or does not contain the key. Always returns 0 so callers
|
||||||
|
# under `set -e` cannot be aborted by parser failure.
|
||||||
|
# Parser order mirrors the historical get_feature_paths behavior: jq -> python3 -> grep/sed.
|
||||||
|
read_feature_json_feature_directory() {
|
||||||
|
local repo_root="$1"
|
||||||
|
local fj="$repo_root/.specify/feature.json"
|
||||||
|
[[ -f "$fj" ]] || { printf '%s' ''; return 0; }
|
||||||
|
|
||||||
|
local _fd=''
|
||||||
|
if command -v jq >/dev/null 2>&1; then
|
||||||
|
if ! _fd=$(jq -r '.feature_directory // empty' "$fj" 2>/dev/null); then
|
||||||
|
_fd=''
|
||||||
|
fi
|
||||||
|
elif command -v python3 >/dev/null 2>&1; then
|
||||||
|
# Use Python so pretty-printed/multi-line JSON still parses correctly.
|
||||||
|
if ! _fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); v=d.get('feature_directory'); print(v if v else '')" "$fj" 2>/dev/null); then
|
||||||
|
_fd=''
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Last-resort single-line grep/sed fallback. The `|| true` guards against
|
||||||
|
# grep returning 1 (no match) aborting under `set -e` / `pipefail`.
|
||||||
|
_fd=$( { grep -E '"feature_directory"[[:space:]]*:' "$fj" 2>/dev/null || true; } \
|
||||||
|
| head -n 1 \
|
||||||
|
| sed -E 's/^[^:]*:[[:space:]]*"([^"]*)".*$/\1/' )
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s' "$_fd"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Returns 0 when .specify/feature.json lists feature_directory that exists as a directory
|
||||||
|
# and matches the resolved active FEATURE_DIR (so /speckit.plan can skip git branch pattern checks).
|
||||||
|
# Delegates parsing to read_feature_json_feature_directory, which is safe under `set -e`.
|
||||||
|
feature_json_matches_feature_dir() {
|
||||||
|
local repo_root="$1"
|
||||||
|
local active_feature_dir="$2"
|
||||||
|
|
||||||
|
local _fd
|
||||||
|
_fd=$(read_feature_json_feature_directory "$repo_root")
|
||||||
|
|
||||||
|
[[ -n "$_fd" ]] || return 1
|
||||||
|
[[ "$_fd" != /* ]] && _fd="$repo_root/$_fd"
|
||||||
|
[[ -d "$_fd" ]] || return 1
|
||||||
|
|
||||||
|
local norm_json norm_active
|
||||||
|
norm_json="$(cd -- "$_fd" 2>/dev/null && pwd -P)" || return 1
|
||||||
|
norm_active="$(cd -- "$active_feature_dir" 2>/dev/null && pwd -P)" || return 1
|
||||||
|
|
||||||
|
[[ "$norm_json" == "$norm_active" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find feature directory by numeric prefix instead of exact branch match
|
||||||
|
# This allows multiple branches to work on the same spec (e.g., 004-fix-bug, 004-add-feature)
|
||||||
|
find_feature_dir_by_prefix() {
|
||||||
|
local repo_root="$1"
|
||||||
|
local branch_name
|
||||||
|
branch_name=$(spec_kit_effective_branch_name "$2")
|
||||||
|
local specs_dir="$repo_root/specs"
|
||||||
|
|
||||||
|
# Extract prefix from branch (e.g., "004" from "004-whatever" or "20260319-143022" from timestamp branches)
|
||||||
|
local prefix=""
|
||||||
|
if [[ "$branch_name" =~ ^([0-9]{8}-[0-9]{6})- ]]; then
|
||||||
|
prefix="${BASH_REMATCH[1]}"
|
||||||
|
elif [[ "$branch_name" =~ ^([0-9]{3,})- ]]; then
|
||||||
|
prefix="${BASH_REMATCH[1]}"
|
||||||
|
else
|
||||||
|
# If branch doesn't have a recognized prefix, fall back to exact match
|
||||||
|
echo "$specs_dir/$branch_name"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Search for directories in specs/ that start with this prefix
|
||||||
|
local matches=()
|
||||||
|
if [[ -d "$specs_dir" ]]; then
|
||||||
|
for dir in "$specs_dir"/"$prefix"-*; do
|
||||||
|
if [[ -d "$dir" ]]; then
|
||||||
|
matches+=("$(basename "$dir")")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Handle results
|
||||||
|
if [[ ${#matches[@]} -eq 0 ]]; then
|
||||||
|
# No match found - return the branch name path (will fail later with clear error)
|
||||||
|
echo "$specs_dir/$branch_name"
|
||||||
|
elif [[ ${#matches[@]} -eq 1 ]]; then
|
||||||
|
# Exactly one match - perfect!
|
||||||
|
echo "$specs_dir/${matches[0]}"
|
||||||
|
else
|
||||||
|
# Multiple matches - this shouldn't happen with proper naming convention
|
||||||
|
echo "ERROR: Multiple spec directories found with prefix '$prefix': ${matches[*]}" >&2
|
||||||
|
echo "Please ensure only one spec directory exists per prefix." >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
get_feature_paths() {
|
||||||
|
local repo_root=$(get_repo_root)
|
||||||
|
local current_branch=$(get_current_branch)
|
||||||
|
local has_git_repo="false"
|
||||||
|
|
||||||
|
if has_git; then
|
||||||
|
has_git_repo="true"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Resolve feature directory. Priority:
|
||||||
|
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
|
||||||
|
# 2. .specify/feature.json "feature_directory" key (persisted by /speckit.specify)
|
||||||
|
# 3. Branch-name-based prefix lookup (legacy fallback)
|
||||||
|
local feature_dir
|
||||||
|
if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then
|
||||||
|
feature_dir="$SPECIFY_FEATURE_DIRECTORY"
|
||||||
|
# Normalize relative paths to absolute under repo root
|
||||||
|
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
|
||||||
|
elif [[ -f "$repo_root/.specify/feature.json" ]]; then
|
||||||
|
# Shared, set -e-safe parser: jq -> python3 -> grep/sed. Returns empty on
|
||||||
|
# missing/unparseable/unset so we fall through to the branch-prefix lookup.
|
||||||
|
local _fd
|
||||||
|
_fd=$(read_feature_json_feature_directory "$repo_root")
|
||||||
|
if [[ -n "$_fd" ]]; then
|
||||||
|
feature_dir="$_fd"
|
||||||
|
# Normalize relative paths to absolute under repo root
|
||||||
|
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
|
||||||
|
elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then
|
||||||
|
echo "ERROR: Failed to resolve feature directory" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
elif ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then
|
||||||
|
echo "ERROR: Failed to resolve feature directory" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use printf '%q' to safely quote values, preventing shell injection
|
||||||
|
# via crafted branch names or paths containing special characters
|
||||||
|
printf 'REPO_ROOT=%q\n' "$repo_root"
|
||||||
|
printf 'CURRENT_BRANCH=%q\n' "$current_branch"
|
||||||
|
printf 'HAS_GIT=%q\n' "$has_git_repo"
|
||||||
|
printf 'FEATURE_DIR=%q\n' "$feature_dir"
|
||||||
|
printf 'FEATURE_SPEC=%q\n' "$feature_dir/spec.md"
|
||||||
|
printf 'IMPL_PLAN=%q\n' "$feature_dir/plan.md"
|
||||||
|
printf 'TASKS=%q\n' "$feature_dir/tasks.md"
|
||||||
|
printf 'RESEARCH=%q\n' "$feature_dir/research.md"
|
||||||
|
printf 'DATA_MODEL=%q\n' "$feature_dir/data-model.md"
|
||||||
|
printf 'QUICKSTART=%q\n' "$feature_dir/quickstart.md"
|
||||||
|
printf 'CONTRACTS_DIR=%q\n' "$feature_dir/contracts"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if jq is available for safe JSON construction
|
||||||
|
has_jq() {
|
||||||
|
command -v jq >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Escape a string for safe embedding in a JSON value (fallback when jq is unavailable).
|
||||||
|
# Handles backslash, double-quote, and JSON-required control character escapes (RFC 8259).
|
||||||
|
json_escape() {
|
||||||
|
local s="$1"
|
||||||
|
s="${s//\\/\\\\}"
|
||||||
|
s="${s//\"/\\\"}"
|
||||||
|
s="${s//$'\n'/\\n}"
|
||||||
|
s="${s//$'\t'/\\t}"
|
||||||
|
s="${s//$'\r'/\\r}"
|
||||||
|
s="${s//$'\b'/\\b}"
|
||||||
|
s="${s//$'\f'/\\f}"
|
||||||
|
# Escape any remaining U+0001-U+001F control characters as \uXXXX.
|
||||||
|
# (U+0000/NUL cannot appear in bash strings and is excluded.)
|
||||||
|
# LC_ALL=C ensures ${#s} counts bytes and ${s:$i:1} yields single bytes,
|
||||||
|
# so multi-byte UTF-8 sequences (first byte >= 0xC0) pass through intact.
|
||||||
|
local LC_ALL=C
|
||||||
|
local i char code
|
||||||
|
for (( i=0; i<${#s}; i++ )); do
|
||||||
|
char="${s:$i:1}"
|
||||||
|
printf -v code '%d' "'$char" 2>/dev/null || code=256
|
||||||
|
if (( code >= 1 && code <= 31 )); then
|
||||||
|
printf '\\u%04x' "$code"
|
||||||
|
else
|
||||||
|
printf '%s' "$char"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; }
|
||||||
|
check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; }
|
||||||
|
|
||||||
|
# Resolve a template name to a file path using the priority stack:
|
||||||
|
# 1. .specify/templates/overrides/
|
||||||
|
# 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
|
||||||
|
# 3. .specify/extensions/<ext-id>/templates/
|
||||||
|
# 4. .specify/templates/ (core)
|
||||||
|
resolve_template() {
|
||||||
|
local template_name="$1"
|
||||||
|
local repo_root="$2"
|
||||||
|
local base="$repo_root/.specify/templates"
|
||||||
|
|
||||||
|
# Priority 1: Project overrides
|
||||||
|
local override="$base/overrides/${template_name}.md"
|
||||||
|
[ -f "$override" ] && echo "$override" && return 0
|
||||||
|
|
||||||
|
# Priority 2: Installed presets (sorted by priority from .registry)
|
||||||
|
local presets_dir="$repo_root/.specify/presets"
|
||||||
|
if [ -d "$presets_dir" ]; then
|
||||||
|
local registry_file="$presets_dir/.registry"
|
||||||
|
if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then
|
||||||
|
# Read preset IDs sorted by priority (lower number = higher precedence).
|
||||||
|
# The python3 call is wrapped in an if-condition so that set -e does not
|
||||||
|
# abort the function when python3 exits non-zero (e.g. invalid JSON).
|
||||||
|
local sorted_presets=""
|
||||||
|
if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c "
|
||||||
|
import json, sys, os
|
||||||
|
try:
|
||||||
|
with open(os.environ['SPECKIT_REGISTRY']) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
presets = data.get('presets', {})
|
||||||
|
for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10):
|
||||||
|
if isinstance(meta, dict) and meta.get('enabled', True) is not False:
|
||||||
|
print(pid)
|
||||||
|
except Exception:
|
||||||
|
sys.exit(1)
|
||||||
|
" 2>/dev/null); then
|
||||||
|
if [ -n "$sorted_presets" ]; then
|
||||||
|
# python3 succeeded and returned preset IDs — search in priority order
|
||||||
|
while IFS= read -r preset_id; do
|
||||||
|
local candidate="$presets_dir/$preset_id/templates/${template_name}.md"
|
||||||
|
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||||
|
done <<< "$sorted_presets"
|
||||||
|
fi
|
||||||
|
# python3 succeeded but registry has no presets — nothing to search
|
||||||
|
else
|
||||||
|
# python3 failed (missing, or registry parse error) — fall back to unordered directory scan
|
||||||
|
for preset in "$presets_dir"/*/; do
|
||||||
|
[ -d "$preset" ] || continue
|
||||||
|
local candidate="$preset/templates/${template_name}.md"
|
||||||
|
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Fallback: alphabetical directory order (no python3 available)
|
||||||
|
for preset in "$presets_dir"/*/; do
|
||||||
|
[ -d "$preset" ] || continue
|
||||||
|
local candidate="$preset/templates/${template_name}.md"
|
||||||
|
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Priority 3: Extension-provided templates
|
||||||
|
local ext_dir="$repo_root/.specify/extensions"
|
||||||
|
if [ -d "$ext_dir" ]; then
|
||||||
|
for ext in "$ext_dir"/*/; do
|
||||||
|
[ -d "$ext" ] || continue
|
||||||
|
# Skip hidden directories (e.g. .backup, .cache)
|
||||||
|
case "$(basename "$ext")" in .*) continue;; esac
|
||||||
|
local candidate="$ext/templates/${template_name}.md"
|
||||||
|
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Priority 4: Core templates
|
||||||
|
local core="$base/${template_name}.md"
|
||||||
|
[ -f "$core" ] && echo "$core" && return 0
|
||||||
|
|
||||||
|
# Template not found in any location.
|
||||||
|
# Return 1 so callers can distinguish "not found" from "found".
|
||||||
|
# Callers running under set -e should use: TEMPLATE=$(resolve_template ...) || true
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve a template name to composed content using composition strategies.
|
||||||
|
# Reads strategy metadata from preset manifests and composes content
|
||||||
|
# from multiple layers using prepend, append, or wrap strategies.
|
||||||
|
#
|
||||||
|
# Usage: CONTENT=$(resolve_template_content "template-name" "$REPO_ROOT")
|
||||||
|
# Returns composed content string on stdout; exit code 1 if not found.
|
||||||
|
resolve_template_content() {
|
||||||
|
local template_name="$1"
|
||||||
|
local repo_root="$2"
|
||||||
|
local base="$repo_root/.specify/templates"
|
||||||
|
|
||||||
|
# Collect all layers (highest priority first)
|
||||||
|
local -a layer_paths=()
|
||||||
|
local -a layer_strategies=()
|
||||||
|
|
||||||
|
# Priority 1: Project overrides (always "replace")
|
||||||
|
local override="$base/overrides/${template_name}.md"
|
||||||
|
if [ -f "$override" ]; then
|
||||||
|
layer_paths+=("$override")
|
||||||
|
layer_strategies+=("replace")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Priority 2: Installed presets (sorted by priority from .registry)
|
||||||
|
local presets_dir="$repo_root/.specify/presets"
|
||||||
|
if [ -d "$presets_dir" ]; then
|
||||||
|
local registry_file="$presets_dir/.registry"
|
||||||
|
local sorted_presets=""
|
||||||
|
if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then
|
||||||
|
if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c "
|
||||||
|
import json, sys, os
|
||||||
|
try:
|
||||||
|
with open(os.environ['SPECKIT_REGISTRY']) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
presets = data.get('presets', {})
|
||||||
|
for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10):
|
||||||
|
if isinstance(meta, dict) and meta.get('enabled', True) is not False:
|
||||||
|
print(pid)
|
||||||
|
except Exception:
|
||||||
|
sys.exit(1)
|
||||||
|
" 2>/dev/null); then
|
||||||
|
if [ -n "$sorted_presets" ]; then
|
||||||
|
local yaml_warned=false
|
||||||
|
while IFS= read -r preset_id; do
|
||||||
|
# Read strategy and file path from preset manifest
|
||||||
|
local strategy="replace"
|
||||||
|
local manifest_file=""
|
||||||
|
local manifest="$presets_dir/$preset_id/preset.yml"
|
||||||
|
if [ -f "$manifest" ] && command -v python3 >/dev/null 2>&1; then
|
||||||
|
# Requires PyYAML; falls back to replace/convention if unavailable
|
||||||
|
local result
|
||||||
|
local py_stderr
|
||||||
|
py_stderr=$(mktemp)
|
||||||
|
result=$(SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" python3 -c "
|
||||||
|
import sys, os
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except ImportError:
|
||||||
|
print('yaml_missing', file=sys.stderr)
|
||||||
|
print('replace\t')
|
||||||
|
sys.exit(0)
|
||||||
|
try:
|
||||||
|
with open(os.environ['SPECKIT_MANIFEST']) as f:
|
||||||
|
data = yaml.safe_load(f)
|
||||||
|
for t in data.get('provides', {}).get('templates', []):
|
||||||
|
if t.get('name') == os.environ['SPECKIT_TMPL'] and t.get('type', 'template') == 'template':
|
||||||
|
print(t.get('strategy', 'replace') + '\t' + t.get('file', ''))
|
||||||
|
sys.exit(0)
|
||||||
|
print('replace\t')
|
||||||
|
except Exception:
|
||||||
|
print('replace\t')
|
||||||
|
" 2>"$py_stderr")
|
||||||
|
local parse_status=$?
|
||||||
|
if [ $parse_status -eq 0 ] && [ -n "$result" ]; then
|
||||||
|
IFS=$'\t' read -r strategy manifest_file <<< "$result"
|
||||||
|
strategy=$(printf '%s' "$strategy" | tr '[:upper:]' '[:lower:]')
|
||||||
|
fi
|
||||||
|
if [ "$yaml_warned" = false ] && grep -q 'yaml_missing' "$py_stderr" 2>/dev/null; then
|
||||||
|
echo "Warning: PyYAML not available; composition strategies may be ignored" >&2
|
||||||
|
yaml_warned=true
|
||||||
|
fi
|
||||||
|
rm -f "$py_stderr"
|
||||||
|
fi
|
||||||
|
# Try manifest file path first, then convention path
|
||||||
|
local candidate=""
|
||||||
|
if [ -n "$manifest_file" ]; then
|
||||||
|
# Reject absolute paths and parent traversal
|
||||||
|
case "$manifest_file" in
|
||||||
|
/*|*../*|../*) manifest_file="" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
if [ -n "$manifest_file" ]; then
|
||||||
|
local mf="$presets_dir/$preset_id/$manifest_file"
|
||||||
|
[ -f "$mf" ] && candidate="$mf"
|
||||||
|
fi
|
||||||
|
if [ -z "$candidate" ]; then
|
||||||
|
local cf="$presets_dir/$preset_id/templates/${template_name}.md"
|
||||||
|
[ -f "$cf" ] && candidate="$cf"
|
||||||
|
fi
|
||||||
|
if [ -n "$candidate" ]; then
|
||||||
|
layer_paths+=("$candidate")
|
||||||
|
layer_strategies+=("$strategy")
|
||||||
|
fi
|
||||||
|
done <<< "$sorted_presets"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# python3 failed — fall back to unordered directory scan (replace only)
|
||||||
|
for preset in "$presets_dir"/*/; do
|
||||||
|
[ -d "$preset" ] || continue
|
||||||
|
local candidate="$preset/templates/${template_name}.md"
|
||||||
|
if [ -f "$candidate" ]; then
|
||||||
|
layer_paths+=("$candidate")
|
||||||
|
layer_strategies+=("replace")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# No python3 or registry — fall back to unordered directory scan (replace only)
|
||||||
|
for preset in "$presets_dir"/*/; do
|
||||||
|
[ -d "$preset" ] || continue
|
||||||
|
local candidate="$preset/templates/${template_name}.md"
|
||||||
|
if [ -f "$candidate" ]; then
|
||||||
|
layer_paths+=("$candidate")
|
||||||
|
layer_strategies+=("replace")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Priority 3: Extension-provided templates (always "replace")
|
||||||
|
local ext_dir="$repo_root/.specify/extensions"
|
||||||
|
if [ -d "$ext_dir" ]; then
|
||||||
|
for ext in "$ext_dir"/*/; do
|
||||||
|
[ -d "$ext" ] || continue
|
||||||
|
case "$(basename "$ext")" in .*) continue;; esac
|
||||||
|
local candidate="$ext/templates/${template_name}.md"
|
||||||
|
if [ -f "$candidate" ]; then
|
||||||
|
layer_paths+=("$candidate")
|
||||||
|
layer_strategies+=("replace")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Priority 4: Core templates (always "replace")
|
||||||
|
local core="$base/${template_name}.md"
|
||||||
|
if [ -f "$core" ]; then
|
||||||
|
layer_paths+=("$core")
|
||||||
|
layer_strategies+=("replace")
|
||||||
|
fi
|
||||||
|
|
||||||
|
local count=${#layer_paths[@]}
|
||||||
|
[ "$count" -eq 0 ] && return 1
|
||||||
|
|
||||||
|
# Check if any layer uses a non-replace strategy
|
||||||
|
local has_composition=false
|
||||||
|
for s in "${layer_strategies[@]}"; do
|
||||||
|
[ "$s" != "replace" ] && has_composition=true && break
|
||||||
|
done
|
||||||
|
|
||||||
|
# If the top (highest-priority) layer is replace, it wins entirely —
|
||||||
|
# lower layers are irrelevant regardless of their strategies.
|
||||||
|
if [ "${layer_strategies[0]}" = "replace" ]; then
|
||||||
|
cat "${layer_paths[0]}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$has_composition" = false ]; then
|
||||||
|
cat "${layer_paths[0]}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Find the effective base: scan from highest priority (index 0) downward
|
||||||
|
# to find the nearest replace layer. Only compose layers above that base.
|
||||||
|
local base_idx=-1
|
||||||
|
local i
|
||||||
|
for (( i=0; i<count; i++ )); do
|
||||||
|
if [ "${layer_strategies[$i]}" = "replace" ]; then
|
||||||
|
base_idx=$i
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ $base_idx -lt 0 ]; then
|
||||||
|
return 1 # no base layer found
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Read the base content; compose layers above the base (higher priority)
|
||||||
|
local content
|
||||||
|
content=$(cat "${layer_paths[$base_idx]}"; printf x)
|
||||||
|
content="${content%x}"
|
||||||
|
|
||||||
|
for (( i=base_idx-1; i>=0; i-- )); do
|
||||||
|
local path="${layer_paths[$i]}"
|
||||||
|
local strat="${layer_strategies[$i]}"
|
||||||
|
local layer_content
|
||||||
|
# Preserve trailing newlines
|
||||||
|
layer_content=$(cat "$path"; printf x)
|
||||||
|
layer_content="${layer_content%x}"
|
||||||
|
|
||||||
|
case "$strat" in
|
||||||
|
replace) content="$layer_content" ;;
|
||||||
|
prepend) content="$(printf '%s\n\n%s' "$layer_content" "$content")" ;;
|
||||||
|
append) content="$(printf '%s\n\n%s' "$content" "$layer_content")" ;;
|
||||||
|
wrap)
|
||||||
|
case "$layer_content" in
|
||||||
|
*'{CORE_TEMPLATE}'*) ;;
|
||||||
|
*) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
while [[ "$layer_content" == *'{CORE_TEMPLATE}'* ]]; do
|
||||||
|
local before="${layer_content%%\{CORE_TEMPLATE\}*}"
|
||||||
|
local after="${layer_content#*\{CORE_TEMPLATE\}}"
|
||||||
|
layer_content="${before}${content}${after}"
|
||||||
|
done
|
||||||
|
content="$layer_content"
|
||||||
|
;;
|
||||||
|
*) echo "Error: unknown strategy '$strat'" >&2; return 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
printf '%s' "$content"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
JSON_MODE=false
|
||||||
|
DRY_RUN=false
|
||||||
|
ALLOW_EXISTING=false
|
||||||
|
SHORT_NAME=""
|
||||||
|
BRANCH_NUMBER=""
|
||||||
|
USE_TIMESTAMP=false
|
||||||
|
ARGS=()
|
||||||
|
i=1
|
||||||
|
while [ $i -le $# ]; do
|
||||||
|
arg="${!i}"
|
||||||
|
case "$arg" in
|
||||||
|
--json)
|
||||||
|
JSON_MODE=true
|
||||||
|
;;
|
||||||
|
--dry-run)
|
||||||
|
DRY_RUN=true
|
||||||
|
;;
|
||||||
|
--allow-existing-branch)
|
||||||
|
ALLOW_EXISTING=true
|
||||||
|
;;
|
||||||
|
--short-name)
|
||||||
|
if [ $((i + 1)) -gt $# ]; then
|
||||||
|
echo 'Error: --short-name requires a value' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
i=$((i + 1))
|
||||||
|
next_arg="${!i}"
|
||||||
|
# Check if the next argument is another option (starts with --)
|
||||||
|
if [[ "$next_arg" == --* ]]; then
|
||||||
|
echo 'Error: --short-name requires a value' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
SHORT_NAME="$next_arg"
|
||||||
|
;;
|
||||||
|
--number)
|
||||||
|
if [ $((i + 1)) -gt $# ]; then
|
||||||
|
echo 'Error: --number requires a value' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
i=$((i + 1))
|
||||||
|
next_arg="${!i}"
|
||||||
|
if [[ "$next_arg" == --* ]]; then
|
||||||
|
echo 'Error: --number requires a value' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
BRANCH_NUMBER="$next_arg"
|
||||||
|
;;
|
||||||
|
--timestamp)
|
||||||
|
USE_TIMESTAMP=true
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>"
|
||||||
|
echo ""
|
||||||
|
echo "Options:"
|
||||||
|
echo " --json Output in JSON format"
|
||||||
|
echo " --dry-run Compute branch name and paths without creating branches, directories, or files"
|
||||||
|
echo " --allow-existing-branch Switch to branch if it already exists instead of failing"
|
||||||
|
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
|
||||||
|
echo " --number N Specify branch number manually (overrides auto-detection)"
|
||||||
|
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
||||||
|
echo " --help, -h Show this help message"
|
||||||
|
echo ""
|
||||||
|
echo "Examples:"
|
||||||
|
echo " $0 'Add user authentication system' --short-name 'user-auth'"
|
||||||
|
echo " $0 'Implement OAuth2 integration for API' --number 5"
|
||||||
|
echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
ARGS+=("$arg")
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
FEATURE_DESCRIPTION="${ARGS[*]}"
|
||||||
|
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
||||||
|
echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Trim whitespace and validate description is not empty (e.g., user passed only whitespace)
|
||||||
|
FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')
|
||||||
|
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
||||||
|
echo "Error: Feature description cannot be empty or contain only whitespace" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Function to get highest number from specs directory
|
||||||
|
get_highest_from_specs() {
|
||||||
|
local specs_dir="$1"
|
||||||
|
local highest=0
|
||||||
|
|
||||||
|
if [ -d "$specs_dir" ]; then
|
||||||
|
for dir in "$specs_dir"/*; do
|
||||||
|
[ -d "$dir" ] || continue
|
||||||
|
dirname=$(basename "$dir")
|
||||||
|
# Match sequential prefixes (>=3 digits), but skip timestamp dirs.
|
||||||
|
if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
|
||||||
|
number=$(echo "$dirname" | grep -Eo '^[0-9]+')
|
||||||
|
number=$((10#$number))
|
||||||
|
if [ "$number" -gt "$highest" ]; then
|
||||||
|
highest=$number
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$highest"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to get highest number from git branches
|
||||||
|
get_highest_from_branches() {
|
||||||
|
git branch -a 2>/dev/null | sed 's/^[* ]*//; s|^remotes/[^/]*/||' | _extract_highest_number
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extract the highest sequential feature number from a list of ref names (one per line).
|
||||||
|
# Shared by get_highest_from_branches and get_highest_from_remote_refs.
|
||||||
|
_extract_highest_number() {
|
||||||
|
local highest=0
|
||||||
|
while IFS= read -r name; do
|
||||||
|
[ -z "$name" ] && continue
|
||||||
|
if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
|
||||||
|
number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0")
|
||||||
|
number=$((10#$number))
|
||||||
|
if [ "$number" -gt "$highest" ]; then
|
||||||
|
highest=$number
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "$highest"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to get highest number from remote branches without fetching (side-effect-free)
|
||||||
|
get_highest_from_remote_refs() {
|
||||||
|
local highest=0
|
||||||
|
|
||||||
|
for remote in $(git remote 2>/dev/null); do
|
||||||
|
local remote_highest
|
||||||
|
remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number)
|
||||||
|
if [ "$remote_highest" -gt "$highest" ]; then
|
||||||
|
highest=$remote_highest
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "$highest"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to check existing branches (local and remote) and return next available number.
|
||||||
|
# When skip_fetch is true, queries remotes via ls-remote (read-only) instead of fetching.
|
||||||
|
check_existing_branches() {
|
||||||
|
local specs_dir="$1"
|
||||||
|
local skip_fetch="${2:-false}"
|
||||||
|
|
||||||
|
if [ "$skip_fetch" = true ]; then
|
||||||
|
# Side-effect-free: query remotes via ls-remote
|
||||||
|
local highest_remote=$(get_highest_from_remote_refs)
|
||||||
|
local highest_branch=$(get_highest_from_branches)
|
||||||
|
if [ "$highest_remote" -gt "$highest_branch" ]; then
|
||||||
|
highest_branch=$highest_remote
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Fetch all remotes to get latest branch info (suppress errors if no remotes)
|
||||||
|
git fetch --all --prune >/dev/null 2>&1 || true
|
||||||
|
local highest_branch=$(get_highest_from_branches)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get highest number from ALL specs (not just matching short name)
|
||||||
|
local highest_spec=$(get_highest_from_specs "$specs_dir")
|
||||||
|
|
||||||
|
# Take the maximum of both
|
||||||
|
local max_num=$highest_branch
|
||||||
|
if [ "$highest_spec" -gt "$max_num" ]; then
|
||||||
|
max_num=$highest_spec
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Return next number
|
||||||
|
echo $((max_num + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to clean and format a branch name
|
||||||
|
clean_branch_name() {
|
||||||
|
local name="$1"
|
||||||
|
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Resolve repository root using common.sh functions which prioritize .specify over git
|
||||||
|
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/common.sh"
|
||||||
|
|
||||||
|
REPO_ROOT=$(get_repo_root)
|
||||||
|
|
||||||
|
# Check if git is available at this repo root (not a parent)
|
||||||
|
if has_git; then
|
||||||
|
HAS_GIT=true
|
||||||
|
else
|
||||||
|
HAS_GIT=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
SPECS_DIR="$REPO_ROOT/specs"
|
||||||
|
if [ "$DRY_RUN" != true ]; then
|
||||||
|
mkdir -p "$SPECS_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Function to generate branch name with stop word filtering and length filtering
|
||||||
|
generate_branch_name() {
|
||||||
|
local description="$1"
|
||||||
|
|
||||||
|
# Common stop words to filter out
|
||||||
|
local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$"
|
||||||
|
|
||||||
|
# Convert to lowercase and split into words
|
||||||
|
local clean_name=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g')
|
||||||
|
|
||||||
|
# Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original)
|
||||||
|
local meaningful_words=()
|
||||||
|
for word in $clean_name; do
|
||||||
|
# Skip empty words
|
||||||
|
[ -z "$word" ] && continue
|
||||||
|
|
||||||
|
# Keep words that are NOT stop words AND (length >= 3 OR are potential acronyms)
|
||||||
|
if ! echo "$word" | grep -qiE "$stop_words"; then
|
||||||
|
if [ ${#word} -ge 3 ]; then
|
||||||
|
meaningful_words+=("$word")
|
||||||
|
elif echo "$description" | grep -q "\b${word^^}\b"; then
|
||||||
|
# Keep short words if they appear as uppercase in original (likely acronyms)
|
||||||
|
meaningful_words+=("$word")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# If we have meaningful words, use first 3-4 of them
|
||||||
|
if [ ${#meaningful_words[@]} -gt 0 ]; then
|
||||||
|
local max_words=3
|
||||||
|
if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi
|
||||||
|
|
||||||
|
local result=""
|
||||||
|
local count=0
|
||||||
|
for word in "${meaningful_words[@]}"; do
|
||||||
|
if [ $count -ge $max_words ]; then break; fi
|
||||||
|
if [ -n "$result" ]; then result="$result-"; fi
|
||||||
|
result="$result$word"
|
||||||
|
count=$((count + 1))
|
||||||
|
done
|
||||||
|
echo "$result"
|
||||||
|
else
|
||||||
|
# Fallback to original logic if no meaningful words found
|
||||||
|
local cleaned=$(clean_branch_name "$description")
|
||||||
|
echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Generate branch name
|
||||||
|
if [ -n "$SHORT_NAME" ]; then
|
||||||
|
# Use provided short name, just clean it up
|
||||||
|
BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME")
|
||||||
|
else
|
||||||
|
# Generate from description with smart filtering
|
||||||
|
BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Warn if --number and --timestamp are both specified
|
||||||
|
if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then
|
||||||
|
>&2 echo "[specify] Warning: --number is ignored when --timestamp is used"
|
||||||
|
BRANCH_NUMBER=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Determine branch prefix
|
||||||
|
if [ "$USE_TIMESTAMP" = true ]; then
|
||||||
|
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
|
||||||
|
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||||
|
else
|
||||||
|
# Determine branch number
|
||||||
|
if [ -z "$BRANCH_NUMBER" ]; then
|
||||||
|
if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then
|
||||||
|
# Dry-run: query remotes via ls-remote (side-effect-free, no fetch)
|
||||||
|
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true)
|
||||||
|
elif [ "$DRY_RUN" = true ]; then
|
||||||
|
# Dry-run without git: local spec dirs only
|
||||||
|
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||||
|
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||||
|
elif [ "$HAS_GIT" = true ]; then
|
||||||
|
# Check existing branches on remotes
|
||||||
|
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
|
||||||
|
else
|
||||||
|
# Fall back to local directory check
|
||||||
|
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||||
|
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal)
|
||||||
|
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
|
||||||
|
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# GitHub enforces a 244-byte limit on branch names
|
||||||
|
# Validate and truncate if necessary
|
||||||
|
MAX_BRANCH_LENGTH=244
|
||||||
|
if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
|
||||||
|
# Calculate how much we need to trim from suffix
|
||||||
|
# Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4
|
||||||
|
PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
|
||||||
|
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
|
||||||
|
|
||||||
|
# Truncate suffix at word boundary if possible
|
||||||
|
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
|
||||||
|
# Remove trailing hyphen if truncation created one
|
||||||
|
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
|
||||||
|
|
||||||
|
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
|
||||||
|
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
|
||||||
|
|
||||||
|
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
|
||||||
|
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
|
||||||
|
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
FEATURE_DIR="$SPECS_DIR/$BRANCH_NAME"
|
||||||
|
SPEC_FILE="$FEATURE_DIR/spec.md"
|
||||||
|
|
||||||
|
if [ "$DRY_RUN" != true ]; then
|
||||||
|
if [ "$HAS_GIT" = true ]; then
|
||||||
|
branch_create_error=""
|
||||||
|
if ! branch_create_error=$(git checkout -q -b "$BRANCH_NAME" 2>&1); then
|
||||||
|
current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
|
||||||
|
# Check if branch already exists
|
||||||
|
if git branch --list "$BRANCH_NAME" | grep -q .; then
|
||||||
|
if [ "$ALLOW_EXISTING" = true ]; then
|
||||||
|
# If we're already on the branch, continue without another checkout.
|
||||||
|
if [ "$current_branch" = "$BRANCH_NAME" ]; then
|
||||||
|
:
|
||||||
|
# Otherwise switch to the existing branch instead of failing.
|
||||||
|
elif ! switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1); then
|
||||||
|
>&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again."
|
||||||
|
if [ -n "$switch_branch_error" ]; then
|
||||||
|
>&2 printf '%s\n' "$switch_branch_error"
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
elif [ "$USE_TIMESTAMP" = true ]; then
|
||||||
|
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name."
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'."
|
||||||
|
if [ -n "$branch_create_error" ]; then
|
||||||
|
>&2 printf '%s\n' "$branch_create_error"
|
||||||
|
else
|
||||||
|
>&2 echo "Please check your git configuration and try again."
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
>&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$FEATURE_DIR"
|
||||||
|
|
||||||
|
if [ ! -f "$SPEC_FILE" ]; then
|
||||||
|
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true
|
||||||
|
if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then
|
||||||
|
cp "$TEMPLATE" "$SPEC_FILE"
|
||||||
|
else
|
||||||
|
echo "Warning: Spec template not found; created empty spec file" >&2
|
||||||
|
touch "$SPEC_FILE"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Inform the user how to persist the feature variable in their own shell
|
||||||
|
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if $JSON_MODE; then
|
||||||
|
if command -v jq >/dev/null 2>&1; then
|
||||||
|
if [ "$DRY_RUN" = true ]; then
|
||||||
|
jq -cn \
|
||||||
|
--arg branch_name "$BRANCH_NAME" \
|
||||||
|
--arg spec_file "$SPEC_FILE" \
|
||||||
|
--arg feature_num "$FEATURE_NUM" \
|
||||||
|
'{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num,DRY_RUN:true}'
|
||||||
|
else
|
||||||
|
jq -cn \
|
||||||
|
--arg branch_name "$BRANCH_NAME" \
|
||||||
|
--arg spec_file "$SPEC_FILE" \
|
||||||
|
--arg feature_num "$FEATURE_NUM" \
|
||||||
|
'{BRANCH_NAME:$branch_name,SPEC_FILE:$spec_file,FEATURE_NUM:$feature_num}'
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if [ "$DRY_RUN" = true ]; then
|
||||||
|
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")"
|
||||||
|
else
|
||||||
|
printf '{"BRANCH_NAME":"%s","SPEC_FILE":"%s","FEATURE_NUM":"%s"}\n' "$(json_escape "$BRANCH_NAME")" "$(json_escape "$SPEC_FILE")" "$(json_escape "$FEATURE_NUM")"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "BRANCH_NAME: $BRANCH_NAME"
|
||||||
|
echo "SPEC_FILE: $SPEC_FILE"
|
||||||
|
echo "FEATURE_NUM: $FEATURE_NUM"
|
||||||
|
if [ "$DRY_RUN" != true ]; then
|
||||||
|
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Parse command line arguments
|
||||||
|
JSON_MODE=false
|
||||||
|
ARGS=()
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--json)
|
||||||
|
JSON_MODE=true
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
echo "Usage: $0 [--json]"
|
||||||
|
echo " --json Output results in JSON format"
|
||||||
|
echo " --help Show this help message"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
ARGS+=("$arg")
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Get script directory and load common functions
|
||||||
|
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/common.sh"
|
||||||
|
|
||||||
|
# Get all paths and variables from common functions
|
||||||
|
_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; }
|
||||||
|
eval "$_paths_output"
|
||||||
|
unset _paths_output
|
||||||
|
|
||||||
|
# If feature.json pins an existing feature directory, branch naming is not required.
|
||||||
|
if ! feature_json_matches_feature_dir "$REPO_ROOT" "$FEATURE_DIR"; then
|
||||||
|
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure the feature directory exists
|
||||||
|
mkdir -p "$FEATURE_DIR"
|
||||||
|
|
||||||
|
# Copy plan template if it exists
|
||||||
|
TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT") || true
|
||||||
|
if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then
|
||||||
|
cp "$TEMPLATE" "$IMPL_PLAN"
|
||||||
|
echo "Copied plan template to $IMPL_PLAN"
|
||||||
|
else
|
||||||
|
echo "Warning: Plan template not found"
|
||||||
|
# Create a basic plan file if template doesn't exist
|
||||||
|
touch "$IMPL_PLAN"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Output results
|
||||||
|
if $JSON_MODE; then
|
||||||
|
if has_jq; then
|
||||||
|
jq -cn \
|
||||||
|
--arg feature_spec "$FEATURE_SPEC" \
|
||||||
|
--arg impl_plan "$IMPL_PLAN" \
|
||||||
|
--arg specs_dir "$FEATURE_DIR" \
|
||||||
|
--arg branch "$CURRENT_BRANCH" \
|
||||||
|
--arg has_git "$HAS_GIT" \
|
||||||
|
'{FEATURE_SPEC:$feature_spec,IMPL_PLAN:$impl_plan,SPECS_DIR:$specs_dir,BRANCH:$branch,HAS_GIT:$has_git}'
|
||||||
|
else
|
||||||
|
printf '{"FEATURE_SPEC":"%s","IMPL_PLAN":"%s","SPECS_DIR":"%s","BRANCH":"%s","HAS_GIT":"%s"}\n' \
|
||||||
|
"$(json_escape "$FEATURE_SPEC")" "$(json_escape "$IMPL_PLAN")" "$(json_escape "$FEATURE_DIR")" "$(json_escape "$CURRENT_BRANCH")" "$(json_escape "$HAS_GIT")"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "FEATURE_SPEC: $FEATURE_SPEC"
|
||||||
|
echo "IMPL_PLAN: $IMPL_PLAN"
|
||||||
|
echo "SPECS_DIR: $FEATURE_DIR"
|
||||||
|
echo "BRANCH: $CURRENT_BRANCH"
|
||||||
|
echo "HAS_GIT: $HAS_GIT"
|
||||||
|
fi
|
||||||
|
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Parse command line arguments
|
||||||
|
JSON_MODE=false
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--json) JSON_MODE=true ;;
|
||||||
|
--help|-h)
|
||||||
|
echo "Usage: $0 [--json]"
|
||||||
|
echo " --json Output results in JSON format"
|
||||||
|
echo " --help Show this help message"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*) echo "ERROR: Unknown option '$arg'" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Source common functions
|
||||||
|
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$SCRIPT_DIR/common.sh"
|
||||||
|
|
||||||
|
# Get feature paths
|
||||||
|
_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; }
|
||||||
|
eval "$_paths_output"
|
||||||
|
unset _paths_output
|
||||||
|
|
||||||
|
# Validate branch
|
||||||
|
# If feature.json pins an existing feature directory, branch naming is not required.
|
||||||
|
if ! feature_json_matches_feature_dir "$REPO_ROOT" "$FEATURE_DIR"; then
|
||||||
|
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$IMPL_PLAN" ]]; then
|
||||||
|
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
|
||||||
|
echo "Run /speckit.plan first to create the implementation plan." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$FEATURE_SPEC" ]]; then
|
||||||
|
echo "ERROR: spec.md not found in $FEATURE_DIR" >&2
|
||||||
|
echo "Run /speckit.specify first to create the feature structure." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build available docs list
|
||||||
|
docs=()
|
||||||
|
[[ -f "$RESEARCH" ]] && docs+=("research.md")
|
||||||
|
[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md")
|
||||||
|
if [[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then
|
||||||
|
docs+=("contracts/")
|
||||||
|
fi
|
||||||
|
[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md")
|
||||||
|
|
||||||
|
# Resolve tasks template through override stack
|
||||||
|
TASKS_TEMPLATE=$(resolve_template "tasks-template" "$REPO_ROOT") || true
|
||||||
|
if [[ -z "$TASKS_TEMPLATE" ]] || [[ ! -f "$TASKS_TEMPLATE" ]]; then
|
||||||
|
echo "ERROR: Could not resolve required tasks-template from the template override stack for $REPO_ROOT" >&2
|
||||||
|
echo "Template 'tasks-template' was not found in any supported location (overrides, presets, extensions, or shared core). Add an override at .specify/templates/overrides/tasks-template.md, or run 'specify init' / reinstall shared infra to restore the core .specify/templates/tasks-template.md template." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Output results
|
||||||
|
if $JSON_MODE; then
|
||||||
|
if has_jq; then
|
||||||
|
if [[ ${#docs[@]} -eq 0 ]]; then
|
||||||
|
json_docs="[]"
|
||||||
|
else
|
||||||
|
json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .)
|
||||||
|
fi
|
||||||
|
jq -cn \
|
||||||
|
--arg feature_dir "$FEATURE_DIR" \
|
||||||
|
--argjson docs "$json_docs" \
|
||||||
|
--arg tasks_template "${TASKS_TEMPLATE:-}" \
|
||||||
|
'{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs,TASKS_TEMPLATE:$tasks_template}'
|
||||||
|
else
|
||||||
|
if [[ ${#docs[@]} -eq 0 ]]; then
|
||||||
|
json_docs="[]"
|
||||||
|
else
|
||||||
|
json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done)
|
||||||
|
json_docs="[${json_docs%,}]"
|
||||||
|
fi
|
||||||
|
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s,"TASKS_TEMPLATE":"%s"}\n' \
|
||||||
|
"$(json_escape "$FEATURE_DIR")" "$json_docs" "$(json_escape "${TASKS_TEMPLATE:-}")"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "FEATURE_DIR: $FEATURE_DIR"
|
||||||
|
echo "TASKS_TEMPLATE: ${TASKS_TEMPLATE:-not found}"
|
||||||
|
echo "AVAILABLE_DOCS:"
|
||||||
|
check_file "$RESEARCH" "research.md"
|
||||||
|
check_file "$DATA_MODEL" "data-model.md"
|
||||||
|
check_dir "$CONTRACTS_DIR" "contracts/"
|
||||||
|
check_file "$QUICKSTART" "quickstart.md"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# [CHECKLIST TYPE] Checklist: [FEATURE NAME]
|
||||||
|
|
||||||
|
**Purpose**: [Brief description of what this checklist covers]
|
||||||
|
**Created**: [DATE]
|
||||||
|
**Feature**: [Link to spec.md or relevant documentation]
|
||||||
|
|
||||||
|
**Note**: This checklist is generated by the `/speckit-checklist` command based on feature context and requirements.
|
||||||
|
|
||||||
|
<!--
|
||||||
|
============================================================================
|
||||||
|
IMPORTANT: The checklist items below are SAMPLE ITEMS for illustration only.
|
||||||
|
|
||||||
|
The /speckit-checklist command MUST replace these with actual items based on:
|
||||||
|
- User's specific checklist request
|
||||||
|
- Feature requirements from spec.md
|
||||||
|
- Technical context from plan.md
|
||||||
|
- Implementation details from tasks.md
|
||||||
|
|
||||||
|
DO NOT keep these sample items in the generated checklist file.
|
||||||
|
============================================================================
|
||||||
|
-->
|
||||||
|
|
||||||
|
## [Category 1]
|
||||||
|
|
||||||
|
- [ ] CHK001 First checklist item with clear action
|
||||||
|
- [ ] CHK002 Second checklist item
|
||||||
|
- [ ] CHK003 Third checklist item
|
||||||
|
|
||||||
|
## [Category 2]
|
||||||
|
|
||||||
|
- [ ] CHK004 Another category item
|
||||||
|
- [ ] CHK005 Item with specific criteria
|
||||||
|
- [ ] CHK006 Final item in this category
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Check items off as completed: `[x]`
|
||||||
|
- Add comments or findings inline
|
||||||
|
- Link to relevant resources or documentation
|
||||||
|
- Items are numbered sequentially for easy reference
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# [PROJECT_NAME] Constitution
|
||||||
|
<!-- Example: Spec Constitution, TaskFlow Constitution, etc. -->
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
### [PRINCIPLE_1_NAME]
|
||||||
|
<!-- Example: I. Library-First -->
|
||||||
|
[PRINCIPLE_1_DESCRIPTION]
|
||||||
|
<!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries -->
|
||||||
|
|
||||||
|
### [PRINCIPLE_2_NAME]
|
||||||
|
<!-- Example: II. CLI Interface -->
|
||||||
|
[PRINCIPLE_2_DESCRIPTION]
|
||||||
|
<!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats -->
|
||||||
|
|
||||||
|
### [PRINCIPLE_3_NAME]
|
||||||
|
<!-- Example: III. Test-First (NON-NEGOTIABLE) -->
|
||||||
|
[PRINCIPLE_3_DESCRIPTION]
|
||||||
|
<!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced -->
|
||||||
|
|
||||||
|
### [PRINCIPLE_4_NAME]
|
||||||
|
<!-- Example: IV. Integration Testing -->
|
||||||
|
[PRINCIPLE_4_DESCRIPTION]
|
||||||
|
<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas -->
|
||||||
|
|
||||||
|
### [PRINCIPLE_5_NAME]
|
||||||
|
<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity -->
|
||||||
|
[PRINCIPLE_5_DESCRIPTION]
|
||||||
|
<!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles -->
|
||||||
|
|
||||||
|
## [SECTION_2_NAME]
|
||||||
|
<!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. -->
|
||||||
|
|
||||||
|
[SECTION_2_CONTENT]
|
||||||
|
<!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. -->
|
||||||
|
|
||||||
|
## [SECTION_3_NAME]
|
||||||
|
<!-- Example: Development Workflow, Review Process, Quality Gates, etc. -->
|
||||||
|
|
||||||
|
[SECTION_3_CONTENT]
|
||||||
|
<!-- Example: Code review requirements, testing gates, deployment approval process, etc. -->
|
||||||
|
|
||||||
|
## Governance
|
||||||
|
<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan -->
|
||||||
|
|
||||||
|
[GOVERNANCE_RULES]
|
||||||
|
<!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance -->
|
||||||
|
|
||||||
|
**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE]
|
||||||
|
<!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 -->
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Implementation Plan: [FEATURE]
|
||||||
|
|
||||||
|
**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link]
|
||||||
|
**Input**: Feature specification from `/specs/[###-feature-name]/spec.md`
|
||||||
|
|
||||||
|
**Note**: This template is filled in by the `/speckit-plan` command. See `.specify/templates/plan-template.md` for the execution workflow.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
[Extract from feature spec: primary requirement + technical approach from research]
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
<!--
|
||||||
|
ACTION REQUIRED: Replace the content in this section with the technical details
|
||||||
|
for the project. The structure here is presented in advisory capacity to guide
|
||||||
|
the iteration process.
|
||||||
|
-->
|
||||||
|
|
||||||
|
**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION]
|
||||||
|
**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION]
|
||||||
|
**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
|
||||||
|
**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION]
|
||||||
|
**Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION]
|
||||||
|
**Project Type**: [e.g., library/cli/web-service/mobile-app/compiler/desktop-app or NEEDS CLARIFICATION]
|
||||||
|
**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION]
|
||||||
|
**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION]
|
||||||
|
**Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION]
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
[Gates determined based on constitution file]
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/[###-feature]/
|
||||||
|
├── plan.md # This file (/speckit-plan command output)
|
||||||
|
├── research.md # Phase 0 output (/speckit-plan command)
|
||||||
|
├── data-model.md # Phase 1 output (/speckit-plan command)
|
||||||
|
├── quickstart.md # Phase 1 output (/speckit-plan command)
|
||||||
|
├── contracts/ # Phase 1 output (/speckit-plan command)
|
||||||
|
└── tasks.md # Phase 2 output (/speckit-tasks command - NOT created by /speckit-plan)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
<!--
|
||||||
|
ACTION REQUIRED: Replace the placeholder tree below with the concrete layout
|
||||||
|
for this feature. Delete unused options and expand the chosen structure with
|
||||||
|
real paths (e.g., apps/admin, packages/something). The delivered plan must
|
||||||
|
not include Option labels.
|
||||||
|
-->
|
||||||
|
|
||||||
|
```text
|
||||||
|
# [REMOVE IF UNUSED] Option 1: Single project (DEFAULT)
|
||||||
|
src/
|
||||||
|
├── models/
|
||||||
|
├── services/
|
||||||
|
├── cli/
|
||||||
|
└── lib/
|
||||||
|
|
||||||
|
tests/
|
||||||
|
├── contract/
|
||||||
|
├── integration/
|
||||||
|
└── unit/
|
||||||
|
|
||||||
|
# [REMOVE IF UNUSED] Option 2: Web application (when "frontend" + "backend" detected)
|
||||||
|
backend/
|
||||||
|
├── src/
|
||||||
|
│ ├── models/
|
||||||
|
│ ├── services/
|
||||||
|
│ └── api/
|
||||||
|
└── tests/
|
||||||
|
|
||||||
|
frontend/
|
||||||
|
├── src/
|
||||||
|
│ ├── components/
|
||||||
|
│ ├── pages/
|
||||||
|
│ └── services/
|
||||||
|
└── tests/
|
||||||
|
|
||||||
|
# [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected)
|
||||||
|
api/
|
||||||
|
└── [same as backend above]
|
||||||
|
|
||||||
|
ios/ or android/
|
||||||
|
└── [platform-specific structure: feature modules, UI flows, platform tests]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: [Document the selected structure and reference the real
|
||||||
|
directories captured above]
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
> **Fill ONLY if Constitution Check has violations that must be justified**
|
||||||
|
|
||||||
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|
|-----------|------------|-------------------------------------|
|
||||||
|
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
|
||||||
|
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# Feature Specification: [FEATURE NAME]
|
||||||
|
|
||||||
|
**Feature Branch**: `[###-feature-name]`
|
||||||
|
**Created**: [DATE]
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "$ARGUMENTS"
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
<!--
|
||||||
|
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
|
||||||
|
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
|
||||||
|
you should still have a viable MVP (Minimum Viable Product) that delivers value.
|
||||||
|
|
||||||
|
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
|
||||||
|
Think of each story as a standalone slice of functionality that can be:
|
||||||
|
- Developed independently
|
||||||
|
- Tested independently
|
||||||
|
- Deployed independently
|
||||||
|
- Demonstrated to users independently
|
||||||
|
-->
|
||||||
|
|
||||||
|
### User Story 1 - [Brief Title] (Priority: P1)
|
||||||
|
|
||||||
|
[Describe this user journey in plain language]
|
||||||
|
|
||||||
|
**Why this priority**: [Explain the value and why it has this priority level]
|
||||||
|
|
||||||
|
**Independent Test**: [Describe how this can be tested independently - e.g., "Can be fully tested by [specific action] and delivers [specific value]"]
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
|
||||||
|
2. **Given** [initial state], **When** [action], **Then** [expected outcome]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - [Brief Title] (Priority: P2)
|
||||||
|
|
||||||
|
[Describe this user journey in plain language]
|
||||||
|
|
||||||
|
**Why this priority**: [Explain the value and why it has this priority level]
|
||||||
|
|
||||||
|
**Independent Test**: [Describe how this can be tested independently]
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - [Brief Title] (Priority: P3)
|
||||||
|
|
||||||
|
[Describe this user journey in plain language]
|
||||||
|
|
||||||
|
**Why this priority**: [Explain the value and why it has this priority level]
|
||||||
|
|
||||||
|
**Independent Test**: [Describe how this can be tested independently]
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[Add more user stories as needed, each with an assigned priority]
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
<!--
|
||||||
|
ACTION REQUIRED: The content in this section represents placeholders.
|
||||||
|
Fill them out with the right edge cases.
|
||||||
|
-->
|
||||||
|
|
||||||
|
- What happens when [boundary condition]?
|
||||||
|
- How does system handle [error scenario]?
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
<!--
|
||||||
|
ACTION REQUIRED: The content in this section represents placeholders.
|
||||||
|
Fill them out with the right functional requirements.
|
||||||
|
-->
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: System MUST [specific capability, e.g., "allow users to create accounts"]
|
||||||
|
- **FR-002**: System MUST [specific capability, e.g., "validate email addresses"]
|
||||||
|
- **FR-003**: Users MUST be able to [key interaction, e.g., "reset their password"]
|
||||||
|
- **FR-004**: System MUST [data requirement, e.g., "persist user preferences"]
|
||||||
|
- **FR-005**: System MUST [behavior, e.g., "log all security events"]
|
||||||
|
|
||||||
|
*Example of marking unclear requirements:*
|
||||||
|
|
||||||
|
- **FR-006**: System MUST authenticate users via [NEEDS CLARIFICATION: auth method not specified - email/password, SSO, OAuth?]
|
||||||
|
- **FR-007**: System MUST retain user data for [NEEDS CLARIFICATION: retention period not specified]
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **[Entity 1]**: [What it represents, key attributes without implementation]
|
||||||
|
- **[Entity 2]**: [What it represents, relationships to other entities]
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
<!--
|
||||||
|
ACTION REQUIRED: Define measurable success criteria.
|
||||||
|
These must be technology-agnostic and measurable.
|
||||||
|
-->
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: [Measurable metric, e.g., "Users can complete account creation in under 2 minutes"]
|
||||||
|
- **SC-002**: [Measurable metric, e.g., "System handles 1000 concurrent users without degradation"]
|
||||||
|
- **SC-003**: [User satisfaction metric, e.g., "90% of users successfully complete primary task on first attempt"]
|
||||||
|
- **SC-004**: [Business metric, e.g., "Reduce support tickets related to [X] by 50%"]
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
<!--
|
||||||
|
ACTION REQUIRED: The content in this section represents placeholders.
|
||||||
|
Fill them out with the right assumptions based on reasonable defaults
|
||||||
|
chosen when the feature description did not specify certain details.
|
||||||
|
-->
|
||||||
|
|
||||||
|
- [Assumption about target users, e.g., "Users have stable internet connectivity"]
|
||||||
|
- [Assumption about scope boundaries, e.g., "Mobile support is out of scope for v1"]
|
||||||
|
- [Assumption about data/environment, e.g., "Existing authentication system will be reused"]
|
||||||
|
- [Dependency on existing system/service, e.g., "Requires access to the existing user profile API"]
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
description: "Task list template for feature implementation"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tasks: [FEATURE NAME]
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/[###-feature-name]/`
|
||||||
|
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
|
||||||
|
|
||||||
|
**Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if explicitly requested in the feature specification.
|
||||||
|
|
||||||
|
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||||
|
|
||||||
|
## Format: `[ID] [P?] [Story] Description`
|
||||||
|
|
||||||
|
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||||
|
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
|
||||||
|
- Include exact file paths in descriptions
|
||||||
|
|
||||||
|
## Path Conventions
|
||||||
|
|
||||||
|
- **Single project**: `src/`, `tests/` at repository root
|
||||||
|
- **Web app**: `backend/src/`, `frontend/src/`
|
||||||
|
- **Mobile**: `api/src/`, `ios/src/` or `android/src/`
|
||||||
|
- Paths shown below assume single project - adjust based on plan.md structure
|
||||||
|
|
||||||
|
<!--
|
||||||
|
============================================================================
|
||||||
|
IMPORTANT: The tasks below are SAMPLE TASKS for illustration purposes only.
|
||||||
|
|
||||||
|
The /speckit-tasks command MUST replace these with actual tasks based on:
|
||||||
|
- User stories from spec.md (with their priorities P1, P2, P3...)
|
||||||
|
- Feature requirements from plan.md
|
||||||
|
- Entities from data-model.md
|
||||||
|
- Endpoints from contracts/
|
||||||
|
|
||||||
|
Tasks MUST be organized by user story so each story can be:
|
||||||
|
- Implemented independently
|
||||||
|
- Tested independently
|
||||||
|
- Delivered as an MVP increment
|
||||||
|
|
||||||
|
DO NOT keep these sample tasks in the generated tasks.md file.
|
||||||
|
============================================================================
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Infrastructure)
|
||||||
|
|
||||||
|
**Purpose**: Project initialization and basic structure
|
||||||
|
|
||||||
|
- [ ] T001 Create project structure per implementation plan
|
||||||
|
- [ ] T002 Initialize [language] project with [framework] dependencies
|
||||||
|
- [ ] T003 [P] Configure linting and formatting tools
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
|
**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented
|
||||||
|
|
||||||
|
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
|
||||||
|
|
||||||
|
Examples of foundational tasks (adjust based on your project):
|
||||||
|
|
||||||
|
- [ ] T004 Setup database schema and migrations framework
|
||||||
|
- [ ] T005 [P] Implement authentication/authorization framework
|
||||||
|
- [ ] T006 [P] Setup API routing and middleware structure
|
||||||
|
- [ ] T007 Create base models/entities that all stories depend on
|
||||||
|
- [ ] T008 Configure error handling and logging infrastructure
|
||||||
|
- [ ] T009 Setup environment configuration management
|
||||||
|
|
||||||
|
**Checkpoint**: Foundation ready - user story implementation can now begin in parallel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - [Title] (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: [Brief description of what this story delivers]
|
||||||
|
|
||||||
|
**Independent Test**: [How to verify this story works on its own]
|
||||||
|
|
||||||
|
### Tests for User Story 1 (OPTIONAL - only if tests requested) ⚠️
|
||||||
|
|
||||||
|
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
|
||||||
|
|
||||||
|
- [ ] T010 [P] [US1] Contract test for [endpoint] in tests/contract/test_[name].py
|
||||||
|
- [ ] T011 [P] [US1] Integration test for [user journey] in tests/integration/test_[name].py
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [ ] T012 [P] [US1] Create [Entity1] model in src/models/[entity1].py
|
||||||
|
- [ ] T013 [P] [US1] Create [Entity2] model in src/models/[entity2].py
|
||||||
|
- [ ] T014 [US1] Implement [Service] in src/services/[service].py (depends on T012, T013)
|
||||||
|
- [ ] T015 [US1] Implement [endpoint/feature] in src/[location]/[file].py
|
||||||
|
- [ ] T016 [US1] Add validation and error handling
|
||||||
|
- [ ] T017 [US1] Add logging for user story 1 operations
|
||||||
|
|
||||||
|
**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - [Title] (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: [Brief description of what this story delivers]
|
||||||
|
|
||||||
|
**Independent Test**: [How to verify this story works on its own]
|
||||||
|
|
||||||
|
### Tests for User Story 2 (OPTIONAL - only if tests requested) ⚠️
|
||||||
|
|
||||||
|
- [ ] T018 [P] [US2] Contract test for [endpoint] in tests/contract/test_[name].py
|
||||||
|
- [ ] T019 [P] [US2] Integration test for [user journey] in tests/integration/test_[name].py
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [ ] T020 [P] [US2] Create [Entity] model in src/models/[entity].py
|
||||||
|
- [ ] T021 [US2] Implement [Service] in src/services/[service].py
|
||||||
|
- [ ] T022 [US2] Implement [endpoint/feature] in src/[location]/[file].py
|
||||||
|
- [ ] T023 [US2] Integrate with User Story 1 components (if needed)
|
||||||
|
|
||||||
|
**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - [Title] (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: [Brief description of what this story delivers]
|
||||||
|
|
||||||
|
**Independent Test**: [How to verify this story works on its own]
|
||||||
|
|
||||||
|
### Tests for User Story 3 (OPTIONAL - only if tests requested) ⚠️
|
||||||
|
|
||||||
|
- [ ] T024 [P] [US3] Contract test for [endpoint] in tests/contract/test_[name].py
|
||||||
|
- [ ] T025 [P] [US3] Integration test for [user journey] in tests/integration/test_[name].py
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [ ] T026 [P] [US3] Create [Entity] model in src/models/[entity].py
|
||||||
|
- [ ] T027 [US3] Implement [Service] in src/services/[service].py
|
||||||
|
- [ ] T028 [US3] Implement [endpoint/feature] in src/[location]/[file].py
|
||||||
|
|
||||||
|
**Checkpoint**: All user stories should now be independently functional
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[Add more user story phases as needed, following the same pattern]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase N: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Improvements that affect multiple user stories
|
||||||
|
|
||||||
|
- [ ] TXXX [P] Documentation updates in docs/
|
||||||
|
- [ ] TXXX Code cleanup and refactoring
|
||||||
|
- [ ] TXXX Performance optimization across all stories
|
||||||
|
- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/
|
||||||
|
- [ ] TXXX Security hardening
|
||||||
|
- [ ] TXXX Run quickstart.md validation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: No dependencies - can start immediately
|
||||||
|
- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories
|
||||||
|
- **User Stories (Phase 3+)**: All depend on Foundational phase completion
|
||||||
|
- User stories can then proceed in parallel (if staffed)
|
||||||
|
- Or sequentially in priority order (P1 → P2 → P3)
|
||||||
|
- **Polish (Final Phase)**: Depends on all desired user stories being complete
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories
|
||||||
|
- **User Story 2 (P2)**: Can start after Foundational (Phase 2) - May integrate with US1 but should be independently testable
|
||||||
|
- **User Story 3 (P3)**: Can start after Foundational (Phase 2) - May integrate with US1/US2 but should be independently testable
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Tests (if included) MUST be written and FAIL before implementation
|
||||||
|
- Models before services
|
||||||
|
- Services before endpoints
|
||||||
|
- Core implementation before integration
|
||||||
|
- Story complete before moving to next priority
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- All Setup tasks marked [P] can run in parallel
|
||||||
|
- All Foundational tasks marked [P] can run in parallel (within Phase 2)
|
||||||
|
- Once Foundational phase completes, all user stories can start in parallel (if team capacity allows)
|
||||||
|
- All tests for a user story marked [P] can run in parallel
|
||||||
|
- Models within a story marked [P] can run in parallel
|
||||||
|
- Different user stories can be worked on in parallel by different team members
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Launch all tests for User Story 1 together (if tests requested):
|
||||||
|
Task: "Contract test for [endpoint] in tests/contract/test_[name].py"
|
||||||
|
Task: "Integration test for [user journey] in tests/integration/test_[name].py"
|
||||||
|
|
||||||
|
# Launch all models for User Story 1 together:
|
||||||
|
Task: "Create [Entity1] model in src/models/[entity1].py"
|
||||||
|
Task: "Create [Entity2] model in src/models/[entity2].py"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (User Story 1 Only)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Setup
|
||||||
|
2. Complete Phase 2: Foundational (CRITICAL - blocks all stories)
|
||||||
|
3. Complete Phase 3: User Story 1
|
||||||
|
4. **STOP and VALIDATE**: Test User Story 1 independently
|
||||||
|
5. Deploy/demo if ready
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Complete Setup + Foundational → Foundation ready
|
||||||
|
2. Add User Story 1 → Test independently → Deploy/Demo (MVP!)
|
||||||
|
3. Add User Story 2 → Test independently → Deploy/Demo
|
||||||
|
4. Add User Story 3 → Test independently → Deploy/Demo
|
||||||
|
5. Each story adds value without breaking previous stories
|
||||||
|
|
||||||
|
### Parallel Team Strategy
|
||||||
|
|
||||||
|
With multiple developers:
|
||||||
|
|
||||||
|
1. Team completes Setup + Foundational together
|
||||||
|
2. Once Foundational is done:
|
||||||
|
- Developer A: User Story 1
|
||||||
|
- Developer B: User Story 2
|
||||||
|
- Developer C: User Story 3
|
||||||
|
3. Stories complete and integrate independently
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- [P] tasks = different files, no dependencies
|
||||||
|
- [Story] label maps task to specific user story for traceability
|
||||||
|
- Each user story should be independently completable and testable
|
||||||
|
- Verify tests fail before implementing
|
||||||
|
- Commit after each task or logical group
|
||||||
|
- Stop at any checkpoint to validate story independently
|
||||||
|
- Avoid: vague tasks, same file conflicts, cross-story dependencies that break independence
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
schema_version: "1.0"
|
||||||
|
workflow:
|
||||||
|
id: "speckit"
|
||||||
|
name: "Full SDD Cycle"
|
||||||
|
version: "1.0.0"
|
||||||
|
author: "GitHub"
|
||||||
|
description: "Runs specify → plan → tasks → implement with review gates"
|
||||||
|
|
||||||
|
requires:
|
||||||
|
speckit_version: ">=0.7.2"
|
||||||
|
integrations:
|
||||||
|
any: ["copilot", "claude", "gemini"]
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
spec:
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
prompt: "Describe what you want to build"
|
||||||
|
integration:
|
||||||
|
type: string
|
||||||
|
default: "copilot"
|
||||||
|
prompt: "Integration to use (e.g. claude, copilot, gemini)"
|
||||||
|
scope:
|
||||||
|
type: string
|
||||||
|
default: "full"
|
||||||
|
enum: ["full", "backend-only", "frontend-only"]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: specify
|
||||||
|
command: speckit.specify
|
||||||
|
integration: "{{ inputs.integration }}"
|
||||||
|
input:
|
||||||
|
args: "{{ inputs.spec }}"
|
||||||
|
|
||||||
|
- id: review-spec
|
||||||
|
type: gate
|
||||||
|
message: "Review the generated spec before planning."
|
||||||
|
options: [approve, reject]
|
||||||
|
on_reject: abort
|
||||||
|
|
||||||
|
- id: plan
|
||||||
|
command: speckit.plan
|
||||||
|
integration: "{{ inputs.integration }}"
|
||||||
|
input:
|
||||||
|
args: "{{ inputs.spec }}"
|
||||||
|
|
||||||
|
- id: review-plan
|
||||||
|
type: gate
|
||||||
|
message: "Review the plan before generating tasks."
|
||||||
|
options: [approve, reject]
|
||||||
|
on_reject: abort
|
||||||
|
|
||||||
|
- id: tasks
|
||||||
|
command: speckit.tasks
|
||||||
|
integration: "{{ inputs.integration }}"
|
||||||
|
input:
|
||||||
|
args: "{{ inputs.spec }}"
|
||||||
|
|
||||||
|
- id: implement
|
||||||
|
command: speckit.implement
|
||||||
|
integration: "{{ inputs.integration }}"
|
||||||
|
input:
|
||||||
|
args: "{{ inputs.spec }}"
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "1.0",
|
||||||
|
"workflows": {
|
||||||
|
"speckit": {
|
||||||
|
"name": "Full SDD Cycle",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Runs specify \u2192 plan \u2192 tasks \u2192 implement with review gates",
|
||||||
|
"source": "bundled",
|
||||||
|
"installed_at": "2026-05-09T21:18:10.434718+00:00",
|
||||||
|
"updated_at": "2026-05-09T21:18:10.434724+00:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.22)
|
||||||
|
project(slopsmith_audio VERSION 0.1.0)
|
||||||
|
|
||||||
|
set(CMAKE_CXX_STANDARD 20)
|
||||||
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
|
||||||
|
|
||||||
|
# cmake-js integration
|
||||||
|
if(DEFINED CMAKE_JS_INC)
|
||||||
|
message(STATUS "Building as Node.js native addon (cmake-js)")
|
||||||
|
else()
|
||||||
|
message(WARNING "CMAKE_JS_INC not defined — building standalone (for testing only)")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# JUCE — add as subdirectory (submodule)
|
||||||
|
if(EXISTS "${CMAKE_SOURCE_DIR}/JUCE/CMakeLists.txt")
|
||||||
|
add_subdirectory(JUCE)
|
||||||
|
else()
|
||||||
|
message(FATAL_ERROR "JUCE submodule not found. Run: git submodule update --init --recursive")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# NAM Core (Neural Amp Modeler)
|
||||||
|
set(NAM_DIR "${CMAKE_SOURCE_DIR}/src/audio/third_party/NAM")
|
||||||
|
if(EXISTS "${NAM_DIR}/CMakeLists.txt")
|
||||||
|
set(NAM_AVAILABLE ON)
|
||||||
|
# NAM uses its own build system — we'll include its source directly
|
||||||
|
message(STATUS "NeuralAmpModelerCore found")
|
||||||
|
else()
|
||||||
|
set(NAM_AVAILABLE OFF)
|
||||||
|
message(STATUS "NeuralAmpModelerCore not found — NAM support disabled")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# RTNeural
|
||||||
|
set(RTNEURAL_DIR "${CMAKE_SOURCE_DIR}/src/audio/third_party/RTNeural")
|
||||||
|
if(EXISTS "${RTNEURAL_DIR}/CMakeLists.txt")
|
||||||
|
set(RTNEURAL_AVAILABLE ON)
|
||||||
|
set(RTNEURAL_XSIMD ON CACHE BOOL "Use XSIMD backend for RTNeural")
|
||||||
|
add_subdirectory("${RTNEURAL_DIR}" rtneural_build EXCLUDE_FROM_ALL)
|
||||||
|
message(STATUS "RTNeural found")
|
||||||
|
else()
|
||||||
|
set(RTNEURAL_AVAILABLE OFF)
|
||||||
|
message(STATUS "RTNeural not found")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# ONNX Runtime — for the Basic Pitch ML note detector (MlNoteDetector).
|
||||||
|
# Sets ONNXRUNTIME_AVAILABLE; build still succeeds (YIN fallback) if it can't
|
||||||
|
# be obtained. See cmake/onnxruntime.cmake.
|
||||||
|
include("${CMAKE_SOURCE_DIR}/cmake/onnxruntime.cmake")
|
||||||
|
|
||||||
|
add_subdirectory(src/audio)
|
||||||
|
|
||||||
|
# Out-of-process VST helpers (slopsmith-vst-host on all platforms now that the
|
||||||
|
# POSIX sandbox runtime exists; macOS also builds the slopsmith-vst-scan probe).
|
||||||
|
add_subdirectory(src/vst-host)
|
||||||
|
|
||||||
|
# Tests (cross-platform; gated on SLOPSMITH_BUILD_TESTS, default ON). The
|
||||||
|
# audio-ring loopback runs everywhere; the control-channel + posix_spawn smoke
|
||||||
|
# tests are POSIX-only (see tests/sandbox/CMakeLists.txt). enable_testing() must
|
||||||
|
# be at the top level so `ctest` from the root build directory discovers tests
|
||||||
|
# registered via add_test() in subdirectories.
|
||||||
|
if(EXISTS "${CMAKE_SOURCE_DIR}/tests/CMakeLists.txt")
|
||||||
|
enable_testing()
|
||||||
|
add_subdirectory(tests)
|
||||||
|
endif()
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Contributors
|
||||||
|
|
||||||
|
Slopsmith Desktop bundles a refreshed slopsmith core and 30+ plugin repos. Releases are a community effort across all of them — this file credits everyone who shipped code into a desktop release.
|
||||||
|
|
||||||
|
## v0.2.7
|
||||||
|
|
||||||
|
- [@byrongamatos](https://github.com/byrongamatos) — desktop runtime, slopsmith core, plugin infrastructure
|
||||||
|
- [@topkoa](https://github.com/topkoa) — 3D Highway "Up Next" HUD + double-sustain trail, Sloppak Converter hardening, Splitscreen viz picker rewrite, Stems API
|
||||||
|
- [@mogul](https://github.com/mogul) (Bret Mogilefsky) — panel-scoped keyboard shortcuts system
|
||||||
|
- [@barlind](https://github.com/barlind) — native audio device probing + safer reconfigure, NAM native desktop audio path
|
||||||
|
- [@rodsakuramoto](https://github.com/rodsakuramoto) — dB sliders, preset defaults, tone automation, safe chain reload
|
||||||
|
- [@vdwstoffel](https://github.com/vdwstoffel) (Christoff van der Walt) — volume up/down keyboard shortcuts
|
||||||
|
- [@masc0t](https://github.com/masc0t) — maintains the Update Manager plugin and several community plugins (Find More, Invert Highway, The Daily, Themes)
|
||||||
|
|
||||||
|
Plus automated review assistance from GitHub Copilot SWE agent across multiple PRs.
|
||||||
@@ -0,0 +1,661 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Slopsmith Desktop
|
||||||
|
|
||||||
|
Standalone cross-platform desktop app that wraps [Slopsmith](https://github.com/slopsmith/slopsmith) with integrated VST hosting, amp modeling, audio I/O, and full plugin support.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Prebuilt installers for the latest tagged release are published on the
|
||||||
|
[GitHub Releases page](https://github.com/slopsmith/slopsmith-desktop/releases/latest).
|
||||||
|
|
||||||
|
| Platform | Download | Notes |
|
||||||
|
|----------|----------|-------|
|
||||||
|
| Windows 10/11 (x64) | `Slopsmith.Setup.<version>.exe` | NSIS installer. On first run Windows SmartScreen may warn — click *More info → Run anyway*. |
|
||||||
|
| macOS 12+ (Apple Silicon) | `Slopsmith-<version>-arm64.dmg` | Signed & notarized. Intel Macs are not currently published — build from source. |
|
||||||
|
| Linux (x86_64) | `Slopsmith-<version>.AppImage` | `chmod +x` then run. Portable, no install step. |
|
||||||
|
| Debian / Ubuntu (x86_64) | `slopsmith-desktop_<version>_amd64.deb` | `sudo apt install ./slopsmith-desktop_<version>_amd64.deb` |
|
||||||
|
|
||||||
|
> **First launch may take a minute or two** while ML model caches populate
|
||||||
|
> in the app cache directory. Subsequent launches are fast.
|
||||||
|
|
||||||
|
There is currently no Homebrew, winget, Chocolatey, Scoop, Flatpak, or
|
||||||
|
Snap distribution — download directly from Releases. The app does not
|
||||||
|
yet ship an auto-updater; check Releases periodically for new versions.
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# Third-Party Notices
|
||||||
|
|
||||||
|
Slopsmith Desktop bundles, links to, or fetches at build time the
|
||||||
|
components listed below. Each entry names the upstream project, the
|
||||||
|
license it is distributed under, and where the corresponding source or
|
||||||
|
license text lives — either in this repository, in an installed
|
||||||
|
artefact, or upstream.
|
||||||
|
|
||||||
|
The desktop binary as a whole is distributed under the GNU Affero
|
||||||
|
General Public License v3.0 (see [`LICENSE`](LICENSE)) because the JUCE
|
||||||
|
framework — statically linked into the audio engine — is licensed under
|
||||||
|
AGPL-3.0 when not used under the separate commercial JUCE licence.
|
||||||
|
AGPL's copyleft propagates through that linkage, which is why the
|
||||||
|
combined work is AGPL-3.0-only.
|
||||||
|
|
||||||
|
Permissive components (MIT, BSD, Apache-2.0, etc.) listed below retain
|
||||||
|
their own terms; their notices must accompany any redistribution of
|
||||||
|
this software.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## JUCE 8
|
||||||
|
|
||||||
|
- **Project:** https://juce.com / https://github.com/juce-framework/JUCE
|
||||||
|
- **License:** AGPL-3.0-only **OR** the commercial JUCE 8 End User
|
||||||
|
Licence Agreement (the upstream `JUCE/LICENSE.md` does not grant the
|
||||||
|
"or any later version" option)
|
||||||
|
- **Use:** Statically linked into the JUCE C++ audio engine
|
||||||
|
(`src/audio/`, native addon `slopsmith_audio.node`).
|
||||||
|
- **Notes:** Slopsmith Desktop is distributed under the AGPL-3.0 arm of
|
||||||
|
this dual licence; the commercial JUCE licence is not used.
|
||||||
|
- **Notice text:** [`JUCE/LICENSE.md`](JUCE/LICENSE.md) (git submodule).
|
||||||
|
|
||||||
|
## Electron
|
||||||
|
|
||||||
|
- **Project:** https://www.electronjs.org/
|
||||||
|
- **License:** MIT
|
||||||
|
- **Use:** Desktop shell. Pinned via `package.json` (`electron`
|
||||||
|
devDependency).
|
||||||
|
- **Notice text:** Distributed with the Electron runtime; redistributed
|
||||||
|
inside the installer / app bundle under `LICENSE.electron.txt` (and
|
||||||
|
equivalent paths) emitted by `electron-builder`.
|
||||||
|
|
||||||
|
## NeuralAmpModelerCore (NAM)
|
||||||
|
|
||||||
|
- **Project:** https://github.com/sdatkinson/NeuralAmpModelerCore
|
||||||
|
- **License:** MIT (Copyright © 2023–2025 Steven Atkinson)
|
||||||
|
- **Use:** Bundled as a git submodule at
|
||||||
|
`src/audio/third_party/NAM/`; compiled into the audio engine to
|
||||||
|
power the built-in Neural Amp Modeler.
|
||||||
|
- **Notice text:**
|
||||||
|
[`src/audio/third_party/NAM/LICENSE`](src/audio/third_party/NAM/LICENSE).
|
||||||
|
|
||||||
|
## RTNeural
|
||||||
|
|
||||||
|
- **Project:** https://github.com/jatinchowdhury18/RTNeural
|
||||||
|
- **License:** BSD 3-Clause (Copyright © 2020 jatinchowdhury18)
|
||||||
|
- **Use:** Bundled as a git submodule at
|
||||||
|
`src/audio/third_party/RTNeural/`; compiled into the audio engine
|
||||||
|
for the real-time NN inference paths used by NAM.
|
||||||
|
- **Notice text:**
|
||||||
|
[`src/audio/third_party/RTNeural/LICENSE`](src/audio/third_party/RTNeural/LICENSE).
|
||||||
|
|
||||||
|
## FluidSynth
|
||||||
|
|
||||||
|
- **Project:** https://www.fluidsynth.org / https://github.com/FluidSynth/fluidsynth
|
||||||
|
- **License:** LGPL-2.1-or-later
|
||||||
|
- **Use:** Bundled into `resources/bin/` for use by the embedded
|
||||||
|
Slopsmith server (Guitar Pro → audio rendering path). On Windows,
|
||||||
|
prebuilt binaries are downloaded at packaging time per the
|
||||||
|
`external.fluidsynth_windows` block in
|
||||||
|
[`.build-config.json`](.build-config.json). On macOS and Linux, the
|
||||||
|
build host's `fluidsynth` binary is copied from `$PATH` by
|
||||||
|
[`scripts/bundle-binaries.sh`](scripts/bundle-binaries.sh).
|
||||||
|
Dynamically linked — users may relink against a compatible
|
||||||
|
FluidSynth build per LGPL terms.
|
||||||
|
- **Notice text:** Included in the upstream FluidSynth release archive
|
||||||
|
redistributed inside the installer (Windows) and travelling with the
|
||||||
|
host's `fluidsynth` package (macOS/Linux).
|
||||||
|
|
||||||
|
## FFmpeg / ffprobe
|
||||||
|
|
||||||
|
- **Project:** https://ffmpeg.org / https://github.com/FFmpeg/FFmpeg
|
||||||
|
- **License:** LGPL-2.1-or-later at minimum; the specific bundled
|
||||||
|
builds may be GPL-licensed (typically GPL-2.0-or-later or
|
||||||
|
GPL-3.0-or-later) if they were compiled with `--enable-gpl` and
|
||||||
|
linked against GPL components such as libx264 / libx265. The
|
||||||
|
third-party macOS builds we fetch (osxexperts.net, evermeet.cx) ship
|
||||||
|
with GPL components enabled, so on macOS the bundled `ffmpeg`/`ffprobe`
|
||||||
|
binaries are effectively under GPL terms. AGPL-3.0-only is compatible
|
||||||
|
with redistribution of GPL-2.0-or-later and GPL-3.0-or-later binaries
|
||||||
|
(FSF compatibility matrix), and FFmpeg is invoked as a separate
|
||||||
|
process — so its license terms do not propagate to the rest of the
|
||||||
|
desktop binary.
|
||||||
|
- **Use:** Required by the embedded Slopsmith server for WAV → OGG
|
||||||
|
transcoding on Guitar Pro 5 imports (`ffmpeg`) and for stream
|
||||||
|
metadata reads by demucs during stem splitting (`ffprobe`). Bundled
|
||||||
|
into `resources/bin/` on all platforms by
|
||||||
|
[`scripts/bundle-binaries.sh`](scripts/bundle-binaries.sh):
|
||||||
|
- macOS: prebuilt binaries downloaded per the
|
||||||
|
`external.ffmpeg_macos_*` / `external.ffprobe_macos_*` blocks in
|
||||||
|
[`.build-config.json`](.build-config.json).
|
||||||
|
- Linux: copied from the build host's `$PATH` (e.g. apt `ffmpeg`).
|
||||||
|
- Windows: copied from the build host's `$PATH` (e.g. Chocolatey /
|
||||||
|
Scoop `ffmpeg`).
|
||||||
|
- **Notice text:** Distributed inside the upstream FFmpeg source /
|
||||||
|
release archives (`COPYING.LGPLv2.1`, `COPYING.GPLv2`, etc.); the
|
||||||
|
source code is publicly available at the upstream URL above.
|
||||||
|
|
||||||
|
## vgmstream-cli
|
||||||
|
|
||||||
|
- **Project:** https://github.com/vgmstream/vgmstream
|
||||||
|
- **License:** ISC-style permissive (see upstream
|
||||||
|
[`COPYING`](https://github.com/vgmstream/vgmstream/blob/master/COPYING)).
|
||||||
|
- **Use:** Required by the embedded Slopsmith server for
|
||||||
|
`.wem` → `.wav` audio decoding. Bundled into `resources/bin/` on all
|
||||||
|
platforms by
|
||||||
|
[`scripts/bundle-binaries.sh`](scripts/bundle-binaries.sh): copied
|
||||||
|
from the build host's `$PATH` when available, or downloaded from the
|
||||||
|
upstream
|
||||||
|
[GitHub releases](https://github.com/vgmstream/vgmstream/releases/latest)
|
||||||
|
as a fallback.
|
||||||
|
- **Notice text:** Distributed in the upstream release archive and
|
||||||
|
source repository.
|
||||||
|
|
||||||
|
## GeneralUser GS SoundFont
|
||||||
|
|
||||||
|
- **Author:** S. Christian Collins —
|
||||||
|
https://www.schristiancollins.com/generaluser
|
||||||
|
- **License:** Custom permissive licence (free redistribution with
|
||||||
|
attribution; no stand-alone resale).
|
||||||
|
- **Use:** Bundled unmodified as `GeneralUser-GS.sf2` for the
|
||||||
|
Guitar Pro → audio rendering path; pinned by SHA-256 per the
|
||||||
|
`external.soundfont_general_user` block in
|
||||||
|
[`.build-config.json`](.build-config.json).
|
||||||
|
- **Notice text:**
|
||||||
|
[`resources/soundfonts/LICENSE`](resources/soundfonts/LICENSE).
|
||||||
|
|
||||||
|
## CPython (embedded Python runtime)
|
||||||
|
|
||||||
|
- **Project:** https://www.python.org/
|
||||||
|
- **License:** Python Software Foundation License (PSF)
|
||||||
|
- **Use:** A relocatable CPython build from
|
||||||
|
[astral-sh/python-build-standalone](https://github.com/astral-sh/python-build-standalone)
|
||||||
|
is fetched at packaging time (macOS / Linux) per the
|
||||||
|
`python_standalone_*` blocks in
|
||||||
|
[`.build-config.json`](.build-config.json) and bundled under
|
||||||
|
`resources/python/` so the embedded Slopsmith server can run
|
||||||
|
without a system Python install.
|
||||||
|
- **Notice text:** Distributed with the standalone Python build.
|
||||||
|
|
||||||
|
## Node.js modules (runtime dependencies)
|
||||||
|
|
||||||
|
The Electron main process pulls in two runtime npm dependencies; both
|
||||||
|
are MIT-licensed and travel inside the packaged app under
|
||||||
|
`resources/app.asar` (or unpacked, per
|
||||||
|
[`package.json`](package.json) `build.asarUnpack`):
|
||||||
|
|
||||||
|
- **lottie-web** — https://github.com/airbnb/lottie-web — MIT
|
||||||
|
- **node-addon-api** — https://github.com/nodejs/node-addon-api — MIT
|
||||||
|
|
||||||
|
Their full licence texts are present in the corresponding
|
||||||
|
`node_modules/<package>/LICENSE` files at install time and travel
|
||||||
|
with the packaged app.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adding a new dependency
|
||||||
|
|
||||||
|
When adding a new dependency, append an entry here describing the
|
||||||
|
project, its licence, how it's bundled, and where the notice text
|
||||||
|
lives. Permissive (MIT / BSD / Apache-2.0) and AGPL-compatible
|
||||||
|
copyleft licences are acceptable; anything more restrictive than
|
||||||
|
AGPL-3.0 — or any licence that forbids combination with AGPL — must
|
||||||
|
not be added without first discussing it on the issue tracker.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Windows Build Requirements
|
||||||
|
|
||||||
|
This document describes the dependencies and setup required to build Slopsmith Desktop on Windows.
|
||||||
|
|
||||||
|
## Required Software
|
||||||
|
|
||||||
|
### Visual Studio Build Tools 2022
|
||||||
|
- **Version**: 17.14 or later
|
||||||
|
- **Purpose**: Compile native C++ audio engine and Node.js native addons
|
||||||
|
- **Install**: `winget install Microsoft.VisualStudio.2022.BuildTools --override "--wait --passive --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"`
|
||||||
|
- **Required Components**: Desktop development with C++ workload
|
||||||
|
|
||||||
|
### CMake
|
||||||
|
- **Version**: 3.22 or later
|
||||||
|
- **Purpose**: Build system for native audio engine
|
||||||
|
- **Install**: `winget install Kitware.CMake`
|
||||||
|
- **Note**: Add to PATH: `C:\Program Files\CMake\bin`
|
||||||
|
|
||||||
|
### Git (for plugin cloning)
|
||||||
|
- **Purpose**: `clone_slopsmith` in `scripts/build-common.sh` clones the Slopsmith repo and every plugin via plain `git clone`. Git for Windows ships with Git Bash, which is required anyway to run the build scripts.
|
||||||
|
- **Install**: `winget install Git.Git` (you almost certainly already have this).
|
||||||
|
- **Optional (private plugins only)**: If any clones target private repos you have access to, configure git credentials (e.g. `git config --global credential.helper manager` plus a one-time push/clone to cache a PAT). The build does NOT use the GitHub CLI.
|
||||||
|
|
||||||
|
### Chocolatey (optional)
|
||||||
|
- **Purpose**: Package manager for cmake and ffmpeg (can also be downloaded directly)
|
||||||
|
- **Install**: See https://chocolatey.org/install
|
||||||
|
|
||||||
|
## Windows-Specific Build Notes
|
||||||
|
|
||||||
|
### Python Embeddable Distribution
|
||||||
|
The Windows build uses Python's embeddable distribution, which has a special configuration:
|
||||||
|
|
||||||
|
1. **`.pth` file isolation**: The Python embeddable distribution uses a `python312._pth` file that enables "isolated mode". In this mode, the `PYTHONPATH` environment variable is **completely ignored**.
|
||||||
|
|
||||||
|
2. **Solution**: Slopsmith paths must be added directly to the `._pth` file. This is handled automatically by `scripts/build-windows.sh`, but if you need to add custom paths:
|
||||||
|
```
|
||||||
|
# In python312._pth (relative to resources/python):
|
||||||
|
../slopsmith
|
||||||
|
../slopsmith/lib
|
||||||
|
```
|
||||||
|
|
||||||
|
### Git authentication for private plugins
|
||||||
|
If your build pulls private plugin repos (e.g. `byrongamatos/slopsmith-plugin-cf`), make sure git can authenticate non-interactively before running the build — Git Credential Manager + a PAT or SSH key both work. The build uses `git clone --depth 1` for every plugin and does NOT call the GitHub CLI.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies (Git for Windows brings Git Bash which the scripts run under)
|
||||||
|
winget install Microsoft.VisualStudio.2022.BuildTools Kitware.CMake Git.Git
|
||||||
|
|
||||||
|
# Add CMake to PATH (if not automatic)
|
||||||
|
export PATH="$PATH:/c/Program Files/CMake/bin"
|
||||||
|
|
||||||
|
# Run the build
|
||||||
|
bash scripts/build-windows.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
### "CMake is not installed"
|
||||||
|
- Install via winget: `winget install Kitware.CMake`
|
||||||
|
- Or add existing installation to PATH: `export PATH="$PATH:/c/Program Files/CMake/bin"`
|
||||||
|
|
||||||
|
### "Repository not found" during plugin cloning
|
||||||
|
- Configure git itself for authentication (the build uses plain `git clone`, not the GitHub CLI):
|
||||||
|
- Git Credential Manager + a Personal Access Token: `git config --global credential.helper manager`
|
||||||
|
- Or an SSH key registered on your account
|
||||||
|
- Verify you can manually `git clone` the failing plugin URL from Git Bash before retrying the build
|
||||||
|
- Confirm the plugin repository exists on GitHub and your account has access to any private repos referenced in `scripts/build-common.sh`'s clone list
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
# ONNX Runtime acquisition for the ML note detector (Basic Pitch).
|
||||||
|
#
|
||||||
|
# Mirrors the NAM_AVAILABLE / RTNEURAL_AVAILABLE conditional pattern: if ONNX
|
||||||
|
# Runtime can be obtained, ONNXRUNTIME_AVAILABLE is set ON and the audio addon
|
||||||
|
# compiles with SLOPSMITH_ONNX_SUPPORT=1; otherwise the build still succeeds and
|
||||||
|
# the engine falls back to the YIN PitchDetector / ChordScorer (Constitution VII).
|
||||||
|
#
|
||||||
|
# Delivery: a pinned, prebuilt CPU release fetched at configure time with a
|
||||||
|
# pinned URL + SHA-256 (Constitution V — reproducible builds). For fully offline
|
||||||
|
# / pre-seeded builds, set -DSLOPSMITH_ONNXRUNTIME_ROOT=/path/to/extracted/onnxruntime
|
||||||
|
# (the directory containing include/ and lib/) and the fetch is skipped.
|
||||||
|
#
|
||||||
|
# Exports (cache/parent scope):
|
||||||
|
# ONNXRUNTIME_AVAILABLE ON/OFF
|
||||||
|
# ONNXRUNTIME_INCLUDE_DIR header directory
|
||||||
|
# ONNXRUNTIME_IMPORT_LIB library to link against
|
||||||
|
# ONNXRUNTIME_RUNTIME_LIB shared lib that must sit next to slopsmith_audio.node
|
||||||
|
|
||||||
|
# Plain variable, not a cache entry: the version is pinned in lock-step with
|
||||||
|
# the per-asset SHA-256 table below, so it must always be the value in this
|
||||||
|
# file. A CACHE STRING would keep a stale version from a reused build dir
|
||||||
|
# (a branch on a different version), making the hash check fail and silently
|
||||||
|
# disable ONNX.
|
||||||
|
set(ONNXRUNTIME_VERSION "1.20.1")
|
||||||
|
|
||||||
|
# --- Resolve the prebuilt asset for this OS/arch --------------------------
|
||||||
|
set(_ort_base "https://github.com/microsoft/onnxruntime/releases/download/v${ONNXRUNTIME_VERSION}")
|
||||||
|
set(_ort_ok ON)
|
||||||
|
|
||||||
|
# Only x64 / arm64 prebuilt assets exist. An unrecognised processor must
|
||||||
|
# disable ONNX (clean YIN fallback) — never default to an x64 asset, which
|
||||||
|
# would only fail later at link/load on e.g. Windows ARM64 or 32-bit ARM.
|
||||||
|
if(CMAKE_SYSTEM_NAME STREQUAL "Windows")
|
||||||
|
if(CMAKE_SYSTEM_PROCESSOR MATCHES "AMD64|x86_64|x64")
|
||||||
|
set(_ort_asset "onnxruntime-win-x64-${ONNXRUNTIME_VERSION}")
|
||||||
|
set(_ort_ext "zip")
|
||||||
|
set(_ort_sha "78d447051e48bd2e1e778bba378bec4ece11191c9e538cf7b2c4a4565e8f5581")
|
||||||
|
set(_ort_import "onnxruntime.lib")
|
||||||
|
set(_ort_runtime "onnxruntime.dll")
|
||||||
|
set(_ort_providers "onnxruntime_providers_shared.dll")
|
||||||
|
else()
|
||||||
|
set(_ort_ok OFF)
|
||||||
|
message(STATUS "ONNX Runtime: unsupported Windows arch '${CMAKE_SYSTEM_PROCESSOR}' — ML note detection disabled")
|
||||||
|
endif()
|
||||||
|
elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin")
|
||||||
|
set(_ort_ext "tgz")
|
||||||
|
if(CMAKE_SYSTEM_PROCESSOR MATCHES "arm64|aarch64")
|
||||||
|
set(_ort_asset "onnxruntime-osx-arm64-${ONNXRUNTIME_VERSION}")
|
||||||
|
set(_ort_sha "b678fc3c2354c771fea4fba420edeccfba205140088334df801e7fc40e83a57a")
|
||||||
|
elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "x86_64|AMD64|x64")
|
||||||
|
set(_ort_asset "onnxruntime-osx-x86_64-${ONNXRUNTIME_VERSION}")
|
||||||
|
set(_ort_sha "0f73006813af2a1a5d1723ed7dfb694fc629d15037124081bb61b7bf7d99fc78")
|
||||||
|
else()
|
||||||
|
set(_ort_ok OFF)
|
||||||
|
message(STATUS "ONNX Runtime: unsupported macOS arch '${CMAKE_SYSTEM_PROCESSOR}' — ML note detection disabled")
|
||||||
|
endif()
|
||||||
|
set(_ort_import "libonnxruntime.dylib")
|
||||||
|
set(_ort_runtime "libonnxruntime.${ONNXRUNTIME_VERSION}.dylib")
|
||||||
|
set(_ort_providers "libonnxruntime_providers_shared.dylib")
|
||||||
|
elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux")
|
||||||
|
set(_ort_ext "tgz")
|
||||||
|
if(CMAKE_SYSTEM_PROCESSOR MATCHES "aarch64|arm64")
|
||||||
|
set(_ort_asset "onnxruntime-linux-aarch64-${ONNXRUNTIME_VERSION}")
|
||||||
|
set(_ort_sha "ae4fedbdc8c18d688c01306b4b50c63de3445cdf2dbd720e01a2fa3810b8106a")
|
||||||
|
elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "x86_64|AMD64|x64")
|
||||||
|
set(_ort_asset "onnxruntime-linux-x64-${ONNXRUNTIME_VERSION}")
|
||||||
|
set(_ort_sha "67db4dc1561f1e3fd42e619575c82c601ef89849afc7ea85a003abbac1a1a105")
|
||||||
|
else()
|
||||||
|
set(_ort_ok OFF)
|
||||||
|
message(STATUS "ONNX Runtime: unsupported Linux arch '${CMAKE_SYSTEM_PROCESSOR}' — ML note detection disabled")
|
||||||
|
endif()
|
||||||
|
set(_ort_import "libonnxruntime.so")
|
||||||
|
# SONAME is libonnxruntime.so.1 — that exact name must sit next to the addon.
|
||||||
|
set(_ort_runtime "libonnxruntime.so.1")
|
||||||
|
set(_ort_providers "libonnxruntime_providers_shared.so")
|
||||||
|
else()
|
||||||
|
set(_ort_ok OFF)
|
||||||
|
message(STATUS "ONNX Runtime: unsupported platform '${CMAKE_SYSTEM_NAME}' — ML note detection disabled")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# --- Obtain the runtime: explicit root override, or pinned fetch ----------
|
||||||
|
set(_ort_root "")
|
||||||
|
|
||||||
|
if(_ort_ok AND DEFINED SLOPSMITH_ONNXRUNTIME_ROOT)
|
||||||
|
if(EXISTS "${SLOPSMITH_ONNXRUNTIME_ROOT}/include/onnxruntime_cxx_api.h")
|
||||||
|
set(_ort_root "${SLOPSMITH_ONNXRUNTIME_ROOT}")
|
||||||
|
message(STATUS "ONNX Runtime: using prepopulated root ${_ort_root}")
|
||||||
|
else()
|
||||||
|
message(WARNING "SLOPSMITH_ONNXRUNTIME_ROOT='${SLOPSMITH_ONNXRUNTIME_ROOT}' "
|
||||||
|
"has no include/onnxruntime_cxx_api.h — ignoring")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Download + extract the prebuilt archive. Every failure mode here is SOFT —
|
||||||
|
# offline machine, download error, hash mismatch, bad layout all just disable
|
||||||
|
# ML detection (YIN fallback). Configure must never abort on this, so we use
|
||||||
|
# file(DOWNLOAD) with a status check rather than FetchContent, which raises a
|
||||||
|
# FATAL_ERROR when the archive can't be fetched.
|
||||||
|
if(_ort_ok AND _ort_root STREQUAL "")
|
||||||
|
set(_ort_url "${_ort_base}/${_ort_asset}.${_ort_ext}")
|
||||||
|
set(_ort_archive "${CMAKE_BINARY_DIR}/_deps/${_ort_asset}.${_ort_ext}")
|
||||||
|
set(_ort_extract "${CMAKE_BINARY_DIR}/_deps/onnxruntime")
|
||||||
|
set(_ort_header "${_ort_extract}/${_ort_asset}/include/onnxruntime_cxx_api.h")
|
||||||
|
|
||||||
|
# On a clean build tree _deps does not exist yet — create it so the
|
||||||
|
# file(DOWNLOAD) below can open its destination file instead of failing
|
||||||
|
# and silently disabling ONNX support on every fresh configure.
|
||||||
|
file(MAKE_DIRECTORY "${CMAKE_BINARY_DIR}/_deps")
|
||||||
|
|
||||||
|
# Download — cached: a prior configure's archive with a matching hash is
|
||||||
|
# reused, so reconfigure / clean-tree rebuilds don't re-fetch.
|
||||||
|
set(_ort_have OFF)
|
||||||
|
if(EXISTS "${_ort_archive}")
|
||||||
|
file(SHA256 "${_ort_archive}" _ort_got)
|
||||||
|
if(_ort_got STREQUAL "${_ort_sha}")
|
||||||
|
set(_ort_have ON)
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
if(NOT _ort_have)
|
||||||
|
message(STATUS "ONNX Runtime: downloading ${_ort_asset}.${_ort_ext}")
|
||||||
|
# Bounded timeouts so a network stall can't hang cmake configure
|
||||||
|
# indefinitely — the status check below then soft-falls to YIN.
|
||||||
|
file(DOWNLOAD "${_ort_url}" "${_ort_archive}" STATUS _ort_dlst
|
||||||
|
TLS_VERIFY ON INACTIVITY_TIMEOUT 30 TIMEOUT 600)
|
||||||
|
list(GET _ort_dlst 0 _ort_dlcode)
|
||||||
|
if(_ort_dlcode EQUAL 0 AND EXISTS "${_ort_archive}")
|
||||||
|
file(SHA256 "${_ort_archive}" _ort_got)
|
||||||
|
if(_ort_got STREQUAL "${_ort_sha}")
|
||||||
|
set(_ort_have ON)
|
||||||
|
else()
|
||||||
|
message(WARNING "ONNX Runtime: SHA-256 mismatch on ${_ort_asset} "
|
||||||
|
"— ML note detection disabled (YIN fallback)")
|
||||||
|
endif()
|
||||||
|
else()
|
||||||
|
list(GET _ort_dlst 1 _ort_dlmsg)
|
||||||
|
message(STATUS "ONNX Runtime: download failed (${_ort_dlmsg}) "
|
||||||
|
"— ML note detection disabled (YIN fallback)")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Extract — only when the archive is good and not already unpacked.
|
||||||
|
if(_ort_have AND NOT EXISTS "${_ort_header}")
|
||||||
|
file(REMOVE_RECURSE "${_ort_extract}")
|
||||||
|
file(MAKE_DIRECTORY "${_ort_extract}")
|
||||||
|
file(ARCHIVE_EXTRACT INPUT "${_ort_archive}" DESTINATION "${_ort_extract}")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(_ort_have AND EXISTS "${_ort_header}")
|
||||||
|
set(_ort_root "${_ort_extract}/${_ort_asset}")
|
||||||
|
message(STATUS "ONNX Runtime ${ONNXRUNTIME_VERSION}: ready (${_ort_asset})")
|
||||||
|
elseif(_ort_have)
|
||||||
|
message(WARNING "ONNX Runtime: unexpected archive layout "
|
||||||
|
"— ML note detection disabled (YIN fallback)")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# --- Publish results -------------------------------------------------------
|
||||||
|
# Verify the link + runtime libraries actually exist before declaring ONNX
|
||||||
|
# available. A prepopulated SLOPSMITH_ONNXRUNTIME_ROOT (or an unexpected
|
||||||
|
# archive layout) could carry the headers but miss/rename the libs — without
|
||||||
|
# this check configure would pass and the build would only fail later at link
|
||||||
|
# or the post-build copy, defeating the intended soft-fall to YIN.
|
||||||
|
set(_ort_import_path "${_ort_root}/lib/${_ort_import}")
|
||||||
|
set(_ort_runtime_path "${_ort_root}/lib/${_ort_runtime}")
|
||||||
|
# The shared-provider stub library ONNX Runtime dlopen()s alongside the main
|
||||||
|
# runtime. The CPU EP works without it, but staging it avoids a session-
|
||||||
|
# creation failure on builds/versions where it is consulted — see below.
|
||||||
|
set(_ort_providers_path "${_ort_root}/lib/${_ort_providers}")
|
||||||
|
if(_ort_ok AND NOT _ort_root STREQUAL ""
|
||||||
|
AND NOT (EXISTS "${_ort_import_path}" AND EXISTS "${_ort_runtime_path}"))
|
||||||
|
message(WARNING "ONNX Runtime: headers found but lib '${_ort_import}' / "
|
||||||
|
"'${_ort_runtime}' missing under ${_ort_root}/lib "
|
||||||
|
"— ML note detection disabled (YIN fallback)")
|
||||||
|
set(_ort_root "")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(_ort_ok AND NOT _ort_root STREQUAL "")
|
||||||
|
# FORCE so a reconfigure always reflects the current probe result rather
|
||||||
|
# than a stale entry from an earlier configure.
|
||||||
|
set(ONNXRUNTIME_AVAILABLE ON CACHE INTERNAL "" FORCE)
|
||||||
|
set(ONNXRUNTIME_INCLUDE_DIR "${_ort_root}/include" CACHE INTERNAL "" FORCE)
|
||||||
|
set(ONNXRUNTIME_IMPORT_LIB "${_ort_import_path}" CACHE INTERNAL "" FORCE)
|
||||||
|
set(ONNXRUNTIME_RUNTIME_LIB "${_ort_runtime_path}" CACHE INTERNAL "" FORCE)
|
||||||
|
# Optional — present in the standard releases but staged only if it exists,
|
||||||
|
# so an unexpected layout never fails the build.
|
||||||
|
if(EXISTS "${_ort_providers_path}")
|
||||||
|
set(ONNXRUNTIME_PROVIDERS_LIB "${_ort_providers_path}" CACHE INTERNAL "" FORCE)
|
||||||
|
else()
|
||||||
|
unset(ONNXRUNTIME_PROVIDERS_LIB CACHE)
|
||||||
|
endif()
|
||||||
|
message(STATUS "ONNX Runtime available — ML note detection enabled")
|
||||||
|
else()
|
||||||
|
set(ONNXRUNTIME_AVAILABLE OFF CACHE INTERNAL "" FORCE)
|
||||||
|
# Clear any stale paths a previous successful configure cached, so a
|
||||||
|
# later reconfigure that loses ONNX can't leave downstream steps
|
||||||
|
# pointed at a runtime that is no longer there.
|
||||||
|
unset(ONNXRUNTIME_INCLUDE_DIR CACHE)
|
||||||
|
unset(ONNXRUNTIME_IMPORT_LIB CACHE)
|
||||||
|
unset(ONNXRUNTIME_RUNTIME_LIB CACHE)
|
||||||
|
unset(ONNXRUNTIME_PROVIDERS_LIB CACHE)
|
||||||
|
message(STATUS "ONNX Runtime not available — ML note detection disabled (YIN fallback)")
|
||||||
|
endif()
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
# Build system architecture
|
||||||
|
|
||||||
|
How Slopsmith Desktop is built — the flow of scripts, config, and
|
||||||
|
packaging across local dev and CI.
|
||||||
|
|
||||||
|
## Philosophy
|
||||||
|
|
||||||
|
1. **npm scripts are the API.** Every build step is callable via
|
||||||
|
`npm run <name>`. CI, local dev, and the DevContainer all use the
|
||||||
|
same entry points.
|
||||||
|
2. **Shell scripts implement.** Complex bundling lives in
|
||||||
|
`scripts/bundle-*.sh`; npm just invokes them.
|
||||||
|
3. **Configuration is centralized.** Tool versions + external pins
|
||||||
|
live in `.build-config.json`; per-OS system deps in `.packages/`.
|
||||||
|
4. **Reproducibility is pinned, not floating.** External dependencies
|
||||||
|
(FluidR3/GeneralUser soundfonts, fluidsynth Windows zip) are tied to
|
||||||
|
specific commits or SHA256s; CI and local see the same bytes.
|
||||||
|
|
||||||
|
## High-level flow
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Build environments │
|
||||||
|
│ │
|
||||||
|
│ GitHub Actions DevContainer (Docker) Developer CLI │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ └─────────────────────┼───────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌─────────────────────┐ │
|
||||||
|
│ │ npm scripts │ │
|
||||||
|
│ │ (package.json) │ ← single-source entry │
|
||||||
|
│ └─────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────────────────┼──────────────────┐ │
|
||||||
|
│ ▼ ▼ ▼ │
|
||||||
|
│ ┌────────────┐ ┌──────────────┐ ┌────────────┐ │
|
||||||
|
│ │build:native│ │ bundle │ │ build:ts │ │
|
||||||
|
│ │ │ │ │ │ │ │
|
||||||
|
│ │• build:audio│ │• bundle: │ │ (tsc) │ │
|
||||||
|
│ │ │ │ slopsmith │ │ │ │
|
||||||
|
│ └────────────┘ │• bundle: │ └────────────┘ │
|
||||||
|
│ │ python │ │
|
||||||
|
│ │• bundle: │ │
|
||||||
|
│ │ binaries │ │
|
||||||
|
│ │• bundle: │ │
|
||||||
|
│ │ soundfont │ │
|
||||||
|
│ └──────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌─────────────────────┐ │
|
||||||
|
│ │ electron-builder │ │
|
||||||
|
│ │ (dist:linux etc.) │ │
|
||||||
|
│ └─────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Configuration files │
|
||||||
|
│ │
|
||||||
|
│ .build-config.json .packages/ │
|
||||||
|
│ ├── versions ├── apt.txt (Ubuntu/Debian) │
|
||||||
|
│ │ ├── node ├── brew.txt (macOS) │
|
||||||
|
│ │ ├── python ├── choco.txt (Windows) │
|
||||||
|
│ │ ├── electron └── README.md │
|
||||||
|
│ │ ├── cmake │
|
||||||
|
│ │ └── ubuntu │
|
||||||
|
│ └── external │
|
||||||
|
│ ├── fluidsynth_windows (version + URL) │
|
||||||
|
│ └── soundfont_general_user (URL + SHA256) │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Detailed flow for `npm run dist:linux`
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run dist:linux
|
||||||
|
└── npm run dist -- --linux
|
||||||
|
└── npm run bundle && npm run build:ts && electron-builder --linux
|
||||||
|
└── bundle
|
||||||
|
├── bundle-slopsmith.sh — server.py + lib + static + plugins
|
||||||
|
├── bundle-python.sh — portable Python 3.12 runtime + pip + app deps
|
||||||
|
├── bundle-binaries.sh — ffmpeg + vgmstream-cli + fluidsynth + its .so chain
|
||||||
|
└── bundle-soundfont.sh — GeneralUser-GS.sf2 (SHA256-verified)
|
||||||
|
└── build:ts
|
||||||
|
└── tsc
|
||||||
|
└── electron-builder
|
||||||
|
└── AppImage + .deb
|
||||||
|
```
|
||||||
|
|
||||||
|
For `build:native` (run separately, not part of `dist`):
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run build:native
|
||||||
|
└── build:audio — cmake-js builds the JUCE C++ native addon
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scripts directory
|
||||||
|
|
||||||
|
```
|
||||||
|
scripts/
|
||||||
|
├── bundle.sh — top-level Linux delegator (→ bundle-*.sh)
|
||||||
|
├── bundle-slopsmith.sh — copy server + plugins
|
||||||
|
├── bundle-python.sh — portable Python runtime (Linux)
|
||||||
|
├── bundle-binaries.sh — ffmpeg + vgmstream + fluidsynth + libs (Linux)
|
||||||
|
├── bundle-soundfont.sh — GeneralUser-GS.sf2 download + verify (cross-platform)
|
||||||
|
├── build-linux-release.sh — Docker wrapper for reproducible Linux AppImage
|
||||||
|
├── parse-build-config.py — JSON value extractor
|
||||||
|
└── setup-dev.sh — local prerequisite checker
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why modular
|
||||||
|
|
||||||
|
- **Composability**: debug or re-run a single step (`npm run bundle:python`)
|
||||||
|
- **Readability**: each script does one thing and has a single reason to change
|
||||||
|
- **CI/local parity**: CI calls the same scripts where platform-independent; platform-specific inline bash stays in `build.yml` for macOS/Windows (dylibbundler, zip downloads) only because those tools don't fit the Linux shell-script model
|
||||||
|
|
||||||
|
### Platform scope per script
|
||||||
|
|
||||||
|
| Script | Linux | macOS | Windows |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `bundle.sh` | yes | n/a | n/a |
|
||||||
|
| `bundle-slopsmith.sh` | yes | yes (untested) | yes (untested) |
|
||||||
|
| `bundle-python.sh` | yes | no (CI inline) | no (CI inline) |
|
||||||
|
| `bundle-binaries.sh` | yes | no (CI uses dylibbundler inline) | no (CI downloads zips) |
|
||||||
|
| `bundle-soundfont.sh` | yes | yes | yes |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### `.build-config.json`
|
||||||
|
|
||||||
|
Tool versions pinned once, read by the CI workflow (via `node -p`) and
|
||||||
|
by the DevContainer (via `parse-build-config.py`). External pins
|
||||||
|
(soundfont SHA256, fluidsynth Windows zip URL) live under `external.*`.
|
||||||
|
|
||||||
|
Update tool versions in one place; every consumer picks them up.
|
||||||
|
|
||||||
|
### `.packages/`
|
||||||
|
|
||||||
|
Platform system-dep lists. One package per line; `#` comments and
|
||||||
|
blank lines are filtered by consumers. See `.packages/README.md` for
|
||||||
|
manual install snippets.
|
||||||
|
|
||||||
|
### Electron version
|
||||||
|
|
||||||
|
One source of truth: `devDependencies.electron` in `package.json`.
|
||||||
|
`build:audio` derives `--runtime-version` via
|
||||||
|
`node -p "require('electron/package.json').version"` so cmake-js
|
||||||
|
rebuilds the native addon against the currently-installed Electron.
|
||||||
|
|
||||||
|
## CI vs local parity
|
||||||
|
|
||||||
|
| Aspect | GitHub Actions | DevContainer |
|
||||||
|
|---|---|---|
|
||||||
|
| Entry | `npm run dist:linux` (via checkout + setup-* + install) | `npm run dist:linux` (inside container) |
|
||||||
|
| Node / Python | From `.build-config.json` | From `.build-config.json` |
|
||||||
|
| System deps | `.packages/apt.txt` | `.packages/apt.txt` |
|
||||||
|
| Slopsmith source | Cloned fresh to `$RUNNER_TEMP` | Bind-mounted from `../slopsmith` |
|
||||||
|
| Python bundle | `scripts/bundle-python.sh` | `scripts/bundle-python.sh` |
|
||||||
|
| Binary bundle | Inline bash (ffmpeg / vgmstream / fluidsynth lib chain) | `scripts/bundle-binaries.sh` (invoked by `npm run bundle`) |
|
||||||
|
| Soundfont bundle | `scripts/bundle-soundfont.sh` | `scripts/bundle-soundfont.sh` |
|
||||||
|
| Smoke test | `fluidsynth --version` + `ffmpeg -version` + `vgmstream-cli --help` | (run manually) |
|
||||||
|
|
||||||
|
## Adding a new build step
|
||||||
|
|
||||||
|
1. Create a script in `scripts/` (e.g. `bundle-foo.sh`)
|
||||||
|
2. Register an npm script in `package.json` (e.g. `"bundle:foo": "bash scripts/bundle-foo.sh"`)
|
||||||
|
3. Wire into the appropriate chain (`bundle`, `build:native`, etc.)
|
||||||
|
4. Test locally: `npm run bundle:foo`
|
||||||
|
5. Open a PR — CI consumes the same npm scripts
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Run individual steps
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run bundle:python # rebuild just the Python runtime
|
||||||
|
npm run bundle:binaries # rebuild just the binary chain
|
||||||
|
npm run bundle:soundfont # re-verify the soundfont
|
||||||
|
```
|
||||||
|
|
||||||
|
### Read the current config
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/parse-build-config.py .build-config.json
|
||||||
|
python3 scripts/parse-build-config.py .build-config.json .versions.electron
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local vs CI divergence
|
||||||
|
|
||||||
|
If local and CI builds differ:
|
||||||
|
|
||||||
|
1. `.build-config.json` — pinned versions identical?
|
||||||
|
2. `.packages/apt.txt` — are you running in the DevContainer?
|
||||||
|
3. The CI workflow inline-bundles some things differently on
|
||||||
|
macOS/Windows (dylibbundler, zip downloads). Local linux dev won't
|
||||||
|
reproduce those.
|
||||||
|
|
||||||
|
## See also
|
||||||
|
|
||||||
|
- [`../.build-config.json`](../.build-config.json)
|
||||||
|
- [`../.packages/`](../.packages/)
|
||||||
|
- [`../.devcontainer/`](../.devcontainer/)
|
||||||
|
- [`../.github/workflows/build.yml`](../.github/workflows/build.yml)
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# Capability Migration Notes
|
||||||
|
|
||||||
|
Slopsmith Desktop is moving first-party renderer integrations away from private
|
||||||
|
webview globals and legacy `song:*` events toward Slopsmith capability domains.
|
||||||
|
The goal is to keep desktop behavior aligned with core while avoiding raw local
|
||||||
|
filenames, device handles, or native transport objects in plugin-visible state.
|
||||||
|
|
||||||
|
## Playback Identity For Tone Mappings
|
||||||
|
|
||||||
|
Core playback capability v1 emits redaction-safe lifecycle events such as
|
||||||
|
`playback:loading`, `playback:ready`, `playback:stopped`, and `playback:ended`.
|
||||||
|
The playback target contains two public identities:
|
||||||
|
|
||||||
|
- `targetId`: arrangement-scoped playback target identity.
|
||||||
|
- `settingsKey`: per-song storage identity shaped like `settings-v1-...`.
|
||||||
|
|
||||||
|
Desktop tone switching now uses `target.settingsKey` as the primary song key for
|
||||||
|
`localStorage.slopsmith-tone-mappings`. Raw filenames from `song:loading` remain
|
||||||
|
only as a compatibility fallback when the embedded Slopsmith core does not expose
|
||||||
|
playback capability v1.
|
||||||
|
|
||||||
|
### Storage Shape
|
||||||
|
|
||||||
|
The store shape does not change:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"global": {},
|
||||||
|
"songs": {
|
||||||
|
"settings-v1-abc1234": { "Clean": "Clean Preset" }
|
||||||
|
},
|
||||||
|
"midiPC": {
|
||||||
|
"settings-v1-abc1234": { "mode": "midi", "vstSlotId": 0, "mappings": {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Only the per-song bucket key changes. New mappings created on playback-capable
|
||||||
|
core builds are written under `settingsKey`. Existing filename-keyed buckets are
|
||||||
|
left in place so older core builds and older desktop releases can still read
|
||||||
|
them.
|
||||||
|
|
||||||
|
### Automatic Migration For Existing Mappings
|
||||||
|
|
||||||
|
When playback capability v1 emits `playback:loading`, desktop receives both the
|
||||||
|
safe `target.settingsKey` and the legacy filename fallback. If
|
||||||
|
`localStorage.slopsmith-tone-mappings` contains a filename-keyed `songs` or
|
||||||
|
`midiPC` bucket for the legacy filename and the corresponding `settingsKey`
|
||||||
|
bucket is missing or empty, desktop copies that bucket to the safe key and leaves
|
||||||
|
the original bucket untouched.
|
||||||
|
|
||||||
|
This migration is intentionally copy-only:
|
||||||
|
|
||||||
|
- Existing `settingsKey` buckets win and are not overwritten.
|
||||||
|
- Filename-keyed buckets remain available to older desktop builds or embedded
|
||||||
|
Slopsmith cores without playback capability v1.
|
||||||
|
- Corrupt or missing mapping stores fall back to the normal empty-store behavior.
|
||||||
|
|
||||||
|
For review/debugging, open a song with an existing filename-keyed mapping and
|
||||||
|
inspect `localStorage.slopsmith-tone-mappings`: the same mapping should appear
|
||||||
|
under both the old filename key and the new `settings-v1-...` key after the
|
||||||
|
`playback:loading` event.
|
||||||
|
|
||||||
|
## Audio Effects Executor
|
||||||
|
|
||||||
|
Core `audio-effects` owns provider selection, policy, safe diagnostics, and the
|
||||||
|
`slopsmith.audio_effects.chain_plan.v1` schema. Desktop owns the trusted physical
|
||||||
|
executor. Renderer plugins may pass a core-resolved chain plan plus a private
|
||||||
|
trusted asset map to `window.slopsmithDesktop.audioEffects.loadChainPlan(...)`;
|
||||||
|
desktop validates the schema, authorization, stage kinds, stage counts, opaque
|
||||||
|
asset references, local asset paths, and extension/kind compatibility before it
|
||||||
|
builds the native preset JSON and calls the existing native `loadPreset` path.
|
||||||
|
|
||||||
|
The preload surface is:
|
||||||
|
|
||||||
|
- `loadChainPlan(request)` — validates and loads a chain plan through the native
|
||||||
|
engine. The request must include `authorization: "user-action"`,
|
||||||
|
`"restore-selection"`, or `"playback-session"`.
|
||||||
|
- `inspectRoute(routeKey)` — returns a redaction-safe route summary: route key,
|
||||||
|
provider id, plan id, state, stage-kind counts, active segment, and last
|
||||||
|
outcome.
|
||||||
|
- `activateSegment(request)` — applies a segment by translating plan stage ids
|
||||||
|
to loaded native slot ids and calling `setMultiBypass`.
|
||||||
|
- `setStageBypass(request)` and `setStageParameter(request)` — route stage-level
|
||||||
|
controls to the loaded native slot.
|
||||||
|
|
||||||
|
The executor accepts raw paths only inside the trusted asset map passed to
|
||||||
|
desktop. It never echoes local paths, filenames, model names, IR names, VST state
|
||||||
|
blobs, native preset JSON, handles, callbacks, DOM nodes, audio buffers, samples,
|
||||||
|
or waveforms in its public outcomes. Failed rich-provider loads return structured
|
||||||
|
`failed`, `degraded`, `unavailable`, or `no-target` outcomes so NAM Tone or core can
|
||||||
|
fall back cleanly instead of leaving a partially described chain in public state.
|
||||||
|
|
||||||
|
### Maintainer Checklist
|
||||||
|
|
||||||
|
When migrating more desktop integrations to capabilities:
|
||||||
|
|
||||||
|
- Prefer capability events and snapshots over `window.playSong`, `window.stopSong`,
|
||||||
|
and raw `song:*` events.
|
||||||
|
- Use `target.settingsKey` for local per-song plugin settings.
|
||||||
|
- Use `targetId` only for arrangement/session correlation, not persistent
|
||||||
|
per-song settings.
|
||||||
|
- Route effect-chain execution through `window.slopsmithDesktop.audioEffects`
|
||||||
|
rather than passing raw native preset JSON through plugin-visible capability
|
||||||
|
state.
|
||||||
|
- Keep raw filename fallback code behind a capability-version check.
|
||||||
|
- Add static migration guards under `tests/` for any removed global wrapper or
|
||||||
|
new capability declaration.
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
# Slopsmith plugin-sandbox — IPC + lifecycle design
|
||||||
|
|
||||||
|
Date: 2026-05-13
|
||||||
|
Companion to: `docs/VST-SANDBOX-DIAG.md`
|
||||||
|
Status: Windows v1 shipped; POSIX (macOS/Linux) IPC foundation + runtime +
|
||||||
|
editor wired and active (§11) — VST3 plugins route through the sandbox on all
|
||||||
|
three desktop platforms, editor included. The sandbox-child editor is a floating
|
||||||
|
top-level window on every platform (HWND / NSWindow + foreground activation
|
||||||
|
policy / X11 via JUCE 8's VST3 IRunLoop hosting). True parent-window embedding
|
||||||
|
(CARemoteLayer on macOS, XEmbed on Linux) remains out of scope.
|
||||||
|
|
||||||
|
## 1. Topology
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌────────────────────────────────────┐ ┌──────────────────────────────┐
|
||||||
|
│ Slopsmith Desktop (Electron main) │ │ slopsmith-vst-host.exe │
|
||||||
|
│ │ │ (one per sandboxed plugin) │
|
||||||
|
│ Node main process │ │ │
|
||||||
|
│ └── slopsmith_audio.node │ │ WinMain → main thread │
|
||||||
|
│ └── SignalChain │ │ └── JUCE MessageManager │
|
||||||
|
│ └── SandboxedProcessor ◀┐ │ │ └── one AudioPlugin │
|
||||||
|
│ ▲ │ │ │ └── editor (HWND) │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ control: │ │ pipe │ │
|
||||||
|
│ │ JSON over named ├──┼─────────┤ │
|
||||||
|
│ │ pipe (req/resp + │ │ │ │
|
||||||
|
│ │ events) │ │ │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ audio: shared │ │ shm │ │
|
||||||
|
│ │ memory ring + ├──┼─────────┤ │
|
||||||
|
│ │ Win32 events │ │ │ │
|
||||||
|
└────────────────────────────────────┘ └──────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
One sandbox process per sandboxed plugin (simplest; matches the PoC). Pooling is a v2
|
||||||
|
optimisation, not v1.
|
||||||
|
|
||||||
|
In-process loading remains the default. A plugin only goes through the sandbox if it
|
||||||
|
matches a denylist (see §5).
|
||||||
|
|
||||||
|
## 2. Process spawn + handshake
|
||||||
|
|
||||||
|
```text
|
||||||
|
slopsmith-vst-host.exe
|
||||||
|
--plugin-path "<absolute vst3 path>"
|
||||||
|
--control-pipe "\\.\pipe\slopsmith-vst-<uuid>"
|
||||||
|
--audio-shm "Local\slopsmith-vst-<uuid>-audio"
|
||||||
|
--audio-event-in "Local\slopsmith-vst-<uuid>-evt-in"
|
||||||
|
--audio-event-out "Local\slopsmith-vst-<uuid>-evt-out"
|
||||||
|
--sample-rate 48000 --max-block 1024 --channels 2
|
||||||
|
```
|
||||||
|
|
||||||
|
Spawned via `CreateProcess`. The main process creates the pipe + shm + events first,
|
||||||
|
then spawns; the sandbox connects on startup. Watchdog: if no `ready` event arrives
|
||||||
|
within `kReadyTimeoutMs` (30 s — long enough for Qt-using plugins like GR6 to
|
||||||
|
spin up their QML engine on a cold cache), kill and report failure.
|
||||||
|
|
||||||
|
`<uuid>` is a v4 UUID generated per spawn so multiple sandboxes coexist cleanly.
|
||||||
|
|
||||||
|
## 3. Control channel — named pipe
|
||||||
|
|
||||||
|
Transport: `PIPE_TYPE_BYTE | PIPE_READMODE_BYTE`, bidirectional, overlapped I/O,
|
||||||
|
with an explicit `[u32 length-LE][body]` framing layer the channel applies on
|
||||||
|
top. (Message-mode was the original plan, but the sandbox's first `ready` frame
|
||||||
|
wasn't being delivered to the host I/O thread reliably; byte mode + length
|
||||||
|
prefixes is what shipped — see commit `2cb9ae9`.)
|
||||||
|
|
||||||
|
Framing per message: `[u32 length-LE] [json body]`. JSON is small and human-readable
|
||||||
|
for logging; the audio fast path is *not* on this channel.
|
||||||
|
|
||||||
|
Every request from main→sandbox carries a `requestId`. The sandbox echoes it on the
|
||||||
|
matching reply. Events the sandbox originates (parameter automation, log lines)
|
||||||
|
carry `requestId: null`.
|
||||||
|
|
||||||
|
### Main → sandbox
|
||||||
|
|
||||||
|
| `op` | Status | Payload | Reply |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `prepare` | v1 | `{ sampleRate, blockSize }` | `{ ok, latencySamples, numInputs, numOutputs }` |
|
||||||
|
| `setBlockSize` | v2 | `{ blockSize }` | `{ ok }` — pause-guarded against the audio worker |
|
||||||
|
| `setParameter` | v1 | `{ index, value }` | `{ ok }` (omit reply if `fireAndForget: true`) |
|
||||||
|
| `getState` | v1 | `{}` | `{ stateBase64 }` — pause-guarded |
|
||||||
|
| `setState` | v1 | `{ stateBase64 }` | `{ ok }` — pause-guarded |
|
||||||
|
| `midiEvent` | removed v2 | (n/a) | MIDI now flows inline in the audio shm; sandbox keeps a deprecation-warning no-op handler for one release |
|
||||||
|
| `openEditor` | v1 | `{}` | `{ hwnd: "0x...", w, h }` |
|
||||||
|
| `closeEditor` | v1 | `{}` | `{ ok }` |
|
||||||
|
| `shutdown` | v1 | `{}` | `{ ok }` then sandbox exits 0 |
|
||||||
|
| `resizeEditor` | planned | `{ w, h }` | `{ ok }` |
|
||||||
|
| `listParameters` | planned | `{}` | `{ params: [{index,name,defaultValue,...}] }` |
|
||||||
|
|
||||||
|
Status reflects the current dispatcher in `src/vst-host/main.cpp`. "Planned"
|
||||||
|
ops are on the PR-body follow-up checklist. Pause-guarded ops are listed in
|
||||||
|
§4 "Audio-thread sync" below.
|
||||||
|
|
||||||
|
### Sandbox → main (events, `requestId: null`)
|
||||||
|
|
||||||
|
| `event` | Payload |
|
||||||
|
|---|---|
|
||||||
|
| `ready` | `{ pluginName, manufacturer, numParams, hasEditor, latencySamples }` (first message after pipe connect) |
|
||||||
|
| `parameterChanged` | `{ index, value }` — plugin moved its own knobs (automation, GUI) |
|
||||||
|
| `editorClosed` | `{ reason }` — user closed window via X, or plugin self-closed |
|
||||||
|
| `log` | `{ level, message }` — surface plugin stderr / JUCE asserts |
|
||||||
|
| `error` | `{ code, message }` — non-fatal recoverable error |
|
||||||
|
| `goodbye` | `{}` — last message before clean exit |
|
||||||
|
|
||||||
|
A broken pipe with no `goodbye` means the sandbox crashed.
|
||||||
|
|
||||||
|
## 4. Audio channel — shared memory + events
|
||||||
|
|
||||||
|
Audio is too latency-sensitive for JSON-on-pipes. One block at 48 k / 256 samples is
|
||||||
|
5.33 ms; we want round-trip overhead well under 1 ms.
|
||||||
|
|
||||||
|
Layout in `audio-shm` (single mapping):
|
||||||
|
|
||||||
|
```text
|
||||||
|
offset size contents
|
||||||
|
0 sizeof(Header) Header (indices, offsets, counters)
|
||||||
|
inputRingOffset maxBlocks × maxBlockSamples × maxCh × 4 B Ring A (host → sandbox, input audio)
|
||||||
|
outputRingOffset maxBlocks × maxBlockSamples × maxCh × 4 B Ring B (sandbox → host, output audio)
|
||||||
|
midiQueueOffset maxBlocks × sizeof(MidiQueue) One MidiQueue per input slot (host → sandbox MIDI)
|
||||||
|
```
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct Header {
|
||||||
|
uint32_t magic; // kAudioShmMagic
|
||||||
|
uint32_t version; // kProtocolVersion (= 3)
|
||||||
|
uint32_t maxBlocks; // typically 4
|
||||||
|
uint32_t maxBlockSamples; // capped at e.g. 1024
|
||||||
|
uint32_t maxChannels; // 2 for stereo
|
||||||
|
uint32_t sampleRate;
|
||||||
|
// Per-direction indices — input (host→sandbox) and output (sandbox→host)
|
||||||
|
// each have their own writer/reader pair so the two directions advance
|
||||||
|
// independently without sharing state.
|
||||||
|
uint64_t inWriteIdx; // host produces ring A
|
||||||
|
uint64_t inReadIdx; // sandbox consumes ring A
|
||||||
|
uint64_t outWriteIdx; // sandbox produces ring B
|
||||||
|
uint64_t outReadIdx; // host consumes ring B
|
||||||
|
// diagnostic — direction-agnostic
|
||||||
|
uint64_t xruns;
|
||||||
|
uint64_t dropouts;
|
||||||
|
uint64_t midiOverflows; // events dropped by pushInputBlock for being SysEx-sized or past kMidiEventsPerSlot
|
||||||
|
// Byte offsets into the mapping (computed at spawn time)
|
||||||
|
uint64_t inputRingOffset;
|
||||||
|
uint64_t outputRingOffset;
|
||||||
|
uint64_t midiQueueOffset; // base of MidiQueue[maxBlocks]
|
||||||
|
uint64_t ringBytesPerSlot;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Indices are stored as plain `uint64_t` and accessed via `std::atomic_ref<uint64_t>`
|
||||||
|
at the call site (C++20). Don't `reinterpret_cast` to `std::atomic<uint64_t>*`
|
||||||
|
— that's not layout-guaranteed and the shm needs to stay trivially copyable.
|
||||||
|
|
||||||
|
Float32, planar (channel0 then channel1 — matches JUCE's `AudioBuffer<float>`).
|
||||||
|
|
||||||
|
### Inline MIDI
|
||||||
|
|
||||||
|
MIDI is bundled with the input audio block in a per-slot `MidiQueue`. The host
|
||||||
|
fills the upcoming slot's queue immediately before pushing the audio block; the
|
||||||
|
sandbox drains it immediately after popping the same slot, before calling
|
||||||
|
`plugin->processBlock`. The audio thread does no control-pipe I/O.
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct MidiEvent {
|
||||||
|
uint32_t frame; // sample offset within the block
|
||||||
|
uint32_t size; // 1..kMidiEventMaxBytes
|
||||||
|
uint8_t bytes[kMidiEventMaxBytes]; // packed; SysEx > 4 B is dropped
|
||||||
|
};
|
||||||
|
struct MidiQueue {
|
||||||
|
uint32_t count; // valid events for the upcoming block
|
||||||
|
MidiEvent events[kMidiEventsPerSlot]; // 64 events @ ≤ 4 bytes each
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Caps in `Protocol.h`: `kMidiEventMaxBytes = 4`, `kMidiEventsPerSlot = 64`.
|
||||||
|
Lossy-by-design: events past the cap (or larger than 4 bytes) bump the global
|
||||||
|
`AudioShmHeader.midiOverflows` counter and are dropped. Audio-thread safety is non-negotiable;
|
||||||
|
back-pressure on a real-time path would be the wrong trade-off. SysEx delivery,
|
||||||
|
if a real workload ever needs it, is a v3 op carried via the control channel
|
||||||
|
rather than the audio fast path.
|
||||||
|
|
||||||
|
### Per-block protocol
|
||||||
|
|
||||||
|
Host audio thread:
|
||||||
|
```text
|
||||||
|
1. Wait until (inWriteIdx - inReadIdx) < maxBlocks (drop block + bump xruns if not)
|
||||||
|
2. Copy input PCM + any MIDI to Ring A[inWriteIdx % maxBlocks]
|
||||||
|
3. ++inWriteIdx (release)
|
||||||
|
4. SetEvent(audio-event-in)
|
||||||
|
5. WaitForSingleObject(audio-event-out, timeout = blockSize / sampleRate * 2)
|
||||||
|
6. Copy Ring B[outReadIdx % maxBlocks] into output buffer
|
||||||
|
7. ++outReadIdx (release)
|
||||||
|
```
|
||||||
|
|
||||||
|
Sandbox audio thread (or sandbox main thread's audio callback):
|
||||||
|
```text
|
||||||
|
1. WaitForSingleObject(audio-event-in, INFINITE)
|
||||||
|
2. Read input from Ring A[inReadIdx % maxBlocks]
|
||||||
|
3. processBlock(in, out) on the plugin
|
||||||
|
4. Write output to Ring B[outWriteIdx % maxBlocks]
|
||||||
|
5. ++inReadIdx (release); ++outWriteIdx (release); SetEvent(audio-event-out)
|
||||||
|
```
|
||||||
|
|
||||||
|
Both events are auto-reset. Worst-case added latency vs in-process: one block period
|
||||||
|
(~5 ms at 48k/256) due to the producer-consumer hop. Acceptable for guitar processing,
|
||||||
|
not great for live monitoring — same trade-off any sandboxed host has.
|
||||||
|
|
||||||
|
### Audio-thread sync for non-realtime ops
|
||||||
|
|
||||||
|
Several control ops mutate plugin or buffer state in ways that race the audio
|
||||||
|
thread's `processBlock` call: `kPrepare` and `kSetBlockSize` change the working
|
||||||
|
block size and re-enter the plugin's `prepareToPlay`; `kGetState` /
|
||||||
|
`kSetState` serialise/restore plugin internals. A v1-style "just touch it from
|
||||||
|
the control thread" implementation is a data race + buffer-overrun footgun.
|
||||||
|
|
||||||
|
v2 introduces a lightweight pause/drain/resume protocol on the sandbox side:
|
||||||
|
|
||||||
|
- HostState owns `audioPauseRequested` (atomic bool), `audioPausedAck`, and
|
||||||
|
`audioResume` (`juce::WaitableEvent`s).
|
||||||
|
- The audio thread checks `audioPauseRequested` at the top of every loop
|
||||||
|
iteration. When set, it signals `audioPausedAck` and blocks on `audioResume`.
|
||||||
|
On resume it re-reads `blockSize` and `setSize`s its working buffer
|
||||||
|
(capacity is pre-allocated at the spawn-time `maxBlockSamples` cap, so the
|
||||||
|
resize is reallocation-free).
|
||||||
|
- The control thread wraps each non-realtime op in an `AudioPauseGuard`:
|
||||||
|
set the flag, `signalSandboxWake()` to break the audio worker out of its
|
||||||
|
`popInputBlock` wait without waiting the full 200 ms timeout, wait for the
|
||||||
|
ack, perform the op, then signal resume in the guard's destructor.
|
||||||
|
|
||||||
|
Pause-guarded ops (sandbox dispatcher, `src/vst-host/main.cpp`):
|
||||||
|
`kPrepare`, `kSetBlockSize`, `kGetState`, `kSetState`. `kOpenEditor` and
|
||||||
|
`kCloseEditor` do not need the guard — they only mutate editor pointers via
|
||||||
|
`MessageManager::callAsync` and don't touch processor state, and JUCE's
|
||||||
|
`AudioProcessor::editorBeingDeleted` / VST3 `IPlugView::removed` are
|
||||||
|
contractually safe to call alongside `processBlock` (the same way every DAW
|
||||||
|
does).
|
||||||
|
|
||||||
|
## 5. Plugin selection (sandbox vs in-process)
|
||||||
|
|
||||||
|
Slopsmith maintains a list of plugin signatures that need the sandbox:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// %APPDATA%/Slopsmith/sandbox-list.json
|
||||||
|
{
|
||||||
|
"needsSandbox": [
|
||||||
|
{ "match": "manufacturer", "value": "Native Instruments" },
|
||||||
|
{ "match": "vst3Uid", "value": "4E545356-24696752-..." },
|
||||||
|
{ "match": "linksDll", "value": "Qt5Core.dll" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolution order on `loadVST`:
|
||||||
|
1. If plugin matches an entry → spawn sandbox.
|
||||||
|
2. Else → in-process (today's path).
|
||||||
|
3. If in-process load aborts the addon (`SIGABRT`, `STATUS_STACK_BUFFER_OVERRUN`, …),
|
||||||
|
the watchdog promotes the plugin's UID into the list automatically, with a
|
||||||
|
`learned-from-crash: true` flag, so the next load is sandboxed.
|
||||||
|
|
||||||
|
`linksDll` matching needs a quick prescan: open the vst3 file, walk its PE import
|
||||||
|
table. Cached in `%LOCALAPPDATA%\Slopsmith\plugin-deps.json`.
|
||||||
|
|
||||||
|
## 6. Window reparenting into Electron
|
||||||
|
|
||||||
|
1. Sandbox creates its editor in its own top-level window (the PoC's `EditorWindow`)
|
||||||
|
— but with `WS_POPUP` style instead of an overlapped frame so it has no border.
|
||||||
|
2. Sandbox sends `editorOpened { hwnd, w, h }` to main.
|
||||||
|
3. Renderer asks Electron for its `BrowserWindow.getNativeWindowHandle()`. From the
|
||||||
|
renderer, a placeholder `<div>` in the plugin chain's UI has a known position and
|
||||||
|
size; the main process reads its bounds via IPC from the renderer.
|
||||||
|
4. Main process calls
|
||||||
|
- `SetWindowLongPtrW(pluginHwnd, GWL_STYLE, (style | WS_CHILD) & ~WS_POPUP)`
|
||||||
|
- `SetParent(pluginHwnd, electronHwnd)`
|
||||||
|
- `SetWindowPos(pluginHwnd, NULL, placeholderX, placeholderY, w, h, SWP_NOZORDER | SWP_FRAMECHANGED)`
|
||||||
|
5. On placeholder resize/move (renderer → main IPC), main `SetWindowPos`'s and also
|
||||||
|
sends `resizeEditor { w, h }` to the sandbox so the plugin re-lays out.
|
||||||
|
6. On `closeEditor`, main `SetParent(pluginHwnd, NULL)` first (un-embeds so the
|
||||||
|
sandbox can DestroyWindow cleanly), then sends `closeEditor`.
|
||||||
|
|
||||||
|
Edge cases:
|
||||||
|
- DPI: the sandbox enables per-monitor DPI awareness; sandbox and Electron must agree.
|
||||||
|
Use `SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)`.
|
||||||
|
- Floating mode (user preference): skip steps 3–4; just send the HWND back informatively.
|
||||||
|
- Focus: WM_ACTIVATE inside the embedded child can confuse Electron's accelerator
|
||||||
|
routing — likely needs a focus-shim subclassed window between Electron and the plugin.
|
||||||
|
|
||||||
|
## 7. Crash + restart
|
||||||
|
|
||||||
|
States that need to survive a sandbox crash:
|
||||||
|
- The plugin's parameter values (Slopsmith caches these as the user changes them, so
|
||||||
|
free)
|
||||||
|
- The plugin's opaque state blob (Slopsmith calls `getState` after every "stable"
|
||||||
|
change — patch load, preset switch — and caches it)
|
||||||
|
- The signal-chain position (already in `SignalChain`, not in the sandbox)
|
||||||
|
|
||||||
|
Restart flow:
|
||||||
|
1. Main detects broken pipe → marks slot as crashed; the SignalChain inserts a silent
|
||||||
|
passthrough for the slot so audio keeps flowing.
|
||||||
|
2. UI shows "plugin crashed — retry" on the slot.
|
||||||
|
3. On retry (auto after 1 s for the first crash; manual for subsequent), spawn a
|
||||||
|
fresh `slopsmith-vst-host.exe`.
|
||||||
|
4. After `ready`, replay: `prepare` → `setState` (last cached blob) → editor reopen
|
||||||
|
if it was open.
|
||||||
|
|
||||||
|
Crash loop detection: if same plugin crashes 3× within 60 s, stop auto-retrying and
|
||||||
|
require a manual restart from the UI.
|
||||||
|
|
||||||
|
## 8. Build / repo layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
slopsmith-desktop/
|
||||||
|
├── src/
|
||||||
|
│ ├── audio/
|
||||||
|
│ │ ├── NodeAddon.cpp (existing — selects sandbox vs in-process at LoadVST)
|
||||||
|
│ │ ├── VSTHost.cpp (existing — used both in-process and inside the sandbox)
|
||||||
|
│ │ └── Sandbox/
|
||||||
|
│ │ ├── Protocol.{h,cpp} (wire protocol — ops, events, encoding)
|
||||||
|
│ │ ├── ControlChannel.{h,cpp} (named-pipe request/response + sandbox-event dispatch)
|
||||||
|
│ │ ├── AudioChannel.{h,cpp} (shared-memory ring + Win32 events for the audio path)
|
||||||
|
│ │ ├── SubprocessHandle.{h,cpp} (sandbox process lifecycle: CreateProcessW, watcher, shutdown)
|
||||||
|
│ │ ├── SandboxedProcessor.{h,cpp} (juce::AudioProcessor that forwards into the sandbox)
|
||||||
|
│ │ ├── SandboxFactory_win.cpp (Windows: shouldSandbox() + tryLoadSandboxed())
|
||||||
|
│ │ └── SandboxFactory_stub.cpp (non-Windows fallback — always returns nullptr)
|
||||||
|
│ └── vst-host/ (sandbox subprocess)
|
||||||
|
│ ├── main.cpp (WinMain + JUCE main-thread message pump)
|
||||||
|
│ └── CMakeLists.txt (target: slopsmith-vst-host.exe)
|
||||||
|
└── CMakeLists.txt (top-level — adds the addon + host targets)
|
||||||
|
```
|
||||||
|
|
||||||
|
`slopsmith-vst-host.exe` ships in the Electron app's `resources/` and is launched from
|
||||||
|
`SandboxedProcessor::initialise()` via `SandboxFactory_win::resolveSandboxExe()`.
|
||||||
|
|
||||||
|
## 9. Out of scope for v1
|
||||||
|
|
||||||
|
- Cross-platform sandbox (macOS NSView/XPC, Linux X11-embed). v1 is Windows-only.
|
||||||
|
- AU/LV2 sandboxing. Most LV2 plugins are well-behaved in-process; reconsider if a
|
||||||
|
specific plugin proves otherwise.
|
||||||
|
- Sandbox pooling (multiple plugins per process). Worth it for memory if a user
|
||||||
|
loads 10+ NI plugins; not v1.
|
||||||
|
- Sample-accurate parameter automation across the IPC boundary. v1 sends parameter
|
||||||
|
changes through the control channel with whatever latency that gives (~ms).
|
||||||
|
v2 can co-opt the audio shm to embed parameter events per-block.
|
||||||
|
- Editor-side input redirection (keyboard for VST3 `IPlugViewContentScaleSupport`).
|
||||||
|
Probably mostly just works through the reparented HWND.
|
||||||
|
|
||||||
|
## 10. Estimate
|
||||||
|
|
||||||
|
Wall clock for a single engineer, assuming the PoC's foundations:
|
||||||
|
|
||||||
|
| Piece | Effort |
|
||||||
|
|---|---|
|
||||||
|
| `slopsmith-vst-host.exe` skeleton (extend the PoC) | 1–2 d |
|
||||||
|
| Control channel (pipe + JSON + 12 message types) | 2–3 d |
|
||||||
|
| Audio channel (shm + events + ring) | 2–3 d |
|
||||||
|
| `SandboxedProcessor` glue inside the addon | 2 d |
|
||||||
|
| Detection list + denylist promotion | 1 d |
|
||||||
|
| Editor reparenting into Electron | 2–4 d (focus + DPI is fiddly) |
|
||||||
|
| Crash detection + restart + state cache | 2 d |
|
||||||
|
| QA pass on the top-10 NI plugins + iterating on weird behaviours | 3–5 d |
|
||||||
|
| **Total** | **~15–22 working days** |
|
||||||
|
|
||||||
|
Roughly 3–4 calendar weeks, in line with the diag report's original estimate, with
|
||||||
|
none of it spent fighting Qt.
|
||||||
|
|
||||||
|
## 11. POSIX backend (macOS / Linux) — IPC foundation
|
||||||
|
|
||||||
|
Tracked by issue #264 (macOS port). The IPC layer is split into a platform-neutral
|
||||||
|
core (`*_shared.cpp`: the lock-free ring algorithm, the request/reply/dispatch loop)
|
||||||
|
plus per-OS backends (`*_win.cpp` / `*_posix.cpp`) selected in CMake, with the private
|
||||||
|
`Impl` struct in `*Impl.h`. The Win32 primitives map to POSIX as follows — the choices
|
||||||
|
are driven by what is simultaneously *implemented on macOS*, *crash-safe*, and *tolerant
|
||||||
|
of coalesced wakeups*:
|
||||||
|
|
||||||
|
| Win32 | POSIX | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| Named pipe + overlapped I/O + `WaitForMultipleObjects(io, stopEvent)` | `socketpair(AF_UNIX, SOCK_STREAM)` + `poll()` + self-pipe stop | UDS has identical stream/partial-read semantics, so the `[u32-LE][body]` framing carries over unchanged; the self-pipe is the manual-reset stopEvent (one never-drained byte). fd-passed, not named → dodges the macOS `sun_path` 104-char limit and leaves no socket file to leak. |
|
||||||
|
| Named file-mapping shm (`CreateFileMappingW` + name) | `shm_open(O_CREAT\|O_EXCL)` + `ftruncate` + **immediate `shm_unlink`**, fd-passed | Anonymous after unlink (fd keeps it alive) → no `/dev/shm` leak on crash, and the macOS 31-char shm-name limit is irrelevant past creation. `fstat().st_size` replaces `VirtualQuery` for the size-bounds check. |
|
||||||
|
| Named auto-reset events (`CreateEventW`/`SetEvent`/`WaitForSingleObject`) | **socketpair doorbell** (write 1 byte = signal; `poll` + drain = wait) | The *only* cross-process option that is (a) implemented on macOS — `sem_init` is ENOSYS there; (b) crash-safe — a process-shared pthread mutex has no robust-mutex support on macOS, so a producer crash would deadlock the consumer, whereas a dead socketpair peer surfaces as `POLLHUP`; (c) tolerant of coalesced signals — the ring consumers re-read the atomic index on wake exactly as on the Win32 auto-reset path. |
|
||||||
|
| `CreateProcessW`, `bInheritHandles=FALSE` | `posix_spawn` + `POSIX_SPAWN_CLOEXEC_DEFAULT` + `file_actions_adddup2` | `posix_spawn` (never a bare fork — the host has touched CoreAudio/Obj-C and fork-without-exec aborts in CF). `CLOEXEC_DEFAULT` (macOS) is the analog of `bInheritHandles=FALSE`: only the dup2()'d channel/doorbell/shm fds reach the child. Linux lacks the flag; the host keeps its fds CLOEXEC instead. |
|
||||||
|
| `WaitForSingleObject(hProcess)` watcher | blocking `waitpid` in a thread | No global SIGCHLD handler (it would fight libuv's child reaping inside Electron); ECHILD (someone else reaped) is tolerated. `kqueue`/`EVFILT_PROC` is the documented fallback if ECHILD proves common. |
|
||||||
|
| `PostThreadMessageW(WM_QUIT)` → `TerminateProcess` | `shutdown` control op → `SIGTERM` → `SIGKILL` | |
|
||||||
|
|
||||||
|
Trap handled in slice 1: writing to a socket whose peer has closed raises **SIGPIPE**
|
||||||
|
(kills the process by default) where Windows merely returns an error — every send uses
|
||||||
|
`MSG_NOSIGNAL` (Linux) and the fd carries `SO_NOSIGPIPE` (macOS), so a dead peer surfaces
|
||||||
|
as `EPIPE`, never a signal.
|
||||||
|
|
||||||
|
The POSIX backends compile on both macOS and Linux (shared primitives) and are verified
|
||||||
|
by `tests/sandbox/` — an in-process audio-ring loopback (incl. a contended threaded
|
||||||
|
producer/consumer over the doorbell), an in-process control-channel loopback, a real
|
||||||
|
`posix_spawn` smoke test (handshake, fd inheritance, clean exit, crash detection, SIGPIPE
|
||||||
|
suppression), and a full **end-to-end** harness (`tests/sandbox/e2e/`) that spawns the
|
||||||
|
real `slopsmith-vst-host`, loads a passthrough VST3, and round-trips paced audio over the
|
||||||
|
shm ring with state + clean shutdown. The unit tests run plain + ASan + TSan and the e2e
|
||||||
|
runs on `ubuntu-22.04` + `macos-14` in `.github/workflows/sandbox.yml`; the TSan run on the
|
||||||
|
threaded audio loopback validates the release/acquire ring ordering on arm64.
|
||||||
|
|
||||||
|
Runtime wiring: `SandboxFactory_posix` + `SandboxedProcessor` are compiled into
|
||||||
|
`slopsmith_audio.node` on macOS/Linux, and `slopsmith-vst-host` is built by the same
|
||||||
|
cmake-js invocation + co-located with the addon (and bundled via `package.json`), so VST3
|
||||||
|
plugins route through the sandbox on all three platforms.
|
||||||
|
|
||||||
|
A subtlety the e2e surfaced: `signalSandboxWake()` must wake the sandbox's *own* audio
|
||||||
|
worker, but a write to the bidirectional doorbell socketpair goes to the *peer*. The POSIX
|
||||||
|
backend uses a dedicated per-side self-wake pipe for it (`waitEvent` polls the socketpair
|
||||||
|
*and* the self-pipe); Windows is unaffected (separate auto-reset events per direction).
|
||||||
|
|
||||||
|
Editor (Slice 3): the sandbox child owns a floating top-level editor window (Reaper-style —
|
||||||
|
the host never reparents it, it only tracks the open/closed bit). On macOS the child calls
|
||||||
|
`juce::Process::makeForegroundProcess()` before showing the window so a `posix_spawn`'d
|
||||||
|
executable (default background activation policy) can show + focus its `NSWindow`; NodeAddon's
|
||||||
|
editor open/close IPC paths are widened from `#if JUCE_WINDOWS` to all platforms. The
|
||||||
|
open/close protocol is asserted by the e2e on macOS + Linux CI; visual focus/DPI is the
|
||||||
|
irreducible headless blind spot (manual on real hardware).
|
||||||
|
|
||||||
|
**Linux** (issue #265) hosts the editor on the same top-level-window model via JUCE 8's VST3
|
||||||
|
editor hosting (`Steinberg::Linux::IRunLoop` integrated with the child's `MessageManager` X11
|
||||||
|
event loop); `getWindowHandle()` returns the X11 `Window`. One Linux-specific gotcha: JUCE only
|
||||||
|
installs its non-fatal X11 error handlers (and calls `XInitThreads`) for *standalone
|
||||||
|
JUCEApplications* — `XWindowSystem`'s ctor gates both on `isStandaloneApp()`. This sandbox
|
||||||
|
child is not a `JUCEApplication` (bare `main()` + `ScopedJuceInitialiser_GUI`), so Xlib's
|
||||||
|
DEFAULT handler stays active and `exit()`s the child on any protocol error — e.g. a benign
|
||||||
|
`BadAtom` from JUCE querying `_NET_WM_STATE` on a window-manager-less server. The child
|
||||||
|
therefore installs its own non-fatal handler at startup (`installLinuxX11Safety` in
|
||||||
|
`src/vst-host/main.cpp`), mirroring the SIGPIPE suppression and the `createEditor` exception
|
||||||
|
containment elsewhere. `slopsmith-vst-host` now link-time depends on `libX11` for those direct
|
||||||
|
`XInitThreads`/`XSetErrorHandler` calls (JUCE otherwise `dlopen`s it). Verified on the e2e
|
||||||
|
across bare Xvfb (CI), and manually on KWin/Xwayland; a non-EWMH WM (twm) fails the editor open
|
||||||
|
*cleanly* (10s reply timeout, no crash) rather than hanging the audio path. True cross-process
|
||||||
|
embedding (CARemoteLayer / Mach-port on macOS, XEmbed on Linux) remains future work.
|
||||||
|
|
||||||
|
Orphan cleanup (Linux): a crashed host already triggers child teardown via the control-socket
|
||||||
|
disconnect callback, but that runs on the message thread (`callAsync`) — which a wedged plugin
|
||||||
|
(the sandbox's raison d'être) can block. As a kernel-level backstop the child sets
|
||||||
|
`prctl(PR_SET_PDEATHSIG, SIGTERM)` at startup (`installLinuxParentDeathSignal`), so the OS
|
||||||
|
reaps it on host death regardless of wedged threads. Covered by the `sandbox_e2e_leak` test
|
||||||
|
(`tests/sandbox/e2e/leak_test.sh`): the driver crashes without a clean shutdown and asserts the
|
||||||
|
child is gone. macOS equivalent (kqueue `EVFILT_PROC`) is future work.
|
||||||
|
|
||||||
|
Packaging (Linux): electron-builder's `linux.files` + `linux.asarUnpack` ship `slopsmith-vst-host`
|
||||||
|
into the **same** `app.asar.unpacked/build/Release/` directory as `slopsmith_audio.node`, so
|
||||||
|
`resolveSandboxExe()` (dladdr on the addon → parent dir → `slopsmith-vst-host`) resolves in both
|
||||||
|
AppImage and `.deb`. The exec bit survives the unpack, and the editor's X libraries (libX11 plus
|
||||||
|
the libXext/libXrandr/libXcursor/… JUCE `dlopen`s) are a subset of Electron/Chromium's own runtime
|
||||||
|
deps, so no extra `deb.depends` is required. Verified with an `electron-builder --linux dir` pack:
|
||||||
|
the e2e drives the *packaged* host binary end-to-end (spawn → audio → state → editor → shutdown).
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# GR6 fast-fail — PoC results + revised diagnosis
|
||||||
|
|
||||||
|
Date: 2026-05-13
|
||||||
|
Branch: `diag/vst-trace` (unchanged) + new scratch dir `C:\Users\byron\vst-poc\` on Win11 VM
|
||||||
|
PoC binary: `C:\Users\byron\vst-poc\build\Release\vst_poc.exe`
|
||||||
|
|
||||||
|
## TL;DR — the original diagnosis was half right
|
||||||
|
|
||||||
|
The original `DIAG-REPORT.md` proposed Fix Path 1 ("sandboxed plugin-host process with
|
||||||
|
QApplication"). The PoC confirms **the sandboxed-process direction is correct**, but
|
||||||
|
**QApplication is not needed at all** — the entire root cause is JUCE's message thread
|
||||||
|
not being the OS main thread.
|
||||||
|
|
||||||
|
| Mode | Process | JUCE MessageManager thread | QApplication? | GR6 editor opens? |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Baseline | `node load-gr6.js` | JUCE-internal (TID ≠ main) | no | **crashes** in 1–2 s with `0xC0000409` |
|
||||||
|
| PoC #1 | `vst_poc.exe` | OS main thread (TID == main) | yes (Qt 5.15.2) | **clean**, editor 1110×780, runs 10 s, exit 0 |
|
||||||
|
| PoC #2 | `vst_poc.exe POC_NO_QAPP=1` | OS main thread (TID == main) | **no** | **clean**, editor 1110×780, runs 10 s, exit 0 |
|
||||||
|
|
||||||
|
PoC #2 is the load-bearing experiment: dropping QApplication entirely still works as long
|
||||||
|
as JUCE owns the OS main thread. The "no QCoreApplication instance" Qt warnings the
|
||||||
|
original report flagged don't even fire in PoC #2 — GR6's static-linked Qt5 is happy as
|
||||||
|
long as the calling thread has the OS-main-thread properties it expects.
|
||||||
|
|
||||||
|
## How the PoC reproduces / refutes the bug
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// main.cpp (essentials)
|
||||||
|
int WINAPI WinMain(HINSTANCE, HINSTANCE, LPSTR, int) {
|
||||||
|
// OPTIONAL — POC_NO_QAPP env disables this leg. Same outcome either way.
|
||||||
|
QApplication qapp(qArgc, qArgv);
|
||||||
|
|
||||||
|
juce::ScopedJuceInitialiser_GUI juceInit; // (a) MessageManager bound to TID=WinMain
|
||||||
|
VSTHost host;
|
||||||
|
auto plugin = host.loadPlugin(GR6_PATH, 48000.0, 256, err);
|
||||||
|
auto editor = plugin->createEditor(); // (b) runs on the same TID
|
||||||
|
editor->setSize(1000, 600);
|
||||||
|
EditorWindow window(...); window.setVisible(true);
|
||||||
|
|
||||||
|
while (alive) { // (c) message pump on main thread
|
||||||
|
juce::MessageManager::getInstance()->runDispatchLoopUntil(50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The three pieces above are necessary and sufficient. Compare to today's Slopsmith addon:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// src/audio/NodeAddon.cpp::OpenPluginEditor
|
||||||
|
juce::MessageManager::callAsync([processor, ...]() {
|
||||||
|
auto* editor = processor->createEditor(); // runs on JUCE's worker thread,
|
||||||
|
... // which is NOT the V8/main thread
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Inside a Node native addon, V8 owns the OS main thread; JUCE silently spins up its own
|
||||||
|
"message thread" via `MessageManager::startup()` to give itself a place to run UI work.
|
||||||
|
That thread has a Win32 message queue but it is not a `QThread` and is not the OS main
|
||||||
|
thread. GR6's embedded Qt5 starts timers and connects QML signals on whatever thread
|
||||||
|
created its editor, hits Qt's thread-affinity guards (`Timers can only be used with
|
||||||
|
threads started with QThread`, `Illegal attempt to connect ... different thread than the
|
||||||
|
QML engine`), then `__fastfail`s.
|
||||||
|
|
||||||
|
## Revised recommended fix
|
||||||
|
|
||||||
|
`slopsmith-vst-host.exe` — a sandbox-process plugin host. **Same shape as the PoC,
|
||||||
|
without the Qt link.** Concretely:
|
||||||
|
|
||||||
|
1. **Subprocess binary**: standalone Win32 exe with `WinMain`. Loads exactly one VST3 via
|
||||||
|
the existing JUCE `VSTHost`. Runs JUCE's MessageManager on its main thread, opens the
|
||||||
|
editor on its main thread, owns the editor HWND.
|
||||||
|
2. **No QApplication, no Qt link**: PoC #2 proves it isn't needed for GR6. Drop the
|
||||||
|
~50 MB of Qt deps the original report assumed.
|
||||||
|
3. **IPC for audio + control**: shared-memory ring buffer for audio, named pipe for
|
||||||
|
control messages (load, setParameter, openEditor, etc.). Same surface as the current
|
||||||
|
in-process `VSTHost`, so `SignalChain` doesn't have to know.
|
||||||
|
4. **Detection**: only route through the sandbox for plugins that fail in-process.
|
||||||
|
The PoC plus the Qt warnings in the JUCE-thread case strongly suggest any Qt-using
|
||||||
|
plugin (Reaktor 6, Massive X, Komplete Kontrol, Battery 4, etc.) has the same issue;
|
||||||
|
non-Qt plugins (most JUCE-based ones) are fine in-process.
|
||||||
|
|
||||||
|
## What is dropped vs. the original plan
|
||||||
|
|
||||||
|
- **Qt dependency entirely.** ~2 weeks of "make Qt cohabit with JUCE" work doesn't happen
|
||||||
|
because we don't need Qt.
|
||||||
|
- The "Qt main loop must own the message pump" anxiety in the original §"Why fixing this
|
||||||
|
in-process is hard" doesn't apply — Win32 message pump (drained by JUCE's
|
||||||
|
`runDispatchLoopUntil`) is enough.
|
||||||
|
- The original cost estimate of ~2 weeks for single-plugin sandbox / ~1 month for pooled
|
||||||
|
is still roughly right, dominated by the IPC layer, not Qt.
|
||||||
|
|
||||||
|
## Followups
|
||||||
|
|
||||||
|
- **Editor window reparenting**: the production sandbox needs to reparent the plugin's
|
||||||
|
HWND into the Electron renderer (or pop a separate top-level window owned by Electron).
|
||||||
|
Standard pattern — `SetParent` from the sandbox after sending the HWND over IPC.
|
||||||
|
- **Audio latency budget**: one extra IPC hop adds ~1–2 ms at 48 kHz/256 frames. Fine
|
||||||
|
for guitar processing; reconsider for low-latency monitoring. Use a shared-memory ring
|
||||||
|
+ futex-style signalling (Windows events) to avoid syscall round-trips per buffer.
|
||||||
|
- **Crash isolation**: a plugin in the sandbox crashing only takes down its own process;
|
||||||
|
Slopsmith Desktop can detect, log, restart. Bigger win than just fixing GR6.
|
||||||
|
|
||||||
|
## Reproducing the baseline crash
|
||||||
|
|
||||||
|
The in-process load that motivated this work is reproduced via the
|
||||||
|
existing smoke harness on a Windows host with GR6 installed:
|
||||||
|
|
||||||
|
```bat
|
||||||
|
cd <slopsmith-desktop checkout>
|
||||||
|
node scripts\dev\load-gr6.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Before this PR's sandbox patch, that command crashed with
|
||||||
|
`STATUS_STACK_BUFFER_OVERRUN (0xC0000409)` from the Qt5 GR6 path. The
|
||||||
|
single-file PoC that confirmed the QApplication-on-message-thread fix
|
||||||
|
lived under a developer-local scratch tree during investigation; the
|
||||||
|
findings are summarised above and the production fix is the
|
||||||
|
out-of-process sandbox in this PR. There is no reproducible artefact to
|
||||||
|
ship for the PoC — the experiment is fully captured by the diagnostic
|
||||||
|
narrative above.
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
{
|
||||||
|
"name": "slopsmith-desktop",
|
||||||
|
"version": "0.2.9",
|
||||||
|
"description": "Slopsmith desktop app with integrated audio engine, VST hosting, and amp modeling",
|
||||||
|
"author": {
|
||||||
|
"name": "Byron",
|
||||||
|
"email": "byron@ougsoft.com"
|
||||||
|
},
|
||||||
|
"license": "AGPL-3.0-only",
|
||||||
|
"main": "dist/main/main.js",
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"build:ts": "tsc && node -e \"const fs=require('fs'),path=require('path');fs.copyFileSync('src/main/splash.html','dist/main/splash.html');fs.copyFileSync('src/main/spinner.json','dist/main/spinner.json');fs.copyFileSync('node_modules/lottie-web/build/player/lottie.min.js','dist/main/lottie.min.js');const dst='dist/main/images';if(fs.existsSync(dst))fs.rmSync(dst,{recursive:true});fs.mkdirSync(dst,{recursive:true});fs.readdirSync('src/main/images',{withFileTypes:true}).filter(d=>d.isFile()&&d.name.endsWith('.webp')).forEach(d=>fs.copyFileSync(path.join('src/main/images',d.name),path.join(dst,d.name)))\"",
|
||||||
|
"build:audio": "bash scripts/build-audio.sh Release",
|
||||||
|
"build:audio:debug": "bash scripts/build-audio.sh Debug",
|
||||||
|
"rebuild:audio": "rm -rf build && bash scripts/build-audio.sh Release",
|
||||||
|
"build:native": "npm run build:audio",
|
||||||
|
"bundle:slopsmith": "bash scripts/bundle-slopsmith.sh",
|
||||||
|
"bundle:python": "bash scripts/bundle-python.sh",
|
||||||
|
"bundle:binaries": "bash scripts/bundle-binaries.sh",
|
||||||
|
"bundle:soundfont": "bash scripts/bundle-soundfont.sh",
|
||||||
|
"bundle": "bash scripts/bundle.sh",
|
||||||
|
"start": "npm run build:ts && electron .",
|
||||||
|
"dev": "npm run build:ts && electron .",
|
||||||
|
"dist": "npm run build:native && npm run bundle && npm run build:ts && electron-builder",
|
||||||
|
"dist:linux": "npm run dist -- --linux",
|
||||||
|
"dist:mac": "npm run dist -- --mac",
|
||||||
|
"dist:win": "npm run dist -- --win",
|
||||||
|
"postinstall": "electron-builder install-app-deps"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "com.byron.slopsmith-desktop",
|
||||||
|
"productName": "Slopsmith",
|
||||||
|
"directories": {
|
||||||
|
"output": "release"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist/**/*",
|
||||||
|
"build/Release/*.node",
|
||||||
|
"build/Release/libonnxruntime*",
|
||||||
|
"build/Release/onnxruntime*.dll"
|
||||||
|
],
|
||||||
|
"extraResources": [
|
||||||
|
{
|
||||||
|
"from": "LICENSE",
|
||||||
|
"to": "LICENSE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "THIRD_PARTY_LICENSES.md",
|
||||||
|
"to": "THIRD_PARTY_LICENSES.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "README.md",
|
||||||
|
"to": "README.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "resources/slopsmith",
|
||||||
|
"to": "slopsmith"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "resources/python",
|
||||||
|
"to": "python"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "resources/bin",
|
||||||
|
"to": "bin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "resources/default-irs",
|
||||||
|
"to": "default-irs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "resources/soundfonts",
|
||||||
|
"to": "soundfonts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "resources/models",
|
||||||
|
"to": "models"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"asarUnpack": [
|
||||||
|
"build/Release/*.node",
|
||||||
|
"build/Release/libonnxruntime*",
|
||||||
|
"build/Release/onnxruntime*.dll"
|
||||||
|
],
|
||||||
|
"linux": {
|
||||||
|
"target": [
|
||||||
|
"AppImage",
|
||||||
|
"deb"
|
||||||
|
],
|
||||||
|
"category": "Audio",
|
||||||
|
"icon": "resources/icons",
|
||||||
|
"files": [
|
||||||
|
"build/Release/slopsmith-vst-host"
|
||||||
|
],
|
||||||
|
"asarUnpack": [
|
||||||
|
"build/Release/slopsmith-vst-host"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mac": {
|
||||||
|
"target": [
|
||||||
|
"dir"
|
||||||
|
],
|
||||||
|
"category": "public.app-category.music",
|
||||||
|
"icon": "resources/icons/icon.icns",
|
||||||
|
"hardenedRuntime": true,
|
||||||
|
"gatekeeperAssess": false,
|
||||||
|
"entitlements": "resources/entitlements.mac.plist",
|
||||||
|
"entitlementsInherit": "resources/entitlements.mac.plist",
|
||||||
|
"notarize": true,
|
||||||
|
"extendInfo": {
|
||||||
|
"NSMicrophoneUsageDescription": "Slopsmith needs microphone access for guitar input and note detection."
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"build/Release/slopsmith-vst-scan",
|
||||||
|
"build/Release/slopsmith-vst-host"
|
||||||
|
],
|
||||||
|
"asarUnpack": [
|
||||||
|
"build/Release/slopsmith-vst-scan",
|
||||||
|
"build/Release/slopsmith-vst-host"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"win": {
|
||||||
|
"target": [
|
||||||
|
"dir"
|
||||||
|
],
|
||||||
|
"icon": "resources/icons/icon.ico",
|
||||||
|
"files": [
|
||||||
|
"build/Release/slopsmith-vst-host.exe"
|
||||||
|
],
|
||||||
|
"asarUnpack": [
|
||||||
|
"build/Release/slopsmith-vst-host.exe"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"lottie-web": "^5.13.0",
|
||||||
|
"node-addon-api": "^8.3.1",
|
||||||
|
"velopack": "0.0.1589-ga2c5a97"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"cmake-js": "^7.3.0",
|
||||||
|
"electron": "^35.0.0",
|
||||||
|
"electron-builder": "^25.1.8",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
},
|
||||||
|
"cmake-js": {
|
||||||
|
"runtime": "electron"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?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.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.device.audio-input</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 411 B |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 981 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 361 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
@@ -0,0 +1,19 @@
|
|||||||
|
# Bundled ML models
|
||||||
|
|
||||||
|
## basic_pitch.onnx — Spotify Basic Pitch (polyphonic note detection)
|
||||||
|
|
||||||
|
The note model used by `MlNoteDetector` (`src/audio/MlNoteDetector.cpp`) for
|
||||||
|
TST-style polyphonic note detection.
|
||||||
|
|
||||||
|
- **Source:** the `basic-pitch` PyPI package, v0.4.0 —
|
||||||
|
`basic_pitch/saved_models/icassp_2022/nmp.onnx`, copied here verbatim.
|
||||||
|
- **Upstream:** Spotify Basic Pitch — https://github.com/spotify/basic-pitch
|
||||||
|
- **License:** Apache-2.0 (see the upstream `LICENSE`).
|
||||||
|
- **SHA-256:** `2c3c1d144bfa61ad236e92e169c13535c880469a12a047d4e73451f2c059a0ec`
|
||||||
|
- **I/O contract:** input `[batch, 43844, 1]` float32 (~2 s mono @ 22050 Hz);
|
||||||
|
outputs onset / note / contour posteriorgrams. See
|
||||||
|
`tests/spike/README.md` for the verified contract.
|
||||||
|
|
||||||
|
Bundled into the packaged app via `electron-builder`'s `extraResources`
|
||||||
|
(Constitution IV — offline first-run). When the file is absent the engine
|
||||||
|
falls back to the YIN `PitchDetector` / `ChordScorer` (Constitution VII).
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
GeneralUser GS soundfont — Licence and Attribution
|
||||||
|
====================================================
|
||||||
|
|
||||||
|
The bundled `GeneralUser-GS.sf2` file is GeneralUser GS, created and
|
||||||
|
maintained by S. Christian Collins.
|
||||||
|
|
||||||
|
Source: https://www.schristiancollins.com/generaluser
|
||||||
|
Packaging source (used by our build): https://github.com/mrbumpy409/GeneralUser-GS
|
||||||
|
|
||||||
|
GeneralUser GS Licence agreement (verbatim, as published by the author):
|
||||||
|
|
||||||
|
Licence Agreement for the GeneralUser GS SoundFont (by S. Christian
|
||||||
|
Collins):
|
||||||
|
|
||||||
|
GeneralUser GS is a free GM-compatible SoundFont that may be used
|
||||||
|
with any musical software, with the following caveats:
|
||||||
|
|
||||||
|
1. You may distribute GeneralUser GS, either freely or as part of a
|
||||||
|
commercial product, provided that the original distribution package
|
||||||
|
remains intact as provided and is accompanied by this Licence
|
||||||
|
Agreement. GeneralUser GS files may be repackaged provided that
|
||||||
|
proper credit is given to the original author.
|
||||||
|
|
||||||
|
2. You may include GeneralUser GS with commercial software at no
|
||||||
|
additional cost, provided that the software is not itself a
|
||||||
|
collection or library of SoundFonts, and provided that proper
|
||||||
|
attribution and this Licence Agreement are included.
|
||||||
|
|
||||||
|
3. You may not sell GeneralUser GS as a stand-alone product or as
|
||||||
|
part of a SoundFont collection.
|
||||||
|
|
||||||
|
The author, S. Christian Collins, disclaims all warranties regarding
|
||||||
|
GeneralUser GS. In no event shall the author be liable for any damages
|
||||||
|
arising from the use of this SoundFont.
|
||||||
|
|
||||||
|
Notes on this bundle
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
- Slopsmith ships an unmodified copy of `GeneralUser-GS.sf2` pinned to
|
||||||
|
a specific commit SHA of the upstream repo for reproducibility.
|
||||||
|
- Attribution: GeneralUser GS by S. Christian Collins
|
||||||
|
(https://www.schristiancollins.com/).
|
||||||
|
- This licence file accompanies the soundfont as required by clause 1.
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
# Build Scripts
|
||||||
|
|
||||||
|
Unified build system for Slopsmith Desktop supporting Linux (via Docker), macOS, and Windows.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/build-release.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Call Hierarchy
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐
|
||||||
|
│ GitHub Actions │
|
||||||
|
│ build.yml │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
│ (calls the same script everywhere)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────┐
|
||||||
|
│ build-release.sh │ Platform dispatcher
|
||||||
|
└────────┬─────────┘
|
||||||
|
│
|
||||||
|
│ Detects host OS:
|
||||||
|
│ - Linux → build-linux-docker.sh
|
||||||
|
│ - macOS → build-macos.sh
|
||||||
|
│ - Windows → build-windows.sh
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────┐
|
||||||
|
│ For Linux: Docker wrapper │
|
||||||
|
│ build-linux-docker.sh → │
|
||||||
|
│ Docker container: │
|
||||||
|
│ ./build-linux-ubuntu.sh │
|
||||||
|
└──────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ Sources:
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ build-common.sh │ Shared build logic (~250 lines)
|
||||||
|
└─────────────────┘
|
||||||
|
▲ ▲ ▲
|
||||||
|
│ │ │
|
||||||
|
│ │ ├─── Platform-specific implementations:
|
||||||
|
│ │ install_system_deps()
|
||||||
|
│ │ bundle_python_impl()
|
||||||
|
│ │ bundle_binaries_impl()
|
||||||
|
│ │
|
||||||
|
│ └─ build-macos.sh
|
||||||
|
│ build-windows.sh
|
||||||
|
│ build-linux-ubuntu.sh
|
||||||
|
│
|
||||||
|
└─ platform: mac / win / linux
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. **build-release.sh** - Platform dispatcher. Detects OS and routes to the right build script.
|
||||||
|
|
||||||
|
2. **Linux builds** - Always Docker-based for reproducibility:
|
||||||
|
- `build-linux-docker.sh` → Docker container → `build-linux-ubuntu.sh` → packages
|
||||||
|
|
||||||
|
3. **Native builds** - macOS and Windows run directly on host:
|
||||||
|
- `build-macos.sh` - Uses Homebrew dependencies
|
||||||
|
- `build-windows.sh` - Uses Git Bash, downloads binaries
|
||||||
|
|
||||||
|
4. **build-common.sh** - Shared logic sourced by platform scripts:
|
||||||
|
- Validates environment (Node.js, Python, .NET)
|
||||||
|
- Runs npm install, builds C++ engine, bundles resources
|
||||||
|
- Calls platform-specific functions for: dependency installation, Python bundling, binary bundling
|
||||||
|
|
||||||
|
## Platform-Specific Scripts
|
||||||
|
|
||||||
|
### Files
|
||||||
|
|
||||||
|
| Script | Purpose | Requirements | Output |
|
||||||
|
|--------|---------|--------------|--------|
|
||||||
|
| `build-linux-docker.sh` | Reproducible Docker build | Docker, adjacent slopsmith repo | `.AppImage`, `.deb` |
|
||||||
|
| `build-linux-ubuntu.sh` | Native Ubuntu build | Ubuntu/Debian + apt | `.AppImage`, `.deb` |
|
||||||
|
| `build-macos.sh` | Native macOS build | Homebrew, Xcode CLI | `.dmg`, `.zip` |
|
||||||
|
| `build-windows.sh` | Native Windows build | Git Bash, Node.js, Python, .NET | `.exe` installer |
|
||||||
|
|
||||||
|
### Two-Layer Ubuntu Builds
|
||||||
|
|
||||||
|
Most Linux distributions don't have identical package versions. Using Docker ensures the build is reproducible:
|
||||||
|
|
||||||
|
- **Direct use**: `./scripts/build-linux-docker.sh`
|
||||||
|
- **Inside container**: Runs `./scripts/build-linux-ubuntu.sh`
|
||||||
|
- **Why**: Guarantees identical builds across different Linux distros
|
||||||
|
|
||||||
|
### Platform-Specific Functions
|
||||||
|
|
||||||
|
Each platform script implements four functions that `build-common.sh` calls:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
install_system_deps() {
|
||||||
|
# Platform-specific: apt install, brew install, choco install, or downloads
|
||||||
|
}
|
||||||
|
|
||||||
|
bundle_python_impl() {
|
||||||
|
# Linux: copy system Python
|
||||||
|
# macOS: download python-build-standalone
|
||||||
|
# Windows: download embeddable Python zip
|
||||||
|
}
|
||||||
|
|
||||||
|
bundle_binaries_impl() {
|
||||||
|
# Linux: copy existing + patchelf
|
||||||
|
# macOS: copy existing + dylibbundler + sign
|
||||||
|
# Windows: download binaries (ffmpeg, vgmstream, fluidsynth)
|
||||||
|
}
|
||||||
|
|
||||||
|
get_expected_artifacts() {
|
||||||
|
# Globs verify_artifacts checks at the end of the build, e.g.
|
||||||
|
# printf "%s\n" "$PROJECT_DIR/release/*.dmg" "$PROJECT_DIR/release/*.zip"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
| Platform | Requirements |
|
||||||
|
|----------|--------------|
|
||||||
|
| **Linux (Docker)** | Docker, adjacent slopsmith repo |
|
||||||
|
| **Linux (native)** | Ubuntu/Debian, sudo, Node.js 22+, Python 3.12+, .NET 10+, apt dependencies |
|
||||||
|
| **macOS** | macOS 11+, Homebrew, Xcode CLI, Node.js 22+, Python 3.12+, .NET 10+ |
|
||||||
|
| **Windows** | Windows 10/11, Git for Windows + Bash, Node.js 22+, Python 3.12+, .NET 10+ |
|
||||||
|
|
||||||
|
**Windows Note:** These scripts must run in Git Bash (MSYS), not `cmd.exe` or PowerShell. They rely on MSYS-style paths such as `/tmp`, which work fine inside Git Bash but won't resolve correctly from a native Windows shell — so for local development outside GitHub Actions, run the scripts from a Git Bash terminal.
|
||||||
|
|
||||||
|
## GitHub Actions
|
||||||
|
|
||||||
|
The CI workflow is extremely simple - just calls the same script:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .github/workflows/build.yml
|
||||||
|
steps:
|
||||||
|
# Install platform-specific dependencies (apt, brew, or choco)
|
||||||
|
- name: Install dependencies
|
||||||
|
run: ...
|
||||||
|
|
||||||
|
# Build using the same script developers use locally
|
||||||
|
- name: Build
|
||||||
|
shell: bash
|
||||||
|
run: ./scripts/build-release.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Result:
|
||||||
|
- Local builds and CI use identical code paths
|
||||||
|
- Build failures can be reproduced and debugged locally
|
||||||
|
- Workflow is "dumb" - all logic lives in versioned scripts
|
||||||
|
|
||||||
|
## macOS Code Signing & Notarization
|
||||||
|
|
||||||
|
The macOS build signs every bundled native binary (fluidsynth, ffmpeg, vgmstream-cli, embedded Python interpreter + dylibs + extension `.so`s) with a Developer ID Application certificate, then electron-builder signs the `.app` and submits it to Apple's notary service. With signing in place, users get no Gatekeeper "app is damaged" warning on first launch.
|
||||||
|
|
||||||
|
### Required GitHub secrets
|
||||||
|
|
||||||
|
| Secret | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `APPLE_CERTIFICATE_P12_BASE64` | Developer ID Application cert exported as `.p12`, then `base64 -i cert.p12` |
|
||||||
|
| `APPLE_CERTIFICATE_PASSWORD` | The `.p12` export password |
|
||||||
|
| `APPLE_SIGNING_IDENTITY` | Full identity, e.g. `Developer ID Application: Your Name (TEAMID)` |
|
||||||
|
| `APPLE_ID` | Apple ID email |
|
||||||
|
| `APPLE_APP_SPECIFIC_PASSWORD` | App-specific password from appleid.apple.com (not the regular Apple ID password) |
|
||||||
|
| `APPLE_TEAM_ID` | 10-char team ID from developer.apple.com → Membership |
|
||||||
|
| `KEYCHAIN_PASSWORD` | Any random string — used for the temporary CI keychain |
|
||||||
|
|
||||||
|
When `APPLE_CERTIFICATE_P12_BASE64` is unset (forks, contributor PRs without secret access), the certificate-import step is skipped and `sign-macos-binaries.sh` exits early. The build still completes — it just produces an unsigned `.app` that will trigger Gatekeeper on macOS.
|
||||||
|
|
||||||
|
### Local macOS builds
|
||||||
|
|
||||||
|
Local builds without `APPLE_SIGNING_IDENTITY` set produce an unsigned `.app` (same as before signing was added). To produce a signed local build for testing, ensure your Developer ID Application certificate is in your login keychain and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
APPLE_SIGNING_IDENTITY="Developer ID Application: Your Name (TEAMID)" \
|
||||||
|
./scripts/build-release.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This signs the bundled binaries but does **not** notarize — notarization requires `APPLE_ID` + `APPLE_APP_SPECIFIC_PASSWORD` + `APPLE_TEAM_ID` env vars and is run by electron-builder when those are present.
|
||||||
|
|
||||||
|
### Local cmake-js cache
|
||||||
|
|
||||||
|
`build-windows.sh` only force-clears `$HOME/.cmake-js` when `$CI` is set (or `CLEAN_CMAKE_JS=1` is exported). Local Windows builds reuse the cache by default; set `CLEAN_CMAKE_JS=1` if you need a fully fresh build to mirror CI behaviour.
|
||||||
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# VST trace — runtime-gated diagnostic logging
|
||||||
|
|
||||||
|
`src/audio/VSTTrace.h` defines a `VST_TRACE(...)` macro the addon, the VST
|
||||||
|
host code, and the sandbox subprocess all use to emit lines into
|
||||||
|
`%TEMP%\slopsmith-vst-trace-<pid>.log` (Linux/macOS:
|
||||||
|
`/tmp/slopsmith-vst-trace-<pid>.log`) and stderr. The filename is per-PID
|
||||||
|
so concurrent runs (e.g. addon spawning a sandbox subprocess) each get
|
||||||
|
their own log without interleaving. It's compiled into every build but no-ops at runtime unless the
|
||||||
|
`SLOPSMITH_SANDBOX_DEBUG` environment variable is set to a non-empty value
|
||||||
|
other than `"0"`.
|
||||||
|
|
||||||
|
The first call caches the env var, so flipping the variable mid-process has
|
||||||
|
no effect — set it before launching the host process (`node ...`,
|
||||||
|
`electron ...`, or the sandbox subprocess via the parent's environment).
|
||||||
|
|
||||||
|
## What you'll see when it's enabled
|
||||||
|
|
||||||
|
* `[ctrl] ...` — control-channel framing from `ControlChannel.cpp`
|
||||||
|
(`ConnectNamedPipe`, `readFrame got N bytes`, `event: ready`, error codes).
|
||||||
|
* Sandbox subprocess startup steps from `slopsmith-vst-host.exe`:
|
||||||
|
`args ok`, `audio shm opened`, `control pipe connected`,
|
||||||
|
`plugin loaded: <name>`, `sending ready event`.
|
||||||
|
* `LoadVST: path='...'`, `SubprocessHandle.start: spawned pid=N`, the full
|
||||||
|
CreateProcess command line.
|
||||||
|
* `VSTHost.loadPlugin / VST3ComponentHolder.initialise` host-callback traces
|
||||||
|
from the JUCE VST3 host context (in-process load path only).
|
||||||
|
|
||||||
|
The sandbox host also opens a per-PID file at
|
||||||
|
`%TEMP%\slopsmith-vst-host-<pid>.log` **unconditionally** — this is by
|
||||||
|
design and intentionally not gated on `SLOPSMITH_SANDBOX_DEBUG`. The
|
||||||
|
sandbox subprocess runs hidden (no console window) and can die before
|
||||||
|
the env var has propagated, so an always-on per-PID file is the only
|
||||||
|
reliable way to diagnose "the subprocess died and I have no console"
|
||||||
|
crashes in the field. The file is small (a handful of lines per session),
|
||||||
|
written from a single process, and rotates per PID so they cap naturally.
|
||||||
|
|
||||||
|
## Turning it on
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
:: Windows — set before launching node / electron / the desktop app:
|
||||||
|
set SLOPSMITH_SANDBOX_DEBUG=1
|
||||||
|
node load-gr6.js
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS / Linux:
|
||||||
|
SLOPSMITH_SANDBOX_DEBUG=1 node load-gr6.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reading the log
|
||||||
|
|
||||||
|
The `<pid>` portion is the OS process ID of the writer — find your most
|
||||||
|
recent run via `dir %TEMP%\slopsmith-vst-trace-*.log` (Windows) or
|
||||||
|
`ls -t /tmp/slopsmith-vst-trace-*.log | head` (POSIX).
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
:: Windows — view the most recently modified trace file:
|
||||||
|
for /f "delims=" %f in ('dir /b /o-d %%TEMP%%\slopsmith-vst-trace-*.log') do @type "%%TEMP%%\%f" & exit /b
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS / Linux
|
||||||
|
cat "$(ls -t /tmp/slopsmith-vst-trace-*.log | head -1)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Per-PID naming means files accumulate over time. Clean up periodically
|
||||||
|
with `del %TEMP%\slopsmith-vst-trace-*.log` (Windows) or
|
||||||
|
`rm /tmp/slopsmith-vst-trace-*.log` (POSIX) when they're no longer
|
||||||
|
needed.
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Build the JUCE audio engine as a Node.js native addon
|
||||||
|
# Usage: ./scripts/build-audio.sh [debug|release]
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
|
||||||
|
BUILD_TYPE="${1:-Release}"
|
||||||
|
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
|
||||||
|
# Ensure JUCE submodule is available
|
||||||
|
if [ ! -f "JUCE/CMakeLists.txt" ]; then
|
||||||
|
echo "Initializing JUCE submodule..."
|
||||||
|
git submodule update --init --recursive
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure node_modules exist (for node-addon-api headers)
|
||||||
|
if [ ! -d "node_modules" ]; then
|
||||||
|
echo "Installing npm dependencies..."
|
||||||
|
npm install
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Detect architecture
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
case "$ARCH" in
|
||||||
|
x86_64)
|
||||||
|
CMAKE_ARCH="x64"
|
||||||
|
;;
|
||||||
|
aarch64|arm64)
|
||||||
|
CMAKE_ARCH="arm64"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
CMAKE_ARCH="$ARCH"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Linux: NeuralAmpModelerCore's A2 (slimmable) sources use
|
||||||
|
# std::atomic<std::shared_ptr<...>>, a C++20 library feature that libstdc++ only
|
||||||
|
# implements from GCC 12 on. ubuntu-22.04's default g++ is 11, where the primary
|
||||||
|
# std::atomic template fires a "trivially copyable" static_assert and the build
|
||||||
|
# fails. Prefer a g++ >= 12 when the default is older. No-op on macOS/Windows and
|
||||||
|
# on hosts whose default compiler is already new enough, and respects a CXX the
|
||||||
|
# caller already set.
|
||||||
|
if [ "$(uname -s)" = "Linux" ] && [ -z "${CXX:-}" ]; then
|
||||||
|
default_major="$(g++ -dumpversion 2>/dev/null | cut -d. -f1)"
|
||||||
|
if [ -n "$default_major" ] && [ "$default_major" -lt 12 ] 2>/dev/null; then
|
||||||
|
for v in 14 13 12; do
|
||||||
|
if command -v "g++-$v" >/dev/null 2>&1; then
|
||||||
|
export CC="gcc-$v" CXX="g++-$v"
|
||||||
|
echo "Default g++ is $default_major (<12, lacks std::atomic<shared_ptr>); using g++-$v for the NAM A2 sources"
|
||||||
|
# A build/ configured earlier with the default g++ has that
|
||||||
|
# compiler cached in CMakeCache.txt; cmake-js would reuse it and
|
||||||
|
# ignore CC/CXX, so the A2 sources would still compile with the
|
||||||
|
# old g++ and hit the static_assert. Drop a stale cache (one that
|
||||||
|
# isn't already on the selected compiler) so cmake reconfigures.
|
||||||
|
if [ -f build/CMakeCache.txt ] && ! grep -q "CMAKE_CXX_COMPILER:.*g++-$v" build/CMakeCache.txt; then
|
||||||
|
echo "Removing stale build/ (configured with a different compiler) so cmake reconfigures"
|
||||||
|
rm -rf build
|
||||||
|
fi
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -z "${CXX:-}" ]; then
|
||||||
|
echo "Warning: default g++ is $default_major (<12) and no g++-12+ was found." >&2
|
||||||
|
echo " The NAM A2 sources need std::atomic<shared_ptr> (GCC 12+ libstdc++); the build will likely fail." >&2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get the Electron version directly from the installed Electron package.
|
||||||
|
# Native addons MUST be built against the exact Electron ABI that ships
|
||||||
|
# with the app — guessing a fallback (the prior `|| echo 35.7.5`) can
|
||||||
|
# produce a .node that loads but crashes at runtime when the actual
|
||||||
|
# Electron version differs.
|
||||||
|
echo "Detecting Electron version..."
|
||||||
|
ELECTRON_PKG="node_modules/electron/package.json"
|
||||||
|
if [[ ! -f "$ELECTRON_PKG" ]]; then
|
||||||
|
echo "Error: $ELECTRON_PKG not found. Run \`npm install\` before building native addons." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ELECTRON_VERSION=$(node -p "require('./$ELECTRON_PKG').version" 2>/dev/null | tr -d '\r\n')
|
||||||
|
if [[ -z "$ELECTRON_VERSION" ]]; then
|
||||||
|
echo "Error: failed to read Electron version from $ELECTRON_PKG." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " Electron version: $ELECTRON_VERSION"
|
||||||
|
|
||||||
|
# Set environment variables for cmake-js
|
||||||
|
# CROSS-PLATFORM NOTE: cmake-js looks for these CMAKE_JS_* variables internally
|
||||||
|
export CMAKE_JS_RUNTIME="electron"
|
||||||
|
export CMAKE_JS_RUNTIME_VERSION="$ELECTRON_VERSION"
|
||||||
|
export CMAKE_JS_ARCH="$CMAKE_ARCH"
|
||||||
|
|
||||||
|
# Also set npm_config variables for compatibility
|
||||||
|
# CROSS-PLATFORM NOTE: These are needed because cmake-js falls back to node-gyp
|
||||||
|
# which expects npm_config_* variables. Both sets are required for reliable
|
||||||
|
# cross-platform builds, especially on Windows where environment handling differs.
|
||||||
|
export npm_config_runtime="electron"
|
||||||
|
export npm_config_target="$ELECTRON_VERSION"
|
||||||
|
export npm_config_arch="$CMAKE_ARCH"
|
||||||
|
export npm_config_target_arch="$CMAKE_ARCH"
|
||||||
|
|
||||||
|
# Optional: clear cmake-js cache on Windows (where this matters most)
|
||||||
|
# CROSS-PLATFORM NOTE: Only clear cache in CI environments by default to avoid
|
||||||
|
# permission issues on local Windows machines and preserve incremental builds.
|
||||||
|
# On Windows, cmake-js downloads headers to a different location
|
||||||
|
# (C:\Users\...\.cmake-js) than on Unix systems.
|
||||||
|
# To force cache clearing locally, set CLEAN_CMAKE_JS=1
|
||||||
|
if [ "${CLEAN_CMAKE_JS:-}" = "1" ] || { [ -n "$CI" ] && [ -d "$HOME/.cmake-js" ]; }; then
|
||||||
|
echo "Clearing cmake-js cache..."
|
||||||
|
rm -rf "$HOME/.cmake-js"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Building audio engine..."
|
||||||
|
echo " Platform: $(uname -s)"
|
||||||
|
echo " Arch: $CMAKE_ARCH"
|
||||||
|
echo " Electron: $ELECTRON_VERSION"
|
||||||
|
echo " Build type: $BUILD_TYPE"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Debug: show what cmake-js will see
|
||||||
|
echo "Environment for cmake-js:"
|
||||||
|
echo " CMAKE_JS_RUNTIME=$CMAKE_JS_RUNTIME"
|
||||||
|
echo " CMAKE_JS_RUNTIME_VERSION=$CMAKE_JS_RUNTIME_VERSION"
|
||||||
|
echo " CMAKE_JS_ARCH=$CMAKE_JS_ARCH"
|
||||||
|
echo " npm_config_runtime=$npm_config_runtime"
|
||||||
|
echo " npm_config_target=$npm_config_target"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
npx cmake-js build \
|
||||||
|
--runtime electron \
|
||||||
|
--runtime-version "$ELECTRON_VERSION" \
|
||||||
|
--arch "$CMAKE_ARCH" \
|
||||||
|
--CDCMAKE_BUILD_TYPE="$BUILD_TYPE"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Build complete!"
|
||||||
|
if [ -f "build/Release/slopsmith_audio.node" ]; then
|
||||||
|
echo "Output: build/Release/slopsmith_audio.node"
|
||||||
|
ls -lh "build/Release/slopsmith_audio.node"
|
||||||
|
else
|
||||||
|
echo "Warning: slopsmith_audio.node not found in expected location"
|
||||||
|
find build -name "*.node" 2>/dev/null
|
||||||
|
fi
|
||||||
@@ -0,0 +1,590 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Common build logic for all platforms.
|
||||||
|
# Platform scripts source this file and implement four functions:
|
||||||
|
# install_system_deps() — install OS packages (apt / brew / winget)
|
||||||
|
# bundle_python_impl() — bundle Python runtime
|
||||||
|
# bundle_binaries_impl() — bundle system binaries (ffmpeg etc.)
|
||||||
|
# get_expected_artifacts() — globs verify_artifacts checks at the end
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Check if this is being sourced by a platform script
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
echo "Error: build-common.sh should not be run directly" >&2
|
||||||
|
echo "Run ./build-release.sh instead" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Script directory must be set by sourcing script
|
||||||
|
if [[ -z "${SCRIPT_DIR:-}" ]]; then
|
||||||
|
echo "Error: SCRIPT_DIR not set by sourcing script" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# is_skipped_lib() — glibc/loader skip list, shared verbatim with
|
||||||
|
# bundle-binaries.sh so the bundler and the audit never disagree.
|
||||||
|
source "$SCRIPT_DIR/bundled-lib-skiplist.sh"
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
if [[ -z "${RED:-}" ]]; then
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check that required variables are set
|
||||||
|
if [[ -z "${PROJECT_DIR:-}" ]]; then
|
||||||
|
echo "Error: PROJECT_DIR not set by sourcing script" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${PLATFORM:-}" ]]; then
|
||||||
|
echo "Error: PLATFORM not set by sourcing script" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure platform is lowercase
|
||||||
|
PLATFORM="$(echo "$PLATFORM" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
|
||||||
|
# Validate platform
|
||||||
|
if [[ ! "$PLATFORM" =~ ^(linux|macos|windows)$ ]]; then
|
||||||
|
echo -e "${RED}Error: Invalid platform: $PLATFORM${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Configuration file
|
||||||
|
CONFIG="$PROJECT_DIR/.build-config.json"
|
||||||
|
PARSE_CONFIG="$SCRIPT_DIR/parse-build-config.py"
|
||||||
|
|
||||||
|
# Check config file
|
||||||
|
if [[ ! -f "$CONFIG" ]]; then
|
||||||
|
echo -e "${RED}Error: $CONFIG not found${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! python3 "$PARSE_CONFIG" "$CONFIG" >/dev/null; then
|
||||||
|
echo -e "${RED}Error: $CONFIG is not valid JSON${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
get_cfg() { python3 "$PARSE_CONFIG" "$CONFIG" "$1"; }
|
||||||
|
|
||||||
|
# --- Platform functions (to be implemented by platform scripts) ---
|
||||||
|
# Platform scripts MUST implement these three functions:
|
||||||
|
# install_system_deps()
|
||||||
|
# bundle_python_impl()
|
||||||
|
# bundle_binaries_impl()
|
||||||
|
|
||||||
|
# --- Common Build Steps ---
|
||||||
|
|
||||||
|
# Clone Slopsmith and plugins (shared across all platforms)
|
||||||
|
clone_slopsmith() {
|
||||||
|
# RUNNER_TEMP on Windows runners is a native Windows path
|
||||||
|
# (e.g. `D:\a\_temp`) that Git Bash / MSYS tools don't reliably
|
||||||
|
# treat as a filesystem path. POSIX `/tmp/slopsmith` is the
|
||||||
|
# default; if a non-POSIX environment really wants RUNNER_TEMP, it
|
||||||
|
# can pass an explicit clone_dir argument (resolved via cygpath -u
|
||||||
|
# on Windows if needed).
|
||||||
|
local clone_dir="${1:-/tmp/slopsmith}"
|
||||||
|
|
||||||
|
# Skip if already set for local development
|
||||||
|
if [[ -n "${SLOPSMITH_DIR:-}" ]] && [[ -d "$SLOPSMITH_DIR" ]]; then
|
||||||
|
echo "Using existing SLOPSMITH_DIR: $SLOPSMITH_DIR"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Make the clone re-runnable: a leftover dir from a previous failed
|
||||||
|
# build would otherwise abort `git clone`. CI runners start fresh so
|
||||||
|
# this is purely a quality-of-life fix for local re-runs.
|
||||||
|
if [[ -d "$clone_dir" ]]; then
|
||||||
|
rm -rf "$clone_dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# SLOPSMITH_REF selects the core branch/tag to bundle (set by the
|
||||||
|
# Build workflow's slopsmith_ref input). Defaults to main so local
|
||||||
|
# builds and the push/tag CI paths behave exactly as before.
|
||||||
|
# --branch accepts either a branch or a tag, both shallow-cloneable.
|
||||||
|
local slopsmith_ref="${SLOPSMITH_REF:-main}"
|
||||||
|
echo "Cloning Slopsmith repository (ref: ${slopsmith_ref})..."
|
||||||
|
git clone --depth 1 --branch "$slopsmith_ref" https://github.com/slopsmith/slopsmith.git "$clone_dir"
|
||||||
|
|
||||||
|
# Remove broken symlinks from plugins dir
|
||||||
|
find "$clone_dir/plugins" -maxdepth 1 -type l -delete 2>/dev/null || true
|
||||||
|
|
||||||
|
# Clone bundled plugins. Format per entry:
|
||||||
|
# <owner>/<repo>[@<branch>][:<dirname>]
|
||||||
|
# Dirname defaults to <repo> minus the "slopsmith-plugin-" prefix
|
||||||
|
# with hyphens replaced by underscores (slopsmith treats plugin
|
||||||
|
# directories as Python module names, which can't contain dashes).
|
||||||
|
# Provide an explicit dirname after a colon for repos that don't
|
||||||
|
# follow the slopsmith-plugin-* naming convention. An optional
|
||||||
|
# @<branch> clones a non-default branch (used to ship in-review
|
||||||
|
# plugin work in a feature-branch test build).
|
||||||
|
cd "$clone_dir/plugins"
|
||||||
|
local plugins=(
|
||||||
|
# byrongamatos plugins
|
||||||
|
slopsmith/slopsmith-plugin-drum-highway-3d
|
||||||
|
slopsmith/slopsmith-plugin-drums
|
||||||
|
slopsmith/slopsmith-plugin-editor
|
||||||
|
slopsmith/slopsmith-plugin-flappy-bend
|
||||||
|
slopsmith/slopsmith-plugin-fretboard
|
||||||
|
slopsmith/slopsmith-plugin-jumpingtab
|
||||||
|
slopsmith/slopsmith-plugin-keys-highway-3d
|
||||||
|
slopsmith/slopsmith-plugin-lyrics-karaoke
|
||||||
|
slopsmith/slopsmith-plugin-metronome
|
||||||
|
slopsmith/slopsmith-plugin-midi
|
||||||
|
slopsmith/slopsmith-plugin-multiplayer
|
||||||
|
slopsmith/slopsmith-plugin-musicxml-import
|
||||||
|
slopsmith/slopsmith-plugin-nam-tone
|
||||||
|
slopsmith/slopsmith-plugin-notedetect
|
||||||
|
slopsmith/slopsmith-plugin-piano
|
||||||
|
slopsmith/slopsmith-plugin-practice
|
||||||
|
slopsmith/slopsmith-plugin-profileimport
|
||||||
|
slopsmith/slopsmith-plugin-sectionmap
|
||||||
|
slopsmith/slopsmith-plugin-setlist
|
||||||
|
slopsmith/slopsmith-plugin-staffview
|
||||||
|
slopsmith/slopsmith-plugin-stepmode
|
||||||
|
slopsmith/slopsmith-plugin-studio
|
||||||
|
slopsmith/slopsmith-plugin-tabimport
|
||||||
|
slopsmith/slopsmith-plugin-tabview
|
||||||
|
slopsmith/slopsmith-plugin-tones
|
||||||
|
slopsmith/slopsmith-plugin-tutorials
|
||||||
|
# Community plugins
|
||||||
|
alleexx/slopsmith-plugin-transpose-chords
|
||||||
|
ChrisBeWithYou/slopsmith-plugin-slopscale
|
||||||
|
DeathlySin/slopsmith-plugin-song-preview
|
||||||
|
Jafz2001/slopsmith-plugin-nam-rig-builder
|
||||||
|
masc0t/slopsmith-plugin-find-more
|
||||||
|
masc0t/slopsmith-plugin-invert-highway
|
||||||
|
masc0t/slopsmith-plugin-themes
|
||||||
|
masc0t/slopsmith-update-manager:update_manager
|
||||||
|
slopsmith/slopsmith-plugin-stem-mixer
|
||||||
|
topkoa/slopsmith-plugin-guitar-theory
|
||||||
|
topkoa/slopsmith-plugin-sloppak-converter
|
||||||
|
topkoa/slopsmith-plugin-splitscreen
|
||||||
|
topkoa/slopsmith-plugin-stems
|
||||||
|
)
|
||||||
|
|
||||||
|
local total=0
|
||||||
|
local cloned=0
|
||||||
|
for entry in "${plugins[@]}"; do
|
||||||
|
total=$((total + 1))
|
||||||
|
# Split off an optional ":<dirname>" then an optional "@<branch>".
|
||||||
|
# Git branch names can't contain ':' so the dirname split is safe
|
||||||
|
# to do first; what's left is "<owner>/<repo>" or "<owner>/<repo>@<branch>".
|
||||||
|
local spec="$entry" dirname="" branch=""
|
||||||
|
if [[ "$spec" == *:* ]]; then
|
||||||
|
dirname="${spec##*:}"
|
||||||
|
spec="${spec%%:*}"
|
||||||
|
fi
|
||||||
|
local owner_repo="$spec"
|
||||||
|
if [[ "$spec" == *@* ]]; then
|
||||||
|
branch="${spec##*@}"
|
||||||
|
owner_repo="${spec%%@*}"
|
||||||
|
fi
|
||||||
|
if [[ -z "$dirname" ]]; then
|
||||||
|
dirname="${owner_repo##*/}"
|
||||||
|
dirname="${dirname#slopsmith-plugin-}"
|
||||||
|
dirname="${dirname//-/_}"
|
||||||
|
fi
|
||||||
|
local clone_args=(--depth 1)
|
||||||
|
[[ -n "$branch" ]] && clone_args+=(--branch "$branch")
|
||||||
|
if git clone "${clone_args[@]}" "https://github.com/${owner_repo}.git" "$dirname" 2>/dev/null; then
|
||||||
|
cloned=$((cloned + 1))
|
||||||
|
else
|
||||||
|
echo " skipped ${owner_repo}${branch:+@$branch}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
export SLOPSMITH_DIR="$clone_dir"
|
||||||
|
echo "Cloned ${cloned} of ${total} plugins"
|
||||||
|
cd - >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
step=1
|
||||||
|
|
||||||
|
echo_validate_env() {
|
||||||
|
echo -e "${BLUE}Step $step: Validating environment${NC}"
|
||||||
|
step=$((step + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
echo_step() {
|
||||||
|
echo -e "${BLUE}Step $step: $1${NC}"
|
||||||
|
step=$((step + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
echo_summary() {
|
||||||
|
echo -e "${GREEN}✓${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo_warning() {
|
||||||
|
echo -e "${YELLOW}!${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo_error() {
|
||||||
|
echo -e "${RED}✗${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_environment() {
|
||||||
|
echo_validate_env
|
||||||
|
|
||||||
|
NODE_VERSION=$(get_cfg .versions.node)
|
||||||
|
PYTHON_VERSION=$(get_cfg .versions.python)
|
||||||
|
|
||||||
|
echo "Platform: $PLATFORM"
|
||||||
|
echo "Node: $NODE_VERSION"
|
||||||
|
echo "Python: $PYTHON_VERSION"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check Node.js
|
||||||
|
if command -v node &>/dev/null; then
|
||||||
|
INSTALLED_NODE=$(node -p "process.version.replace('v', '')")
|
||||||
|
echo_summary "Found Node.js $INSTALLED_NODE"
|
||||||
|
else
|
||||||
|
echo_error "Node.js not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check Python 3
|
||||||
|
if command -v python3 &>/dev/null; then
|
||||||
|
INSTALLED_PYTHON=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')")
|
||||||
|
echo_summary "Found Python $INSTALLED_PYTHON"
|
||||||
|
else
|
||||||
|
echo_error "Python 3 not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
install_npm_deps() {
|
||||||
|
echo_step "Installing npm dependencies"
|
||||||
|
npm install
|
||||||
|
echo_summary "npm dependencies installed"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
build_native_addons() {
|
||||||
|
echo_step "Building native addons (audio engine)"
|
||||||
|
npm run build:native
|
||||||
|
echo_summary "Native addons built"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
bundle_slopsmith() {
|
||||||
|
echo_step "Bundling Slopsmith and plugins"
|
||||||
|
npm run bundle:slopsmith
|
||||||
|
echo_summary "Slopsmith bundled"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
bundle_python() {
|
||||||
|
mkdir -p "$PROJECT_DIR/resources"
|
||||||
|
bundle_python_impl
|
||||||
|
echo_summary "Python runtime bundled"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
bundle_binaries() {
|
||||||
|
mkdir -p "$PROJECT_DIR/resources/bin"
|
||||||
|
bundle_binaries_impl
|
||||||
|
echo_summary "System binaries bundled"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_bundled_binaries() {
|
||||||
|
# Smoke test: verify bundled binaries are executable and can run
|
||||||
|
local bin_dir="$PROJECT_DIR/resources/bin"
|
||||||
|
local ext=""
|
||||||
|
if [[ "$PLATFORM" == "windows" ]]; then
|
||||||
|
ext=".exe"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo_step "Verifying bundled binaries"
|
||||||
|
|
||||||
|
# Verify fluidsynth: supports --version
|
||||||
|
local fs_path="$bin_dir/fluidsynth${ext}"
|
||||||
|
if [[ ! -f "$fs_path" ]]; then
|
||||||
|
echo_error "Missing bundled binary: $fs_path"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! "$fs_path" --version >/dev/null 2>&1; then
|
||||||
|
echo_error "Binary fluidsynth failed to execute"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " ✓ fluidsynth"
|
||||||
|
|
||||||
|
# Verify ffmpeg: supports -version
|
||||||
|
local ff_path="$bin_dir/ffmpeg${ext}"
|
||||||
|
if [[ ! -f "$ff_path" ]]; then
|
||||||
|
echo_error "Missing bundled binary: $ff_path"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! "$ff_path" -version >/dev/null 2>&1; then
|
||||||
|
echo_error "Binary ffmpeg failed to execute"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " ✓ ffmpeg"
|
||||||
|
|
||||||
|
# Verify ffprobe: demucs spawns it before ffmpeg to read stream
|
||||||
|
# metadata. Required on every platform because falling through to a
|
||||||
|
# host-installed ffprobe makes stem splitting work on the build host
|
||||||
|
# and silently fail on user machines without it.
|
||||||
|
local ffp_path="$bin_dir/ffprobe${ext}"
|
||||||
|
if [[ ! -f "$ffp_path" ]]; then
|
||||||
|
echo_error "Missing bundled binary: $ffp_path"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! "$ffp_path" -version >/dev/null 2>&1; then
|
||||||
|
echo_error "Binary ffprobe failed to execute"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " ✓ ffprobe"
|
||||||
|
|
||||||
|
# Verify vgmstream-cli: doesn't have --version, check it produces output with version
|
||||||
|
local vgm_path="$bin_dir/vgmstream-cli${ext}"
|
||||||
|
echo " Checking vgmstream-cli at: $vgm_path"
|
||||||
|
if [[ ! -f "$vgm_path" ]]; then
|
||||||
|
echo_error "Missing bundled binary: $vgm_path"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " File exists, checking permissions:"
|
||||||
|
ls -la "$vgm_path"
|
||||||
|
# `file` is a diagnostic-only call — keep it best-effort so a minimal
|
||||||
|
# base image (e.g. the Linux Docker builder, which doesn't ship the
|
||||||
|
# libmagic-backed `file` binary) doesn't fail the build over a debug
|
||||||
|
# line. The actual smoke test is the run-and-grep below.
|
||||||
|
if command -v file >/dev/null 2>&1; then
|
||||||
|
echo " File type:"
|
||||||
|
file "$vgm_path"
|
||||||
|
fi
|
||||||
|
echo " Attempting to run vgmstream-cli..."
|
||||||
|
local vgm_output
|
||||||
|
local vgm_exit_code
|
||||||
|
# vgmstream-cli with no args prints its version header then exits 1.
|
||||||
|
# Capture the exit code via the if-branch so `set -e` doesn't trip
|
||||||
|
# AND vgm_exit_code reflects the binary's real status (not the `|| true`
|
||||||
|
# short-circuit that the previous form ended up reporting).
|
||||||
|
if vgm_output=$("$vgm_path" 2>&1); then
|
||||||
|
vgm_exit_code=0
|
||||||
|
else
|
||||||
|
vgm_exit_code=$?
|
||||||
|
fi
|
||||||
|
echo " Exit code: $vgm_exit_code"
|
||||||
|
echo " Raw output:"
|
||||||
|
echo "$vgm_output" | head -20
|
||||||
|
echo " Checking if output matches expected pattern..."
|
||||||
|
if [[ -z "$vgm_output" ]]; then
|
||||||
|
echo_error "Binary vgmstream-cli produced no output"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ! "$vgm_output" =~ vgmstream.*CLI.*decoder ]]; then
|
||||||
|
echo_error "Binary vgmstream-cli produced unexpected output"
|
||||||
|
echo " Expected pattern: vgmstream.*CLI.*decoder"
|
||||||
|
echo " Actual output (first 500 chars):"
|
||||||
|
echo "${vgm_output:0:500}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " ✓ vgmstream-cli"
|
||||||
|
|
||||||
|
# On Linux, audit each bundled ELF binary's NEEDED entries: every
|
||||||
|
# SONAME must either be in the glibc/loader skip list (delegated to
|
||||||
|
# the user's libc) or sit next to the binary in resources/bin/.
|
||||||
|
# The `-version` smoke tests above can't catch this on the build
|
||||||
|
# host because /usr/lib happens to satisfy the deps — but on a user
|
||||||
|
# machine with a different ffmpeg ABI the load fails at runtime
|
||||||
|
# (issue #68 on Fedora 44 / Arch).
|
||||||
|
if [[ "$PLATFORM" == "linux" ]]; then
|
||||||
|
if ! command -v readelf >/dev/null 2>&1; then
|
||||||
|
echo_error "readelf not found (apt: binutils) - required to audit bundled binaries' shared library deps"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
for bin in fluidsynth ffmpeg ffprobe vgmstream-cli; do
|
||||||
|
audit_bundled_deps "$bin_dir/$bin" "$bin_dir" || exit 1
|
||||||
|
done
|
||||||
|
# Also audit each bundled .so's own NEEDED entries. ldd-on-the-top-
|
||||||
|
# level-binary usually resolves the full transitive closure, but
|
||||||
|
# dlopen-resolved deps and interposer libs can slip through that
|
||||||
|
# traversal. Auditing every bundled .so closes the gap.
|
||||||
|
for so in "$bin_dir"/*.so*; do
|
||||||
|
[ -f "$so" ] || continue
|
||||||
|
audit_bundled_deps "$so" "$bin_dir" || exit 1
|
||||||
|
done
|
||||||
|
echo " ✓ shared-library audit"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo_summary "All bundled binaries verified"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Asserts every NEEDED SONAME in $1 is either in the glibc skip list
|
||||||
|
# or present as a file in $2 (the bundle directory). Returns non-zero
|
||||||
|
# with a specific error if any SONAME is unsatisfied.
|
||||||
|
audit_bundled_deps() {
|
||||||
|
local bin_path="$1"
|
||||||
|
local bundle_dir="$2"
|
||||||
|
local missing=()
|
||||||
|
local soname
|
||||||
|
while IFS= read -r soname; do
|
||||||
|
[ -n "$soname" ] || continue
|
||||||
|
is_skipped_lib "$soname" && continue
|
||||||
|
[ -f "$bundle_dir/$soname" ] && continue
|
||||||
|
missing+=("$soname")
|
||||||
|
done < <(readelf -d "$bin_path" 2>/dev/null | awk -F'[][]' '/\(NEEDED\)/ {print $2}')
|
||||||
|
|
||||||
|
if [ ${#missing[@]} -gt 0 ]; then
|
||||||
|
echo_error "Bundled $(basename "$bin_path") needs shared libs that are not in resources/bin/:"
|
||||||
|
for soname in "${missing[@]}"; do
|
||||||
|
echo " - $soname"
|
||||||
|
done
|
||||||
|
echo " Fix: extend scripts/bundle-binaries.sh so these SONAMEs are bundled (or add them to the glibc skip list if they MUST come from the host libc)."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
bundle_soundfont() {
|
||||||
|
echo_step "Bundling default soundfont"
|
||||||
|
bash "$SCRIPT_DIR/bundle-soundfont.sh"
|
||||||
|
echo_summary "Soundfont bundled"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
build_typescript() {
|
||||||
|
echo_step "Building TypeScript"
|
||||||
|
npm run build:ts
|
||||||
|
echo_summary "TypeScript built"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
package_application() {
|
||||||
|
echo_step "Packaging application"
|
||||||
|
# Call electron-builder directly. The package.json `dist:*` scripts
|
||||||
|
# chain `build:native && bundle && build:ts && electron-builder`,
|
||||||
|
# but build-common.sh's main() has already run all three of those
|
||||||
|
# explicitly. Going through `npm run dist:*` would re-run them, which
|
||||||
|
# on macOS is wasteful: build:native rebuilds the native audio addon
|
||||||
|
# that build-common.sh already produced.
|
||||||
|
#
|
||||||
|
# `--publish never` is required: on a tag push electron-builder
|
||||||
|
# defaults to auto-publishing to GitHub Releases and then errors out
|
||||||
|
# without GH_TOKEN. The workflow has a dedicated `release` job that
|
||||||
|
# publishes via softprops/action-gh-release after artifact upload —
|
||||||
|
# the build job just needs to produce artifacts, not publish them.
|
||||||
|
local builder_platform
|
||||||
|
case "$PLATFORM" in
|
||||||
|
linux) builder_platform="--linux" ;;
|
||||||
|
macos) builder_platform="--mac" ;;
|
||||||
|
windows) builder_platform="--win" ;;
|
||||||
|
*)
|
||||||
|
echo_error "Unsupported packaging platform: $PLATFORM"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
npx electron-builder "$builder_platform" --publish never
|
||||||
|
echo_summary "Application packaged"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_artifacts() {
|
||||||
|
echo_step "Verifying artifacts"
|
||||||
|
|
||||||
|
ARTIFACTS_FOUND=0
|
||||||
|
|
||||||
|
# Read patterns into array (avoid process substitution for CI compatibility)
|
||||||
|
patterns=()
|
||||||
|
tempfile=$(mktemp)
|
||||||
|
get_expected_artifacts > "$tempfile"
|
||||||
|
cat "$tempfile" >&2
|
||||||
|
while IFS= read -r line; do
|
||||||
|
patterns+=("$line")
|
||||||
|
done < "$tempfile"
|
||||||
|
rm -f "$tempfile"
|
||||||
|
for pattern in "${patterns[@]}"; do
|
||||||
|
shopt -s nullglob
|
||||||
|
files=($pattern)
|
||||||
|
shopt -u nullglob
|
||||||
|
if [ ${#files[@]} -gt 0 ]; then
|
||||||
|
ARTIFACTS_FOUND=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ $ARTIFACTS_FOUND -eq 1 ]]; then
|
||||||
|
echo_summary "Build successful!"
|
||||||
|
echo ""
|
||||||
|
ls -lh "$PROJECT_DIR/release/" 2>/dev/null | grep -v "^total" | awk 'NR > 1' | head -10 || true
|
||||||
|
else
|
||||||
|
echo_error "No artifacts found"
|
||||||
|
if [[ -d "$PROJECT_DIR/release" ]]; then
|
||||||
|
echo "Contents of release/:"
|
||||||
|
ls -la "$PROJECT_DIR/release/" 2>&1 || echo "(directory empty)"
|
||||||
|
else
|
||||||
|
echo "release/ directory doesn't exist"
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main entry point - platform scripts call this
|
||||||
|
main() {
|
||||||
|
local start_time=$(date +%s)
|
||||||
|
|
||||||
|
case "$PLATFORM" in
|
||||||
|
linux|macos|windows)
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo_error "Unsupported platform: $PLATFORM"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Verify that all required functions are defined by the sourcing platform script
|
||||||
|
local missing_functions=()
|
||||||
|
local required_funcs=(
|
||||||
|
install_system_deps
|
||||||
|
bundle_python_impl
|
||||||
|
bundle_binaries_impl
|
||||||
|
get_expected_artifacts
|
||||||
|
)
|
||||||
|
|
||||||
|
for func in "${required_funcs[@]}"; do
|
||||||
|
if ! type "$func" &>/dev/null; then
|
||||||
|
missing_functions+=("$func")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ ${#missing_functions[@]} -gt 0 ]]; then
|
||||||
|
echo_error "Required functions not defined by platform script:"
|
||||||
|
for func in "${missing_functions[@]}"; do
|
||||||
|
echo " - $func"
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
validate_environment
|
||||||
|
install_system_deps
|
||||||
|
install_npm_deps
|
||||||
|
# clone_slopsmith provides the slopsmith core + plugins that
|
||||||
|
# bundle_slopsmith packages into the app; run it before the build steps
|
||||||
|
# that consume $SLOPSMITH_DIR.
|
||||||
|
clone_slopsmith
|
||||||
|
build_native_addons
|
||||||
|
bundle_slopsmith
|
||||||
|
bundle_python
|
||||||
|
bundle_binaries
|
||||||
|
verify_bundled_binaries
|
||||||
|
bundle_soundfont
|
||||||
|
build_typescript
|
||||||
|
package_application
|
||||||
|
verify_artifacts
|
||||||
|
|
||||||
|
local end_time=$(date +%s)
|
||||||
|
local duration=$((end_time - start_time))
|
||||||
|
|
||||||
|
echo -e "${GREEN}✓${NC} Build complete for $PLATFORM in ${duration}s"
|
||||||
|
echo "Output: $PROJECT_DIR/release/"
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Docker-based Linux build wrapper
|
||||||
|
# Runs build-linux-ubuntu.sh inside a reproducible container
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
DEVCONTAINER_DIR="$PROJECT_DIR/.devcontainer"
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
echo "=== Slopsmith Desktop Docker Build ==="
|
||||||
|
echo ""
|
||||||
|
echo "This script provides reproducible Linux builds by running"
|
||||||
|
echo "build-linux-ubuntu.sh inside a Docker container."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check prerequisites
|
||||||
|
echo -e "${BLUE}Checking prerequisites...${NC}"
|
||||||
|
|
||||||
|
if ! command -v docker &>/dev/null; then
|
||||||
|
echo -e "${RED}Error: Docker is not installed${NC}" >&2
|
||||||
|
echo "Install: https://docs.docker.com/get-docker/" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! docker info &>/dev/null; then
|
||||||
|
echo -e "${RED}Error: Docker daemon is not running${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${GREEN}✓${NC} Docker available"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Build container image
|
||||||
|
echo -e "${BLUE}Building container image...${NC}"
|
||||||
|
echo " (This will take a few minutes on first run)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
docker build \
|
||||||
|
-f "$DEVCONTAINER_DIR/Dockerfile" \
|
||||||
|
-t slopsmith-ubuntu-builder \
|
||||||
|
"$PROJECT_DIR"
|
||||||
|
# `set -e` at the top of this script already aborts on a failed
|
||||||
|
# `docker build` — no manual `$?` check needed (and the check that
|
||||||
|
# was here would in practice be unreachable).
|
||||||
|
|
||||||
|
echo -e "${GREEN}✓${NC} Container image built"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Clear stale CMake build cache. CMakeCache.txt bakes in the build path;
|
||||||
|
# when the project is mounted at a different path inside the container the
|
||||||
|
# paths don't match and cmake aborts. A clean build/ guarantees consistency.
|
||||||
|
if [[ -d "$PROJECT_DIR/build" ]]; then
|
||||||
|
echo -e "${BLUE}Clearing stale CMake cache...${NC}"
|
||||||
|
rm -rf "$PROJECT_DIR/build"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Generate unique container name
|
||||||
|
CONTAINER_NAME="slopsmith-build-$(date +%s)-$$-$RANDOM"
|
||||||
|
|
||||||
|
echo -e "${BLUE}Running build in container...${NC}"
|
||||||
|
echo -e "${BLUE}Container name:${NC} $CONTAINER_NAME"
|
||||||
|
echo ""
|
||||||
|
echo "The container will be preserved after the build to allow debugging."
|
||||||
|
echo "Clean up when done:"
|
||||||
|
echo " docker stop $CONTAINER_NAME && docker rm $CONTAINER_NAME"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
set +e
|
||||||
|
docker run \
|
||||||
|
--name "$CONTAINER_NAME" \
|
||||||
|
-v "$PROJECT_DIR:/workspace" \
|
||||||
|
-w /workspace \
|
||||||
|
-e ELECTRON_CACHE=/home/vscode/.cache/electron \
|
||||||
|
-e ELECTRON_BUILDER_CACHE=/home/vscode/.cache/electron-builder \
|
||||||
|
-e GIT_TERMINAL_PROMPT=0 \
|
||||||
|
-t \
|
||||||
|
slopsmith-ubuntu-builder \
|
||||||
|
bash -c './scripts/build-linux-ubuntu.sh'
|
||||||
|
BUILD_EXIT_CODE=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
if [[ $BUILD_EXIT_CODE -eq 0 ]]; then
|
||||||
|
echo -e "${GREEN}✓${NC} Build completed successfully!"
|
||||||
|
else
|
||||||
|
echo -e "${RED}✗${NC} Build failed (exit code: $BUILD_EXIT_CODE)"
|
||||||
|
echo ""
|
||||||
|
echo "To debug:"
|
||||||
|
echo " docker exec -it $CONTAINER_NAME /bin/bash"
|
||||||
|
echo " docker logs $CONTAINER_NAME"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit $BUILD_EXIT_CODE
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Native Ubuntu build script
|
||||||
|
# Assumes host is running Ubuntu Linux
|
||||||
|
# Uses native Ubuntu packages (apt) and copies system Python
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
CONFIG="$PROJECT_DIR/.build-config.json"
|
||||||
|
|
||||||
|
# Platform identifier
|
||||||
|
export PLATFORM="linux"
|
||||||
|
|
||||||
|
# Check we're on Linux
|
||||||
|
if [[ ! -f /etc/os-release ]] || ! grep -q "ubuntu\|debian" /etc/os-release; then
|
||||||
|
echo "Note: This script is optimized for Ubuntu/Debian but may work on other distributions."
|
||||||
|
echo "For non-Ubuntu Linux, system dependencies might need manual installation."
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Slopsmith Desktop Ubuntu Native Build ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Color setup
|
||||||
|
export RED='\033[0;31m'
|
||||||
|
export GREEN='\033[0;32m'
|
||||||
|
export YELLOW='\033[1;33m'
|
||||||
|
export BLUE='\033[0;34m'
|
||||||
|
export NC='\033[0m'
|
||||||
|
|
||||||
|
# Source common build logic
|
||||||
|
source "$SCRIPT_DIR/build-common.sh"
|
||||||
|
|
||||||
|
# Platform-specific: Return expected artifact patterns
|
||||||
|
get_expected_artifacts() {
|
||||||
|
printf "%s\n" "$PROJECT_DIR/release/*.AppImage" "$PROJECT_DIR/release/*.deb"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Platform-specific: Install system dependencies
|
||||||
|
install_system_deps() {
|
||||||
|
if command -v sudo &>/dev/null; then
|
||||||
|
sudo apt-get update
|
||||||
|
PACKAGES=$(grep -v '^[[:space:]]*#' "$PROJECT_DIR/.packages/apt.txt" | grep -v '^[[:space:]]*$' | tr '\n' ' ')
|
||||||
|
if [[ -n "$PACKAGES" ]]; then
|
||||||
|
sudo apt-get install -y $PACKAGES
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${YELLOW}!${NC} sudo not available, skipping apt package installation"
|
||||||
|
echo " Make sure build dependencies are already installed"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Platform-specific: Bundle Python runtime
|
||||||
|
bundle_python_impl() {
|
||||||
|
# Linux: use existing bundle-python.sh script
|
||||||
|
bash "$SCRIPT_DIR/bundle-python.sh"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Platform-specific: Bundle system binaries
|
||||||
|
bundle_binaries_impl() {
|
||||||
|
# Linux: use existing bundle-binaries.sh script
|
||||||
|
bash "$SCRIPT_DIR/bundle-binaries.sh"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run the build
|
||||||
|
main "$@"
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Native macOS build script
|
||||||
|
# Uses Homebrew for dependencies and system Python
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
CONFIG="$PROJECT_DIR/.build-config.json"
|
||||||
|
|
||||||
|
# Platform identifier
|
||||||
|
export PLATFORM="macos"
|
||||||
|
|
||||||
|
# Check we're on macOS
|
||||||
|
if [[ "$OSTYPE" != "darwin"* ]]; then
|
||||||
|
echo "Error: This script is for macOS only" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Slopsmith Desktop macOS Build ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Disable electron-builder's keychain-identity auto-discovery on unsigned
|
||||||
|
# builds. Without this, electron-builder picks the first codesigning
|
||||||
|
# identity it finds (often an "Apple Development" cert from Xcode) and
|
||||||
|
# tries to sign with it — which both produces unusable artifacts AND
|
||||||
|
# fails when Slopsmith.app contains paths the Apple Development cert
|
||||||
|
# can't sign. Signed CI builds set APPLE_SIGNING_IDENTITY / CSC_NAME, so
|
||||||
|
# this guard only triggers for local unsigned dev builds.
|
||||||
|
if [[ -z "${APPLE_SIGNING_IDENTITY:-}" && -z "${CSC_NAME:-}" && -z "${CSC_LINK:-}" ]]; then
|
||||||
|
export CSC_IDENTITY_AUTO_DISCOVERY=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Derive CSC_NAME (electron-builder's identity name) from
|
||||||
|
# APPLE_SIGNING_IDENTITY. codesign accepts the full identity string with
|
||||||
|
# "Developer ID Application:" prefix; electron-builder rejects that
|
||||||
|
# prefix and wants the bare team-name + team-id form. Strip the prefix
|
||||||
|
# once here so the rest of the build (sign-macos-binaries.sh and
|
||||||
|
# electron-builder) can each consume the form they expect.
|
||||||
|
if [[ -z "${CSC_NAME:-}" && -n "${APPLE_SIGNING_IDENTITY:-}" ]]; then
|
||||||
|
export CSC_NAME="${APPLE_SIGNING_IDENTITY#Developer ID Application: }"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Color setup
|
||||||
|
export RED='\033[0;31m'
|
||||||
|
export GREEN='\033[0;32m'
|
||||||
|
export YELLOW='\033[1;33m'
|
||||||
|
export BLUE='\033[0;34m'
|
||||||
|
export NC='\033[0m'
|
||||||
|
|
||||||
|
# Source common build logic
|
||||||
|
source "$SCRIPT_DIR/build-common.sh"
|
||||||
|
|
||||||
|
# Platform-specific: Install system dependencies
|
||||||
|
install_system_deps() {
|
||||||
|
if command -v brew &>/dev/null; then
|
||||||
|
PACKAGES=$(grep -v '^[[:space:]]*#' "$PROJECT_DIR/.packages/brew.txt" | grep -v '^[[:space:]]*$' | tr '\n' ' ')
|
||||||
|
if [[ -n "$PACKAGES" ]]; then
|
||||||
|
brew install $PACKAGES
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Error: Homebrew not found. Install from https://brew.sh" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Platform-specific: Bundle Python runtime
|
||||||
|
#
|
||||||
|
# Uses python-build-standalone (Astral) — a fully relocatable CPython
|
||||||
|
# distribution built specifically for redistribution. Avoids every
|
||||||
|
# hazard of trying to copy a Homebrew framework: no PEP 668 marker, no
|
||||||
|
# install_name_tool dance, no broken site-packages symlink, sys.prefix
|
||||||
|
# correctly resolves to the bundle's location at runtime.
|
||||||
|
bundle_python_impl() {
|
||||||
|
local config_py
|
||||||
|
config_py=$(python3 "$SCRIPT_DIR/parse-build-config.py" "$PROJECT_DIR/.build-config.json" .versions.python)
|
||||||
|
local py_mm="${config_py%.*}"
|
||||||
|
|
||||||
|
local arch
|
||||||
|
arch=$(uname -m)
|
||||||
|
local config_key
|
||||||
|
case "$arch" in
|
||||||
|
arm64|aarch64) config_key="python_standalone_macos_arm64" ;;
|
||||||
|
x86_64) config_key="python_standalone_macos_x64" ;;
|
||||||
|
*)
|
||||||
|
echo "Error: unsupported macOS arch: $arch" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
local pbs_url pbs_sha
|
||||||
|
pbs_url=$(python3 "$SCRIPT_DIR/parse-build-config.py" "$PROJECT_DIR/.build-config.json" ".external.${config_key}.url")
|
||||||
|
pbs_sha=$(python3 "$SCRIPT_DIR/parse-build-config.py" "$PROJECT_DIR/.build-config.json" ".external.${config_key}.sha256")
|
||||||
|
|
||||||
|
local runtime="$PROJECT_DIR/resources/python/runtime"
|
||||||
|
local tarball="/tmp/cpython-${config_py}-macos-${arch}.tar.gz"
|
||||||
|
|
||||||
|
mkdir -p "$PROJECT_DIR/resources/python"
|
||||||
|
rm -rf "$runtime"
|
||||||
|
|
||||||
|
if [[ ! -f "$tarball" ]] || ! shasum -a 256 "$tarball" | awk '{print $1}' | grep -qx "$pbs_sha"; then
|
||||||
|
echo " Downloading python-build-standalone ${config_py} (${arch})"
|
||||||
|
curl -sL --fail --retry 5 --retry-delay 5 --retry-all-errors "$pbs_url" -o "$tarball"
|
||||||
|
fi
|
||||||
|
local actual_sha
|
||||||
|
actual_sha=$(shasum -a 256 "$tarball" | awk '{print $1}')
|
||||||
|
if [[ "$actual_sha" != "$pbs_sha" ]]; then
|
||||||
|
echo "Error: python-build-standalone tarball SHA256 mismatch" >&2
|
||||||
|
echo " expected: $pbs_sha" >&2
|
||||||
|
echo " got: $actual_sha" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# PBS tarballs extract to a top-level `python/` dir; rename to
|
||||||
|
# `runtime` so the rest of the build (and python.ts) finds the
|
||||||
|
# interpreter at resources/python/runtime/bin/python3.
|
||||||
|
local extract_dir="/tmp/pbs-extract-$$"
|
||||||
|
rm -rf "$extract_dir"
|
||||||
|
mkdir -p "$extract_dir"
|
||||||
|
tar -xzf "$tarball" -C "$extract_dir"
|
||||||
|
mv "$extract_dir/python" "$runtime"
|
||||||
|
rm -rf "$extract_dir"
|
||||||
|
|
||||||
|
# PBS tarballs ship a working pip pre-installed in the bundle's
|
||||||
|
# site-packages, and `bin/python3` is a real binary (not a symlink),
|
||||||
|
# so the install is fully relocatable as-is.
|
||||||
|
#
|
||||||
|
# Install slopsmith's runtime requirements first (single source of
|
||||||
|
# truth — drift used to silently break desktop builds whenever
|
||||||
|
# slopsmith added a dep), then desktop-only extras. SLOPSMITH_DIR
|
||||||
|
# is exported by clone_slopsmith() in build-common.sh; fall back
|
||||||
|
# to local-dev paths to match bundle-slopsmith.sh's discovery so
|
||||||
|
# this script works outside CI too.
|
||||||
|
if [[ -z "${SLOPSMITH_DIR:-}" ]]; then
|
||||||
|
if [[ -d "$PROJECT_DIR/../slopsmith" ]]; then
|
||||||
|
SLOPSMITH_DIR="$PROJECT_DIR/../slopsmith"
|
||||||
|
elif [[ -d "$HOME/Repositories/slopsmith" ]]; then
|
||||||
|
SLOPSMITH_DIR="$HOME/Repositories/slopsmith"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [[ -z "${SLOPSMITH_DIR:-}" ]] || [[ ! -f "$SLOPSMITH_DIR/requirements.txt" ]]; then
|
||||||
|
echo "ERROR: slopsmith requirements.txt not found (SLOPSMITH_DIR=${SLOPSMITH_DIR:-<unset>})." >&2
|
||||||
|
echo " Expected SLOPSMITH_DIR to be exported by clone_slopsmith() in build-common.sh," >&2
|
||||||
|
echo " or slopsmith cloned next to this repo." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
"$runtime/bin/python3" -m pip install --quiet --no-cache-dir \
|
||||||
|
-r "$SLOPSMITH_DIR/requirements.txt" 2>&1 | tail -5
|
||||||
|
"$runtime/bin/python3" -m pip install --quiet --no-cache-dir \
|
||||||
|
-r "$PROJECT_DIR/.packages/python.txt" 2>&1 | tail -5
|
||||||
|
}
|
||||||
|
|
||||||
|
# Platform-specific: Return expected artifact patterns
|
||||||
|
get_expected_artifacts() {
|
||||||
|
# mac.target is "dir": electron-builder writes the unpacked
|
||||||
|
# Slopsmith.app to release/mac-arm64/ (no .dmg/.zip). Velopack's
|
||||||
|
# pack step turns that .app into the actual release assets. Glob
|
||||||
|
# mac*/ so the check also passes for an x64 (mac/) or universal
|
||||||
|
# (mac-universal/) local build — verify_artifacts expands this.
|
||||||
|
printf "%s\n" "$PROJECT_DIR/release/mac*/Slopsmith.app"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Platform-specific: Bundle system binaries
|
||||||
|
bundle_binaries_impl() {
|
||||||
|
# macOS: copy existing binaries and bundle dependencies
|
||||||
|
|
||||||
|
# ffmpeg + ffprobe (static builds, NOT brew's ffmpeg).
|
||||||
|
#
|
||||||
|
# Homebrew's stock `ffmpeg` formula (8.1.1+) no longer ships
|
||||||
|
# --enable-libvorbis. Sloppak conversion encodes .ogg with
|
||||||
|
# `-c:a libvorbis`, so a brew-ffmpeg-bundled desktop app silently
|
||||||
|
# degrades to the built-in `vorbis -strict experimental` encoder on
|
||||||
|
# user machines. Pull the matching arch's static build instead:
|
||||||
|
# - osxexperts.net for arm64 (Apple Silicon)
|
||||||
|
# - evermeet.cx for x86_64 (Intel)
|
||||||
|
# Both ship with --enable-libvorbis; URLs + SHA256 pins live in
|
||||||
|
# .build-config.json so an upstream rebuild surfaces as a SHA
|
||||||
|
# mismatch rather than a silent codec change. demucs needs ffprobe
|
||||||
|
# alongside ffmpeg (it reads stream metadata before invoking the
|
||||||
|
# encoder), so we download both from the same provider.
|
||||||
|
local arch ff_key fp_key
|
||||||
|
arch=$(uname -m)
|
||||||
|
case "$arch" in
|
||||||
|
arm64|aarch64) ff_key="ffmpeg_macos_arm64"; fp_key="ffprobe_macos_arm64" ;;
|
||||||
|
x86_64) ff_key="ffmpeg_macos_x64"; fp_key="ffprobe_macos_x64" ;;
|
||||||
|
*)
|
||||||
|
echo "Error: unsupported macOS arch: $arch" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
local bin_dir="$PROJECT_DIR/resources/bin"
|
||||||
|
mkdir -p "$bin_dir"
|
||||||
|
|
||||||
|
download_and_install_macos_ffmpeg_tool() {
|
||||||
|
# $1 = tool name (ffmpeg|ffprobe), $2 = config key
|
||||||
|
local tool="$1" key="$2"
|
||||||
|
local url sha tarball
|
||||||
|
url=$(python3 "$SCRIPT_DIR/parse-build-config.py" "$CONFIG" ".external.${key}.url")
|
||||||
|
sha=$(python3 "$SCRIPT_DIR/parse-build-config.py" "$CONFIG" ".external.${key}.sha256")
|
||||||
|
tarball="/tmp/${tool}-macos-${arch}.zip"
|
||||||
|
|
||||||
|
if [[ ! -f "$tarball" ]] || ! shasum -a 256 "$tarball" | awk '{print $1}' | grep -qx "$sha"; then
|
||||||
|
echo " Downloading $tool from $url"
|
||||||
|
curl -sL --fail --retry 5 --retry-delay 5 --retry-all-errors "$url" -o "$tarball"
|
||||||
|
fi
|
||||||
|
local actual_sha
|
||||||
|
actual_sha=$(shasum -a 256 "$tarball" | awk '{print $1}')
|
||||||
|
if [[ "$actual_sha" != "$sha" ]]; then
|
||||||
|
echo "Error: $tool zip SHA256 mismatch — upstream rebuilt under the same URL" >&2
|
||||||
|
echo " expected: $sha" >&2
|
||||||
|
echo " got: $actual_sha" >&2
|
||||||
|
echo " url: $url" >&2
|
||||||
|
echo "Update .external.${key}.sha256 in .build-config.json after verifying the new binary." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local extract_dir="/tmp/${tool}-extract-$$"
|
||||||
|
rm -rf "$extract_dir"
|
||||||
|
mkdir -p "$extract_dir"
|
||||||
|
# `find` after unzip tolerates either layout (osxexperts puts
|
||||||
|
# the binary at the zip root, evermeet does too at the moment,
|
||||||
|
# but the spec doesn't promise it). The osxexperts zip also
|
||||||
|
# ships __MACOSX/._* resource forks alongside the binary; the
|
||||||
|
# path filter avoids picking those up as the result.
|
||||||
|
unzip -q -o "$tarball" -d "$extract_dir"
|
||||||
|
local found
|
||||||
|
found=$(find "$extract_dir" -type f -name "$tool" -not -path '*/__MACOSX/*' | head -1)
|
||||||
|
if [[ -z "$found" ]]; then
|
||||||
|
echo "Error: '$tool' binary not found after unzipping $tarball — upstream layout may have changed." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cp "$found" "$bin_dir/$tool"
|
||||||
|
chmod +x "$bin_dir/$tool"
|
||||||
|
# Static builds from third-party sites carry the macOS quarantine
|
||||||
|
# xattr by default; clear it so the binary can be exec'd by the
|
||||||
|
# build steps that follow (sign-macos-binaries.sh would also
|
||||||
|
# strip this, but verify_bundled_binaries runs the binary first).
|
||||||
|
xattr -d com.apple.quarantine "$bin_dir/$tool" 2>/dev/null || true
|
||||||
|
rm -rf "$extract_dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
download_and_install_macos_ffmpeg_tool ffmpeg "$ff_key"
|
||||||
|
download_and_install_macos_ffmpeg_tool ffprobe "$fp_key"
|
||||||
|
|
||||||
|
# Sloppak conversion encodes .ogg with -c:a libvorbis. Verify the
|
||||||
|
# downloaded ffmpeg actually has the encoder — both osxexperts and
|
||||||
|
# evermeet build with --enable-libvorbis today, but pinning by SHA
|
||||||
|
# already catches binary drift; this is the runtime guarantee. The
|
||||||
|
# lib/sloppak_convert.py fallback is a safety net for unbundled
|
||||||
|
# installs, not a license to ship a libvorbis-less binary.
|
||||||
|
if ! "$bin_dir/ffmpeg" -hide_banner -encoders 2>/dev/null | grep -wq libvorbis; then
|
||||||
|
echo "Error: bundled ffmpeg lacks libvorbis encoder. Sloppak conversion would fall back to the lower-quality built-in vorbis encoder on user machines." >&2
|
||||||
|
echo "The pinned static build for arch=$arch ($ff_key) should include --enable-libvorbis; if it doesn't, pick a different upstream and update .build-config.json." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Apple Silicon only: the native arm64 ffmpeg static builds (osxexperts,
|
||||||
|
# martin-riedl) omit --enable-librubberband, so Retune's pitch-shift step
|
||||||
|
# has no `rubberband` filter and fails with "Filter not found". Bundle the
|
||||||
|
# Intel evermeet ffmpeg (which HAS rubberband) as `ffmpeg-rubberband`;
|
||||||
|
# lib/retune.py prefers it for that one step and it runs under Rosetta 2.
|
||||||
|
# Everything else keeps using the native arm64 ffmpeg (no Rosetta needed).
|
||||||
|
if [[ "$arch" == "arm64" || "$arch" == "aarch64" ]]; then
|
||||||
|
local rb_url rb_sha rb_zip rb_extract rb_found rb_actual
|
||||||
|
rb_url=$(python3 "$SCRIPT_DIR/parse-build-config.py" "$CONFIG" ".external.ffmpeg_macos_rubberband.url")
|
||||||
|
rb_sha=$(python3 "$SCRIPT_DIR/parse-build-config.py" "$CONFIG" ".external.ffmpeg_macos_rubberband.sha256")
|
||||||
|
rb_zip="/tmp/ffmpeg-rubberband-macos.zip"
|
||||||
|
if [[ ! -f "$rb_zip" ]] || ! shasum -a 256 "$rb_zip" | awk '{print $1}' | grep -qx "$rb_sha"; then
|
||||||
|
echo " Downloading ffmpeg-rubberband (Intel) from $rb_url"
|
||||||
|
curl -sL --fail --retry 5 --retry-delay 5 --retry-all-errors "$rb_url" -o "$rb_zip"
|
||||||
|
fi
|
||||||
|
rb_actual=$(shasum -a 256 "$rb_zip" | awk '{print $1}')
|
||||||
|
if [[ "$rb_actual" != "$rb_sha" ]]; then
|
||||||
|
echo "Error: ffmpeg-rubberband zip SHA256 mismatch — upstream rebuilt under the same URL" >&2
|
||||||
|
echo " expected: $rb_sha" >&2
|
||||||
|
echo " got: $rb_actual" >&2
|
||||||
|
echo " url: $rb_url" >&2
|
||||||
|
echo "Update .external.ffmpeg_macos_rubberband.sha256 in .build-config.json after verifying the new binary." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rb_extract="/tmp/ffmpeg-rubberband-extract-$$"
|
||||||
|
rm -rf "$rb_extract"; mkdir -p "$rb_extract"
|
||||||
|
unzip -q -o "$rb_zip" -d "$rb_extract"
|
||||||
|
rb_found=$(find "$rb_extract" -type f -name ffmpeg -not -path '*/__MACOSX/*' | head -1)
|
||||||
|
if [[ -z "$rb_found" ]]; then
|
||||||
|
echo "Error: 'ffmpeg' binary not found after unzipping $rb_zip — upstream layout may have changed." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cp "$rb_found" "$bin_dir/ffmpeg-rubberband"
|
||||||
|
chmod +x "$bin_dir/ffmpeg-rubberband"
|
||||||
|
xattr -d com.apple.quarantine "$bin_dir/ffmpeg-rubberband" 2>/dev/null || true
|
||||||
|
rm -rf "$rb_extract"
|
||||||
|
# Verify librubberband via the embedded `configuration:` string with
|
||||||
|
# `grep -a`, NOT by running the binary: the build host may be Apple
|
||||||
|
# Silicon without Rosetta 2, so executing this Intel binary could fail
|
||||||
|
# even though it's correct. The config string is arch-independent.
|
||||||
|
if ! grep -a -q 'enable-librubberband' "$bin_dir/ffmpeg-rubberband"; then
|
||||||
|
echo "Error: bundled ffmpeg-rubberband lacks --enable-librubberband — Retune pitch-shift would still fail on Apple Silicon." >&2
|
||||||
|
echo "Pick an Intel ffmpeg build with librubberband and update .external.ffmpeg_macos_rubberband in .build-config.json." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Retune re-encodes the shifted audio as OGG (vorbis); require libvorbis
|
||||||
|
# here too so its output isn't downgraded to the built-in encoder.
|
||||||
|
if ! grep -a -q 'enable-libvorbis' "$bin_dir/ffmpeg-rubberband"; then
|
||||||
|
echo "Error: bundled ffmpeg-rubberband lacks --enable-libvorbis — Retune output OGG would use the lower-quality built-in vorbis encoder." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " ffmpeg-rubberband (Intel, for Retune via Rosetta 2) bundled and verified."
|
||||||
|
fi
|
||||||
|
|
||||||
|
local fluidsynth_bin
|
||||||
|
fluidsynth_bin="$(command -v fluidsynth || true)"
|
||||||
|
if [[ -z "$fluidsynth_bin" ]]; then
|
||||||
|
echo "Error: fluidsynth not found on PATH. Install it with \`brew install fluid-synth\` (and ensure /opt/homebrew/bin is on PATH)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cp "$fluidsynth_bin" "$PROJECT_DIR/resources/bin/"
|
||||||
|
|
||||||
|
# vgmstream: use the local Homebrew Intel build instead of the upstream mac zip.
|
||||||
|
# The upstream vgmstream-mac.zip currently gives this Intel Mac an arm64 binary,
|
||||||
|
# which causes "Bad CPU type in executable" and pulls /opt/homebrew dependencies.
|
||||||
|
echo -e "${BLUE}=== Using Homebrew vgmstream-cli ===${NC}"
|
||||||
|
VGM_BIN="$(command -v vgmstream-cli || true)"
|
||||||
|
if [[ -z "$VGM_BIN" ]]; then
|
||||||
|
echo -e "${RED}ERROR: vgmstream-cli not found. Install it with: brew install vgmstream${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Found vgmstream-cli at: $VGM_BIN"
|
||||||
|
cp "$VGM_BIN" "$PROJECT_DIR/resources/bin/vgmstream-cli"
|
||||||
|
chmod +x "$PROJECT_DIR/resources/bin/vgmstream-cli"
|
||||||
|
|
||||||
|
echo "Copied binary details:"
|
||||||
|
ls -la "$PROJECT_DIR/resources/bin/vgmstream-cli"
|
||||||
|
file "$PROJECT_DIR/resources/bin/vgmstream-cli"
|
||||||
|
|
||||||
|
xattr -d com.apple.quarantine "$PROJECT_DIR/resources/bin/vgmstream-cli" 2>/dev/null || true
|
||||||
|
|
||||||
|
echo -e "${BLUE}=== Skipping vgmstream-cli self-test ===${NC}"
|
||||||
|
# The Homebrew Intel binary is copied and architecture-checked above.
|
||||||
|
# vgmstream-cli returns non-zero for its info/help modes, so don't block packaging here.
|
||||||
|
|
||||||
|
echo -e "${GREEN}vgmstream-cli setup complete${NC}"
|
||||||
|
|
||||||
|
# Run dylibbundler on every bundled binary so each one's brew deps
|
||||||
|
# (libfluidsynth, libspeex, libmpg123, libvorbis, libogg, ffmpeg
|
||||||
|
# libs, etc.) get copied into resources/bin/ and the binaries' load
|
||||||
|
# commands get rewritten to @executable_path/. Without this,
|
||||||
|
# vgmstream-cli (downloaded from upstream) at runtime asks dyld for
|
||||||
|
# /opt/homebrew/opt/speex/lib/libspeex.1.dylib — fine on the dev
|
||||||
|
# machine, fatal on every other Mac. ffmpeg has the same problem
|
||||||
|
# against its own brew deps. dylibbundler is idempotent and skips
|
||||||
|
# paths it has already rewritten, so the per-binary loop is safe
|
||||||
|
# even when binaries share dylibs.
|
||||||
|
if command -v dylibbundler &>/dev/null; then
|
||||||
|
for bin in fluidsynth ffmpeg ffprobe vgmstream-cli; do
|
||||||
|
local target="$PROJECT_DIR/resources/bin/$bin"
|
||||||
|
[[ -f "$target" ]] || continue
|
||||||
|
echo -e "${BLUE}Bundling ${bin} dependencies...${NC}"
|
||||||
|
dylibbundler -cd -b -of -x "$target" \
|
||||||
|
-d "$PROJECT_DIR/resources/bin" \
|
||||||
|
-p '@executable_path/'
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Sign all bundled native binaries with the Developer ID Application
|
||||||
|
# cert before verify_bundled_binaries runs them. Signing also clears
|
||||||
|
# the macOS quarantine attribute that downloaded binaries carry, so
|
||||||
|
# the verify step doesn't have to special-case quarantine. No-op
|
||||||
|
# when APPLE_SIGNING_IDENTITY is unset (local dev without a cert).
|
||||||
|
"$SCRIPT_DIR/sign-macos-binaries.sh"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run the build
|
||||||
|
main "$@"
|
||||||
|
|
||||||
|
# Post-build: notarize and staple the DMG. electron-builder notarizes
|
||||||
|
# and staples the .app, then builds + signs the DMG — but the DMG
|
||||||
|
# itself is not submitted to Apple's notary service, so it ships
|
||||||
|
# unstapled. That's fine for online installs (Gatekeeper checks the
|
||||||
|
# .app inside on first launch), but offline first launches and some
|
||||||
|
# enterprise tools want a stapled DMG. notarytool with --wait blocks
|
||||||
|
# until Apple finishes (usually 30s–3min), then stapler embeds the
|
||||||
|
# ticket so the DMG verifies offline. No-op when signing was off.
|
||||||
|
if [[ -n "${APPLE_SIGNING_IDENTITY:-}" && -n "${APPLE_ID:-}" \
|
||||||
|
&& -n "${APPLE_APP_SPECIFIC_PASSWORD:-}" \
|
||||||
|
&& -n "${APPLE_TEAM_ID:-}" ]]; then
|
||||||
|
shopt -s nullglob
|
||||||
|
for dmg in "$PROJECT_DIR"/release/*.dmg; do
|
||||||
|
echo -e "${BLUE}Notarizing $(basename "$dmg") (wait for Apple)...${NC}"
|
||||||
|
xcrun notarytool submit "$dmg" \
|
||||||
|
--apple-id "$APPLE_ID" \
|
||||||
|
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
|
||||||
|
--team-id "$APPLE_TEAM_ID" \
|
||||||
|
--wait
|
||||||
|
echo -e "${BLUE}Stapling notarization ticket to $(basename "$dmg")...${NC}"
|
||||||
|
# `notarytool submit --wait` returns when Apple's notary service
|
||||||
|
# accepts the submission, but the ticket can take an extra
|
||||||
|
# 30-60 s to propagate to CloudKit (where `stapler` reads from).
|
||||||
|
# Stapling immediately fails with `Error 65: Record not found`
|
||||||
|
# on CI roughly half the time. Retry with backoff.
|
||||||
|
staple_ok=0
|
||||||
|
for attempt in 1 2 3 4 5; do
|
||||||
|
if xcrun stapler staple "$dmg"; then
|
||||||
|
staple_ok=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo " staple attempt $attempt failed; waiting before retry..."
|
||||||
|
sleep $((attempt * 15))
|
||||||
|
done
|
||||||
|
if [[ "$staple_ok" -ne 1 ]]; then
|
||||||
|
echo -e "${RED}Failed to staple $(basename "$dmg") after 5 attempts${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
xcrun stapler validate "$dmg"
|
||||||
|
done
|
||||||
|
shopt -u nullglob
|
||||||
|
fi
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Unified release build script that dispatches to platform-specific scripts.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
|
||||||
|
# Color output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
echo "=== Slopsmith Desktop Build ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Detect platform
|
||||||
|
# CROSS-PLATFORM NOTE: MINGW*, MSYS*, and CYGWIN* patterns must all be checked
|
||||||
|
# to properly detect Windows environment. Git Bash sets OSTYPE to msys, but we
|
||||||
|
# also check for MINGW and CYGWIN to cover all Windows bash environments.
|
||||||
|
PLATFORM=""
|
||||||
|
case "$(uname -s)" in
|
||||||
|
Linux*)
|
||||||
|
PLATFORM="linux"
|
||||||
|
;;
|
||||||
|
Darwin*)
|
||||||
|
PLATFORM="macos"
|
||||||
|
;;
|
||||||
|
MINGW*|MSYS*|CYGWIN*)
|
||||||
|
PLATFORM="windows"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}Error: Unsupported platform: $(uname -s)${NC}" >&2
|
||||||
|
echo "Supported platforms: Linux, macOS, Windows (Git Bash)" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo -e "${GREEN}Platform:${NC} $PLATFORM"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
case "$PLATFORM" in
|
||||||
|
linux)
|
||||||
|
# Check if running on Ubuntu
|
||||||
|
if [[ -f /etc/os-release ]] && grep -q '^ID=ubuntu' /etc/os-release; then
|
||||||
|
# Ubuntu: Use native Ubuntu build
|
||||||
|
if [[ -f "$SCRIPT_DIR/build-linux-ubuntu.sh" ]]; then
|
||||||
|
bash "$SCRIPT_DIR/build-linux-ubuntu.sh"
|
||||||
|
else
|
||||||
|
echo -e "${RED}Error: build-linux-ubuntu.sh not found${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Other Linux: Use Docker for reproducibility across distros
|
||||||
|
if [[ -f "$SCRIPT_DIR/build-linux-docker.sh" ]]; then
|
||||||
|
bash "$SCRIPT_DIR/build-linux-docker.sh"
|
||||||
|
else
|
||||||
|
echo -e "${RED}Error: build-linux-docker.sh not found${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
macos)
|
||||||
|
if [[ -f "$SCRIPT_DIR/build-macos.sh" ]]; then
|
||||||
|
bash "$SCRIPT_DIR/build-macos.sh"
|
||||||
|
else
|
||||||
|
echo -e "${RED}Error: build-macos.sh not found${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
windows)
|
||||||
|
if [[ -f "$SCRIPT_DIR/build-windows.sh" ]]; then
|
||||||
|
bash "$SCRIPT_DIR/build-windows.sh"
|
||||||
|
else
|
||||||
|
echo -e "${RED}Error: build-windows.sh not found${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}Error: Unexpected platform: $PLATFORM${NC}" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# The exit code from the platform build script
|
||||||
|
exit_code=$?
|
||||||
|
|
||||||
|
if [[ $exit_code -eq 0 ]]; then
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}✓${NC} Build complete!"
|
||||||
|
echo "Artifacts: $PROJECT_DIR/release/"
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo -e "${RED}✗${NC} Build failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit $exit_code
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Native Windows build script
|
||||||
|
# Runs in Git Bash (Git for Windows)
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Use plain `pwd` (POSIX path). `pwd -W` returns a Windows-form path
|
||||||
|
# with backslashes on MSYS / Git Bash, which would then break `dirname`
|
||||||
|
# and `source "$SCRIPT_DIR/build-common.sh"` since those expect POSIX
|
||||||
|
# paths. If a Windows-form path is needed downstream (e.g. for cmake-js
|
||||||
|
# or a non-MSYS tool), convert at the point of use via
|
||||||
|
# `cygpath -w "$SCRIPT_DIR"`.
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
CONFIG="$PROJECT_DIR/.build-config.json"
|
||||||
|
|
||||||
|
# Platform identifier
|
||||||
|
export PLATFORM="windows"
|
||||||
|
|
||||||
|
# Check for Git Bash/MSYS
|
||||||
|
if [[ "$OSTYPE" != "msys" ]] && [[ "$OSTYPE" != "win32" ]] && [[ -z "${MSYSTEM:-}" ]]; then
|
||||||
|
echo "Error: This script must be run in Git Bash (Git for Windows)" >&2
|
||||||
|
echo "Download: https://git-scm.com/download/win" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Slopsmith Desktop Windows Build ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Color setup
|
||||||
|
export RED='\033[0;31m'
|
||||||
|
export GREEN='\033[0;32m'
|
||||||
|
export YELLOW='\033[1;33m'
|
||||||
|
export BLUE='\033[0;34m'
|
||||||
|
export NC='\033[0m'
|
||||||
|
|
||||||
|
# Source common build logic
|
||||||
|
source "$SCRIPT_DIR/build-common.sh"
|
||||||
|
|
||||||
|
# Platform-specific: Return expected artifact patterns
|
||||||
|
# Windows target is now "dir" (electron-builder unpacked only) because Velopack
|
||||||
|
# generates the installer via `vpk pack` on tagged CI runs. For non-tag builds
|
||||||
|
# (PR validation, main-branch pushes) only the unpacked dir is produced.
|
||||||
|
get_expected_artifacts() {
|
||||||
|
printf "%s\n" "$PROJECT_DIR/release/win-unpacked/*.exe"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Platform-specific: Install system dependencies
|
||||||
|
install_system_deps() {
|
||||||
|
# Windows: install via Chocolatey if available
|
||||||
|
if command -v choco.exe &>/dev/null || command -v choco &>/dev/null; then
|
||||||
|
choco install cmake ffmpeg -y --installargs 'ADD_CMAKE_TO_PATH=System' || echo "Chocolatey install may have failed, continuing..."
|
||||||
|
else
|
||||||
|
echo_warning "Chocolatey not found - skipping system package installation"
|
||||||
|
echo " Make sure cmake and ffmpeg are already in PATH"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Platform-specific: Bundle Python runtime
|
||||||
|
bundle_python_impl() {
|
||||||
|
# Windows: download embeddable Python
|
||||||
|
PYTHON_VERSION=$(python3 "$SCRIPT_DIR/parse-build-config.py" "$CONFIG" .versions.python)
|
||||||
|
PYTHON_MAJOR="${PYTHON_VERSION%%.*}"
|
||||||
|
PYTHON_MINOR="${PYTHON_VERSION#*.}"
|
||||||
|
PYTHON_EMBED_URL="https://www.python.org/ftp/python/${PYTHON_VERSION}/python-${PYTHON_VERSION}-embed-amd64.zip"
|
||||||
|
|
||||||
|
echo "Downloading Python embeddable..."
|
||||||
|
local curl_status=0
|
||||||
|
curl -sL --fail --retry 5 --retry-delay 5 --retry-all-errors "$PYTHON_EMBED_URL" -o /tmp/python-embed.zip || curl_status=$?
|
||||||
|
if [[ "$curl_status" -ne 0 ]]; then
|
||||||
|
echo_error "Failed to download Python embeddable package"
|
||||||
|
echo " URL: $PYTHON_EMBED_URL"
|
||||||
|
echo " curl exit code: $curl_status"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Wipe the existing python dir before extracting so a re-run doesn't
|
||||||
|
# leave stale files (e.g. an old ._pth from a previous Python version)
|
||||||
|
# mixed in with the freshly-extracted embeddable distribution.
|
||||||
|
rm -rf "$PROJECT_DIR/resources/python"
|
||||||
|
mkdir -p "$PROJECT_DIR/resources/python"
|
||||||
|
unzip -q /tmp/python-embed.zip -d "$PROJECT_DIR/resources/python/"
|
||||||
|
|
||||||
|
# Enable site-packages by editing the ._pth file
|
||||||
|
# IMPORTANT: On Windows embeddable Python, PYTHONPATH environment variable is IGNORED
|
||||||
|
# when a ._pth file exists (isolated mode). We must add paths directly to the .pth file.
|
||||||
|
# The embeddable zip is supposed to ship a ._pth file; if it doesn't,
|
||||||
|
# the rest of the script's PATH-injection won't work, so fail fast.
|
||||||
|
PTH_FILE=$(find "$PROJECT_DIR/resources/python" -name "*._pth" | head -1)
|
||||||
|
if [[ -z "$PTH_FILE" ]]; then
|
||||||
|
echo_error "No ._pth file found in extracted embeddable Python — upstream zip layout may have changed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -n "$PTH_FILE" ]]; then
|
||||||
|
# Enable site-packages
|
||||||
|
sed -i 's/#import site/import site/' "$PTH_FILE"
|
||||||
|
echo "Lib/site-packages" >> "$PTH_FILE"
|
||||||
|
# Add Slopsmith paths (relative to resources/python)
|
||||||
|
# These must be in the .pth file since PYTHONPATH is ignored in isolated mode
|
||||||
|
echo "# Slopsmith modules (relative to resources/python)" >> "$PTH_FILE"
|
||||||
|
echo "../slopsmith" >> "$PTH_FILE"
|
||||||
|
echo "../slopsmith/lib" >> "$PTH_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install pip
|
||||||
|
echo "Downloading pip..."
|
||||||
|
if ! curl -sL --fail --retry 5 --retry-delay 5 --retry-all-errors \
|
||||||
|
https://bootstrap.pypa.io/get-pip.py -o /tmp/get-pip.py; then
|
||||||
|
echo_error "Failed to download pip installer"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
"$PROJECT_DIR/resources/python/python.exe" /tmp/get-pip.py --quiet --no-cache-dir
|
||||||
|
|
||||||
|
# Install packages
|
||||||
|
# Install build tools first (required for building from source on Windows embeddable Python)
|
||||||
|
"$PROJECT_DIR/resources/python/python.exe" -m pip install --quiet --no-cache-dir \
|
||||||
|
setuptools wheel
|
||||||
|
# Install slopsmith runtime requirements (single source of truth —
|
||||||
|
# drift used to silently break desktop builds whenever slopsmith added
|
||||||
|
# a dep), then desktop-only extras. SLOPSMITH_DIR is exported by
|
||||||
|
# clone_slopsmith() in build-common.sh; fall back to local-dev paths
|
||||||
|
# to match bundle-slopsmith.sh's discovery so this works outside CI.
|
||||||
|
if [[ -z "${SLOPSMITH_DIR:-}" ]]; then
|
||||||
|
if [[ -d "$PROJECT_DIR/../slopsmith" ]]; then
|
||||||
|
SLOPSMITH_DIR="$PROJECT_DIR/../slopsmith"
|
||||||
|
elif [[ -d "$HOME/Repositories/slopsmith" ]]; then
|
||||||
|
SLOPSMITH_DIR="$HOME/Repositories/slopsmith"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [[ -z "${SLOPSMITH_DIR:-}" ]] || [[ ! -f "$SLOPSMITH_DIR/requirements.txt" ]]; then
|
||||||
|
echo "ERROR: slopsmith requirements.txt not found (SLOPSMITH_DIR=${SLOPSMITH_DIR:-<unset>})." >&2
|
||||||
|
echo " Expected SLOPSMITH_DIR to be exported by clone_slopsmith() in build-common.sh," >&2
|
||||||
|
echo " or slopsmith cloned next to this repo." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
"$PROJECT_DIR/resources/python/python.exe" -m pip install --quiet --no-cache-dir \
|
||||||
|
-r "$SLOPSMITH_DIR/requirements.txt"
|
||||||
|
"$PROJECT_DIR/resources/python/python.exe" -m pip install --quiet --no-cache-dir \
|
||||||
|
-r "$PROJECT_DIR/.packages/python.txt"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Usage: download_with_retries <url> <output_path> <description>
|
||||||
|
download_with_retries() {
|
||||||
|
local url="$1"
|
||||||
|
local output_path="$2"
|
||||||
|
local description="$3"
|
||||||
|
local max_attempts=3
|
||||||
|
local attempt=1
|
||||||
|
local delay=10
|
||||||
|
|
||||||
|
while [[ $attempt -le $max_attempts ]]; do
|
||||||
|
echo " Downloading $description (attempt $attempt/$max_attempts)..."
|
||||||
|
if curl -sL --fail --max-time 120 "$url" -o "$output_path"; then
|
||||||
|
echo " Successfully downloaded $description"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local exit_code=$?
|
||||||
|
echo " Download failed with exit code $exit_code"
|
||||||
|
|
||||||
|
if [[ $attempt -lt $max_attempts ]]; then
|
||||||
|
echo " Retrying in ${delay}s..."
|
||||||
|
sleep $delay
|
||||||
|
delay=$((delay * 2))
|
||||||
|
fi
|
||||||
|
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
echo_error "Failed to download $description after $max_attempts attempts"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Platform-specific: Bundle system binaries
|
||||||
|
# These binaries are REQUIRED for core functionality. The build will fail
|
||||||
|
# if downloads don't succeed after multiple retry attempts.
|
||||||
|
bundle_binaries_impl() {
|
||||||
|
mkdir -p "$PROJECT_DIR/resources/bin"
|
||||||
|
|
||||||
|
# ffmpeg static build
|
||||||
|
echo "Downloading ffmpeg..."
|
||||||
|
if ! download_with_retries \
|
||||||
|
"https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip" \
|
||||||
|
"/tmp/ffmpeg.zip" \
|
||||||
|
"ffmpeg"; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
unzip -q /tmp/ffmpeg.zip -d /tmp/ffmpeg
|
||||||
|
# Validate the expected layout instead of `cp ... || true` — a broken
|
||||||
|
# / changed zip layout would otherwise drop `ffmpeg.exe` silently and
|
||||||
|
# surface as a less-direct error in `verify_bundled_binaries`.
|
||||||
|
FFMPEG_BIN=$(find /tmp/ffmpeg -name 'ffmpeg.exe' -type f | head -1)
|
||||||
|
if [[ -z "$FFMPEG_BIN" ]]; then
|
||||||
|
echo_error "ffmpeg.exe not found after extracting /tmp/ffmpeg.zip — upstream zip layout may have changed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cp "$FFMPEG_BIN" "$PROJECT_DIR/resources/bin/"
|
||||||
|
|
||||||
|
# Sloppak conversion encodes .ogg with -c:a libvorbis. The BtbN GPL
|
||||||
|
# build typically ships libvorbis, but verify so a future upstream
|
||||||
|
# change doesn't silently degrade users to the built-in vorbis
|
||||||
|
# encoder. The lib/sloppak_convert.py fallback is a safety net for
|
||||||
|
# unbundled installs, not a license to ship a libvorbis-less binary.
|
||||||
|
if ! "$PROJECT_DIR/resources/bin/ffmpeg.exe" -hide_banner -encoders 2>/dev/null | grep -wq libvorbis; then
|
||||||
|
echo_error "bundled ffmpeg lacks libvorbis encoder. Sloppak conversion would fall back to the lower-quality built-in vorbis encoder on user machines."
|
||||||
|
echo_error "BtbN's GPL build no longer includes --enable-libvorbis; pick a different release asset (or earlier build) that ships it."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ffprobe ships in the same BtbN zip as ffmpeg. demucs's audio loader
|
||||||
|
# spawns ffprobe before ffmpeg to read stream metadata; without it the
|
||||||
|
# loader dies with FileNotFoundError before ffmpeg is ever invoked.
|
||||||
|
FFPROBE_BIN=$(find /tmp/ffmpeg -name 'ffprobe.exe' -type f | head -1)
|
||||||
|
if [[ -z "$FFPROBE_BIN" ]]; then
|
||||||
|
echo_error "ffprobe.exe not found after extracting /tmp/ffmpeg.zip — upstream zip layout may have changed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cp "$FFPROBE_BIN" "$PROJECT_DIR/resources/bin/"
|
||||||
|
|
||||||
|
# vgmstream-cli
|
||||||
|
echo "Downloading vgmstream-cli..."
|
||||||
|
if ! download_with_retries \
|
||||||
|
"https://github.com/vgmstream/vgmstream/releases/latest/download/vgmstream-win64.zip" \
|
||||||
|
"/tmp/vgmstream.zip" \
|
||||||
|
"vgmstream-cli"; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
unzip -q /tmp/vgmstream.zip -d /tmp/vgmstream
|
||||||
|
VGMSTREAM_EXE="/tmp/vgmstream/vgmstream-cli.exe"
|
||||||
|
shopt -s nullglob
|
||||||
|
VGMSTREAM_DLLS=("/tmp/vgmstream"/*.dll)
|
||||||
|
shopt -u nullglob
|
||||||
|
if [[ ! -f "$VGMSTREAM_EXE" ]]; then
|
||||||
|
echo_error "vgmstream-cli.exe not found at $VGMSTREAM_EXE after extraction"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ${#VGMSTREAM_DLLS[@]} -eq 0 ]]; then
|
||||||
|
echo_error "Expected vgmstream DLLs in /tmp/vgmstream after extraction but found none"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cp "$VGMSTREAM_EXE" "$PROJECT_DIR/resources/bin/"
|
||||||
|
cp "${VGMSTREAM_DLLS[@]}" "$PROJECT_DIR/resources/bin/"
|
||||||
|
|
||||||
|
# fluidsynth
|
||||||
|
echo "Downloading fluidsynth..."
|
||||||
|
# Reuse parse-build-config.py — Git Bash auto-converts the MSYS-style
|
||||||
|
# $CONFIG path to native Windows form when it's passed as an arg, but
|
||||||
|
# NOT when it's interpolated into an inline `python -c "...open('$CONFIG')..."`
|
||||||
|
# string. The previous inline form silently failed with set -e on
|
||||||
|
# Windows because Python opened a "/d/a/.../config.json" path that
|
||||||
|
# doesn't exist as a literal Windows path.
|
||||||
|
FS_URL=$(python3 "$SCRIPT_DIR/parse-build-config.py" "$CONFIG" .external.fluidsynth_windows.url 2>/dev/null || true)
|
||||||
|
if [[ -n "$FS_URL" ]]; then
|
||||||
|
if ! download_with_retries \
|
||||||
|
"$FS_URL" \
|
||||||
|
"/tmp/fluidsynth.zip" \
|
||||||
|
"fluidsynth"; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
unzip -q /tmp/fluidsynth.zip -d /tmp/fluidsynth
|
||||||
|
FS_BIN=$(find /tmp/fluidsynth -name 'fluidsynth.exe' -type f | head -1)
|
||||||
|
if [[ -n "$FS_BIN" ]]; then
|
||||||
|
cp "$FS_BIN" "$PROJECT_DIR/resources/bin/"
|
||||||
|
cp "$(dirname "$FS_BIN")"/*.dll "$PROJECT_DIR/resources/bin/" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo_error "Fluidsynth URL not found in build config"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo_summary "All required Windows binaries downloaded and installed"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run the build
|
||||||
|
main "$@"
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Bundle system binaries (ffmpeg, ffprobe, vgmstream-cli, fluidsynth)
|
||||||
|
# into resources/bin/ along with their non-glibc shared library
|
||||||
|
# dependencies, then set RPATH=$ORIGIN so each binary loads its
|
||||||
|
# siblings from its own directory at runtime.
|
||||||
|
#
|
||||||
|
# Without this, a build host with a different ffmpeg ABI than the user
|
||||||
|
# (e.g. Ubuntu 22.04 ffmpeg 4.x → libav*.so.58, Fedora 44 / Arch
|
||||||
|
# ffmpeg 7.x → libav*.so.62) ships a binary the user can't load.
|
||||||
|
#
|
||||||
|
# Linux-only. macOS bundling runs dylibbundler inline in the CI workflow
|
||||||
|
# because it's a different dynamic-linker story (Mach-O load paths +
|
||||||
|
# codesign invalidation). Windows downloads pinned zip archives inline.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ "$(uname -s)" != "Linux" ]; then
|
||||||
|
echo "bundle-binaries.sh is Linux-only. macOS/Windows bundling runs inline in CI." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
BIN_DIR="$PROJECT_DIR/resources/bin"
|
||||||
|
|
||||||
|
mkdir -p "$BIN_DIR"
|
||||||
|
echo "=== Bundling system binaries ==="
|
||||||
|
|
||||||
|
# patchelf is required to set RPATH=$ORIGIN on bundled binaries and
|
||||||
|
# libs. Without that, the runtime linker on the user's machine falls
|
||||||
|
# back to /usr/lib and fails when the host ABI doesn't match the build
|
||||||
|
# host's. Fail here rather than letting fluidsynth's section fail later.
|
||||||
|
if ! command -v patchelf >/dev/null 2>&1; then
|
||||||
|
echo "ERROR: patchelf not found on PATH (apt: patchelf) - required to set RPATH on bundled binaries." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# is_skipped_lib() — the glibc/loader skip list, shared verbatim with
|
||||||
|
# build-common.sh's audit so the two never drift.
|
||||||
|
source "$SCRIPT_DIR/bundled-lib-skiplist.sh"
|
||||||
|
|
||||||
|
# Copy every non-glibc shared library that the given binary links to
|
||||||
|
# into resources/bin/. The final patchelf sweep (below) sets RPATH on
|
||||||
|
# every binary and every .so so the directory becomes a single
|
||||||
|
# self-contained load tree.
|
||||||
|
bundle_with_deps() {
|
||||||
|
local bin_path="$1"
|
||||||
|
# ldd exits non-zero (and prints "not a dynamic executable") on
|
||||||
|
# statically linked binaries like the vgmstream-cli GitHub release.
|
||||||
|
# Trap that and treat it as "no deps to bundle" rather than letting
|
||||||
|
# pipefail kill the build.
|
||||||
|
local ldd_out
|
||||||
|
if ! ldd_out=$(ldd "$bin_path" 2>/dev/null); then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "$ldd_out" | awk '/=>/ {print $3}' | while read -r lib; do
|
||||||
|
[ -n "$lib" ] && [ -f "$lib" ] || continue
|
||||||
|
if is_skipped_lib "$(basename "$lib")"; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
# -L follows symlinks; -n avoids re-copying a lib already
|
||||||
|
# contributed by an earlier binary (every bundled binary on
|
||||||
|
# the same build host links the same /usr/lib versions, so
|
||||||
|
# first-wins is safe). Errors are NOT suppressed: -n makes the
|
||||||
|
# already-bundled case a no-op exit 0, so any non-zero status
|
||||||
|
# here is a real copy failure that must fail the build.
|
||||||
|
cp -Ln "$lib" "$BIN_DIR/"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# ffmpeg - used for WAV → OGG transcoding on GP5 imports.
|
||||||
|
# verify_bundled_binaries treats resources/bin/ffmpeg as required and
|
||||||
|
# will hard-fail later if it's missing — fail here with the actual cause
|
||||||
|
# rather than letting that downstream check produce a less-direct error.
|
||||||
|
if command -v ffmpeg >/dev/null 2>&1; then
|
||||||
|
cp "$(which ffmpeg)" "$BIN_DIR/"
|
||||||
|
echo " ffmpeg: $(ls -lh "$BIN_DIR/ffmpeg" | awk '{print $5}')"
|
||||||
|
else
|
||||||
|
echo "ERROR: ffmpeg not found on PATH; resources/bin/ffmpeg is required for the bundled build (apt: ffmpeg / brew: ffmpeg)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Sloppak conversion encodes .ogg with -c:a libvorbis. Fail the build now
|
||||||
|
# rather than ship an ffmpeg that produces "Unknown encoder 'libvorbis'"
|
||||||
|
# at runtime on user machines. The lib/sloppak_convert.py fallback to
|
||||||
|
# the built-in `vorbis -strict experimental` encoder is a safety net for
|
||||||
|
# unbundled installs, not a license to ship a libvorbis-less binary.
|
||||||
|
if ! "$BIN_DIR/ffmpeg" -hide_banner -encoders 2>/dev/null | grep -wq libvorbis; then
|
||||||
|
echo "ERROR: bundled ffmpeg lacks libvorbis encoder. Sloppak conversion would fall back to the lower-quality built-in vorbis encoder on user machines." >&2
|
||||||
|
echo "Install an ffmpeg built with --enable-libvorbis (apt's ffmpeg ships it by default; check your distro's package if this fails)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
bundle_with_deps "$BIN_DIR/ffmpeg"
|
||||||
|
|
||||||
|
# ffprobe - demucs's audio loader spawns ffprobe before ffmpeg to read
|
||||||
|
# stream metadata; falling through to a host-installed ffprobe (or none
|
||||||
|
# at all) means the desktop bundle behaves differently on each user's
|
||||||
|
# machine. Ship the build host's ffprobe alongside ffmpeg so the bundle
|
||||||
|
# is self-contained on every platform. apt's ffmpeg package includes
|
||||||
|
# ffprobe, so this is universally available where ffmpeg already is.
|
||||||
|
if command -v ffprobe >/dev/null 2>&1; then
|
||||||
|
cp "$(which ffprobe)" "$BIN_DIR/"
|
||||||
|
echo " ffprobe: $(ls -lh "$BIN_DIR/ffprobe" | awk '{print $5}')"
|
||||||
|
else
|
||||||
|
echo "ERROR: ffprobe not found on PATH; resources/bin/ffprobe is required so demucs can read stream metadata in stem-splitting (apt: ffmpeg / brew: ffmpeg)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
bundle_with_deps "$BIN_DIR/ffprobe"
|
||||||
|
|
||||||
|
# vgmstream-cli - used for WEM → WAV decoding.
|
||||||
|
# Download from GitHub releases if not in PATH (CI does this inline).
|
||||||
|
# verify_bundled_binaries downstream treats this as required and will
|
||||||
|
# hard-fail if it ends up missing — fail here with the actual cause
|
||||||
|
# instead.
|
||||||
|
if command -v vgmstream-cli >/dev/null 2>&1; then
|
||||||
|
cp "$(which vgmstream-cli)" "$BIN_DIR/"
|
||||||
|
echo " vgmstream-cli: $(ls -lh "$BIN_DIR/vgmstream-cli" | awk '{print $5}')"
|
||||||
|
else
|
||||||
|
for tool in curl unzip; do
|
||||||
|
if ! command -v "$tool" >/dev/null 2>&1; then
|
||||||
|
echo "ERROR: $tool not on PATH; required to download/extract vgmstream-cli." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Downloading vgmstream-cli from GitHub releases..."
|
||||||
|
VGM_ASSET="vgmstream-linux.zip"
|
||||||
|
if ! curl -sL --fail --retry 5 --retry-delay 5 --retry-all-errors \
|
||||||
|
"https://github.com/vgmstream/vgmstream/releases/latest/download/${VGM_ASSET}" \
|
||||||
|
-o /tmp/vgmstream.zip; then
|
||||||
|
echo "ERROR: failed to download vgmstream-cli zip from upstream releases." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! unzip -q /tmp/vgmstream.zip -d /tmp/vgmstream; then
|
||||||
|
echo "ERROR: failed to extract /tmp/vgmstream.zip — upstream archive may be malformed." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
VGM_BIN=$(find /tmp/vgmstream -maxdepth 2 -name 'vgmstream-cli' -type f | head -1)
|
||||||
|
if [ -z "$VGM_BIN" ]; then
|
||||||
|
echo "ERROR: vgmstream-cli binary not found in downloaded archive — upstream zip layout may have changed." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cp "$VGM_BIN" "$BIN_DIR/vgmstream-cli"
|
||||||
|
chmod +x "$BIN_DIR/vgmstream-cli"
|
||||||
|
echo " vgmstream-cli: $(ls -lh "$BIN_DIR/vgmstream-cli" | awk '{print $5}') (downloaded)"
|
||||||
|
rm -rf /tmp/vgmstream /tmp/vgmstream.zip
|
||||||
|
fi
|
||||||
|
|
||||||
|
bundle_with_deps "$BIN_DIR/vgmstream-cli"
|
||||||
|
|
||||||
|
# fluidsynth - used for MIDI → WAV in GP5 imports.
|
||||||
|
if command -v fluidsynth >/dev/null 2>&1; then
|
||||||
|
cp "$(which fluidsynth)" "$BIN_DIR/fluidsynth"
|
||||||
|
echo " fluidsynth: $(ls -lh "$BIN_DIR/fluidsynth" | awk '{print $5}')"
|
||||||
|
else
|
||||||
|
echo "ERROR: fluidsynth not found on PATH - it is required to bundle GP5 import support. Install fluidsynth and rerun this script (apt: fluidsynth)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
bundle_with_deps "$BIN_DIR/fluidsynth"
|
||||||
|
|
||||||
|
# Final patchelf sweep: every dynamic binary gets RPATH=$ORIGIN, and
|
||||||
|
# every dynamic .so does too so transitive deps also load from
|
||||||
|
# resources/bin/. Done once at the end so the order in which binaries
|
||||||
|
# contribute their libs doesn't matter.
|
||||||
|
#
|
||||||
|
# Be strict about patchelf success on dynamic binaries. The downstream
|
||||||
|
# audit only checks lib *presence*, not RPATH — so a silent patchelf
|
||||||
|
# failure here would ship a binary whose loader falls back to /usr/lib
|
||||||
|
# at runtime, exactly the issue #68 regression. Detect static-vs-dynamic
|
||||||
|
# explicitly via NEEDED entries in the .dynamic section and only skip
|
||||||
|
# patchelf on the static case (e.g. the vgmstream-cli GitHub release).
|
||||||
|
for bin in ffmpeg ffprobe vgmstream-cli fluidsynth; do
|
||||||
|
bin_path="$BIN_DIR/$bin"
|
||||||
|
if readelf -d "$bin_path" 2>/dev/null | grep -q '(NEEDED)'; then
|
||||||
|
patchelf --set-rpath '$ORIGIN' "$bin_path"
|
||||||
|
else
|
||||||
|
echo " $bin: statically linked, RPATH not applicable"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
for so in "$BIN_DIR"/*.so*; do
|
||||||
|
[ -f "$so" ] || continue
|
||||||
|
if readelf -d "$so" 2>/dev/null | grep -q '(NEEDED)'; then
|
||||||
|
patchelf --set-rpath '$ORIGIN' "$so"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo " Total resources/bin/: $(du -sh "$BIN_DIR" | cut -f1)"
|
||||||
|
echo "=== Binary bundle complete ==="
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Bundle a portable Python runtime into resources/python/runtime/ for Linux.
|
||||||
|
#
|
||||||
|
# Produces a relocatable interpreter + stdlib that the Electron main process
|
||||||
|
# spawns via `python.ts`. The layout matches python.ts's expectations:
|
||||||
|
# resources/python/runtime/bin/python3
|
||||||
|
#
|
||||||
|
# Downloads python-build-standalone (same source as actions/setup-python in
|
||||||
|
# CI) so the result is identical regardless of what Python is installed on the
|
||||||
|
# host or in the Docker container.
|
||||||
|
#
|
||||||
|
# Linux-only: macOS and Windows bundles are handled inline in build-common.sh.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ "$(uname -s)" != "Linux" ]; then
|
||||||
|
echo "bundle-python.sh is Linux-only. macOS and Windows bundles run inline in CI." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
CONFIG="$PROJECT_DIR/.build-config.json"
|
||||||
|
|
||||||
|
PYTHON_FULL_VERSION=$(python3 "$SCRIPT_DIR/parse-build-config.py" "$CONFIG" .versions.python)
|
||||||
|
PYTHON_VERSION="${PYTHON_FULL_VERSION%.*}"
|
||||||
|
|
||||||
|
PYTHON_STANDALONE_URL=$(python3 "$SCRIPT_DIR/parse-build-config.py" "$CONFIG" .external.python_standalone_linux_x64.url)
|
||||||
|
PYTHON_STANDALONE_SHA256=$(python3 "$SCRIPT_DIR/parse-build-config.py" "$CONFIG" .external.python_standalone_linux_x64.sha256)
|
||||||
|
|
||||||
|
PYTHON_BUNDLE="$PROJECT_DIR/resources/python/runtime"
|
||||||
|
|
||||||
|
echo "=== Bundling Python $PYTHON_FULL_VERSION runtime ==="
|
||||||
|
echo " Downloading python-build-standalone..."
|
||||||
|
|
||||||
|
TMPDIR_PBS=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$TMPDIR_PBS"' EXIT
|
||||||
|
|
||||||
|
curl -fsSL "$PYTHON_STANDALONE_URL" -o "$TMPDIR_PBS/python-standalone.tar.gz"
|
||||||
|
echo "${PYTHON_STANDALONE_SHA256} $TMPDIR_PBS/python-standalone.tar.gz" | sha256sum -c -
|
||||||
|
tar -xzf "$TMPDIR_PBS/python-standalone.tar.gz" -C "$TMPDIR_PBS"
|
||||||
|
|
||||||
|
PBS_PREFIX="$TMPDIR_PBS/python"
|
||||||
|
|
||||||
|
rm -rf "$PROJECT_DIR/resources/python"
|
||||||
|
mkdir -p "$PYTHON_BUNDLE/bin" "$PYTHON_BUNDLE/lib"
|
||||||
|
|
||||||
|
cp "$PBS_PREFIX/bin/python${PYTHON_VERSION}" "$PYTHON_BUNDLE/bin/python3"
|
||||||
|
chmod +x "$PYTHON_BUNDLE/bin/python3"
|
||||||
|
cp -r "$PBS_PREFIX/lib/python${PYTHON_VERSION}" "$PYTHON_BUNDLE/lib/"
|
||||||
|
cp "$PBS_PREFIX/lib"/libpython${PYTHON_VERSION}*.so* "$PYTHON_BUNDLE/lib/"
|
||||||
|
|
||||||
|
echo " Bootstrapping pip via ensurepip in the bundled runtime"
|
||||||
|
LD_LIBRARY_PATH="$PYTHON_BUNDLE/lib" "$PYTHON_BUNDLE/bin/python3" -m ensurepip --upgrade --default-pip
|
||||||
|
|
||||||
|
# Resolve the slopsmith repo so we can pip install from its
|
||||||
|
# requirements.txt — that's the single source of truth for runtime
|
||||||
|
# deps. Search order matches bundle-slopsmith.sh:
|
||||||
|
# 1. $SLOPSMITH_DIR env var (set by clone_slopsmith() in CI)
|
||||||
|
# 2. ../slopsmith (sibling to this repo)
|
||||||
|
# 3. ~/Repositories/slopsmith (legacy dev layout)
|
||||||
|
if [ -z "${SLOPSMITH_DIR:-}" ]; then
|
||||||
|
if [ -d "$PROJECT_DIR/../slopsmith" ]; then
|
||||||
|
SLOPSMITH_DIR="$PROJECT_DIR/../slopsmith"
|
||||||
|
elif [ -d "$HOME/Repositories/slopsmith" ]; then
|
||||||
|
SLOPSMITH_DIR="$HOME/Repositories/slopsmith"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ -z "${SLOPSMITH_DIR:-}" ] || [ ! -f "$SLOPSMITH_DIR/requirements.txt" ]; then
|
||||||
|
echo "ERROR: slopsmith requirements.txt not found." >&2
|
||||||
|
echo "Searched:" >&2
|
||||||
|
echo " \$SLOPSMITH_DIR=${SLOPSMITH_DIR:-<unset>}" >&2
|
||||||
|
echo " $PROJECT_DIR/../slopsmith" >&2
|
||||||
|
echo " $HOME/Repositories/slopsmith" >&2
|
||||||
|
echo "Clone slopsmith next to this repo: git clone https://github.com/slopsmith/slopsmith.git $PROJECT_DIR/../slopsmith" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " Installing slopsmith runtime requirements ($SLOPSMITH_DIR/requirements.txt)"
|
||||||
|
LD_LIBRARY_PATH="$PYTHON_BUNDLE/lib" "$PYTHON_BUNDLE/bin/python3" -m pip install --quiet --no-cache-dir \
|
||||||
|
-r "$SLOPSMITH_DIR/requirements.txt" 2>&1 | tail -3
|
||||||
|
|
||||||
|
echo " Installing desktop-only Python extras"
|
||||||
|
LD_LIBRARY_PATH="$PYTHON_BUNDLE/lib" "$PYTHON_BUNDLE/bin/python3" -m pip install --quiet --no-cache-dir \
|
||||||
|
-r "$PROJECT_DIR/.packages/python.txt" 2>&1 | tail -3
|
||||||
|
|
||||||
|
echo " Python runtime size: $(du -sh "$PYTHON_BUNDLE" | cut -f1)"
|
||||||
|
echo "=== Python bundle complete ==="
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Bundle the Slopsmith server source + plugins into resources/slopsmith/.
|
||||||
|
#
|
||||||
|
# Slopsmith repo location is resolved in this order:
|
||||||
|
# 1. $SLOPSMITH_DIR env var
|
||||||
|
# 2. ../slopsmith (sibling to this repo)
|
||||||
|
# 3. ~/Repositories/slopsmith (legacy dev layout)
|
||||||
|
#
|
||||||
|
# Cross-platform: avoids `readlink -f` (not available on macOS by default)
|
||||||
|
# by using python's os.path.realpath. `rsync` is used for the resolved-
|
||||||
|
# symlink copy step and must be present (see .packages/apt.txt).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
BUNDLE_DIR="$PROJECT_DIR/resources/slopsmith"
|
||||||
|
|
||||||
|
if [ -z "${SLOPSMITH_DIR:-}" ]; then
|
||||||
|
if [ -d "$PROJECT_DIR/../slopsmith" ]; then
|
||||||
|
SLOPSMITH_DIR="$PROJECT_DIR/../slopsmith"
|
||||||
|
elif [ -d "$HOME/Repositories/slopsmith" ]; then
|
||||||
|
SLOPSMITH_DIR="$HOME/Repositories/slopsmith"
|
||||||
|
else
|
||||||
|
SLOPSMITH_DIR=""
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$SLOPSMITH_DIR" ] || [ ! -d "$SLOPSMITH_DIR" ]; then
|
||||||
|
echo "ERROR: Slopsmith repository not found." >&2
|
||||||
|
echo "Searched:" >&2
|
||||||
|
echo " \$SLOPSMITH_DIR (unset)" >&2
|
||||||
|
echo " $PROJECT_DIR/../slopsmith" >&2
|
||||||
|
echo " $HOME/Repositories/slopsmith" >&2
|
||||||
|
echo "Clone it with: git clone https://github.com/slopsmith/slopsmith.git ../slopsmith" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Portable realpath — readlink -f doesn't exist on stock macOS.
|
||||||
|
realpath_portable() {
|
||||||
|
python3 -c 'import os, sys; print(os.path.realpath(sys.argv[1]))' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "=== Bundling Slopsmith server and plugins ==="
|
||||||
|
echo " Source: $SLOPSMITH_DIR"
|
||||||
|
|
||||||
|
rm -rf "$BUNDLE_DIR"
|
||||||
|
mkdir -p "$BUNDLE_DIR/static" "$BUNDLE_DIR/plugins"
|
||||||
|
|
||||||
|
# Server + lib
|
||||||
|
cp "$SLOPSMITH_DIR/server.py" "$BUNDLE_DIR/"
|
||||||
|
cp "$SLOPSMITH_DIR/VERSION" "$BUNDLE_DIR/"
|
||||||
|
cp -r "$SLOPSMITH_DIR/lib" "$BUNDLE_DIR/"
|
||||||
|
rm -rf "$BUNDLE_DIR/lib/__pycache__"
|
||||||
|
|
||||||
|
# Bundled content (progression paths, quests, shop definitions)
|
||||||
|
[ -d "$SLOPSMITH_DIR/data" ] && cp -r "$SLOPSMITH_DIR/data" "$BUNDLE_DIR/"
|
||||||
|
|
||||||
|
# Static assets — copy the whole directory. User-data dirs (art/, sloppak_cache/)
|
||||||
|
# and generated audio_*.mp3 files are gitignored and won't exist in a clean checkout.
|
||||||
|
cp -r "$SLOPSMITH_DIR/static/." "$BUNDLE_DIR/static/"
|
||||||
|
# Strip any leftover user-data that may exist in a dev checkout.
|
||||||
|
rm -rf "$BUNDLE_DIR/static/art" "$BUNDLE_DIR/static/sloppak_cache"
|
||||||
|
find "$BUNDLE_DIR/static" -maxdepth 1 -name 'audio_*.mp3' -delete
|
||||||
|
|
||||||
|
# Builtin diagnostic sloppak — server._seed_builtin_diagnostic_sloppaks() copies
|
||||||
|
# this into DLC_DIR/diagnostics-builtin/ on library scan startup.
|
||||||
|
DIAG_SLOPPAK="$SLOPSMITH_DIR/docs/diagnostics/slopsmith-diagnostic-basic-guitar.sloppak"
|
||||||
|
if [ -f "$DIAG_SLOPPAK" ]; then
|
||||||
|
mkdir -p "$BUNDLE_DIR/docs/diagnostics"
|
||||||
|
cp "$DIAG_SLOPPAK" "$BUNDLE_DIR/docs/diagnostics/"
|
||||||
|
else
|
||||||
|
echo "WARNING: diagnostic sloppak not found at $DIAG_SLOPPAK — builtin seeding will skip in packaged builds" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Cross-platform "cp -r minus .git" — Git Bash on Windows doesn't ship
|
||||||
|
# rsync, so we can't rely on `rsync --exclude=.git`. Plain `cp -r`
|
||||||
|
# followed by stripping any nested `.git/` directories works on
|
||||||
|
# Linux/macOS/Git-Bash alike. The .git stripping matters because
|
||||||
|
# plugin directories cloned by clone_slopsmith() are git working trees;
|
||||||
|
# bundling their .git/objects/ would inflate the .app and (on macOS)
|
||||||
|
# trip electron-builder with EACCES on read-only pack files.
|
||||||
|
copy_plugin() {
|
||||||
|
local src="$1"
|
||||||
|
local dst="$2"
|
||||||
|
mkdir -p "$dst"
|
||||||
|
# Use cp -R rather than -r for portable symlink-following semantics.
|
||||||
|
cp -R "$src/." "$dst/"
|
||||||
|
find "$dst" -name '.git' -type d -prune -exec rm -rf {} +
|
||||||
|
}
|
||||||
|
|
||||||
|
# Built-in plugins (real directories, not symlinks to avoid duplicates).
|
||||||
|
for plugin_dir in "$SLOPSMITH_DIR/plugins/editor" "$SLOPSMITH_DIR/plugins/note_detect"; do
|
||||||
|
if [ -d "$plugin_dir" ] && [ ! -L "$plugin_dir" ]; then
|
||||||
|
name=$(basename "$plugin_dir")
|
||||||
|
copy_plugin "$plugin_dir" "$BUNDLE_DIR/plugins/$name"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# External plugins: resolve symlinks, skip .git
|
||||||
|
for plugin_link in "$SLOPSMITH_DIR/plugins/"*; do
|
||||||
|
name=$(basename "$plugin_link")
|
||||||
|
[ "$name" = "__pycache__" ] && continue
|
||||||
|
[ "$name" = "__init__.py" ] && continue
|
||||||
|
target="$BUNDLE_DIR/plugins/$name"
|
||||||
|
[ -d "$target" ] && continue # already copied
|
||||||
|
|
||||||
|
if [ -L "$plugin_link" ]; then
|
||||||
|
real_dir=$(realpath_portable "$plugin_link")
|
||||||
|
if [ -d "$real_dir" ]; then
|
||||||
|
copy_plugin "$real_dir" "$target"
|
||||||
|
fi
|
||||||
|
elif [ -d "$plugin_link" ]; then
|
||||||
|
copy_plugin "$plugin_link" "$target"
|
||||||
|
elif [ -f "$plugin_link" ]; then
|
||||||
|
cp "$plugin_link" "$target"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Plugin-discovery __init__.py
|
||||||
|
cp "$SLOPSMITH_DIR/plugins/__init__.py" "$BUNDLE_DIR/plugins/"
|
||||||
|
|
||||||
|
# Desktop-specific plugins (audio_engine, plugin_manager) declared in
|
||||||
|
# src/renderer/**/plugin.json
|
||||||
|
for dp in "$PROJECT_DIR/src/renderer" "$PROJECT_DIR/src/renderer/plugin-manager"; do
|
||||||
|
if [ -f "$dp/plugin.json" ]; then
|
||||||
|
pname=$(python3 -c "import json, sys; print(json.load(open(sys.argv[1]))['id'])" "$dp/plugin.json")
|
||||||
|
mkdir -p "$BUNDLE_DIR/plugins/$pname"
|
||||||
|
cp "$dp"/*.html "$dp"/*.js "$dp"/plugin.json "$BUNDLE_DIR/plugins/$pname/" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Rebuild Tailwind CSS over the FULL bundled plugin set ──────────────────
|
||||||
|
# Core's committed static/tailwind.min.css is built scanning only the in-tree
|
||||||
|
# plugins (highway_3d, editor, note_detect, app_tour_*). Shipped as-is it would
|
||||||
|
# leave most of the 30+ bundled plugins' classes unstyled — the Play CDN's
|
||||||
|
# runtime JIT used to cover them, but it was removed (slopsmith#411). So
|
||||||
|
# regenerate the sheet HERE, after every plugin is copied, so it covers the
|
||||||
|
# whole bundled set and scales automatically as more plugins are added.
|
||||||
|
#
|
||||||
|
# We reuse core's tailwind.config.js for parity (same theme colors, safelist,
|
||||||
|
# and the highway_3d exclusion — that plugin ships its own assets/plugin.css
|
||||||
|
# via the `styles` capability). The config is copied into the bundle and run
|
||||||
|
# from there so its relative content globs (./static/**, ./plugins/**) resolve
|
||||||
|
# against the bundle regardless of Tailwind's cwd-vs-config-dir semantics.
|
||||||
|
if command -v npx >/dev/null 2>&1; then
|
||||||
|
echo "=== Rebuilding Tailwind CSS over bundled plugins ==="
|
||||||
|
# Whole pipeline runs inside one guarded `if (...)` so ANY failure (config
|
||||||
|
# copy, no npm cache/network, build error, or the final swap) falls back to
|
||||||
|
# the committed sheet instead of aborting the bundle under `set -e`. Two
|
||||||
|
# subtleties: (1) `set -e` is suppressed inside an `if` condition, so the
|
||||||
|
# steps are &&-chained to make an early failure short-circuit; (2) the build
|
||||||
|
# writes to a temp file that's mv'd into place only as the last link, so a
|
||||||
|
# failed/partial `npx` can never truncate the committed fallback sheet.
|
||||||
|
if (
|
||||||
|
cp "$SLOPSMITH_DIR/tailwind.config.js" "$BUNDLE_DIR/tailwind.config.js" \
|
||||||
|
&& cd "$BUNDLE_DIR" \
|
||||||
|
&& npx -y tailwindcss@3.4.19 \
|
||||||
|
-c tailwind.config.js \
|
||||||
|
-i static/_tailwind.src.css \
|
||||||
|
-o static/tailwind.min.css.new \
|
||||||
|
--minify \
|
||||||
|
&& mv -f static/tailwind.min.css.new static/tailwind.min.css
|
||||||
|
); then
|
||||||
|
# Drop the build-only inputs so they don't ship in resources/slopsmith.
|
||||||
|
# `|| true`: cleanup is best-effort — a stray rm failure (e.g. Windows
|
||||||
|
# file locks) must never abort the bundle under `set -e`.
|
||||||
|
rm -f "$BUNDLE_DIR/tailwind.config.js" "$BUNDLE_DIR/static/_tailwind.src.css" || true
|
||||||
|
echo " Tailwind CSS: $(wc -c < "$BUNDLE_DIR/static/tailwind.min.css") bytes (bundled-plugin-aware)"
|
||||||
|
else
|
||||||
|
# Discard any partial output; the committed sheet copied earlier stays
|
||||||
|
# intact. Still drop the build-only input so it never ships (matches the
|
||||||
|
# success path). `|| true` keeps cleanup non-fatal.
|
||||||
|
rm -f "$BUNDLE_DIR/tailwind.config.js" "$BUNDLE_DIR/static/tailwind.min.css.new" "$BUNDLE_DIR/static/_tailwind.src.css" || true
|
||||||
|
echo "WARN: Tailwind rebuild failed — shipping core's committed sheet as-is." >&2
|
||||||
|
echo " Bundled plugins using classes outside it may render unstyled." >&2
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# No rebuild engine; still drop the build-only input so bundle contents are
|
||||||
|
# consistent regardless of whether the rebuild ran. `|| true` keeps it
|
||||||
|
# non-fatal under `set -e`.
|
||||||
|
rm -f "$BUNDLE_DIR/static/_tailwind.src.css" || true
|
||||||
|
echo "WARN: npx/node not found — shipping core's committed tailwind.min.css as-is." >&2
|
||||||
|
echo " Bundled plugins using classes outside core's sheet may render unstyled." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " Slopsmith server: $(du -sh "$BUNDLE_DIR" | cut -f1)"
|
||||||
|
echo " Plugins: $(ls -d "$BUNDLE_DIR/plugins/"*/ 2>/dev/null | wc -l)"
|
||||||
|
echo "=== Slopsmith bundle complete ==="
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Download + verify the default General-MIDI soundfont into
|
||||||
|
# resources/soundfonts/. Used by both CI and local `npm run bundle` on
|
||||||
|
# all three platforms.
|
||||||
|
#
|
||||||
|
# URL + SHA256 are read from .build-config.json (external.soundfont_general_user).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
CONFIG="$PROJECT_DIR/.build-config.json"
|
||||||
|
|
||||||
|
SF_URL=$(python3 "$SCRIPT_DIR/parse-build-config.py" "$CONFIG" .external.soundfont_general_user.url)
|
||||||
|
SF_SHA256=$(python3 "$SCRIPT_DIR/parse-build-config.py" "$CONFIG" .external.soundfont_general_user.sha256)
|
||||||
|
SF_DIR="$PROJECT_DIR/resources/soundfonts"
|
||||||
|
SF_FILE="$SF_DIR/GeneralUser-GS.sf2"
|
||||||
|
|
||||||
|
mkdir -p "$SF_DIR"
|
||||||
|
|
||||||
|
if [ -f "$SF_FILE" ]; then
|
||||||
|
echo " Existing soundfont found — verifying checksum"
|
||||||
|
else
|
||||||
|
echo " Downloading GeneralUser-GS.sf2 (~32 MB) from $SF_URL"
|
||||||
|
curl -sL --fail --retry 5 --retry-delay 5 --retry-all-errors "$SF_URL" -o "$SF_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# macOS ships `shasum -a 256`; Linux / Windows-git-bash ship `sha256sum`.
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
echo "${SF_SHA256} ${SF_FILE}" | sha256sum -c - >/dev/null
|
||||||
|
else
|
||||||
|
echo "${SF_SHA256} ${SF_FILE}" | shasum -a 256 -c - >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " Soundfont: $(ls -lh "$SF_FILE" | awk '{print $5}') (SHA256 verified)"
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Top-level bundle delegator. Calls the per-concern modular scripts so
|
||||||
|
# local dev matches CI. Linux-focused; macOS/Windows bundling lives in
|
||||||
|
# the GitHub Actions workflow because those platforms have quite
|
||||||
|
# different packaging needs (dylibbundler, zip downloads, etc.).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
|
||||||
|
echo "=== Bundling Slopsmith Desktop ==="
|
||||||
|
|
||||||
|
bash "$SCRIPT_DIR/bundle-slopsmith.sh"
|
||||||
|
# Skip Python bundling on non-Linux platforms (handled inline in platform scripts)
|
||||||
|
if [[ "$(uname -s)" == "Linux" ]]; then
|
||||||
|
bash "$SCRIPT_DIR/bundle-python.sh"
|
||||||
|
fi
|
||||||
|
# Skip binary bundling on non-Linux platforms (handled inline in platform scripts)
|
||||||
|
if [[ "$(uname -s)" == "Linux" ]]; then
|
||||||
|
bash "$SCRIPT_DIR/bundle-binaries.sh"
|
||||||
|
fi
|
||||||
|
bash "$SCRIPT_DIR/bundle-soundfont.sh"
|
||||||
|
|
||||||
|
# Default IRs — small copy step that doesn't need its own script.
|
||||||
|
echo "=== Copying default IRs ==="
|
||||||
|
mkdir -p "$PROJECT_DIR/resources/default-irs"
|
||||||
|
cp "$PROJECT_DIR/models/cabs/"*.wav "$PROJECT_DIR/resources/default-irs/" 2>/dev/null || true
|
||||||
|
echo " Default IRs: $(ls "$PROJECT_DIR/resources/default-irs/" | wc -l) file(s)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Bundle complete ==="
|
||||||
|
echo " Total resources: $(du -sh "$PROJECT_DIR/resources" | cut -f1)"
|
||||||
|
echo ""
|
||||||
|
echo "Ready for: npm run dist"
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Shared skip list for bundled-binary shared-library handling.
|
||||||
|
#
|
||||||
|
# Sourced by both scripts/bundle-binaries.sh (which decides which libs
|
||||||
|
# to copy into resources/bin/) and scripts/build-common.sh (which
|
||||||
|
# audits that every NEEDED SONAME is satisfied). Keeping the list in
|
||||||
|
# one place prevents drift: a name present in only one copy would make
|
||||||
|
# the bundler and the audit disagree about which libs must be present.
|
||||||
|
#
|
||||||
|
# These are the low-level libc / loader pieces that MUST come from the
|
||||||
|
# user's own glibc — bundling them across distros breaks the dynamic
|
||||||
|
# linker.
|
||||||
|
|
||||||
|
is_skipped_lib() {
|
||||||
|
case "$1" in
|
||||||
|
libc.so*|libm.so*|libpthread.so*|libdl.so*|librt.so*|\
|
||||||
|
ld-linux*|libresolv.so*|linux-vdso*|linux-gate*|\
|
||||||
|
libnsl.so*|libutil.so*|libgcc_s.so*)
|
||||||
|
return 0 ;;
|
||||||
|
esac
|
||||||
|
return 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// Smoke-test harness for the Windows VST sandbox path. Loads the addon,
|
||||||
|
// spawns the sandbox subprocess for Guitar Rig 6, opens its editor, then
|
||||||
|
// closes and shuts down cleanly. Used by clean-rerun.cmd on the test VM.
|
||||||
|
//
|
||||||
|
// Run from the repo root: `node scripts/dev/load-gr6.js > load-gr6-sandbox.log`.
|
||||||
|
// The Guitar Rig 6 path is hardcoded to its standard Win11 install location;
|
||||||
|
// adjust the GR6 path below if your install differs.
|
||||||
|
'use strict';
|
||||||
|
const path = require('path');
|
||||||
|
const addonPath = path.join(process.cwd(), 'build', 'Release', 'slopsmith_audio.node');
|
||||||
|
console.log('[test] loading addon from', addonPath);
|
||||||
|
const addon = require(addonPath);
|
||||||
|
console.log('[test] addon loaded; methods:', Object.keys(addon).slice(0, 10).join(', '), '...');
|
||||||
|
|
||||||
|
// Global watchdog — best-effort coverage for *asynchronous* hang paths
|
||||||
|
// (event-loop livelocks, setTimeout-stacked cleanup). loadVST is now a
|
||||||
|
// Napi::AsyncWorker, so the libuv event loop continues to fire timers
|
||||||
|
// while the load runs on a worker thread; this watchdog can pre-empt
|
||||||
|
// a hung async load. addon.shutdown still parks the event loop
|
||||||
|
// synchronously inside dispatchOnMessageThread, so if shutdown itself
|
||||||
|
// hangs the timer callback never fires — a proper supervisor-process
|
||||||
|
// + SIGKILL watchdog belongs in the CI harness (test-suite follow-up).
|
||||||
|
//
|
||||||
|
// The timer callback hard-exits — do NOT call addon.shutdown() here,
|
||||||
|
// it would block on the same dispatchOnMessageThread the addon is
|
||||||
|
// already stuck in and deadlock the process.
|
||||||
|
const WATCHDOG_MS = 60000;
|
||||||
|
const watchdog = setTimeout(() => {
|
||||||
|
console.error(`[test] FATAL: watchdog tripped after ${WATCHDOG_MS} ms (async hang)`);
|
||||||
|
process.exit(1);
|
||||||
|
}, WATCHDOG_MS);
|
||||||
|
|
||||||
|
function failExit(msg) {
|
||||||
|
if (msg) console.log('[test] FAIL:', msg);
|
||||||
|
// addon.shutdown blocks on dispatchOnMessageThread (up to 15s) if
|
||||||
|
// JUCE init partially succeeded; an init-failure path that triggered
|
||||||
|
// *because* the message thread never came up would then time out
|
||||||
|
// before the process exits. Cap with a hard process.exit timer so a
|
||||||
|
// hung shutdown can't extend the failure window beyond 3s.
|
||||||
|
const hardKill = setTimeout(() => {
|
||||||
|
console.error('[test] FAIL: addon.shutdown hung, force-exiting');
|
||||||
|
process.exit(2);
|
||||||
|
}, 3000);
|
||||||
|
hardKill.unref();
|
||||||
|
try { addon.shutdown(); } catch (_) {}
|
||||||
|
try { clearTimeout(watchdog); } catch (_) {}
|
||||||
|
try { clearTimeout(hardKill); } catch (_) {}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[test] addon.init()');
|
||||||
|
try {
|
||||||
|
addon.init();
|
||||||
|
} catch (e) {
|
||||||
|
failExit('EXCEPTION on init: ' + e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
// Allow override for CI / dev machines whose VST3 layout differs from
|
||||||
|
// the standard "C:\Program Files\Common Files\VST3" install location.
|
||||||
|
// The default Native Instruments install ships "Guitar Rig 6.vst3";
|
||||||
|
// some installer versions or FX-only variants land as
|
||||||
|
// "Guitar Rig 6 FX.vst3". Try both before giving up.
|
||||||
|
const fs = require('fs');
|
||||||
|
const candidates = process.env.GR6_PATH
|
||||||
|
? [process.env.GR6_PATH]
|
||||||
|
: [
|
||||||
|
// NI's own installer drops into a vendor subdir; this is the
|
||||||
|
// most common default on a fresh GR6 install.
|
||||||
|
'C:\\Program Files\\Native Instruments\\VST3\\Guitar Rig 6.vst3',
|
||||||
|
'C:\\Program Files\\Native Instruments\\VST3\\Guitar Rig 6 FX.vst3',
|
||||||
|
// Some installs (and the existing CI fixture VM) drop into the
|
||||||
|
// shared Common Files VST3 dir; keep these as fallbacks so the
|
||||||
|
// existing smoke harness doesn't have to flip overnight.
|
||||||
|
'C:\\Program Files\\Common Files\\VST3\\Guitar Rig 6.vst3',
|
||||||
|
'C:\\Program Files\\Common Files\\VST3\\Guitar Rig 6 FX.vst3',
|
||||||
|
];
|
||||||
|
const gr6 = candidates.find(p => { try { return fs.existsSync(p); } catch (_) { return false; } });
|
||||||
|
if (!gr6) {
|
||||||
|
console.error('[test] FATAL: no Guitar Rig 6 install found at any of:');
|
||||||
|
for (const p of candidates) console.error(' - ' + p);
|
||||||
|
console.error('Set GR6_PATH to override (e.g. GR6_PATH="C:\\path\\to\\Guitar Rig 6.vst3" node scripts\\dev\\load-gr6.js).');
|
||||||
|
try { addon.shutdown(); } catch (_) {}
|
||||||
|
clearTimeout(watchdog);
|
||||||
|
process.exit(2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('[test] calling addon.loadVST(' + gr6 + ')');
|
||||||
|
let slot;
|
||||||
|
try {
|
||||||
|
// addon.loadVST is now a Promise<number> (Napi::AsyncWorker); await
|
||||||
|
// it. The enclosing setTimeout callback was made async above.
|
||||||
|
slot = await addon.loadVST(gr6);
|
||||||
|
console.log('[test] loadVST returned slot:', slot);
|
||||||
|
} catch (e) {
|
||||||
|
failExit('EXCEPTION on loadVST: ' + e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(slot) || slot < 0) {
|
||||||
|
failExit('loadVST returned invalid slot: ' + String(slot));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('[test] calling addon.openPluginEditor(' + slot + ')');
|
||||||
|
let ok = false;
|
||||||
|
try {
|
||||||
|
ok = addon.openPluginEditor(slot);
|
||||||
|
console.log('[test] openPluginEditor returned:', ok);
|
||||||
|
} catch (e) {
|
||||||
|
failExit('EXCEPTION on openPluginEditor: ' + e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!ok) {
|
||||||
|
try { addon.closePluginEditor(slot); } catch (_) {}
|
||||||
|
failExit('openPluginEditor returned false');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('[test] sleeping 5s for editor creation + potential crash...');
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('[test] still alive after editor wait; closing');
|
||||||
|
try { addon.closePluginEditor(slot); } catch (e) {}
|
||||||
|
try { addon.shutdown(); } catch (e) {}
|
||||||
|
clearTimeout(watchdog);
|
||||||
|
setTimeout(() => process.exit(0), 1000);
|
||||||
|
}, 5000);
|
||||||
|
}, 1500);
|
||||||
|
}, 2000);
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Normalise ONNX Runtime install names on macOS (slopsmith#818).
|
||||||
|
#
|
||||||
|
# The prebuilt onnxruntime macOS dylib bakes the *build machine's absolute
|
||||||
|
# extraction path* (e.g. /Users/runner/work/.../_deps/onnxruntime/.../lib/
|
||||||
|
# libonnxruntime.1.20.1.dylib) into its LC_ID_DYLIB. At link time the linker
|
||||||
|
# copies that absolute path into slopsmith_audio.node's LC_LOAD_DYLIB. The
|
||||||
|
# .node carries the correct `@loader_path` rpath (set in src/audio/
|
||||||
|
# CMakeLists.txt) and the runtime is staged right beside it — but dyld never
|
||||||
|
# consults the rpath, because the load command is an absolute path, not an
|
||||||
|
# `@rpath/...` one. Result: the addon loads fine *only* on the CI runner;
|
||||||
|
# every other Mac fails to find onnxruntime, MlNoteDetector can't initialise,
|
||||||
|
# and the engine silently falls back to YIN ("ML detection: OFF").
|
||||||
|
#
|
||||||
|
# Fix: rewrite the install names to be `@rpath`-relative so the addon's
|
||||||
|
# existing `@loader_path` rpath resolves the co-located runtime everywhere.
|
||||||
|
# Idempotent — re-running is a no-op once the names are already `@rpath/...`.
|
||||||
|
#
|
||||||
|
# Usage: fix-onnxruntime-install-names.sh <slopsmith_audio.node> <runtime-lib-name>
|
||||||
|
# $1 absolute path to the built slopsmith_audio.node
|
||||||
|
# $2 runtime lib filename, e.g. libonnxruntime.1.20.1.dylib
|
||||||
|
#
|
||||||
|
# Invoked as a POST_BUILD step on Apple platforms only. install_name_tool
|
||||||
|
# invalidates any existing (adhoc) code signature, so we re-adhoc-sign the
|
||||||
|
# touched binaries afterwards; electron-builder overrides this with the real
|
||||||
|
# Developer ID signature when it packages + signs the .app.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
node="${1:?path to slopsmith_audio.node required}"
|
||||||
|
libname="${2:?onnxruntime runtime lib name required}"
|
||||||
|
dir="$(cd "$(dirname "$node")" && pwd)"
|
||||||
|
runtime="$dir/$libname"
|
||||||
|
providers="$dir/libonnxruntime_providers_shared.dylib"
|
||||||
|
|
||||||
|
# Re-adhoc-sign a binary after rewriting its load commands. `install_name_tool`
|
||||||
|
# strips the lightweight adhoc signature the linker attaches on Apple Silicon;
|
||||||
|
# without one, dyld refuses to load the addon on a local (unsigned) dev build.
|
||||||
|
resign() {
|
||||||
|
codesign --remove-signature "$1" 2>/dev/null || true
|
||||||
|
codesign --force --sign - "$1" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# Discover the addon's current (absolute) reference to the runtime, if any.
|
||||||
|
# Skip when it is already `@rpath/...` so re-runs / already-correct builds are
|
||||||
|
# no-ops. Match the exact runtime filename to avoid touching unrelated entries.
|
||||||
|
old_ref="$(otool -L "$node" | awk -v L="$libname" 'index($1,L) && $1 !~ /^@rpath\// {print $1; exit}')"
|
||||||
|
if [[ -n "${old_ref:-}" ]]; then
|
||||||
|
install_name_tool -change "$old_ref" "@rpath/$libname" "$node"
|
||||||
|
resign "$node"
|
||||||
|
echo "fix-onnxruntime-install-names: $node -> @rpath/$libname"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Normalise the runtime's own id so future links (and any consumer that reads
|
||||||
|
# LC_ID_DYLIB) get `@rpath/...` instead of an absolute build path.
|
||||||
|
if [[ -f "$runtime" ]]; then
|
||||||
|
cur_id="$(otool -D "$runtime" | sed -n '2p')"
|
||||||
|
if [[ "$cur_id" != "@rpath/$libname" ]]; then
|
||||||
|
install_name_tool -id "@rpath/$libname" "$runtime"
|
||||||
|
resign "$runtime"
|
||||||
|
echo "fix-onnxruntime-install-names: id $runtime -> @rpath/$libname"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# providers_shared is dlopen()'d by the runtime at session-init and links the
|
||||||
|
# main runtime by the same baked absolute path; rewrite it too so a build that
|
||||||
|
# ships the providers stub doesn't reintroduce the absolute dependency.
|
||||||
|
if [[ -f "$providers" ]]; then
|
||||||
|
prov_name="$(basename "$providers")"
|
||||||
|
cur_pid="$(otool -D "$providers" | sed -n '2p')"
|
||||||
|
if [[ "$cur_pid" != "@rpath/$prov_name" ]]; then
|
||||||
|
install_name_tool -id "@rpath/$prov_name" "$providers"
|
||||||
|
fi
|
||||||
|
prov_ref="$(otool -L "$providers" | awk -v L="$libname" 'index($1,L) && $1 !~ /^@rpath\// {print $1; exit}')"
|
||||||
|
if [[ -n "${prov_ref:-}" ]]; then
|
||||||
|
install_name_tool -change "$prov_ref" "@rpath/$libname" "$providers"
|
||||||
|
fi
|
||||||
|
resign "$providers"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Parse `.build-config.json` and emit a value (or the whole doc).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
parse-build-config.py <path> # pretty-print the whole document
|
||||||
|
parse-build-config.py <path> .versions.node # print a single value
|
||||||
|
|
||||||
|
Keys are dot-delimited, e.g. `.external.rs2014net.commit`. Plain JSON
|
||||||
|
only — if a future config needs comments, add a proper JSONC parser
|
||||||
|
(naive regex-based `//` stripping breaks on URLs like `https://...`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print(f"Usage: {sys.argv[0]} <path> [.json.path]", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
config_file = sys.argv[1]
|
||||||
|
json_path = sys.argv[2] if len(sys.argv) > 2 else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(config_file, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f"Error: file not found: {config_file}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f"Error: invalid JSON in {config_file}: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if json_path is None:
|
||||||
|
print(json.dumps(data, indent=2))
|
||||||
|
return
|
||||||
|
|
||||||
|
value = data
|
||||||
|
for key in json_path.lstrip('.').split('.'):
|
||||||
|
try:
|
||||||
|
value = value[key]
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
print(f"Error: key {json_path!r} not found in {config_file}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
print(value)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Development environment setup
|
||||||
|
# Installs all dependencies and verifies the build chain
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
|
||||||
|
cd "$PROJECT_DIR"
|
||||||
|
|
||||||
|
echo "=== Slopsmith Desktop Development Setup ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check prerequisites
|
||||||
|
echo "Checking prerequisites..."
|
||||||
|
|
||||||
|
check_command() {
|
||||||
|
if command -v "$1" &>/dev/null; then
|
||||||
|
echo " [OK] $1"
|
||||||
|
else
|
||||||
|
echo " [MISSING] $1 — $2"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verify the media chain: ffmpeg (WEM/OGG transcode), ffprobe (demucs probes
|
||||||
|
# stream metadata before invoking ffmpeg), and ffmpeg's libvorbis encoder
|
||||||
|
# (Sloppak conversion encodes .ogg with `-c:a libvorbis`).
|
||||||
|
# $1 — install hint shown when ffmpeg/ffprobe are missing.
|
||||||
|
# $2 — platform-specific remediation shown when libvorbis is absent.
|
||||||
|
check_media_chain() {
|
||||||
|
local install_hint="$1"
|
||||||
|
local libvorbis_hint="$2"
|
||||||
|
command -v ffmpeg >/dev/null 2>&1 && echo " [OK] ffmpeg" || echo " [MISSING] ffmpeg ($install_hint)"
|
||||||
|
command -v ffprobe >/dev/null 2>&1 && echo " [OK] ffprobe" || echo " [MISSING] ffprobe (ships with ffmpeg — $install_hint)"
|
||||||
|
if command -v ffmpeg >/dev/null 2>&1; then
|
||||||
|
if ffmpeg -hide_banner -encoders 2>/dev/null | grep -wq libvorbis; then
|
||||||
|
echo " [OK] ffmpeg libvorbis encoder"
|
||||||
|
else
|
||||||
|
echo " [WARN] ffmpeg lacks the libvorbis encoder — Sloppak conversion"
|
||||||
|
echo " falls back to the lower-quality built-in vorbis encoder."
|
||||||
|
echo " $libvorbis_hint"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_command node "Install Node.js 20+" || exit 1
|
||||||
|
check_command npm "Comes with Node.js" || exit 1
|
||||||
|
check_command cmake "Install cmake (apt/brew/pacman)" || exit 1
|
||||||
|
check_command python3 "Install Python 3.12+" || exit 1
|
||||||
|
check_command git "Install git" || exit 1
|
||||||
|
|
||||||
|
# Platform-specific checks
|
||||||
|
case "$(uname -s)" in
|
||||||
|
Linux)
|
||||||
|
echo ""
|
||||||
|
echo "Checking Linux build dependencies..."
|
||||||
|
pkg-config --exists alsa 2>/dev/null && echo " [OK] ALSA" || echo " [MISSING] ALSA dev headers (apt: libasound2-dev / pacman: alsa-lib)"
|
||||||
|
pkg-config --exists jack 2>/dev/null && echo " [OK] JACK" || echo " [MISSING] JACK dev headers (apt: libjack-jackd2-dev / pacman: jack2)"
|
||||||
|
pkg-config --exists freetype2 2>/dev/null && echo " [OK] freetype2" || echo " [MISSING] freetype2 (apt: libfreetype-dev / pacman: freetype2)"
|
||||||
|
pkg-config --exists x11 2>/dev/null && echo " [OK] X11" || echo " [MISSING] X11 dev headers"
|
||||||
|
pkg-config --exists xrandr 2>/dev/null && echo " [OK] Xrandr" || echo " [MISSING] Xrandr dev headers"
|
||||||
|
pkg-config --exists xcursor 2>/dev/null && echo " [OK] Xcursor" || echo " [MISSING] Xcursor dev headers"
|
||||||
|
pkg-config --exists xinerama 2>/dev/null && echo " [OK] Xinerama" || echo " [MISSING] Xinerama dev headers"
|
||||||
|
check_media_chain "apt: ffmpeg / pacman: ffmpeg" \
|
||||||
|
"Most distro ffmpeg packages enable libvorbis — reinstall your distro's ffmpeg if this build does not."
|
||||||
|
command -v vgmstream-cli >/dev/null 2>&1 && echo " [OK] vgmstream-cli" || echo " [MISSING] vgmstream-cli (AUR: yay -S vgmstream-cli-bin / or github.com/vgmstream/vgmstream/releases)"
|
||||||
|
;;
|
||||||
|
Darwin)
|
||||||
|
echo ""
|
||||||
|
echo "Checking macOS dependencies..."
|
||||||
|
xcode-select -p &>/dev/null && echo " [OK] Xcode Command Line Tools" || echo " [MISSING] Run: xcode-select --install"
|
||||||
|
check_media_chain "brew install ffmpeg" \
|
||||||
|
"Homebrew's ffmpeg 8.1.1+ omits libvorbis — install a static ffmpeg build instead (packaged builds bundle one)."
|
||||||
|
command -v vgmstream-cli >/dev/null 2>&1 && echo " [OK] vgmstream-cli" || echo " [MISSING] vgmstream-cli (brew install vgmstream)"
|
||||||
|
;;
|
||||||
|
MINGW*|MSYS*|CYGWIN*)
|
||||||
|
echo ""
|
||||||
|
echo "Checking Windows (Git Bash) dependencies..."
|
||||||
|
check_media_chain "install ffmpeg and add it to PATH (e.g. winget install Gyan.FFmpeg)" \
|
||||||
|
"Most prebuilt Windows ffmpeg builds (e.g. Gyan) include libvorbis — pick one that does."
|
||||||
|
command -v vgmstream-cli >/dev/null 2>&1 && echo " [OK] vgmstream-cli" || echo " [MISSING] vgmstream-cli (github.com/vgmstream/vgmstream/releases — add to PATH)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Initialize submodules
|
||||||
|
echo "Initializing git submodules..."
|
||||||
|
git submodule update --init --recursive 2>/dev/null || echo " Note: Run 'git submodule update --init --recursive' manually if this is a fresh clone"
|
||||||
|
|
||||||
|
# Install npm dependencies
|
||||||
|
echo ""
|
||||||
|
echo "Installing npm dependencies..."
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Locate Slopsmith. Matches the build scripts (bundle-slopsmith.sh,
|
||||||
|
# bundle-python.sh, build-macos.sh): an explicit $SLOPSMITH_DIR is honoured
|
||||||
|
# verbatim — a typo or partial checkout there is surfaced, never silently
|
||||||
|
# masked by a sibling or legacy checkout. Only when $SLOPSMITH_DIR is unset
|
||||||
|
# do we fall back to ../slopsmith then ~/Repositories/slopsmith, and a
|
||||||
|
# fallback candidate only counts if it actually contains server.py.
|
||||||
|
SLOPSMITH_DIR_ENV="${SLOPSMITH_DIR:-}"
|
||||||
|
if [ -z "${SLOPSMITH_DIR:-}" ]; then
|
||||||
|
if [ -f "$PROJECT_DIR/../slopsmith/server.py" ]; then
|
||||||
|
SLOPSMITH_DIR="$PROJECT_DIR/../slopsmith"
|
||||||
|
elif [ -f "$HOME/Repositories/slopsmith/server.py" ]; then
|
||||||
|
SLOPSMITH_DIR="$HOME/Repositories/slopsmith"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "${SLOPSMITH_DIR:-}" ] && [ -f "$SLOPSMITH_DIR/server.py" ]; then
|
||||||
|
SLOPSMITH_DIR="$(cd "$SLOPSMITH_DIR" && pwd)"
|
||||||
|
echo ""
|
||||||
|
echo "Slopsmith found at: $SLOPSMITH_DIR"
|
||||||
|
|
||||||
|
# On Windows/Git Bash a bash check passes for an MSYS path like
|
||||||
|
# /c/src/slopsmith, but `npm run dev` (Electron) resolves $SLOPSMITH_DIR
|
||||||
|
# with Node, which needs a native Windows path. Warn before setup
|
||||||
|
# reports a config that dev mode would still fail to start.
|
||||||
|
case "$(uname -s)" in
|
||||||
|
MINGW*|MSYS*|CYGWIN*)
|
||||||
|
if [ -n "$SLOPSMITH_DIR_ENV" ] && [ "${SLOPSMITH_DIR_ENV#/}" != "$SLOPSMITH_DIR_ENV" ]; then
|
||||||
|
echo ""
|
||||||
|
echo " NOTE: \$SLOPSMITH_DIR is an MSYS/Git-Bash path. 'npm run dev' needs a"
|
||||||
|
echo " native Windows path. Re-export it as:"
|
||||||
|
if command -v cygpath >/dev/null 2>&1; then
|
||||||
|
echo " export SLOPSMITH_DIR='$(cygpath -w "$SLOPSMITH_DIR_ENV")'"
|
||||||
|
else
|
||||||
|
echo " a native path such as C:\\src\\slopsmith"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
PYTHON="${PROJECT_DIR}/.venv/bin/python3"
|
||||||
|
[ -x "$PYTHON" ] || PYTHON="python3"
|
||||||
|
echo "Checking Python dependencies (\"$PYTHON\")..."
|
||||||
|
"$PYTHON" -c "import fastapi" 2>/dev/null && echo " [OK] fastapi" || echo " [MISSING] \"$PYTHON\" -m pip install -r \"$SLOPSMITH_DIR/requirements.txt\""
|
||||||
|
"$PYTHON" -c "import uvicorn" 2>/dev/null && echo " [OK] uvicorn" || echo " [MISSING] \"$PYTHON\" -m pip install -r \"$SLOPSMITH_DIR/requirements.txt\""
|
||||||
|
elif [ -n "${SLOPSMITH_DIR:-}" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "WARNING: \$SLOPSMITH_DIR is set to '$SLOPSMITH_DIR' but no server.py was found there."
|
||||||
|
echo " Fix the path or unset \$SLOPSMITH_DIR to fall back to ../slopsmith or ~/Repositories/slopsmith."
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "WARNING: Slopsmith not found. Set \$SLOPSMITH_DIR, clone to $PROJECT_DIR/../slopsmith, or use ~/Repositories/slopsmith"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Setup Complete ==="
|
||||||
|
echo ""
|
||||||
|
echo "Build commands:"
|
||||||
|
echo " npm run build:audio # Build JUCE native addon"
|
||||||
|
echo " npm run build:ts # Compile TypeScript"
|
||||||
|
echo " npm run dev # Run in development mode"
|
||||||
|
echo " npm run dist:linux # Build Linux package"
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Sign every native binary inside resources/bin and resources/python/runtime
|
||||||
|
# with the Developer ID Application certificate so the .app passes
|
||||||
|
# notarization. Runs as part of the macOS bundle step, after binaries
|
||||||
|
# are downloaded/copied but before verify_bundled_binaries runs them
|
||||||
|
# (signing also clears the quarantine attribute that GitHub release
|
||||||
|
# downloads carry by default).
|
||||||
|
#
|
||||||
|
# Skips silently when APPLE_SIGNING_IDENTITY is unset — local builds
|
||||||
|
# without a Developer ID cert still produce a (Gatekeeper-rejected)
|
||||||
|
# unsigned .app, same as before this script existed.
|
||||||
|
#
|
||||||
|
# Required env (set by CI):
|
||||||
|
# APPLE_SIGNING_IDENTITY Full identity string, e.g.
|
||||||
|
# "Developer ID Application: Name (TEAMID)"
|
||||||
|
# Must already be present in the active keychain.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ -z "${APPLE_SIGNING_IDENTITY:-}" ]]; then
|
||||||
|
echo "[sign-macos] APPLE_SIGNING_IDENTITY not set — skipping (unsigned build)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$OSTYPE" != "darwin"* ]]; then
|
||||||
|
echo "[sign-macos] not on macOS — skipping" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
ENTITLEMENTS="$PROJECT_DIR/resources/entitlements.mac.plist"
|
||||||
|
|
||||||
|
if [[ ! -f "$ENTITLEMENTS" ]]; then
|
||||||
|
echo "[sign-macos] entitlements file missing: $ENTITLEMENTS" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
sign_one() {
|
||||||
|
local target="$1"
|
||||||
|
[[ -e "$target" ]] || return 0
|
||||||
|
# Skip symlinks — they get followed during bundling and signing the
|
||||||
|
# link target separately is enough.
|
||||||
|
[[ -L "$target" ]] && return 0
|
||||||
|
echo " $target"
|
||||||
|
codesign --force --options runtime --timestamp \
|
||||||
|
--sign "$APPLE_SIGNING_IDENTITY" \
|
||||||
|
--entitlements "$ENTITLEMENTS" \
|
||||||
|
"$target"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "[sign-macos] signing bundled binaries with: $APPLE_SIGNING_IDENTITY"
|
||||||
|
|
||||||
|
# 1. Top-level executables and bundled dylibs in resources/bin.
|
||||||
|
# dylibbundler copies fluidsynth's deps into this directory and
|
||||||
|
# rewrites their install names to @executable_path/, so each one
|
||||||
|
# has to be signed individually before the app bundle is built.
|
||||||
|
BIN_DIR="$PROJECT_DIR/resources/bin"
|
||||||
|
if [[ -d "$BIN_DIR" ]]; then
|
||||||
|
while IFS= read -r -d '' f; do
|
||||||
|
sign_one "$f"
|
||||||
|
done < <(find "$BIN_DIR" -maxdepth 1 -type f \( -perm -u+x -o -name '*.dylib' \) -print0)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Embedded CPython runtime — interpreter + libpython dylib + every
|
||||||
|
# compiled extension. Notarization rejects the bundle if any of
|
||||||
|
# these is unsigned.
|
||||||
|
PY_RUNTIME="$PROJECT_DIR/resources/python/runtime"
|
||||||
|
if [[ -d "$PY_RUNTIME" ]]; then
|
||||||
|
# Interpreter binaries (python3, python3.x, etc.)
|
||||||
|
while IFS= read -r -d '' f; do
|
||||||
|
sign_one "$f"
|
||||||
|
done < <(find "$PY_RUNTIME/bin" -maxdepth 1 -type f -perm -u+x -print0 2>/dev/null || true)
|
||||||
|
|
||||||
|
# libpython dylib(s) and any other shared libs
|
||||||
|
while IFS= read -r -d '' f; do
|
||||||
|
sign_one "$f"
|
||||||
|
done < <(find "$PY_RUNTIME/lib" -maxdepth 2 -type f -name '*.dylib' -print0 2>/dev/null || true)
|
||||||
|
|
||||||
|
# Compiled extension modules — both stdlib lib-dynload and any
|
||||||
|
# site-packages C extensions installed via pip.
|
||||||
|
while IFS= read -r -d '' f; do
|
||||||
|
sign_one "$f"
|
||||||
|
done < <(find "$PY_RUNTIME/lib" -type f \( -name '*.so' -o -name '*.dylib' \) -print0 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[sign-macos] done"
|
||||||
|
|
||||||
|
# Spot-check a couple of binaries so a botched sign call fails the
|
||||||
|
# build here rather than mid-notarization 2 minutes later.
|
||||||
|
for probe in "$BIN_DIR/fluidsynth" "$BIN_DIR/ffmpeg" "$BIN_DIR/vgmstream-cli"; do
|
||||||
|
if [[ -f "$probe" ]]; then
|
||||||
|
codesign --verify --strict "$probe" || {
|
||||||
|
echo "[sign-macos] verification failed: $probe" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
done
|
||||||