citomni / installer
Deterministic scaffold materializer and lifecycle tool for CitOmni applications.
Requires
- php: ^8.5
- citomni/kernel: ^1.0
Requires (Dev)
None
Suggests
- citomni/cli: Provides CLI runtime and package-owned CLI scaffold manifests that citomni/installer can discover, install, repair, and sync.
- citomni/http: Provides HTTP runtime and package-owned HTTP scaffold manifests that citomni/installer can discover, install, repair, and sync.
Provides
None
Conflicts
None
Replaces
None
README
Official scaffold lifecycle tool for CitOmni applications.
Environment materialization uses explicit dev, stage, or prod selection and strict state format 2. See environment materialization for recovery, locking, Composer requirements, and regression tests.
citomni/installer is the package-owned scaffold materializer for the CitOmni ecosystem. It discovers scaffold manifests from installed Composer packages, renders package-owned scaffold files, writes the few files that must physically exist in the application layer, and tracks their state so future repairs and scaffold updates can be handled safely.
The package is deliberately narrow. It is not a framework runtime, not a web updater, not a Composer replacement, not a historical restore tool, and not a merge engine with a heroic cape and a questionable threat model.
In practical terms, citomni/installer lets CitOmni keep citomni/app-skeleton neutral and almost empty while allowing packages such as citomni/http and citomni/cli to own the runtime-near entrypoints and scaffold files that belong to them.
Highlights
- Official scaffold lifecycle tool for CitOmni with explicit ownership of scaffold discovery, rendering, planning, status, repair, sync, and state.
- Composer-native scaffold model where package-owned scaffold comes from the currently installed package versions, never from live GitHub templates.
- Migration-only app-skeleton audit that may fetch one pinned current
citomni/app-skeletoncommit to produce advisory.newreferences for app-owned root files. - Neutral app skeleton support so
citomni/app-skeletoncan remain a thin application container instead of a pile of mode-specific runtime files. - Conservative write behavior with no blind overwrites,
.newfiles for locally modified managed targets during sync, confirmation before plain forced replacement, and backups before forced overwrites. - Stateful managed-file sync using both raw stub checksums and rendered-file checksums to separate upstream scaffold drift from local changes.
- Generated
.gitignoreblock that keeps package-managed scaffold targets and package runtime paths out of Git, while every other line stays app-owned. - Bootstrap-independent CLI through
vendor/bin/citomni-installer, without requiringcitomni/cli,citomni/http, or a running CitOmni application. - Stable JSON output for project tooling, CI, deploy hooks, and other automation that needs machine-readable scaffold status.
What this package is
citomni/installer is the scaffold materialization and lifecycle package for CitOmni.
It handles the few package-owned files that cannot stay in /vendor/ because they must exist in the application tree to function. Typical examples are an HTTP front controller such as public/index.php or a CLI entrypoint such as bin/citomni.
The package works from installed Composer packages. A package that owns scaffold declares a manifest, and citomni/installer uses that manifest to plan and apply safe changes in the application layer.
This keeps the ownership model mechanical and easy to reason about:
- Runtime packages own their scaffold stubs.
- The application owns its local code, root files, and configuration after project creation.
- Composer owns dependency resolution and installed package versions.
citomni/installerowns the mechanics that materialize package-owned scaffold into the app.
No smoke, no mirrors, and preferably no wizard hat in production.
What this package provides
Scaffold discovery
- Discovery of installed Composer packages.
- Support for scaffold manifests declared through
extra.citomni.scaffold. - Convention fallback to
install/manifest.phpwhen no explicit Composer extra value exists. - Manifest validation, package-name validation, and manifest version validation.
- Deterministic path normalization and guard checks before any write operation.
Scaffold materialization
- Creation of missing scaffold files from package-owned stubs.
- Placeholder rendering using deterministic placeholder sources.
- Registration of rendered files in app-local installer state.
- Conservative handling of existing unknown files.
- Targeted package filtering through
--package=vendor/packageafter the initial application-wide materialization. - One delimited block in the application's
.gitignore, generated from every installed manifest. A Git checkout without a.gitignoregets one.
Scaffold lifecycle commands
doctorfor read-only environment validation.statusfor read-only scaffold status.install --environment=<dev|stage|prod>for initial scaffold and Composer materialization.migrate [--environment=<dev|stage|prod>]for repeatable migration/reconciliation with conservative PHP comparison, refreshed.newreview sidecars, root-reference audit, recursive PHP lint, and a Markdown report. The environment defaults todev.environment <dev|stage|prod>for authoritative switching of environment-aware targets.repairfor recreating missing scaffold files from recorded state using the currently installed package stub and the recorded placeholder snapshot.syncfor controlled updates of managed scaffold files.
Migration reports already include rendered diffs for files that need review. A standalone diff command may still be added for non-migration workflows.
Integration output
- Human-readable CLI output by default.
- JSON output for project tooling, CI, deployment tooling, and automation.
- Stable machine-readable status and reason values for scaffold drift, missing files, local changes, and blocked sync.
What this package owns
citomni/installer owns the lifecycle mechanics around package-owned scaffold.
That includes:
- The
vendor/bin/citomni-installerCLI entrypoint. - Discovery of scaffold manifests from installed Composer packages.
- Validation of scaffold manifests, source paths, target paths, and supported manifest versions.
- Placeholder resolution and simple placeholder rendering.
- Plan-first scaffold decisions.
- Safe write behavior for scaffold targets,
.newfiles, backups, and state. - The delimited
citomni/installerblock in the application's.gitignore. - App-local scaffold state format.
- Checksum semantics for raw stubs and rendered files.
- Read-only status and doctor diagnostics.
- JSON output intended for project tooling and other automation.
This package owns the mechanism, not the application. That distinction is the whole point.
What this package does not own
citomni/installer is intentionally not a general application generator or updater.
It does not own:
- HTTP runtime behavior.
- CLI runtime behavior.
- The contents of HTTP scaffold stubs.
- The contents of CLI scaffold stubs.
- Application business code.
- Application root-level files after
composer create-project. - General
composer.jsonediting orcomposer.lockmutation. Environment materialization sets onlyconfig.classmap-authoritativethrough Composer and regenerates autoload files. - Composer dependency resolution.
- Composer
requireorupdateexecution. - GitHub repository creation.
- Deployment profiles.
- Secrets management.
- Historical restoration of previous scaffold bytes.
- Automatic merging of local modifications.
- HTTP write access to the codebase.
Those responsibilities belong elsewhere. The installer stays small because that is where most of its safety comes from.
Relationship to other CitOmni packages
CitOmni separates project creation, dependency management, runtime packages, scaffold materialization, and project tooling into distinct layers.
Within that model:
citomni/app-skeletonprovides the neutral application container.- Composer installs and updates packages.
- Runtime packages such as
citomni/httpandcitomni/cliown their own scaffold stubs. citomni/installermaterializes package-owned scaffold into the application layer.- External project tooling may orchestrate richer workflows around project creation, repository setup, secrets, deployment, and registration.
A minimal HTTP application can therefore start from a neutral skeleton, require citomni/http, and then materialize only the HTTP-owned files needed by that mode.
A minimal CLI application can do the same with citomni/cli without dragging a public/ directory around like ceremonial luggage.
Scaffold ownership model
Package-owned scaffold should live in the package that owns the runtime function.
Examples:
vendor/citomni/http/install/manifest.php
vendor/citomni/http/install/scaffold/public/index.php.dev.stub
vendor/citomni/http/install/scaffold/public/index.php.stage.stub
vendor/citomni/http/install/scaffold/public/index.php.prod.stub
vendor/citomni/http/install/scaffold/config/citomni_http_cfg.php.stub
vendor/citomni/http/install/scaffold/config/citomni_http_routes.php.stub
vendor/citomni/cli/install/manifest.php
vendor/citomni/cli/install/scaffold/bin/citomni.dev.stub
vendor/citomni/cli/install/scaffold/bin/citomni.stage.stub
vendor/citomni/cli/install/scaffold/bin/citomni.prod.stub
vendor/citomni/cli/install/scaffold/config/citomni_cli_cfg.php.stub
Those stubs are the package-owned reference versions. The materialized files in the app are app-local runtime artifacts derived from those stubs. Environment-aware targets such as public/index.php and bin/citomni are materialized from the stub for the selected environment.
Examples:
public/index.php
bin/citomni
config/citomni_http_cfg.php
config/citomni_cli_cfg.php
This duplication is intentional. The files have different lifecycles:
- The stub in
/vendor/follows the installed package version. - The materialized file in the app may be present, missing, clean, locally modified, or ready for sync.
Composer remains the source of truth for which upstream scaffold version is available.
Application skeleton model
citomni/app-skeleton should stay neutral and mode-free.
It may provide the application container and base structure, such as:
composer.json
.gitattributes
.gitignore
.htaccess
LICENSE
NOTICE
README.md
TRADEMARKS.md
bin/
config/
language/
src/
tests/
var/
It should not need to include complete HTTP or CLI runtime entrypoints as permanent root-owned files.
Examples:
public/index.phpcomes fromcitomni/http.bin/citomnicomes fromcitomni/cli.config/citomni_installer.phpmay be added by the app to provide versioned placeholder configuration when scaffold placeholders are needed.
After composer create-project, root-level files are app-owned. Outside the explicit legacy migrate workflow, citomni/installer writes only targets declared by discovered scaffold manifests, plus its own delimited block in .gitignore, which it creates in a Git checkout that has none (see .gitignore block); every line outside that block stays app-owned. App-owned files such as README.md, .gitignore, composer.json, and composer.lock should therefore not be declared as package-owned scaffold targets. .gitignore is reserved and rejected as a target.
During legacy migration only, the installer may compare .gitignore, .gitattributes, and composer.json with one pinned current citomni/app-skeleton commit. A difference produces an advisory sibling <target>.new; the live app-owned root file is never replaced by this audit. The installer block is left out of the .gitignore comparison and carried into .gitignore.new.
Requirements
- PHP 8.5+
- Composer autoloading
ext-jsonfor JSON output
ext-curl is optional. Legacy migrate uses it only for the advisory citomni/app-skeleton reference audit; an unavailable extension or failed GitHub request is reported without blocking package-owned scaffold migration.
citomni/installer is intentionally independent of citomni/cli and citomni/http. It must be able to run before either runtime package has been installed in the application.
citomni/kernel is not required for the installer CLI bootstrap unless a future implementation explicitly chooses that dependency.
Installation
In a new CitOmni application, citomni/installer is normally installed through citomni/app-skeleton.
composer create-project citomni/app-skeleton my-app
cd my-app
For an existing application, install the package explicitly:
composer require citomni/installer composer dump-autoload -o
The package provides its own CLI entrypoint:
vendor/bin/citomni-installer
No CitOmni provider registration is required for MVP usage. The installer is a CLI lifecycle tool, not a request-time runtime dependency.
Typical workflows
Add HTTP support
composer require citomni/http vendor/bin/citomni-installer install --environment=dev
Environment-aware manifests select their source through --environment; the environment is not a placeholder override. Supply other required tokens through config/citomni_installer.php or repeated --placeholder=KEY=VALUE options.
After the initial application-wide install, package filtering is allowed with the recorded environment:
vendor/bin/citomni-installer install --environment=dev --package=citomni/http
Add CLI support
composer require citomni/cli vendor/bin/citomni-installer install --environment=dev
Check scaffold status
vendor/bin/citomni-installer status vendor/bin/citomni-installer status --package=citomni/http vendor/bin/citomni-installer status --format=json
Update framework packages and sync scaffold
composer update citomni/http citomni/installer vendor/bin/citomni-installer status --package=citomni/http vendor/bin/citomni-installer sync --package=citomni/http
Repair missing files
vendor/bin/citomni-installer repair --package=citomni/http
repair is not historical restore. It recreates only missing targets that already have recorded installer state, using the currently installed package stub together with the placeholder snapshot stored in that state.
Stop versioning package-managed scaffold
Package-managed targets are rendered per installation, like vendor/. In an application that still commits them, write the .gitignore block once and untrack the targets it lists as newly ignored:
vendor/bin/citomni-installer sync
git rm --cached --ignore-unmatch -- .htaccess bin/citomni public/index.php
git commit -m "Stop versioning package-managed scaffold"
Use the exact paths from the sync output; --ignore-unmatch skips listed targets that were never tracked. After that, a fresh checkout, a new server or a build workspace materializes these files with install --environment=<env>.
Every other checkout deletes these files when it pulls that commit, because Git removes files that a pulled commit stops tracking. Run citomni-installer sync (or install --environment=<env> without recorded state) right after pulling. A server that deploys with git pull must do so before it serves traffic, or it loses its front controller and the protecting .htaccess files.
Composer scripts
Applications may expose shorter commands through root-level Composer scripts.
Example:
{
"scripts": {
"citomni:status": "vendor/bin/citomni-installer status",
"citomni:install": "vendor/bin/citomni-installer install --environment=dev",
"citomni:sync": "vendor/bin/citomni-installer sync",
"citomni:repair": "vendor/bin/citomni-installer repair",
"citomni:doctor": "vendor/bin/citomni-installer doctor"
}
}
Usage:
composer citomni:status composer citomni:sync
These scripts belong to the application. citomni/installer must not rewrite composer.json to add or modify them.
Avoid wiring write-capable commands such as sync into automatic Composer hooks. Read-only hints are friendly. Surprise file mutation after dependency update is how a tool earns side-eye.
Commands
doctor
Runs read-only validation of the application environment.
Typical checks include:
- Application root detection.
- Presence of
vendor/. - Readability of Composer metadata.
- Discovery of scaffold manifests.
- Write access diagnostics for relevant app paths.
- State readability and format validation where relevant.
Examples:
vendor/bin/citomni-installer doctor vendor/bin/citomni-installer doctor --format=json
status
Shows scaffold status without changing files.
It reports whether known scaffold targets are missing, clean, locally modified, affected by upstream stub changes, affected by placeholder changes, or blocked from automatic sync.
It also compares the .gitignore block with every installed package, including with --package: up_to_date, update_available (exit 3, also for a Git checkout without a .gitignore), malformed (exit 4), absent when the application has no .gitignore and lies outside a Git checkout, or unsupported when the installer cannot maintain it, such as a symlinked .gitignore.
Examples:
vendor/bin/citomni-installer status vendor/bin/citomni-installer status --package=citomni/http vendor/bin/citomni-installer status --format=json
install
Materializes scaffold for an explicitly selected environment. The first install must omit --package; later installs may filter packages while using the recorded environment. Use environment <env> to switch environments, including when --force is supplied.
Default behavior:
- Creates missing files.
- Does not overwrite existing files.
- Registers created files and adopts existing exact rendered matches in installer state.
- Leaves a clean recorded baseline that lags behind the installed package to
sync. - Stores checksum and placeholder metadata where relevant.
- Writes the .gitignore block from every installed package, also with
--package, and creates a missing.gitignorein a Git checkout. Malformed block markers stop install before it writes anything. - Fails clearly on conflicts.
An existing environment-aware target that differs from the requested rendering stops install before anything is written. The error names every blocking target with its reason, and JSON output lists them under packages:
unknown_existing: there is no recorded baseline, so another environment, an older package revision, and a local edit cannot be told apart. Delete a stale package-managed file so install recreates it, or install the environment the file belongs to and switch withenvironment <env>.local_modified: the file differs from its recorded baseline.syncwrites the current rendering as.newfor review;environment <env>replaces it after a backup.
Initial materialization:
vendor/bin/citomni-installer install --environment=dev
Forced replacement:
vendor/bin/citomni-installer install --environment=dev --package=citomni/http --force vendor/bin/citomni-installer install --environment=dev --package=citomni/http --force=yes
--force allows existing scaffold targets to be overwritten, but asks for interactive confirmation before writing. --force=yes confirms the overwrite without prompting and is intended for scripts or explicitly confirmed manual runs. Forced replacements create backups before writing.
migrate
Reconciles an application with the currently installed scaffold manifests and current
migration reference sources. The environment defaults to dev when omitted.
The command is deliberately repeatable:
- Missing state or known v1 state uses rebuild mode from current manifests.
- Current v2 state uses reconciliation mode and keeps its managed-file baselines, so
clean upstream drift can move forward while locally modified managed files receive
.newinstead of being overwritten. - Every create-only target is compared again against the current rendered source on
every run. Legitimate app-specific differences therefore keep appearing as advisory
review items and receive a freshly generated
.newreference each time without blocking Composer posture or v2 state completion. - Existing
.newfiles are generated artifacts and are overwritten on every real run. Do not use them as a workspace for manual merge edits. - Existing
.tpltargets are authoritative package templates and are refreshed when they differ. config/citomni_installer.phpis audited for missing and currently unused values.- Current package scaffold sources are scanned for
%UPPER_SNAKE_CASE%-style tokens and reported as advisory source-hygiene warnings without changing migrate exit behavior. - The .gitignore block is refreshed from every installed package, and
a missing
.gitignoreis created in a Git checkout; malformed block markers stop migration before it writes anything. .gitignore,.gitattributes, andcomposer.jsonare compared with one pinned currentcitomni/app-skeletoncommit using text semantics that ignore CRLF/LF and one final-newline difference; real content differences are written as.new. The installer block is left out of the.gitignorecomparison and kept in.gitignore.new.- App-owned
.phpand.php.tplfiles are recursively checked withphp -l; vendor, caches, backups, uploads, Git metadata, node_modules, and root_...workspaces are excluded. Full lint output is stored in the migration report. - Local diff content is omitted for non-template targets below
var/secrets;.tpltargets remain diffable because they are explicit templates and must not contain populated secrets. - Targets below
config/also omit diff content when a PHP array key contains a known credential fragment such aspassword,secret,access_token, orapi_key. - A Markdown audit report is written under
var/logs/citomni-installer/migrationson real runs.
Preview using the default development environment:
vendor/bin/citomni-installer migrate --dry-run
Apply using the default development environment:
vendor/bin/citomni-installer migrate
Select another environment explicitly when needed:
vendor/bin/citomni-installer migrate --environment=stage vendor/bin/citomni-installer migrate --environment=prod
A blocking managed-file .new, conflict, or PHP lint failure results in exit 4 so
required corrective action cannot be missed. Create-only .new references and app-skeleton
root-file differences are advisory.
repair
Recreates missing scaffold files from recorded installer state and the currently installed package stubs.
Default behavior:
- Writes only files that are missing.
- Does not overwrite existing files.
- Requires recorded installer state with a placeholder snapshot for the missing target; without it, the target is left untouched.
- Uses the recorded placeholder snapshot rather than current
config/citomni_installer.phpor CLI placeholder overrides for the recreated file. - Updates the baseline for files it recreates.
- Reports
recreated_stub_driftwhen the recorded stub checksum differs from the currently installed package stub. - Ignores
--force; existing files are never touched byrepair. - Does not touch
.gitignore.
Example:
vendor/bin/citomni-installer repair --package=citomni/cli
sync
Updates managed scaffold files safely.
Default behavior:
- Creates missing scaffold files, including missing
managedandcreate-onlytargets. - Updates files that still match the previous rendered baseline.
- Does not overwrite locally modified files.
- Writes the current rendered scaffold to
.newwhen a local modification blocks automatic sync. - Adopts an unknown existing managed file into state without rewriting it when its bytes exactly match the current rendered scaffold.
- Does not update existing
create-onlyfiles by default; forced replacement requires--forceor--force=yes. - Refreshes the .gitignore block from every installed package, also with
--package, and creates a missing.gitignorein a Git checkout. A positional target leaves.gitignorealone.
Example:
vendor/bin/citomni-installer sync --package=citomni/http
Forced sync:
vendor/bin/citomni-installer sync public/index.php --package=citomni/http --force vendor/bin/citomni-installer sync public/index.php --package=citomni/http --force=yes
--force replaces the existing target after interactive confirmation and writes a backup first. --force=yes performs the same forced replacement without prompting. A positional target limits the run to that app-relative file; without a target, the command applies to the selected package scope.
Conflict example:
public/index.php changed locally and was not overwritten.
Current rendered scaffold written to public/index.php.new
Review public/index.php and public/index.php.new before merging manually.
Post-MVP may add a diff command for comparing local app files against the current rendered upstream scaffold.
Package manifest convention
Packages that provide scaffold should declare a scaffold manifest. So should packages that write runtime paths in the application, even without scaffold files, so the .gitignore block covers those paths (see gitignore).
Composer extra is the primary discovery mechanism:
{
"extra": {
"citomni": {
"scaffold": "install/manifest.php"
}
}
}
If no explicit value exists, the installer may fall back to:
install/manifest.php
If both exist and point to different paths, the Composer extra value wins.
The manifest's scaffold source base is normally install/scaffold/.
Example manifest:
<?php declare(strict_types=1); return [ 'package' => 'citomni/http', 'version' => 2, 'files' => [ [ 'target' => 'public/index.php', 'type' => 'entrypoint', 'policy' => 'managed', 'environments' => [ 'dev' => ['source' => 'install/scaffold/public/index.php.dev.stub'], 'stage' => ['source' => 'install/scaffold/public/index.php.stage.stub'], 'prod' => ['source' => 'install/scaffold/public/index.php.prod.stub'], ], ], [ 'target' => 'config/citomni_http_cfg.php', 'source' => 'install/scaffold/config/citomni_http_cfg.php.stub', 'type' => 'config', 'policy' => 'create-only', ], [ 'target' => 'config/citomni_http_routes.php', 'source' => 'install/scaffold/config/citomni_http_routes.php.stub', 'type' => 'routes', 'policy' => 'create-only', ], ], ];
version is the manifest schema version, not the package semver. Supported versions are 1 and 2. environments requires version 2, which also accepts entries with a plain source.
Manifest fields
target
Relative path in the application where the scaffold file should be materialized.
Targets must be safe relative app paths. Absolute paths, parent traversal, invalid separators, and paths outside the application root must be rejected.
source
Relative path inside the package root pointing to the scaffold stub.
Sources must stay inside the package root. The installer must not follow a manifest into writing or reading arbitrary system files. Manifests are configuration, not a treasure map.
An entry declares either source or environments, never both.
environments
Per-environment sources for a target whose content differs between environments, such as an entrypoint that defines CITOMNI_ENVIRONMENT.
Rules:
- Requires manifest schema version
2and policymanaged. - Must define exactly
dev,stage, andprod. - Each environment must contain exactly one non-empty
source, which follows the same rules assourceabove.
install --environment=<env> materializes the selected environment's source. sync and repair use the recorded environment, and environment <env> switches environment-aware targets. See environment materialization.
type
Diagnostic metadata describing what the file is.
Examples:
entrypointconfigroutesserver-config
type may be used for output, grouping, logging, JSON responses, and future read-only UI.
policy
The write behavior contract.
Supported MVP policies:
managedcreate-only
Write behavior is controlled by policy, not by type.
gitignore
Optional top-level list of .gitignore lines for the runtime paths the package's code writes in the application, such as caches, logs, state and secrets. Accepted in manifest versions 1 and 2. A manifest may declare gitignore with an empty files list; citomni/installer declares its own runtime paths that way.
'gitignore' => [ '/var/logs/*', '/var/secrets/*', '!/var/logs/.gitkeep', '!/var/secrets/.gitkeep', '!/var/secrets/webhooks.secret.php.tpl', ],
Conventions for package authors:
- Ignore the contents of every runtime directory the package's code writes to under its default cfg paths, as
/var/<dir>/*anchored at the application root. A file the package writes through another package's service is covered by that package's rule: a log written through thecitomni/infrastructurelog service falls under its/var/logs/*. Never name another package's files. - Keep the directory's placeholder with
!/var/<dir>/.gitkeep. Packages that share a runtime directory declare the same pair, and the block deduplicates it. Without the keep, the package rule would ignore the placeholder again after the application skeleton kept it. - Never ignore a shared runtime directory literally (
/var/<dir>/or/var/<dir>). Git cannot re-include a file inside an excluded directory, so discovery rejects that rule as soon as another installed package keeps a file in the directory, and every installer command then fails in applications that install both packages. - Never use broad rules such as
/var/*; declare only the directories the package writes. - Keep a file the package ships and wants versioned with its own exception, such as
!/var/secrets/webhooks.secret.php.tpl. Nothing is kept implicitly: acreate-onlytarget inside an ignored directory stays ignored, which is what generated secrets and runtime state need. - The rules cover the package's default paths. An application that moves a runtime path out of its directory owns the ignore rule for the new location and writes it after the block.
The installer merges every package's lines into one .gitignore block, with all ignore rules before all exceptions. Discovery therefore enforces:
- One rule per entry, without comments, control characters, backslash escapes, or leading or trailing whitespace.
- Ignore rules before exceptions. The merge keeps that order, so each package's own meaning is unchanged.
- An exception (
!) is an anchored literal path without wildcards, so it cannot re-include paths it does not name. Because every exception follows every ignore rule, it does override any package's ignore rule for exactly that path. - A directory is kept only in directory form (
!/var/data/imports/), and the same package must ignore its contents with/var/data/imports/*or/var/data/imports/**. The directory's contents then stay ignored unless their own exceptions name them. An exception counts as a directory when it ends with/or when any package declares/var/data/imports/or a rule beneath it; any other exception names a file. - A kept file must not be ignored by the identical rule in any package; the packages would disagree about that path.
- No exception may lie beneath a directory that any package ignores with an anchored literal rule (
/data/or/data), unless that directory is itself kept in directory form; Git cannot re-include a file inside an excluded directory. Ignore the contents (/data/*) instead.
These checks span all installed packages and look only at the declared rules, never at the application's files. Wildcard rules are not evaluated as directory signals, so an exception for a directory must always use the directory form.
Managed targets are added to the block automatically and do not belong in this list.
File policies
managed
Used for small framework-near files where the package owner may continue to ship updates.
Examples:
public/index.phpbin/citomni
Rules:
- May be updated automatically when the local file still matches the previous rendered baseline.
- Must not be overwritten when locally modified.
- Stores both
stub_checksumandrendered_checksumin state. - Produces
.newduringsyncwhen local changes block automatic replacement.
create-only
Used for files that should normally be created once and then left to the application.
Examples:
config/citomni_http_cfg.phpconfig/citomni_http_routes.phpconfig/citomni_cli_cfg.php
Rules:
- Create if missing.
- Register newly created files in state.
- Do not update automatically.
- Do not treat upstream drift as an automatic update signal.
- Require explicit force or manual action for replacement.
.gitignore block
Managed targets are rendered per installation from the installed packages, like vendor/, so they should not be committed. A committed managed file goes stale as soon as its package changes, and an install without recorded state cannot tell a stale rendering from another environment or a local edit. The runtime paths that packages write, such as caches, logs, sessions and generated secrets, do not belong in Git either. The installer therefore keeps one delimited block in the application's .gitignore.
The file has three tiers. Git uses the last matching pattern, so each tier takes precedence over the ones above it, in the same order as configuration merging: baseline, packages, application.
###> citomni/app-skeleton ### # Defaults from citomni/app-skeleton. citomni/installer inserts its block below # this one; package rules there take precedence over these defaults. # Put application rules at the end of the file; they take precedence over both. /vendor/ /var/state/* !/var/state/.gitkeep ###< citomni/app-skeleton ### ###> citomni/installer ### # Generated by citomni/installer from the installed package manifests. # Rewritten by install, sync and migrate; edits inside this block are lost. # Application rules belong after the block. # Package runtime paths /var/state/* # Package runtime files kept in Git !/var/state/.gitkeep # Package-managed scaffold targets /.htaccess /bin/citomni /public/index.php ###< citomni/installer ### # Application rules: everything below takes precedence over both blocks above. !/var/state/fixture.json
- Defaults from
citomni/app-skeletonfor the runtime directories it creates, plus general conventions. They arrive with the project and belong to the application from then on; no tool rewrites them. - The installer block with the rules of the installed packages.
- Application rules at the end of the file.
Rules:
- The block is generated from every installed manifest: each package's
gitignorelines plus everymanagedtarget.create-onlytargets are app-owned after creation and are not added; they are ignored only when a runtime rule covers their directory. - Lines are grouped as ignore rules, kept files and managed targets, and each group is sorted and deduplicated, so the bytes do not depend on package order. Kept files win over every ignore rule in the block, and managed targets stay ignored even when a kept file names them.
install,migrateandsyncwithout a positional target rewrite the block, also with--package.repairandenvironmentnever touch.gitignore.statusreports a stale block as an update (exit3). Without any scaffold manifest, no command maintains or reports the block;citomni/installerships a manifest for its own runtime paths, so an application that installs it through Composer always has one.- The block contains no environment or timestamp, so it is identical on every machine with the same packages.
- Everything outside the markers is preserved byte for byte, including line endings and a UTF-8 BOM. A new block is inserted at the top, after marker blocks of other tools such as the
citomni/app-skeletonblock, followed by a blank line. - A missing
.gitignoreis created with the block alone when the application lies in a Git checkout, that is when a.gitdirectory or file (a linked worktree or a submodule) exists in the application root or a parent directory. Until thenstatusreportsupdate_availablewith reasongitignore_missing(exit3). Outside a Git checkout, such as an exported build or a deploy tree, a missing.gitignorestays missing and is reported asabsent. Rules kept only in.git/info/excludedo not prevent the creation. - A
.gitignorethe installer cannot maintain is reported asunsupportedand left alone; it never fails a command. That covers a symlink, even one pointing inside the application, because Git does not read a symlinked.gitignorefrom the working tree, as well as a directory or an unreadable file. - Marker lines tolerate trailing spaces and tabs and are rewritten exactly. Malformed markers (missing, repeated, out of order, or another line starting like a marker) are a conflict and leave the file untouched:
installandmigratestop before writing anything,syncreports the conflict (exit4), andstatusexits4. - Managed targets are written as anchored literal paths; wildcard characters and trailing spaces are escaped.
Git keeps tracking files it already tracks. When the block starts ignoring a managed target, the output lists it as newly ignored (newly_ignored in JSON). Untrack it once with git rm --cached --ignore-unmatch -- <path> and commit the result; see Stop versioning package-managed scaffold for what that means for other checkouts. Runtime files that were committed before a package rule covered them are not listed; git ls-files -ci --exclude-standard shows every tracked file that the current rules ignore.
Placeholder rendering
Scaffold stubs may use simple placeholders.
Examples:
{{APP_NAMESPACE}}
{{ APP_NAME }}
{{CITOMNI_ENVIRONMENT}}
{{ CITOMNI_ENVIRONMENT }}
Whitespace immediately inside the braces is ignored, but placeholder names themselves must still use uppercase keys matching [A-Z][A-Z0-9_]*.
MVP placeholder rules:
- Keep placeholders few and explicit.
- Do not use a general template engine.
- Do not use
eval. - Do not allow arbitrary PHP execution.
- Fail on unknown placeholders.
- Resolve placeholders from deterministic sources.
- Store the actual placeholder snapshot used for each rendered file in installer state.
The installer reads application-level placeholder configuration from this fixed path when the file exists:
config/citomni_installer.php
Example:
<?php declare(strict_types=1); return [ 'placeholders' => [ 'APP_NAMESPACE' => 'App', 'APP_NAME' => 'My App', 'CITOMNI_ENVIRONMENT' => 'dev', ], ];
Placeholder precedence, from highest to lowest:
- CLI
--placeholder=KEY=VALUEoptions. config/citomni_installer.php.
PROD_ROOT_URL and STAGE_ROOT_URL must be canonical base URLs without a trailing slash. The installer rejects trailing slashes from both application config and CLI overrides before scaffold rendering or materialization continues.
Future versions may add Composer metadata, existing CitOmni config, or explicit package defaults.
Example:
vendor/bin/citomni-installer install --environment=dev --package=citomni/http --placeholder=APP_NAMESPACE=App
APP_NAMESPACE should be explicit when Composer autoload configuration is ambiguous.
State and checksums
Installer state is stored in the application, not in the installer package, runtime package, or /vendor/.
Only state format 2 is accepted; there is no state format 1 migration or fallback. State records the application environment in addition to package/file metadata.
The state path is fixed:
var/state/citomni/installer-scaffold.php
The state file stores enough information to determine whether a managed file is clean, locally modified, affected by upstream stub changes, or affected by placeholder changes.
For managed files, the installer uses two checksum concepts.
stub_checksum
Checksum of the raw scaffold stub before placeholder rendering.
Purpose:
- Identifies the upstream stub.
- Detects package-owned scaffold changes.
- Stays independent of application placeholder values.
rendered_checksum
Checksum of the rendered file that was written to the application.
Purpose:
- Detects local modifications.
- Allows safe automatic updates when the local file still matches the previous rendered baseline.
Package semver is useful metadata, but it must not drive sync decisions alone. A package can change without scaffold drift, and a dev checkout can change scaffold without a meaningful semver change. Bytes beat vibes.
Write safety model
The table below covers ordinary scaffold lifecycle commands. environment <env> authoritatively replaces differing environment-aware targets with backups, and does not touch source-only entries.
| Situation | Default behavior |
|---|---|
| Target is missing | Create file |
| Managed target exists but has no state | install and sync adopt an exact current rendered match; otherwise install conflicts and sync writes .new unless forced |
Managed target matches previous rendered_checksum |
Leave unchanged if it also matches the current render; otherwise update to the current render |
| Target is locally modified | Do not overwrite |
Target is create-only and exists |
Do not touch unless explicitly forced |
Environment-aware target differs during install |
Stop before writing and list every blocking target |
.gitignore installer block differs |
Rewrite the block only; malformed markers conflict, and install/migrate stop before writing |
.gitignore is missing in a Git checkout |
Create it with the block alone; a .gitignore that appears after planning is a conflict |
| Target is outside app root | Fail |
| Source is missing in package | Fail |
| Manifest is invalid | Fail |
| State is unknown or unsafe | Fail without materializing scaffold or committing state; a real write command may leave its administrative lock file |
Plain --force asks for confirmation before destructive writes. --force=yes confirms the same destructive write without prompting. Both modes create backups before replacing existing files.
When forced replacement overwrites a file, the installer must create a backup first.
Forced-overwrite backups are stored under:
var/backups/citomni-installer/<utc-microseconds>-<random-suffix>/path/to/file
Scaffold files, .new files, backups, and state are written through temporary files and renamed into place. Existing Unix rwx permission bits are preserved.
Real write commands share the persistent advisory lock var/state/citomni/installer.lock. Dry runs create no lock or scaffold files. Planned target bytes are checked again during apply. Read backup_dir from the command JSON result instead of guessing the newest backup by directory modification time.
Exit codes
CLI exit codes are defined explicitly so project tooling, CI, and deploy scripts can make deterministic decisions.
Exit codes:
| Code | Meaning |
|---|---|
0 |
OK, no problems |
1 |
General error |
2 |
Invalid usage or invalid arguments |
3 |
Drift found, no conflict |
4 |
Conflict or local changes requiring manual action |
5 |
Unsafe state or migration required |
6 |
IO or permission error |
Examples:
statusmay return3when an update is available but no local conflict exists, including a stale.gitignoreblock or a Git checkout without a.gitignore.statusmay return4when local changes block sync.installreturns4without writing when an environment-aware target conflicts.syncmay return4when it writes.newbecause a local file was modified.doctormay return5when the state format is unknown.
JSON output
All MVP commands support JSON output.
Example:
vendor/bin/citomni-installer status --format=json
JSON output should be stable enough for project tooling and automation. It includes package name, type, policy, target, status, reason, and action values that tooling can use to infer whether a file can be synced without conflict.
status and the write commands also return a top-level gitignore key: an object with target, status, reason, newly_ignored, and error, where write commands add action (none, create, update or conflict), applied (created, updated, skipped, conflict or failed; null when the command stopped before applying), and error_type. It is null when the command does not maintain the block, including early failures. When install stops before writing, error explains the conflict, and packages lists the blocking targets or gitignore carries the malformed block.
Interactive confirmation is not available in JSON mode. When a forced overwrite is required, automation must use --force=yes; plain --force --format=json returns an invalid-usage response instead of prompting.
Status, reason, and error values should distinguish at least:
- Missing target.
- Local modification.
- Upstream stub drift.
- Placeholder drift.
- Invalid manifest, usually as a top-level command error or
doctorcheck. - Unsafe state, usually as a top-level command error or
doctorcheck. - Permission issue, usually as a top-level command error or
doctorcheck.
Human output can be friendly. JSON output should be boring. Boring is a feature when another program has to parse it.
Tooling integration
External project tooling can use citomni/installer as the scaffold engine in a larger app-creation workflow.
A typical project-tooling flow may look like this:
Create application workspace
composer create-project citomni/app-skeleton <path>
composer require citomni/http and/or citomni/cli
vendor/bin/citomni-installer install --environment=dev
Create project repository
Push initial commit
Register the application in external tooling
Create deployment and secret placeholders
Project tooling should not need to know the internal contents of public/index.php, bin/citomni, HTTP config stubs, or CLI config stubs. It should ask the installer for status or ask the installer to materialize the package-owned scaffold.
Legacy applications
Legacy migration is not an MVP responsibility, but the state model should allow a future adopt command.
A possible future flow:
composer require citomni/installer vendor/bin/citomni-installer doctor vendor/bin/citomni-installer status vendor/bin/citomni-installer adopt
Important principles for legacy adoption:
- Do not assume an existing file is framework-owned.
- Render stubs before comparing templated files.
- Register clean matches differently from local modifications.
- Do not compare raw stub bytes directly against rendered app files.
Existing applications deserve caution. They may contain history, intentional edits, and the occasional fossil from a heroic Friday deploy.
HTTP UI
HTTP UI is not part of the MVP.
A future UI may be read-only and show status, diffs, and recommended commands. It should not write to the codebase from a web request, run Composer update, mutate /vendor/, or act as a web updater.
If remote write behavior is ever needed, it should be handled through an out-of-band runner, deploy hook, cron job, or explicit CLI process rather than direct HTTP request execution.
Runtime and architectural model
citomni/installer follows CitOmni's architectural discipline without requiring a full CitOmni runtime.
Recommended internal structure:
- CLI classes own argument parsing and output.
- Operations own the decision graph and return arrays.
- State classes own reading and writing installer state.
- Support classes may handle IO-oriented helper behavior.
- Utilities remain pure and side-effect free.
There is no repository layer in MVP because there is no SQL.
There is no HTTP controller in MVP because HTTP UI is not part of the first version.
There is no service-map singleton dependency in MVP because the installer must work before a CitOmni app runtime is bootable.
Performance notes
- The installer is not loaded in the HTTP request boot pipeline.
- The installer should keep its dependency footprint small.
- Scaffold discovery and status checks should be deterministic and file-based.
- Runtime packages should not need the installer during normal request handling.
- Production applications should still use optimized Composer autoloading and OPcache for runtime code.
Composer example:
{
"config": {
"optimize-autoloader": true,
"classmap-authoritative": true,
"apcu-autoloader": true
}
}
Then run:
composer dump-autoload -o
Error handling philosophy
Fail fast.
citomni/installer should reject unsafe paths, unknown manifest versions, invalid state, missing sources, unknown placeholders, and ambiguous writes before touching the application tree.
When a file is locally modified, the correct default is not to be clever. The correct default is to stop, write .new where appropriate, and make the human decision obvious.
Silent fallback behavior feels nice until it quietly edits the wrong file. That is not the kind of magic CitOmni is trying to collect.
Contributing
- PHP 8.5+
- PSR-4
- Tabs for indentation
- K&R brace style
- Keep ownership boundaries sharp
- Keep write behavior conservative
- Keep installer independent of
citomni/cliandcitomni/http - Validate paths before IO
- Build plans before writing files
- Prefer explicit failure over hidden fallback behavior
- Do not introduce HTTP write paths in MVP
Coding & Documentation Conventions
All CitOmni projects follow the shared conventions documented here: CitOmni Coding & Documentation Conventions
License
CitOmni Installer is open-source under the MIT License. See LICENSE.
Trademark notice: "CitOmni" and the CitOmni logo are trademarks of Lars Grove Mortensen. Usage of the name or logo must follow the policy in NOTICE. Do not imply endorsement or affiliation without prior written permission.
Trademarks
"CitOmni" and the CitOmni logo are trademarks of Lars Grove Mortensen. You may make factual references to "CitOmni", but do not modify the marks, create confusingly similar logos, or imply sponsorship, endorsement, or affiliation without prior written permission. Do not register or use "citomni" or confusingly similar terms in company names, domains, social handles, or top-level vendor/package names. For details, see NOTICE.
Author
Developed by Lars Grove Mortensen (c) 2012-present.
CitOmni - low overhead, high performance, ready for anything.