Skip to content

Commit b00dad6

Browse files
committed
feat: support json schema
1 parent 0f7eb5d commit b00dad6

5 files changed

Lines changed: 791 additions & 0 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ reproducible.
4040
- Group software installations into logical "steps" with sophisticated orchestration.
4141
- **Category headers** to visually organize your installers list.
4242
- **Template variables** for dynamic values (architecture, OS, device ID) in commands and filenames.
43+
- **JSON Schema** for editor autocompletion and validation of your config files. See
44+
[JSON Schema docs](./docs/json-schema.md).
4345

4446
---
4547

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ For a general overview, see the [README](/README.md).
88
- [Command Line Interface (CLI)](./command-line-interface.md)
99
- [Configuration Reference](./configuration-reference.md)
1010
- [Installer Configuration](./installer-configuration.md)
11+
- [JSON Schema](./json-schema.md) - Editor autocompletion and validation for your config files
1112
- [Recipes](./recipes) - Installer groups you can use immediately as remote manifests

docs/json-schema.md

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
# JSON Schema
2+
3+
`sofmani` ships a [JSON Schema](https://json-schema.org/) describing the full configuration format.
4+
Pointing your editor at it gives you **autocompletion**, **inline documentation**, and
5+
**validation** while editing your `sofmani.yaml` or `sofmani.json` files.
6+
7+
The schema lives in the repo at [`schema/sofmani.schema.json`](../schema/sofmani.schema.json) and is
8+
published on the `master` branch at:
9+
10+
```
11+
https://raw.githubusercontent.com/chenasraf/sofmani/master/schema/sofmani.schema.json
12+
```
13+
14+
## Using the schema with YAML
15+
16+
Most editors use the
17+
[YAML Language Server](https://github.com/redhat-developer/yaml-language-server) (bundled with VS
18+
Code's Red Hat YAML extension, Neovim's `yamlls`, Zed, and others). You can enable the schema in
19+
either of two ways.
20+
21+
### 1. Inline comment (per file)
22+
23+
Add a modeline as the **first line** of the YAML file:
24+
25+
```yaml
26+
# yaml-language-server: $schema=https://raw.githubusercontent.com/chenasraf/sofmani/master/schema/sofmani.schema.json
27+
28+
debug: true
29+
install:
30+
- name: neovim
31+
type: brew
32+
```
33+
34+
### 2. Editor-wide association
35+
36+
In VS Code, add this to your `settings.json`:
37+
38+
```json
39+
{
40+
"yaml.schemas": {
41+
"https://raw.githubusercontent.com/chenasraf/sofmani/master/schema/sofmani.schema.json": [
42+
"sofmani.yaml",
43+
"sofmani.yml",
44+
"**/sofmani/*.yml",
45+
"**/recipes/*.yml"
46+
]
47+
}
48+
}
49+
```
50+
51+
Adjust the glob patterns to match where you keep your manifests.
52+
53+
### Using a local copy
54+
55+
If you have `sofmani` checked out locally, or you vendor the schema, point at the file on disk:
56+
57+
```yaml
58+
# yaml-language-server: $schema=./schema/sofmani.schema.json
59+
```
60+
61+
## Using the schema with JSON
62+
63+
In a JSON config, set the `$schema` key at the top of the document:
64+
65+
```json
66+
{
67+
"$schema": "https://raw.githubusercontent.com/chenasraf/sofmani/master/schema/sofmani.schema.json",
68+
"debug": true,
69+
"install": [
70+
{
71+
"name": "neovim",
72+
"type": "brew"
73+
}
74+
]
75+
}
76+
```
77+
78+
VS Code picks this up automatically. Most other JSON-aware editors do too.
79+
80+
Alternatively, you can configure `json.schemas` in VS Code's `settings.json`:
81+
82+
```json
83+
{
84+
"json.schemas": [
85+
{
86+
"fileMatch": ["sofmani.json", "**/sofmani/*.json"],
87+
"url": "https://raw.githubusercontent.com/chenasraf/sofmani/master/schema/sofmani.schema.json"
88+
}
89+
]
90+
}
91+
```
92+
93+
## Validating from the command line
94+
95+
You can validate a config file against the schema using any JSON Schema validator. Two convenient
96+
options:
97+
98+
### `check-jsonschema` (Python)
99+
100+
```bash
101+
pipx install check-jsonschema
102+
check-jsonschema \
103+
--schemafile schema/sofmani.schema.json \
104+
sofmani.yaml
105+
```
106+
107+
`check-jsonschema` supports both JSON and YAML input files out of the box.
108+
109+
### `ajv` (Node)
110+
111+
For JSON files:
112+
113+
```bash
114+
npx ajv-cli validate \
115+
-s schema/sofmani.schema.json \
116+
-d sofmani.json
117+
```
118+
119+
For YAML files, convert on the fly with `yq`:
120+
121+
```bash
122+
yq -o=json sofmani.yaml | npx ajv-cli validate -s schema/sofmani.schema.json -d /dev/stdin
123+
```
124+
125+
## What the schema covers
126+
127+
- All top-level options (`debug`, `check_updates`, `summary`, `category_display`, `repo_update`,
128+
`defaults`, `env`, `platform_env`, `machine_aliases`, `install`).
129+
- All supported installer types and their type-specific `opts`.
130+
- Enums for `category_display`, `repo_update` modes, installer `type`, and platform names.
131+
- The `frequency` duration pattern (`1d`, `12h`, `1w2d`, ...).
132+
- Dual shapes for fields like `skip_summary` (bool or object), `enabled` (bool or shell string), and
133+
`github-release` `download_filename` (string or per-platform map).

schema/schema_test.go

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
package schema_test
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"sort"
8+
"testing"
9+
10+
"github.com/chenasraf/sofmani/appconfig"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
// schemaPath returns the absolute path to the schema file regardless of where
16+
// the tests are run from.
17+
func schemaPath(t *testing.T) string {
18+
t.Helper()
19+
wd, err := os.Getwd()
20+
require.NoError(t, err)
21+
return filepath.Join(wd, "sofmani.schema.json")
22+
}
23+
24+
func loadSchema(t *testing.T) map[string]any {
25+
t.Helper()
26+
data, err := os.ReadFile(schemaPath(t))
27+
require.NoError(t, err)
28+
var m map[string]any
29+
require.NoError(t, json.Unmarshal(data, &m), "schema must be valid JSON")
30+
return m
31+
}
32+
33+
func TestSchemaIsValidJSON(t *testing.T) {
34+
m := loadSchema(t)
35+
assert.Equal(t, "http://json-schema.org/draft-07/schema#", m["$schema"])
36+
assert.NotEmpty(t, m["$id"])
37+
assert.NotEmpty(t, m["title"])
38+
assert.Equal(t, "object", m["type"])
39+
}
40+
41+
func TestSchemaTopLevelProperties(t *testing.T) {
42+
m := loadSchema(t)
43+
props, ok := m["properties"].(map[string]any)
44+
require.True(t, ok, "top-level properties must exist")
45+
46+
// These must be declared at the top level of the schema.
47+
expected := []string{
48+
"$schema",
49+
"debug",
50+
"check_updates",
51+
"summary",
52+
"category_display",
53+
"repo_update",
54+
"defaults",
55+
"env",
56+
"platform_env",
57+
"machine_aliases",
58+
"install",
59+
}
60+
for _, key := range expected {
61+
_, exists := props[key]
62+
assert.Truef(t, exists, "top-level property %q missing from schema", key)
63+
}
64+
}
65+
66+
// TestInstallerTypesMatchGoConstants ensures the schema's list of installer
67+
// types stays in lock-step with the Go InstallerType constants. If a new
68+
// installer type is added in code but not in the schema (or vice-versa), this
69+
// test fails and prompts the developer to update both sides.
70+
func TestInstallerTypesMatchGoConstants(t *testing.T) {
71+
m := loadSchema(t)
72+
defs, ok := m["definitions"].(map[string]any)
73+
require.True(t, ok)
74+
installerType, ok := defs["installerType"].(map[string]any)
75+
require.True(t, ok)
76+
enum, ok := installerType["enum"].([]any)
77+
require.True(t, ok)
78+
79+
schemaTypes := make([]string, 0, len(enum))
80+
for _, v := range enum {
81+
s, ok := v.(string)
82+
require.True(t, ok)
83+
schemaTypes = append(schemaTypes, s)
84+
}
85+
sort.Strings(schemaTypes)
86+
87+
goTypes := []string{
88+
string(appconfig.InstallerTypeGroup),
89+
string(appconfig.InstallerTypeShell),
90+
string(appconfig.InstallerTypeDocker),
91+
string(appconfig.InstallerTypeBrew),
92+
string(appconfig.InstallerTypeApt),
93+
string(appconfig.InstallerTypeApk),
94+
string(appconfig.InstallerTypeGit),
95+
string(appconfig.InstallerTypeGitHubRelease),
96+
string(appconfig.InstallerTypeRsync),
97+
string(appconfig.InstallerTypeNpm),
98+
string(appconfig.InstallerTypePnpm),
99+
string(appconfig.InstallerTypeYarn),
100+
string(appconfig.InstallerTypePipx),
101+
string(appconfig.InstallerTypeManifest),
102+
string(appconfig.InstallerTypePacman),
103+
string(appconfig.InstallerTypeYay),
104+
string(appconfig.InstallerTypeCargo),
105+
}
106+
sort.Strings(goTypes)
107+
108+
assert.Equal(t, goTypes, schemaTypes, "installerType enum in schema is out of sync with Go constants")
109+
}
110+
111+
func TestCategoryDisplayEnumMatchesGoConstants(t *testing.T) {
112+
m := loadSchema(t)
113+
props := m["properties"].(map[string]any)
114+
cat := props["category_display"].(map[string]any)
115+
enum, ok := cat["enum"].([]any)
116+
require.True(t, ok)
117+
118+
schemaVals := make([]string, 0, len(enum))
119+
for _, v := range enum {
120+
schemaVals = append(schemaVals, v.(string))
121+
}
122+
sort.Strings(schemaVals)
123+
124+
goVals := []string{
125+
string(appconfig.CategoryDisplayBorder),
126+
string(appconfig.CategoryDisplayBorderCompact),
127+
string(appconfig.CategoryDisplayMinimal),
128+
}
129+
sort.Strings(goVals)
130+
131+
assert.Equal(t, goVals, schemaVals)
132+
}
133+
134+
func TestRepoUpdateEnumMatchesGoConstants(t *testing.T) {
135+
m := loadSchema(t)
136+
defs := m["definitions"].(map[string]any)
137+
mode := defs["repoUpdateMode"].(map[string]any)
138+
enum, ok := mode["enum"].([]any)
139+
require.True(t, ok)
140+
141+
schemaVals := make([]string, 0, len(enum))
142+
for _, v := range enum {
143+
schemaVals = append(schemaVals, v.(string))
144+
}
145+
sort.Strings(schemaVals)
146+
147+
goVals := []string{
148+
string(appconfig.RepoUpdateOnce),
149+
string(appconfig.RepoUpdateAlways),
150+
string(appconfig.RepoUpdateNever),
151+
}
152+
sort.Strings(goVals)
153+
154+
assert.Equal(t, goVals, schemaVals)
155+
}
156+
157+
// TestRecipesParseAgainstSchemaShape is a structural smoke test: every recipe
158+
// shipped in docs/recipes must only use top-level keys that the schema
159+
// declares. This catches typos and schema drift without pulling in a full
160+
// JSON-schema validator as a dependency.
161+
func TestRecipesParseAgainstSchemaShape(t *testing.T) {
162+
recipesDir := filepath.Join("..", "docs", "recipes")
163+
entries, err := os.ReadDir(recipesDir)
164+
require.NoError(t, err)
165+
166+
for _, entry := range entries {
167+
if entry.IsDir() {
168+
continue
169+
}
170+
name := entry.Name()
171+
if filepath.Ext(name) != ".yml" && filepath.Ext(name) != ".yaml" {
172+
continue
173+
}
174+
t.Run(name, func(t *testing.T) {
175+
path := filepath.Join(recipesDir, name)
176+
data, err := os.ReadFile(path)
177+
require.NoError(t, err)
178+
179+
cfg, err := appconfig.ParseConfigFromContent(data)
180+
require.NoError(t, err, "recipe must parse as AppConfig")
181+
182+
// Basic sanity: every installer in the recipe has a recognized
183+
// type (or is a category header).
184+
for _, inst := range cfg.Install {
185+
if inst.IsCategory() {
186+
continue
187+
}
188+
assert.NotEmptyf(t, string(inst.Type), "installer in %s missing type", name)
189+
}
190+
})
191+
}
192+
193+
}

0 commit comments

Comments
 (0)