@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 ADDED
@@ -0,0 +1,166 @@
1
+ <img src="https://raw.githubusercontent.com/shreeve/rip-lang/main/docs/assets/rip.png" style="width:50px" /> <br>
2
+
3
+ # Stamp - @rip-lang/stamp
4
+
5
+ **Declarative host provisioning — no state file, no agent, no YAML.**
6
+
7
+ Stamp reads a Stampfile, resolves each directive to a handler, and reconciles
8
+ the declared state against reality using three operations: **check**, **apply**,
9
+ and **verify**.
10
+
11
+ ```
12
+ stamp apply Hostfile
13
+ ```
14
+
15
+ ---
16
+
17
+ ## Quick Start
18
+
19
+ ```bash
20
+ # Preview changes (read-only)
21
+ stamp plan Hostfile
22
+
23
+ # Apply the declared state
24
+ stamp apply Hostfile
25
+
26
+ # Audit current state
27
+ stamp verify Hostfile
28
+ ```
29
+
30
+ ## Stampfile Syntax
31
+
32
+ A Stampfile declares the desired state of a system. Each top-level entry
33
+ names a resource using a **directive**. Indented lines below it describe
34
+ properties.
35
+
36
+ ```
37
+ packages curl git jq zfsutils-linux
38
+
39
+ pool tank /dev/sdb
40
+ compression zstd
41
+ atime off
42
+
43
+ container web ubuntu/24.04
44
+ profile trusted
45
+ disk data /tank/web -> /data
46
+ start
47
+
48
+ firewall
49
+ default deny incoming
50
+ default allow outgoing
51
+ allow ssh
52
+ allow 443/tcp
53
+ ```
54
+
55
+ ### Three levels of expression
56
+
57
+ - **Inline** — single line: `packages curl git jq`
58
+ - **Block** — directive + indented properties
59
+ - **Expanded** — property with its own nested sub-block
60
+
61
+ ### Variables
62
+
63
+ ```
64
+ set POOL tank
65
+ set DEVICE /dev/sdb
66
+
67
+ pool $POOL $DEVICE
68
+ ```
69
+
70
+ ### The `->` operator
71
+
72
+ Source-to-destination mapping:
73
+
74
+ ```
75
+ disk data /tank/web -> /data
76
+ disk logs /tank/logs -> /var/log readonly
77
+ ```
78
+
79
+ ### Grouped directives
80
+
81
+ Plural forms expand to individual directives:
82
+
83
+ ```
84
+ datasets
85
+ tank/home
86
+ tank/shared
87
+ owner 1001:1001
88
+ mode 2775
89
+ ```
90
+
91
+ ## Built-in Directives
92
+
93
+ | Directive | Purpose |
94
+ |-----------|---------|
95
+ | `brew` | Homebrew packages (macOS) |
96
+ | `packages` | System packages (apt-get) |
97
+ | `pool` | ZFS pool creation |
98
+ | `dataset` | ZFS dataset with ownership/permissions |
99
+ | `profile` | Incus profile configuration |
100
+ | `container` | Incus container management |
101
+ | `incus` | Incus daemon initialization |
102
+ | `multipass` | Multipass virtual machines |
103
+ | `user` | System user management |
104
+ | `group` | System group management |
105
+ | `firewall` | ufw firewall rules |
106
+ | `ssh` | SSH daemon configuration |
107
+ | `service` | systemd service management |
108
+
109
+ ## Handler Contract
110
+
111
+ Every directive handler exports three async functions:
112
+
113
+ - **check(name, props)** — returns `"ok"`, `"drift"`, or `"missing"`
114
+ - **apply(name, props)** — make reality match the declaration
115
+ - **verify(name, props)** — return `[{ status, message }]` results
116
+
117
+ ## Plugin System
118
+
119
+ Handlers resolve in this order:
120
+
121
+ 1. Built-in (`directives/`)
122
+ 2. Local (`./directives/` beside Stampfile)
123
+ 3. Installed (`~/.stamp/directives/`)
124
+ 4. npm (`@stamp/<name>` or `stamp-<name>`)
125
+ 5. Remote (via `use` directive)
126
+
127
+ ### Writing a directive
128
+
129
+ ```coffee
130
+ import { sh, ok, run } from "@rip-lang/stamp/helpers"
131
+
132
+ export name = "mydirective"
133
+ export description = "What it does"
134
+
135
+ export check = (name, props) ->
136
+ return 'missing' unless ok "some-check"
137
+ 'ok'
138
+
139
+ export apply = (name, props) ->
140
+ sh "some-command"
141
+
142
+ export verify = (name, props) ->
143
+ [{ status: 'pass', message: 'looks good' }]
144
+ ```
145
+
146
+ ## CLI
147
+
148
+ ```
149
+ stamp apply [file] Reconcile system to match Stampfile
150
+ stamp verify [file] Check current state, report PASS/WARN/FAIL
151
+ stamp plan [file] Dry-run: show what apply would do
152
+ stamp list Show all available directives
153
+ stamp info <directive> Show a directive's syntax and properties
154
+ stamp version Print version
155
+ stamp help Show help
156
+ ```
157
+
158
+ Default file search: `Stampfile`, `Hostfile`, `Containerfile`.
159
+
160
+ ## Runtime
161
+
162
+ Bun + Rip. Zero dependencies beyond `rip-lang`.
163
+
164
+ ## License
165
+
166
+ MIT
package/bin/stamp ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { fileURLToPath } from 'url';
4
+ import { dirname, join } from 'path';
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ const loaderPath = join(__dirname, '..', '..', '..', 'rip-loader.js');
8
+
9
+ // Preload the Rip loader so .rip imports work
10
+ const { spawnSync } = await import('child_process');
11
+
12
+ const result = spawnSync('bun', [
13
+ '--preload', loaderPath,
14
+ join(__dirname, '..', 'src', 'cli.rip'),
15
+ ...process.argv.slice(2)
16
+ ], {
17
+ stdio: 'inherit',
18
+ env: { ...process.env, NODE_PATH: join(__dirname, '..', '..', '..') }
19
+ });
20
+
21
+ process.exit(result.status || 0);
@@ -0,0 +1,48 @@
1
+ export name = "brew"
2
+ export description = "Ensures Homebrew packages are installed (macOS)"
3
+ export positional = []
4
+
5
+ gather = (name, props) ->
6
+ list = [...(props.args or [])]
7
+ for own k, entries of props when k != 'args'
8
+ list.push k
9
+ for entry in entries
10
+ list.push ...entry.args if entry.args?.length
11
+ list
12
+
13
+ installed = (pkg) ->
14
+ ok $"brew list #{pkg}"
15
+
16
+ export check = (name, props) ->
17
+ return 'missing' unless ok $"which brew"
18
+ list = gather name, props
19
+ return 'ok' if list.length == 0
20
+ for pkg in list
21
+ return 'drift' unless installed pkg
22
+ 'ok'
23
+
24
+ export apply = (name, props) ->
25
+ unless ok $"which brew"
26
+ sh "/bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\""
27
+
28
+ list = gather name, props
29
+ return if list.length == 0
30
+ missing = (pkg for pkg in list when not installed pkg)
31
+ return if missing.length == 0
32
+ for pkg in missing
33
+ sh $"brew install #{pkg}"
34
+
35
+ export verify = (name, props) ->
36
+ results = []
37
+ unless ok $"which brew"
38
+ results.push { status: 'fail', message: "homebrew not installed" }
39
+ return results
40
+ results.push { status: 'pass', message: "homebrew installed" }
41
+
42
+ list = gather name, props
43
+ for pkg in list
44
+ if installed pkg
45
+ results.push { status: 'pass', message: "#{pkg} installed" }
46
+ else
47
+ results.push { status: 'fail', message: "#{pkg} not installed" }
48
+ results
@@ -0,0 +1,106 @@
1
+ export name = "container"
2
+ export description = "Manages Incus containers"
3
+ export positional = ["name", "image"]
4
+ export properties =
5
+ profile: { description: "Attach an Incus profile", multi: true }
6
+ disk: { description: "Mount a host path into the container", arrow: true, multi: true }
7
+ user: { description: "Create a user inside the container", multi: true }
8
+ start: { description: "Ensure container is running" }
9
+
10
+ isRunning = (name) ->
11
+ out = run $"incus info #{name}"
12
+ out.ok and out.stdout.toLowerCase().includes 'status: running'
13
+
14
+ export check = (name, props) ->
15
+ return 'missing' unless ok $"incus info #{name}"
16
+
17
+ if props.profile
18
+ current = sh $"incus config show #{name}"
19
+ for entry in props.profile
20
+ return 'drift' unless current.includes entry.args[0]
21
+
22
+ if props.disk
23
+ for entry in props.disk
24
+ return 'drift' unless ok $"incus config device get #{name} #{entry.args[0]} source"
25
+
26
+ return 'drift' if props.start and not isRunning name
27
+
28
+ 'ok'
29
+
30
+ export apply = (name, props) ->
31
+ image = props.args?[0]
32
+
33
+ unless ok $"incus info #{name}"
34
+ raise "container '#{name}' requires an image argument" unless image
35
+ sh $"incus init #{image} #{name}"
36
+
37
+ if props.profile
38
+ current = sh $"incus config show #{name}"
39
+ for entry in props.profile
40
+ sh $"incus profile add #{name} #{entry.args[0]}" unless current.includes entry.args[0]
41
+
42
+ if props.disk
43
+ for entry in props.disk
44
+ devname = entry.args[0]
45
+ unless ok $"incus config device get #{name} #{devname} source"
46
+ ro = 'readonly=true' if 'readonly' in (entry.flags or [])
47
+ sh $"incus config device add #{name} #{devname} disk source=#{entry.source} path=#{entry.dest} #{ro}"
48
+
49
+ if props.start and not isRunning name
50
+ sh $"incus start #{name}"
51
+
52
+ attempts = 30
53
+ while attempts > 0
54
+ break if ok $"incus exec #{name} -- true"
55
+ Bun.sleepSync 1000
56
+ attempts--
57
+ raise "Container #{name} not ready after 30 seconds" if attempts == 0
58
+
59
+ if props.user and isRunning name
60
+ for entry in props.user
61
+ username = entry.args[0]
62
+ uidgid = entry.args[1]
63
+ unless ok $"incus exec #{name} -- id #{username}"
64
+ [uid, gid] = uidgid.split ':'
65
+ ok $"incus exec #{name} -- groupadd -g #{gid} #{username}" unless ok $"incus exec #{name} -- getent group #{gid}"
66
+ sh $"incus exec #{name} -- useradd -m -u #{uid} -g #{gid} #{username}"
67
+
68
+ export verify = (name, props) ->
69
+ results = []
70
+
71
+ unless ok $"incus info #{name}"
72
+ results.push { status: 'fail', message: "container #{name} missing" }
73
+ return results
74
+ results.push { status: 'pass', message: "container #{name} exists" }
75
+
76
+ if isRunning name
77
+ results.push { status: 'pass', message: "#{name} running" }
78
+ else
79
+ results.push { status: 'fail', message: "#{name} not running" }
80
+
81
+ if props.profile
82
+ current = sh $"incus config show #{name}"
83
+ for entry in props.profile
84
+ want = entry.args[0]
85
+ if current.includes want
86
+ results.push { status: 'pass', message: "#{name} profile #{want} attached" }
87
+ else
88
+ results.push { status: 'fail', message: "#{name} profile #{want} not attached" }
89
+
90
+ if props.disk
91
+ for entry in props.disk
92
+ devname = entry.args[0]
93
+ if ok $"incus config device get #{name} #{devname} source"
94
+ results.push { status: 'pass', message: "#{name} disk #{devname} present" }
95
+ else
96
+ results.push { status: 'fail', message: "#{name} disk #{devname} missing" }
97
+
98
+ if props.user and isRunning name
99
+ for entry in props.user
100
+ username = entry.args[0]
101
+ if ok $"incus exec #{name} -- id #{username}"
102
+ results.push { status: 'pass', message: "#{name} user #{username} exists" }
103
+ else
104
+ results.push { status: 'fail', message: "#{name} user #{username} missing" }
105
+
106
+ results
@@ -0,0 +1,40 @@
1
+ export name = "dataset"
2
+ export description = "Ensures a ZFS dataset exists with correct ownership and permissions"
3
+ export positional = ["path"]
4
+ export properties =
5
+ owner: { description: "Ownership as uid:gid" }
6
+ mode: { description: "Permission mode (octal)" }
7
+
8
+ export check = (path, props) ->
9
+ return 'missing' unless ok $"zfs list #{path}"
10
+ mountpoint = sh $"zfs get -H -o value mountpoint #{path}"
11
+ if want = props.owner?[0]?.args?[0]
12
+ return 'drift' if want isnt sh $"stat -c %u:%g #{mountpoint}"
13
+ if want = props.mode?[0]?.args?[0]
14
+ return 'drift' if want isnt sh $"stat -c %a #{mountpoint}"
15
+ 'ok'
16
+
17
+ export apply = (path, props) ->
18
+ sh $"zfs create #{path}" unless ok $"zfs list #{path}"
19
+ mountpoint = sh $"zfs get -H -o value mountpoint #{path}"
20
+ sh $"chown #{props.owner[0].args[0]} #{mountpoint}" if props.owner
21
+ sh $"chmod #{props.mode[0].args[0]} #{mountpoint}" if props.mode
22
+
23
+ export verify = (path, props) ->
24
+ results = []
25
+ unless ok $"zfs list #{path}"
26
+ results.push { status: 'fail', message: "dataset missing: #{path}" }
27
+ return results
28
+ results.push { status: 'pass', message: "dataset exists: #{path}" }
29
+ mountpoint = sh $"zfs get -H -o value mountpoint #{path}"
30
+ if want = props.owner?[0]?.args?[0]
31
+ if want is (have = sh $"stat -c %u:%g #{mountpoint}")
32
+ results.push { status: 'pass', message: "#{path} owner is #{want}" }
33
+ else
34
+ results.push { status: 'fail', message: "#{path} owner is #{have}, expected #{want}" }
35
+ if want = props.mode?[0]?.args?[0]
36
+ if want is (have = sh $"stat -c %a #{mountpoint}")
37
+ results.push { status: 'pass', message: "#{path} mode is #{want}" }
38
+ else
39
+ results.push { status: 'fail', message: "#{path} mode is #{have}, expected #{want}" }
40
+ results
@@ -0,0 +1,78 @@
1
+ export name = "firewall"
2
+ export description = "Ensures ufw firewall rules are configured"
3
+ export positional = []
4
+ export properties =
5
+ default: { description: "Default policy: deny/allow incoming/outgoing", multi: true }
6
+ allow: { description: "Allow rule", multi: true }
7
+ deny: { description: "Deny rule", multi: true }
8
+
9
+ ufwActive = ->
10
+ out = run $"ufw status"
11
+ out.ok and out.stdout.includes 'Status: active'
12
+
13
+ export check = (name, props) ->
14
+ return 'missing' unless ok $"which ufw"
15
+ return 'drift' unless ufwActive()
16
+
17
+ if props.default
18
+ status = sh $"ufw status verbose"
19
+ for entry in props.default
20
+ [action, direction] = entry.args
21
+ return 'drift' unless status.includes action
22
+
23
+ if props.allow
24
+ status = sh $"ufw status"
25
+ for entry in props.allow
26
+ rule = entry.args.join ' '
27
+ return 'drift' unless status.toUpperCase().includes rule.toUpperCase()
28
+
29
+ 'ok'
30
+
31
+ export apply = (name, props) ->
32
+ unless ok $"which ufw"
33
+ sh $"apt-get update -qq"
34
+ sh $"apt-get install -y -qq ufw"
35
+
36
+ if props.default
37
+ for entry in props.default
38
+ [action, direction] = entry.args
39
+ sh $"ufw default #{action} #{direction}"
40
+
41
+ if props.allow
42
+ for entry in props.allow
43
+ sh $"ufw allow #{entry.args.join ' '}"
44
+
45
+ if props.deny
46
+ for entry in props.deny
47
+ sh $"ufw deny #{entry.args.join ' '}"
48
+
49
+ unless ufwActive()
50
+ sh $"ufw --force enable"
51
+
52
+ export verify = (name, props) ->
53
+ results = []
54
+
55
+ unless ok $"which ufw"
56
+ results.push { status: 'fail', message: "ufw not installed" }
57
+ return results
58
+
59
+ if ufwActive()
60
+ results.push { status: 'pass', message: "ufw active" }
61
+ else
62
+ results.push { status: 'fail', message: "ufw not active" }
63
+
64
+ if props.default
65
+ status = sh $"ufw status verbose"
66
+ for entry in props.default
67
+ [action, direction] = entry.args
68
+ if status.includes action
69
+ results.push { status: 'pass', message: "default #{action} #{direction}" }
70
+ else
71
+ results.push { status: 'fail', message: "default #{action} #{direction} not set" }
72
+
73
+ if props.allow
74
+ for entry in props.allow
75
+ rule = entry.args.join ' '
76
+ results.push { status: 'pass', message: "allow #{rule}" }
77
+
78
+ results
@@ -0,0 +1,34 @@
1
+ export name = "group"
2
+ export description = "Ensures a system group exists on the host"
3
+ export positional = ["name", "gid"]
4
+
5
+ export check = (name, props) ->
6
+ return 'missing' unless ok $"getent group #{name}"
7
+
8
+ if want = props.args?[0]
9
+ return 'drift' if want isnt (sh $"getent group #{name}").split(':')[2]
10
+
11
+ 'ok'
12
+
13
+ export apply = (name, props) ->
14
+ gid = props.args?[0]
15
+ unless ok $"getent group #{name}"
16
+ if gid
17
+ sh $"groupadd -g #{gid} #{name}"
18
+ else
19
+ sh $"groupadd #{name}"
20
+
21
+ export verify = (name, props) ->
22
+ results = []
23
+ unless ok $"getent group #{name}"
24
+ results.push { status: 'fail', message: "group #{name} missing" }
25
+ return results
26
+ results.push { status: 'pass', message: "group #{name} exists" }
27
+
28
+ if want = props.args?[0]
29
+ if want is (have = (sh $"getent group #{name}").split(':')[2])
30
+ results.push { status: 'pass', message: "#{name} gid is #{want}" }
31
+ else
32
+ results.push { status: 'fail', message: "#{name} gid is #{have}, expected #{want}" }
33
+
34
+ results
@@ -0,0 +1,111 @@
1
+ export name = "incus"
2
+ export description = "Ensures the Incus daemon is initialized with network and storage"
3
+ export positional = []
4
+ export properties =
5
+ storage: { description: "Storage pool: <name> <driver> <source>" }
6
+ network: { description: "Network bridge name" }
7
+
8
+ export check = (name, props) ->
9
+ return 'missing' unless ok $"incus info"
10
+
11
+ if props.storage
12
+ poolName = props.storage[0].args[0]
13
+ return 'drift' unless ok $"incus storage show #{poolName}"
14
+
15
+ if props.network
16
+ bridge = props.network[0].args[0]
17
+ return 'drift' unless ok $"incus network show #{bridge}"
18
+
19
+ 'ok'
20
+
21
+ export apply = (name, props) ->
22
+ unless ok $"systemctl is-active incus"
23
+ sh $"systemctl enable --now incus"
24
+
25
+ unless ok $"incus info"
26
+ storageEntry = props.storage?[0]
27
+ networkEntry = props.network?[0]
28
+
29
+ networkYaml = if networkEntry
30
+ bridge = networkEntry.args[0]
31
+ """
32
+ networks:
33
+ - config:
34
+ ipv4.address: auto
35
+ ipv4.nat: "true"
36
+ ipv6.address: none
37
+ description: ""
38
+ name: #{bridge}
39
+ type: bridge
40
+ """
41
+ else
42
+ "networks: []"
43
+
44
+ storageYaml = if storageEntry
45
+ [poolName, driver, source] = storageEntry.args
46
+ """
47
+ storage_pools:
48
+ - config:
49
+ source: #{source}
50
+ description: ""
51
+ driver: #{driver}
52
+ name: #{poolName}
53
+ """
54
+ else
55
+ "storage_pools: []"
56
+
57
+ defaultPool = storageEntry?.args?[0] or 'default'
58
+
59
+ preseed = """
60
+ config: {}
61
+ #{networkYaml}
62
+ #{storageYaml}
63
+ profiles:
64
+ - config: {}
65
+ description: Default Incus profile
66
+ devices:
67
+ root:
68
+ path: /
69
+ pool: #{defaultPool}
70
+ type: disk
71
+ name: default
72
+ """
73
+
74
+ out = Bun.spawnSync ['incus', 'admin', 'init', '--preseed'], { stdin: Buffer.from(preseed), stdout: 'pipe', stderr: 'pipe' }
75
+ unless out.exitCode == 0
76
+ raise "incus admin init failed: #{out.stderr.toString().trim()}"
77
+ else
78
+ if props.storage
79
+ poolName = props.storage[0].args[0]
80
+ unless ok $"incus storage show #{poolName}"
81
+ raise "Incus already initialized but storage pool '#{poolName}' does not exist"
82
+
83
+ if props.network
84
+ bridge = props.network[0].args[0]
85
+ unless ok $"incus network show #{bridge}"
86
+ raise "Incus already initialized but network '#{bridge}' does not exist"
87
+
88
+ export verify = (name, props) ->
89
+ results = []
90
+
91
+ if ok $"incus info"
92
+ results.push { status: 'pass', message: "incus daemon running" }
93
+ else
94
+ results.push { status: 'fail', message: "incus daemon not running" }
95
+ return results
96
+
97
+ if props.storage
98
+ poolName = props.storage[0].args[0]
99
+ if ok $"incus storage show #{poolName}"
100
+ results.push { status: 'pass', message: "storage pool #{poolName} exists" }
101
+ else
102
+ results.push { status: 'fail', message: "storage pool #{poolName} missing" }
103
+
104
+ if props.network
105
+ bridge = props.network[0].args[0]
106
+ if ok $"incus network show #{bridge}"
107
+ results.push { status: 'pass', message: "network #{bridge} exists" }
108
+ else
109
+ results.push { status: 'fail', message: "network #{bridge} missing" }
110
+
111
+ results
@@ -0,0 +1,47 @@
1
+ export name = "multipass"
2
+ export description = "Manages Multipass virtual machines"
3
+ export positional = ["name", "image"]
4
+ export properties =
5
+ cpus: { description: "Number of CPUs (default: 1)" }
6
+ memory: { description: "Memory allocation (default: 1G)" }
7
+ disk: { description: "Disk size (default: 5G)" }
8
+ start: { description: "Ensure VM is running" }
9
+
10
+ isRunning = (name) ->
11
+ out = run $"multipass info #{name}"
12
+ out.ok and out.stdout.includes 'State:' and out.stdout.includes 'Running'
13
+
14
+ export check = (name, props) ->
15
+ return 'missing' unless ok $"multipass info #{name}"
16
+ return 'drift' if props.start and not isRunning name
17
+ 'ok'
18
+
19
+ export apply = (name, props) ->
20
+ image = props.args?[0] or '24.04'
21
+
22
+ unless ok $"multipass info #{name}"
23
+ cpus = props.cpus?[0]?.args?[0]
24
+ memory = props.memory?[0]?.args?[0]
25
+ disk = props.disk?[0]?.args?[0]
26
+ sh ['multipass', 'launch', image, '--name', name,
27
+ '--cpus', cpus or '1',
28
+ '--memory', memory or '1G',
29
+ '--disk', disk or '5G']
30
+
31
+ if props.start and not isRunning name
32
+ sh $"multipass start #{name}"
33
+
34
+ export verify = (name, props) ->
35
+ results = []
36
+
37
+ unless ok $"multipass info #{name}"
38
+ results.push { status: 'fail', message: "vm #{name} missing" }
39
+ return results
40
+ results.push { status: 'pass', message: "vm #{name} exists" }
41
+
42
+ if isRunning name
43
+ results.push { status: 'pass', message: "#{name} running" }
44
+ else
45
+ results.push { status: 'fail', message: "#{name} not running" }
46
+
47
+ results
@@ -0,0 +1,40 @@
1
+ export name = "packages"
2
+ export description = "Ensures system packages are installed"
3
+ export positional = []
4
+
5
+ gather = (name, props) ->
6
+ list = [...(props.args or [])]
7
+ for own k, entries of props when k != 'args'
8
+ list.push k
9
+ for entry in entries
10
+ list.push ...entry.args if entry.args?.length
11
+ list
12
+
13
+ installed = (pkg) ->
14
+ out = run $"dpkg -s #{pkg}"
15
+ out.ok and out.stdout.includes 'Status: install ok installed'
16
+
17
+ export check = (name, props) ->
18
+ list = gather name, props
19
+ return 'ok' if list.length == 0
20
+ for pkg in list
21
+ return 'drift' unless installed pkg
22
+ 'ok'
23
+
24
+ export apply = (name, props) ->
25
+ list = gather name, props
26
+ return if list.length == 0
27
+ missing = (pkg for pkg in list when not installed pkg)
28
+ return if missing.length == 0
29
+ sh $"apt-get update -qq"
30
+ sh ['apt-get', 'install', '-y', '-qq', ...missing]
31
+
32
+ export verify = (name, props) ->
33
+ list = gather name, props
34
+ results = []
35
+ for pkg in list
36
+ if installed pkg
37
+ results.push { status: 'pass', message: "#{pkg} installed" }
38
+ else
39
+ results.push { status: 'fail', message: "#{pkg} not installed" }
40
+ results