excuse-ops 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +213 -2
- package/artifacts/excuse_ops-0.1.0-py3-none-any.whl +0 -0
- package/bin/excuse.mjs +107 -0
- package/bootstrap.py +184 -0
- package/package.json +39 -4
- package/release.json +33 -0
- package/requirements.lock +1 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ExcuseOps contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,214 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ExcuseOps
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Local-first version control for your personal mythology.**
|
|
4
|
+
|
|
5
|
+
ExcuseOps is a terminal app for checking whether accounts of a day can coexist. Record who was told what, find timing and travel contradictions, preview a permitted repair, and replay the original history. A thin npm launcher runs the real Python/Z3 engine; SQLite keeps the data on your machine.
|
|
6
|
+
|
|
7
|
+
This is a fictional-scenario consistency tool, not a truth detector. Results depend on the times, routes and rules you enter. It does not send messages or verify real-world events.
|
|
8
|
+
|
|
9
|
+
## What it does
|
|
10
|
+
|
|
11
|
+
- Checks each audience's story and the combined schedule, including uncertainty windows, travel, shared events, ordering and explicitly allowed overlaps.
|
|
12
|
+
- Explains clashes with source statements, travel gaps, a text Lie Meter and a template snitch question. Nothing is actually sent.
|
|
13
|
+
- Separates logical consistency from communication exposure: disconnected audiences do not make contradictory accounts consistent.
|
|
14
|
+
- Suggests bounded start-time translations, minimizing changed statements before total minutes moved. Applying a repair is explicit.
|
|
15
|
+
- Locks versions marked sent. Corrections and retractions preserve the originals rather than rewriting history.
|
|
16
|
+
- Stores revisions and reports locally, with read-only replay and first-break analysis.
|
|
17
|
+
|
|
18
|
+
There is no browser, web server, cloud account or JavaScript solver fallback.
|
|
19
|
+
|
|
20
|
+
## Current release and requirements
|
|
21
|
+
|
|
22
|
+
Version **0.1.0** is the initial early-access CLI release. Registry releases use the **`next`** tag rather than `latest`; installation support is intentionally narrow.
|
|
23
|
+
|
|
24
|
+
This release is limited to the measured environment below. The launcher enforces the Node version, OS family and CPU architecture; setup enforces the CPython version. Other combinations are not verified or supported:
|
|
25
|
+
|
|
26
|
+
| Component | Supported combination |
|
|
27
|
+
| --- | --- |
|
|
28
|
+
| Platform | macOS on Apple Silicon; verified on macOS 26.5.1 arm64 |
|
|
29
|
+
| Node.js | 26.7.0 |
|
|
30
|
+
| Python | Existing CPython 3.14.6 |
|
|
31
|
+
| npm | Verified with 11.19.0 |
|
|
32
|
+
| Solver | `z3-solver` 4.16.0.0, installed by explicit setup |
|
|
33
|
+
|
|
34
|
+
Python is **not** downloaded for you. Set `EXCUSE_OPS_PYTHON` to its executable path if automatic discovery cannot find the supported interpreter.
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
### Install the current local artifact
|
|
39
|
+
|
|
40
|
+
Obtain `excuse-ops-0.1.0.tgz` from a local build, then install it into a directory you control:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
mkdir excuseops-demo
|
|
44
|
+
cd excuseops-demo
|
|
45
|
+
npm install --ignore-scripts /absolute/path/to/excuse-ops-0.1.0.tgz
|
|
46
|
+
npx --no-install excuse setup
|
|
47
|
+
npx --no-install excuse --help
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`setup` explains the dependency download and asks for consent. It creates a release-specific isolated Python environment and installs the bundled application wheel plus the hash-pinned Z3 wheel. It does not modify system Python. Installing the npm package alone does not run setup: there is no postinstall hook or implicit installation on ordinary commands.
|
|
51
|
+
|
|
52
|
+
Network access is needed for initial dependency setup. Once setup succeeds, scenario commands work offline without remote services or a repository checkout. Repeating setup verifies and reuses a healthy runtime; an update prepares its own runtime without replacing project databases.
|
|
53
|
+
|
|
54
|
+
### Registry installation (early access)
|
|
55
|
+
|
|
56
|
+
Install the early-access channel:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
npm install excuse-ops@next
|
|
60
|
+
npx --no-install excuse setup
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Use `next` explicitly; it does not imply a stable `latest` release. If the registry package is not yet available, use the local tarball installation above. No global installation is required.
|
|
64
|
+
|
|
65
|
+
## Quick start: the Tuesday contradiction
|
|
66
|
+
|
|
67
|
+
The commands below use `npx --no-install excuse` from the directory where you installed the package. Prompts are interactive; confirmation defaults to **no**.
|
|
68
|
+
|
|
69
|
+
### 1. Create and configure a project
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
npx --no-install excuse init
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Enter a project name, such as `Tuesday`, and choose a blank project. Run `config` once for each item below, review the proposed change, and confirm saving it:
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
npx --no-install excuse config
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
| Category | Operation | Values to enter |
|
|
82
|
+
| --- | --- | --- |
|
|
83
|
+
| `people` | `add` | `Boss`, then `Friend` in a separate invocation |
|
|
84
|
+
| `places` | `add` | `Clinic`, then `Arcade` in a separate invocation |
|
|
85
|
+
| `activities` | `add` | `Dentist`, then `Gaming` in a separate invocation |
|
|
86
|
+
| `travel` | `set` | `Clinic`, `Arcade`, `25` minutes, `30` kilometers |
|
|
87
|
+
|
|
88
|
+
Travel is bidirectional. Do not add a people-link yet.
|
|
89
|
+
|
|
90
|
+
### 2. Record the accounts
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
npx --no-install excuse add "Dentist at Clinic, 10:00-11:00, told Boss"
|
|
94
|
+
npx --no-install excuse mark-sent <dentist-id>
|
|
95
|
+
npx --no-install excuse merge
|
|
96
|
+
npx --no-install excuse add "Gaming at Arcade, 11:05-12:30, told Friend; may move 11:00-12:30"
|
|
97
|
+
npx --no-install excuse status
|
|
98
|
+
npx --no-install excuse diff
|
|
99
|
+
npx --no-install excuse merge
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Replace `<dentist-id>` with the actual ID returned by `add`; do not type angle-bracket placeholders into your shell. Confirm `mark-sent` only to record that the account was communicated—this action sends nothing.
|
|
103
|
+
|
|
104
|
+
Dentist alone is consistent. Adding Gaming leaves a five-minute gap for a 25-minute trip: the combined merge is **blocked**, even though each audience's story is individually consistent. The entered distance implies 360 km/h over that gap. Without a communication path, exposure is **safe-for-now**, not logically consistent. A blocked merge deliberately returns exit **1** and saves its report; do not use a success-only `&&` chain for the whole example.
|
|
105
|
+
|
|
106
|
+
### 3. Inspect exposure, preview and apply a repair
|
|
107
|
+
|
|
108
|
+
```sh
|
|
109
|
+
npx --no-install excuse link Boss Friend --label "group chat"
|
|
110
|
+
npx --no-install excuse merge
|
|
111
|
+
npx --no-install excuse resolve
|
|
112
|
+
npx --no-install excuse resolve --apply <plan-id>
|
|
113
|
+
npx --no-install excuse merge
|
|
114
|
+
npx --no-install excuse log
|
|
115
|
+
npx --no-install excuse log --at <action-id>
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Use the plan ID printed by `resolve` and an action ID shown by `log`. Linking changes exposure to **danger** without fixing the clash. The optimal permitted repair moves Gaming by +20 minutes to **11:25–12:50**, preserving its 85-minute duration and the sent Dentist version.
|
|
119
|
+
|
|
120
|
+
A preview does not change account content or the domain revision. Applying a current plan appends new versions; stale or already-consumed plans are rejected. Replay is read-only: viewing an older blocked/disconnected state does not replace today's repaired workspace.
|
|
121
|
+
|
|
122
|
+
## Statement syntax
|
|
123
|
+
|
|
124
|
+
Entities must be configured first. Input is a small, explicit language—not arbitrary natural-language interpretation.
|
|
125
|
+
|
|
126
|
+
```text
|
|
127
|
+
Dentist at Clinic, 10:00-11:00, told Boss
|
|
128
|
+
Gaming at Arcade, 11:05-12:30, told Friend; may move 11:00-12:30
|
|
129
|
+
Groceries at Market, 45 min, starts 09:00-09:30, told Parent
|
|
130
|
+
Lunch at Cafe, 12:00-13:00, told Boss; event team-lunch; after s1
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The last two examples require their own configured names; `s1` must be an actual active statement ID. Start windows express uncertainty, while `may move` bounds permitted replacement starts. Repairs preserve window width, duration, activity, place, audience, shared-event identity and ordering references.
|
|
134
|
+
|
|
135
|
+
Times use one day at integer-minute resolution. Intervals are half-open; `24:00` is allowed only as an exclusive end. No overnight or multiple-date scheduling. Configured names cannot contain commas, semicolons, newlines, ` at ` or ` told `.
|
|
136
|
+
|
|
137
|
+
## Commands
|
|
138
|
+
|
|
139
|
+
Prefix these commands with `npx --no-install excuse`, or use `excuse` directly when its installed bin directory is on your PATH.
|
|
140
|
+
|
|
141
|
+
| Command | Purpose |
|
|
142
|
+
| --- | --- |
|
|
143
|
+
| `setup` | Explicitly prepare or verify the isolated runtime |
|
|
144
|
+
| `init` | Create a blank or guided project without overwriting an existing one |
|
|
145
|
+
| `config` | Inspect/edit entities, travel, communication links and overlap permissions |
|
|
146
|
+
| `add "<statement>"` | Save an immutable draft version |
|
|
147
|
+
| `edit <id>` | Replace an active draft with a new version |
|
|
148
|
+
| `status`, `diff` | Inspect current accounts, pending changes and report staleness |
|
|
149
|
+
| `merge` | Check stories/global consistency, save a report and explain clashes |
|
|
150
|
+
| `link <person> <person> --label "<label>"` | Record a communication connection; send nothing |
|
|
151
|
+
| `resolve` | Preview permitted repairs without applying them |
|
|
152
|
+
| `resolve --apply <plan-id>` | Revalidate and atomically apply a current plan |
|
|
153
|
+
| `mark-sent <id>` | Confirm a communicated version's lock |
|
|
154
|
+
| `correct <id>`, `retract <id>` | Explicitly supersede or deactivate an account while retaining history |
|
|
155
|
+
| `log`, `log --at <action-id>` | Inspect history or replay an original snapshot read-only |
|
|
156
|
+
| `--help`, `--version` | Show usage or the installed application version |
|
|
157
|
+
|
|
158
|
+
`merge`, `resolve` and `log` accept `--budget-seconds`. A timeout is unknown/incomplete, never proof of consistency or impossibility. Incomplete repairs are labeled best-found rather than optimal.
|
|
159
|
+
|
|
160
|
+
## Data, safety and exit codes
|
|
161
|
+
|
|
162
|
+
Projects live in **`.excuseops/project.sqlite`** beneath the directory where you run `init`. Commands find the nearest initialized ancestor, so child directories use the same project. Back up this database to retain the scenario and its history; runtimes are separate and are not a backup of project data.
|
|
163
|
+
|
|
164
|
+
Managed runtimes live under **`~/Library/Application Support/ExcuseOps`**, overridden by `EXCUSE_OPS_HOME`. Never delete an unfamiliar runtime directory or setup lock as an automatic recovery step. See the detailed guide for ownership/retry behavior.
|
|
165
|
+
|
|
166
|
+
| Exit | Meaning |
|
|
167
|
+
| --- | --- |
|
|
168
|
+
| `0` | Successful command/cancellation, consistent merge or completed valid repair search |
|
|
169
|
+
| `1` | Proven contradiction or proven absence of a permitted repair |
|
|
170
|
+
| `2` | Invalid input/usage, missing project/prerequisite or unsupported runtime |
|
|
171
|
+
| `3` | Unknown/timed-out/incomplete solving, including a validated incomplete candidate |
|
|
172
|
+
| `4` | Storage/runtime failure or rejected stale/concurrent mutation |
|
|
173
|
+
| `130` | User interruption |
|
|
174
|
+
|
|
175
|
+
Sent versions cannot be moved by ordinary edits or repairs. Explicit corrections/retractions retain originals. No command delivers a message, reads live maps or synchronizes a cloud account.
|
|
176
|
+
|
|
177
|
+
## Verification and known limits
|
|
178
|
+
|
|
179
|
+
The CLI is checked with real Z3 regressions, a separately authored specification-only AI exhaustive oracle, seeded comparisons and installation of the actual npm tarball outside the checkout. The independent oracle is bounded; its results are not extrapolated to exhaustive 30-account proofs.
|
|
180
|
+
|
|
181
|
+
Recorded verification includes 138 Python tests, 157 oracle cases, 100 seeded agreements, seven Node launcher tests and an installed-consumer acceptance run covering lifecycle, repair, replay, interruption, quoting, rollback, concurrent writers and upgrades.
|
|
182
|
+
|
|
183
|
+
Performance is a measured limitation, not a blanket guarantee: at a one-second budget, 14 of 100 measured 30-account repair searches returned valid best-found candidates without completing optimality proofs. The detailed guide records timings, workload scope and provenance.
|
|
184
|
+
|
|
185
|
+
## Build and development
|
|
186
|
+
|
|
187
|
+
From a checkout with the already-installed pinned development tools (setuptools 80.9.0, pytest 9.0.2 and z3-solver 4.16.0.0):
|
|
188
|
+
|
|
189
|
+
```sh
|
|
190
|
+
PYTHONPATH=src .venv/bin/python -m pytest tests -q
|
|
191
|
+
.venv/bin/python scripts/build_npm.py
|
|
192
|
+
npm pack ./npm
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
The builder copies this root README into the npm payload; do not maintain a second handwritten package README. The tarball bundles the application wheel, launcher, explicit bootstrap and dependency lock—not a prebuilt environment or project data.
|
|
196
|
+
|
|
197
|
+
To repeat the disposable installed-artifact checks, including setup downloads and a real upgrade, only in an environment where these installations are authorized:
|
|
198
|
+
|
|
199
|
+
```sh
|
|
200
|
+
.venv/bin/python scripts/verify_artifact.py --allow-install
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## Documentation
|
|
204
|
+
|
|
205
|
+
- [Full CLI guide, grammar, setup and measured limitations](https://github.com/danilocasim/excuse-ops/blob/main/docs/CLI_MVP.md)
|
|
206
|
+
- [Independent checker provenance and supported bounds](https://github.com/danilocasim/excuse-ops/blob/main/verification/ORACLE_PROVENANCE.md)
|
|
207
|
+
- [Implementation and acceptance evidence](https://github.com/danilocasim/excuse-ops/blob/main/.plan/excuse-mvp/TODO.md)
|
|
208
|
+
- [Broader CLI/web product plan](https://github.com/danilocasim/excuse-ops/blob/main/docs/CLI_WEB_IMPLEMENTATION_PLAN.md)
|
|
209
|
+
|
|
210
|
+
This package delivers the CLI-only milestone. A web UI, multiple dates, actual messaging, cloud collaboration and AI interpretation of arbitrary prose are outside this release.
|
|
211
|
+
|
|
212
|
+
## License
|
|
213
|
+
|
|
214
|
+
MIT. See [LICENSE](https://github.com/danilocasim/excuse-ops/blob/main/LICENSE). The bundled application is MIT-licensed; separately installed dependencies retain their own licenses.
|
|
Binary file
|
package/bin/excuse.mjs
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
3
|
+
import { readFileSync, statSync, realpathSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { dirname, join, resolve, delimiter } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
8
|
+
|
|
9
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
10
|
+
|
|
11
|
+
class PrerequisiteError extends Error {}
|
|
12
|
+
|
|
13
|
+
function exists(path) {
|
|
14
|
+
try { statSync(path); return true; }
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return false;
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function findPython(release) {
|
|
22
|
+
const override = process.env.EXCUSE_OPS_PYTHON;
|
|
23
|
+
const candidates = override ? [resolve(override)] :
|
|
24
|
+
['python3.14', 'python3', 'python'].flatMap(name =>
|
|
25
|
+
(process.env.PATH ?? '').split(delimiter).filter(Boolean).map(directory => join(directory, name)));
|
|
26
|
+
for (const candidate of [...new Set(candidates)]) {
|
|
27
|
+
const probe = spawnSync(candidate, ['-I', '-c',
|
|
28
|
+
'import json,sys; print(json.dumps({"version":list(sys.version_info[:3]),"implementation":sys.implementation.name,"executable":sys.executable}))'],
|
|
29
|
+
{ encoding:'utf8', timeout:5000, shell:false });
|
|
30
|
+
if (probe.error) {
|
|
31
|
+
if (probe.error.code === 'ENOENT' || probe.error.code === 'ENOTDIR') continue;
|
|
32
|
+
throw probe.error; // Permission failures must never trigger another route.
|
|
33
|
+
}
|
|
34
|
+
if (probe.status !== 0) continue;
|
|
35
|
+
let info;
|
|
36
|
+
try { info = JSON.parse(probe.stdout); } catch { continue; }
|
|
37
|
+
if (info.implementation === 'cpython' && isDeepStrictEqual(info.version, release.python)) return info.executable;
|
|
38
|
+
}
|
|
39
|
+
throw new PrerequisiteError(`CPython ${release.python.join('.')} is required. Install it yourself, or set EXCUSE_OPS_PYTHON to its executable path; Python is never downloaded.`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function run(command, args) {
|
|
43
|
+
return new Promise(resolveExit => {
|
|
44
|
+
// One isolated POSIX process group owns Python and its setup descendants.
|
|
45
|
+
// The terminal signals Node's group; Node forwards once to this other group.
|
|
46
|
+
const child = spawn(command, args, { stdio:'inherit', shell:false, detached:true });
|
|
47
|
+
let signalled = null;
|
|
48
|
+
let failed = false;
|
|
49
|
+
function forward(signal) {
|
|
50
|
+
if (signalled || !child.pid) return;
|
|
51
|
+
signalled = signal;
|
|
52
|
+
try { process.kill(-child.pid, signal); }
|
|
53
|
+
catch (error) { if (error.code !== 'ESRCH') console.error(`Cannot signal runtime: ${error.message}`); }
|
|
54
|
+
}
|
|
55
|
+
const interrupt = () => forward('SIGINT');
|
|
56
|
+
const terminate = () => forward('SIGTERM');
|
|
57
|
+
process.on('SIGINT', interrupt);
|
|
58
|
+
process.on('SIGTERM', terminate);
|
|
59
|
+
child.on('error', error => { failed = true; console.error(`Cannot launch runtime: ${error.message}`); });
|
|
60
|
+
child.on('close', (code, signal) => {
|
|
61
|
+
process.off('SIGINT', interrupt);
|
|
62
|
+
process.off('SIGTERM', terminate);
|
|
63
|
+
resolveExit(failed ? 4 : signalled === 'SIGINT' || signal === 'SIGINT' ? 130 :
|
|
64
|
+
signalled === 'SIGTERM' || signal === 'SIGTERM' ? 143 : code ?? 4);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function main() {
|
|
70
|
+
const release = JSON.parse(readFileSync(join(root, 'release.json'), 'utf8'));
|
|
71
|
+
if (process.platform !== release.platform || process.arch !== release.arch || process.versions.node !== release.node) {
|
|
72
|
+
throw new PrerequisiteError(`This artifact is verified only for ${release.platform}/${release.arch}, Node ${release.node}.`);
|
|
73
|
+
}
|
|
74
|
+
const args = process.argv.slice(2);
|
|
75
|
+
if (args[0] === 'setup') {
|
|
76
|
+
return run(findPython(release), ['-I', join(root, 'bootstrap.py'), '--package-root', root, ...args.slice(1)]);
|
|
77
|
+
}
|
|
78
|
+
const override = process.env.EXCUSE_OPS_HOME;
|
|
79
|
+
const expanded = override === '~' ? homedir() :
|
|
80
|
+
override?.startsWith('~/') ? join(homedir(), override.slice(2)) : override;
|
|
81
|
+
const homePath = expanded ? resolve(expanded) : join(homedir(), 'Library', 'Application Support', 'ExcuseOps');
|
|
82
|
+
const home = exists(homePath) ? realpathSync(homePath) : homePath;
|
|
83
|
+
const runtime = join(home, 'runtimes', release.runtime_tag);
|
|
84
|
+
const lock = join(home, 'runtimes', `.${release.runtime_tag}.setup.lock`);
|
|
85
|
+
const marker = join(runtime, 'ready.json');
|
|
86
|
+
const python = join(runtime, 'bin', 'python');
|
|
87
|
+
if (exists(lock) || !exists(marker) || !exists(python)) {
|
|
88
|
+
throw new PrerequisiteError('No ready runtime for this release. Run excuse setup explicitly.');
|
|
89
|
+
}
|
|
90
|
+
let ready;
|
|
91
|
+
try { ready = JSON.parse(readFileSync(marker, 'utf8')); }
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (error.code === 'EACCES' || error.code === 'EPERM') throw error;
|
|
94
|
+
throw new PrerequisiteError('Runtime readiness metadata is invalid. Run excuse setup to repair it.');
|
|
95
|
+
}
|
|
96
|
+
const expected = Object.fromEntries(['version','runtime_tag','files','python','platform','arch'].map(key => [key,release[key]]));
|
|
97
|
+
if (!isDeepStrictEqual(ready, expected) || realpathSync(runtime) !== resolve(runtime)) {
|
|
98
|
+
throw new PrerequisiteError('Runtime identity does not match this release. Run excuse setup.');
|
|
99
|
+
}
|
|
100
|
+
return run(python, ['-I', '-m', 'excuse_ops', ...args]);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
try { process.exitCode = await main(); }
|
|
104
|
+
catch (error) {
|
|
105
|
+
console.error(error.message);
|
|
106
|
+
process.exitCode = error instanceof PrerequisiteError ? 2 : 4;
|
|
107
|
+
}
|
package/bootstrap.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Explicit, stdlib-only installation of an npm release's isolated runtime."""
|
|
2
|
+
import argparse
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import platform
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Iterator, Sequence
|
|
12
|
+
import venv
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SetupError(RuntimeError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PrerequisiteError(SetupError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def digest(path: Path) -> str:
|
|
24
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def release(package: Path) -> dict:
|
|
28
|
+
try:
|
|
29
|
+
data = json.loads((package / 'release.json').read_text())
|
|
30
|
+
if data['schema'] != 1:
|
|
31
|
+
raise PrerequisiteError('Unsupported release manifest.')
|
|
32
|
+
for name, expected in data['files'].items():
|
|
33
|
+
target = (package / name).resolve()
|
|
34
|
+
if not target.is_relative_to(package.resolve()) or digest(target) != expected:
|
|
35
|
+
raise SetupError(f'Packaged artifact integrity failure: {name}')
|
|
36
|
+
if not data['runtime_tag'].startswith(data['version'] + '-') or '/' in data['runtime_tag']:
|
|
37
|
+
raise SetupError('Invalid runtime identity.')
|
|
38
|
+
return data
|
|
39
|
+
except (KeyError, TypeError, ValueError) as error:
|
|
40
|
+
raise SetupError('Invalid release metadata.') from error
|
|
41
|
+
except FileNotFoundError as error:
|
|
42
|
+
raise PrerequisiteError('An intact npm artifact is required; use excuse setup or pass --package-root PATH.') from error
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def home() -> Path:
|
|
46
|
+
override = os.environ.get('EXCUSE_OPS_HOME')
|
|
47
|
+
return Path(override).expanduser().resolve() if override else Path.home() / 'Library' / 'Application Support' / 'ExcuseOps'
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def runtime_path(data: dict) -> Path:
|
|
51
|
+
return home() / 'runtimes' / data['runtime_tag']
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def identity(data: dict) -> dict:
|
|
55
|
+
return {key: data[key] for key in ('version', 'runtime_tag', 'files', 'python', 'platform', 'arch')}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _atomic_json(path: Path, value: dict) -> None:
|
|
59
|
+
temporary = path.with_name(path.name + f'.{os.getpid()}.tmp')
|
|
60
|
+
try:
|
|
61
|
+
with temporary.open('x') as stream:
|
|
62
|
+
json.dump(value, stream, sort_keys=True)
|
|
63
|
+
stream.flush()
|
|
64
|
+
os.fsync(stream.fileno())
|
|
65
|
+
temporary.replace(path)
|
|
66
|
+
finally:
|
|
67
|
+
temporary.unlink(missing_ok=True)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@contextmanager
|
|
71
|
+
def _ownership(lock: Path, data: dict) -> Iterator[None]:
|
|
72
|
+
try:
|
|
73
|
+
stream = lock.open('x')
|
|
74
|
+
except FileExistsError as error:
|
|
75
|
+
raise SetupError(f'Setup ownership already exists: {lock}. Verify its PID and recover explicitly; no automatic lock removal.') from error
|
|
76
|
+
try:
|
|
77
|
+
with stream:
|
|
78
|
+
json.dump({'pid': os.getpid(), 'runtime_tag': data['runtime_tag']}, stream)
|
|
79
|
+
yield
|
|
80
|
+
finally:
|
|
81
|
+
lock.unlink()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _verify(directory: Path, data: dict) -> bool:
|
|
85
|
+
python = directory / 'bin' / 'python'
|
|
86
|
+
if not python.exists():
|
|
87
|
+
return False
|
|
88
|
+
script = '''import importlib,importlib.metadata as m,json,sys,z3
|
|
89
|
+
expected=json.loads(sys.argv[1])
|
|
90
|
+
assert list(sys.version_info[:3]) == expected['python']
|
|
91
|
+
assert sys.prefix != sys.base_prefix
|
|
92
|
+
assert m.version('excuse-ops') == expected['version']
|
|
93
|
+
assert m.version('z3-solver') == expected['solver_version']
|
|
94
|
+
for name in expected['modules']: importlib.import_module('excuse_ops.'+name)
|
|
95
|
+
x=z3.Int('setup_probe'); solver=z3.Solver(); solver.add(x==7)
|
|
96
|
+
assert solver.check()==z3.sat and solver.model().eval(x).as_long()==7
|
|
97
|
+
'''
|
|
98
|
+
result = subprocess.run([str(python), '-I', '-c', script, json.dumps(data)], capture_output=True, text=True, timeout=30)
|
|
99
|
+
return result.returncode == 0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _install(directory: Path, package: Path, data: dict) -> None:
|
|
103
|
+
venv.EnvBuilder(with_pip=True).create(directory)
|
|
104
|
+
requirements = directory / 'release-requirements.txt'
|
|
105
|
+
wheel = package / data['wheel']
|
|
106
|
+
requirements.write_text((package / 'requirements.lock').read_text() + '\n' + wheel.as_uri() + ' --hash=sha256:' + data['files'][data['wheel']] + '\n')
|
|
107
|
+
# An explicitly approved retry must repair files even if installed metadata
|
|
108
|
+
# still claims the correct version after an interrupted/corrupt installation.
|
|
109
|
+
subprocess.run([
|
|
110
|
+
str(directory / 'bin' / 'python'), '-I', '-m', 'pip', '--isolated', 'install',
|
|
111
|
+
'--disable-pip-version-check', '--no-input', '--only-binary=:all:',
|
|
112
|
+
'--require-hashes', '--force-reinstall', '-r', str(requirements),
|
|
113
|
+
], check=True)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def setup(package: Path) -> int:
|
|
117
|
+
package = package.resolve()
|
|
118
|
+
data = release(package)
|
|
119
|
+
if (sys.implementation.name != 'cpython' or list(sys.version_info[:3]) != data['python']
|
|
120
|
+
or sys.platform != data['platform'] or platform.machine() != data['arch']):
|
|
121
|
+
raise PrerequisiteError(f"This release requires CPython {'.'.join(map(str,data['python']))} on {data['platform']}/{data['arch']}.")
|
|
122
|
+
directory = runtime_path(data)
|
|
123
|
+
lock = directory.parent / ('.' + data['runtime_tag'] + '.setup.lock')
|
|
124
|
+
ready = directory / 'ready.json'
|
|
125
|
+
owner = directory / 'owner.json'
|
|
126
|
+
expected = identity(data)
|
|
127
|
+
if lock.exists():
|
|
128
|
+
raise SetupError(f'Setup ownership exists: {lock}; verify its PID before explicit recovery.')
|
|
129
|
+
if directory.exists():
|
|
130
|
+
if directory.is_symlink() or not owner.is_file() or json.loads(owner.read_text()) != expected:
|
|
131
|
+
raise SetupError(f'Refusing to modify an unknown/incompatible directory: {directory}')
|
|
132
|
+
if ready.is_file() and json.loads(ready.read_text()) == expected and _verify(directory, data):
|
|
133
|
+
print(f'Runtime verified and reused: {directory}')
|
|
134
|
+
return 0
|
|
135
|
+
print(f'Runtime: {directory}')
|
|
136
|
+
print(f"Installs bundled ExcuseOps {data['version']} and downloads hash-pinned z3-solver {data['solver_version']}. System Python and project data are not changed.")
|
|
137
|
+
try:
|
|
138
|
+
answer = input('Install/repair this isolated runtime? [y/N]: ').strip().lower()
|
|
139
|
+
except EOFError:
|
|
140
|
+
answer = ''
|
|
141
|
+
if answer not in ('y', 'yes'):
|
|
142
|
+
print('Cancelled. Nothing installed.')
|
|
143
|
+
return 0
|
|
144
|
+
directory.parent.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
with _ownership(lock, data):
|
|
146
|
+
# Recheck after acquiring ownership: another setup may have completed
|
|
147
|
+
# while this process was waiting for consent.
|
|
148
|
+
if directory.exists():
|
|
149
|
+
if directory.is_symlink() or not owner.is_file() or json.loads(owner.read_text()) != expected:
|
|
150
|
+
raise SetupError(f'Refusing to modify an unknown/incompatible directory: {directory}')
|
|
151
|
+
if ready.is_file() and json.loads(ready.read_text()) == expected and _verify(directory, data):
|
|
152
|
+
print(f'Runtime verified and reused: {directory}')
|
|
153
|
+
return 0
|
|
154
|
+
else:
|
|
155
|
+
directory.mkdir()
|
|
156
|
+
_atomic_json(owner, expected)
|
|
157
|
+
ready.unlink(missing_ok=True)
|
|
158
|
+
_install(directory, package, data)
|
|
159
|
+
if not _verify(directory, data):
|
|
160
|
+
raise SetupError('Installed runtime failed application/Z3 verification; not marked ready. Run setup to retry.')
|
|
161
|
+
_atomic_json(ready, expected)
|
|
162
|
+
print(f'Runtime ready: {directory}')
|
|
163
|
+
return 0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
167
|
+
parser = argparse.ArgumentParser(prog='excuse setup', allow_abbrev=False)
|
|
168
|
+
parser.add_argument('--package-root', type=Path, default=Path(__file__).resolve().parent)
|
|
169
|
+
args = parser.parse_args(argv)
|
|
170
|
+
try:
|
|
171
|
+
return setup(args.package_root)
|
|
172
|
+
except PrerequisiteError as error:
|
|
173
|
+
print(str(error), file=sys.stderr)
|
|
174
|
+
return 2
|
|
175
|
+
except (SetupError, OSError, ValueError, subprocess.SubprocessError) as error:
|
|
176
|
+
print(f'Setup failed: {error}', file=sys.stderr)
|
|
177
|
+
return 4
|
|
178
|
+
except KeyboardInterrupt:
|
|
179
|
+
print('Setup interrupted; no incomplete runtime was marked ready.', file=sys.stderr)
|
|
180
|
+
return 130
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
if __name__ == '__main__':
|
|
184
|
+
raise SystemExit(main())
|
package/package.json
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
{
|
|
2
|
+
"bin": {
|
|
3
|
+
"excuse": "bin/excuse.mjs"
|
|
4
|
+
},
|
|
5
|
+
"bugs": {
|
|
6
|
+
"url": "https://github.com/danilocasim/excuse-ops/issues"
|
|
7
|
+
},
|
|
8
|
+
"cpu": [
|
|
9
|
+
"arm64"
|
|
10
|
+
],
|
|
11
|
+
"description": "Local-first version control for your personal mythology",
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": "26.7.0"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE",
|
|
18
|
+
"bin/excuse.mjs",
|
|
19
|
+
"bootstrap.py",
|
|
20
|
+
"release.json",
|
|
21
|
+
"requirements.lock",
|
|
22
|
+
"artifacts/excuse_ops-0.1.0-py3-none-any.whl"
|
|
23
|
+
],
|
|
24
|
+
"homepage": "https://github.com/danilocasim/excuse-ops#readme",
|
|
25
|
+
"license": "MIT",
|
|
2
26
|
"name": "excuse-ops",
|
|
3
|
-
"
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
27
|
+
"os": [
|
|
28
|
+
"darwin"
|
|
29
|
+
],
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public",
|
|
32
|
+
"registry": "https://registry.npmjs.org/",
|
|
33
|
+
"tag": "next"
|
|
34
|
+
},
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/danilocasim/excuse-ops.git"
|
|
38
|
+
},
|
|
39
|
+
"type": "module",
|
|
40
|
+
"version": "0.1.0"
|
|
41
|
+
}
|
package/release.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"arch": "arm64",
|
|
3
|
+
"files": {
|
|
4
|
+
"artifacts/excuse_ops-0.1.0-py3-none-any.whl": "aff2ab6c74530a04f5d88fbc071d4adeb05eaa5222041f83aa078482a8781ddc",
|
|
5
|
+
"bootstrap.py": "44ec6e6711f53bfb62b306a0317e73f1c2db9454b8e7963045e564dba2733f6d",
|
|
6
|
+
"requirements.lock": "7f30566ba72b3ed4e8d24fa601d7b972273215312c3ef7604bebfba7c25995d3"
|
|
7
|
+
},
|
|
8
|
+
"modules": [
|
|
9
|
+
"__init__",
|
|
10
|
+
"application",
|
|
11
|
+
"bootstrap",
|
|
12
|
+
"checker",
|
|
13
|
+
"cli",
|
|
14
|
+
"domain",
|
|
15
|
+
"history",
|
|
16
|
+
"parser",
|
|
17
|
+
"repair",
|
|
18
|
+
"reports",
|
|
19
|
+
"storage"
|
|
20
|
+
],
|
|
21
|
+
"node": "26.7.0",
|
|
22
|
+
"platform": "darwin",
|
|
23
|
+
"python": [
|
|
24
|
+
3,
|
|
25
|
+
14,
|
|
26
|
+
6
|
|
27
|
+
],
|
|
28
|
+
"runtime_tag": "0.1.0-darwin-arm64-cpython-3146-aff2ab6c74530a04",
|
|
29
|
+
"schema": 1,
|
|
30
|
+
"solver_version": "4.16.0.0",
|
|
31
|
+
"version": "0.1.0",
|
|
32
|
+
"wheel": "artifacts/excuse_ops-0.1.0-py3-none-any.whl"
|
|
33
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
z3-solver==4.16.0.0 --hash=sha256:cc52843cfdd3d3f2cd24bedc62e71c18af8c8b7b23fb05e639ab60b01b5f8f2f
|