@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.
@@ -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}"
@@ -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}"