feat: support json schema

This commit is contained in:
2026-04-05 11:14:17 +03:00
parent 0f7eb5d5d6
commit f734c0a69b
5 changed files with 806 additions and 0 deletions

View File

@@ -40,6 +40,8 @@ reproducible.
- Group software installations into logical "steps" with sophisticated orchestration.
- **Category headers** to visually organize your installers list.
- **Template variables** for dynamic values (architecture, OS, device ID) in commands and filenames.
- **JSON Schema** for editor autocompletion and validation of your config files. See
[JSON Schema docs](./docs/json-schema.md).
---

View File

@@ -8,4 +8,5 @@ For a general overview, see the [README](/README.md).
- [Command Line Interface (CLI)](./command-line-interface.md)
- [Configuration Reference](./configuration-reference.md)
- [Installer Configuration](./installer-configuration.md)
- [JSON Schema](./json-schema.md) - Editor autocompletion and validation for your config files
- [Recipes](./recipes) - Installer groups you can use immediately as remote manifests

139
docs/json-schema.md Normal file
View File

@@ -0,0 +1,139 @@
# JSON Schema
`sofmani` ships a [JSON Schema](https://json-schema.org/) describing the full configuration format.
Pointing your editor at it gives you **autocompletion**, **inline documentation**, and
**validation** while editing your `sofmani.yaml` or `sofmani.json` files.
The schema lives in the repo at [`schema/sofmani.schema.json`](../schema/sofmani.schema.json) and is
published on the `master` branch at:
```
https://raw.githubusercontent.com/chenasraf/sofmani/master/schema/sofmani.schema.json
```
## Using the schema with YAML
Most editors use the
[YAML Language Server](https://github.com/redhat-developer/yaml-language-server) (bundled with VS
Code's Red Hat YAML extension, Neovim's `yamlls`, Zed, and others). You can enable the schema in
either of two ways.
### 1. Inline comment (per file)
Add a modeline as the **first line** of the YAML file:
```yaml
# yaml-language-server: $schema=https://raw.githubusercontent.com/chenasraf/sofmani/master/schema/sofmani.schema.json
debug: true
install:
- name: neovim
type: brew
```
### 2. Editor-wide association
In VS Code, add this to your `settings.json`:
```json
{
"yaml.schemas": {
"https://raw.githubusercontent.com/chenasraf/sofmani/master/schema/sofmani.schema.json": [
"sofmani.yaml",
"sofmani.yml",
"**/sofmani/*.yml",
"**/recipes/*.yml"
]
}
}
```
Adjust the glob patterns to match where you keep your manifests.
### Using a local copy
If you have `sofmani` checked out locally, or you vendor the schema, point at the file on disk:
```yaml
# yaml-language-server: $schema=./schema/sofmani.schema.json
```
## Using the schema with JSON
In a JSON config, set the `$schema` key at the top of the document:
```json
{
"$schema": "https://raw.githubusercontent.com/chenasraf/sofmani/master/schema/sofmani.schema.json",
"debug": true,
"install": [
{
"name": "neovim",
"type": "brew"
}
]
}
```
VS Code picks this up automatically. Most other JSON-aware editors do too.
Alternatively, you can configure `json.schemas` in VS Code's `settings.json`:
```json
{
"json.schemas": [
{
"fileMatch": ["sofmani.json", "**/sofmani/*.json"],
"url": "https://raw.githubusercontent.com/chenasraf/sofmani/master/schema/sofmani.schema.json"
}
]
}
```
## Validating from the command line
You can validate a config file against the schema using any JSON Schema validator. Two convenient
options:
### `check-jsonschema` (Python)
```bash
pipx install check-jsonschema
check-jsonschema \
--schemafile schema/sofmani.schema.json \
sofmani.yaml
```
`check-jsonschema` supports both JSON and YAML input files out of the box.
### `ajv` (Node)
For JSON files:
```bash
npx ajv-cli validate \
-s schema/sofmani.schema.json \
-d sofmani.json
```
For YAML files, convert on the fly with `yq`:
```bash
yq -o=json sofmani.yaml | npx ajv-cli validate -s schema/sofmani.schema.json -d /dev/stdin
```
## What the schema covers
- All top-level options (`debug`, `check_updates`, `summary`, `category_display`, `repo_update`,
`defaults`, `env`, `platform_env`, `machine_aliases`, `install`).
- All supported installer types and their type-specific `opts`.
- Enums for `category_display`, `repo_update` modes, installer `type`, and platform names.
- The `frequency` duration pattern (`1d`, `12h`, `1w2d`, ...).
- Dual shapes for fields like `skip_summary` (bool or object), `enabled` (bool or shell string), and
`github-release` `download_filename` (string or per-platform map).
- Per-type narrowing of `opts`: typos like `tap: foo` vs. `tapp: foo` are flagged, and `group`
installers cannot accidentally set `opts`.
- The shell-script fields (`check_has_update`, `check_installed`, `pre_install`, `post_install`,
`pre_update`, `post_update`) accept either a string or a boolean. Booleans are a shorthand that
YAML coerces to the literal `"true"`/`"false"` — handy for forcing `check_has_update: true` to
mean "always treat as having an update".

193
schema/schema_test.go Normal file
View File

@@ -0,0 +1,193 @@
package schema_test
import (
"encoding/json"
"os"
"path/filepath"
"sort"
"testing"
"github.com/chenasraf/sofmani/appconfig"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// schemaPath returns the absolute path to the schema file regardless of where
// the tests are run from.
func schemaPath(t *testing.T) string {
t.Helper()
wd, err := os.Getwd()
require.NoError(t, err)
return filepath.Join(wd, "sofmani.schema.json")
}
func loadSchema(t *testing.T) map[string]any {
t.Helper()
data, err := os.ReadFile(schemaPath(t))
require.NoError(t, err)
var m map[string]any
require.NoError(t, json.Unmarshal(data, &m), "schema must be valid JSON")
return m
}
func TestSchemaIsValidJSON(t *testing.T) {
m := loadSchema(t)
assert.Equal(t, "http://json-schema.org/draft-07/schema#", m["$schema"])
assert.NotEmpty(t, m["$id"])
assert.NotEmpty(t, m["title"])
assert.Equal(t, "object", m["type"])
}
func TestSchemaTopLevelProperties(t *testing.T) {
m := loadSchema(t)
props, ok := m["properties"].(map[string]any)
require.True(t, ok, "top-level properties must exist")
// These must be declared at the top level of the schema.
expected := []string{
"$schema",
"debug",
"check_updates",
"summary",
"category_display",
"repo_update",
"defaults",
"env",
"platform_env",
"machine_aliases",
"install",
}
for _, key := range expected {
_, exists := props[key]
assert.Truef(t, exists, "top-level property %q missing from schema", key)
}
}
// TestInstallerTypesMatchGoConstants ensures the schema's list of installer
// types stays in lock-step with the Go InstallerType constants. If a new
// installer type is added in code but not in the schema (or vice-versa), this
// test fails and prompts the developer to update both sides.
func TestInstallerTypesMatchGoConstants(t *testing.T) {
m := loadSchema(t)
defs, ok := m["definitions"].(map[string]any)
require.True(t, ok)
installerType, ok := defs["installerType"].(map[string]any)
require.True(t, ok)
enum, ok := installerType["enum"].([]any)
require.True(t, ok)
schemaTypes := make([]string, 0, len(enum))
for _, v := range enum {
s, ok := v.(string)
require.True(t, ok)
schemaTypes = append(schemaTypes, s)
}
sort.Strings(schemaTypes)
goTypes := []string{
string(appconfig.InstallerTypeGroup),
string(appconfig.InstallerTypeShell),
string(appconfig.InstallerTypeDocker),
string(appconfig.InstallerTypeBrew),
string(appconfig.InstallerTypeApt),
string(appconfig.InstallerTypeApk),
string(appconfig.InstallerTypeGit),
string(appconfig.InstallerTypeGitHubRelease),
string(appconfig.InstallerTypeRsync),
string(appconfig.InstallerTypeNpm),
string(appconfig.InstallerTypePnpm),
string(appconfig.InstallerTypeYarn),
string(appconfig.InstallerTypePipx),
string(appconfig.InstallerTypeManifest),
string(appconfig.InstallerTypePacman),
string(appconfig.InstallerTypeYay),
string(appconfig.InstallerTypeCargo),
}
sort.Strings(goTypes)
assert.Equal(t, goTypes, schemaTypes, "installerType enum in schema is out of sync with Go constants")
}
func TestCategoryDisplayEnumMatchesGoConstants(t *testing.T) {
m := loadSchema(t)
props := m["properties"].(map[string]any)
cat := props["category_display"].(map[string]any)
enum, ok := cat["enum"].([]any)
require.True(t, ok)
schemaVals := make([]string, 0, len(enum))
for _, v := range enum {
schemaVals = append(schemaVals, v.(string))
}
sort.Strings(schemaVals)
goVals := []string{
string(appconfig.CategoryDisplayBorder),
string(appconfig.CategoryDisplayBorderCompact),
string(appconfig.CategoryDisplayMinimal),
}
sort.Strings(goVals)
assert.Equal(t, goVals, schemaVals)
}
func TestRepoUpdateEnumMatchesGoConstants(t *testing.T) {
m := loadSchema(t)
defs := m["definitions"].(map[string]any)
mode := defs["repoUpdateMode"].(map[string]any)
enum, ok := mode["enum"].([]any)
require.True(t, ok)
schemaVals := make([]string, 0, len(enum))
for _, v := range enum {
schemaVals = append(schemaVals, v.(string))
}
sort.Strings(schemaVals)
goVals := []string{
string(appconfig.RepoUpdateOnce),
string(appconfig.RepoUpdateAlways),
string(appconfig.RepoUpdateNever),
}
sort.Strings(goVals)
assert.Equal(t, goVals, schemaVals)
}
// TestRecipesParseAgainstSchemaShape is a structural smoke test: every recipe
// shipped in docs/recipes must only use top-level keys that the schema
// declares. This catches typos and schema drift without pulling in a full
// JSON-schema validator as a dependency.
func TestRecipesParseAgainstSchemaShape(t *testing.T) {
recipesDir := filepath.Join("..", "docs", "recipes")
entries, err := os.ReadDir(recipesDir)
require.NoError(t, err)
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if filepath.Ext(name) != ".yml" && filepath.Ext(name) != ".yaml" {
continue
}
t.Run(name, func(t *testing.T) {
path := filepath.Join(recipesDir, name)
data, err := os.ReadFile(path)
require.NoError(t, err)
cfg, err := appconfig.ParseConfigFromContent(data)
require.NoError(t, err, "recipe must parse as AppConfig")
// Basic sanity: every installer in the recipe has a recognized
// type (or is a category header).
for _, inst := range cfg.Install {
if inst.IsCategory() {
continue
}
assert.NotEmptyf(t, string(inst.Type), "installer in %s missing type", name)
}
})
}
}

471
schema/sofmani.schema.json Normal file
View File

@@ -0,0 +1,471 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://raw.githubusercontent.com/chenasraf/sofmani/master/schema/sofmani.schema.json",
"title": "sofmani configuration",
"description": "Schema for sofmani (Software Manifest) configuration files. See https://github.com/chenasraf/sofmani for documentation.",
"type": "object",
"additionalProperties": false,
"properties": {
"$schema": {
"type": "string",
"description": "Reference to the JSON schema for this configuration file."
},
"debug": {
"type": "boolean",
"description": "Enable or disable debug mode.",
"default": false
},
"check_updates": {
"type": "boolean",
"description": "Enable or disable checking for updates before running operations.",
"default": false
},
"summary": {
"type": "boolean",
"description": "Enable or disable the installation summary at the end.",
"default": true
},
"category_display": {
"description": "Controls how category headers are rendered.",
"type": "string",
"enum": ["border", "border-compact", "minimal"],
"default": "border"
},
"repo_update": {
"type": "object",
"description": "Controls repository index updates per installer type.",
"additionalProperties": false,
"properties": {
"brew": { "$ref": "#/definitions/repoUpdateMode" },
"apt": { "$ref": "#/definitions/repoUpdateMode" },
"apk": { "$ref": "#/definitions/repoUpdateMode" }
}
},
"defaults": {
"type": "object",
"description": "Default configurations to apply to all installers of a given type.",
"additionalProperties": false,
"properties": {
"type": {
"type": "object",
"description": "Map of installer type to default installer data.",
"additionalProperties": { "$ref": "#/definitions/installer" }
}
}
},
"env": {
"$ref": "#/definitions/envMap",
"description": "Environment variables set for all installers."
},
"platform_env": {
"$ref": "#/definitions/platformEnvMap",
"description": "Platform-specific environment variables set for all installers."
},
"machine_aliases": {
"type": "object",
"description": "Map of friendly names to machine IDs. Use 'sofmani --machine-id' to get your machine's ID.",
"additionalProperties": { "type": "string" }
},
"install": {
"type": "array",
"description": "List of installers / steps to run, in order.",
"items": { "$ref": "#/definitions/installStep" }
}
},
"definitions": {
"repoUpdateMode": {
"type": "string",
"enum": ["once", "always", "never"],
"description": "How often the repository index should be updated during a sofmani run."
},
"platform": {
"type": "string",
"enum": ["macos", "linux", "windows"]
},
"envMap": {
"type": "object",
"additionalProperties": { "type": "string" }
},
"platformEnvMap": {
"type": "object",
"additionalProperties": false,
"properties": {
"macos": { "$ref": "#/definitions/envMap" },
"linux": { "$ref": "#/definitions/envMap" },
"windows": { "$ref": "#/definitions/envMap" }
}
},
"platforms": {
"type": "object",
"additionalProperties": false,
"description": "Platform-specific execution controls.",
"properties": {
"only": {
"type": "array",
"items": { "$ref": "#/definitions/platform" },
"description": "Platforms where the step should execute. Supersedes 'except'."
},
"except": {
"type": "array",
"items": { "$ref": "#/definitions/platform" },
"description": "Platforms where the step should NOT execute."
}
}
},
"machines": {
"type": "object",
"additionalProperties": false,
"description": "Machine-specific execution controls.",
"properties": {
"only": {
"type": "array",
"items": { "type": "string" },
"description": "Machine IDs or aliases where the step should execute. Supersedes 'except'."
},
"except": {
"type": "array",
"items": { "type": "string" },
"description": "Machine IDs or aliases where the step should NOT execute."
}
}
},
"envShell": {
"type": "object",
"additionalProperties": false,
"description": "Shell to use for command executions per platform. Windows always uses cmd.",
"properties": {
"macos": { "type": "string" },
"linux": { "type": "string" }
}
},
"skipSummary": {
"description": "Exclude this installer from the summary. Set to true to skip both install and update summaries, or use an object for granular control.",
"oneOf": [
{ "type": "boolean" },
{
"type": "object",
"additionalProperties": false,
"properties": {
"install": { "type": "boolean" },
"update": { "type": "boolean" }
}
}
]
},
"enabled": {
"description": "Enable or disable the step. Accepts a boolean, a boolean string ('true'/'false'), or a shell command whose exit code decides the result.",
"oneOf": [{ "type": "boolean" }, { "type": "string" }]
},
"shellScript": {
"description": "A shell command or script. Accepts a string, or a boolean shorthand where 'true' runs the unix 'true' builtin (always succeeds) and 'false' runs 'false' (always fails).",
"oneOf": [{ "type": "string" }, { "type": "boolean" }]
},
"frequency": {
"type": "string",
"description": "Duration string limiting how often the installer runs (e.g. '60s', '30m', '12h', '1d', '1w', '1d12h'). Supported units: s, m, h, d, w.",
"pattern": "^(\\d+[smhdw])+$"
},
"installerType": {
"type": "string",
"enum": [
"group",
"shell",
"docker",
"brew",
"apt",
"apk",
"git",
"github-release",
"rsync",
"npm",
"pnpm",
"yarn",
"pipx",
"manifest",
"pacman",
"yay",
"cargo"
]
},
"installStep": {
"description": "An entry in the top-level 'install' list. Must be either a category header (has 'category') or an installer (has 'name' and 'type').",
"allOf": [
{ "$ref": "#/definitions/installer" },
{
"if": { "not": { "required": ["category"] } },
"then": { "required": ["name", "type"] }
}
]
},
"installer": {
"type": "object",
"additionalProperties": false,
"description": "Installer data. Used both for steps in 'install' and for 'defaults.type' overrides (where 'name' and 'type' may be omitted).",
"properties": {
"category": {
"type": "string",
"description": "Category header text. When set, this entry is a category header only (no installation)."
},
"desc": {
"type": "string",
"description": "Optional description shown below a category header."
},
"name": {
"type": "string",
"description": "Identifier for the step. Typically also used to check for the app's existence unless overridden by bin_name."
},
"type": { "$ref": "#/definitions/installerType" },
"enabled": { "$ref": "#/definitions/enabled" },
"tags": {
"type": "string",
"description": "Space-separated list of tags used for filtering."
},
"platforms": { "$ref": "#/definitions/platforms" },
"machines": { "$ref": "#/definitions/machines" },
"env": { "$ref": "#/definitions/envMap" },
"platform_env": { "$ref": "#/definitions/platformEnvMap" },
"steps": {
"type": "array",
"description": "Sub-installers for 'group' type.",
"items": { "$ref": "#/definitions/installStep" }
},
"opts": {
"type": "object",
"description": "Step-specific options. Content depends on the installer 'type'."
},
"bin_name": {
"type": "string",
"description": "Binary name for the installed software, used instead of 'name' when checking for existence."
},
"check_has_update": {
"$ref": "#/definitions/shellScript",
"description": "Shell command to check if an update is available. Exit 0 means an update is available. Use `true` as a shortcut for 'always has update'."
},
"check_installed": {
"$ref": "#/definitions/shellScript",
"description": "Shell command to check if the software is already installed. Exit 0 means installed. Use `true` as a shortcut for 'always installed'."
},
"pre_install": { "$ref": "#/definitions/shellScript", "description": "Shell script to run before install." },
"post_install": { "$ref": "#/definitions/shellScript", "description": "Shell script to run after install." },
"pre_update": { "$ref": "#/definitions/shellScript", "description": "Shell script to run before update." },
"post_update": { "$ref": "#/definitions/shellScript", "description": "Shell script to run after update." },
"env_shell": { "$ref": "#/definitions/envShell" },
"skip_summary": { "$ref": "#/definitions/skipSummary" },
"verbose": {
"type": "boolean",
"description": "Pass verbose flags to the underlying installer tool.",
"default": false
},
"frequency": { "$ref": "#/definitions/frequency" }
},
"allOf": [
{
"if": { "properties": { "type": { "const": "shell" } }, "required": ["type"] },
"then": {
"properties": {
"opts": {
"type": "object",
"additionalProperties": false,
"properties": {
"command": { "type": "string", "description": "Shell command to run for install." },
"update_command": { "type": "string", "description": "Shell command to run for update." }
}
}
}
}
},
{
"if": { "properties": { "type": { "const": "git" } }, "required": ["type"] },
"then": {
"properties": {
"opts": {
"type": "object",
"additionalProperties": false,
"properties": {
"destination": { "type": "string" },
"ref": { "type": "string" },
"flags": { "type": "string" },
"install_flags": { "type": "string" },
"update_flags": { "type": "string" }
}
}
}
}
},
{
"if": { "properties": { "type": { "const": "github-release" } }, "required": ["type"] },
"then": {
"properties": {
"opts": {
"type": "object",
"additionalProperties": false,
"properties": {
"repository": { "type": "string", "description": "user/repository-name" },
"destination": { "type": "string" },
"strategy": {
"type": "string",
"enum": ["tar", "zip", "none"],
"default": "none"
},
"download_filename": {
"description": "Asset filename, or a per-platform map. Supports Go template variables.",
"oneOf": [
{ "type": "string" },
{
"type": "object",
"additionalProperties": false,
"properties": {
"macos": { "type": "string" },
"linux": { "type": "string" },
"windows": { "type": "string" }
}
}
]
},
"archive_bin_name": { "type": "string" },
"extract_to": { "type": "string", "description": "Enables tree mode: extract entire archive to this directory." },
"strip_components": { "type": "integer", "minimum": 0 },
"bin_links": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["target"],
"properties": {
"source": { "type": "string" },
"target": { "type": "string" }
}
}
},
"github_token": { "type": "string" }
}
}
}
}
},
{
"if": { "properties": { "type": { "const": "manifest" } }, "required": ["type"] },
"then": {
"properties": {
"opts": {
"type": "object",
"additionalProperties": false,
"properties": {
"source": { "type": "string" },
"path": { "type": "string" },
"ref": { "type": "string" }
}
}
}
}
},
{
"if": { "properties": { "type": { "const": "rsync" } }, "required": ["type"] },
"then": {
"properties": {
"opts": {
"type": "object",
"additionalProperties": false,
"properties": {
"source": { "type": "string" },
"destination": { "type": "string" },
"flags": { "type": "string" }
}
}
}
}
},
{
"if": { "properties": { "type": { "const": "brew" } }, "required": ["type"] },
"then": {
"properties": {
"opts": {
"type": "object",
"additionalProperties": false,
"properties": {
"tap": { "type": "string" },
"cask": { "type": "boolean" },
"flags": { "type": "string" },
"install_flags": { "type": "string" },
"update_flags": { "type": "string" }
}
}
}
}
},
{
"if": {
"properties": {
"type": { "enum": ["npm", "pnpm", "yarn", "apt", "apk", "pipx", "cargo"] }
},
"required": ["type"]
},
"then": {
"properties": {
"opts": {
"type": "object",
"additionalProperties": false,
"properties": {
"flags": { "type": "string" },
"install_flags": { "type": "string" },
"update_flags": { "type": "string" }
}
}
}
}
},
{
"if": {
"properties": { "type": { "enum": ["pacman", "yay"] } },
"required": ["type"]
},
"then": {
"properties": {
"opts": {
"type": "object",
"additionalProperties": false,
"properties": {
"needed": { "type": "boolean" },
"flags": { "type": "string" },
"install_flags": { "type": "string" },
"update_flags": { "type": "string" }
}
}
}
}
},
{
"if": { "properties": { "type": { "const": "docker" } }, "required": ["type"] },
"then": {
"properties": {
"opts": {
"type": "object",
"additionalProperties": false,
"properties": {
"flags": { "type": "string" },
"platform": {
"type": "object",
"additionalProperties": false,
"properties": {
"macos": { "type": "string" },
"linux": { "type": "string" },
"windows": { "type": "string" }
}
},
"skip_if_unavailable": { "type": "boolean" }
}
}
}
}
},
{
"if": { "properties": { "type": { "const": "group" } }, "required": ["type"] },
"then": {
"not": { "required": ["opts"] },
"description": "The 'group' type uses 'steps', not 'opts'."
}
}
]
}
}
}