szpont 0.2.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/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # szpont
2
+
3
+ Install-and-run for **[Diplomat](https://github.com/latekvo/Diplomat)** (Szpont
4
+ Yon) - the menu-bar / system-tray applet that reviews your pull requests, fixes
5
+ your own, resolves conflicts, and keeps one device per agent.
6
+
7
+ ```bash
8
+ npx szpont # or: npm i -g szpont && szpont
9
+ ```
10
+
11
+ That is the whole install. macOS and Linux.
12
+
13
+ ## What it actually does
14
+
15
+ Diplomat is built out of the checkout it runs from - a SwiftUI bundle on macOS, a
16
+ PySide6 package plus the `diplomat-core` Swift binary on Linux - and it keeps
17
+ *being* that checkout: the Update button, the daily 06:00 timer and the commit the
18
+ panel reports are all git operations on it. A package holding a copy would be a
19
+ second Diplomat that no update could reach.
20
+
21
+ So this package is the steps in between, and every one of them runs a script that
22
+ is already in the repository:
23
+
24
+ | | macOS | Linux |
25
+ |---|---|---|
26
+ | get it | `git clone` into `~/.diplomat/checkout`, or fast-forward it | same |
27
+ | build it | `install/build-app.sh` → `Diplomat.app` | `install/build-core.sh` when the prompt binary is missing |
28
+ | set it up | - | a venv at `~/.diplomat/venv` with `requirements.txt` in it |
29
+ | start it | `open Diplomat.app` | the package's own `./diplomat`, on the venv's interpreter |
30
+
31
+ Nothing is rebuilt that does not need to be: a venv is left alone until
32
+ `requirements.txt` changes, and the Swift prompt binary is only built when the
33
+ applet would not find one. `git` is needed to fetch Diplomat, and a Swift
34
+ toolchain to build it - if one is missing you are told which and where to get it,
35
+ before anything is downloaded.
36
+
37
+ ```
38
+ szpont --plan # print what would be run, as JSON, and do none of it
39
+ szpont --no-update # launch the checkout as it stands
40
+ szpont -- --dump # everything after -- goes to the applet
41
+ ```
42
+
43
+ `DIPLOMAT_SELF_REPO` points at a checkout of your own - the same variable the
44
+ running applet reads, and one this launcher never updates, because a working copy
45
+ may have work in it. `DIPLOMAT_REPO_URL` clones from a fork.
46
+
47
+ ## The same name on both indexes
48
+
49
+ `pip install szpont` installs the same launcher (plus the typed
50
+ [SzpontNet](https://github.com/latekvo/Diplomat/tree/main/packages/szpont)
51
+ bindings, which is what that name means on PyPI). The two implementations are
52
+ separate files in separate languages, so what keeps them one command is
53
+ `test/parity-with-python.mjs`: every machine shape in `test/scenarios.mjs` goes
54
+ through both planners, and the two answers have to be identical.
55
+
56
+ ## Its own tests
57
+
58
+ ```bash
59
+ npm test # plan, cli, a real clone-build-launch, and the parity check
60
+ ```
61
+
62
+ The end-to-end test clones from a git repository it makes on disk and runs the
63
+ whole sequence into stub build and launch scripts, so it needs neither the network
64
+ nor a Swift toolchain.
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "szpont",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "description": "Install-and-run for Diplomat (Szpont Yon): fetches the checkout, builds it, and starts the applet.",
6
+ "keywords": ["diplomat", "szpont", "szpontnet", "launcher", "menu-bar", "tray"],
7
+ "license": "MIT",
8
+ "author": "Ignacy Łątka",
9
+ "homepage": "https://github.com/latekvo/Diplomat",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/latekvo/Diplomat.git",
13
+ "directory": "packages/szpont-npm"
14
+ },
15
+ "bugs": "https://github.com/latekvo/Diplomat/issues",
16
+ "bin": {
17
+ "szpont": "src/cli.js"
18
+ },
19
+ "files": ["src", "README.md"],
20
+ "engines": {
21
+ "node": ">=18"
22
+ },
23
+ "os": ["darwin", "linux"],
24
+ "scripts": {
25
+ "test": "node test/run-all.mjs"
26
+ }
27
+ }
package/src/cli.js ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ // The `szpont` / `npx szpont` entry point. Everything it does is in launcher.js,
3
+ // which the tests import directly — this file exists so that the bin has no logic
4
+ // of its own to test.
5
+
6
+ import { main } from './launcher.js';
7
+
8
+ process.exitCode = main(process.argv.slice(2));
@@ -0,0 +1,339 @@
1
+ // The npm twin of `packages/szpont/szpont_launcher.py`, line for line where it
2
+ // matters: `probe()` reads the machine, `plan()` turns those facts into the steps
3
+ // that will run, and `run()` runs them. Splitting it that way is what lets
4
+ // `test/parity-with-python.mjs` hand both implementations one set of synthetic
5
+ // facts and demand the same plan back — the two are published under the same name
6
+ // to the two indexes, and a `szpont` that meant different things depending on
7
+ // which one you installed from would be the whole failure mode.
8
+ //
9
+ // Why a bootstrapper at all, rather than a package holding Diplomat: see the
10
+ // Python module's header. Short version — Diplomat is built out of the checkout it
11
+ // runs from, and updates itself as one.
12
+
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import { createHash } from 'node:crypto';
17
+ import { spawnSync } from 'node:child_process';
18
+
19
+ export const VERSION = '0.2.0';
20
+ export const DEFAULT_REPO_URL = 'https://github.com/latekvo/Diplomat.git';
21
+ export const SUPPORTED = ['darwin', 'linux'];
22
+ export const MIN_PYTHON = [3, 10];
23
+
24
+ // The venv and the managed checkout live under the state directory Diplomat
25
+ // already owns, so uninstalling is one `rm -rf ~/.diplomat` rather than a hunt.
26
+ const STATE_DIR = '.diplomat';
27
+
28
+ function which(name, env) {
29
+ const dirs = (env.PATH || '').split(path.delimiter).filter(Boolean);
30
+ return dirs.some((dir) => {
31
+ try {
32
+ fs.accessSync(path.join(dir, name), fs.constants.X_OK);
33
+ return true;
34
+ } catch {
35
+ return false;
36
+ }
37
+ });
38
+ }
39
+
40
+ // `python3 --version`, or null when there is no python3 to ask. Only ever called
41
+ // on Linux: a stock macOS has a /usr/bin/python3 that is a Command Line Tools
42
+ // stub, and *running* it pops the graphical installer at whoever typed `szpont`.
43
+ function python3Version(env) {
44
+ if (!which('python3', env)) return null;
45
+ const r = spawnSync('python3', ['--version'], { encoding: 'utf8', timeout: 30_000 });
46
+ if (r.status !== 0 || !r.stdout) return null;
47
+ const parts = r.stdout.trim().split(/\s+/);
48
+ return parts.length ? parts[parts.length - 1] : null;
49
+ }
50
+
51
+ function digestOf(file) {
52
+ try {
53
+ return createHash('sha256').update(fs.readFileSync(file)).digest('hex');
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ // A directory, not merely something with that name — the Python twin asks
60
+ // `is_dir()` here, and the two have to answer alike about the same disk.
61
+ function isDir(p) {
62
+ try {
63
+ return fs.statSync(p).isDirectory();
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ function readText(file) {
70
+ try {
71
+ return fs.readFileSync(file, 'utf8').trim();
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ // Whether the applet would find a `diplomat-core` to shell out to. The three
78
+ // candidates and their order are `promptcore.core_bin`'s; asking a different
79
+ // question here would rebuild a binary the applet can already see, or skip
80
+ // building one it cannot.
81
+ function coreBinPresent(home, env) {
82
+ if (env.DIPLOMAT_CORE_BIN && fs.existsSync(env.DIPLOMAT_CORE_BIN)) return true;
83
+ if (which('diplomat-core', env)) return true;
84
+ const data = env.XDG_DATA_HOME || path.join(home, '.local', 'share');
85
+ return fs.existsSync(path.join(data, 'diplomat', 'diplomat-core'));
86
+ }
87
+
88
+ export function probe(appArgs = [], { update = true, env = process.env } = {}) {
89
+ const home = env.HOME || os.homedir();
90
+ const platform = process.platform;
91
+ // DIPLOMAT_SELF_REPO is what the applet itself calls the checkout it lives in
92
+ // (selfupdate.repo_root), so pointing the launcher at a working copy and
93
+ // pointing the running applet at one are the same act.
94
+ const explicit = env.DIPLOMAT_SELF_REPO;
95
+ const checkout = explicit || path.join(home, STATE_DIR, 'checkout');
96
+
97
+ let state;
98
+ if (!fs.existsSync(checkout)) state = 'absent';
99
+ else if (isDir(path.join(checkout, 'packages', 'diplomat-platform'))) state = 'checkout';
100
+ else state = 'foreign';
101
+
102
+ const venv = path.join(home, STATE_DIR, 'venv');
103
+ const digest = digestOf(path.join(checkout, 'packages', 'diplomat-platform', 'linux', 'requirements.txt'));
104
+
105
+ return {
106
+ platform,
107
+ path: env.PATH || '',
108
+ checkout,
109
+ checkout_state: state,
110
+ // Only a checkout this launcher created is one it may move: a working copy
111
+ // someone named themselves is theirs, and a launcher that fast-forwarded it
112
+ // would be editing a tree with work in it.
113
+ managed: !explicit,
114
+ repo_url: env.DIPLOMAT_REPO_URL || DEFAULT_REPO_URL,
115
+ update: Boolean(update),
116
+ git: which('git', env),
117
+ swift: which('swift', env),
118
+ // Linux-only questions, and asked only there — see python3Version.
119
+ python3: platform === 'linux' ? python3Version(env) : null,
120
+ core_bin: platform === 'linux' ? coreBinPresent(home, env) : null,
121
+ venv,
122
+ venv_python: fs.existsSync(path.join(venv, 'bin', 'python')),
123
+ venv_current: digest !== null && readText(path.join(venv, '.szpont-requirements')) === digest,
124
+ args: [...appArgs],
125
+ };
126
+ }
127
+
128
+ function step(id, cmd, { cwd = null, env = {}, needs = null, optional = false } = {}) {
129
+ return { id, cmd, cwd, env, needs, optional };
130
+ }
131
+
132
+ // An optional step whose tool is missing is skipped, never a blocker: a checkout
133
+ // that cannot be fast-forwarded is still a checkout that runs.
134
+ function dropped(s, facts) {
135
+ return Boolean(s.optional && s.needs && !facts[s.needs]);
136
+ }
137
+
138
+ const MISSING = {
139
+ git: 'git is needed to fetch Diplomat',
140
+ swift: 'a Swift toolchain is needed to build Diplomat',
141
+ python3: 'python3 is needed to run the Linux applet',
142
+ };
143
+ const FIX = {
144
+ git: { darwin: 'xcode-select --install', linux: 'install git from your package manager' },
145
+ swift: {
146
+ darwin: 'install Xcode, or the Command Line Tools (xcode-select --install)',
147
+ linux: 'https://swift.org/install - swiftly is the easy path',
148
+ },
149
+ python3: { darwin: 'install python3', linux: 'install python3 from your package manager' },
150
+ };
151
+
152
+ function tooOld(version) {
153
+ const parts = String(version || '').split('.').slice(0, 2).map((p) => Number.parseInt(p, 10));
154
+ if (parts.length !== 2 || parts.some(Number.isNaN)) return false; // unreadable is not old
155
+ return parts[0] < MIN_PYTHON[0] || (parts[0] === MIN_PYTHON[0] && parts[1] < MIN_PYTHON[1]);
156
+ }
157
+
158
+ // What no step could fix: the wrong OS, or the wrong directory.
159
+ function unrunnable(facts) {
160
+ if (!SUPPORTED.includes(facts.platform)) {
161
+ return {
162
+ tool: null,
163
+ reason: `Diplomat runs on macOS and Linux, not ${facts.platform}`,
164
+ fix: 'run it on one of those',
165
+ };
166
+ }
167
+ if (facts.checkout_state === 'foreign') {
168
+ return {
169
+ tool: null,
170
+ reason: `${facts.checkout} exists but is not a Diplomat checkout`,
171
+ fix: 'remove it, or point DIPLOMAT_SELF_REPO at a real one',
172
+ };
173
+ }
174
+ return null;
175
+ }
176
+
177
+ // The first tool this plan needs and this machine has not got, or null. Reported
178
+ // rather than thrown, so `--plan` on a machine missing a toolchain still prints
179
+ // the whole plan and names what it is short of.
180
+ function blockedBy(facts, steps) {
181
+ for (const s of steps) {
182
+ if (!s.needs || dropped(s, facts) || facts[s.needs]) continue;
183
+ return { tool: s.needs, reason: MISSING[s.needs], fix: FIX[s.needs][facts.platform] };
184
+ }
185
+ // Only the interpreter a venv is about to be *made* from: an existing venv is
186
+ // whatever it was built with, and this launcher does not get to re-open that.
187
+ if (steps.some((s) => s.id === 'venv') && tooOld(facts.python3)) {
188
+ return {
189
+ tool: 'python3',
190
+ reason: `the applet needs Python ${MIN_PYTHON[0]}.${MIN_PYTHON[1]}+, and python3 is ${facts.python3}`,
191
+ fix: 'install a newer python3',
192
+ };
193
+ }
194
+ return null;
195
+ }
196
+
197
+ // What running probe()'s machine would do, as data. Pure: same facts in, same plan
198
+ // out, on either implementation and either OS. That is what makes `--plan` worth
199
+ // printing before anything is cloned or built, and what the parity test compares.
200
+ export function plan(facts) {
201
+ // Nothing is worth planning past these two: a machine that cannot run Diplomat
202
+ // and a directory that is not it are both answers, and a plan that offered to
203
+ // clone anyway would be describing a download that ends in the same message.
204
+ const stop = unrunnable(facts);
205
+ if (stop) return { platform: facts.platform, checkout: facts.checkout, steps: [], blocked: stop };
206
+
207
+ const macos = path.join(facts.checkout, 'packages', 'diplomat-platform', 'macos');
208
+ const linux = path.join(facts.checkout, 'packages', 'diplomat-platform', 'linux');
209
+ const args = [...facts.args];
210
+ const steps = [];
211
+
212
+ if (facts.checkout_state === 'absent') {
213
+ steps.push(step('clone', ['git', 'clone', facts.repo_url, facts.checkout], { needs: 'git' }));
214
+ } else if (facts.managed && facts.update) {
215
+ // Best-effort and fast-forward-only. The applet updates itself daily and on a
216
+ // button, so this is not the update path — it is what keeps a one-shot
217
+ // `npx szpont` from launching last month's commit.
218
+ steps.push(step('update', ['git', '-C', facts.checkout, 'pull', '--ff-only', '--quiet'], {
219
+ needs: 'git',
220
+ optional: true,
221
+ }));
222
+ }
223
+
224
+ if (facts.platform === 'darwin') {
225
+ steps.push(step('build', [path.join(macos, 'install', 'build-app.sh')], { cwd: macos, needs: 'swift' }));
226
+ // build-app.sh rebuilds unconditionally, so the bundle always matches the
227
+ // source it was just cloned or pulled from; `open` then detaches it from this
228
+ // terminal, which is where a menu-bar app belongs.
229
+ const launch = ['open', path.join(macos, 'Diplomat.app')];
230
+ if (args.length) launch.push('--args', ...args);
231
+ steps.push(step('launch', launch, { cwd: macos }));
232
+ } else if (facts.platform === 'linux') {
233
+ if (!facts.core_bin) {
234
+ steps.push(step('build-core', [path.join(linux, 'install', 'build-core.sh')], { cwd: linux, needs: 'swift' }));
235
+ }
236
+ // A system python3 is wanted for exactly one thing, creating the venv;
237
+ // everything after it runs on the venv's own interpreter.
238
+ if (!facts.venv_python) {
239
+ steps.push(step('venv', ['python3', '-m', 'venv', facts.venv], { needs: 'python3' }));
240
+ }
241
+ if (!facts.venv_current) {
242
+ steps.push(step('deps', [
243
+ path.join(facts.venv, 'bin', 'python'), '-m', 'pip', 'install', '--upgrade', '--quiet',
244
+ '-r', path.join(linux, 'requirements.txt'),
245
+ ]));
246
+ }
247
+ // The checkout's own launcher, with the venv's interpreter in front of it — so
248
+ // PySide6 resolves without the user's python having heard of Qt, and the two
249
+ // spellings of "start the applet" stay one script.
250
+ steps.push(step('launch', [path.join(linux, 'diplomat'), ...args], {
251
+ cwd: linux,
252
+ env: { PATH: path.join(facts.venv, 'bin') + path.delimiter + facts.path },
253
+ }));
254
+ }
255
+
256
+ return {
257
+ platform: facts.platform,
258
+ checkout: facts.checkout,
259
+ steps: steps.filter((s) => !dropped(s, facts)),
260
+ blocked: blockedBy(facts, steps),
261
+ };
262
+ }
263
+
264
+ // Run a plan's steps in order, announcing each. Node cannot exec over itself the
265
+ // way the Python twin does, so the applet is a child here and this process stays
266
+ // up as its parent — inheriting stdio, and returning its exit code as ours.
267
+ export function run(steps, { checkout, venv }) {
268
+ for (const s of steps) {
269
+ process.stderr.write(`→ ${s.cmd.join(' ')}\n`);
270
+ const r = spawnSync(s.cmd[0], s.cmd.slice(1), {
271
+ cwd: s.cwd || undefined,
272
+ env: { ...process.env, ...s.env },
273
+ stdio: 'inherit',
274
+ });
275
+ if (r.error || r.status !== 0) {
276
+ const why = r.error ? r.error.message : `exit ${r.status}`;
277
+ if (s.optional) {
278
+ process.stderr.write(` (skipped: ${why})\n`);
279
+ continue;
280
+ }
281
+ process.stderr.write(`szpont: ${s.id} failed (${why})\n`);
282
+ return r.status === null || r.status === undefined ? 1 : r.status;
283
+ }
284
+ // The venv now matches the requirements it was just given, and recording that
285
+ // is what makes every later launch skip the install. Hashed here rather than
286
+ // at probe time: on a first run the file arrives with the clone, two steps
287
+ // after the only chance probe had to read it.
288
+ if (s.id === 'deps') {
289
+ const digest = digestOf(path.join(checkout, 'packages', 'diplomat-platform', 'linux', 'requirements.txt'));
290
+ try {
291
+ fs.writeFileSync(path.join(venv, '.szpont-requirements'), digest || '');
292
+ } catch {
293
+ // a re-install next time is the whole cost of not recording it
294
+ }
295
+ }
296
+ }
297
+ return 0;
298
+ }
299
+
300
+ const USAGE = `usage: szpont [--plan] [--no-update] [--version] [-- APP_ARGS...]
301
+
302
+ Fetch Diplomat if it isn't here, build it, and start it.
303
+
304
+ --plan print what would be run, as JSON, and do none of it
305
+ --no-update launch the checkout as it stands, without fast-forwarding it
306
+ --version print the launcher version
307
+
308
+ Everything after -- is passed to the applet. DIPLOMAT_SELF_REPO points at your own
309
+ checkout (never updated); DIPLOMAT_REPO_URL clones from somewhere other than GitHub.
310
+ `;
311
+
312
+ export function main(argv) {
313
+ const flags = { plan: false, update: true };
314
+ const appArgs = [];
315
+ let passthrough = false;
316
+ for (const arg of argv) {
317
+ if (passthrough) appArgs.push(arg);
318
+ else if (arg === '--') passthrough = true;
319
+ else if (arg === '--plan') flags.plan = true;
320
+ else if (arg === '--no-update') flags.update = false;
321
+ else if (arg === '--version') { process.stdout.write(`szpont ${VERSION}\n`); return 0; }
322
+ else if (arg === '--help' || arg === '-h') { process.stdout.write(USAGE); return 0; }
323
+ else if (arg.startsWith('-')) { process.stderr.write(`szpont: unknown option ${arg}\n${USAGE}`); return 2; }
324
+ else appArgs.push(arg);
325
+ }
326
+
327
+ const facts = probe(appArgs, { update: flags.update });
328
+ const steps = plan(facts);
329
+
330
+ if (flags.plan) {
331
+ process.stdout.write(`${JSON.stringify({ version: VERSION, facts, plan: steps }, null, 2)}\n`);
332
+ return 0;
333
+ }
334
+ if (steps.blocked) {
335
+ process.stderr.write(`szpont: ${steps.blocked.reason}\n → ${steps.blocked.fix}\n`);
336
+ return 2;
337
+ }
338
+ return run(steps.steps, { checkout: facts.checkout, venv: facts.venv });
339
+ }