diff --git a/README.md b/README.md index 9929fdd..d566536 100755 --- a/README.md +++ b/README.md @@ -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). --- diff --git a/docs/README.md b/docs/README.md index c1b8388..0211724 100755 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/docs/json-schema.md b/docs/json-schema.md new file mode 100644 index 0000000..41fc80b --- /dev/null +++ b/docs/json-schema.md @@ -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". diff --git a/schema/schema_test.go b/schema/schema_test.go new file mode 100644 index 0000000..093fece --- /dev/null +++ b/schema/schema_test.go @@ -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) + } + }) + } + +} diff --git a/schema/sofmani.schema.json b/schema/sofmani.schema.json new file mode 100644 index 0000000..40af61b --- /dev/null +++ b/schema/sofmani.schema.json @@ -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'." + } + } + ] + } + } +}