@compozy/skeeper 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +351 -0
- package/install.js +6 -0
- package/lib.js +259 -0
- package/package.json +90 -0
- package/run-skeeper.js +6 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Compozy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<h1>skeeper</h1>
|
|
3
|
+
<p><strong>Version your spec artifacts — PRDs, tech specs, ADRs, AI plans — in a sidecar Git repository, without polluting your main PRs.</strong></p>
|
|
4
|
+
<p>
|
|
5
|
+
<a href="https://github.com/compozy/skeeper/actions/workflows/ci.yml">
|
|
6
|
+
<img src="https://github.com/compozy/skeeper/actions/workflows/ci.yml/badge.svg" alt="CI">
|
|
7
|
+
</a>
|
|
8
|
+
<a href="https://pkg.go.dev/github.com/compozy/skeeper">
|
|
9
|
+
<img src="https://pkg.go.dev/badge/github.com/compozy/skeeper.svg" alt="Go Reference">
|
|
10
|
+
</a>
|
|
11
|
+
<a href="https://goreportcard.com/report/github.com/compozy/skeeper">
|
|
12
|
+
<img src="https://goreportcard.com/badge/github.com/compozy/skeeper" alt="Go Report Card">
|
|
13
|
+
</a>
|
|
14
|
+
<a href="LICENSE">
|
|
15
|
+
<img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT">
|
|
16
|
+
</a>
|
|
17
|
+
<a href="https://github.com/compozy/skeeper/releases">
|
|
18
|
+
<img src="https://img.shields.io/github/v/release/compozy/skeeper?include_prereleases" alt="Release">
|
|
19
|
+
</a>
|
|
20
|
+
</p>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
Specs and code want to live together. Spec files (`SPEC.md`, `docs/specs/*`, `.claude/plans/*`, ADRs, RFCs) belong next to the code they describe — but committing them to your main repo bloats every PR with documentation noise, and ignoring them loses history. `skeeper` runs a sidecar Git repository that mirrors matched spec files on every commit. You edit specs at their natural paths, your main PRs stay focused on code, and a separate Git history keeps full `git log`, `git blame`, and branch-aware versioning of every spec change. One `skeeper init` and the post-commit hook does the rest — without ever blocking your `git commit`.
|
|
24
|
+
|
|
25
|
+
## ✨ Highlights
|
|
26
|
+
|
|
27
|
+
- **One sidecar repo, full Git history.** Specs version normally — `git log`, `git blame`, branches, PRs — without touching your main repo's diff.
|
|
28
|
+
- **Shared sidecars without collisions.** `directory` namespaces each source repo inside one sidecar remote, including both stored paths and pushed branches.
|
|
29
|
+
- **Edit specs where they belong.** Spec files stay next to the code they describe. `skeeper` mirrors them into `.skeeper/` for you.
|
|
30
|
+
- **A post-commit hook that never breaks your commit.** 750 ms foreground budget; on failure, the sync queues locally and retries on the next manual `skeeper sync`.
|
|
31
|
+
- **Branch-aware mirroring.** Sidecar branches track main-tree branches, so feature work and `main` stay isolated.
|
|
32
|
+
- **Fresh-clone hydration.** `skeeper hydrate` restores matched specs into a new clone so teammates start with full context.
|
|
33
|
+
- **Glob-based pattern matching.** Doublestar globs (`**/SPEC.md`, `docs/specs/**`, `.claude/plans/**`) — match specs the way you actually organize them.
|
|
34
|
+
- **Shells out to `git` and `gh`.** Reuses your existing GitHub auth. Every operation is debuggable with the same Git commands you already know.
|
|
35
|
+
- **Single static binary, zero runtime deps.** Linux, macOS, Windows on amd64/arm64. CGO disabled.
|
|
36
|
+
|
|
37
|
+
## 📦 Installation
|
|
38
|
+
|
|
39
|
+
#### Homebrew
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
brew tap compozy/compozy
|
|
43
|
+
brew install --cask skeeper
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
#### NPM
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npm install -g @compozy/skeeper
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
#### Go
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
go install github.com/compozy/skeeper/cmd/skeeper@latest
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
#### From Source
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
git clone git@github.com:compozy/skeeper.git
|
|
62
|
+
cd skeeper && make verify && go build -o bin/skeeper ./cmd/skeeper
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
#### Docker
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
git clone git@github.com:compozy/skeeper.git
|
|
69
|
+
cd skeeper && make docker-build # builds skeeper:dev (distroless, nonroot)
|
|
70
|
+
docker run --rm -v "$PWD:/workspace" -w /workspace skeeper:dev status
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
#### Prerequisites
|
|
74
|
+
|
|
75
|
+
- `git` on `PATH`
|
|
76
|
+
- `gh` (GitHub CLI) **only when `skeeper init` creates a new sidecar** — existing sidecars can be reused with `--sidecar`. Day-to-day commands need only `git`.
|
|
77
|
+
|
|
78
|
+
## 🔄 How It Works
|
|
79
|
+
|
|
80
|
+
Spec files live at their natural paths next to code. Your main repo's `.gitignore` lists those patterns plus `.skeeper/`, so neither the specs nor the sidecar clone ever appear in a main-repo diff.
|
|
81
|
+
|
|
82
|
+
On every `git commit`, the managed post-commit hook runs `skeeper sync --hook` with a 750 ms foreground budget. `skeeper` matches files against your patterns, copies them into `.skeeper/`, commits with a reference to the main commit SHA, and pushes to the sidecar remote.
|
|
83
|
+
|
|
84
|
+
When `directory` is configured, files are stored under that namespace in the sidecar and branches are pushed as `<directory>/__branches__/<source-branch>`. For example, `directory: skeeper` on source branch `main` stores `src/auth/SPEC.md` as `skeeper/src/auth/SPEC.md` and pushes sidecar branch `skeeper/__branches__/main`.
|
|
85
|
+
|
|
86
|
+
If anything fails — network, auth, push rejection, timeout — `skeeper` writes a retry record to `.git/skeeper/queue.json`, appends a one-line audit entry to `.git/skeeper/sync.log`, and exits 0 so your `git commit` always succeeds. Run `skeeper sync` later to drain the queue.
|
|
87
|
+
|
|
88
|
+
```mermaid
|
|
89
|
+
flowchart LR
|
|
90
|
+
A[Developer<br/>git commit] --> B[post-commit hook<br/>skeeper sync --hook]
|
|
91
|
+
B --> C{Sync within<br/>750 ms?}
|
|
92
|
+
C -- yes --> D[Copy matched specs<br/>into .skeeper/]
|
|
93
|
+
D --> E[git commit<br/>in sidecar]
|
|
94
|
+
E --> F[git push<br/>to sidecar remote]
|
|
95
|
+
C -- no / error --> G[Write retry record<br/>.git/skeeper/queue.json]
|
|
96
|
+
G --> H[Hook exits 0<br/>main commit succeeds]
|
|
97
|
+
H -. later .-> I[skeeper sync<br/>drains queue]
|
|
98
|
+
I --> D
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## ⚙️ Configuration
|
|
102
|
+
|
|
103
|
+
`skeeper init` writes `.skeeper.yml` at the repo root. Commit it — your teammates need it for `skeeper hydrate`.
|
|
104
|
+
|
|
105
|
+
```yaml
|
|
106
|
+
# Required: sidecar repository URL
|
|
107
|
+
sidecar: git@github.com:user/myproject-specs.git
|
|
108
|
+
|
|
109
|
+
# Recommended: namespace for this source repo inside a shared sidecar
|
|
110
|
+
directory: myproject
|
|
111
|
+
|
|
112
|
+
# Required: doublestar globs that select spec files
|
|
113
|
+
patterns:
|
|
114
|
+
- "**/SPEC.md"
|
|
115
|
+
- "docs/specs/**"
|
|
116
|
+
- ".claude/plans/**"
|
|
117
|
+
- "**/*.spec.md"
|
|
118
|
+
|
|
119
|
+
# Optional: install one-liner shown to teammates after `skeeper hydrate`
|
|
120
|
+
bootstrap: brew tap compozy/compozy && brew install --cask skeeper
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Unknown keys are rejected — config errors fail loud, not silently.
|
|
124
|
+
|
|
125
|
+
`directory` is strongly recommended for every new project and is written by `skeeper init` by default. It lets multiple source repos safely use one sidecar repository: each repo writes to its own sidecar subdirectory and its own namespaced sidecar branches. If `directory` is omitted, `skeeper` uses the legacy behavior: files are mirrored at the sidecar root and the sidecar branch is exactly the source branch. That legacy mode is useful for dedicated sidecars, but it is unsafe for shared sidecars because different repos can delete each other's matched files and compete for the same branch.
|
|
126
|
+
|
|
127
|
+
There is no automatic migration from root storage to a new `directory`. Adding `directory` starts using a new path namespace and branch namespace.
|
|
128
|
+
|
|
129
|
+
Local-only state lives under `.git/skeeper/` (already gitignored by Git's hooks directory):
|
|
130
|
+
|
|
131
|
+
| File | Purpose |
|
|
132
|
+
| ------------ | ------------------------------------------------------ |
|
|
133
|
+
| `queue.json` | Pending retries from failed hook runs |
|
|
134
|
+
| `sync.log` | Append-only audit log of sync attempts and error codes |
|
|
135
|
+
|
|
136
|
+
## 🚀 Quick Start
|
|
137
|
+
|
|
138
|
+
### 1. Install
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
go install github.com/compozy/skeeper/cmd/skeeper@latest
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### 2. Initialize the sidecar
|
|
145
|
+
|
|
146
|
+
In a Git repo where you want to track specs:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
skeeper init
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Interactive by default — opens a terminal form for the sidecar mode, repository name or URL, `directory`, bootstrap command, and spec patterns. Or pass values as flags:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
skeeper init \
|
|
156
|
+
--sidecar-name myproject-specs \
|
|
157
|
+
--visibility private \
|
|
158
|
+
--directory myproject \
|
|
159
|
+
--patterns "**/SPEC.md" \
|
|
160
|
+
--patterns ".claude/plans/**"
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
To reuse one shared sidecar remote across multiple source repos:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
skeeper init \
|
|
167
|
+
--sidecar git@github.com:user/shared-specs.git \
|
|
168
|
+
--directory myproject \
|
|
169
|
+
--patterns "**/SPEC.md"
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`skeeper init` creates the GitHub repo with `gh repo create` unless `--sidecar` points to an existing remote. It clones the sidecar into `.skeeper/`, writes `.skeeper.yml`, updates `.gitignore`, and installs the post-commit hook. New init runs default `directory` to the source repo name; pass `--no-directory` only when you intentionally want legacy root behavior.
|
|
173
|
+
|
|
174
|
+
### 3. Edit specs and commit normally
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
$EDITOR src/auth/SPEC.md
|
|
178
|
+
git add .
|
|
179
|
+
git commit -m "auth: design OAuth provider flow"
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
The hook fires automatically. No extra step.
|
|
183
|
+
|
|
184
|
+
### 4. Inspect
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
skeeper status # sidecar URL, branch mapping, last sync, pending count
|
|
188
|
+
skeeper log src/auth/SPEC.md # sidecar Git history for one file
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### 5. Onboard a teammate
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
git clone git@github.com:user/myproject.git
|
|
195
|
+
cd myproject
|
|
196
|
+
skeeper hydrate
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`hydrate` clones the sidecar into `.skeeper/`, restores matched specs into the working tree, and installs the hook.
|
|
200
|
+
|
|
201
|
+
### 6. Recover from a failed sync
|
|
202
|
+
|
|
203
|
+
If the hook ever queued work (network blip, push rejection):
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
skeeper sync # drain queued retries, then run a fresh sync
|
|
207
|
+
skeeper sync --pull # rebase the sidecar branch first — useful when teammates pushed
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## 🧰 How Sync Works
|
|
211
|
+
|
|
212
|
+
The post-commit hook is a _managed block_ in `.git/hooks/post-commit`, installed idempotently. It runs `skeeper sync --hook` with a 750 ms foreground budget so your `git commit` stays snappy even on a slow network.
|
|
213
|
+
|
|
214
|
+
On the success path, `skeeper` matches files with doublestar globs, copies them into `.skeeper/`, then runs `git add`, `git commit`, and `git push` against the sidecar remote. With `directory`, the copy destination is `.skeeper/<directory>/<path>` and the push target is `<directory>/__branches__/<source-branch>`. Sidecar commits reference the main-repo SHA so you can correlate spec changes back to the code change that triggered them.
|
|
215
|
+
|
|
216
|
+
On the failure path — timeout, auth failure, network failure, or push rejection — `skeeper` writes a retry record to `.git/skeeper/queue.json`, appends to `.git/skeeper/sync.log`, prints a one-line note, and the hook exits 0. The next `skeeper sync` drains the queue before running a normal sync. Use `skeeper sync --pull` when a teammate pushed sidecar updates between your commits; it fetches and rebases before pushing.
|
|
217
|
+
|
|
218
|
+
This design has two consequences worth knowing:
|
|
219
|
+
|
|
220
|
+
- **`git commit` never fails because of `skeeper`.** Worst case, you have queued work to drain.
|
|
221
|
+
- **Conflicts surface as Git conflicts.** `skeeper sync --pull` stops if the rebase reports unresolved conflicts; resolve them in `.skeeper/` with normal Git tooling, then re-run.
|
|
222
|
+
|
|
223
|
+
## 📖 CLI Reference
|
|
224
|
+
|
|
225
|
+
<details>
|
|
226
|
+
<summary><code>skeeper init</code> — Create and connect a sidecar specs repository</summary>
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
skeeper init [flags]
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
| Flag | Default | Description |
|
|
233
|
+
| ---------------- | --------- | ----------------------------------------------------- |
|
|
234
|
+
| `--sidecar` | | Existing sidecar repository URL |
|
|
235
|
+
| `--sidecar-name` | | GitHub sidecar repository name or `OWNER/REPO` |
|
|
236
|
+
| `--visibility` | `private` | GitHub visibility: `private`, `public`, or `internal` |
|
|
237
|
+
| `--directory` | repo slug | Sidecar directory namespace for this source repo |
|
|
238
|
+
| `--no-directory` | `false` | Omit namespace and use legacy root behavior |
|
|
239
|
+
| `--bootstrap` | | Optional install command stored in `.skeeper.yml` |
|
|
240
|
+
| `--patterns` | | Spec glob pattern; repeat for multiple patterns |
|
|
241
|
+
|
|
242
|
+
When run interactively, `init` opens a terminal form. It runs `gh repo create` for `--sidecar-name` or the create mode, but skips GitHub creation when `--sidecar` is provided. `--sidecar` and `--sidecar-name` are mutually exclusive; `--directory` and `--no-directory` are mutually exclusive.
|
|
243
|
+
|
|
244
|
+
</details>
|
|
245
|
+
|
|
246
|
+
<details>
|
|
247
|
+
<summary><code>skeeper hydrate</code> — Restore spec files from the sidecar repository</summary>
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
skeeper hydrate
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Use after a fresh clone of the main repo. `hydrate` clones the sidecar into `.skeeper/`, copies matched spec files into the working tree, and installs the post-commit hook. No flags.
|
|
254
|
+
|
|
255
|
+
</details>
|
|
256
|
+
|
|
257
|
+
<details>
|
|
258
|
+
<summary><code>skeeper sync</code> — Mirror spec files into the sidecar repository</summary>
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
skeeper sync [flags]
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
| Flag | Default | Description |
|
|
265
|
+
| -------- | ------- | ---------------------------------------------------------------------- |
|
|
266
|
+
| `--pull` | `false` | Pull and rebase the sidecar branch before syncing |
|
|
267
|
+
| `--hook` | `false` | Run in post-commit hook mode: 750 ms foreground budget, always exits 0 |
|
|
268
|
+
|
|
269
|
+
Drains queued retries from `.git/skeeper/queue.json`, then mirrors spec files into `.skeeper/`, commits, and pushes. Use `--pull` when teammates may have pushed sidecar updates between your commits. `--hook` is what the installed post-commit hook calls — you rarely run it manually.
|
|
270
|
+
|
|
271
|
+
</details>
|
|
272
|
+
|
|
273
|
+
<details>
|
|
274
|
+
<summary><code>skeeper status</code> — Show sidecar sync status</summary>
|
|
275
|
+
|
|
276
|
+
```bash
|
|
277
|
+
skeeper status
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Prints the sidecar URL, current source branch, directory namespace when configured, sidecar branch, last sync commit and age, remote URL, count of tracked spec files, and count of pending queued syncs. No flags.
|
|
281
|
+
|
|
282
|
+
</details>
|
|
283
|
+
|
|
284
|
+
<details>
|
|
285
|
+
<summary><code>skeeper log <path></code> — Show sidecar history for a spec file</summary>
|
|
286
|
+
|
|
287
|
+
```bash
|
|
288
|
+
skeeper log <path>
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Runs `git log` against the sidecar for one spec file. The path is relative to the main repo root, e.g. `skeeper log src/auth/SPEC.md`. When `directory` is configured, `skeeper` resolves that path to `<directory>/src/auth/SPEC.md` inside the sidecar.
|
|
292
|
+
|
|
293
|
+
</details>
|
|
294
|
+
|
|
295
|
+
<details>
|
|
296
|
+
<summary><code>skeeper version</code> — Print build metadata</summary>
|
|
297
|
+
|
|
298
|
+
```bash
|
|
299
|
+
skeeper version
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
Prints `Version`, `Commit`, and `BuildDate` injected at build time via ldflags.
|
|
303
|
+
|
|
304
|
+
</details>
|
|
305
|
+
|
|
306
|
+
## 🛠️ Development
|
|
307
|
+
|
|
308
|
+
```bash
|
|
309
|
+
mise install # provision Go 1.26.2, Bun 1.3.4, and CLI tools
|
|
310
|
+
bun install
|
|
311
|
+
make hooks-install
|
|
312
|
+
make verify # fmt → lint → test → build (BLOCKING gate)
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Common targets:
|
|
316
|
+
|
|
317
|
+
```bash
|
|
318
|
+
make fmt # gofmt every .go file
|
|
319
|
+
make lint # golangci-lint v2 + gopls modernize (zero tolerance)
|
|
320
|
+
make test # gotestsum + -race -parallel=4
|
|
321
|
+
make build # bin/skeeper with version ldflags
|
|
322
|
+
make cover # coverage.out + coverage.html
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
Releases are prepared through release pull requests and published with [GoReleaser Pro](.goreleaser.yml). A push to `main` creates or updates a release PR with `pr-release`; the release PR runs a GoReleaser dry run, and merging the release commit publishes GitHub release artifacts, the Homebrew cask, and the NPM package.
|
|
326
|
+
|
|
327
|
+
Release publishing requires these GitHub Actions secrets:
|
|
328
|
+
|
|
329
|
+
| Secret | Purpose |
|
|
330
|
+
| ---------------- | ------------------------------------------------------------- |
|
|
331
|
+
| `RELEASE_TOKEN` | Create/update release PRs, push release tags, update Homebrew |
|
|
332
|
+
| `GORELEASER_KEY` | Run GoReleaser Pro |
|
|
333
|
+
| `NPM_TOKEN` | Publish `@compozy/skeeper` and authenticate npm in release CI |
|
|
334
|
+
|
|
335
|
+
Release notes are generated by `pr-release`. Add pending human-authored notes under `.release-notes/`; the release PR writes the current release body to `RELEASE_BODY.md` and prepends it to `RELEASE_NOTES.md`. The production workflow passes `RELEASE_BODY.md` to GoReleaser with the Skeeper release header and footer templates.
|
|
336
|
+
|
|
337
|
+
Local release checks:
|
|
338
|
+
|
|
339
|
+
```bash
|
|
340
|
+
make release-snapshot # requires GoReleaser Pro in PATH, or GORELEASER_KEY for the installer
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
Contributor guidance, commit conventions, and the agent skill dispatch protocol live in [`CLAUDE.md`](CLAUDE.md) and [`AGENTS.md`](AGENTS.md).
|
|
344
|
+
|
|
345
|
+
## 🤝 Contributing
|
|
346
|
+
|
|
347
|
+
Contributions are welcome. Open an issue to discuss larger changes, or send a pull request for fixes and small improvements. All commits follow [Conventional Commits](https://www.conventionalcommits.org/) (`build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `test`), enforced by `commitlint`.
|
|
348
|
+
|
|
349
|
+
## 📄 License
|
|
350
|
+
|
|
351
|
+
[MIT](LICENSE)
|
package/install.js
ADDED
package/lib.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// This file was generated by GoReleaser. DO NOT EDIT.
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import crypto from "crypto";
|
|
4
|
+
import http from "http";
|
|
5
|
+
import https from "https";
|
|
6
|
+
import path from "path";
|
|
7
|
+
import JSZip from "jszip";
|
|
8
|
+
import { x as tarExtract } from "tar";
|
|
9
|
+
import { ProxyAgent } from "proxy-agent";
|
|
10
|
+
import { spawnSync } from "child_process";
|
|
11
|
+
import { fileURLToPath } from "url";
|
|
12
|
+
|
|
13
|
+
const { archives, name, version } = JSON.parse(
|
|
14
|
+
fs.readFileSync(new URL("./package.json", import.meta.url), "utf8"),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
const agent = new ProxyAgent();
|
|
18
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
|
|
20
|
+
const getArchive = () => {
|
|
21
|
+
let target = `${process.platform}-${process.arch}`;
|
|
22
|
+
const archive = archives[target];
|
|
23
|
+
if (!archive) {
|
|
24
|
+
throw new Error(`No archive available for ${target}`);
|
|
25
|
+
}
|
|
26
|
+
return archive;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const binDir = path.join(__dirname, "bin");
|
|
30
|
+
|
|
31
|
+
async function extractTar(tarPath, binaries, dir, wrappedIn) {
|
|
32
|
+
try {
|
|
33
|
+
const filesToExtract = wrappedIn
|
|
34
|
+
? binaries.map((bin) =>
|
|
35
|
+
path.join(wrappedIn, bin).replace(/\\/g, "/"),
|
|
36
|
+
)
|
|
37
|
+
: binaries;
|
|
38
|
+
|
|
39
|
+
await tarExtract({
|
|
40
|
+
file: tarPath,
|
|
41
|
+
cwd: dir,
|
|
42
|
+
filter: (path) => filesToExtract.includes(path),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// If wrapped, move files from wrapped directory to bin directory
|
|
46
|
+
if (wrappedIn) {
|
|
47
|
+
const wrappedDir = path.join(dir, wrappedIn);
|
|
48
|
+
for (const binary of binaries) {
|
|
49
|
+
const srcPath = path.join(wrappedDir, binary);
|
|
50
|
+
const destPath = path.join(dir, binary);
|
|
51
|
+
if (fs.existsSync(srcPath)) {
|
|
52
|
+
fs.renameSync(srcPath, destPath);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Clean up empty wrapped directory
|
|
56
|
+
try {
|
|
57
|
+
fs.rmSync(wrappedDir, { recursive: true, force: true });
|
|
58
|
+
} catch (err) {
|
|
59
|
+
// Ignore cleanup errors
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
console.log(`Successfully extracted ${binaries} to "${dir}"`);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
throw new Error(`Extraction failed: ${err.message}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function extractZip(zipPath, binaries, dir, wrappedIn) {
|
|
70
|
+
try {
|
|
71
|
+
const zipData = fs.readFileSync(zipPath);
|
|
72
|
+
const zip = await JSZip.loadAsync(zipData);
|
|
73
|
+
|
|
74
|
+
for (const binary of binaries) {
|
|
75
|
+
const binaryPath = wrappedIn
|
|
76
|
+
? path.join(wrappedIn, binary).replace(/\\/g, "/")
|
|
77
|
+
: binary;
|
|
78
|
+
|
|
79
|
+
if (!zip.files[binaryPath]) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`Error: ${binaryPath} does not exist in ${zipPath}`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const content = await zip.files[binaryPath].async("nodebuffer");
|
|
86
|
+
if (!fs.existsSync(dir)) {
|
|
87
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
88
|
+
}
|
|
89
|
+
const file = path.join(dir, binary);
|
|
90
|
+
fs.writeFileSync(file, content);
|
|
91
|
+
fs.chmodSync(file, "755");
|
|
92
|
+
console.log(`Successfully extracted "${binary}" to "${dir}"`);
|
|
93
|
+
}
|
|
94
|
+
} catch (err) {
|
|
95
|
+
throw new Error(`Extraction failed: ${err.message}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const run = async (bin) => {
|
|
100
|
+
await install();
|
|
101
|
+
if (process.platform === "win32") {
|
|
102
|
+
bin += ".exe";
|
|
103
|
+
}
|
|
104
|
+
const [, , ...args] = process.argv;
|
|
105
|
+
let result = spawnSync(path.join(binDir, bin), args, {
|
|
106
|
+
cwd: process.cwd(),
|
|
107
|
+
stdio: "inherit",
|
|
108
|
+
});
|
|
109
|
+
if (result.error) {
|
|
110
|
+
console.error(result.error);
|
|
111
|
+
}
|
|
112
|
+
return result.status;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const install = async () => {
|
|
116
|
+
try {
|
|
117
|
+
let archive = getArchive();
|
|
118
|
+
if (await exists(archive)) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
let tmp = fs.mkdtempSync("archive-");
|
|
122
|
+
let archivePath = path.join(tmp, archive.name);
|
|
123
|
+
await download(archive.url, archivePath);
|
|
124
|
+
verify(archivePath, archive.checksum);
|
|
125
|
+
|
|
126
|
+
if (!fs.existsSync(binDir)) {
|
|
127
|
+
fs.mkdirSync(binDir);
|
|
128
|
+
}
|
|
129
|
+
switch (archive.format) {
|
|
130
|
+
case "binary":
|
|
131
|
+
const bin = path.join(binDir, archive.bins[0]);
|
|
132
|
+
fs.copyFileSync(archivePath, bin);
|
|
133
|
+
fs.chmodSync(bin, 0o755);
|
|
134
|
+
break;
|
|
135
|
+
case "zip":
|
|
136
|
+
await extractZip(
|
|
137
|
+
archivePath,
|
|
138
|
+
archive.bins,
|
|
139
|
+
binDir,
|
|
140
|
+
archive.wrappedIn,
|
|
141
|
+
);
|
|
142
|
+
break;
|
|
143
|
+
case "tar":
|
|
144
|
+
case "tar.gz":
|
|
145
|
+
case "tgz":
|
|
146
|
+
await extractTar(
|
|
147
|
+
archivePath,
|
|
148
|
+
archive.bins,
|
|
149
|
+
binDir,
|
|
150
|
+
archive.wrappedIn,
|
|
151
|
+
);
|
|
152
|
+
break;
|
|
153
|
+
case "tar.zst":
|
|
154
|
+
case "tzst":
|
|
155
|
+
case "tar.xz":
|
|
156
|
+
case "txz":
|
|
157
|
+
default:
|
|
158
|
+
throw new Error(`unsupported format: ${archive.format}`);
|
|
159
|
+
}
|
|
160
|
+
console.log(`Installed ${name} ${version} to ${binDir}`);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
throw new Error(`Installation failed: ${err.message}`);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const verify = (filename, checksum) => {
|
|
167
|
+
if (checksum.algorithm == "" || checksum.digest == "") {
|
|
168
|
+
console.warn("Warning: No checksum provided for verification");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
let digest = crypto
|
|
172
|
+
.createHash(checksum.algorithm)
|
|
173
|
+
.update(fs.readFileSync(filename))
|
|
174
|
+
.digest("hex");
|
|
175
|
+
if (digest != checksum.digest) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`${filename}: ${checksum.algorithm} does not match, expected ${checksum.digest}, got ${digest}`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const download = async (url, filename, maxRedirects = 10) => {
|
|
183
|
+
try {
|
|
184
|
+
console.log(`Downloading ${url} to ${filename}...`);
|
|
185
|
+
const dir = path.dirname(filename);
|
|
186
|
+
if (!fs.existsSync(dir)) {
|
|
187
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return new Promise((resolve, reject) => {
|
|
191
|
+
const parsedUrl = new URL(url);
|
|
192
|
+
const mod = parsedUrl.protocol === "https:" ? https : http;
|
|
193
|
+
|
|
194
|
+
const request = mod.get(url, { agent }, (response) => {
|
|
195
|
+
if (
|
|
196
|
+
response.statusCode >= 300 &&
|
|
197
|
+
response.statusCode < 400 &&
|
|
198
|
+
response.headers.location
|
|
199
|
+
) {
|
|
200
|
+
if (maxRedirects <= 0) {
|
|
201
|
+
reject(new Error("Too many redirects"));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
download(response.headers.location, filename, maxRedirects - 1)
|
|
205
|
+
.then(resolve)
|
|
206
|
+
.catch(reject);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (response.statusCode !== 200) {
|
|
211
|
+
reject(
|
|
212
|
+
new Error(
|
|
213
|
+
`HTTP ${response.statusCode}: ${response.statusMessage}`,
|
|
214
|
+
),
|
|
215
|
+
);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const writer = fs.createWriteStream(filename);
|
|
220
|
+
response.pipe(writer);
|
|
221
|
+
|
|
222
|
+
writer.on("finish", () => {
|
|
223
|
+
console.log(`Download complete: ${filename}`);
|
|
224
|
+
resolve(dir);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
writer.on("error", (err) => {
|
|
228
|
+
console.error(`Error writing file: ${err.message}`);
|
|
229
|
+
reject(err);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
request.on("error", (err) => {
|
|
234
|
+
reject(new Error(`Request failed: ${err.message}`));
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
request.setTimeout(300000, () => {
|
|
238
|
+
request.destroy();
|
|
239
|
+
reject(new Error("Request timed out"));
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
} catch (err) {
|
|
243
|
+
throw new Error(`Download failed: ${err.message}`);
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
function exists(archive) {
|
|
248
|
+
if (!fs.existsSync(binDir)) {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
return archive.bins.every((bin) => fs.existsSync(path.join(binDir, bin)));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export {
|
|
255
|
+
install,
|
|
256
|
+
run,
|
|
257
|
+
getArchive,
|
|
258
|
+
download,
|
|
259
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@compozy/skeeper",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "Sidecar Git versioning for spec artifacts",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"postinstall": "node install.js",
|
|
8
|
+
"run": "node run-skeeper.js"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/compozy/skeeper.git"
|
|
13
|
+
},
|
|
14
|
+
"author": "Compozy Team \u003csupport@compozy.com\u003e",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/compozy/skeeper/issues"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/compozy/skeeper",
|
|
20
|
+
"bin": {
|
|
21
|
+
"skeeper": "run-skeeper.js"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"jszip": "3.10.1",
|
|
25
|
+
"proxy-agent": "8.0.1",
|
|
26
|
+
"tar": "7.5.13"
|
|
27
|
+
},
|
|
28
|
+
"archives": {
|
|
29
|
+
"darwin-arm64": {
|
|
30
|
+
"name": "skeeper_0.1.0_darwin_arm64.tar.gz",
|
|
31
|
+
"url": "https://github.com/compozy/skeeper/releases/download/v0.1.0/skeeper_0.1.0_darwin_arm64.tar.gz",
|
|
32
|
+
"bins": [
|
|
33
|
+
"skeeper"
|
|
34
|
+
],
|
|
35
|
+
"format": "tar.gz",
|
|
36
|
+
"checksum": {
|
|
37
|
+
"algorithm": "sha256",
|
|
38
|
+
"digest": "3dffc1d6f8c06971d2ee8ba7bd6b3e0986f6a7d2f20ec11d9ba3d44ccc8a7e91"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"darwin-x64": {
|
|
42
|
+
"name": "skeeper_0.1.0_darwin_x86_64.tar.gz",
|
|
43
|
+
"url": "https://github.com/compozy/skeeper/releases/download/v0.1.0/skeeper_0.1.0_darwin_x86_64.tar.gz",
|
|
44
|
+
"bins": [
|
|
45
|
+
"skeeper"
|
|
46
|
+
],
|
|
47
|
+
"format": "tar.gz",
|
|
48
|
+
"checksum": {
|
|
49
|
+
"algorithm": "sha256",
|
|
50
|
+
"digest": "d1376a86f8660996be34293586b8361404bf4f59b0ff530fb5d45af31d0b6df6"
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"linux-arm64": {
|
|
54
|
+
"name": "skeeper_0.1.0_linux_arm64.tar.gz",
|
|
55
|
+
"url": "https://github.com/compozy/skeeper/releases/download/v0.1.0/skeeper_0.1.0_linux_arm64.tar.gz",
|
|
56
|
+
"bins": [
|
|
57
|
+
"skeeper"
|
|
58
|
+
],
|
|
59
|
+
"format": "tar.gz",
|
|
60
|
+
"checksum": {
|
|
61
|
+
"algorithm": "sha256",
|
|
62
|
+
"digest": "73ceebead4563597d5918f022371998a1756fb7ebd86cc9ecc8a571ee39c9298"
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
"linux-x64": {
|
|
66
|
+
"name": "skeeper_0.1.0_linux_x86_64.tar.gz",
|
|
67
|
+
"url": "https://github.com/compozy/skeeper/releases/download/v0.1.0/skeeper_0.1.0_linux_x86_64.tar.gz",
|
|
68
|
+
"bins": [
|
|
69
|
+
"skeeper"
|
|
70
|
+
],
|
|
71
|
+
"format": "tar.gz",
|
|
72
|
+
"checksum": {
|
|
73
|
+
"algorithm": "sha256",
|
|
74
|
+
"digest": "3f3debdd7ab88395271cc1d0c904d16a07aaa881fc683fcbcbcf0324ca588caa"
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
"win32-x64": {
|
|
78
|
+
"name": "skeeper_0.1.0_windows_x86_64.zip",
|
|
79
|
+
"url": "https://github.com/compozy/skeeper/releases/download/v0.1.0/skeeper_0.1.0_windows_x86_64.zip",
|
|
80
|
+
"bins": [
|
|
81
|
+
"skeeper.exe"
|
|
82
|
+
],
|
|
83
|
+
"format": "zip",
|
|
84
|
+
"checksum": {
|
|
85
|
+
"algorithm": "sha256",
|
|
86
|
+
"digest": "2e1f4162817f0b8d7673d3489d7152a10409a61f788273e09455befbcff9fad7"
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|