@rip-lang/stamp 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +166 -0
- package/bin/stamp +21 -0
- package/directives/brew.rip +48 -0
- package/directives/container.rip +106 -0
- package/directives/dataset.rip +40 -0
- package/directives/firewall.rip +78 -0
- package/directives/group.rip +34 -0
- package/directives/incus.rip +111 -0
- package/directives/multipass.rip +47 -0
- package/directives/packages.rip +40 -0
- package/directives/pool.rip +53 -0
- package/directives/profile.rip +29 -0
- package/directives/service.rip +52 -0
- package/directives/ssh.rip +64 -0
- package/directives/user.rip +61 -0
- package/package.json +45 -0
- package/src/cli.rip +125 -0
- package/src/engine.rip +146 -0
- package/src/helpers.rip +83 -0
- package/src/parser.rip +170 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export name = "pool"
|
|
2
|
+
export description = "Ensures a ZFS pool exists with specified properties"
|
|
3
|
+
export positional = ["name", "device"]
|
|
4
|
+
export properties =
|
|
5
|
+
ashift: { description: "Pool ashift value (default: 12)" }
|
|
6
|
+
compression: { description: "Compression algorithm (default: zstd)" }
|
|
7
|
+
atime: { description: "Access time tracking (default: off)" }
|
|
8
|
+
mountpoint: { description: "Pool mountpoint (default: /<name>)" }
|
|
9
|
+
|
|
10
|
+
export check = (name, props) ->
|
|
11
|
+
return 'missing' unless ok $"zpool list #{name}"
|
|
12
|
+
'ok'
|
|
13
|
+
|
|
14
|
+
export apply = (name, props) ->
|
|
15
|
+
device = props.args?[0]
|
|
16
|
+
raise "pool '#{name}' requires a device argument" unless device
|
|
17
|
+
|
|
18
|
+
unless ok $"zpool list #{name}"
|
|
19
|
+
raise "Block device not found: #{device}" unless ok $"test -b #{device}"
|
|
20
|
+
raise "Device #{device} has existing data. Run 'wipefs -a #{device}' first." if ok $"blkid #{device}"
|
|
21
|
+
|
|
22
|
+
ashift = props.ashift?[0]?.args?[0] or '12'
|
|
23
|
+
sh $"zpool create -f -o ashift=#{ashift} #{name} #{device}"
|
|
24
|
+
|
|
25
|
+
compression = props.compression?[0]?.args?[0] or 'zstd'
|
|
26
|
+
sh $"zfs set compression=#{compression} #{name}"
|
|
27
|
+
|
|
28
|
+
atime = props.atime?[0]?.args?[0] or 'off'
|
|
29
|
+
sh $"zfs set atime=#{atime} #{name}"
|
|
30
|
+
|
|
31
|
+
if mp = props.mountpoint?[0]?.args?[0]
|
|
32
|
+
sh $"zfs set mountpoint=#{mp} #{name}"
|
|
33
|
+
|
|
34
|
+
export verify = (name, props) ->
|
|
35
|
+
results = []
|
|
36
|
+
unless ok $"zpool list #{name}"
|
|
37
|
+
results.push { status: 'fail', message: "pool #{name} missing" }
|
|
38
|
+
return results
|
|
39
|
+
results.push { status: 'pass', message: "pool #{name} exists" }
|
|
40
|
+
|
|
41
|
+
if want = props.compression?[0]?.args?[0]
|
|
42
|
+
if want is (have = sh $"zfs get -H -o value compression #{name}")
|
|
43
|
+
results.push { status: 'pass', message: "#{name} compression is #{want}" }
|
|
44
|
+
else
|
|
45
|
+
results.push { status: 'warn', message: "#{name} compression is #{have}, expected #{want}" }
|
|
46
|
+
|
|
47
|
+
if want = props.atime?[0]?.args?[0]
|
|
48
|
+
if want is (have = sh $"zfs get -H -o value atime #{name}")
|
|
49
|
+
results.push { status: 'pass', message: "#{name} atime is #{want}" }
|
|
50
|
+
else
|
|
51
|
+
results.push { status: 'warn', message: "#{name} atime is #{have}, expected #{want}" }
|
|
52
|
+
|
|
53
|
+
results
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export name = "profile"
|
|
2
|
+
export description = "Ensures an Incus profile exists with declared configuration"
|
|
3
|
+
export positional = ["name"]
|
|
4
|
+
|
|
5
|
+
export check = (name, props) ->
|
|
6
|
+
return 'missing' unless ok $"incus profile show #{name}"
|
|
7
|
+
for own key, entries of props when key != 'args'
|
|
8
|
+
want = entries[0].args[0]
|
|
9
|
+
return 'drift' if want isnt ((run $"incus profile get #{name} #{key}").stdout or '')
|
|
10
|
+
'ok'
|
|
11
|
+
|
|
12
|
+
export apply = (name, props) ->
|
|
13
|
+
sh $"incus profile create #{name}" unless ok $"incus profile show #{name}"
|
|
14
|
+
for own key, entries of props when key != 'args'
|
|
15
|
+
sh $"incus profile set #{name} #{key} #{entries[0].args[0]}"
|
|
16
|
+
|
|
17
|
+
export verify = (name, props) ->
|
|
18
|
+
results = []
|
|
19
|
+
unless ok $"incus profile show #{name}"
|
|
20
|
+
results.push { status: 'fail', message: "profile #{name} missing" }
|
|
21
|
+
return results
|
|
22
|
+
results.push { status: 'pass', message: "profile #{name} exists" }
|
|
23
|
+
for own key, entries of props when key != 'args'
|
|
24
|
+
want = entries[0].args[0]
|
|
25
|
+
if want is (have = (run $"incus profile get #{name} #{key}").stdout or '')
|
|
26
|
+
results.push { status: 'pass', message: "#{name} #{key} is #{want}" }
|
|
27
|
+
else
|
|
28
|
+
results.push { status: 'fail', message: "#{name} #{key} is #{have}, expected #{want}" }
|
|
29
|
+
results
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export name = "service"
|
|
2
|
+
export description = "Ensures a systemd service is enabled and running"
|
|
3
|
+
export positional = ["name"]
|
|
4
|
+
export properties =
|
|
5
|
+
enabled: { description: "Whether the service should be enabled (default: true)" }
|
|
6
|
+
running: { description: "Whether the service should be running (default: true)" }
|
|
7
|
+
|
|
8
|
+
export check = (name, props) ->
|
|
9
|
+
wantEnabled = props.enabled?[0]?.args?[0] != 'false'
|
|
10
|
+
wantRunning = props.running?[0]?.args?[0] != 'false'
|
|
11
|
+
|
|
12
|
+
isEnabled = ok $"systemctl is-enabled #{name}"
|
|
13
|
+
isRunning = ok $"systemctl is-active #{name}"
|
|
14
|
+
|
|
15
|
+
return 'drift' if wantEnabled isnt isEnabled
|
|
16
|
+
return 'drift' if wantRunning isnt isRunning
|
|
17
|
+
|
|
18
|
+
'ok'
|
|
19
|
+
|
|
20
|
+
export apply = (name, props) ->
|
|
21
|
+
wantEnabled = props.enabled?[0]?.args?[0] != 'false'
|
|
22
|
+
wantRunning = props.running?[0]?.args?[0] != 'false'
|
|
23
|
+
|
|
24
|
+
if wantEnabled
|
|
25
|
+
sh $"systemctl enable #{name}" unless ok $"systemctl is-enabled #{name}"
|
|
26
|
+
else
|
|
27
|
+
sh $"systemctl disable #{name}" if ok $"systemctl is-enabled #{name}"
|
|
28
|
+
|
|
29
|
+
if wantRunning
|
|
30
|
+
sh $"systemctl start #{name}" unless ok $"systemctl is-active #{name}"
|
|
31
|
+
else
|
|
32
|
+
sh $"systemctl stop #{name}" if ok $"systemctl is-active #{name}"
|
|
33
|
+
|
|
34
|
+
export verify = (name, props) ->
|
|
35
|
+
results = []
|
|
36
|
+
wantEnabled = props.enabled?[0]?.args?[0] != 'false'
|
|
37
|
+
wantRunning = props.running?[0]?.args?[0] != 'false'
|
|
38
|
+
|
|
39
|
+
isEnabled = ok $"systemctl is-enabled #{name}"
|
|
40
|
+
isRunning = ok $"systemctl is-active #{name}"
|
|
41
|
+
|
|
42
|
+
if wantEnabled
|
|
43
|
+
results.push { status: (if isEnabled then 'pass' else 'fail'), message: "#{name} #{if isEnabled then 'enabled' else 'not enabled'}" }
|
|
44
|
+
else
|
|
45
|
+
results.push { status: (if not isEnabled then 'pass' else 'fail'), message: "#{name} #{if isEnabled then 'should be disabled' else 'disabled'}" }
|
|
46
|
+
|
|
47
|
+
if wantRunning
|
|
48
|
+
results.push { status: (if isRunning then 'pass' else 'fail'), message: "#{name} #{if isRunning then 'running' else 'not running'}" }
|
|
49
|
+
else
|
|
50
|
+
results.push { status: (if not isRunning then 'pass' else 'fail'), message: "#{name} #{if isRunning then 'should be stopped' else 'stopped'}" }
|
|
51
|
+
|
|
52
|
+
results
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import * as fs from 'fs'
|
|
2
|
+
|
|
3
|
+
export name = "ssh"
|
|
4
|
+
export description = "Ensures the SSH daemon is configured"
|
|
5
|
+
export positional = []
|
|
6
|
+
export properties =
|
|
7
|
+
'password-auth': { description: "Enable password authentication (yes/no)" }
|
|
8
|
+
'permit-root-login': { description: "Root login policy (yes/no/prohibit-password)" }
|
|
9
|
+
'pubkey-auth': { description: "Enable pubkey authentication (yes/no)" }
|
|
10
|
+
|
|
11
|
+
CONF_PATH = '/etc/ssh/sshd_config.d/99-stamp.conf'
|
|
12
|
+
|
|
13
|
+
OPTION_MAP =
|
|
14
|
+
'password-auth': 'PasswordAuthentication'
|
|
15
|
+
'permit-root-login': 'PermitRootLogin'
|
|
16
|
+
'pubkey-auth': 'PubkeyAuthentication'
|
|
17
|
+
|
|
18
|
+
export check = (name, props) ->
|
|
19
|
+
out = run $"sshd -T"
|
|
20
|
+
return 'missing' unless out.ok
|
|
21
|
+
|
|
22
|
+
for own key, entries of props when key != 'args'
|
|
23
|
+
want = entries[0].args[0]
|
|
24
|
+
sshdKey = OPTION_MAP[key]?.toLowerCase()
|
|
25
|
+
continue unless sshdKey
|
|
26
|
+
match = out.stdout.match new RegExp "^#{sshdKey}\\s+(\\S+)", 'mi'
|
|
27
|
+
return 'drift' if match?[1] != want
|
|
28
|
+
|
|
29
|
+
'ok'
|
|
30
|
+
|
|
31
|
+
export apply = (name, props) ->
|
|
32
|
+
lines = ["# Managed by stamp"]
|
|
33
|
+
for own key, entries of props when key != 'args'
|
|
34
|
+
sshdKey = OPTION_MAP[key]
|
|
35
|
+
continue unless sshdKey
|
|
36
|
+
lines.push "#{sshdKey} #{entries[0].args[0]}"
|
|
37
|
+
|
|
38
|
+
fs.writeFileSync CONF_PATH, lines.join('\n') + '\n'
|
|
39
|
+
|
|
40
|
+
if ok $"systemctl is-active ssh"
|
|
41
|
+
sh $"systemctl reload ssh"
|
|
42
|
+
else if ok $"systemctl is-active sshd"
|
|
43
|
+
sh $"systemctl reload sshd"
|
|
44
|
+
|
|
45
|
+
export verify = (name, props) ->
|
|
46
|
+
results = []
|
|
47
|
+
out = run $"sshd -T"
|
|
48
|
+
|
|
49
|
+
unless out.ok
|
|
50
|
+
results.push { status: 'fail', message: "sshd not running or not installed" }
|
|
51
|
+
return results
|
|
52
|
+
|
|
53
|
+
for own key, entries of props when key != 'args'
|
|
54
|
+
want = entries[0].args[0]
|
|
55
|
+
sshdKey = OPTION_MAP[key]?.toLowerCase()
|
|
56
|
+
continue unless sshdKey
|
|
57
|
+
match = out.stdout.match new RegExp "^#{sshdKey}\\s+(\\S+)", 'mi'
|
|
58
|
+
actual = match?[1]
|
|
59
|
+
if actual == want
|
|
60
|
+
results.push { status: 'pass', message: "#{key} is #{want}" }
|
|
61
|
+
else
|
|
62
|
+
results.push { status: 'fail', message: "#{key} is #{actual or 'unset'}, expected #{want}" }
|
|
63
|
+
|
|
64
|
+
results
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export name = "user"
|
|
2
|
+
export description = "Ensures a system user exists on the host"
|
|
3
|
+
export positional = ["name", "uid:gid"]
|
|
4
|
+
export properties =
|
|
5
|
+
shell: { description: "User shell (default: /bin/bash)" }
|
|
6
|
+
groups: { description: "Supplementary group memberships" }
|
|
7
|
+
home: { description: "Home directory (default: /home/<name>)" }
|
|
8
|
+
|
|
9
|
+
export check = (name, props) ->
|
|
10
|
+
return 'missing' unless ok $"id -u #{name}"
|
|
11
|
+
|
|
12
|
+
if props.args?[0]
|
|
13
|
+
[wantUid, wantGid] = props.args[0].split ':'
|
|
14
|
+
return 'drift' if wantUid isnt sh $"id -u #{name}"
|
|
15
|
+
return 'drift' if wantGid isnt sh $"id -g #{name}"
|
|
16
|
+
|
|
17
|
+
'ok'
|
|
18
|
+
|
|
19
|
+
export apply = (name, props) ->
|
|
20
|
+
uidgid = props.args?[0]
|
|
21
|
+
shell = props.shell?[0]?.args?[0] or '/bin/bash'
|
|
22
|
+
home = props.home?[0]?.args?[0] or "/home/#{name}"
|
|
23
|
+
|
|
24
|
+
unless ok $"id -u #{name}"
|
|
25
|
+
if uidgid
|
|
26
|
+
[uid, gid] = uidgid.split ':'
|
|
27
|
+
sh $"groupadd -g #{gid} #{name}" unless ok $"getent group #{gid}"
|
|
28
|
+
sh $"useradd -m -u #{uid} -g #{gid} -s #{shell} -d #{home} #{name}"
|
|
29
|
+
else
|
|
30
|
+
sh $"useradd -m -s #{shell} -d #{home} #{name}"
|
|
31
|
+
|
|
32
|
+
if props.groups
|
|
33
|
+
groupList = props.groups[0].args.join ','
|
|
34
|
+
sh $"usermod -aG #{groupList} #{name}"
|
|
35
|
+
|
|
36
|
+
export verify = (name, props) ->
|
|
37
|
+
results = []
|
|
38
|
+
|
|
39
|
+
unless ok $"id -u #{name}"
|
|
40
|
+
results.push { status: 'fail', message: "user #{name} missing" }
|
|
41
|
+
return results
|
|
42
|
+
results.push { status: 'pass', message: "user #{name} exists" }
|
|
43
|
+
|
|
44
|
+
if props.args?[0]
|
|
45
|
+
[wantUid, wantGid] = props.args[0].split ':'
|
|
46
|
+
if wantUid is (haveUid = sh $"id -u #{name}")
|
|
47
|
+
results.push { status: 'pass', message: "#{name} uid is #{wantUid}" }
|
|
48
|
+
else
|
|
49
|
+
results.push { status: 'fail', message: "#{name} uid is #{haveUid}, expected #{wantUid}" }
|
|
50
|
+
if wantGid is (haveGid = sh $"id -g #{name}")
|
|
51
|
+
results.push { status: 'pass', message: "#{name} gid is #{wantGid}" }
|
|
52
|
+
else
|
|
53
|
+
results.push { status: 'fail', message: "#{name} gid is #{haveGid}, expected #{wantGid}" }
|
|
54
|
+
|
|
55
|
+
if want = props.shell?[0]?.args?[0]
|
|
56
|
+
if want is (have = (sh $"getent passwd #{name}").split(':')[6])
|
|
57
|
+
results.push { status: 'pass', message: "#{name} shell is #{want}" }
|
|
58
|
+
else
|
|
59
|
+
results.push { status: 'warn', message: "#{name} shell is #{have}, expected #{want}" }
|
|
60
|
+
|
|
61
|
+
results
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rip-lang/stamp",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Declarative host provisioning — no state file, no agent, no YAML",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/cli.rip",
|
|
7
|
+
"bin": {
|
|
8
|
+
"stamp": "./bin/stamp"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "bun --preload ../../rip-loader.js test/runner.rip"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"provisioning",
|
|
15
|
+
"declarative",
|
|
16
|
+
"infrastructure",
|
|
17
|
+
"idempotent",
|
|
18
|
+
"incus",
|
|
19
|
+
"zfs",
|
|
20
|
+
"containers",
|
|
21
|
+
"bun",
|
|
22
|
+
"rip",
|
|
23
|
+
"rip-lang"
|
|
24
|
+
],
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/shreeve/rip-lang.git",
|
|
28
|
+
"directory": "packages/stamp"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://github.com/shreeve/rip-lang/tree/main/packages/stamp#readme",
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/shreeve/rip-lang/issues"
|
|
33
|
+
},
|
|
34
|
+
"author": "Steve Shreeve <steve.shreeve@gmail.com>",
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"rip-lang": ">=3.13.102"
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"bin/",
|
|
41
|
+
"src/",
|
|
42
|
+
"directives/",
|
|
43
|
+
"README.md"
|
|
44
|
+
]
|
|
45
|
+
}
|
package/src/cli.rip
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import * as fs from 'fs'
|
|
2
|
+
import * as path from 'path'
|
|
3
|
+
import { parse } from './parser.rip'
|
|
4
|
+
import { execute } from './engine.rip'
|
|
5
|
+
|
|
6
|
+
VERSION = '0.1.0'
|
|
7
|
+
|
|
8
|
+
# ── Default file resolution ──────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
findStampfile = (dir = '.') ->
|
|
11
|
+
for name in ['Stampfile', 'Hostfile', 'Containerfile']
|
|
12
|
+
candidate = path.resolve dir, name
|
|
13
|
+
return candidate if fs.existsSync candidate
|
|
14
|
+
null
|
|
15
|
+
|
|
16
|
+
# ── CLI ──────────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
showHelp = ->
|
|
19
|
+
p """
|
|
20
|
+
stamp #{VERSION} — declarative host provisioning
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
stamp apply [file] Reconcile system to match Stampfile
|
|
24
|
+
stamp verify [file] Check current state, report PASS/WARN/FAIL
|
|
25
|
+
stamp plan [file] Dry-run: show what apply would do
|
|
26
|
+
|
|
27
|
+
stamp list Show all available directives
|
|
28
|
+
stamp info <directive> Show a directive's syntax and properties
|
|
29
|
+
|
|
30
|
+
stamp version Print stamp version
|
|
31
|
+
stamp help Show this help
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
showVersion = ->
|
|
35
|
+
p "stamp #{VERSION}"
|
|
36
|
+
|
|
37
|
+
# ── Subcommands ──────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
runMode = (mode, file) ->
|
|
40
|
+
stampfile = file or findStampfile!
|
|
41
|
+
|
|
42
|
+
unless stampfile
|
|
43
|
+
warn "No Stampfile found. Provide a file or create Stampfile, Hostfile, or Containerfile."
|
|
44
|
+
exit 2
|
|
45
|
+
|
|
46
|
+
unless fs.existsSync stampfile
|
|
47
|
+
warn "File not found: #{stampfile}"
|
|
48
|
+
exit 2
|
|
49
|
+
|
|
50
|
+
source = fs.readFileSync stampfile, 'utf-8'
|
|
51
|
+
stampDir = path.dirname path.resolve stampfile
|
|
52
|
+
parsed = parse source, process.env
|
|
53
|
+
|
|
54
|
+
try
|
|
55
|
+
result = execute! mode, parsed, stampDir
|
|
56
|
+
|
|
57
|
+
if mode == 'verify'
|
|
58
|
+
exit 1 if result.fail > 0
|
|
59
|
+
else if mode == 'plan'
|
|
60
|
+
exit 1 if result > 0
|
|
61
|
+
catch err
|
|
62
|
+
warn "\n Error: #{err.message}"
|
|
63
|
+
exit 1
|
|
64
|
+
|
|
65
|
+
listDirectives = ->
|
|
66
|
+
builtins = ['brew', 'packages', 'pool', 'dataset', 'profile', 'container', 'incus', 'multipass', 'user', 'group', 'firewall', 'ssh', 'service']
|
|
67
|
+
p "\nBuilt-in directives:\n"
|
|
68
|
+
for name in builtins
|
|
69
|
+
p " #{name}"
|
|
70
|
+
p ""
|
|
71
|
+
|
|
72
|
+
infoDirective = (name) ->
|
|
73
|
+
unless name
|
|
74
|
+
warn "Usage: stamp info <directive>"
|
|
75
|
+
exit 2
|
|
76
|
+
|
|
77
|
+
dirPath = path.join import.meta.dir, '..', 'directives', "#{name}.rip"
|
|
78
|
+
unless fs.existsSync dirPath
|
|
79
|
+
warn "Unknown directive: #{name}"
|
|
80
|
+
exit 2
|
|
81
|
+
|
|
82
|
+
try
|
|
83
|
+
mod = import! dirPath
|
|
84
|
+
p "\n#{mod.name or name}"
|
|
85
|
+
p " #{mod.description}" if mod.description
|
|
86
|
+
if mod.positional
|
|
87
|
+
p " Positional: #{mod.positional.join ', '}"
|
|
88
|
+
if mod.properties
|
|
89
|
+
p " Properties:"
|
|
90
|
+
for own k, v of mod.properties
|
|
91
|
+
flags = []
|
|
92
|
+
flags.push 'multi' if v.multi
|
|
93
|
+
flags.push 'arrow' if v.arrow
|
|
94
|
+
desc = v.description or ''
|
|
95
|
+
flagStr = if flags.length then " (#{flags.join ', '})" else ''
|
|
96
|
+
p " #{k}#{flagStr} — #{desc}"
|
|
97
|
+
p ""
|
|
98
|
+
catch err
|
|
99
|
+
warn "Failed to load directive '#{name}': #{err.message}"
|
|
100
|
+
exit 1
|
|
101
|
+
|
|
102
|
+
# ── Main ─────────────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
do ->
|
|
105
|
+
args = process.argv[2..]
|
|
106
|
+
command = args[0]
|
|
107
|
+
rest = args[1..]
|
|
108
|
+
|
|
109
|
+
switch command
|
|
110
|
+
when 'apply' then runMode 'apply', rest[0]
|
|
111
|
+
when 'verify' then runMode 'verify', rest[0]
|
|
112
|
+
when 'plan' then runMode 'plan', rest[0]
|
|
113
|
+
when 'list' then listDirectives!
|
|
114
|
+
when 'info' then infoDirective! rest[0]
|
|
115
|
+
when 'version', '-v' then showVersion!
|
|
116
|
+
when 'help', '-h' then showHelp!
|
|
117
|
+
when undefined then showHelp!
|
|
118
|
+
else
|
|
119
|
+
# If the first arg is a file path, treat it as `stamp apply <file>`
|
|
120
|
+
if fs.existsSync command
|
|
121
|
+
runMode 'apply', command
|
|
122
|
+
else
|
|
123
|
+
warn "Unknown command: #{command}"
|
|
124
|
+
showHelp!
|
|
125
|
+
exit 2
|
package/src/engine.rip
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import * as fs from 'fs'
|
|
2
|
+
import * as path from 'path'
|
|
3
|
+
import { pathToFileURL } from 'url'
|
|
4
|
+
import { sh, ok, run, log } from './helpers.rip'
|
|
5
|
+
|
|
6
|
+
globalThis.sh ??= sh
|
|
7
|
+
globalThis.ok ??= ok
|
|
8
|
+
globalThis.run ??= run
|
|
9
|
+
|
|
10
|
+
# ── Handler resolution ───────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
STAMP_HOME = process.env.STAMP_HOME or path.join(process.env.HOME or '', '.stamp')
|
|
13
|
+
|
|
14
|
+
resolveHandler = (type, stampDir) ->
|
|
15
|
+
candidates = [
|
|
16
|
+
path.join(stampDir, '..', 'directives', "#{type}.rip") # Built-in
|
|
17
|
+
path.join(stampDir, 'directives', "#{type}.rip") # Local (beside Stampfile)
|
|
18
|
+
path.join(stampDir, 'directives', "#{type}.js")
|
|
19
|
+
path.join(STAMP_HOME, 'directives', "#{type}.rip") # Installed
|
|
20
|
+
path.join(STAMP_HOME, 'directives', "#{type}.js")
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
for candidate in candidates
|
|
24
|
+
if fs.existsSync candidate
|
|
25
|
+
return import! pathToFileURL(candidate).href
|
|
26
|
+
|
|
27
|
+
# Try npm resolution
|
|
28
|
+
for pkg in ["@stamp/#{type}", "stamp-#{type}"]
|
|
29
|
+
try
|
|
30
|
+
return import! pkg
|
|
31
|
+
catch
|
|
32
|
+
continue
|
|
33
|
+
|
|
34
|
+
null
|
|
35
|
+
|
|
36
|
+
resolveHandlers = (directives, uses, stampDir) ->
|
|
37
|
+
handlers = {}
|
|
38
|
+
|
|
39
|
+
# Pre-resolve `use` declared handlers
|
|
40
|
+
for source in uses
|
|
41
|
+
if source.startsWith('http://') or source.startsWith('https://')
|
|
42
|
+
cacheDir = path.join STAMP_HOME, 'cache'
|
|
43
|
+
fs.mkdirSync cacheDir, { recursive: true } unless fs.existsSync cacheDir
|
|
44
|
+
cacheName = source.replace(/[^a-zA-Z0-9.-]/g, '_')
|
|
45
|
+
cachePath = path.join cacheDir, cacheName
|
|
46
|
+
|
|
47
|
+
unless fs.existsSync cachePath
|
|
48
|
+
log.info "Fetching #{source}..."
|
|
49
|
+
response = fetch! source
|
|
50
|
+
unless response.ok
|
|
51
|
+
throw new Error "Failed to fetch #{source}: #{response.status}"
|
|
52
|
+
text = response.text!
|
|
53
|
+
fs.writeFileSync cachePath, text
|
|
54
|
+
handlers[path.basename(source, path.extname(source))] = import! pathToFileURL(cachePath).href
|
|
55
|
+
else
|
|
56
|
+
mod = import! source
|
|
57
|
+
name = mod.name or path.basename(source, path.extname(source))
|
|
58
|
+
handlers[name] = mod
|
|
59
|
+
|
|
60
|
+
# Resolve handlers for each directive type
|
|
61
|
+
for dir in directives
|
|
62
|
+
unless handlers[dir.type]
|
|
63
|
+
handler = resolveHandler! dir.type, stampDir
|
|
64
|
+
unless handler
|
|
65
|
+
throw new Error "Unknown directive '#{dir.type}' (line #{dir.line or '?'})"
|
|
66
|
+
handlers[dir.type] = handler
|
|
67
|
+
|
|
68
|
+
handlers
|
|
69
|
+
|
|
70
|
+
# ── Execution modes ──────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
propsFor = (dir) ->
|
|
73
|
+
props = { args: dir.args ?? [], ...dir.properties }
|
|
74
|
+
props
|
|
75
|
+
|
|
76
|
+
export apply = (directives, handlers) ->
|
|
77
|
+
applied = 0
|
|
78
|
+
total = directives.length
|
|
79
|
+
|
|
80
|
+
for dir in directives
|
|
81
|
+
handler = handlers[dir.type]
|
|
82
|
+
props = propsFor dir
|
|
83
|
+
result = handler.check! dir.name, props
|
|
84
|
+
|
|
85
|
+
if result == 'ok'
|
|
86
|
+
log.ok dir.type, dir.name
|
|
87
|
+
else
|
|
88
|
+
log.apply dir.type, dir.name
|
|
89
|
+
handler.apply! dir.name, props
|
|
90
|
+
|
|
91
|
+
after = handler.check! dir.name, props
|
|
92
|
+
if after != 'ok'
|
|
93
|
+
log.fail dir.type, dir.name, "still not ok after apply"
|
|
94
|
+
throw new Error "Directive '#{dir.type}#{if dir.name then ' ' + dir.name else ''}' failed post-apply check"
|
|
95
|
+
applied++
|
|
96
|
+
|
|
97
|
+
p "\nApplied #{applied} of #{total} directives."
|
|
98
|
+
applied
|
|
99
|
+
|
|
100
|
+
export verify = (directives, handlers) ->
|
|
101
|
+
counts = { pass: 0, warn: 0, fail: 0 }
|
|
102
|
+
|
|
103
|
+
for dir in directives
|
|
104
|
+
handler = handlers[dir.type]
|
|
105
|
+
props = propsFor dir
|
|
106
|
+
results = handler.verify! dir.name, props
|
|
107
|
+
|
|
108
|
+
for result in results
|
|
109
|
+
switch result.status
|
|
110
|
+
when 'pass' then log.pass "#{dir.type}: #{result.message}"; counts.pass++
|
|
111
|
+
when 'warn' then log.warn "#{dir.type}: #{result.message}"; counts.warn++
|
|
112
|
+
when 'fail' then log.error "#{dir.type}: #{result.message}"; counts.fail++
|
|
113
|
+
|
|
114
|
+
p "\nPASS=#{counts.pass} WARN=#{counts.warn} FAIL=#{counts.fail}"
|
|
115
|
+
counts
|
|
116
|
+
|
|
117
|
+
export plan = (directives, handlers) ->
|
|
118
|
+
changes = 0
|
|
119
|
+
|
|
120
|
+
for dir in directives
|
|
121
|
+
handler = handlers[dir.type]
|
|
122
|
+
props = propsFor dir
|
|
123
|
+
result = handler.check! dir.name, props
|
|
124
|
+
|
|
125
|
+
switch result
|
|
126
|
+
when 'ok' then log.ok dir.type, dir.name
|
|
127
|
+
when 'drift' then log.update dir.type, dir.name; changes++
|
|
128
|
+
when 'missing' then log.create dir.type, dir.name; changes++
|
|
129
|
+
|
|
130
|
+
if changes > 0
|
|
131
|
+
p "\n#{changes} change#{if changes > 1 then 's' else ''} would be applied."
|
|
132
|
+
else
|
|
133
|
+
p "\nNothing to do."
|
|
134
|
+
changes
|
|
135
|
+
|
|
136
|
+
# ── Main entry point ─────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
export execute = (mode, parsed, stampDir) ->
|
|
139
|
+
{ directives, uses } = parsed
|
|
140
|
+
handlers = resolveHandlers! directives, uses, stampDir
|
|
141
|
+
|
|
142
|
+
switch mode
|
|
143
|
+
when 'apply' then apply! directives, handlers
|
|
144
|
+
when 'verify' then verify! directives, handlers
|
|
145
|
+
when 'plan' then plan! directives, handlers
|
|
146
|
+
else throw new Error "Unknown mode: #{mode}"
|
package/src/helpers.rip
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# ── Shell execution helpers ──────────────────────────────────────────────────
|
|
2
|
+
#
|
|
3
|
+
# Each function works in two modes:
|
|
4
|
+
#
|
|
5
|
+
# String mode: sh "echo hello" → passes to sh -c (shell)
|
|
6
|
+
# Template mode: sh $"echo #{name}" → builds argv array (no shell)
|
|
7
|
+
#
|
|
8
|
+
# run(cmd) → { ok, stdout, stderr, code }
|
|
9
|
+
# sh(cmd) → stdout string; throws on non-zero exit
|
|
10
|
+
# ok(cmd) → boolean; true if exit code is 0
|
|
11
|
+
|
|
12
|
+
buildArgv = (strings, values) ->
|
|
13
|
+
argv = []
|
|
14
|
+
for s, i in strings
|
|
15
|
+
if s.length
|
|
16
|
+
if argv.length and not /^\s/.test s
|
|
17
|
+
parts = s.split /(\s+)/
|
|
18
|
+
argv[argv.length - 1] += parts[0]
|
|
19
|
+
for part in parts[1..]
|
|
20
|
+
argv.push part unless part == '' or /^\s+$/.test part
|
|
21
|
+
else
|
|
22
|
+
for word in s.split /\s+/
|
|
23
|
+
argv.push word unless word == ''
|
|
24
|
+
if i < values.length and values[i]?
|
|
25
|
+
val = String values[i]
|
|
26
|
+
if val.length
|
|
27
|
+
if s.length and not /\s$/.test s
|
|
28
|
+
argv[argv.length - 1] += val if argv.length
|
|
29
|
+
argv.push val unless argv.length
|
|
30
|
+
else
|
|
31
|
+
argv.push val
|
|
32
|
+
argv
|
|
33
|
+
|
|
34
|
+
toArgv = (args) ->
|
|
35
|
+
first = args[0]
|
|
36
|
+
if kind(first) == 'array'
|
|
37
|
+
if first.raw then buildArgv(first, args[1..]) else first
|
|
38
|
+
else
|
|
39
|
+
['sh', '-c', first]
|
|
40
|
+
|
|
41
|
+
export run = (...args) ->
|
|
42
|
+
try
|
|
43
|
+
out = Bun.spawnSync toArgv(args), { stdout: 'pipe', stderr: 'pipe' }
|
|
44
|
+
{
|
|
45
|
+
ok: out.exitCode == 0
|
|
46
|
+
stdout: out.stdout.toString().trim()
|
|
47
|
+
stderr: out.stderr.toString().trim()
|
|
48
|
+
code: out.exitCode
|
|
49
|
+
}
|
|
50
|
+
catch e
|
|
51
|
+
{ ok: false, stdout: '', stderr: e.message, code: 1 }
|
|
52
|
+
|
|
53
|
+
export sh = (...args) ->
|
|
54
|
+
out = run ...args
|
|
55
|
+
unless out.ok
|
|
56
|
+
cmd = toArgv(args).join ' '
|
|
57
|
+
raise "Command failed (exit #{out.code}): #{cmd}\n#{out.stderr}"
|
|
58
|
+
out.stdout
|
|
59
|
+
|
|
60
|
+
export ok = (...args) ->
|
|
61
|
+
(run ...args).ok
|
|
62
|
+
|
|
63
|
+
# ── Logging ──────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
RESET = "\x1b[0m"
|
|
66
|
+
BOLD = "\x1b[1m"
|
|
67
|
+
DIM = "\x1b[2m"
|
|
68
|
+
GREEN = "\x1b[32m"
|
|
69
|
+
YELLOW = "\x1b[33m"
|
|
70
|
+
RED = "\x1b[31m"
|
|
71
|
+
CYAN = "\x1b[36m"
|
|
72
|
+
|
|
73
|
+
export log =
|
|
74
|
+
ok: (type, name) -> p " #{GREEN}ok#{RESET} #{type}#{if name then ' ' + name else ''}"
|
|
75
|
+
apply: (type, name) -> p " #{CYAN}apply#{RESET} #{type}#{if name then ' ' + name else ''}"
|
|
76
|
+
create: (type, name) -> p " #{CYAN}create#{RESET} #{type}#{if name then ' ' + name else ''}"
|
|
77
|
+
update: (type, name) -> p " #{YELLOW}update#{RESET} #{type}#{if name then ' ' + name else ''}"
|
|
78
|
+
fail: (type, name, msg) -> p " #{RED}FAIL#{RESET} #{type}#{if name then ' ' + name else ''}#{if msg then ' — ' + msg else ''}"
|
|
79
|
+
pass: (msg) -> p " #{GREEN}PASS#{RESET} #{msg}"
|
|
80
|
+
warn: (msg) -> p " #{YELLOW}WARN#{RESET} #{msg}"
|
|
81
|
+
error: (msg) -> p " #{RED}FAIL#{RESET} #{msg}"
|
|
82
|
+
info: (msg) -> p " #{DIM}#{msg}#{RESET}"
|
|
83
|
+
header: (msg) -> p "\n#{BOLD}#{msg}#{RESET}"
|