omakit 0.5.1 → 0.6.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 +38 -45
- package/blocks/history.json +68 -0
- package/blocks/run/NOTICE +12 -0
- package/blocks/run/Run.qml +242 -0
- package/blocks/run/run-supervisor.py +522 -0
- package/blocks/store/NOTICE +12 -0
- package/blocks/store/Store.qml +157 -0
- package/blocks/store/store-helper.py +431 -0
- package/package.json +12 -5
- package/skills/omarchy-plugin-audit/SKILL.md +11 -5
- package/skills/omarchy-plugin-build/SKILL.md +164 -0
- package/skills/omarchy-plugin-check/SKILL.md +6 -3
- package/skills/omarchy-plugin-submit/SKILL.md +4 -1
- package/skills/omarchy-plugin-validation-watch/SKILL.md +5 -2
- package/skills/omarchy-plugin-weigh/SKILL.md +13 -2
- package/tests/fixtures/weigh/clean/Widget.qml +19 -0
- package/tests/fixtures/weigh/clean/manifest.json +9 -0
- package/tests/fixtures/weigh/clean/tests/harness.qml +7 -0
- package/tests/fixtures/weigh/idle-panel/Panel.qml +65 -0
- package/tests/fixtures/weigh/idle-panel/manifest.json +9 -0
- package/tests/fixtures/weigh/poller/Service.qml +50 -0
- package/tests/fixtures/weigh/poller/manifest.json +9 -0
- package/tests/fixtures/weigh/timer-180ms/Widget.qml +25 -0
- package/tests/fixtures/weigh/timer-180ms/manifest.json +9 -0
- package/tests/lab/run/harness/scenarios/controls.sh +6 -0
- package/tests/lab/run/harness/scenarios/envprobe.sh +10 -0
- package/tests/lab/run/harness/scenarios/forge.sh +11 -0
- package/tests/lab/run/harness/scenarios/holder.sh +5 -0
- package/tests/lab/run/harness/scenarios/orphan.sh +6 -0
- package/tests/lab/run/harness/scenarios/stall.sh +5 -0
- package/tests/lab/run/harness/scenarios/stubborn.sh +5 -0
- package/tests/lab/run/harness/scenarios/tree.sh +7 -0
- package/tests/lab/run/harness/shell.qml +84 -0
- package/tests/lab/run/report.py +217 -0
- package/tests/lab/run/suite.sh +106 -0
- package/tests/lab/store/harness/shell.qml +73 -0
- package/tests/lab/store/report.py +133 -0
- package/tests/lab/store/suite.sh +109 -0
- package/tests/parity/corpus.mjs +8 -3
- package/tests/parity/run.mjs +4 -4
- package/tools/audit/audit.mjs +17 -6
- package/tools/audit/git.mjs +3 -3
- package/tools/audit/report.mjs +31 -5
- package/tools/blocks/add.mjs +138 -0
- package/tools/blocks/commit.json +5 -0
- package/tools/blocks/record-commit.mjs +77 -0
- package/tools/blocks/registry.mjs +191 -0
- package/tools/blocks/stamp.mjs +61 -0
- package/tools/inspect/contract.mjs +36 -5
- package/tools/inspect/helpers.mjs +217 -0
- package/tools/inspect/inspect.mjs +68 -3
- package/tools/inspect/patterns.mjs +18 -3
- package/tools/inspect/processes.mjs +38 -5
- package/tools/inspect/report.mjs +18 -2
- package/tools/inspect/writes.mjs +22 -4
- package/tools/lab/guest.mjs +155 -0
- package/tools/lab/harness.sh +119 -0
- package/tools/lab/host.mjs +177 -0
- package/tools/lab/inspect.mjs +240 -0
- package/tools/lab/omarchy.gpg +13 -0
- package/tools/lab/patches/omarchy-iso-test.patch +351 -0
- package/tools/lab/paths.mjs +173 -0
- package/tools/lab/pin.json +42 -0
- package/tools/lab/pin.mjs +64 -0
- package/tools/lab/prune.mjs +68 -0
- package/tools/lab/qemu.mjs +153 -0
- package/tools/lab/qmp-cli.mjs +21 -0
- package/tools/lab/report.mjs +183 -0
- package/tools/lab/run.mjs +344 -0
- package/tools/lab/setup.mjs +430 -0
- package/tools/lab/suites/run.sh +35 -0
- package/tools/lab/suites/store.sh +41 -0
- package/tools/lab/suites/weigh.sh +196 -0
- package/tools/lab/suites.mjs +142 -0
- package/tools/lab/verify.mjs +134 -0
- package/tools/marketplace/README.md +38 -1
- package/tools/marketplace/banner.mjs +23 -2
- package/tools/marketplace/cli.mjs +449 -146
- package/tools/marketplace/completion-check.mjs +27 -1
- package/tools/marketplace/completion.mjs +32 -4
- package/tools/marketplace/doctor.mjs +47 -9
- package/tools/marketplace/github.mjs +52 -6
- package/tools/marketplace/local-transport.mjs +1 -1
- package/tools/marketplace/options.mjs +16 -5
- package/tools/marketplace/outcome.mjs +244 -0
- package/tools/marketplace/pin.mjs +178 -33
- package/tools/marketplace/setup.mjs +16 -15
- package/tools/marketplace/tree.mjs +1 -1
- package/tools/marketplace/upgrade.mjs +5 -5
- package/tools/marketplace/usage.mjs +116 -72
- package/tools/subject/resolve.mjs +19 -6
- package/tools/weigh/audit.mjs +47 -16
- package/tools/weigh/config.mjs +105 -24
- package/tools/weigh/list.mjs +10 -1
|
@@ -34,11 +34,20 @@ function hasFlag(flags, letter, long = null) {
|
|
|
34
34
|
return flags.some((flag) => flag === `-${letter}` || (long && (flag === long || flag.startsWith(`${long}=`))) || (/^-[A-Za-z]+$/.test(flag) && flag.includes(letter)))
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* The tool a process resolves through PATH: its first non-wrapper word
|
|
39
|
+
* when that word is a literal with no slash. A word that is an expression
|
|
40
|
+
* (`[script, name]`, `[root.helperPath("x.sh")]`) is not a name looked up
|
|
41
|
+
* in PATH; it is a value the text does not show, and the argument-grammar
|
|
42
|
+
* class already names the site. Measured on the Theme Manager port
|
|
43
|
+
* (2026-09-17): 12 of its 24 Run sites read as "resolved from PATH" with
|
|
44
|
+
* the tool `script`, an absolute path at run time.
|
|
45
|
+
*/
|
|
38
46
|
function pathResolved(process) {
|
|
39
47
|
if (!Array.isArray(process.argv) || !process.argv.length) return null
|
|
40
|
-
const { tool } = toolOf(process.argv)
|
|
48
|
+
const { tool, index } = toolOf(process.argv)
|
|
41
49
|
if (!tool || tool.includes("/")) return null
|
|
50
|
+
if ((process.expressions || []).some((entry) => entry.index === index)) return null
|
|
42
51
|
return tool
|
|
43
52
|
}
|
|
44
53
|
|
|
@@ -222,13 +231,19 @@ export const PATTERNS = Object.freeze([
|
|
|
222
231
|
share: 0.07,
|
|
223
232
|
sample: SAMPLE,
|
|
224
233
|
precondition: ({ processes, hosts }) => {
|
|
225
|
-
|
|
234
|
+
// A line of a helper started through Run resolves its tools in the
|
|
235
|
+
// block's closed PATH (docs/BLOCKS.md); it is counted apart, never as
|
|
236
|
+
// an ambient lookup.
|
|
237
|
+
const ambient = processes.filter((row) => !row.closedEnvironment)
|
|
238
|
+
const resolved = ambient.map((row) => ({ row, tool: pathResolved(row) })).filter((entry) => entry.tool)
|
|
239
|
+
const closed = processes.filter((row) => row.closedEnvironment && pathResolved(row))
|
|
226
240
|
const curls = hosts.filter((row) => row.tool === "curl" && !hasFlag(row.flags, "q", "--disable"))
|
|
227
241
|
const parts = []
|
|
228
242
|
const names = [...new Set(resolved.map((entry) => entry.tool))]
|
|
229
243
|
const named = names.length > 6 ? `${names.slice(0, 6).join(", ")} and ${names.length - 6} more` : names.join(", ")
|
|
230
244
|
if (resolved.length) parts.push(`${plural(resolved.length, "tool")} resolved from PATH (${named}; ${sites(resolved.map((entry) => entry.row))})`)
|
|
231
245
|
if (curls.length) parts.push(`curl without -q (${sites(curls)})`)
|
|
246
|
+
if (closed.length) parts.push(`${plural(closed.length, "tool name")} in ${plural(new Set(closed.map((row) => row.file)).size, "helper")} started through Run, resolved in the block's closed PATH and not counted`)
|
|
232
247
|
return { sites: [...resolved.map((entry) => entry.row), ...curls].map(site), observation: `observed ${parts.join("; ")}` }
|
|
233
248
|
},
|
|
234
249
|
},
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Process sites: every `Process {` block in QML (its `command:` inside the
|
|
2
|
-
// block or assigned to its id elsewhere in the file), every
|
|
2
|
+
// block or assigned to its id elsewhere in the file), every `Run {` block
|
|
3
|
+
// where the tree carries the omakit run block unmodified (its deadline and
|
|
4
|
+
// caps are the block's, docs/BLOCKS.md), every
|
|
3
5
|
// `Quickshell.execDetached([...])`, and every command line of a shell
|
|
4
6
|
// script. Each row carries the argv the text shows, whether a deadline is
|
|
5
7
|
// observed for it, what collects its output and whether a producer-side
|
|
@@ -167,6 +169,7 @@ function qmlRow(file, text, block, command, commandOffset) {
|
|
|
167
169
|
deadline: deadlineFor(text, id, resolved.argv),
|
|
168
170
|
output: outputOf(block.body, resolved.argv),
|
|
169
171
|
shellWrapper: isShellWrapper(resolved.argv),
|
|
172
|
+
closedEnvironment: false,
|
|
170
173
|
pipedFrom: null,
|
|
171
174
|
}
|
|
172
175
|
}
|
|
@@ -195,16 +198,42 @@ function detachedRows(file, text) {
|
|
|
195
198
|
deadline: { observed: false, via: null, ms: null },
|
|
196
199
|
output: { collector: "none", capObserved: false, via: null },
|
|
197
200
|
shellWrapper: isShellWrapper(resolved.argv),
|
|
201
|
+
closedEnvironment: false,
|
|
198
202
|
pipedFrom: null,
|
|
199
203
|
})
|
|
200
204
|
}
|
|
201
205
|
return rows
|
|
202
206
|
}
|
|
203
207
|
|
|
204
|
-
/**
|
|
205
|
-
|
|
208
|
+
/**
|
|
209
|
+
* A `Run {` site of the omakit run block: the deadline is the block's
|
|
210
|
+
* (`deadlineMs`, default 10000), the caps are the block's (`maxBytes`),
|
|
211
|
+
* and a shell string is refused unless `allowShellString: true` is on the
|
|
212
|
+
* block's own line, in which case the wrapper is observed as it would be
|
|
213
|
+
* on a Process.
|
|
214
|
+
*/
|
|
215
|
+
function runRow(file, text, block) {
|
|
216
|
+
const inside = propertyValue(block.body, "command")
|
|
217
|
+
const command = inside && !/^\[\s*\]$/.test(inside.text) ? inside : assignedCommand(text, block.id)
|
|
218
|
+
const offset = inside && !/^\[\s*\]$/.test(inside.text) ? block.open + 1 + inside.offset : command ? command.offset : null
|
|
219
|
+
const row = qmlRow(file, text, block, command, offset)
|
|
220
|
+
const deadline = numeric(propertyValue(block.body, "deadlineMs")?.text)
|
|
221
|
+
const allowShellString = propertyValue(block.body, "allowShellString")?.text === "true"
|
|
222
|
+
return {
|
|
223
|
+
...row,
|
|
224
|
+
running: row.running || (block.id ? new RegExp(`(?<![\\w.])${block.id}\\.start\\s*\\(`).test(text) : false),
|
|
225
|
+
block: "run",
|
|
226
|
+
deadline: { observed: true, via: "block-run", ms: deadline ?? 10000 },
|
|
227
|
+
output: { collector: "Run", capObserved: true, via: "maxBytes" },
|
|
228
|
+
shellWrapper: allowShellString ? row.shellWrapper : false,
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Every `Process {` block and detached exec in a QML file; `Run {` blocks too when the tree carries the run block. */
|
|
233
|
+
export function qmlProcesses(file, { runBlock = false } = {}) {
|
|
206
234
|
const text = blankComments(file.text)
|
|
207
235
|
const rows = []
|
|
236
|
+
if (runBlock) for (const block of blocks(text, "Run")) rows.push(runRow(file, text, block))
|
|
208
237
|
for (const block of blocks(text, "Process")) {
|
|
209
238
|
const inside = propertyValue(block.body, "command")
|
|
210
239
|
// `command: []` is a placeholder for a value set later: the assignment to
|
|
@@ -235,6 +264,9 @@ function commandWords(segment) {
|
|
|
235
264
|
if (words[0] === "for" || words[0] === "case" || words[0] === "select" || words[0] === "function") return null
|
|
236
265
|
words = words.slice(1)
|
|
237
266
|
}
|
|
267
|
+
// `exec cmd` replaces the shell with cmd: a process site, the wrapper
|
|
268
|
+
// dropped; `exec` alone or with only redirections runs nothing.
|
|
269
|
+
while (words.length > 1 && words[0] === "exec" && !/^[<>&\d]/.test(words[1])) words = words.slice(1)
|
|
238
270
|
if (!words.length) return null
|
|
239
271
|
if (/^[A-Za-z_]\w*\(\)$/.test(words[0])) return null
|
|
240
272
|
if (BUILTINS.has(basename(words[0])) && basename(words[0]) !== "eval") return null
|
|
@@ -278,6 +310,7 @@ export function shellProcesses(file) {
|
|
|
278
310
|
deadline: stripped.some((word) => basename(word) === "timeout") ? { observed: true, via: "timeout-argv", ms: deadlineMs } : { observed: false, via: null, ms: null },
|
|
279
311
|
output: { collector: "none", capObserved: lineCap.observed, via: lineCap.via },
|
|
280
312
|
shellWrapper: isShellWrapper(stripped),
|
|
313
|
+
closedEnvironment: false,
|
|
281
314
|
pipedFrom: segment.operator === "|" || segment.operator === "|&" ? previous : null,
|
|
282
315
|
}
|
|
283
316
|
rows.push(row)
|
|
@@ -291,8 +324,8 @@ export function shellProcesses(file) {
|
|
|
291
324
|
* @param {{ path: string, kind: string, text: string }} file
|
|
292
325
|
* @returns {Array} process rows, in file order
|
|
293
326
|
*/
|
|
294
|
-
export function extractProcesses(file) {
|
|
295
|
-
if (file.kind === "qml") return qmlProcesses(file)
|
|
327
|
+
export function extractProcesses(file, options = {}) {
|
|
328
|
+
if (file.kind === "qml") return qmlProcesses(file, options)
|
|
296
329
|
if (file.kind === "shell") return shellProcesses(file)
|
|
297
330
|
return []
|
|
298
331
|
}
|
package/tools/inspect/report.mjs
CHANGED
|
@@ -20,7 +20,6 @@
|
|
|
20
20
|
import { colourEnabled, field, GUTTER, INSPECT_VERDICT, mark, outputColumns, styler, verdict, wrap } from "../marketplace/style.mjs"
|
|
21
21
|
import { withHomeAbbreviated } from "../marketplace/paths.mjs"
|
|
22
22
|
import { PATTERNS, SIZE } from "./patterns.mjs"
|
|
23
|
-
import { toolOf } from "./processes.mjs"
|
|
24
23
|
|
|
25
24
|
const NOTHING = "observed nothing of this kind"
|
|
26
25
|
|
|
@@ -106,7 +105,7 @@ function hostRow(host, c) {
|
|
|
106
105
|
}
|
|
107
106
|
|
|
108
107
|
function writeRow(write, c) {
|
|
109
|
-
const what = write.via === "FileView" ? `FileView path: ${write.path}` : `${write.via} ${write.path}`
|
|
108
|
+
const what = write.via === "FileView" ? `FileView path: ${write.path}` : write.via === "block-store" ? `Store ${write.path} (through the store block)` : `${write.via} ${write.path}`
|
|
110
109
|
const where = write.controlledDirectory === "observed"
|
|
111
110
|
? `observed (${write.controlledBy})`
|
|
112
111
|
: write.controlledDirectory === "not-observed"
|
|
@@ -263,6 +262,7 @@ export function renderInspect(document, { colour = colourEnabled(), full = false
|
|
|
263
262
|
out.push(...field("subject", `${withHomeAbbreviated(document.subject.dir)} at ${document.subject.commit ? document.subject.commit.slice(0, 8) : "no commit"}`, c))
|
|
264
263
|
out.push(...uncommittedLine(document, c))
|
|
265
264
|
out.push(...field("baseline", baselineText(document.marketplaceBaseline), c))
|
|
265
|
+
if (document.blocks.length) out.push(...field("blocks", blocksText(document.blocks), c))
|
|
266
266
|
const score = document.size.score
|
|
267
267
|
out.push(...field("size score", score === null
|
|
268
268
|
? `none: no function to rank`
|
|
@@ -336,6 +336,7 @@ function renderFull(document, { colour }) {
|
|
|
336
336
|
out.push(...field("subject", `${withHomeAbbreviated(document.subject.dir)} at ${document.subject.commit ? document.subject.commit.slice(0, 8) : "no commit"}, ${plural(total, "file")} read${kinds.length ? ` (${kinds.join(", ")})` : ""}`, c))
|
|
337
337
|
out.push(...uncommittedLine(document, c))
|
|
338
338
|
out.push(...field("method", document.method, c))
|
|
339
|
+
if (document.blocks.length) out.push(...field("blocks", blocksText(document.blocks), c))
|
|
339
340
|
out.push("")
|
|
340
341
|
|
|
341
342
|
const sections = [
|
|
@@ -370,6 +371,21 @@ function renderFull(document, { colour }) {
|
|
|
370
371
|
}
|
|
371
372
|
|
|
372
373
|
/** The process split in words: how many are QML Process sites and how many are shell lines. */
|
|
374
|
+
/**
|
|
375
|
+
* The omakit blocks in the tree, one clause each: an unmodified block is
|
|
376
|
+
* its name, version and file count, and its files raised no row; a
|
|
377
|
+
* modified one names the files whose body is not a shipped one, which
|
|
378
|
+
* were read like any other file.
|
|
379
|
+
*/
|
|
380
|
+
function blocksText(blocks) {
|
|
381
|
+
return blocks.map((block) => {
|
|
382
|
+
const version = block.shippedVersion && block.shippedVersion !== block.version ? `${block.version} (omakit ships ${block.shippedVersion})` : block.version
|
|
383
|
+
if (block.state === "unmodified") return `${block.name} ${version}, ${plural(block.files.length, "file")}, unmodified${block.complete ? "" : ", incomplete"}: no row of its own`
|
|
384
|
+
const changed = block.files.filter((file) => file.state === "modified").map((file) => file.path)
|
|
385
|
+
return `${block.name} ${version}, modified (${changed.join(", ")}): read like any other file`
|
|
386
|
+
}).join("; ")
|
|
387
|
+
}
|
|
388
|
+
|
|
373
389
|
function split({ qml, shell }) {
|
|
374
390
|
if (!shell) return qml === 1 ? "in qml" : "all in qml"
|
|
375
391
|
if (!qml) return shell === 1 ? "a shell line" : "all shell lines"
|
package/tools/inspect/writes.mjs
CHANGED
|
@@ -68,9 +68,26 @@ function row(file, line, rawPath, via, pluginId, mode = null) {
|
|
|
68
68
|
return { file: file.path, line, path: String(rawPath).trim(), canonicalPath: canonical, via, ...classified, mode }
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
/**
|
|
72
|
+
* A `Store {` site of the omakit store block: one file under the plugin's
|
|
73
|
+
* private directory in the XDG state or cache base (docs/BLOCKS.md), so
|
|
74
|
+
* the path is `$XDG_STATE_HOME/<pluginId>/<name>` or the cache one, a
|
|
75
|
+
* directory the plugin controls, written at mode 0600 through a staging
|
|
76
|
+
* file. Read only where the tree carries the store block unmodified.
|
|
77
|
+
*/
|
|
78
|
+
function storeRow(file, text, block, pluginId) {
|
|
79
|
+
const kind = stringLiteral(propertyValue(block.body, "kind")?.text ?? "") ?? "state"
|
|
80
|
+
const base = kind === "cache" ? "$XDG_CACHE_HOME" : "$XDG_STATE_HOME"
|
|
81
|
+
const id = stringLiteral(propertyValue(block.body, "pluginId")?.text ?? "")
|
|
82
|
+
const name = stringLiteral(propertyValue(block.body, "name")?.text ?? "")
|
|
83
|
+
const path = `${base}/${id || "<pluginId>"}/${name || "<name>"}`
|
|
84
|
+
return { ...row(file, lineOf(text, block.start), path, "block-store", pluginId, "0600"), block: "store" }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function qmlWrites(file, pluginId, { storeBlock = false } = {}) {
|
|
72
88
|
const text = blankComments(file.text)
|
|
73
89
|
const rows = []
|
|
90
|
+
if (storeBlock) for (const block of blocks(text, "Store")) rows.push(storeRow(file, text, block, pluginId))
|
|
74
91
|
for (const block of blocks(text, "FileView")) {
|
|
75
92
|
const path = propertyValue(block.body, "path")
|
|
76
93
|
if (!path) continue
|
|
@@ -182,11 +199,12 @@ function shellWrites(file, pluginId) {
|
|
|
182
199
|
|
|
183
200
|
/**
|
|
184
201
|
* @param {{ path: string, kind: string, text: string }} file
|
|
185
|
-
* @param {{ pluginId?: string|null }} [
|
|
202
|
+
* @param {{ pluginId?: string|null, storeBlock?: boolean }} [options] storeBlock: the tree carries the store block unmodified, so `Store {` is a write site
|
|
186
203
|
* @returns {Array} write rows, in file order
|
|
187
204
|
*/
|
|
188
|
-
export function extractWrites(file,
|
|
189
|
-
|
|
205
|
+
export function extractWrites(file, options = {}) {
|
|
206
|
+
const { pluginId = null } = options
|
|
207
|
+
if (file.kind === "qml") return qmlWrites(file, pluginId, options)
|
|
190
208
|
if (file.kind === "js") return jsWrites(file, pluginId)
|
|
191
209
|
if (file.kind === "shell") return shellWrites(file, pluginId)
|
|
192
210
|
if (file.kind === "python") return pythonWrites(file, pluginId)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Talking to the guest: SSH with the lab's own key to 127.0.0.1 and
|
|
2
|
+
// nowhere else, the session established the way a person logs in (the
|
|
3
|
+
// password typed at SDDM through the virtual keyboard until a Hyprland
|
|
4
|
+
// owned by the user exists), and the identity read from inside.
|
|
5
|
+
//
|
|
6
|
+
// The SSH argument list is pure and tests/unit/lab.test.mjs reads it: the
|
|
7
|
+
// key is the base's, the host is the loopback address, BatchMode, no
|
|
8
|
+
// agent, no forwarding of any kind, no known-hosts entry written into the
|
|
9
|
+
// user's file (the guest's host key is new with every base and the
|
|
10
|
+
// connection is to a port QEMU forwards on the loopback interface).
|
|
11
|
+
|
|
12
|
+
import { spawnSync } from "node:child_process"
|
|
13
|
+
|
|
14
|
+
export const GUEST_HOST = "127.0.0.1"
|
|
15
|
+
|
|
16
|
+
/** @param {{ key: string, port: number, user: string }} guest */
|
|
17
|
+
export function sshArgs({ key, port, user }) {
|
|
18
|
+
return [
|
|
19
|
+
"-i", key,
|
|
20
|
+
"-p", String(port),
|
|
21
|
+
"-o", "BatchMode=yes",
|
|
22
|
+
"-o", "IdentitiesOnly=yes",
|
|
23
|
+
"-o", "IdentityAgent=none",
|
|
24
|
+
"-o", "StrictHostKeyChecking=no",
|
|
25
|
+
"-o", "UserKnownHostsFile=/dev/null",
|
|
26
|
+
"-o", "ForwardAgent=no",
|
|
27
|
+
"-o", "ForwardX11=no",
|
|
28
|
+
"-o", "ConnectTimeout=5",
|
|
29
|
+
"-o", "LogLevel=ERROR",
|
|
30
|
+
`${user}@${GUEST_HOST}`,
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The environment a command needs to reach the running session from an
|
|
36
|
+
* SSH login, as the toolchain's `ssh_session` set it: the runtime
|
|
37
|
+
* directory, the session bus, the Hyprland signature, the Wayland display,
|
|
38
|
+
* and the Omarchy path from /etc/omarchy.conf when there is one (a
|
|
39
|
+
* dev-linked guest names its checkout there).
|
|
40
|
+
*/
|
|
41
|
+
export const SESSION_PREAMBLE = [
|
|
42
|
+
"export XDG_RUNTIME_DIR=/run/user/$(id -u)",
|
|
43
|
+
"export DBUS_SESSION_BUS_ADDRESS=unix:path=$XDG_RUNTIME_DIR/bus",
|
|
44
|
+
"export LANG=$(systemctl --user show-environment | sed -n 's/^LANG=//p' | head -1)",
|
|
45
|
+
"export LANG=${LANG:-C.UTF-8}",
|
|
46
|
+
"export HYPRLAND_INSTANCE_SIGNATURE=$(ls -t $XDG_RUNTIME_DIR/hypr | head -1)",
|
|
47
|
+
"export WAYLAND_DISPLAY=$(find $XDG_RUNTIME_DIR -maxdepth 1 -name 'wayland-*' ! -name '*.lock' -printf '%f\\n' | head -1)",
|
|
48
|
+
"[[ ! -f /etc/omarchy.conf ]] || source /etc/omarchy.conf",
|
|
49
|
+
"export OMARCHY_PATH=${OMARCHY_PATH:-/usr/share/omarchy}",
|
|
50
|
+
"export PATH=$OMARCHY_PATH/bin:$PATH",
|
|
51
|
+
].join("; ")
|
|
52
|
+
|
|
53
|
+
/** Run one command in the guest; stdout captured, status returned, never a throw. */
|
|
54
|
+
export function sshGuest(guest, command, { input = "", timeoutMs = 120000 } = {}) {
|
|
55
|
+
const result = spawnSync("ssh", [...sshArgs(guest), command], { encoding: "utf8", input, stdio: ["pipe", "pipe", "pipe"], timeout: timeoutMs })
|
|
56
|
+
return { status: result.status ?? 255, stdout: result.stdout || "", stderr: result.stderr || "", error: result.error || null }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The same, inside the session's environment. */
|
|
60
|
+
export function sshSession(guest, command, options) {
|
|
61
|
+
return sshGuest(guest, `${SESSION_PREAMBLE}; ${command}`, options)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** One wait, shared by every lab module that polls. */
|
|
65
|
+
export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
66
|
+
|
|
67
|
+
/** Wait until `ssh true` answers, polling every 5 s like the toolchain; `alive()` says whether the VM is still there. */
|
|
68
|
+
export async function waitForSsh(guest, { timeoutSeconds, alive, onPhase = () => {} }) {
|
|
69
|
+
const started = Date.now()
|
|
70
|
+
for (;;) {
|
|
71
|
+
if (sshGuest(guest, "true", { timeoutMs: 10000 }).status === 0) return Math.round((Date.now() - started) / 1000)
|
|
72
|
+
if (alive && !(await alive())) throw Object.assign(new Error("the guest exited while the lab waited for SSH"), { code: "guest-exited" })
|
|
73
|
+
const waited = (Date.now() - started) / 1000
|
|
74
|
+
if (waited >= timeoutSeconds) throw Object.assign(new Error(`no SSH answer from the guest after ${timeoutSeconds} s`), { code: "guest-timeout" })
|
|
75
|
+
onPhase(`waiting for the guest's SSH (${Math.round(waited)} s)`)
|
|
76
|
+
await sleep(5000)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Only a Hyprland owned by the guest user proves the session: SDDM runs a compositor of its own as sddm. */
|
|
81
|
+
export function sessionStarted(guest) {
|
|
82
|
+
return sshGuest(guest, "pgrep -u $(id -u) -x Hyprland >/dev/null", { timeoutMs: 10000 }).status === 0
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Log in at SDDM the way a person does: the password field is focused
|
|
87
|
+
* for the remembered user, so type the password and Enter, wait ten
|
|
88
|
+
* seconds, look for Hyprland, repeat. Measured by the toolchain: a login
|
|
89
|
+
* takes one or two rounds after first boot.
|
|
90
|
+
*/
|
|
91
|
+
export async function establishSession({ guest, socket, password, typeText, press, timeoutSeconds = 300, onPhase = () => {} }) {
|
|
92
|
+
const started = Date.now()
|
|
93
|
+
let rounds = 0
|
|
94
|
+
while (!sessionStarted(guest)) {
|
|
95
|
+
if ((Date.now() - started) / 1000 >= timeoutSeconds) throw Object.assign(new Error(`no Hyprland session owned by ${guest.user} after ${timeoutSeconds} s`), { code: "session-timeout" })
|
|
96
|
+
rounds += 1
|
|
97
|
+
onPhase(`logging in at the greeter (round ${rounds})`)
|
|
98
|
+
await typeText(socket, password)
|
|
99
|
+
await press(socket, ["ret"])
|
|
100
|
+
await sleep(10000)
|
|
101
|
+
}
|
|
102
|
+
return { rounds, seconds: Math.round((Date.now() - started) / 1000) }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** `hyprctl -j layers` says whether a namespace is on screen; used to wait for the startup notifications to go. */
|
|
106
|
+
export function layerPresent(guest, namespace) {
|
|
107
|
+
return sshSession(guest, `hyprctl -j layers | jq -e --arg namespace '${namespace}' '[.. | objects | select(.namespace? == $namespace)] | length > 0'`).status === 0
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The startup notifications a fresh base shows, dismissed so a suite's
|
|
112
|
+
* screen is deterministic; a notification the suite raises later is its
|
|
113
|
+
* own. Ported from the toolchain's clear_startup_notifications.
|
|
114
|
+
*/
|
|
115
|
+
export async function clearStartupNotifications(guest, { onPhase = () => {} } = {}) {
|
|
116
|
+
onPhase("waiting for the notification service")
|
|
117
|
+
const deadline = Date.now() + 15000
|
|
118
|
+
while (sshSession(guest, "omarchy-shell notifications ping >/dev/null").status !== 0) {
|
|
119
|
+
if (Date.now() > deadline) return { cleared: false, reason: "the notification service did not answer within 15 s" }
|
|
120
|
+
await sleep(1000)
|
|
121
|
+
}
|
|
122
|
+
sshSession(guest, "omarchy-shell notifications dismissAll >/dev/null")
|
|
123
|
+
const gone = Date.now() + 15000
|
|
124
|
+
while (layerPresent(guest, "omarchy-notifications")) {
|
|
125
|
+
if (Date.now() > gone) return { cleared: false, reason: "the notification layer stayed on screen for 15 s after dismissAll" }
|
|
126
|
+
await sleep(1000)
|
|
127
|
+
}
|
|
128
|
+
return { cleared: true, reason: null }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Who the guest is, read from inside before any suite runs: the installed
|
|
133
|
+
* omarchy package, the kernel, and whether the session is running from
|
|
134
|
+
* the package or from a linked checkout (`OMARCHY_PATH` in
|
|
135
|
+
* /etc/omarchy.conf; a dev-linked guest names `.local/share/omarchy`).
|
|
136
|
+
* This is the line the old gates never wrote (docs/history/2026-09-18-lab-
|
|
137
|
+
* inventory.md P8), and packaging/LAB_PLAN.md's run identity requires.
|
|
138
|
+
*/
|
|
139
|
+
export function guestIdentity(guest) {
|
|
140
|
+
const read = (command) => sshGuest(guest, command).stdout.trim()
|
|
141
|
+
const omarchyPackage = read("pacman -Q omarchy 2>/dev/null")
|
|
142
|
+
const version = omarchyPackage.split(/\s+/)[1] || null
|
|
143
|
+
const conf = read("cat /etc/omarchy.conf 2>/dev/null")
|
|
144
|
+
const omarchyPath = conf.match(/^OMARCHY_PATH=(.+)$/m)?.[1]?.replace(/^["']|["']$/g, "") || null
|
|
145
|
+
const linked = Boolean(omarchyPath && !/^\/usr\/share\/omarchy\/?$/.test(omarchyPath))
|
|
146
|
+
return {
|
|
147
|
+
omarchyPackage: omarchyPackage || null,
|
|
148
|
+
version,
|
|
149
|
+
kernel: read("uname -r") || null,
|
|
150
|
+
hostname: read("cat /etc/hostname") || null,
|
|
151
|
+
omarchyPath,
|
|
152
|
+
linked,
|
|
153
|
+
testedSource: linked ? `linked checkout at ${omarchyPath}` : (omarchyPackage ? `installed package ${omarchyPackage}` : "unknown"),
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/bash
|
|
2
|
+
# The one harness every suite runs through, on the host, against a guest
|
|
3
|
+
# that `omakit lab prove` has already booted, logged in and identified.
|
|
4
|
+
#
|
|
5
|
+
# bash tools/lab/harness.sh <suite.sh> <run-dir> <ssh-key> <ssh-port> <qmp-socket> <repo-root> <session-preamble> <guest-user> <guest-password> [suite arguments...]
|
|
6
|
+
#
|
|
7
|
+
# Every value comes in as an argument, none from the environment: omakit
|
|
8
|
+
# reads no variable of its own, and the harness inherits that. The suite
|
|
9
|
+
# file defines `omakit_lab_suite`, which gets the suite arguments and the
|
|
10
|
+
# helpers below, the same six the toolchain's host tests had (log,
|
|
11
|
+
# ssh_guest, ssh_session, wait_for_guest_state, capture_console, RUN_DIR)
|
|
12
|
+
# plus the staging and detached-job helpers the three gates each wrote for
|
|
13
|
+
# themselves (docs/history/2026-09-18-lab-inventory.md P1). Nothing here
|
|
14
|
+
# reaches the host's own session: every ssh goes to 127.0.0.1 on the
|
|
15
|
+
# forwarded port with the lab's key, and tests/unit/lab.test.mjs reads
|
|
16
|
+
# this file for the words it must not contain.
|
|
17
|
+
set -u
|
|
18
|
+
set -o pipefail
|
|
19
|
+
|
|
20
|
+
LAB_SUITE_FILE=$1; RUN_DIR=$2; LAB_SSH_KEY=$3; LAB_SSH_PORT=$4; LAB_QMP_SOCKET=$5; OMAKIT_DIR=$6; LAB_SESSION_PREAMBLE=$7; GUEST_USER=$8; GUEST_PASSWORD=$9
|
|
21
|
+
shift 9
|
|
22
|
+
|
|
23
|
+
log() { printf '==> %s\n' "$1"; }
|
|
24
|
+
|
|
25
|
+
ssh_guest() {
|
|
26
|
+
ssh -i "$LAB_SSH_KEY" -p "$LAB_SSH_PORT" \
|
|
27
|
+
-o BatchMode=yes -o IdentitiesOnly=yes -o IdentityAgent=none \
|
|
28
|
+
-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
|
29
|
+
-o ForwardAgent=no -o ForwardX11=no -o ConnectTimeout=5 -o LogLevel=ERROR \
|
|
30
|
+
"$GUEST_USER@127.0.0.1" "$@"
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# The same command inside the session: the preamble omakit built (the
|
|
34
|
+
# runtime directory, the session bus, the Hyprland signature, the Wayland
|
|
35
|
+
# display, the Omarchy path) comes in as one argument, so bash and Node
|
|
36
|
+
# agree on it by construction.
|
|
37
|
+
ssh_session() {
|
|
38
|
+
ssh_guest "$LAB_SESSION_PREAMBLE; $1" </dev/null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
wait_for_guest_state() {
|
|
42
|
+
local description="$1" timeout="$2"
|
|
43
|
+
shift 2
|
|
44
|
+
local deadline=$((SECONDS + timeout))
|
|
45
|
+
until "$@" >/dev/null 2>&1; do
|
|
46
|
+
if ((SECONDS >= deadline)); then
|
|
47
|
+
printf 'not ok - %s\n' "$description" >&2
|
|
48
|
+
return 1
|
|
49
|
+
fi
|
|
50
|
+
sleep 1
|
|
51
|
+
done
|
|
52
|
+
printf 'ok - %s\n' "$description"
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# A screendump into the run directory, converted to PNG when magick is on
|
|
56
|
+
# PATH and left as PPM otherwise; QMP is spoken by node, never by socat.
|
|
57
|
+
capture_console() {
|
|
58
|
+
local name="$1" shot="$RUN_DIR/$1.ppm"
|
|
59
|
+
sleep 1
|
|
60
|
+
node "$OMAKIT_DIR/tools/lab/qmp-cli.mjs" "$LAB_QMP_SOCKET" screendump "$shot" >/dev/null 2>&1 || return 0
|
|
61
|
+
[[ -s $shot ]] || { rm -f "$shot"; return 0; }
|
|
62
|
+
if command -v magick >/dev/null 2>&1; then
|
|
63
|
+
magick "$shot" "$RUN_DIR/$name.png" 2>/dev/null && rm -f "$shot"
|
|
64
|
+
fi
|
|
65
|
+
return 0
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# A directory into the guest, whole, under a fresh destination.
|
|
69
|
+
stage_tree() {
|
|
70
|
+
local src="$1" dest="$2"
|
|
71
|
+
shift 2
|
|
72
|
+
tar -C "$src" "$@" -cf - . | ssh_guest "rm -rf $dest && mkdir -p $dest && tar -C $dest -xf -"
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# Named paths under a source directory into the guest, under a destination.
|
|
76
|
+
stage_paths() {
|
|
77
|
+
local src="$1" dest="$2"
|
|
78
|
+
shift 2
|
|
79
|
+
tar -C "$src" -cf - "$@" | ssh_guest "mkdir -p $dest && tar -C $dest -xf -"
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
# A staged tree turned into a local git repository in the guest, which is
|
|
83
|
+
# what omarchy-plugin-add takes.
|
|
84
|
+
stage_plugin() {
|
|
85
|
+
stage_tree "$1" "$2" --exclude=.git --exclude=.cache --exclude=node_modules
|
|
86
|
+
ssh_guest "git -C $2 init -q && git -C $2 add . && git -C $2 -c user.name=Lab -c user.email=lab@invalid commit -qm staged"
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
# One detached job in the session: a suite that restarts the shell, or
|
|
90
|
+
# that must outlive the ssh call, runs under setsid and reports through a
|
|
91
|
+
# done file; its log is copied back beside host.log when it ends.
|
|
92
|
+
# guest_job <tag> <limit-seconds> <command>
|
|
93
|
+
guest_job() {
|
|
94
|
+
local tag="$1" limit="$2" command="$3"
|
|
95
|
+
ssh_session "rm -f /tmp/omakit-lab-$tag.done /tmp/omakit-lab-$tag.log; \
|
|
96
|
+
setsid bash -c '$command > /tmp/omakit-lab-$tag.log 2>&1; echo \$? > /tmp/omakit-lab-$tag.done' >/dev/null 2>&1 < /dev/null &" || return 1
|
|
97
|
+
wait_for_guest_state "the $tag job finished" "$limit" ssh_guest "test -f /tmp/omakit-lab-$tag.done" || {
|
|
98
|
+
ssh_guest "tail -n 40 /tmp/omakit-lab-$tag.log" || true
|
|
99
|
+
ssh_guest "cat /tmp/omakit-lab-$tag.log" > "$RUN_DIR/$tag.log" 2>/dev/null || true
|
|
100
|
+
return 1
|
|
101
|
+
}
|
|
102
|
+
ssh_guest "cat /tmp/omakit-lab-$tag.log" > "$RUN_DIR/$tag.log" 2>/dev/null || true
|
|
103
|
+
}
|
|
104
|
+
guest_job_status() { ssh_guest "cat /tmp/omakit-lab-$1.done"; }
|
|
105
|
+
|
|
106
|
+
# A file out of the guest into the run directory.
|
|
107
|
+
guest_file() { ssh_guest "cat $1" > "$RUN_DIR/$2" 2>/dev/null; }
|
|
108
|
+
|
|
109
|
+
# The two questions every gate asks after its suite: the shell answers,
|
|
110
|
+
# and Hyprland reports no configuration error.
|
|
111
|
+
guest_shell_healthy() {
|
|
112
|
+
ssh_session "omarchy-shell shell ping | grep -qx ok" || { echo "the guest's shell does not answer after the suite" >&2; return 1; }
|
|
113
|
+
ssh_session "test -z \"\$(hyprctl configerrors)\"" || { echo "hyprctl reports configuration errors after the suite" >&2; return 1; }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
# shellcheck source=/dev/null
|
|
117
|
+
source "$LAB_SUITE_FILE"
|
|
118
|
+
declare -F omakit_lab_suite >/dev/null || { echo "$LAB_SUITE_FILE must define omakit_lab_suite()" >&2; exit 2; }
|
|
119
|
+
omakit_lab_suite "$@"
|