oysterun 0.1.1-beta.2 → 0.1.1-beta.5

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.
@@ -7587,6 +7587,62 @@
7587
7587
  </div>
7588
7588
  </div>
7589
7589
  </section>
7590
+ <section class="table-panel">
7591
+ <div class="table-title">
7592
+ <div class="table-title-main">
7593
+ <strong>Oysterun Update</strong>
7594
+ <div class="table-title-copy">
7595
+ Check and update the installed Host package.
7596
+ </div>
7597
+ </div>
7598
+ </div>
7599
+ <div class="kv-table">
7600
+ <div class="kv-label">Current Version</div>
7601
+ <div class="kv-value">
7602
+ <div class="mono-path" id="host-update-current">
7603
+ --
7604
+ </div>
7605
+ </div>
7606
+ <div class="kv-label">Channel</div>
7607
+ <div class="kv-value">
7608
+ <div class="mono-path" id="host-update-channel">
7609
+ --
7610
+ </div>
7611
+ </div>
7612
+ <div class="kv-label">Latest Version</div>
7613
+ <div class="kv-value">
7614
+ <div class="value-stack">
7615
+ <div class="mono-path" id="host-update-latest">
7616
+ --
7617
+ </div>
7618
+ <div class="field-hint" id="host-update-status">
7619
+ Click Check for updates to query npm.
7620
+ </div>
7621
+ </div>
7622
+ </div>
7623
+ <div class="kv-label">Action</div>
7624
+ <div class="kv-value">
7625
+ <div class="control-cell">
7626
+ <button
7627
+ class="btn-primary"
7628
+ type="button"
7629
+ id="host-update-check-btn"
7630
+ onClick=${call("checkOysterunUpdate")}
7631
+ >
7632
+ Check for updates
7633
+ </button>
7634
+ <button
7635
+ class="btn-primary"
7636
+ type="button"
7637
+ id="host-update-run-btn"
7638
+ onClick=${call("runOysterunUpdate")}
7639
+ >
7640
+ Update now
7641
+ </button>
7642
+ </div>
7643
+ </div>
7644
+ </div>
7645
+ </section>
7590
7646
  <section
7591
7647
  class="table-panel browser-notification-card"
7592
7648
  id="host-notification-card"
@@ -11265,6 +11321,11 @@ Waiting for Claude login output…</pre
11265
11321
  loginQrError: "",
11266
11322
  restartLoading: false,
11267
11323
  restartStatus: "",
11324
+ update: null,
11325
+ updateChecking: false,
11326
+ updateRunning: false,
11327
+ updateStatus: "",
11328
+ updateError: "",
11268
11329
  localUrl: "",
11269
11330
  defaultBrowsePath: "",
11270
11331
  resolvedDefaultBrowsePath: "",
@@ -26515,6 +26576,14 @@ Waiting for Claude login output…</pre
26515
26576
  loginQrLoading: false,
26516
26577
  loginQrError: "",
26517
26578
  restartLoading: false,
26579
+ update:
26580
+ data.update && typeof data.update === "object"
26581
+ ? data.update
26582
+ : hostPreferencesState.update,
26583
+ updateChecking: false,
26584
+ updateRunning: false,
26585
+ updateStatus: "",
26586
+ updateError: "",
26518
26587
  localUrl: data.local_url || "",
26519
26588
  defaultBrowsePath: data.default_browse_path || "",
26520
26589
  resolvedDefaultBrowsePath:
@@ -28099,6 +28168,50 @@ Waiting for Claude login output…</pre
28099
28168
  ? "Restarting..."
28100
28169
  : "Restart Host";
28101
28170
  }
28171
+ const update = hostPreferencesState.update || {};
28172
+ setText("host-update-current", update.current_version || "--");
28173
+ setText("host-update-channel", update.channel || "--");
28174
+ setText("host-update-latest", update.latest_version || "--");
28175
+ let updateStatus =
28176
+ hostPreferencesState.updateError ||
28177
+ hostPreferencesState.updateStatus ||
28178
+ update.error ||
28179
+ "";
28180
+ if (!updateStatus) {
28181
+ if (update.update_available === true) {
28182
+ updateStatus = `Update available: ${update.latest_version}.`;
28183
+ } else if (update.latest_version) {
28184
+ updateStatus = "Oysterun is up to date for this channel.";
28185
+ } else if (update.update_supported === false) {
28186
+ updateStatus =
28187
+ "Update now is only available on the production npm Host stack.";
28188
+ } else {
28189
+ updateStatus = "Click Check for updates to query npm.";
28190
+ }
28191
+ }
28192
+ setText("host-update-status", updateStatus);
28193
+ const updateCheckButton = $("host-update-check-btn");
28194
+ if (updateCheckButton) {
28195
+ updateCheckButton.disabled =
28196
+ disabled ||
28197
+ hostPreferencesState.updateChecking ||
28198
+ hostPreferencesState.updateRunning;
28199
+ updateCheckButton.textContent = hostPreferencesState.updateChecking
28200
+ ? "Checking..."
28201
+ : "Check for updates";
28202
+ }
28203
+ const updateRunButton = $("host-update-run-btn");
28204
+ if (updateRunButton) {
28205
+ updateRunButton.disabled =
28206
+ disabled ||
28207
+ hostPreferencesState.updateChecking ||
28208
+ hostPreferencesState.updateRunning ||
28209
+ update.update_supported !== true ||
28210
+ update.update_available !== true;
28211
+ updateRunButton.textContent = hostPreferencesState.updateRunning
28212
+ ? "Updating..."
28213
+ : "Update now";
28214
+ }
28102
28215
  const qrPanel = $("host-login-qr-panel");
28103
28216
  const qr = hostPreferencesState.loginQr;
28104
28217
  if (qrPanel) {
@@ -28314,6 +28427,98 @@ Waiting for Claude login output…</pre
28314
28427
  }
28315
28428
  }
28316
28429
 
28430
+ async function checkOysterunUpdate() {
28431
+ if (
28432
+ hostPreferencesState.updateChecking ||
28433
+ hostPreferencesState.updateRunning
28434
+ ) {
28435
+ return;
28436
+ }
28437
+ hostPreferencesState = {
28438
+ ...hostPreferencesState,
28439
+ updateChecking: true,
28440
+ updateStatus: "Checking npm for updates...",
28441
+ updateError: "",
28442
+ };
28443
+ renderHostPreferences();
28444
+ try {
28445
+ const data = await apiWithAuthRetry("POST", "/admin/update-check", {
28446
+ channel: hostPreferencesState.update?.channel || null,
28447
+ });
28448
+ hostPreferencesState = {
28449
+ ...hostPreferencesState,
28450
+ update: data,
28451
+ updateChecking: false,
28452
+ updateStatus:
28453
+ data.update_available === true
28454
+ ? `Update available: ${data.latest_version}.`
28455
+ : "Oysterun is up to date for this channel.",
28456
+ updateError: "",
28457
+ };
28458
+ renderHostPreferences();
28459
+ } catch (err) {
28460
+ hostPreferencesState = {
28461
+ ...hostPreferencesState,
28462
+ updateChecking: false,
28463
+ updateStatus: "",
28464
+ updateError: err.message || "Could not check for updates.",
28465
+ };
28466
+ renderHostPreferences();
28467
+ }
28468
+ }
28469
+
28470
+ async function runOysterunUpdate() {
28471
+ if (
28472
+ hostPreferencesState.updateRunning ||
28473
+ hostPreferencesState.updateChecking
28474
+ ) {
28475
+ return;
28476
+ }
28477
+ const update = hostPreferencesState.update || {};
28478
+ if (
28479
+ update.update_supported !== true ||
28480
+ update.update_available !== true
28481
+ ) {
28482
+ return;
28483
+ }
28484
+ const confirmed = window.confirm(
28485
+ "Updating Oysterun restarts the Host. Existing live runtime sessions and running loops will close; chat history remains and supported sessions can be resumed from Sessions after restart. Continue?"
28486
+ );
28487
+ if (!confirmed) return;
28488
+ hostPreferencesState = {
28489
+ ...hostPreferencesState,
28490
+ updateRunning: true,
28491
+ updateStatus: "Scheduling Oysterun update...",
28492
+ updateError: "",
28493
+ };
28494
+ renderHostPreferences();
28495
+ try {
28496
+ const data = await apiWithAuthRetry("POST", "/admin/update-run", {
28497
+ channel: update.channel || null,
28498
+ });
28499
+ hostPreferencesState = {
28500
+ ...hostPreferencesState,
28501
+ update: data,
28502
+ updateRunning: false,
28503
+ updateStatus:
28504
+ "Oysterun update scheduled. Reconnect after the service comes back.",
28505
+ updateError: "",
28506
+ };
28507
+ renderHostPreferences();
28508
+ window.setTimeout(() => {
28509
+ window.location.href = "/app/settings";
28510
+ }, 6000);
28511
+ } catch (err) {
28512
+ hostPreferencesState = {
28513
+ ...hostPreferencesState,
28514
+ updateRunning: false,
28515
+ updateStatus: "",
28516
+ updateError: err.message || "Could not schedule update.",
28517
+ };
28518
+ renderHostPreferences();
28519
+ }
28520
+ }
28521
+
28317
28522
  function clearHostPreferencesInput() {
28318
28523
  discardUnsavedHostPreferencesChanges();
28319
28524
  }
@@ -41361,6 +41566,8 @@ Waiting for Claude login output…</pre
41361
41566
  window.saveHostPreferences = saveHostPreferences;
41362
41567
  window.generateDirectHostLoginQr = generateDirectHostLoginQr;
41363
41568
  window.restartHostFromPreferences = restartHostFromPreferences;
41569
+ window.checkOysterunUpdate = checkOysterunUpdate;
41570
+ window.runOysterunUpdate = runOysterunUpdate;
41364
41571
  window.refreshWebNotificationPreferenceState =
41365
41572
  refreshWebNotificationPreferenceState;
41366
41573
  window.sendWebNotificationTest = sendWebNotificationTest;
@@ -48,7 +48,9 @@ separate.
48
48
  [2/7] Host port
49
49
  2. Ask for Host port.
50
50
  - default production port is 8802.
51
- - if occupied, offer the next available port.
51
+ - if occupied by the same managed Oysterun service, allow the selected port
52
+ and restart that service at the final step.
53
+ - if occupied by another process, offer the next available port.
52
54
 
53
55
  [3/7] iPhone connection URL
54
56
  3. Ask how the iPhone will connect to this Mac.
@@ -72,25 +74,34 @@ separate.
72
74
  9. Explain: "You need the Oysterun phone app to scan the login QR."
73
75
  Ask: "Show phone app download link and QR code?"
74
76
  - yes: show the phone app download link and QR.
75
- - no: continue to the Host login QR step.
77
+ - no: continue to service start.
76
78
  - first version download URL: `https://oysterun.com`.
77
79
  - later production URL should be the App Store link.
78
80
 
79
81
  [6/7] Login QR
80
- 10. Create a 15-minute one-time QR/bootstrap login token.
81
- 11. Show the QR code in terminal.
82
+ 10. Do not print the login QR before Host start.
83
+ 11. Explain that a fresh login QR will be shown after the Host service starts.
84
+ If the user chooses not to start now, setup must not print a login QR.
82
85
 
83
86
  [7/7] Start Host
84
87
  12. Install the launchd service.
85
- 13. Ask whether to start Host now, default yes.
86
- 14. yes:
87
- launchd start
88
+ 13. If no managed Oysterun service is running, ask whether to start Host now,
89
+ default yes.
90
+ 14. If a managed Oysterun service is already running, ask whether to restart
91
+ Host now, default yes.
92
+ - warn that existing live runtime sessions and running loops will close.
93
+ - explain that chat history remains and supported sessions can be resumed
94
+ from Sessions after restart.
95
+ 15. yes:
96
+ launchd start or restart
88
97
  health check
89
98
  display Web URL
90
99
  on interactive macOS setup, open `<Host URL>/app/sessions` in the browser
91
100
  after health check succeeds
101
+ create and print a fresh 15-minute one-time Host login QR
92
102
  no:
93
103
  print `oysterun` as the start command.
104
+ explain that the start command will print a fresh login QR.
94
105
  ```
95
106
 
96
107
  Setup must not ask for macOS Full Disk Access during first-run installation.
@@ -113,6 +124,11 @@ If the Host is already running and setup changes runtime connection settings,
113
124
  setup should tell the user restart is required and offer to restart through the
114
125
  service manager.
115
126
 
127
+ Setup rerun must still go through the normal setup questions. A running Host
128
+ does not skip port selection, password update, login QR readiness explanation,
129
+ or the final service action. The login QR itself is only printed after the
130
+ service starts or restarts successfully.
131
+
116
132
  ## Service Contract
117
133
 
118
134
  Release Host should run as a local managed service, not as a foreground
@@ -138,21 +154,24 @@ Release service behavior:
138
154
  - does not require the user to remember a raw server command
139
155
  ```
140
156
 
157
+ `oysterun service:start` and `oysterun service:restart` must print a fresh
158
+ 15-minute one-time Host login QR at the end after the Host start/restart
159
+ finishes successfully.
160
+
141
161
  ## QR Login Contract
142
162
 
143
163
  The setup QR is a convenience login/bootstrap path. It should contain enough
144
164
  information for the app to connect and log in without typing IP:port/password:
145
165
 
146
166
  ```text
147
- host_id
148
167
  direct_host_url
149
168
  one-time login/bootstrap token
150
- expiry
151
- Cloud API URL when Cloud direct mode is enabled
152
- display name
153
169
  ```
154
170
 
155
- The one-time token should expire after 15 minutes.
171
+ The terminal QR should use the smallest app-compatible payload. In Stage 1 this
172
+ is `u` for Host URL and `b` for the one-time login/bootstrap token. Host ID,
173
+ Host display name, and expiry are product state shown or fetched separately, not
174
+ required QR fields. The one-time token should expire after 15 minutes.
156
175
 
157
176
  Host Preferences must provide a "generate login QR" action for later pairing or
158
177
  re-login. It creates a new short-lived token and displays the QR.
@@ -240,6 +259,42 @@ settings. For settings that require process restart:
240
259
  Runtime edits must not require rebuilding the phone app. Capacitor sync/build
241
260
  is only for app code changes, not day-to-day Host/IP changes.
242
261
 
262
+ ## Host Update Control
263
+
264
+ Host Preferences may expose package update controls for npm installs:
265
+
266
+ ```text
267
+ current Oysterun version
268
+ update channel, such as latest or beta
269
+ latest version from npm registry after manual check
270
+ Check for updates
271
+ Update now
272
+ ```
273
+
274
+ Update now is explicit. It must not run automatically on startup.
275
+
276
+ First implementation:
277
+
278
+ ```text
279
+ 1. Owner clicks Check for updates.
280
+ 2. Host queries npm registry for the selected channel.
281
+ 3. If a newer version exists, Host enables Update now.
282
+ 4. Owner clicks Update now and confirms the restart warning.
283
+ 5. Host runs npm install -g oysterun@<channel>.
284
+ 6. Host restarts through the same service restart path.
285
+ ```
286
+
287
+ Update now is supported only on the production npm Host stack. Dev/test stacks
288
+ may show version and check status, but must not mutate the global npm package.
289
+
290
+ Until runtime restore is implemented, update/restart UI must warn:
291
+
292
+ ```text
293
+ Updating restarts Oysterun Host. Existing live runtime sessions and running
294
+ loops will close; chat history remains and supported sessions can be resumed
295
+ from Sessions after restart.
296
+ ```
297
+
243
298
  ## Stage 2 Direction
244
299
 
245
300
  Stage 2 may add:
@@ -117,12 +117,12 @@ write ~/.oysterun/config.json
117
117
  create dashboard credential hash
118
118
  register Cloud direct mode when enabled by release setup
119
119
  write Host Cloud identity state outside user-editable config
120
- create 15-minute one-time QR/bootstrap token
121
120
  show phone app download/QR step
122
- show Host login QR
121
+ explain that Host login QR is shown after service start
123
122
  install service manager integration
124
123
  start Host by default after explicit setup flow
125
124
  wait for /health
125
+ create and show a fresh 15-minute Host login QR only when Host starts
126
126
  show/open /app/sessions when browser open is available
127
127
  ```
128
128
 
@@ -153,6 +153,41 @@ oysterun service:logs
153
153
  Explicit service commands should fail fast when setup has not completed. They
154
154
  must not silently create config or run setup.
155
155
 
156
+ `oysterun service:start` and `oysterun service:restart` print a fresh
157
+ 15-minute one-time Host login QR after the Host start/restart succeeds.
158
+
159
+ ## Update Control
160
+
161
+ The npm package must support owner-visible update awareness after install.
162
+
163
+ Host Preferences may show:
164
+
165
+ ```text
166
+ current_version
167
+ channel: latest or beta
168
+ latest_version after manual npm registry check
169
+ Check for updates
170
+ Update now
171
+ ```
172
+
173
+ Update now must be explicit and restart-aware:
174
+
175
+ ```text
176
+ 1. Owner clicks Update now.
177
+ 2. Host confirms that updating restarts Oysterun Host.
178
+ 3. Host runs npm install -g oysterun@<channel>.
179
+ 4. Host restarts through the release service path.
180
+ 5. Host keeps config.json, cloud_identity.json, Matrix history, and agent
181
+ folders untouched.
182
+ ```
183
+
184
+ Update now is production-stack only. Dev/test stacks may check npm but must not
185
+ mutate the global npm installation from Host Preferences.
186
+
187
+ Until runtime restore is implemented, the update confirmation must warn that
188
+ live runtime sessions and running loops will close, while durable chat history
189
+ remains and supported sessions can be resumed from Sessions.
190
+
156
191
  ## Cloud Endpoint
157
192
 
158
193
  The production Cloud endpoint is a git-tracked product constant.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oysterun-host-service",
3
- "version": "0.1.1-beta.2",
3
+ "version": "0.1.1-beta.5",
4
4
  "type": "module",
5
5
  "description": "Oysterun host service — runs on Mac mini, manages Claude Code sessions",
6
6
  "scripts": {
@@ -4842,6 +4842,144 @@ function getActiveStackName() {
4842
4842
  );
4843
4843
  }
4844
4844
 
4845
+ const OYSTERUN_NPM_PACKAGE_NAME = "oysterun";
4846
+
4847
+ function readOysterunPackageVersion() {
4848
+ for (const packagePath of [
4849
+ join(REPO_ROOT, "package.json"),
4850
+ join(REPO_ROOT, "host-service", "package.json"),
4851
+ ]) {
4852
+ try {
4853
+ const parsed = JSON.parse(readFileSync(packagePath, "utf-8"));
4854
+ if (typeof parsed.version === "string" && parsed.version.trim()) {
4855
+ return parsed.version.trim();
4856
+ }
4857
+ } catch {
4858
+ // Best-effort diagnostics must not break Host Preferences.
4859
+ }
4860
+ }
4861
+ return "unknown";
4862
+ }
4863
+
4864
+ function inferOysterunUpdateChannel(version = readOysterunPackageVersion()) {
4865
+ return String(version || "").includes("-beta") ? "beta" : "latest";
4866
+ }
4867
+
4868
+ function normalizeOysterunUpdateChannel(value, fallback = null) {
4869
+ const normalized = normalizeString(value || fallback);
4870
+ if (normalized === "beta" || normalized === "latest") return normalized;
4871
+ return inferOysterunUpdateChannel();
4872
+ }
4873
+
4874
+ function compareVersionStrings(a, b) {
4875
+ const normalize = (value) =>
4876
+ String(value || "")
4877
+ .replace(/^v/, "")
4878
+ .split(/[-+.]/)
4879
+ .map((part) => {
4880
+ const number = Number.parseInt(part, 10);
4881
+ return Number.isNaN(number) ? part : number;
4882
+ });
4883
+ const left = normalize(a);
4884
+ const right = normalize(b);
4885
+ const length = Math.max(left.length, right.length);
4886
+ for (let index = 0; index < length; index += 1) {
4887
+ const l = left[index] ?? 0;
4888
+ const r = right[index] ?? 0;
4889
+ if (typeof l === "number" && typeof r === "number" && l !== r) {
4890
+ return l > r ? 1 : -1;
4891
+ }
4892
+ const ls = String(l);
4893
+ const rs = String(r);
4894
+ if (ls !== rs) return ls > rs ? 1 : -1;
4895
+ }
4896
+ return 0;
4897
+ }
4898
+
4899
+ function buildOysterunUpdateStatusPayload(extra = {}) {
4900
+ const currentVersion = readOysterunPackageVersion();
4901
+ const channel = normalizeOysterunUpdateChannel(extra.channel, currentVersion);
4902
+ const latestVersion = normalizeString(extra.latest_version);
4903
+ return {
4904
+ status: extra.status || "update_status",
4905
+ package_name: OYSTERUN_NPM_PACKAGE_NAME,
4906
+ current_version: currentVersion,
4907
+ channel,
4908
+ latest_version: latestVersion || null,
4909
+ update_available: latestVersion
4910
+ ? compareVersionStrings(latestVersion, currentVersion) > 0
4911
+ : false,
4912
+ update_supported: getActiveStackName() === "production",
4913
+ active_stack: getActiveStackName(),
4914
+ checked_at: extra.checked_at || null,
4915
+ error: extra.error || null,
4916
+ log_path: extra.log_path || null,
4917
+ };
4918
+ }
4919
+
4920
+ async function checkOysterunNpmUpdate({ channel = null } = {}) {
4921
+ const resolvedChannel = normalizeOysterunUpdateChannel(channel);
4922
+ const resp = await fetch(
4923
+ `https://registry.npmjs.org/${encodeURIComponent(OYSTERUN_NPM_PACKAGE_NAME)}`,
4924
+ { headers: { Accept: "application/json" } }
4925
+ );
4926
+ if (!resp.ok) {
4927
+ throw new Error(`npm registry returned HTTP ${resp.status}`);
4928
+ }
4929
+ const metadata = await resp.json();
4930
+ const latestVersion = normalizeString(metadata?.["dist-tags"]?.[resolvedChannel]);
4931
+ if (!latestVersion) {
4932
+ throw new Error(`npm dist-tag not found: ${resolvedChannel}`);
4933
+ }
4934
+ return buildOysterunUpdateStatusPayload({
4935
+ status: "update_check",
4936
+ channel: resolvedChannel,
4937
+ latest_version: latestVersion,
4938
+ checked_at: new Date().toISOString(),
4939
+ });
4940
+ }
4941
+
4942
+ function shellQuote(value) {
4943
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
4944
+ }
4945
+
4946
+ function scheduleOysterunPackageUpdate({ channel = null } = {}) {
4947
+ const stackName = getActiveStackName();
4948
+ if (stackName !== "production") {
4949
+ throw new Error("Oysterun package update is only available on the production npm Host stack.");
4950
+ }
4951
+ const resolvedChannel = normalizeOysterunUpdateChannel(channel);
4952
+ const logDir = join(getConfigDir(), "operation_logs");
4953
+ mkdirSync(logDir, { recursive: true });
4954
+ const logPath = join(
4955
+ logDir,
4956
+ `oysterun-update-${new Date().toISOString().replace(/[:.]/g, "-")}.log`
4957
+ );
4958
+ const packageSpec = `${OYSTERUN_NPM_PACKAGE_NAME}@${resolvedChannel}`;
4959
+ const command = [
4960
+ "set -euo pipefail",
4961
+ 'export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH"',
4962
+ `echo "[oysterun-update] start $(date -Is)"`,
4963
+ `npm install -g ${shellQuote(packageSpec)}`,
4964
+ `echo "[oysterun-update] npm install complete $(date -Is)"`,
4965
+ "oysterun service:restart",
4966
+ ].join("\n");
4967
+ setTimeout(() => {
4968
+ const child = spawn("bash", ["-lc", `${command} >> ${shellQuote(logPath)} 2>&1`], {
4969
+ cwd: REPO_ROOT,
4970
+ detached: true,
4971
+ env: { ...process.env, OYSTERUN_RELEASE_STACK: "production" },
4972
+ stdio: "ignore",
4973
+ });
4974
+ child.unref();
4975
+ }, 500).unref();
4976
+ return buildOysterunUpdateStatusPayload({
4977
+ status: "update_scheduled",
4978
+ channel: resolvedChannel,
4979
+ log_path: logPath,
4980
+ });
4981
+ }
4982
+
4845
4983
  function scheduleHostRestart({ redirectUrl = null } = {}) {
4846
4984
  const stackName = getActiveStackName();
4847
4985
  if (!stackName) {
@@ -4918,6 +5056,7 @@ function buildHostPreferencesPayload() {
4918
5056
  config_path: getConfigPath(),
4919
5057
  config_dir: getConfigDir(),
4920
5058
  config_source: getConfigSource(),
5059
+ update: buildOysterunUpdateStatusPayload(),
4921
5060
  ios_push: buildApnsPushStatusPayload("dashboard-admin"),
4922
5061
  session_defaults: config.session_defaults,
4923
5062
  error: null,
@@ -8146,6 +8285,45 @@ const server = createServer(async (req, res) => {
8146
8285
  }
8147
8286
  }
8148
8287
 
8288
+ // ── Oysterun npm package update status/control (dashboard-authenticated) ──
8289
+ if (req.method === "GET" && path === "/admin/update-status") {
8290
+ if (!requireDashboardMode(claims, res)) return;
8291
+ return respond(res, 200, buildOysterunUpdateStatusPayload());
8292
+ }
8293
+
8294
+ if (req.method === "POST" && path === "/admin/update-check") {
8295
+ if (!requireDashboardMode(claims, res)) return;
8296
+ try {
8297
+ const body = await readJsonBody(req);
8298
+ return respond(
8299
+ res,
8300
+ 200,
8301
+ await checkOysterunNpmUpdate({ channel: body.channel })
8302
+ );
8303
+ } catch (err) {
8304
+ return respond(res, 409, {
8305
+ ...buildOysterunUpdateStatusPayload({
8306
+ status: "update_check_failed",
8307
+ error: err.message,
8308
+ }),
8309
+ });
8310
+ }
8311
+ }
8312
+
8313
+ if (req.method === "POST" && path === "/admin/update-run") {
8314
+ if (!requireDashboardMode(claims, res)) return;
8315
+ try {
8316
+ const body = await readJsonBody(req);
8317
+ return respond(
8318
+ res,
8319
+ 202,
8320
+ scheduleOysterunPackageUpdate({ channel: body.channel })
8321
+ );
8322
+ } catch (err) {
8323
+ return respond(res, 409, { error: err.message });
8324
+ }
8325
+ }
8326
+
8149
8327
  // ── Authenticated transcript cleanup dry-run/status APIs (P39E source only) ──
8150
8328
  if (
8151
8329
  req.method === "GET" &&
@@ -245,16 +245,39 @@ async function promptHidden(question) {
245
245
  });
246
246
  }
247
247
 
248
- function runReleaseService(command, args = [], opts = {}) {
248
+ function runReleaseService(command, args = [], opts = {}, options = {}) {
249
249
  const serviceStack = inferReleaseServiceStack(opts);
250
- execFileSync(releaseServiceScript, [command, ...args], {
250
+ const execOptions = {
251
251
  cwd: repoRoot,
252
- stdio: "inherit",
253
252
  env: {
254
253
  ...process.env,
255
254
  OYSTERUN_RELEASE_STACK: serviceStack,
256
255
  },
257
- });
256
+ };
257
+ if (!options.quiet) {
258
+ execFileSync(releaseServiceScript, [command, ...args], {
259
+ ...execOptions,
260
+ stdio: "inherit",
261
+ });
262
+ return "";
263
+ }
264
+ try {
265
+ const stdout = execFileSync(releaseServiceScript, [command, ...args], {
266
+ ...execOptions,
267
+ encoding: "utf8",
268
+ stdio: ["ignore", "pipe", "pipe"],
269
+ });
270
+ return stdout || "";
271
+ } catch (err) {
272
+ const stdout = err.stdout ? String(err.stdout).trim() : "";
273
+ const stderr = err.stderr ? String(err.stderr).trim() : "";
274
+ if (stdout || stderr) {
275
+ console.error(" Host service diagnostic output:");
276
+ if (stdout) console.error(stdout);
277
+ if (stderr) console.error(stderr);
278
+ }
279
+ throw err;
280
+ }
258
281
  }
259
282
 
260
283
  function inferReleaseServiceStack(opts = {}) {
@@ -275,6 +298,55 @@ function inferReleaseServiceStack(opts = {}) {
275
298
  return "production";
276
299
  }
277
300
 
301
+ function parseReleaseServiceStatusOutput(output) {
302
+ const status = {};
303
+ for (const line of String(output || "").split(/\r?\n/)) {
304
+ const match = line.match(/^([a-z_]+)=(.*)$/);
305
+ if (!match) continue;
306
+ status[match[1]] = match[2].trim();
307
+ }
308
+ return status;
309
+ }
310
+
311
+ function getReleaseServiceStatus(opts = {}) {
312
+ if (process.platform !== "darwin") {
313
+ return { available: false, running: false, status: {}, error: null };
314
+ }
315
+ try {
316
+ const output = runReleaseService("status", [], opts, { quiet: true });
317
+ const status = parseReleaseServiceStatusOutput(output);
318
+ const pid = status.pid && status.pid !== "-" ? status.pid : "";
319
+ const running =
320
+ status.loaded === "yes" || Boolean(pid) || status.health === "yes";
321
+ return { available: true, running, status, error: null };
322
+ } catch (err) {
323
+ return {
324
+ available: false,
325
+ running: false,
326
+ status: {},
327
+ error: err?.message || String(err),
328
+ };
329
+ }
330
+ }
331
+
332
+ function getUrlPort(value) {
333
+ try {
334
+ const parsed = new URL(String(value || ""));
335
+ if (parsed.port) return Number.parseInt(parsed.port, 10);
336
+ if (parsed.protocol === "https:") return 443;
337
+ if (parsed.protocol === "http:") return 80;
338
+ } catch {
339
+ return null;
340
+ }
341
+ return null;
342
+ }
343
+
344
+ function isPortOccupiedByManagedReleaseService(port, opts = {}) {
345
+ const releaseStatus = getReleaseServiceStatus(opts);
346
+ if (!releaseStatus.running) return false;
347
+ return getUrlPort(releaseStatus.status.url) === port;
348
+ }
349
+
278
350
  async function maybeOpenSetupBrowser(url) {
279
351
  if (!isInteractiveSetup() || process.platform !== "darwin" || !url) return;
280
352
  try {
@@ -289,37 +361,54 @@ async function maybeOpenSetupBrowser(url) {
289
361
 
290
362
  async function maybeInstallOrStartReleaseService(opts, { hostUrl }) {
291
363
  if (!opts.installService && !opts.startService && !opts.noStartService) {
292
- return false;
364
+ return { handled: false, started: false, serviceAlreadyRunning: false };
293
365
  }
294
366
  if (process.platform !== "darwin") {
295
367
  throw new Error("release service install/start requires macOS launchd");
296
368
  }
297
369
 
298
- printSetupStep(7, "Start Host");
370
+ const serviceStatus = getReleaseServiceStatus(opts);
371
+ const serviceAlreadyRunning = serviceStatus.running === true;
372
+ printSetupStep(7, serviceAlreadyRunning ? "Restart Host" : "Start Host");
373
+ if (serviceAlreadyRunning && isInteractiveSetup()) {
374
+ console.log(" An Oysterun Host service is already running.");
375
+ console.log(
376
+ " Restarting will close existing live runtime sessions and running loops."
377
+ );
378
+ console.log(
379
+ " Chat history remains, and supported sessions can be resumed from Sessions after restart."
380
+ );
381
+ }
299
382
  const shouldStart = opts.noStartService
300
383
  ? false
301
384
  : opts.startService
302
385
  ? true
303
- : await promptYesNoDefaultYes("Start Oysterun Host now?");
386
+ : await promptYesNoDefaultYes(
387
+ serviceAlreadyRunning
388
+ ? "Restart Oysterun Host now?"
389
+ : "Start Oysterun Host now?"
390
+ );
304
391
 
305
392
  console.log();
306
393
  if (shouldStart) {
307
394
  console.log(
308
- ` Installing LaunchAgent and starting Oysterun Host (${inferReleaseServiceStack(opts)})...`
395
+ ` Installing LaunchAgent and ${serviceAlreadyRunning ? "restarting" : "starting"} Oysterun Host (${inferReleaseServiceStack(opts)})...`
309
396
  );
310
- runReleaseService("install", [], opts);
397
+ runReleaseService("install", [], opts, { quiet: true });
311
398
  console.log(`\n ✓ Oysterun Host is running at ${hostUrl}`);
312
399
  await maybeOpenSetupBrowser(`${hostUrl}/app/sessions`);
400
+ return { handled: true, started: true, serviceAlreadyRunning };
313
401
  } else {
314
402
  console.log(
315
403
  ` Installing LaunchAgent without starting Oysterun Host (${inferReleaseServiceStack(opts)})...`
316
404
  );
317
- runReleaseService("install", ["--no-start"], opts);
405
+ runReleaseService("install", ["--no-start"], opts, { quiet: true });
318
406
  console.log("\n Host service was installed but not started.");
319
407
  console.log(" Start later with:");
320
408
  console.log(" oysterun");
409
+ console.log(" A fresh login QR will be shown after the Host starts.");
410
+ return { handled: true, started: false, serviceAlreadyRunning };
321
411
  }
322
- return true;
323
412
  }
324
413
 
325
414
  function requireNonEmptyPassword(value, label) {
@@ -390,10 +479,16 @@ async function findAvailablePort(startPort) {
390
479
  return null;
391
480
  }
392
481
 
393
- async function promptHostPort({ defaultPort, optsPort }) {
482
+ async function promptHostPort({ defaultPort, optsPort, opts = {} }) {
394
483
  if (optsPort !== undefined) {
395
484
  const port = parsePortInput(optsPort);
396
485
  if (port === null) throw new Error(`Invalid port: ${optsPort}`);
486
+ if (
487
+ !(await isPortAvailable(port)) &&
488
+ !isPortOccupiedByManagedReleaseService(port, opts)
489
+ ) {
490
+ throw new Error(`Port ${port} is already in use`);
491
+ }
397
492
  return port;
398
493
  }
399
494
 
@@ -407,6 +502,12 @@ async function promptHostPort({ defaultPort, optsPort }) {
407
502
  continue;
408
503
  }
409
504
  if (await isPortAvailable(port)) return port;
505
+ if (isPortOccupiedByManagedReleaseService(port, opts)) {
506
+ console.log(
507
+ ` Port ${port} is used by the current Oysterun Host service and will be reused after restart.`
508
+ );
509
+ return port;
510
+ }
410
511
 
411
512
  const fallback = await findAvailablePort(port + 1);
412
513
  if (fallback && isInteractiveSetup()) {
@@ -625,6 +726,29 @@ function createDirectHostLoginQrPayload({ hostId, directHostUrl }) {
625
726
  };
626
727
  }
627
728
 
729
+ function buildTerminalDirectHostLoginQrPayload(qrPayload) {
730
+ return {
731
+ u: qrPayload.verbose.direct_host_url,
732
+ b: qrPayload.verbose.bootstrap_token,
733
+ };
734
+ }
735
+
736
+ function printDirectHostLoginQr({ hostId, directHostUrl }) {
737
+ const qrPayload = createDirectHostLoginQrPayload({
738
+ hostId,
739
+ directHostUrl,
740
+ });
741
+ console.log("\n Direct Host connection QR:\n");
742
+ qrcode.generate(
743
+ JSON.stringify(buildTerminalDirectHostLoginQrPayload(qrPayload)),
744
+ { small: true }
745
+ );
746
+ console.log(`\n Host ID: ${hostId}`);
747
+ console.log(` Direct Host URL: ${directHostUrl}`);
748
+ console.log(` QR expires at: ${qrPayload.verbose.expires_at}`);
749
+ return qrPayload;
750
+ }
751
+
628
752
  async function configureDirectMode(opts, config) {
629
753
  const detectedCommands = detectProviderCommands();
630
754
  const directSetupDefaults = getDirectSetupDefaults();
@@ -646,6 +770,7 @@ async function configureDirectMode(opts, config) {
646
770
  const port = await promptHostPort({
647
771
  defaultPort: parsePortInput(currentPort) || defaultConfig.port,
648
772
  optsPort: opts.port,
773
+ opts,
649
774
  });
650
775
 
651
776
  const currentPublicBaseUrl = config.public_base_url || "";
@@ -691,10 +816,10 @@ async function configureDirectMode(opts, config) {
691
816
  console.log(` Local login username: ${dashboardUser}`);
692
817
  printDetectedProviderStatus(detectedCommands);
693
818
  console.log(` Config saved to ${getConfigPath()}`);
694
- const serviceHandled = await maybeInstallOrStartReleaseService(opts, {
819
+ const serviceResult = await maybeInstallOrStartReleaseService(opts, {
695
820
  hostUrl: publicBaseUrl || `http://localhost:${port}`,
696
821
  });
697
- if (!serviceHandled) {
822
+ if (!serviceResult.handled) {
698
823
  console.log(`\n Next steps:`);
699
824
  console.log(` 1. Run: oysterun`);
700
825
  console.log(` 2. Open the Host URL directly from your client app`);
@@ -727,6 +852,7 @@ async function configureCloudDirectMode(opts, config, backendUrl, backendStage)
727
852
  const port = await promptHostPort({
728
853
  defaultPort: parsePortInput(portValue) || defaultConfig.port,
729
854
  optsPort: opts.port,
855
+ opts,
730
856
  });
731
857
 
732
858
  const directHostUrl = await promptDirectHostUrl({
@@ -821,11 +947,6 @@ async function configureCloudDirectMode(opts, config, backendUrl, backendStage)
821
947
  codex_command: detectedCommands.codex_command,
822
948
  });
823
949
 
824
- const qrPayload = createDirectHostLoginQrPayload({
825
- hostId,
826
- directHostUrl,
827
- });
828
-
829
950
  console.log(`\n ✓ Direct-IP Cloud Host registration complete`);
830
951
  console.log(` Host ID: ${hostId}`);
831
952
  console.log(` Direct Host URL: ${directHostUrl}`);
@@ -835,27 +956,34 @@ async function configureCloudDirectMode(opts, config, backendUrl, backendStage)
835
956
  printDetectedProviderStatus(detectedCommands);
836
957
  await maybeShowPhoneAppDownloadStep();
837
958
  printSetupStep(6, "Login QR");
838
- console.log("\n Direct Host connection QR:\n");
839
- qrcode.generate(JSON.stringify(qrPayload.compact), { small: true });
840
- console.log(`\n QR expires at: ${qrPayload.verbose.expires_at}`);
959
+ console.log(" A fresh login QR will be shown after the Host service starts.");
960
+ console.log(" If you choose not to start now, run `oysterun` later to start and print a QR.");
841
961
  console.log(`\n Config saved to ${getConfigPath()}`);
842
962
  console.log(` Cloud identity saved to ${getCloudIdentityPath()}`);
843
- const serviceHandled = await maybeInstallOrStartReleaseService(opts, {
963
+ const serviceResult = await maybeInstallOrStartReleaseService(opts, {
844
964
  hostUrl: directHostUrl,
845
965
  });
846
- if (serviceHandled) {
966
+ if (serviceResult.started) {
967
+ printDirectHostLoginQr({ hostId, directHostUrl });
847
968
  console.log("\n Oysterun is ready.");
848
969
  console.log(` Web: ${directHostUrl}/app/sessions`);
849
- console.log(" iPhone: scan the QR above, or enter the Host URL and Host password.");
970
+ console.log(" iPhone: scan the QR above.");
850
971
  console.log(" Later: run `oysterun`.");
972
+ return;
973
+ }
974
+ if (serviceResult.handled) {
975
+ console.log("\n Host service was not started.");
976
+ console.log(" Start later with: oysterun");
977
+ console.log(" The start command will print a fresh login QR.");
978
+ return;
851
979
  }
852
- if (!serviceHandled) {
980
+
981
+ if (!serviceResult.handled) {
853
982
  console.log(`\n Next steps:`);
854
983
  console.log(` 1. Run: oysterun`);
855
984
  console.log(` 2. Connect the app to ${directHostUrl}`);
856
- console.log(
857
- ` 3. Scan the QR within 15 minutes, or log in with the Host password`
858
- );
985
+ console.log(` 3. Run: oysterun show-qr`);
986
+ console.log(` 4. Scan the QR, or log in with the Host password`);
859
987
  console.log();
860
988
  }
861
989
  }
@@ -990,15 +1118,11 @@ async function main() {
990
1118
  // ── --show-qr: Re-display existing QR ──────────────────
991
1119
  if (opts.showQr) {
992
1120
  if (config.connection_mode === "direct" && config.host_id && config.direct_host_url) {
993
- const qrPayload = createDirectHostLoginQrPayload({
1121
+ printDirectHostLoginQr({
994
1122
  hostId: config.host_id,
995
1123
  directHostUrl: config.direct_host_url,
996
1124
  });
997
- console.log("\n Direct Host connection QR:\n");
998
- qrcode.generate(JSON.stringify(qrPayload.compact), { small: true });
999
- console.log(`\n Host ID: ${config.host_id}`);
1000
- console.log(` Direct Host URL: ${config.direct_host_url}`);
1001
- console.log(` QR expires at: ${qrPayload.verbose.expires_at}\n`);
1125
+ console.log();
1002
1126
  return;
1003
1127
  }
1004
1128
  if (config.connection_mode !== "cloud" || !config.onboarding_url) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oysterun",
3
- "version": "0.1.1-beta.2",
3
+ "version": "0.1.1-beta.5",
4
4
  "type": "module",
5
5
  "description": "Oysterun Host installer and local service",
6
6
  "bin": {
@@ -26,18 +26,43 @@ for development with OYSTERUN_RELEASE_STACK=<stack>.
26
26
  EOF
27
27
  }
28
28
 
29
+ print_login_qr_after_service_ready() {
30
+ local service_args=("$@")
31
+ local target_stack="${STACK_NAME}"
32
+ (
33
+ source "${SCRIPT_DIR}/oysterun_service_common.sh"
34
+ STACK_NAME="${target_stack}"
35
+ parse_common_args "${service_args[@]}"
36
+ configure_stack_runtime
37
+ local node_bin
38
+ node_bin="$(resolve_command_path node)"
39
+ if [[ -z "${node_bin}" ]]; then
40
+ echo "[oysterun-service] node not found; cannot print login QR" >&2
41
+ exit 1
42
+ fi
43
+ cd "${ROOT_DIR}"
44
+ OYSTERUN_CONFIG_DIR="${CONFIG_DIR}" \
45
+ OYSTERUN_PORT="${HOST_PORT}" \
46
+ "${node_bin}" host-service/setup.mjs --show-qr
47
+ ) || {
48
+ echo "[oysterun-service] Login QR unavailable. Run 'oysterun show-qr' after setup." >&2
49
+ }
50
+ }
51
+
29
52
  case "${COMMAND}" in
30
53
  install)
31
54
  exec "${SCRIPT_DIR}/install_oysterun_launch_agents.sh" --stack "${STACK_NAME}" "$@"
32
55
  ;;
33
56
  start)
34
- exec "${SCRIPT_DIR}/start_oysterun.sh" --stack "${STACK_NAME}" "$@"
57
+ "${SCRIPT_DIR}/start_oysterun.sh" --stack "${STACK_NAME}" "$@"
58
+ print_login_qr_after_service_ready "$@"
35
59
  ;;
36
60
  stop)
37
61
  exec "${SCRIPT_DIR}/stop_oysterun.sh" --stack "${STACK_NAME}" "$@"
38
62
  ;;
39
63
  restart)
40
- exec "${SCRIPT_DIR}/restart_oysterun.sh" --stack "${STACK_NAME}" "$@"
64
+ "${SCRIPT_DIR}/restart_oysterun.sh" --stack "${STACK_NAME}" "$@"
65
+ print_login_qr_after_service_ready "$@"
41
66
  ;;
42
67
  status)
43
68
  exec "${SCRIPT_DIR}/status_oysterun_launch_agents.sh" --stack "${STACK_NAME}" "$@"