Skip to content

Commit 9b0a0b2

Browse files
tinywavescodex
andcommitted
feat: add runtime project initializer
Co-authored-by: Codex <codex@openai.com>
1 parent f0aa161 commit 9b0a0b2

17 files changed

Lines changed: 1031 additions & 16 deletions

File tree

‎.vscode/settings.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"gosec",
1515
"govet",
1616
"govulncheck",
17+
"JSTS",
1718
"ldflags",
1819
"noctx",
1920
"nolintlint",
@@ -23,7 +24,8 @@
2324
"thelper",
2425
"tparallel",
2526
"unconvert",
26-
"unparam"
27+
"unparam",
28+
"yarlson"
2729
],
2830
"[go]": {
2931
"editor.defaultFormatter": "golang.go",

‎AGENTS.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,16 @@ All implementations should be designed for CLI use. Commands must be clear, beha
3030
- Do not introduce a large package, a complex dependency tree, or a high maintenance burden for a small feature. The benefit of a dependency must clearly outweigh its cost.
3131
- Keep implementations simple, maintain clear module boundaries, and provide useful error messages for user-visible failures.
3232

33+
## Package Layout
34+
35+
- Keep executable entry points under `cmd/<binary>` small. They should wire dependencies, execute the application, report terminal errors, and avoid containing command business logic.
36+
- Assemble the Devkit root command in `internal/app`.
37+
- Put each top-level user-facing CLI command in its own package under `internal/commands`. Use a clear package suffix such as `initcmd` when the command name conflicts with a Go keyword or would otherwise be ambiguous.
38+
- Keep sub-flows that only belong to one command inside that command package. Extract a separate internal package only when logic or data is genuinely shared or forms an independent domain boundary.
39+
- Keep reusable runtime version types, embedded version data, and loading logic in `internal/runtime`. Treat `internal/runtime/data/versions.json` as checked-in data, not user configuration.
40+
- Keep maintenance executables, such as the runtime snapshot updater, as separate binaries under `cmd` rather than exposing them as user-facing Devkit commands.
41+
- Keep tests beside the package they verify. Test command packages directly, and keep a small assembly test for the root command.
42+
3343
## Third-Party Package Selection
3444

3545
Conduct thorough research before introducing any third-party Go package. Do not select a package based on assumptions or a single metric.

‎Makefile‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ APP_NAME := devkit
22
VERSION ?= $(shell git describe --tags --match 'v*' --abbrev=0 2>/dev/null || echo dev)
33
LDFLAGS := -s -w -X main.version=$(VERSION)
44

5-
.PHONY: help fmt lint test test-race vet mod-verify check prek install-hooks build release-patch release-minor release-major
5+
.PHONY: help fmt lint test test-race vet mod-verify check prek install-hooks update-runtime build release-patch release-minor release-major
66

77
help:
88
@printf '%-28s%s\n' \
@@ -15,6 +15,7 @@ help:
1515
'make check' 'Run formatting, tests, vet, and module checks' \
1616
'make prek' 'Run prek hooks against all files' \
1717
'make install-hooks' 'Install prek Git hooks' \
18+
'make update-runtime' 'Refresh checked-in Node.js and pnpm versions' \
1819
'make build' 'Build the CLI' \
1920
'make release-patch' 'Create and push the next patch release' \
2021
'make release-minor' 'Create and push the next minor release' \
@@ -49,6 +50,9 @@ prek:
4950
install-hooks:
5051
prek install
5152

53+
update-runtime:
54+
go run ./cmd/update-runtime
55+
5256
build:
5357
go build -trimpath -ldflags "$(LDFLAGS)" -o "$(APP_NAME)" ./cmd/devkit
5458

‎README.md‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,23 @@ curl -fsSL https://raw.githubusercontent.com/tinywaves/devkit/main/scripts/insta
4646

4747
Devkit is implemented in Go and targets a simple, portable command-line experience.
4848

49+
## Project Structure
50+
51+
```text
52+
cmd/
53+
├── devkit/ # Devkit executable entry point
54+
└── update-runtime/ # Runtime version snapshot updater
55+
internal/
56+
├── app/ # Root command assembly
57+
├── commands/
58+
│ └── initcmd/ # devkit init command and initializers
59+
└── runtime/
60+
├── config.go # Embedded version data loader
61+
└── data/versions.json # Node.js and pnpm version snapshot
62+
```
63+
64+
Executable entry points under `cmd` stay small. User-facing commands live in dedicated packages under `internal/commands`, while reusable domain logic and embedded data live in focused packages under `internal`.
65+
4966
## Development
5067

5168
The repository uses the conventional root-level `Makefile` for local development tasks. The lowercase name `makefile` is also recognized by GNU Make, but this project keeps only `Makefile` to follow common Go project conventions and avoid case-sensitive filesystem ambiguity.
@@ -68,6 +85,43 @@ go run ./cmd/devkit --help
6885
go run ./cmd/devkit --version
6986
```
7087

88+
Initialize project tooling interactively:
89+
90+
```sh
91+
devkit init
92+
```
93+
94+
Or select an initializer directly:
95+
96+
```sh
97+
devkit init js/ts-runtime
98+
devkit init eslint
99+
```
100+
101+
The `js/ts-runtime` initializer only runs in a directory containing `package.json`. It uses the configured Node.js and pnpm version snapshots, then writes the selected versions to `packageManager` and `devEngines` without requiring network access.
102+
103+
### Runtime Version Snapshots
104+
105+
The version data is checked into `internal/runtime/data/versions.json` and embedded into the Devkit binary. The current snapshot contains the highest stable release for each Node.js major version from 18 onward and each pnpm major version from 10 onward. Node.js LTS labels are maintained alongside the Node.js versions.
106+
107+
The snapshot is based on these upstream sources:
108+
109+
- Node.js release index: `https://nodejs.org/dist/index.json`
110+
- Node.js release schedule: `https://raw.githubusercontent.com/nodejs/Release/main/schedule.json`
111+
- pnpm package metadata: `https://registry.npmjs.org/pnpm`
112+
113+
The two Node.js requests are intentional: the release index provides available versions, while the release schedule identifies LTS release lines. The pnpm request provides all published versions, from which the highest stable version in each major line is selected.
114+
115+
Normal `devkit init` does not query these endpoints. Because a static snapshot cannot detect upstream changes while offline, refreshing it belongs to repository maintenance rather than project initialization. The Go updater downloads the complete JSON responses, extracts the required fields, and rewrites the normalized `versions.json`. If pnpm 12 or any other release line receives a newer stable version, the rewritten JSON produces a Git diff; no upstream changes produce no diff. Releasing a new Devkit binary then distributes the refreshed choices.
116+
117+
Refresh the snapshot locally with:
118+
119+
```sh
120+
make update-runtime
121+
```
122+
123+
This command is intended for repository maintenance. It updates the checked-in data snapshot; regular users do not need to run it and normal initialization remains offline.
124+
71125
Build a local binary:
72126

73127
```sh
@@ -94,6 +148,7 @@ make check
94148
make lint
95149
make prek
96150
make install-hooks
151+
make update-runtime
97152
make test
98153
make vet
99154
make build

‎cmd/devkit/main.go‎

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,14 @@ import (
44
"fmt"
55
"os"
66

7-
"github.com/spf13/cobra"
7+
"github.com/tinywaves/devkit/internal/app"
88
)
99

1010
var version = "dev"
1111

1212
func main() {
13-
rootCommand := &cobra.Command{
14-
Use: "devkit",
15-
Short: "Automate project setup and local environment preparation",
16-
Version: version,
17-
SilenceErrors: true,
18-
SilenceUsage: true,
19-
RunE: func(command *cobra.Command, _ []string) error {
20-
return command.Help()
21-
},
22-
}
23-
rootCommand.SetVersionTemplate("devkit version {{.Version}}\n")
13+
rootCommand := app.NewRootCommand(os.Stdin, os.Stdout, version)
14+
rootCommand.SetErr(os.Stderr)
2415
if err := rootCommand.Execute(); err != nil {
2516
fmt.Fprintf(os.Stderr, "devkit: %v\n", err)
2617
os.Exit(1)

‎cmd/update-runtime/main.go‎

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"os"
8+
"sort"
9+
"strings"
10+
"time"
11+
12+
"github.com/Masterminds/semver/v3"
13+
)
14+
15+
const (
16+
nodeIndexURL = "https://nodejs.org/dist/index.json"
17+
nodeScheduleURL = "https://raw.githubusercontent.com/nodejs/Release/main/schedule.json"
18+
pnpmRegistryURL = "https://registry.npmjs.org/pnpm"
19+
dataPath = "internal/runtime/data/versions.json"
20+
)
21+
22+
type nodeRelease struct {
23+
Version string `json:"version"`
24+
}
25+
type nodeSchedule struct {
26+
LTS json.RawMessage `json:"lts"`
27+
}
28+
type pnpmMetadata struct {
29+
Versions map[string]json.RawMessage `json:"versions"`
30+
}
31+
type nodeVersion struct {
32+
Version string `json:"version"`
33+
LTS bool `json:"lts,omitempty"`
34+
}
35+
type runtimeConfig struct {
36+
Node []nodeVersion `json:"node"`
37+
Pnpm []string `json:"pnpm"`
38+
}
39+
40+
var client = http.Client{Timeout: 30 * time.Second}
41+
42+
func main() {
43+
var releases []nodeRelease
44+
var schedule map[string]nodeSchedule
45+
var metadata pnpmMetadata
46+
for _, request := range []struct {
47+
url string
48+
out any
49+
name string
50+
}{
51+
{
52+
nodeIndexURL,
53+
&releases,
54+
"Node.js releases",
55+
},
56+
{
57+
nodeScheduleURL,
58+
&schedule,
59+
"Node.js schedule",
60+
},
61+
{
62+
pnpmRegistryURL,
63+
&metadata,
64+
"pnpm releases",
65+
},
66+
} {
67+
if err := fetch(request.url, request.out); err != nil {
68+
fail("fetch "+request.name, err)
69+
}
70+
}
71+
72+
nodeValues := make([]string, 0, len(releases))
73+
for _, release := range releases {
74+
nodeValues = append(nodeValues, release.Version)
75+
}
76+
nodeReleases := latest(nodeValues, 18)
77+
node := make([]nodeVersion, len(nodeReleases))
78+
for index, version := range nodeReleases {
79+
node[index] = nodeVersion{
80+
Version: version.String(),
81+
LTS: isLTS(schedule[fmt.Sprintf("v%d", version.Major())].LTS),
82+
}
83+
}
84+
85+
pnpmValues := make([]string, 0, len(metadata.Versions))
86+
for version := range metadata.Versions {
87+
pnpmValues = append(pnpmValues, version)
88+
}
89+
pnpm := latest(pnpmValues, 10)
90+
if len(node) == 0 || len(pnpm) == 0 {
91+
fail("build runtime config", fmt.Errorf("upstream metadata contains no supported versions"))
92+
}
93+
94+
config := runtimeConfig{Node: node, Pnpm: make([]string, len(pnpm))}
95+
for index, version := range pnpm {
96+
config.Pnpm[index] = version.String()
97+
}
98+
data, err := json.MarshalIndent(config, "", strings.Repeat(" ", 2))
99+
if err != nil {
100+
fail("encode runtime config", err)
101+
}
102+
if err := os.WriteFile(dataPath, append(data, '\n'), 0o644); err != nil {
103+
fail("write runtime config", err)
104+
}
105+
fmt.Printf("Updated %s\n", dataPath)
106+
}
107+
108+
func fetch(url string, target any) error {
109+
response, err := client.Get(url)
110+
if err != nil {
111+
return err
112+
}
113+
defer response.Body.Close()
114+
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
115+
return fmt.Errorf("server returned %s", response.Status)
116+
}
117+
return json.NewDecoder(response.Body).Decode(target)
118+
}
119+
120+
func latest(values []string, minimumMajor uint64) []*semver.Version {
121+
versions := make(map[uint64]*semver.Version)
122+
for _, value := range values {
123+
version, err := stable(value)
124+
if err != nil || version.Major() < minimumMajor {
125+
continue
126+
}
127+
if current := versions[version.Major()]; current == nil || version.GreaterThan(current) {
128+
versions[version.Major()] = version
129+
}
130+
}
131+
result := make([]*semver.Version, 0, len(versions))
132+
for _, version := range versions {
133+
result = append(result, version)
134+
}
135+
sort.Slice(result, func(left, right int) bool {
136+
return result[left].GreaterThan(result[right])
137+
})
138+
return result
139+
}
140+
141+
func stable(value string) (*semver.Version, error) {
142+
version, err := semver.StrictNewVersion(strings.TrimPrefix(strings.TrimSpace(value), "v"))
143+
if err != nil || version.Prerelease() != "" || version.Metadata() != "" {
144+
return nil, fmt.Errorf("not a stable semantic version")
145+
}
146+
return version, nil
147+
}
148+
149+
func isLTS(value json.RawMessage) bool {
150+
return strings.HasPrefix(strings.TrimSpace(string(value)), `"`)
151+
}
152+
153+
func fail(action string, err error) {
154+
fmt.Fprintf(os.Stderr, "update runtime: %s: %v\n", action, err)
155+
os.Exit(1)
156+
}

‎cmd/update-runtime/main_test.go‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package main
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
func TestLatest(t *testing.T) {
9+
got := latest(
10+
[]string{"v17.9.1", "v18.20.8", "v18.20.7", "20.20.1", "20.20.2", "20.21.0-rc.1", "12.1.0"},
11+
18,
12+
)
13+
if got := strings.Join([]string{got[0].String(), got[1].String()}, ","); got != "20.20.2,18.20.8" {
14+
t.Fatalf("latest versions = %q", got)
15+
}
16+
}

‎go.mod‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@ module github.com/tinywaves/devkit
22

33
go 1.27.0
44

5-
require github.com/spf13/cobra v1.10.2
5+
require (
6+
github.com/Masterminds/semver/v3 v3.5.0
7+
github.com/spf13/cobra v1.10.2
8+
github.com/yarlson/tap v0.13.1
9+
)
610

711
require (
812
charm.land/fang/v2 v2.0.1 // indirect
913
charm.land/lipgloss/v2 v2.0.1 // indirect
10-
github.com/Masterminds/semver/v3 v3.5.0 // indirect
1114
github.com/caarlos0/go-version v0.2.2 // indirect
1215
github.com/caarlos0/svu/v3 v3.4.1 // indirect
1316
github.com/charmbracelet/colorprofile v0.4.2 // indirect
@@ -24,7 +27,9 @@ require (
2427
github.com/gobwas/glob v0.2.3 // indirect
2528
github.com/inconshreveable/mousetrap v1.1.0 // indirect
2629
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
30+
github.com/mattn/go-isatty v0.0.20 // indirect
2731
github.com/mattn/go-runewidth v0.0.20 // indirect
32+
github.com/mattn/go-tty v0.0.7 // indirect
2833
github.com/muesli/cancelreader v0.2.2 // indirect
2934
github.com/muesli/mango v0.1.0 // indirect
3035
github.com/muesli/mango-cobra v1.2.0 // indirect
@@ -43,6 +48,7 @@ require (
4348
go.yaml.in/yaml/v3 v3.0.4 // indirect
4449
golang.org/x/sync v0.19.0 // indirect
4550
golang.org/x/sys v0.42.0 // indirect
51+
golang.org/x/term v0.38.0 // indirect
4652
golang.org/x/text v0.28.0 // indirect
4753
)
4854

0 commit comments

Comments
 (0)