shraga 0.1.35 → 0.1.36

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.
@@ -86,12 +86,18 @@ List checkpoints with `git -C "$APP_DIR" log --oneline`; reset to any of them.
86
86
  When the deployment consumes `shraga` as an npm dependency, you can move your own pinned version.
87
87
  **Only when the user asks** — there is no timer on this.
88
88
 
89
+ Both routes need auth — use your internal token, as with any other `/api/` call:
90
+
89
91
  ```bash
90
- curl -s $ORIGIN/api/self-upgrade # current, latest, blockers
91
- curl -s -XPOST $ORIGIN/api/self-upgrade -d '{"version":"latest"}' # or a specific "0.1.35"
92
+ AUTH="x-internal-token: $INTERNAL_API_TOKEN"
93
+ curl -s -H "$AUTH" http://localhost:$PORT/api/self-upgrade # current, latest, blockers
94
+ curl -s -H "$AUTH" -H 'content-type: application/json' \
95
+ -XPOST http://localhost:$PORT/api/self-upgrade -d '{"version":"latest"}' # or "0.1.35"
92
96
  ```
93
97
 
94
- - **Owner only.** Both routes 403 for anyone else.
98
+ - **Owner only.** Your internal token carries the identity of whoever you are acting for, so a 403
99
+ means *that person* is not an owner — not a bug. A scheduled run is `agent-internal` and is
100
+ therefore refused by design: this cannot fire unattended.
95
101
  - **Check `blockers` first and relay them.** A source checkout (upgrade with git instead) and a
96
102
  local dev symlink at `node_modules/shraga` are both refused on purpose — do not work around either.
97
103
  - **202 means started, not succeeded.** The upgrade restarts the server, so the POST cannot report
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shraga",
3
- "version": "0.1.35",
3
+ "version": "0.1.36",
4
4
  "description": "The teammate you delegate coding to — a self-hostable, multi-user AI coding agent web UI (Claude Code, with a pluggable engine seam).",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -10,7 +10,7 @@
10
10
  // entry point. Deployments that want it scheduled can point a schedule at that route, which keeps
11
11
  // the "should I upgrade tonight?" policy out of the mechanism.
12
12
  import { existsSync, lstatSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
13
- import { spawn } from 'node:child_process';
13
+ import { spawn, spawnSync } from 'node:child_process';
14
14
  import path from 'node:path';
15
15
  import { APP_ROOT, PACKAGE_ROOT, dataPath } from '../paths.ts';
16
16
  import { emitEvent } from '../events/bus.ts';
@@ -79,6 +79,18 @@ export class SelfUpgrade {
79
79
  reasons.push('no restart command configured (RESTART_CMD / SHRAGA_RESTART_CMD) — an installed version could never be started');
80
80
  }
81
81
 
82
+ // The supervisor is detached with stdio ignored, so a missing script fails INVISIBLY: nothing
83
+ // happens, no report is ever written, and the in-flight marker sits there until it ages out.
84
+ if (!existsSync(this.o.supervisorScript)) {
85
+ reasons.push(`upgrade supervisor missing at ${this.o.supervisorScript}`);
86
+ }
87
+
88
+ // The supervisor needs python3 to edit package.json. Better to say so now than to find out
89
+ // mid-upgrade, with the pin already rewritten.
90
+ if (!this.o.hasPython3()) {
91
+ reasons.push('python3 not found on PATH — the upgrade supervisor needs it to edit package.json');
92
+ }
93
+
82
94
  const active = this.inFlight();
83
95
  if (active) reasons.push(`an upgrade to ${active.target} is already in flight (started ${active.startedAt})`);
84
96
 
@@ -111,27 +123,34 @@ export class SelfUpgrade {
111
123
  mkdirSync(path.dirname(this.o.reportFile), { recursive: true });
112
124
  writeFileSync(this.o.lockFile, JSON.stringify({ target, startedAt: new Date().toISOString(), at: Date.now() }));
113
125
 
114
- // detached + ignored stdio: the supervisor must outlive us, and it will — we are its first
115
- // casualty. It logs to a file of its own (see supervisor.sh).
116
- const child = spawn('bash', [this.o.supervisorScript], {
117
- cwd: this.o.appRoot,
118
- detached: true,
119
- stdio: 'ignore',
120
- env: {
121
- ...process.env,
122
- APP_ROOT: this.o.appRoot,
123
- PKG: this.o.pkg,
124
- TARGET: target,
125
- FROM: from ?? '',
126
- RESTART_CMD: this.o.restartCmd,
127
- HEALTH_URL: this.o.healthUrl,
128
- REPORT: this.o.reportFile,
129
- BUN: this.o.bun,
130
- BOOT_TIMEOUT: String(this.o.bootTimeoutSec),
131
- SOAK: String(this.o.soakSec),
132
- },
133
- });
134
- child.unref();
126
+ let child;
127
+ try {
128
+ // detached + ignored stdio: the supervisor must outlive us, and it will — we are its first
129
+ // casualty. It logs to a file of its own (see supervisor.sh).
130
+ child = spawn('bash', [this.o.supervisorScript], {
131
+ cwd: this.o.appRoot,
132
+ detached: true,
133
+ stdio: 'ignore',
134
+ env: {
135
+ ...process.env,
136
+ APP_ROOT: this.o.appRoot,
137
+ PKG: this.o.pkg,
138
+ TARGET: target,
139
+ FROM: from ?? '',
140
+ RESTART_CMD: this.o.restartCmd,
141
+ HEALTH_URL: this.o.healthUrl,
142
+ REPORT: this.o.reportFile,
143
+ BUN: this.o.bun,
144
+ BOOT_TIMEOUT: String(this.o.bootTimeoutSec),
145
+ SOAK: String(this.o.soakSec),
146
+ },
147
+ });
148
+ child.unref();
149
+ } catch (err) {
150
+ // Nothing was started, so the marker would otherwise block every retry until it ages out.
151
+ try { unlinkSync(this.o.lockFile); } catch { /* best effort */ }
152
+ return { started: false, from, target, reason: `could not start the upgrade supervisor: ${(err as Error).message}` };
153
+ }
135
154
 
136
155
  console.log(`${TAG} handed off ${this.o.pkg} ${from} -> ${target} to supervisor pid ${child.pid}`);
137
156
  return { started: true, from, target, reason: `upgrading to ${target}; the result will be reported when the server comes back` };
@@ -178,6 +197,11 @@ export class SelfUpgradeOptions {
178
197
  public soakSec: number = 60;
179
198
  /** After this, an in-flight marker is treated as abandoned (supervisor killed by a reboot). */
180
199
  public maxRunMs: number = 30 * 60 * 1000;
200
+ /** Injectable so the preflight is testable without depending on the test host's PATH. */
201
+ public hasPython3: () => boolean = () => {
202
+ try { return spawnSync('python3', ['--version'], { stdio: 'ignore' }).status === 0; }
203
+ catch { return false; }
204
+ };
181
205
  }
182
206
 
183
207
  export interface UpgradePlan {
@@ -32,6 +32,15 @@ BACKUP="$(mktemp -d)"
32
32
  LOG="${REPORT%.json}.log"
33
33
  exec >>"$LOG" 2>&1
34
34
 
35
+ # python3 edits package.json and escapes the report. Check it BEFORE anything is touched: discovering
36
+ # it missing halfway through means a rewritten pin we can no longer safely put back. SelfUpgrade
37
+ # .blockers() checks this too, so a deployment normally learns about it at request time, not here.
38
+ if ! command -v python3 >/dev/null 2>&1; then
39
+ printf '{"status":"failed","detail":"python3 not found on PATH — nothing was changed","from":"%s","target":"%s","installed":"%s","package":"%s","log":"%s","finishedAt":"%s"}\n' \
40
+ "$FROM" "$TARGET" "$FROM" "$PKG" "$LOG" "$(date -u '+%FT%TZ')" > "$REPORT"
41
+ exit 1
42
+ fi
43
+
35
44
  ts() { date '+%F %T'; }
36
45
  say() { echo "[upgrade] $(ts) $*"; }
37
46
 
@@ -67,15 +76,25 @@ restore() {
67
76
  return 0
68
77
  }
69
78
 
70
- pin() { # version rewrite only this dep's pin, leaving the rest of package.json byte-identical
71
- PKG="$PKG" V="$1" python3 - <<'PY'
72
- import json, os, re
73
- pkg, ver = os.environ['PKG'], os.environ['V']
79
+ # Rewrite ONLY this dep's pin, leaving the rest of package.json byte-identical (a JSON round-trip
80
+ # would reformat a file the deployment owns). Text editing a structured file is only safe if it is
81
+ # unambiguous, so this refuses unless exactly ONE "<pkg>": "<version>" pair exists AND its current
82
+ # value is the one we expect — a package.json that also names the package under overrides/
83
+ # resolutions/peerDependencies must not be edited by guesswork.
84
+ pin() { # new-version expected-current-version
85
+ PKG="$PKG" NEW="$1" EXPECT="$2" python3 - <<'PY'
86
+ import os, re, sys
87
+ pkg, new, expect = os.environ['PKG'], os.environ['NEW'], os.environ['EXPECT']
74
88
  src = open('package.json').read()
75
- pat = re.compile(r'("%s"\s*:\s*")[^"]*(")' % re.escape(pkg))
76
- if not pat.search(src):
77
- raise SystemExit(f'{pkg} not found in package.json')
78
- open('package.json', 'w').write(pat.sub(lambda m: m.group(1) + ver + m.group(2), src, count=1))
89
+ pat = re.compile(r'("%s"\s*:\s*")([^"]*)(")' % re.escape(pkg))
90
+ hits = list(pat.finditer(src))
91
+ if len(hits) != 1:
92
+ sys.exit(f'expected exactly one "{pkg}" version entry in package.json, found {len(hits)}')
93
+ current = hits[0].group(2).lstrip('^~')
94
+ if expect and current != expect:
95
+ sys.exit(f'package.json has {pkg}@{hits[0].group(2)}, expected {expect} — refusing to edit')
96
+ m = hits[0]
97
+ open('package.json', 'w').write(src[:m.start()] + m.group(1) + new + m.group(3) + src[m.end():])
79
98
  PY
80
99
  }
81
100
 
@@ -108,9 +127,9 @@ soak() { # version seconds
108
127
  return 0
109
128
  }
110
129
 
111
- install_and_restart() { # version label
112
- say "installing $PKG@$1 ($2)"
113
- if ! pin "$1"; then say "pin failed"; return 1; fi
130
+ install_and_restart() { # version expected-current label
131
+ say "installing $PKG@$1 ($3)"
132
+ if ! pin "$1" "$2"; then say "pin failed"; return 1; fi
114
133
  if ! "$BUN" install; then say "bun install failed"; return 1; fi
115
134
  say "restarting via: $RESTART_CMD"
116
135
  eval "$RESTART_CMD" || say "restart command returned non-zero (continuing — some managers do)"
@@ -124,7 +143,7 @@ if ! snapshot; then
124
143
  exit 1
125
144
  fi
126
145
 
127
- if ! install_and_restart "$TARGET" upgrade; then
146
+ if ! install_and_restart "$TARGET" "$FROM" upgrade; then
128
147
  restore && "$BUN" install >/dev/null 2>&1
129
148
  write_report failed "install of $PKG@$TARGET failed; package.json restored, service untouched" "$FROM"
130
149
  exit 1
@@ -142,7 +161,7 @@ if ! restore; then
142
161
  write_report revert-failed "upgrade to $TARGET failed AND the backup could not be restored — MANUAL FIX NEEDED. Backup: $BACKUP" unknown
143
162
  exit 1
144
163
  fi
145
- if ! install_and_restart "$FROM" revert; then
164
+ if ! install_and_restart "$FROM" "" revert; then
146
165
  write_report revert-failed "upgrade to $TARGET failed and reinstalling $FROM ALSO failed — MANUAL FIX NEEDED. Backup: $BACKUP" unknown
147
166
  exit 1
148
167
  fi