Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,13 @@ The Go module ports the **architecture**, not every mechanism. The build
lifecycle (pre → reload → post), producers, model output, dryrun, and watch
semantics match TypeScript. Two things differ by necessity:

- **Actions are a registry, not dynamic modules.** TypeScript declares actions
in a config file and loads them with `require()`. Go cannot load code at
runtime, so actions are registered programmatically via `ModelSpec.Actions`
(`map[string]ActionDef`) — see `go/producer.go`.
- **The config declares actions; the registry binds them.** Like TypeScript,
Go resolves `.model-config/model-config.jsonic` (auto-created when missing),
writes `model-config.json`, and takes the action order from
`sys.model.order.action` (`go/config.go`). But Go cannot load code at
runtime, so the action *functions* are registered programmatically via
`ModelSpec.Actions` (`map[string]ActionDef`) and bound to the
config-declared names — see `go/producer.go`.
- **Watching polls modification times** (`go/watch.go`) rather than using
chokidar.

Expand Down
26 changes: 26 additions & 0 deletions docs/how-to.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Focused recipes for specific tasks. Each is self-contained. If you are new to
the tool, do the [tutorial](./tutorial.md) first; for exhaustive detail see the
[reference](./reference.md).

- [Initialize a new model](#initialize-a-new-model)
- [Build once vs. watch](#build-once-vs-watch)
- [Pass build arguments to actions](#pass-build-arguments-to-actions)
- [Write a build action](#write-a-build-action)
Expand All @@ -20,6 +21,31 @@ the tool, do the [tutorial](./tutorial.md) first; for exhaustive detail see the
- [Tune watch behavior](#tune-watch-behavior)


## Initialize a new model

Scaffold a starter `model/model.jsonic` and `model/.model-config/model-config.jsonic`:

```bash
voxgig-model init # in the current directory
voxgig-model init my-project # under my-project/
```

Existing files are left untouched. Then build it:

```bash
voxgig-model model/model.jsonic
```

The Go CLI is identical (`go run github.com/voxgig/model/go/cmd/voxgig-model init`).
From code, use `initModel(dir, fs)` (TypeScript) or `model.Init(dir)` (Go):

```js
const Fs = require('node:fs')
const { initModel } = require('@voxgig/model')
const { created, skipped } = initModel('.', Fs)
```


## Build once vs. watch

Once, then exit:
Expand Down
16 changes: 16 additions & 0 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,22 @@ voxgig-model --dryrun model/model.jsonic
voxgig-model -b '{env:prod, region:eu-west-1}' model/model.jsonic
```

### `init` — scaffold a new model

```
voxgig-model init [dir]
```

Creates a starter project under `<dir>/model` (default `dir`: the current
directory): `model/model.jsonic` and `model/.model-config/model-config.jsonic`.
Existing files are left untouched. Both the TypeScript and Go CLIs support
this and produce identical files.

```bash
voxgig-model init # scaffold ./model
voxgig-model model/model.jsonic # build it
```


## Project layout

Expand Down
7 changes: 5 additions & 2 deletions go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,11 @@ This port mirrors the architecture — the build lifecycle (pre → reload →
post), producers, dryrun, and watch semantics — but adapts a few mechanisms to
Go:

- **Actions are registered programmatically** (`ModelSpec.Actions`) instead of
being loaded from a config file: Go cannot `require()` code at runtime.
- **Actions are registered programmatically** (`ModelSpec.Actions`): the
`.model-config/model-config.jsonic` file still declares which actions run and
in what order (and is auto-created and written to `model-config.json`, as in
TypeScript), but Go binds each declared name to a registered func rather than
`require()`-ing a module.
- **Watching polls modification times** instead of using chokidar.
- **Imports** resolve relative to the model base directory; the resolver
briefly changes the working directory because the Go aontu `Generate(src)`
Expand Down
25 changes: 25 additions & 0 deletions go/cmd/voxgig-model/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ func main() {
// separated from main (which only adds os.Exit and signal handling) so the
// behavior can be tested directly.
func run(args []string, stderr io.Writer) int {
if len(args) > 0 && args[0] == "init" {
return runInit(args[1:], stderr)
}

fs := flag.NewFlagSet("voxgig-model", flag.ContinueOnError)
fs.SetOutput(stderr)
watch := fs.Bool("w", false, "watch and rebuild on change")
Expand Down Expand Up @@ -79,6 +83,27 @@ func run(args []string, stderr io.Writer) int {
return 0
}

// runInit scaffolds a starter model and config under <dir>/model.
func runInit(args []string, out io.Writer) int {
dir := "."
if len(args) > 0 && args[0] != "" {
dir = args[0]
}
created, skipped, err := model.Init(dir)
if err != nil {
fmt.Fprintln(out, "ERROR:", err)
return 1
}
for _, p := range created {
fmt.Fprintln(out, "created:", p)
}
for _, p := range skipped {
fmt.Fprintln(out, "exists: ", p)
}
fmt.Fprintln(out, "Next: voxgig-model "+filepath.Join(dir, "model", "model.jsonic"))
return 0
}

func reportErrs(br *model.BuildResult, stderr io.Writer) {
if br == nil {
return
Expand Down
22 changes: 22 additions & 0 deletions go/cmd/voxgig-model/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,25 @@ func TestCLIBadModel(t *testing.T) {
t.Fatalf("stderr = %q", out.String())
}
}

func TestCLIInit(t *testing.T) {
dir := t.TempDir()

var out bytes.Buffer
if code := run([]string{"init", dir}, &out); code != 0 {
t.Fatalf("init exit %d: %s", code, out.String())
}
if !strings.Contains(out.String(), "created:") {
t.Fatalf("init output = %q", out.String())
}
root := filepath.Join(dir, "model", "model.jsonic")
if _, err := os.Stat(root); err != nil {
t.Fatalf("init did not scaffold the model: %v", err)
}

// The scaffold should then build through the CLI.
var out2 bytes.Buffer
if code := run([]string{"-g", "silent", root}, &out2); code != 0 {
t.Fatalf("building the scaffold exit %d: %s", code, out2.String())
}
}
67 changes: 67 additions & 0 deletions go/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/* Copyright © 2021-2026 Voxgig Ltd, MIT License. */

package model

import "path/filepath"

// configStub is written when a model has no config file yet. It is
// self-contained (no package import) so it resolves with the Go aontu engine,
// and mirrors the effective shape of the TypeScript default config.
const configStub = "# Model configuration. Declare build actions and their order here.\n" +
"#\n" +
"# Example (Go binds each declared action name to a func registered in\n" +
"# ModelSpec.Actions):\n" +
"# sys: model: action: { example: load: 'build/example' }\n" +
"# sys: model: order: action: 'example'\n" +
"\n" +
"sys: model: action: {}\n" +
"sys: model: order: action: *''\n"

// Config is the build for a model's .model-config/model-config.jsonic. It
// mirrors the TypeScript Config: it resolves the config model, writes
// model-config.json, and is the source of the action order. The file is
// auto-created from configStub when missing.
type Config struct {
build *Build
log Log
}

// newConfig sets up (and bootstraps) the config build for a model base.
func newConfig(base string, spec ModelSpec, log Log) *Config {
cbase := filepath.Join(base, ".model-config")
cpath := filepath.Join(cbase, "model-config.jsonic")

cb := NewBuild(BuildSpec{
Name: "config",
Path: cpath,
Base: cbase,
Dryrun: spec.Dryrun,
Resolver: spec.Resolver,
Log: log,
Res: []ProducerDef{{Path: "/", Build: ModelProducer}},
})

ensureConfigFile(cb.FS, cpath, cbase)
return &Config{build: cb, log: log}
}

// ensureConfigFile writes the default config stub if none exists. It uses the
// build's filesystem, so a dryrun keeps the stub in memory.
func ensureConfigFile(fs FS, cpath, cbase string) {
if _, err := fs.Stat(cpath); err == nil {
return
}
_ = fs.MkdirAll(cbase, 0o755)
_ = fs.WriteFile(cpath, []byte(configStub), 0o644)
}

// Run resolves the config model and writes model-config.json.
func (c *Config) Run() *BuildResult { return c.build.Run(false) }

// Model returns the resolved config model (valid after Run).
func (c *Config) Model() map[string]any {
if c == nil || c.build == nil {
return nil
}
return c.build.Model
}
64 changes: 64 additions & 0 deletions go/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/* Copyright © 2021-2026 Voxgig Ltd, MIT License. */

package model

import (
"os"
"path/filepath"
"strings"
"testing"
)

// New auto-creates the config file and writes model-config.json.
func TestConfigAutoCreated(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "model.jsonic", "x: 1\n")

m := New(ModelSpec{Path: filepath.Join(dir, "model.jsonic"), Base: dir})
if br := m.Run(); !br.OK {
t.Fatalf("run failed: %v", br.Errs)
}
if _, err := os.Stat(filepath.Join(dir, ".model-config", "model-config.jsonic")); err != nil {
t.Fatalf("config file not auto-created: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, ".model-config", "model-config.json")); err != nil {
t.Fatalf("model-config.json not written: %v", err)
}
if m.Config().Model() == nil {
t.Fatal("config model not resolved")
}
}

// The config's sys.model.order.action drives the action run order, overriding
// the registry's default (sorted) order.
func TestConfigDrivesActionOrder(t *testing.T) {
dir := t.TempDir()
mdir := filepath.Join(dir, "model")
cdir := filepath.Join(mdir, ".model-config")
if err := os.MkdirAll(cdir, 0o755); err != nil {
t.Fatal(err)
}
writeFile(t, mdir, "model.jsonic", "x: 1\n")
writeFile(t, cdir, "model-config.jsonic",
"sys: model: action: { a: load: 'x', b: load: 'y' }\n"+
"sys: model: order: action: 'b,a'\n")

var order []string
mk := func(n string) ActionDef {
return ActionDef{Run: func(_ map[string]any, _ *Build, _ *BuildContext) ActionResult {
order = append(order, n)
return ActionResult{OK: true}
}}
}
m := New(ModelSpec{
Path: filepath.Join(mdir, "model.jsonic"),
Base: mdir,
Actions: map[string]ActionDef{"a": mk("a"), "b": mk("b")},
})
if br := m.Run(); !br.OK {
t.Fatalf("run failed: %v", br.Errs)
}
if strings.Join(order, ",") != "b,a" {
t.Fatalf("action order = %v, want [b a] (from config order.action)", order)
}
}
52 changes: 52 additions & 0 deletions go/init.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/* Copyright © 2021-2026 Voxgig Ltd, MIT License. */

package model

import (
"os"
"path/filepath"
)

const starterModel = "# Voxgig model. Edit this file, then build it:\n" +
"# voxgig-model model/model.jsonic\n" +
"#\n" +
"# Models are unified .jsonic - add types, defaults, references, imports.\n" +
"# Tutorial: https://github.com/voxgig/model/blob/main/docs/tutorial.md\n" +
"\n" +
"name: 'my-model'\n"

const starterConfig = "# Model configuration. Declare build actions and their order here.\n" +
"#\n" +
"# Example (TypeScript loads the module; Go binds the name to a func\n" +
"# registered in ModelSpec.Actions):\n" +
"# sys: model: action: { example: load: 'build/example' }\n" +
"# sys: model: order: action: 'example'\n" +
"\n" +
"sys: model: action: {}\n" +
"sys: model: order: action: *''\n"

// Init scaffolds a starter model and config under <dir>/model. Existing files
// are left untouched. It returns the paths created and the paths skipped.
func Init(dir string) (created, skipped []string, err error) {
if dir == "" {
dir = "."
}
files := []struct{ path, content string }{
{filepath.Join(dir, "model", "model.jsonic"), starterModel},
{filepath.Join(dir, "model", ".model-config", "model-config.jsonic"), starterConfig},
}
for _, f := range files {
if _, serr := os.Stat(f.path); serr == nil {
skipped = append(skipped, f.path)
continue
}
if merr := os.MkdirAll(filepath.Dir(f.path), 0o755); merr != nil {
return created, skipped, merr
}
if werr := os.WriteFile(f.path, []byte(f.content), 0o644); werr != nil {
return created, skipped, werr
}
created = append(created, f.path)
}
return created, skipped, nil
}
Loading
Loading