omakit 0.5.0 → 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.
Files changed (96) hide show
  1. package/README.md +39 -32
  2. package/blocks/history.json +68 -0
  3. package/blocks/run/NOTICE +12 -0
  4. package/blocks/run/Run.qml +242 -0
  5. package/blocks/run/run-supervisor.py +522 -0
  6. package/blocks/store/NOTICE +12 -0
  7. package/blocks/store/Store.qml +157 -0
  8. package/blocks/store/store-helper.py +431 -0
  9. package/package.json +12 -5
  10. package/skills/omarchy-plugin-audit/SKILL.md +11 -5
  11. package/skills/omarchy-plugin-build/SKILL.md +164 -0
  12. package/skills/omarchy-plugin-check/SKILL.md +7 -4
  13. package/skills/omarchy-plugin-submit/SKILL.md +4 -1
  14. package/skills/omarchy-plugin-validation-watch/SKILL.md +5 -2
  15. package/skills/omarchy-plugin-weigh/SKILL.md +13 -2
  16. package/tests/fixtures/weigh/clean/Widget.qml +19 -0
  17. package/tests/fixtures/weigh/clean/manifest.json +9 -0
  18. package/tests/fixtures/weigh/clean/tests/harness.qml +7 -0
  19. package/tests/fixtures/weigh/idle-panel/Panel.qml +65 -0
  20. package/tests/fixtures/weigh/idle-panel/manifest.json +9 -0
  21. package/tests/fixtures/weigh/poller/Service.qml +50 -0
  22. package/tests/fixtures/weigh/poller/manifest.json +9 -0
  23. package/tests/fixtures/weigh/timer-180ms/Widget.qml +25 -0
  24. package/tests/fixtures/weigh/timer-180ms/manifest.json +9 -0
  25. package/tests/lab/run/harness/scenarios/controls.sh +6 -0
  26. package/tests/lab/run/harness/scenarios/envprobe.sh +10 -0
  27. package/tests/lab/run/harness/scenarios/forge.sh +11 -0
  28. package/tests/lab/run/harness/scenarios/holder.sh +5 -0
  29. package/tests/lab/run/harness/scenarios/orphan.sh +6 -0
  30. package/tests/lab/run/harness/scenarios/stall.sh +5 -0
  31. package/tests/lab/run/harness/scenarios/stubborn.sh +5 -0
  32. package/tests/lab/run/harness/scenarios/tree.sh +7 -0
  33. package/tests/lab/run/harness/shell.qml +84 -0
  34. package/tests/lab/run/report.py +217 -0
  35. package/tests/lab/run/suite.sh +106 -0
  36. package/tests/lab/store/harness/shell.qml +73 -0
  37. package/tests/lab/store/report.py +133 -0
  38. package/tests/lab/store/suite.sh +109 -0
  39. package/tests/parity/corpus.mjs +8 -3
  40. package/tests/parity/run.mjs +4 -4
  41. package/tools/audit/audit.mjs +17 -6
  42. package/tools/audit/git.mjs +3 -3
  43. package/tools/audit/report.mjs +31 -5
  44. package/tools/blocks/add.mjs +138 -0
  45. package/tools/blocks/commit.json +5 -0
  46. package/tools/blocks/record-commit.mjs +77 -0
  47. package/tools/blocks/registry.mjs +191 -0
  48. package/tools/blocks/stamp.mjs +61 -0
  49. package/tools/inspect/contract.mjs +37 -6
  50. package/tools/inspect/functions.mjs +236 -12
  51. package/tools/inspect/helpers.mjs +217 -0
  52. package/tools/inspect/inspect.mjs +71 -5
  53. package/tools/inspect/measure-functions.mjs +12 -3
  54. package/tools/inspect/patterns.mjs +35 -18
  55. package/tools/inspect/processes.mjs +38 -5
  56. package/tools/inspect/report.mjs +33 -6
  57. package/tools/inspect/writes.mjs +22 -4
  58. package/tools/lab/guest.mjs +155 -0
  59. package/tools/lab/harness.sh +119 -0
  60. package/tools/lab/host.mjs +177 -0
  61. package/tools/lab/inspect.mjs +240 -0
  62. package/tools/lab/omarchy.gpg +13 -0
  63. package/tools/lab/patches/omarchy-iso-test.patch +351 -0
  64. package/tools/lab/paths.mjs +173 -0
  65. package/tools/lab/pin.json +42 -0
  66. package/tools/lab/pin.mjs +64 -0
  67. package/tools/lab/prune.mjs +68 -0
  68. package/tools/lab/qemu.mjs +153 -0
  69. package/tools/lab/qmp-cli.mjs +21 -0
  70. package/tools/lab/report.mjs +183 -0
  71. package/tools/lab/run.mjs +344 -0
  72. package/tools/lab/setup.mjs +430 -0
  73. package/tools/lab/suites/run.sh +35 -0
  74. package/tools/lab/suites/store.sh +41 -0
  75. package/tools/lab/suites/weigh.sh +196 -0
  76. package/tools/lab/suites.mjs +142 -0
  77. package/tools/lab/verify.mjs +134 -0
  78. package/tools/marketplace/README.md +38 -1
  79. package/tools/marketplace/banner.mjs +23 -2
  80. package/tools/marketplace/cli.mjs +449 -146
  81. package/tools/marketplace/completion-check.mjs +27 -1
  82. package/tools/marketplace/completion.mjs +32 -4
  83. package/tools/marketplace/doctor.mjs +47 -9
  84. package/tools/marketplace/github.mjs +52 -6
  85. package/tools/marketplace/local-transport.mjs +1 -1
  86. package/tools/marketplace/options.mjs +16 -5
  87. package/tools/marketplace/outcome.mjs +244 -0
  88. package/tools/marketplace/pin.mjs +178 -33
  89. package/tools/marketplace/setup.mjs +16 -15
  90. package/tools/marketplace/tree.mjs +1 -1
  91. package/tools/marketplace/upgrade.mjs +5 -5
  92. package/tools/marketplace/usage.mjs +116 -72
  93. package/tools/subject/resolve.mjs +19 -6
  94. package/tools/weigh/audit.mjs +47 -16
  95. package/tools/weigh/config.mjs +105 -24
  96. package/tools/weigh/list.mjs +10 -1
@@ -0,0 +1,157 @@
1
+ // omakit block: store 0.2.0
2
+ // SPDX-License-Identifier: MIT
3
+ // Copyright (c) 2026 Maarten Tolhuijs
4
+ // Source: omakit blocks/store/Store.qml, commit unstamped
5
+ // Body sha256: 6fafd2ec98b4d4fe18ead2ae1f1221386e9714eb9a33ef0f1b9cf48520f2c743
6
+ // end of omakit block header
7
+ //
8
+ // Store: one private file for a plugin, read and written the way the
9
+ // review asks for. The file lives in the plugin's own 0700 directory under
10
+ // the XDG state or cache base; store-helper.py, next to this file and
11
+ // started through the Run block (Run.qml, the same directory), reaches it
12
+ // by a descriptor walk with O_NOFOLLOW at every step, checks owner, kind
13
+ // and mode on the descriptor after every open and never before, caps a
14
+ // read in bytes, parses it against a schema, and writes through an
15
+ // exclusive 0600 staging file renamed into place. docs/BLOCKS.md is the
16
+ // contract and says which review comments each line answers.
17
+ //
18
+ // Store {
19
+ // id: memory
20
+ // pluginId: "io.github.me.plugin"
21
+ // name: "memory.json"
22
+ // schema: ({ type: "object", required: ["version"], properties: { version: { type: "integer" } } })
23
+ // onFinished: result => { if (result.op === "read" && result.state === "ok") apply(result.value) }
24
+ // }
25
+ // memory.read(); memory.write({ version: 1, themes: {} }); memory.remove()
26
+ import QtQuick
27
+ import Quickshell
28
+
29
+ QtObject {
30
+ id: store
31
+
32
+ // The plugin's id, which names its private directory: letters, digits,
33
+ // dots, dashes and underscores. The file name inside it, the same set.
34
+ property string pluginId: ""
35
+ property string name: ""
36
+ // "state" for $XDG_STATE_HOME (~/.local/state), "cache" for
37
+ // $XDG_CACHE_HOME (~/.cache); the base has to be inside HOME.
38
+ property string kind: "state"
39
+ // A read over this many bytes is overflow; a write is refused over it too.
40
+ property int maxBytes: 1048576
41
+ // The schema subset a read and a write are checked against: type,
42
+ // properties, required, additionalProperties: false, items, enum,
43
+ // maxLength, maxItems, maxProperties, minimum, maximum, pattern. Null
44
+ // checks nothing but that the file is JSON.
45
+ property var schema: null
46
+
47
+ readonly property bool busy: _queue.length > 0 || _helper.running
48
+ // One object per operation: op is read, write or remove; state is one
49
+ // of ok, missing, invalid, refused, overflow, failed, helper-failed;
50
+ // value (a read), bytes, mtime, path, and reason for every state that
51
+ // is not ok or missing.
52
+ signal finished(var result)
53
+
54
+ // A write goes to the helper as one argument; the kernel bounds one
55
+ // argument at 128 KiB, so a write is refused above this many UTF-8
56
+ // bytes before anything starts, and so is a schema. A cache a helper of
57
+ // the plugin downloads itself uses the helper's own transaction; Store
58
+ // is for the plugin's state.
59
+ readonly property int maxWriteBytes: 65536
60
+ readonly property string helper: decodeURIComponent(Qt.resolvedUrl("store-helper.py").toString().replace(/^file:\/\//, ""))
61
+ property var _queue: []
62
+ property var _current: null
63
+
64
+ readonly property var _environment: {
65
+ const environment = {}
66
+ for (const name of ["XDG_STATE_HOME", "XDG_CACHE_HOME"]) {
67
+ const value = Quickshell.env(name)
68
+ if (value) environment[name] = value
69
+ }
70
+ return environment
71
+ }
72
+
73
+ function read() { _enqueue({ op: "read" }) }
74
+ function remove() { _enqueue({ op: "remove" }) }
75
+
76
+ function write(value) {
77
+ let text
78
+ try { text = JSON.stringify(value) } catch (error) { _report({ op: "write", state: "invalid", reason: "the value is not JSON: " + error }); return }
79
+ if (typeof text !== "string") { _report({ op: "write", state: "invalid", reason: "the value is undefined" }); return }
80
+ const bytes = _utf8Bytes(text)
81
+ if (bytes < 0) { _report({ op: "write", state: "invalid", reason: "the value holds a lone surrogate" }); return }
82
+ if (bytes > maxWriteBytes) { _report({ op: "write", state: "overflow", reason: "the value is " + bytes + " bytes, over " + maxWriteBytes }); return }
83
+ _enqueue({ op: "write", value: text })
84
+ }
85
+
86
+ // UTF-8 bytes of a string, counted the way the kernel counts an argument; -1 for a lone surrogate.
87
+ function _utf8Bytes(text) {
88
+ try { return encodeURIComponent(text).replace(/%[0-9A-F]{2}/g, ".").length } catch (error) { return -1 }
89
+ }
90
+
91
+ function _schemaText() {
92
+ if (!schema) return null
93
+ const text = JSON.stringify(schema)
94
+ return typeof text === "string" && _utf8Bytes(text) > 0 && _utf8Bytes(text) <= maxWriteBytes ? text : ""
95
+ }
96
+
97
+ function _enqueue(operation) {
98
+ _queue = _queue.concat([operation])
99
+ _next()
100
+ }
101
+
102
+ function _next() {
103
+ if (_helper.running || _queue.length === 0) return
104
+ _current = _queue[0]
105
+ _queue = _queue.slice(1)
106
+ const schemaText = _schemaText()
107
+ if (schemaText === "") { _onHelper({ state: "spawn-failed", reason: "the schema is not a JSON object under " + maxWriteBytes + " bytes" }); return }
108
+ _helper.command = _argv(_current, schemaText)
109
+ _helper.start()
110
+ }
111
+
112
+ function _argv(operation, schemaText) {
113
+ const argv = ["/usr/bin/python3", "-I", "-S", "-B", helper, operation.op, "--kind", kind, "--plugin", pluginId, "--name", name, "--max-bytes", String(maxBytes)]
114
+ if (schemaText) argv.push("--schema", schemaText)
115
+ if (operation.op === "write") argv.push("--value", operation.value)
116
+ return argv
117
+ }
118
+
119
+ function _report(result) {
120
+ finished(result)
121
+ }
122
+
123
+ function _parsed(run) {
124
+ if (run.state !== "ok") return null
125
+ try {
126
+ const result = JSON.parse(String(run.stdout || "").trim().split("\n").pop())
127
+ return result && result.ev === "result" ? result : null
128
+ } catch (error) {
129
+ return null
130
+ }
131
+ }
132
+
133
+ function _lost(op, run) {
134
+ const detail = (run.reason ? ": " + run.reason : "") + (run.stderr ? "; " + String(run.stderr).slice(0, 512) : "")
135
+ return { op: op, state: "helper-failed", reason: "the helper ended as " + run.state + detail }
136
+ }
137
+
138
+ function _onHelper(run) {
139
+ const operation = _current || { op: "unknown" }
140
+ _current = null
141
+ const result = _parsed(run) || _lost(operation.op, run)
142
+ delete result.ev
143
+ _report(result)
144
+ _next()
145
+ }
146
+
147
+ // The helper: a descriptor walk, one read or write, a few fsyncs: 10 s
148
+ // covers a slow disk; its one line carries the value, so the cap is the
149
+ // file cap plus the line around it.
150
+ property Run _helper: Run {
151
+ environment: store._environment
152
+ deadlineMs: 10000
153
+ maxBytes: store.maxBytes + 65536
154
+ keepBytes: store.maxBytes + 65536
155
+ onFinished: function(result) { store._onHelper(result) }
156
+ }
157
+ }
@@ -0,0 +1,431 @@
1
+ # omakit block: store 0.2.0
2
+ # SPDX-License-Identifier: MIT
3
+ # Copyright (c) 2026 Maarten Tolhuijs
4
+ # Source: omakit blocks/store/store-helper.py, commit unstamped
5
+ # Body sha256: 2dd4d6ad308bbed473936c29844793411d8849415812b96ca5e7a6688097e37c
6
+ # end of omakit block header
7
+ #
8
+ # The helper behind Store.qml, started through the Run block as
9
+ # /usr/bin/python3 -I -S -B <this file> read|write|remove --kind state|cache
10
+ # --plugin <id> --name <file> [--max-bytes N] [--schema JSON] [--value JSON]
11
+ # and never by hand. One private directory per plugin under the XDG base,
12
+ # reached by a descriptor walk from HOME: every directory opened with
13
+ # O_NOFOLLOW and O_DIRECTORY, checked on its descriptor to be a directory
14
+ # owned by this user and writable by nobody else, created with mode 0700
15
+ # where missing. Every file is opened relative to that descriptor with
16
+ # O_NOFOLLOW and O_NONBLOCK (a FIFO is refused, never waited on) and
17
+ # checked the same way after the open, never before it. A read is capped
18
+ # in bytes and parsed against a schema; a write goes to an exclusive 0600
19
+ # staging file, every byte written and fsynced before the rename, or the
20
+ # staging file is unlinked and the write fails. The shape is the
21
+ # catalog cache transaction of omarchy-theme-manager 0.5.15, which the
22
+ # marketplace review read without a further file or state comment. One
23
+ # JSON line on stdout is the result; docs/BLOCKS.md is the contract.
24
+ import errno
25
+ import json
26
+ import os
27
+ import re
28
+ import secrets
29
+ import stat
30
+ import sys
31
+ import time
32
+
33
+ SAFE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
34
+ STAGING = re.compile(r"^\.store-\d+-[0-9a-f]{16}\.tmp$")
35
+ STAGING_STALE_SECONDS = 600
36
+ DEFAULT_MAX_BYTES = 1048576
37
+ KINDS = {"state": ("XDG_STATE_HOME", ".local/state"), "cache": ("XDG_CACHE_HOME", ".cache")}
38
+ DIRECTORY_FLAGS = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
39
+ TYPES = {"object": dict, "array": list, "string": str, "boolean": bool, "null": type(None)}
40
+
41
+
42
+ class Refused(Exception):
43
+ """A check on a descriptor failed: the directory or file is not ours to use."""
44
+
45
+
46
+ class Invalid(Exception):
47
+ """The file's content is not what the schema says."""
48
+
49
+
50
+ class Overflow(Exception):
51
+ """The file, or the value to write, is over the cap."""
52
+
53
+
54
+ MAX_NESTING = 64
55
+
56
+
57
+ def nesting(value, depth=0):
58
+ """How deep a JSON value nests; past MAX_NESTING it stops counting, which is enough to refuse it."""
59
+ if depth > MAX_NESTING or not isinstance(value, (dict, list)):
60
+ return depth
61
+ members = value.values() if isinstance(value, dict) else value
62
+ return max([depth] + [nesting(member, depth + 1) for member in members])
63
+
64
+
65
+ def emit(obj):
66
+ """One UTF-8 line: the value as it is, never \\u-escaped into more bytes than the file holds."""
67
+ sys.stdout.buffer.write(json.dumps(obj, ensure_ascii=False, sort_keys=True).encode("utf-8", "surrogatepass") + b"\n")
68
+ sys.stdout.flush()
69
+
70
+
71
+ def parse(argv):
72
+ """The operation and its options; every value is a string, the caller converts."""
73
+ if not argv or argv[0] not in ("read", "write", "remove"):
74
+ raise SystemExit("store-helper: read, write or remove")
75
+ opts = {"op": argv[0], "max_bytes": str(DEFAULT_MAX_BYTES)}
76
+ index = 1
77
+ while index < len(argv):
78
+ key = argv[index][2:].replace("-", "_")
79
+ if not argv[index].startswith("--") or key not in ("kind", "plugin", "name", "max_bytes", "schema", "value") or index + 1 >= len(argv):
80
+ raise SystemExit("store-helper: unknown option %s" % argv[index])
81
+ opts[key] = argv[index + 1]
82
+ index += 2
83
+ return opts
84
+
85
+
86
+ def check_options(opts):
87
+ if opts.get("kind") not in KINDS:
88
+ raise Refused("kind must be state or cache")
89
+ for key in ("plugin", "name"):
90
+ if not SAFE_NAME.match(opts.get(key, "")) or ".." in opts[key]:
91
+ raise Refused("%s is not a safe name: %r" % (key, opts.get(key, "")))
92
+ if not opts["max_bytes"].isdigit() or int(opts["max_bytes"]) < 1:
93
+ raise Refused("max-bytes must be a positive integer")
94
+
95
+
96
+ def check_schema(schema):
97
+ """The schema is the documented subset in shape: an object whose keywords carry the right kinds, all the way down."""
98
+ if not isinstance(schema, dict):
99
+ raise Refused("schema is not an object")
100
+ kinds = {"type": str, "properties": dict, "required": list, "additionalProperties": bool, "items": dict, "enum": list,
101
+ "maxLength": int, "maxItems": int, "maxProperties": int, "minimum": (int, float), "maximum": (int, float), "pattern": str}
102
+ for key, value in schema.items():
103
+ if key not in kinds or not isinstance(value, kinds[key]) or (isinstance(value, bool) and kinds[key] is not bool):
104
+ raise Refused("schema keyword %r is not in the supported subset, or its value is of the wrong kind" % key)
105
+ for member in list(schema.get("properties", {}).values()) + ([schema["items"]] if "items" in schema else []):
106
+ check_schema(member)
107
+
108
+
109
+ def verify_owned(fd, label, directory):
110
+ """The descriptor is the kind of thing expected, ours, and writable by nobody else."""
111
+ info = os.fstat(fd)
112
+ if directory and not stat.S_ISDIR(info.st_mode):
113
+ raise Refused("%s is not a directory" % label)
114
+ if not directory and not stat.S_ISREG(info.st_mode):
115
+ raise Refused("%s is not a regular file" % label)
116
+ if info.st_uid != os.getuid():
117
+ raise Refused("%s is not owned by this user" % label)
118
+ if info.st_mode & 0o022:
119
+ raise Refused("%s is writable by the group or by others" % label)
120
+ return info
121
+
122
+
123
+ def open_child_directory(parent_fd, name, label, create):
124
+ """A child directory by descriptor, created 0700 when missing and allowed."""
125
+ if create:
126
+ try:
127
+ os.mkdir(name, mode=0o700, dir_fd=parent_fd)
128
+ except FileExistsError:
129
+ pass
130
+ fd = open_no_follow(name, DIRECTORY_FLAGS, parent_fd, label)
131
+ try:
132
+ verify_owned(fd, label, True)
133
+ except Refused:
134
+ os.close(fd)
135
+ raise
136
+ return fd
137
+
138
+
139
+ def open_no_follow(name, flags, dir_fd, label):
140
+ """openat with O_NOFOLLOW; a symbolic link in the way is a refusal, by name."""
141
+ try:
142
+ return os.open(name, flags, dir_fd=dir_fd)
143
+ except OSError as error:
144
+ if error.errno == errno.ELOOP:
145
+ raise Refused("%s is a symbolic link" % label) from None
146
+ if error.errno == errno.ENOTDIR and flags & os.O_DIRECTORY:
147
+ raise Refused("%s is a symbolic link or a file, not a directory" % label) from None
148
+ raise
149
+
150
+
151
+ def base_components(kind, environ):
152
+ """HOME and the XDG base's path components below it; the base must be inside HOME."""
153
+ home = environ.get("HOME", "")
154
+ variable, default = KINDS[kind]
155
+ base = environ.get(variable) or os.path.join(home, default)
156
+ if not home.startswith("/") or not base.startswith("/"):
157
+ raise Refused("HOME and %s must be absolute paths" % variable)
158
+ home, base = os.path.normpath(home), os.path.normpath(base)
159
+ if os.path.commonpath([home, base]) != home:
160
+ raise Refused("%s is not inside HOME, so its ownership cannot be walked" % variable)
161
+ relative = os.path.relpath(base, home)
162
+ components = [] if relative == "." else relative.split(os.sep)
163
+ if any(part in ("", ".", "..") for part in components):
164
+ raise Refused("%s has an unsafe component" % variable)
165
+ return home, base, components
166
+
167
+
168
+ def open_private_directory(kind, plugin, environ=None):
169
+ """The plugin's private directory, by a descriptor walk from HOME; the descriptors on the way stay open.
170
+
171
+ `environ` is where HOME and the XDG base are read from: the process
172
+ environment by default, or a mapping a long-running importer passes so
173
+ a test can point the walk at a throwaway base without touching the
174
+ process. Measured before this: an importer's tests wrote into the
175
+ user's real state directory.
176
+ """
177
+ home, base, components = base_components(kind, os.environ if environ is None else environ)
178
+ fds = [open_no_follow(home, DIRECTORY_FLAGS, None, "HOME")]
179
+ try:
180
+ verify_owned(fds[0], "HOME", True)
181
+ for part in components:
182
+ fds.append(open_child_directory(fds[-1], part, "the %s directory" % kind, True))
183
+ fds.append(open_child_directory(fds[-1], plugin, "the plugin's %s directory" % kind, True))
184
+ except BaseException:
185
+ for fd in fds: # a refusal partway leaves nothing open behind an importer
186
+ os.close(fd)
187
+ raise
188
+ return fds, os.path.join(base, plugin)
189
+
190
+
191
+ def read_capped(fd, maximum):
192
+ chunks, total = [], 0
193
+ while True:
194
+ chunk = os.read(fd, min(65536, maximum + 1 - total))
195
+ if not chunk:
196
+ return b"".join(chunks)
197
+ chunks.append(chunk)
198
+ total += len(chunk)
199
+ if total > maximum:
200
+ raise Overflow("the file is over %d bytes" % maximum)
201
+
202
+
203
+ def schema_problem(value, schema, path):
204
+ """The first way `value` departs from the schema subset, or None."""
205
+ expected = schema.get("type")
206
+ if expected == "number" and (isinstance(value, bool) or not isinstance(value, (int, float))):
207
+ return "%s is not a number" % path
208
+ if expected == "integer" and (isinstance(value, bool) or not isinstance(value, int)):
209
+ return "%s is not an integer" % path
210
+ if expected in TYPES and not isinstance(value, TYPES[expected]):
211
+ return "%s is not %s" % (path, expected)
212
+ if "enum" in schema and value not in schema["enum"]:
213
+ return "%s is not one of the allowed values" % path
214
+ return bounds_problem(value, schema, path) or members_problem(value, schema, path)
215
+
216
+
217
+ def bounds_problem(value, schema, path):
218
+ if isinstance(value, str) and len(value) > schema.get("maxLength", len(value)):
219
+ return "%s is longer than %d" % (path, schema["maxLength"])
220
+ if isinstance(value, str) and "pattern" in schema and not re.search(schema["pattern"], value):
221
+ return "%s does not match the pattern" % path
222
+ if isinstance(value, list) and len(value) > schema.get("maxItems", len(value)):
223
+ return "%s has more than %d items" % (path, schema["maxItems"])
224
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
225
+ if value < schema.get("minimum", value) or value > schema.get("maximum", value):
226
+ return "%s is out of range" % path
227
+ if isinstance(value, dict) and len(value) > schema.get("maxProperties", len(value)):
228
+ return "%s has more than %d keys" % (path, schema["maxProperties"])
229
+ return None
230
+
231
+
232
+ def members_problem(value, schema, path):
233
+ if isinstance(value, dict):
234
+ return object_problem(value, schema, path)
235
+ if isinstance(value, list) and "items" in schema:
236
+ return array_problem(value, schema, path)
237
+ return None
238
+
239
+
240
+ def object_problem(value, schema, path):
241
+ for key in schema.get("required", []):
242
+ if key not in value:
243
+ return "%s lacks %s" % (path, key)
244
+ for key, member in value.items():
245
+ member_schema = schema.get("properties", {}).get(key)
246
+ if member_schema is None and schema.get("additionalProperties") is False:
247
+ return "%s has an unexpected key %s" % (path, key)
248
+ problem = schema_problem(member, member_schema, "%s.%s" % (path, key)) if member_schema else None
249
+ if problem:
250
+ return problem
251
+ return None
252
+
253
+
254
+ def array_problem(value, schema, path):
255
+ for index, item in enumerate(value):
256
+ problem = schema_problem(item, schema["items"], "%s[%d]" % (path, index))
257
+ if problem:
258
+ return problem
259
+ return None
260
+
261
+
262
+ def parse_value(data, schema):
263
+ try:
264
+ value = json.loads(data.decode("utf-8"), parse_constant=lambda token: (_ for _ in ()).throw(ValueError(token)))
265
+ except (UnicodeDecodeError, ValueError) as error:
266
+ raise Invalid("not valid JSON: %s" % error) from None
267
+ except RecursionError:
268
+ raise Invalid("nested deeper than the parser follows") from None
269
+ if nesting(value) > MAX_NESTING:
270
+ raise Invalid("nested deeper than %d levels" % MAX_NESTING)
271
+ problem = schema_problem(value, schema, "value") if schema else None
272
+ if problem:
273
+ raise Invalid(problem)
274
+ return value
275
+
276
+
277
+ def open_regular(dir_fd, name):
278
+ """The file by descriptor: opened without blocking (a FIFO opens at once instead of waiting for a writer), checked to be a regular file that is ours, and only then made blocking."""
279
+ fd = open_no_follow(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC, dir_fd, name)
280
+ try:
281
+ info = verify_owned(fd, name, False)
282
+ os.set_blocking(fd, True)
283
+ except BaseException:
284
+ os.close(fd)
285
+ raise
286
+ return fd, info
287
+
288
+
289
+ def read_file(dir_fd, name, maximum, schema):
290
+ try:
291
+ fd, info = open_regular(dir_fd, name)
292
+ except FileNotFoundError:
293
+ return {"state": "missing"}
294
+ try:
295
+ data = read_capped(fd, maximum)
296
+ finally:
297
+ os.close(fd)
298
+ return {"state": "ok", "value": parse_value(data, schema), "bytes": len(data), "mtime": int(info.st_mtime)}
299
+
300
+
301
+ def stale_staging(dir_fd, entry):
302
+ """A staging file of ours, older than a crashed writer could still be using."""
303
+ if not STAGING.match(entry):
304
+ return False
305
+ try:
306
+ info = os.stat(entry, dir_fd=dir_fd, follow_symlinks=False)
307
+ except OSError:
308
+ return False
309
+ return stat.S_ISREG(info.st_mode) and info.st_uid == os.getuid() and time.time() - info.st_mtime > STAGING_STALE_SECONDS
310
+
311
+
312
+ def unlink_quietly(dir_fd, entry):
313
+ try:
314
+ os.unlink(entry, dir_fd=dir_fd)
315
+ except OSError:
316
+ pass
317
+
318
+
319
+ def sweep_staging(dir_fd):
320
+ """Staging files a crashed writer left behind, once they are old enough to be nobody's."""
321
+ for entry in os.listdir(dir_fd):
322
+ if stale_staging(dir_fd, entry):
323
+ unlink_quietly(dir_fd, entry)
324
+
325
+
326
+ def create_staging(dir_fd):
327
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC
328
+ for _attempt in range(100):
329
+ name = ".store-%d-%s.tmp" % (os.getpid(), secrets.token_hex(8))
330
+ try:
331
+ return name, os.open(name, flags, 0o600, dir_fd=dir_fd)
332
+ except FileExistsError:
333
+ continue
334
+ raise Refused("could not create an exclusive staging file")
335
+
336
+
337
+ def write_all(fd, data):
338
+ """Every byte, or an error: a short write (a quota, a full disk, a size limit) never leaves a partial staging file to rename."""
339
+ written = 0
340
+ while written < len(data):
341
+ count = os.write(fd, data[written:])
342
+ if count <= 0:
343
+ raise OSError(errno.ENOSPC, "short write: %d of %d bytes" % (written, len(data)))
344
+ written += count
345
+
346
+
347
+ def write_file(dir_fd, name, maximum, schema, raw):
348
+ data = json.dumps(parse_value(raw.encode("utf-8"), schema), ensure_ascii=False, sort_keys=True, indent=1).encode("utf-8") + b"\n"
349
+ if len(data) > maximum:
350
+ raise Overflow("the value is %d bytes, over %d" % (len(data), maximum))
351
+ sweep_staging(dir_fd)
352
+ staging, fd = create_staging(dir_fd)
353
+ try:
354
+ write_all(fd, data)
355
+ os.fsync(fd)
356
+ os.rename(staging, name, src_dir_fd=dir_fd, dst_dir_fd=dir_fd)
357
+ staging = None
358
+ os.fsync(dir_fd)
359
+ finally:
360
+ os.close(fd)
361
+ if staging:
362
+ unlink_quietly(dir_fd, staging)
363
+ return {"state": "ok", "bytes": len(data)}
364
+
365
+
366
+ def remove_file(dir_fd, name):
367
+ try:
368
+ os.unlink(name, dir_fd=dir_fd)
369
+ except FileNotFoundError:
370
+ return {"state": "missing"}
371
+ os.fsync(dir_fd)
372
+ return {"state": "ok"}
373
+
374
+
375
+ def parse_schema(text):
376
+ """The schema argument: JSON, the supported subset in shape, at most 64 KiB, or None."""
377
+ if not text:
378
+ return None
379
+ if len(text.encode("utf-8")) > 65536:
380
+ raise Refused("schema is over 65536 bytes")
381
+ try:
382
+ schema = json.loads(text)
383
+ except (ValueError, RecursionError) as error:
384
+ raise Refused("schema is not valid JSON: %s" % error) from None
385
+ check_schema(schema)
386
+ return schema
387
+
388
+
389
+ def operate(opts, environ=None):
390
+ check_options(opts)
391
+ schema = parse_schema(opts.get("schema"))
392
+ fds, path = open_private_directory(opts["kind"], opts["plugin"], environ)
393
+ try:
394
+ if opts["op"] == "read":
395
+ result = read_file(fds[-1], opts["name"], int(opts["max_bytes"]), schema)
396
+ elif opts["op"] == "write":
397
+ result = write_file(fds[-1], opts["name"], int(opts["max_bytes"]), schema, opts.get("value", ""))
398
+ else:
399
+ result = remove_file(fds[-1], opts["name"])
400
+ finally:
401
+ for fd in fds:
402
+ os.close(fd)
403
+ result["path"] = os.path.join(path, opts["name"])
404
+ return result
405
+
406
+
407
+ def result_of(opts, environ=None):
408
+ """One operation as the result object Store.qml reports: importable by a long-running program that keeps its own state through this file."""
409
+ try:
410
+ result = operate(opts, environ)
411
+ except Refused as why:
412
+ result = {"state": "refused", "reason": str(why)}
413
+ except Invalid as why:
414
+ result = {"state": "invalid", "reason": str(why)}
415
+ except Overflow as why:
416
+ result = {"state": "overflow", "reason": str(why)}
417
+ except (OSError, ValueError) as why:
418
+ result = {"state": "failed", "reason": "%s: %s" % (type(why).__name__, why)}
419
+ except RecursionError:
420
+ result = {"state": "invalid", "reason": "nested deeper than the check follows"}
421
+ return dict(result, op=opts["op"])
422
+
423
+
424
+ def main():
425
+ opts = parse(sys.argv[1:])
426
+ emit(dict(result_of(opts), ev="result"))
427
+ return 0
428
+
429
+
430
+ if __name__ == "__main__":
431
+ sys.exit(main())
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "omakit",
3
- "version": "0.5.0",
4
- "description": "The safe place to find out: everything knowable about an Omarchy Quattro plugin submission before you post it, on your own machine. Agent-first, read-only against the marketplace, posts nothing, zero dependencies.",
3
+ "version": "0.6.1",
4
+ "description": "Tested plumbing, the marketplace's own checks, and a disposable Omarchy to test in.",
5
5
  "license": "MIT",
6
6
  "author": "Maarten Tolhuijs",
7
7
  "keywords": [
@@ -9,6 +9,9 @@
9
9
  "quattro",
10
10
  "plugin",
11
11
  "marketplace",
12
+ "building-blocks",
13
+ "qml",
14
+ "quickshell",
12
15
  "preflight",
13
16
  "cli",
14
17
  "agent"
@@ -22,9 +25,10 @@
22
25
  },
23
26
  "scripts": {
24
27
  "pin": "node tools/marketplace/cli.mjs pin",
25
- "test": "node --test tests/unit/*.test.mjs",
28
+ "test": "node --test --test-timeout=180000 tests/unit/*.test.mjs",
26
29
  "parity": "node tools/marketplace/cli.mjs parity --count 30",
27
- "doctor": "node tools/marketplace/cli.mjs doctor"
30
+ "doctor": "node tools/marketplace/cli.mjs doctor",
31
+ "pack:release": "node tools/blocks/record-commit.mjs && node tools/blocks/record-commit.mjs --check && npm pack --ignore-scripts; node tools/blocks/record-commit.mjs --clear"
28
32
  },
29
33
  "repository": {
30
34
  "type": "git",
@@ -34,7 +38,10 @@
34
38
  "bin",
35
39
  "tools",
36
40
  "skills",
41
+ "blocks",
37
42
  "tests/parity/corpus.mjs",
38
- "tests/parity/run.mjs"
43
+ "tests/parity/run.mjs",
44
+ "tests/lab",
45
+ "tests/fixtures/weigh"
39
46
  ]
40
47
  }
@@ -3,7 +3,10 @@ name: omarchy-plugin-audit
3
3
  description: Compare installed Omarchy Quattro plugin commits with the exact commits the marketplace validated. Use before touching an installed third-party plugin, before enabling or updating one, or when asked whether installed plugins match marketplace review. Read-only.
4
4
  ---
5
5
 
6
- # Audit installed plugin commits
6
+ # Track installed plugin commits
7
+
8
+ This is the other half of the track job: compare what the shell is running with
9
+ the exact commits the marketplace records, without changing either checkout.
7
10
 
8
11
  Run `omakit audit` before touching an installed third-party plugin. Run
9
12
  `omakit audit <plugin-id-or-dir>` when only one installed plugin is in scope.
@@ -38,7 +41,10 @@ replace it with `omarchy plugin update`; update moves to mutable HEAD and does
38
41
  not establish marketplace validation.
39
42
 
40
43
  `AUDITED`, exit 0, means every audited row is validated, or there was nothing
41
- third-party to audit. `DRIFT`, exit 1, means drift or an unknown answer.
42
- Exit 2 means the invocation was refused. `NOT AUDITED` because the shell or
43
- catalog did not answer is a
44
- result to report, not a reason to substitute a directory scan.
44
+ third-party to audit. `DRIFT`, exit 1, means a compared row is off its
45
+ validated commit. An `unknown` row could not be compared and is not drift:
46
+ the closing sentence counts it apart, with why, and when nothing drifted the
47
+ word is `NOT AUDITED`, exit 1. Exit 2 means the invocation was refused. `NOT
48
+ AUDITED` because the shell or catalog did not answer is a
49
+ result to report, not a reason to substitute a directory scan. On any
50
+ nonzero exit the report is on stderr.