Skip to content

Package Manager (git-based) ​

Phony distributes generator packages over git, Go-modules style. A package is just a git repository (optionally a subdirectory of one) that contains a valid phony.json. There is no central registry, no publish, no search: anyone makes a package by pushing the right files to a git repo, and others use it by declaring its git URL as a dependency.

phony-cli reads a project's phony.json, resolves its dependency closure from git, fetches each package once into a global content-addressed store, pins the result in a phony.lock, and loads the whole closure into the engine so phony generate can use any generator or asset from any dependency. Large data assets (n-gram models, big lists) are fetched out of git as release artifacts (URL + checksum), so package repos stay lean.

Implementation status

Implemented in phony-cli (the pkg module): the phony.json manifest + dependency parsing, MVS resolution, the content-addressed store, git fetch (shell-out), remote-asset fetch + verification, phony.lock, the closure load, and the phony install / add / remove / update / list commands — plus generate auto-loading the project closure. Hosted registry, search, publish, and the .phony tarball remain out of scope (see the last section).

This supersedes the registry/publish/inherits parts of Package Format and Locale System — the older manifest.json + schema.pgdl.json + inherits examples there are replaced by the flat phony.json + dependencies model below (those pages will be reconciled). It builds on the locale contribution model and the Execution Model.

Repo organisation

The built-in Tier-0 generators (@phony/core:logic.*, :list, :model, :statistical, :event_sequence, and composition) are compiled into the engine — they are always available and are never fetched as dependencies. First-party content packages (@phony/base, locale packages like @phony/tr_TR) are ordinary git packages: each lives in its own repository (independent vX.Y.Z tags), and ships its large models/lists as release artifacts rather than committing them into git history.

The package: phony.json ​

A package is a directory with a single flat manifest, phony.json — the form the engine's loader already consumes (load_package_dir). It is the only manifest the package manager reads: there is no separate manifest.json or schema.pgdl.json (those belonged to the now-removed entity layer). Assets are either committed alongside the manifest (small lists) or fetched as release artifacts (large models — see Remote assets).

json
{
  "name": "@acme/geo-tr",
  "version": "1.2.0",
  "dependencies": {
    "@phony/base": { "git": "https://github.com/phonycloud/base", "version": "^1.0.0" }
  },
  "generators": [
    { "name": "@acme/geo-tr:address", "let": { "il": { "type": "list", "source": "@acme/geo-tr:cities" } }, "body": "{{ il }}" }
  ],
  "assets": {
    "lists":  { "@acme/geo-tr:cities": { "*": "data/cities.json" } },
    "models": { "@acme/geo-tr:names":  { "tr_TR": { "url": "https://github.com/acme/geo-tr/releases/download/v1.2.0/names-tr.ngram", "sha256": "…" } } }
  }
}
fieldrequiredmeaning
nameyesthe package id @scope/pkg; each segment lowercase a-z 0-9 - _ . (@<scope>/<pkg>); MUST equal the name a dependent declares
versionyesthe package's semver (1.2.0); the resolved git commit is authoritative, version is informational and SHOULD match the git tag (v1.2.0)
dependenciesno (default {})map of dependency name → git source (see below); subsumes the old inherits — a base/locale package is depended-on like any other
generatorsno (default [])an array of composition generator objects ({ name, params?, let?, body|variants|output }); the only generator form a package ships (Tier 0 are built in, Tier 2 WASM is deferred)
assetsno (default {})lists/models contributions, keyed by asset name then locale; each value is a path ("data/cities.json", in-repo), inline data (a list array ["a","b"] / map object), or a remote artifact { "url", "sha256" } (see below)

Optional metadata (description, license, author, keywords, repository) is permitted and ignored by the loader. A package's generators MUST be in its own namespace (@acme/geo-tr:…); a generator name outside it is a fatal error at resolution. Asset keys are looser — a package may contribute to another package's asset (the locale-contribution model; see Relationship to the locale system). A phony.json that is missing, unparseable, or missing name/version is a fatal error naming the package and its git source (or, for the root project, its local path).

Dependency declaration ​

dependencies maps a package name to a git source. Two equivalent forms:

json
"dependencies": {
  "@phony/base":  { "git": "https://github.com/phonycloud/base", "version": "^1.0.0", "path": "pkg" },
  "@acme/colors": "github.com/acme/colors@v2.1.0"
}
keymeaning
gitthe clone URL (object form); https:// or ssh:///git@
versiona semver range resolved against the repo's vX.Y.Z tags; ranges exclude pre-release tags (1.0.0-rc.1) unless the range itself names a pre-release
refa branch or commit to pin instead of version; mutually exclusive with version (declaring both is a fatal error)
patha subdirectory of the repo holding the package's phony.json; a relative subpath only (no .., no absolute path), else a fatal error (default: repo root)

The string shorthand "<host>/<path>@<spec>" expands to { git: "https://<host>/<path>", … }. <spec> is read as a version when it parses as a semver range (it starts with a digit, v+digit, or one of ^ ~ > < = * x, e.g. @^1.2.0, @v1.2.0, @>=1.0 <2.0); otherwise it is a ref — a branch or a commit (e.g. @main, @a1b2c3d). The object path field has no shorthand — use the object form for subdirectories. A dependency's declared name MUST match the name in the fetched package's phony.json, else resolution errors naming both.

Resolution (minimal version selection) ​

Resolution builds the transitive dependency closure and selects versions with MVS (Go-modules style — pick the lowest version that satisfies every constraint, for reproducibility):

  1. Start from the project phony.json dependencies.
  2. For each dependency, fetch its repo at a candidate tag, read its phony.json (a missing/unparseable manifest fails, naming the package + git source), and recurse into its dependencies.
  3. Match version ranges against the repo's tags of the form vMAJOR.MINOR.PATCH[-prerelease][+build] (semver with a v prefix); tags that do not parse this way are silently skipped for range matching. Compare by semver.org precedence (pre-release < release); for a package required at several version ranges, select the lowest tag that satisfies all of them. A ref-pinned requirement pins exactly (and skips tag matching). If a version is requested but the repo has no matching semver tag, fail with "no version matching <range>".
  4. If no tag satisfies all constraints, fail with a conflict error naming the package and the conflicting requirers.
  5. Detect cycles by depth-first traversal: a package re-entered while on the current path is a cycle, reported with the path; traversal is bounded to a closure depth of 100 so a pathological graph cannot hang.

The output is the resolved closure: each member as { name, git, version, commit }, in a deterministic order — a topological sort, dependencies (leaves) before dependents, ties broken by package name ascending.

The store: ~/.phony/store ​

Fetched packages and remote assets live in a single global, content-addressed store, reused across all projects:

~/.phony/store/
├── git/<host>/<scope>/<pkg>/<commit>/   # a package's files at that exact commit
└── blobs/<sha256>                        # a remote asset (release artifact) by content hash
rulebehaviour
location~/.phony/store, overridable by PHONY_HOME ($PHONY_HOME/store); created (recursively, user-only — 0700 on Unix, the OS equivalent elsewhere) on first use; must be writable, else a clear error
git keythe resolved git commit as full lowercase hex, so a moved tag is still distinguishable
blob keythe asset's sha256 (lowercase hex); two packages referencing the same artifact share one blob
atomicityan entry is fetched into a temp path and renamed into place — the rename is the commit point, so a reader never sees a partial entry; if the rename fails or the entry already exists the temp is discarded; an interrupted fetch leaves only an orphaned temp (ignored, retried on the next install); once present an entry is read-only
reuseif the commit / blob is already present, no network fetch happens

Remote (release) assets ​

A models/lists value may be a remote artifact { "url": "<https-url>", "sha256": "<hex>" } instead of an in-repo path — the way large .ngram models and big lists are shipped (e.g. attached to a GitHub release: …/releases/download/v1.2.0/names-tr.ngram), keeping git history small. The URL SHOULD embed the package version, so a new release's phony.json points at fresh artifacts and phony update picks them up.

rulebehaviour
schemeonly https:// URLs are allowed; any other scheme is a fatal error when the asset reference is read (at install/fetch time, where asset URLs are first examined)
fetchdownloaded into store/blobs/<sha256> with the same atomic temp→rename as a git entry (an interrupted download leaves only an orphaned temp, ignored and retried on the next install)
verifythe bytes MUST hash to the declared sha256 on fetch, else a fatal "asset checksum mismatch" naming the asset + URL; because the blob is stored under its sha256, a present blob needs no re-hash on load
integritythe asset sha256 values live in the (git-pinned) phony.json, so the lock's commit pins the manifest and therefore the asset set — this is the commit, not the package checksum (which excludes remote bytes)
installphony install eagerly fetches all remote assets declared across the closure (so --frozen/offline then works); phony add runs install, provisioning a new dependency's assets too
--frozennever download; error if a referenced blob is absent from the store

The lockfile: phony.lock ​

phony install writes a phony.lock at the project root pinning the exact closure for reproducible loads:

json
{
  "version": 1,
  "packages": [
    { "name": "@phony/base", "git": "https://github.com/phonycloud/base", "version": "1.0.3", "commit": "a1b2c3…", "checksum": "sha256:…" }
  ]
}
fieldmeaning
namethe closure member's package name
gitthe resolved clone URL
versionthe selected semver (informational; commit is authoritative)
committhe exact commit fetched into the store — the source of truth
checksumsha256:<hex> of the package content (see below), computed at install, re-verified on load

The checksum is deterministic: list every regular file under the package directory (a symlink under the package is a fatal error, surfaced at install/load with the package + path; empty directories are not represented), sort the relative paths lexicographically as raw bytes (always /-separated, regardless of OS), and feed each file to one SHA-256 stream as path · 0x00 · the byte length as an ASCII decimal string (e.g. a 1024-byte file contributes the four bytes 1024) · 0x00 · file bytes; no filesystem metadata (mode, mtime) is hashed. Remote-asset bytes are not in the package directory, so they are excluded from this checksum — they are pinned by their own sha256 in the (git-pinned) phony.json.

When a phony.lock is present it is used verbatim (no re-resolution) by phony install and phony generate; phony update re-resolves and rewrites it. A phony.lock whose commit (or a referenced blob) is absent from the store triggers a fetch (unless --frozen); the phony.lock stores no absolute paths, so it is portable across machines and PHONY_HOME values.

Loading the closure ​

phony generate, run in a project that has a phony.json, loads the full closure — the root package plus every locked dependency — into one Registry + LocaleAssets, then generates:

  1. Read the manifest at ./phony.json (or --manifest <path>, resolved relative to the CWD; a missing manifest is a clear error); if a phony.lock exists, use it, else resolve first.
  2. If any locked package or referenced blob is missing from the store, fetch it (unless --frozen, which errors instead); a fetched package is verified against its lock checksum and a fetched blob against its sha256 (an already-present blob, stored under its sha256, is not re-hashed).
  3. Load the closure in the resolved order — leaves first, the root package last (ties broken by name ascending) — via the existing package loader, resolving remote assets to their blobs.
  4. Compose the requested generator against the merged registry/assets, exactly as today.
rulebehaviour
duplicate generator nametwo closure members registering the same @scope/pkg:name → error (a real conflict)
asset contribution to the same (asset, locale)the package later in load order wins for that exact (asset, locale): the root (loaded last) overrides its dependencies, and between two non-root members the later in load order (topological, then name-ascending) wins — deterministic, not an error
locale resolutionthe domain > locale > base chain (which locale a single asset resolves to, at generate time) is unchanged and orthogonal to the above (which package's contribution supplies a given (asset, locale))
--package <dir>loads a single local directory with no resolution, as an explicit override layer after the closure; a name it re-defines wins (this is how you test a local package against a closure) — distinct from the within-closure duplicate-name error above
--frozennever fetch; error if the store is missing a locked package or blob

Relationship to the locale system ​

The locale system's three layers (base → locale → domain) are not a separate package mechanism: a domain or locale package simply lists the packages it builds on under dependencies (this replaces the old inherits field). Getting those packages into the closure is the dependency mechanism above; which locale a given asset resolves to at generate time is the separate domain > locale > basechain passed to the resolver (--locale). The two never conflict — one is about loading packages, the other about resolving an asset within a locale chain. A generator name collision is always an error; an asset (name, locale) supplied by two packages is resolved by load order (root wins), as above.

Cross-package asset contribution. Because the locale model lets any package supply data for an asset, asset keys are not namespace-restricted. The loader classifies each asset key by comparing its @scope/pkg segment to the manifest's own name: a key in the package's own namespace (@acme/geo-tr:cities) defines a new asset, while a key naming another package (@phony/names:first) contributes to that package's asset (e.g. a data-only repo adds a de_DE model to someone else's @phony/names:first, without touching that package). Contributions to different locales of an asset merge into the index; two contributions to the same (asset, locale) resolve by load order (root wins, per § Loading the closure). Granularity is one whole list/model per (asset, locale) — never a partial merge of one locale's data across packages. (A contributor SHOULD list the owning package under dependencies to align versions, though the closure brings both in regardless.)

One model = one file. An n-gram model is inherently single-language: mixing languages in one model blends their statistics into incoherent output. So the model file is the atomic unit (one language's model); the package is the bundle (many model files + lists + generators, versioned together); the asset is the locale-polymorphic name (locale → file). "All locales in one shippable unit" is what a package gives you — via separate per-locale files — so there is no multi-model container file (it would break independent contribution, locale-independence, and selective fetch).

Git access ​

phony-cli shells out to the system git binary (like go get): a shallow (--depth 1) fetch of the resolved tag or ref into the store. There is no embedded git library, and Phony adds no auth of its own.

concernbehaviour
authenticationuses the system git configuration (SSH keys, credential helpers, .netrc); private repos work iff the user's git can already clone them
git missing"git is required to fetch packages — install git", before any fetch
repo unreachablethe package name + the underlying git error
shallow fallbackonly when the shallow fetch reports the target tag/ref unreachable, retry once with a full fetch before declaring it missing; other git errors (auth, network) fail immediately (shallow is only an optimisation)
tag/ref missing"no version matching <range> (tags: …)" or "ref <ref> not found"
checksum mismatch"store entry for <pkg> failed its checksum — re-install"

CLI commands ​

phony-cli gains the package commands (git-based; no search/publish/login):

commandbehaviour
phony installwith a valid phony.lock, fetch any missing closure members + blobs from it; else resolve the project phony.json deps, fetch, and write phony.lock. --update ignores the lock and re-resolves from the current phony.json; --frozen never fetches
phony add <git-url>[@version] [--name <@scope/pkg>]fetch the repo, read its phony.json name (if --name is given it MUST match, else error), add the dependency to phony.json, re-resolve, update phony.lock; an already-present dependency is updated to the new spec
phony remove <name>remove a dependency from phony.json, re-resolve, rewrite phony.lock
phony update [name]re-resolve the named (or all) deps ignoring the lock, fetch, rewrite phony.lock; a ref-pinned dependency only moves when named explicitly
phony listprint the resolved closure — one package per line, space-separated name version commit git-url (or a JSON array of { name, version, commit, git } with --json)
phony generatealso auto-loads the project closure from ./phony.json/phony.lock; any --package <dir> is loaded (with no resolution) after the closure, so its generators/assets take precedence on a conflict

Errors & edge cases ​

casebehaviour
no git on PATHclear error before any fetch
dependency's phony.json missing/unparseableerror naming the package + git source
dependency name ≠ fetched phony.json nameerror naming both
both version and ref on a dependencyfatal error (mutually exclusive)
path containing .. or an absolute pathfatal error
version conflict across the closureerror naming the package + conflicting requirers
dependency cycledetected (DFS) and reported with the cycle path
package checksum mismatch vs lockerror (store tampered or corrupt)
remote asset unreachableerror naming the asset + URL
remote asset sha256 mismatchfatal error naming the asset + URL
--frozen with a missing package or bloberror (no implicit fetch)
offline with every locked commit + blob already in the storeworks (no network)
phony update <name> on a ref-pinned depre-fetches the ref to its current tip at origin; unnamed phony update leaves ref-pinned deps untouched
private repo the user's git cannot accessthe underlying git auth error, surfaced with the package name

Security & trust ​

Git distribution + Tier-1 pure-data generators mean installing a package cannot run code on your machine (no install scripts; generators are data + PEL, not executables). The lockfile checksum (and each remote asset's sha256) verify store integrity on every load. Package identity is the git URL, not a global name registry, so a fork at a different URL is a different package — verify the URLs you depend on. Tier-2 WASM generators (sandboxed) and package signing are deferred.

Out of scope (this build) ​

  • A hosted registry, search, publish, login, and auth servers.
  • The .phony tarball as a distribution/registry artifact (git is the channel; a local --package <dir> / <file> install can come later).
  • Tier-2 WASM packaging and cryptographic signing.

Phony Cloud — Documentation & Specification