Skip to content

Commit c9522b1

Browse files
release v0.11.7: symlink resolution, simplified remote fetch
Packs in monorepos can now share content via symlinks (e.g. rules/shared.md -> ../../shared/base.md). Symlinks are resolved to regular files at install time. Targets must stay within the repository boundary — absolute paths, .git traversal, and directory symlinks are rejected. Remote pack fetches for SSH URLs now use shallow clone universally, matching the behavior of all non-GitHub URLs. This fixes content discovery for packs that rely on directory-walking rather than explicit manifest inventory. Signed-off-by: David Foster <david.foster@oracle.com> Signed-off-by: davidfos <david.d.foster@oracle.com>
1 parent 824338e commit c9522b1

20 files changed

+1224
-781
lines changed

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@ The format is based on Keep a Changelog, and releases use semantic versioning ta
66

77
## Unreleased
88

9+
## [0.11.7]
10+
11+
### Added
12+
13+
- Symlinks in pack content are now resolved at install time when the target is within the source repository boundary. This enables packs in monorepos to share content via symlinks (e.g. `rules/shared.md -> ../../shared/base.md`). The installed pack always contains regular files. Symlinks that escape the repo boundary, point to directories, traverse `.git/`, or use absolute targets are rejected. The `--link` install method is unchanged.
14+
15+
### Changed
16+
17+
- Bitbucket Server SSH URLs (port 7999) now use shallow clone instead of `git archive --remote`. This fixes auto-discovery for packs with incomplete manifests and simplifies the install pipeline. Packs previously installed via archive will update via clone on next `pack update`.
18+
919
## [0.11.6]
1020

1121
### Changed

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.11.6
1+
0.11.7

docs/aipack.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,14 @@ aipack pack create ./path/to/dir --name custom-pack-name
6565
Installs a pack into `~/.config/aipack/packs/<name>/`. Supports three sources:
6666

6767
- **Local path** (symlinked by default, `--copy` for full copy)
68-
- **URL** (`--url` — fetched via `git archive` with automatic fallback to shallow clone)
68+
- **URL** (`--url` — fetched via HTTP tarball for GitHub, shallow clone for everything else)
6969
- **Registry name** (bare name like `my-team-pack` — looked up in registry, then fetched)
7070

7171
`aipack install` is a top-level alias for `aipack pack install`.
7272

7373
With `-m`/`--missing`, installs all missing packs from the active profile by looking them up in the registry. This is the easiest way to catch up after setting a profile or after new packs are added to a shared profile.
7474

75-
Remote packs are fetched using a two-phase process: first the manifest (`pack.json`) is retrieved to determine declared content, then only the declared files are fetched. This avoids downloading the full repository. When the remote doesn't support `git archive --remote` (e.g. GitHub), aipack falls back to a shallow clone automatically.
75+
Remote packs from GitHub HTTPS URLs are fetched as HTTP tarballs (no git binary required). All other URLs use a shallow clone (`git clone --depth 1`).
7676

7777
Both HTTPS and SSH URLs are supported. SSH URLs (`git@host:path` or `ssh://`) avoid credential prompts.
7878

@@ -108,7 +108,7 @@ aipack pack install ./my-pack --profile production
108108

109109
### pack list
110110

111-
Lists all installed packs with name, install method (link/copy/clone/archive), version, origin, and broken-link status.
111+
Lists all installed packs with name, install method (link/copy/clone/http-tarball), version, origin, and broken-link status.
112112

113113
```bash
114114
aipack pack list
@@ -126,7 +126,7 @@ aipack pack show my-pack --json
126126

127127
### pack update
128128

129-
Updates installed pack(s) to latest version from their origin. For archive-installed packs, re-fetches declared content and shows a file-level diff of changes. For git-cloned packs, runs `git pull`. For copied packs, re-copies from the recorded origin. For symlinked packs, re-validates the link target. Exactly one of `<name>` or `--all` is required.
129+
Updates installed pack(s) to latest version from their origin. For cloned packs, runs `git pull`. For HTTP-tarball packs, re-downloads and shows a file-level diff. For copied packs, re-copies from the recorded origin. For symlinked packs, re-validates the link target. Exactly one of `<name>` or `--all` is required.
130130

131131
```bash
132132
aipack pack update my-pack

docs/cli-spec.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,7 @@ Always an array. Empty `[]` when no packs are installed.
327327
{
328328
"name": "essentials",
329329
"path": "/Users/x/.config/aipack/packs/essentials",
330-
"method": "archive",
330+
"method": "http-tarball",
331331
"version": "2026.03.07",
332332
"origin": "https://github.com/shrug-labs/packs.git",
333333
"is_link": false
@@ -354,7 +354,7 @@ Content ID arrays are always present (empty `[]`, never null).
354354
"name": "essentials",
355355
"version": "2026.03.07",
356356
"path": "/Users/x/.config/aipack/packs/essentials",
357-
"method": "archive",
357+
"method": "http-tarball",
358358
"origin": "https://github.com/shrug-labs/packs.git",
359359
"ref": "main",
360360
"commit_hash": "abc123def456",
@@ -497,7 +497,7 @@ Several flags follow a common resolution chain across commands:
497497

498498
**Diff kinds:** `create` (file doesn't exist on disk), `identical` (desired matches on-disk), `managed` (on-disk matches ledger — safe to update), `conflict` (user-modified since last sync), `untracked` (exists on disk but not in ledger), `error` (classification failed)
499499

500-
**Install methods:** `archive` (git archive fetch), `clone` (shallow git clone), `copy` (copied from local path), `link` (symlinked to local path), `local` (already in packs directory, registered in-place)
500+
**Install methods:** `clone` (shallow git clone), `http-tarball` (GitHub HTTP tarball), `copy` (copied from local path), `link` (symlinked to local path), `local` (already in packs directory, registered in-place), `archive` (legacy — treated as clone on update)
501501

502502
**Finding categories:** `frontmatter`, `policy`, `consistency`, `inventory`
503503

docs/configuration.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ defaults:
5353
installed_packs: # managed by pack install/delete/update
5454
essentials:
5555
origin: "https://github.com/shrug-labs/packs.git"
56-
method: archive
56+
method: http-tarball
5757
installed_at: "2026-03-10T08:30:00Z"
5858
ref: main
5959
sub_path: essentials
@@ -91,7 +91,7 @@ Each entry records how a pack was installed. Keys are pack names.
9191
| Field | Type | Description |
9292
|-------|------|-------------|
9393
| `origin` | string | Absolute local path or remote URL |
94-
| `method` | string | `link`, `copy`, `clone`, or `archive` |
94+
| `method` | string | `link`, `copy`, `clone`, `http-tarball`, or `archive` (legacy) |
9595
| `installed_at` | string | RFC 3339 timestamp |
9696
| `ref` | string | Git ref used at install time (remote only) |
9797
| `sub_path` | string | Subdirectory within the repo (remote only) |
@@ -121,10 +121,10 @@ Packs live under `~/.config/aipack/packs/<name>/`. Four install methods produce
121121
| `link` | Symlink to source directory | Yes — edits at either location hit the same files | Re-validates symlink target |
122122
| `copy` | Full copy from local path | No — edits are local only | Re-copies from recorded origin |
123123
| `clone` | Shallow git clone | Yes (with `git pull`) | `git pull` in the clone |
124-
| `archive` | Extracted from `git archive` | No — edits are local only | Re-fetches via archive, shows file-level diff |
124+
| `http-tarball` | Downloaded from GitHub | No — edits are local only | Re-downloads tarball, shows file-level diff |
125125
| `local` | Pack already in packs directory | Yes — it's the source | Registered in-place, no fetch |
126126

127-
`link` is the default for local installs and is the best choice for pack development — you edit the source and `sync --watch` picks up changes automatically. `archive` is the default for remote installs and is the most space-efficient for consumers.
127+
`link` is the default for local installs and is the best choice for pack development — you edit the source and `sync --watch` picks up changes automatically. `clone` is the default for SSH remote installs; `http-tarball` is used for GitHub HTTPS URLs.
128128

129129
### Integrity tracking
130130

docs/pack-format.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -499,8 +499,8 @@ Without the override declaration, duplicate IDs across packs are treated as erro
499499

500500
Packs are installed from git repositories. Two fetch strategies are supported:
501501

502-
1. **Archive fetch** (preferred) — `git archive --remote` retrieves only declared content. Two phases: manifest first (to discover content), then declared files. Efficient for large repos where the pack is a subdirectory.
503-
2. **Shallow clone** (fallback) — used when the remote doesn't support `git archive` (e.g., GitHub). Performs a depth-1 clone.
502+
1. **HTTP tarball** — GitHub HTTPS URLs download a tarball directly (no git binary required).
503+
2. **Shallow clone** — all other URLs use `git clone --depth 1`.
504504

505505
Both HTTPS and SSH URLs are supported. Packs can live in a subdirectory of a larger repository (common for team mono-repos).
506506

@@ -560,7 +560,7 @@ aipack sync
560560

561561
Pack versioning uses git refs. The `version` field in `pack.json` is informational. The authoritative version is the git ref (branch, tag, or commit) used at install time.
562562

563-
For archive-installed packs, the commit hash at install time is recorded, enabling update detection.
563+
For remotely installed packs, the commit hash at install time is recorded, enabling update detection.
564564

565565
## 10. Harness Contract
566566

internal/app/pack.go

Lines changed: 17 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
package app
22

33
import (
4-
"bytes"
54
"context"
6-
"errors"
75
"fmt"
86
"io"
97
"os"
@@ -58,7 +56,6 @@ type PackAddRequest struct {
5856

5957
// Test injection points:
6058
RunGitFn func(ctx context.Context, args ...string) error
61-
ArchiveFn func(ctx context.Context, repoURL, ref string, paths []string) ([]byte, error) // nil = config.GitArchiveFiles
6259
HTTPTarballFn func(ctx context.Context, tarballURL, destDir, subPath string, opts config.ArchiveOpts) error
6360
URLOKFn func(ctx context.Context, raw string) (bool, error)
6461
NowFn func() time.Time
@@ -180,7 +177,8 @@ func packAddFromPath(req PackAddRequest, stdout io.Writer) error {
180177
return fmt.Errorf("creating temp dir: %w", err)
181178
}
182179
defer os.RemoveAll(tmpDir)
183-
if err := util.CopyDir(packDir, tmpDir); err != nil {
180+
boundary := util.FindRepoRoot(packDir)
181+
if err := util.CopyDirResolvingSymlinks(packDir, tmpDir, boundary); err != nil {
184182
return fmt.Errorf("copying pack: %w", err)
185183
}
186184
packRemoveExisting(destDir, stdout)
@@ -297,8 +295,6 @@ func packAddFromURL(ctx context.Context, req PackAddRequest, stdout io.Writer) e
297295
switch strategy {
298296
case config.StrategyHTTPTarball:
299297
result, err = packFetchHTTPTarball(ctx, req, info, packsDir, stdout)
300-
case config.StrategyGitArchive:
301-
result, err = packTryArchive(ctx, req, info, packsDir, stdout)
302298
default:
303299
result, err = packShallowClone(ctx, req, info, packsDir, stdout)
304300
}
@@ -351,100 +347,6 @@ type packInstallResult struct {
351347
commitHash string
352348
}
353349

354-
// packTryArchive performs a two-phase git archive fetch (Bitbucket Server only).
355-
// Archive errors are terminal — no fallback to clone.
356-
func packTryArchive(ctx context.Context, req PackAddRequest, info config.PackURLInfo, packsDir string, stdout io.Writer) (packInstallResult, error) {
357-
subPath := info.SubPath
358-
archiveFn := req.ArchiveFn
359-
if archiveFn == nil {
360-
archiveFn = config.GitArchiveFiles
361-
}
362-
363-
// Phase 1: fetch only pack.json via archive.
364-
manifestRelPath := "pack.json"
365-
if subPath != "" {
366-
manifestRelPath = subPath + "/pack.json"
367-
}
368-
369-
tarData, err := archiveFn(ctx, info.RepoURL, info.Ref, []string{manifestRelPath})
370-
if err != nil {
371-
return packInstallResult{}, fmt.Errorf("fetching manifest from %s: %w", info.RepoURL, err)
372-
}
373-
374-
// Extract pack.json from the tar stream to parse manifest.
375-
manifest, err := parseManifestFromTar(tarData, manifestRelPath)
376-
if err != nil {
377-
return packInstallResult{}, fmt.Errorf("parsing manifest from archive: %w", err)
378-
}
379-
380-
name, err := resolvePackName(req.Name, manifest.Name)
381-
if err != nil {
382-
return packInstallResult{}, err
383-
}
384-
385-
// Phase 2: compute content paths and fetch all declared files.
386-
contentPaths := manifest.ContentPaths()
387-
// Prepend subPath prefix if the pack lives in a subdirectory.
388-
if subPath != "" {
389-
for i, p := range contentPaths {
390-
contentPaths[i] = subPath + "/" + p
391-
}
392-
}
393-
394-
tarData, err = archiveFn(ctx, info.RepoURL, info.Ref, contentPaths)
395-
if err != nil {
396-
if errors.Is(err, config.ErrArchivePathNotFound) {
397-
return packInstallResult{}, fmt.Errorf("pack.json declares content not found in the repository — check that all listed rules/skills/workflows are committed: %w", err)
398-
}
399-
return packInstallResult{}, fmt.Errorf("fetching pack content from %s: %w", info.RepoURL, err)
400-
}
401-
402-
// Extract into temp dir with safety validation.
403-
archiveDir, err := makePackTempDir(req.ConfigDir, "archive-*")
404-
if err != nil {
405-
return packInstallResult{}, fmt.Errorf("creating temp dir: %w", err)
406-
}
407-
// Archive dir is always transient — clean it unconditionally.
408-
defer os.RemoveAll(archiveDir)
409-
410-
if err := config.ExtractArchive(bytes.NewReader(tarData), archiveDir, config.ArchiveOpts{}); err != nil {
411-
return packInstallResult{}, fmt.Errorf("extracting pack archive: %w", err)
412-
}
413-
414-
// For subdirectory packs, the extracted content is under archiveDir/<subPath>/.
415-
// Extract it into its own temp dir so we can install just the subtree.
416-
installDir := archiveDir
417-
packRoot := archiveDir
418-
if subPath != "" {
419-
subTmp, err := extractSubtree(packStagingDir(req.ConfigDir), archiveDir, subPath)
420-
if err != nil {
421-
return packInstallResult{}, err
422-
}
423-
defer func() {
424-
if _, serr := os.Lstat(subTmp); serr == nil {
425-
os.RemoveAll(subTmp)
426-
}
427-
}()
428-
installDir = subTmp
429-
packRoot = subTmp
430-
}
431-
432-
// Verify pack.json exists in the extracted content.
433-
if _, err := os.Stat(filepath.Join(packRoot, "pack.json")); err != nil {
434-
return packInstallResult{}, fmt.Errorf("pack.json not found in extracted archive")
435-
}
436-
437-
destDir := filepath.Join(packsDir, name)
438-
packRemoveExisting(destDir, stdout)
439-
440-
if err := os.Rename(installDir, destDir); err != nil {
441-
return packInstallResult{}, fmt.Errorf("moving archive to %s: %w", destDir, err)
442-
}
443-
fmt.Fprintf(stdout, "Installed: %s -> %s\n", req.URL, destDir)
444-
445-
return packInstallResult{name: name, destDir: destDir, method: config.MethodArchive, manifest: manifest}, nil
446-
}
447-
448350
// packFetchHTTPTarball installs a pack by downloading a GitHub HTTP tarball.
449351
func packFetchHTTPTarball(ctx context.Context, req PackAddRequest, info config.PackURLInfo, packsDir string, stdout io.Writer) (packInstallResult, error) {
450352
tarballURL, err := config.GitHubTarballURL(info.RepoURL, info.Ref)
@@ -563,6 +465,16 @@ func packShallowClone(ctx context.Context, req PackAddRequest, info config.PackU
563465
if err := os.Rename(installDir, destDir); err != nil {
564466
return packInstallResult{}, fmt.Errorf("moving clone to %s: %w", destDir, err)
565467
}
468+
469+
// For root packs (no subPath extraction), the installed directory may
470+
// contain symlinks from the cloned repo. Resolve them in-place so the
471+
// installed pack contains only regular files.
472+
if subPath == "" {
473+
if err := util.ResolveSymlinksInDir(destDir); err != nil {
474+
return packInstallResult{}, fmt.Errorf("resolving symlinks in clone: %w", err)
475+
}
476+
}
477+
566478
fmt.Fprintf(stdout, "Cloned: %s -> %s\n", req.URL, destDir)
567479

568480
return packInstallResult{name: name, destDir: destDir, method: config.MethodClone, manifest: manifest, commitHash: commitHash}, nil
@@ -588,17 +500,6 @@ func listClonePacks(dir string) string {
588500
return strings.Join(packs, ", ")
589501
}
590502

591-
// parseManifestFromTar extracts and parses pack.json from tar data.
592-
// The expectedPath is the relative path within the tar (e.g. "pack.json"
593-
// or "subdir/pack.json").
594-
func parseManifestFromTar(tarData []byte, expectedPath string) (config.PackManifest, error) {
595-
data, err := config.ExtractSingleFileFromTar(tarData, expectedPath)
596-
if err != nil {
597-
return config.PackManifest{}, err
598-
}
599-
return config.ParsePackManifest(data)
600-
}
601-
602503
// packProfileName returns the trimmed profile name, defaulting to "default".
603504
func packProfileName(raw string) string {
604505
if p := strings.TrimSpace(raw); p != "" {
@@ -955,7 +856,7 @@ func extractSubtree(parentDir, cloneDir, subPath string) (string, error) {
955856
if err != nil {
956857
return "", err
957858
}
958-
if err := util.CopyDir(src, tmp); err != nil {
859+
if err := util.CopyDirResolvingSymlinks(src, tmp, cloneDir); err != nil {
959860
os.RemoveAll(tmp)
960861
return "", fmt.Errorf("copying pack subtree: %w", err)
961862
}
@@ -1112,10 +1013,9 @@ type PackUpdateRequest struct {
11121013
ConfigDir string
11131014
Name string // empty when All=true
11141015
All bool
1115-
RunGitFn func(ctx context.Context, args ...string) error // test injection; nil = real git
1116-
NowFn func() time.Time // test injection; nil = time.Now
1117-
GitHashFn func(ctx context.Context, dir string) (string, error) // test injection; nil = config.GitHeadHash
1118-
ArchiveFn func(ctx context.Context, repoURL, ref string, paths []string) ([]byte, error) // test injection; nil = config.GitArchiveFiles
1016+
RunGitFn func(ctx context.Context, args ...string) error // test injection; nil = real git
1017+
NowFn func() time.Time // test injection; nil = time.Now
1018+
GitHashFn func(ctx context.Context, dir string) (string, error) // test injection; nil = config.GitHeadHash
11191019
HTTPTarballFn func(ctx context.Context, tarballURL, destDir, subPath string, opts config.ArchiveOpts) error
11201020
}
11211021

@@ -1174,7 +1074,6 @@ type packUpdateContext struct {
11741074
runGitFn func(ctx context.Context, args ...string) error
11751075
nowFn func() time.Time
11761076
gitHashFn func(ctx context.Context, dir string) (string, error)
1177-
archiveFn func(ctx context.Context, repoURL, ref string, paths []string) ([]byte, error)
11781077
httpTarballFn func(ctx context.Context, tarballURL, destDir, subPath string, opts config.ArchiveOpts) error
11791078
stdout io.Writer
11801079
}
@@ -1202,7 +1101,6 @@ func newPackUpdateContext(req PackUpdateRequest, sc config.SyncConfig, stdout io
12021101
runGitFn: runGitFn,
12031102
nowFn: nowFn,
12041103
gitHashFn: req.GitHashFn,
1205-
archiveFn: req.ArchiveFn,
12061104
httpTarballFn: req.HTTPTarballFn,
12071105
stdout: stdout,
12081106
}
@@ -1374,7 +1272,6 @@ func packUpdateOne(ctx context.Context, name string, uctx packUpdateContext) Pac
13741272
Ref: ref,
13751273
SubPath: subPath,
13761274
Name: name,
1377-
ArchiveFn: uctx.archiveFn,
13781275
HTTPTarballFn: uctx.httpTarballFn,
13791276
}
13801277
addInfo := config.PackURLInfo{RepoURL: origin, Ref: ref, SubPath: subPath}
@@ -1383,7 +1280,7 @@ func packUpdateOne(ctx context.Context, name string, uctx packUpdateContext) Pac
13831280
if method == config.MethodHTTPTarball {
13841281
result, err = packFetchHTTPTarball(ctx, addReq, addInfo, uctx.packsDir, io.Discard)
13851282
} else {
1386-
result, err = packTryArchive(ctx, addReq, addInfo, uctx.packsDir, io.Discard)
1283+
result, err = packShallowClone(ctx, addReq, addInfo, uctx.packsDir, io.Discard)
13871284
}
13881285
if err != nil {
13891286
return PackUpdateResult{Name: name, Method: method, Status: "error", Message: err.Error()}

0 commit comments

Comments
 (0)