bloby-bot 0.70.12 → 0.71.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.
Files changed (65) hide show
  1. package/bin/cli.js +234 -48
  2. package/dist-bloby/assets/{bloby-DSNB0g4w.js → bloby-es6cZJzs.js} +6 -6
  3. package/dist-bloby/assets/globals-DBqwNiJV.css +2 -0
  4. package/dist-bloby/assets/{globals-B3cTbITX.js → globals-DN3F0CQE.js} +1 -1
  5. package/dist-bloby/assets/{highlighted-body-OFNGDK62-BLforpkr.js → highlighted-body-OFNGDK62-8PiOHw9p.js} +1 -1
  6. package/dist-bloby/assets/mermaid-GHXKKRXX-BJWX8urU.js +1 -0
  7. package/dist-bloby/assets/{onboard-Dn2Ws_G2.js → onboard-BKgy17OU.js} +1 -1
  8. package/dist-bloby/bloby.html +3 -3
  9. package/dist-bloby/onboard.html +3 -3
  10. package/package.json +3 -4
  11. package/scripts/install +156 -41
  12. package/scripts/install.ps1 +146 -29
  13. package/scripts/install.sh +156 -41
  14. package/shared/config.ts +37 -2
  15. package/shared/relay.ts +3 -1
  16. package/supervisor/channels/manager.ts +84 -44
  17. package/supervisor/channels/telegram.ts +57 -16
  18. package/supervisor/channels/types.ts +4 -1
  19. package/supervisor/channels/whatsapp.ts +57 -10
  20. package/supervisor/chat/OnboardWizard.tsx +0 -15
  21. package/supervisor/chat/src/components/Chat/AudioBubble.tsx +1 -1
  22. package/supervisor/chat/src/components/Chat/AuthedImage.tsx +16 -3
  23. package/supervisor/chat/src/components/Chat/BlobyImageCard.tsx +2 -2
  24. package/supervisor/chat/src/components/Chat/ImageLightbox.tsx +25 -8
  25. package/supervisor/chat/src/components/Chat/InputBar.tsx +62 -7
  26. package/supervisor/chat/src/components/Chat/MessageBubble.tsx +37 -18
  27. package/supervisor/chat/src/components/Chat/MessageList.tsx +3 -3
  28. package/supervisor/chat/src/hooks/useChat.ts +52 -0
  29. package/supervisor/chat/src/lib/authedFile.ts +24 -12
  30. package/supervisor/file-saver.ts +92 -19
  31. package/supervisor/harnesses/attachment-policy.ts +111 -0
  32. package/supervisor/harnesses/claude.ts +62 -15
  33. package/supervisor/harnesses/codex.ts +69 -43
  34. package/supervisor/harnesses/pi/index.ts +367 -112
  35. package/supervisor/harnesses/pi/providers/humanize-error.ts +27 -2
  36. package/supervisor/harnesses/pi/providers/retry.ts +31 -0
  37. package/supervisor/harnesses/pi/providers/stream-anthropic.ts +31 -3
  38. package/supervisor/harnesses/pi/providers/stream-google.ts +26 -3
  39. package/supervisor/harnesses/pi/providers/stream-openai-completions.ts +32 -9
  40. package/supervisor/harnesses/pi/providers/types.ts +29 -1
  41. package/supervisor/harnesses/pi/session.ts +143 -3
  42. package/supervisor/harnesses/pi/test-completion.ts +56 -0
  43. package/supervisor/harnesses/pi/tools/bash.ts +198 -22
  44. package/supervisor/harnesses/pi/tools/glob.ts +79 -0
  45. package/supervisor/harnesses/pi/tools/grep.ts +0 -0
  46. package/supervisor/harnesses/pi/tools/registry.ts +18 -6
  47. package/supervisor/harnesses/pi/tools/todo-write.ts +45 -0
  48. package/supervisor/harnesses/pi/tools/web-fetch.ts +129 -0
  49. package/supervisor/index.ts +93 -18
  50. package/supervisor/widget.js +19 -5
  51. package/worker/db.ts +2 -0
  52. package/worker/index.ts +18 -1
  53. package/worker/prompts/bloby-system-prompt-codex.txt +1 -1
  54. package/worker/prompts/bloby-system-prompt-pi.txt +6 -24
  55. package/worker/prompts/bloby-system-prompt.txt +1 -1
  56. package/workspace/client/src/components/Dashboard/DashboardPage.tsx +4 -117
  57. package/workspace/client/src/components/Dashboard/deleteme_placeholders.tsx +194 -0
  58. package/workspace/client/src/components/Layout/Sidebar.tsx +52 -30
  59. package/workspace/client/src/components/deleteme_onboarding/WorkspaceTour.tsx +25 -15
  60. package/workspace/client/src/components/deleteme_onboarding/tour-theme.css +24 -0
  61. package/workspace/skills/mac/SKILL.md +13 -4
  62. package/dist-bloby/assets/globals-DyeW509Y.css +0 -2
  63. package/dist-bloby/assets/mermaid-GHXKKRXX-C1H_fSCU.js +0 -1
  64. package/supervisor/public/headphones_spritesheet.webp +0 -0
  65. package/supervisor/public/spritesheet.webp +0 -0
package/bin/cli.js CHANGED
@@ -125,13 +125,16 @@ const flags = new Set(globalArgs.filter(a => a.startsWith('-')));
125
125
  const HOSTED = flags.has('--hosted');
126
126
  const FOREGROUND = flags.has('--foreground');
127
127
  const FOLLOW = flags.has('-f') || flags.has('--follow');
128
+ // `init -advanced` (also --advanced) reveals the named-tunnel + private-network
129
+ // options. Without it, init goes straight to the zero-config quick tunnel.
130
+ const ADVANCED = flags.has('-advanced') || flags.has('--advanced');
128
131
  const positional = globalArgs.filter(a => !a.startsWith('-'));
129
132
  const command = positional[0];
130
133
  const subcommand = positional[1];
131
134
 
132
135
  // Strict parsing covers flags too — a typo like --forground must not silently
133
136
  // fall through to a different behavior (x402's own flags pass through verbatim).
134
- const KNOWN_FLAGS = new Set(['--hosted', '--foreground', '-f', '--follow', '-n', '--lines', '-h', '--help', '-v', '--version']);
137
+ const KNOWN_FLAGS = new Set(['--hosted', '--foreground', '-f', '--follow', '-n', '--lines', '-h', '--help', '-v', '--version', '-advanced', '--advanced']);
135
138
  for (const f of flags) {
136
139
  const name = f.includes('=') ? f.slice(0, f.indexOf('=')) : f;
137
140
  if (!KNOWN_FLAGS.has(name)) {
@@ -524,6 +527,7 @@ function printHelp() {
524
527
  ${c.blue}${'version'.padEnd(18)}${c.reset}Print the installed version
525
528
 
526
529
  ${c.bold}Flags${c.reset}
530
+ ${c.dim}init -advanced${c.reset} Choose a named tunnel or private-network mode
527
531
  ${c.dim}start --foreground${c.reset} Run attached to this terminal (debugging)
528
532
  `);
529
533
  }
@@ -568,7 +572,46 @@ function unknownCommand(input) {
568
572
  // ── Config helpers ──
569
573
 
570
574
  function readConfig() {
571
- try { return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); } catch { return null; }
575
+ try { return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); }
576
+ catch {
577
+ // A torn/truncated write must NOT fall through to null — init treats a null
578
+ // config as "not set up" and regenerates a NEW wallet, permanently losing the
579
+ // funded key. Recover from the .bak mirror instead.
580
+ try {
581
+ const bak = `${CONFIG_PATH}.bak`;
582
+ if (fs.existsSync(bak)) {
583
+ const cfg = JSON.parse(fs.readFileSync(bak, 'utf-8'));
584
+ try { writeConfig(cfg); } catch {}
585
+ return cfg;
586
+ }
587
+ } catch {}
588
+ return null;
589
+ }
590
+ }
591
+
592
+ /** Atomic config write: temp file + rename (atomic on the same fs), then mirror
593
+ * to .bak. The CLI and the long-lived supervisor both write config.json, so a
594
+ * direct writeFileSync can be observed half-written (→ wallet loss). */
595
+ function writeConfig(config) {
596
+ fs.mkdirSync(DATA_DIR, { recursive: true });
597
+ const json = JSON.stringify(config, null, 2);
598
+ const tmp = `${CONFIG_PATH}.${process.pid}.tmp`;
599
+ try {
600
+ fs.writeFileSync(tmp, json);
601
+ try {
602
+ fs.renameSync(tmp, CONFIG_PATH);
603
+ } catch (err) {
604
+ // Windows: rename over a file another process holds open can EPERM/EEXIST.
605
+ if (PLATFORM === 'win32' && (err.code === 'EPERM' || err.code === 'EEXIST')) {
606
+ fs.copyFileSync(tmp, CONFIG_PATH);
607
+ fs.unlinkSync(tmp);
608
+ } else throw err;
609
+ }
610
+ try { fs.copyFileSync(CONFIG_PATH, `${CONFIG_PATH}.bak`); } catch {}
611
+ } catch (err) {
612
+ try { fs.unlinkSync(tmp); } catch {}
613
+ throw err;
614
+ }
572
615
  }
573
616
 
574
617
  function tunnelModeOf(config) {
@@ -718,11 +761,17 @@ function getRealHome() {
718
761
  }
719
762
 
720
763
  function generateUnitFile({ user, home, nodePath, dataDir }) {
764
+ // A newline in any interpolated value would inject arbitrary unit directives.
765
+ for (const [k, v] of Object.entries({ user, home, nodePath, dataDir })) {
766
+ if (/[\r\n]/.test(String(v))) throw new Error(`Invalid ${k} for systemd unit (contains a newline)`);
767
+ }
721
768
  const nodeBinDir = path.dirname(nodePath);
722
769
  return `[Unit]
723
770
  Description=Bloby Bot
724
771
  After=network-online.target
725
772
  Wants=network-online.target
773
+ StartLimitIntervalSec=60
774
+ StartLimitBurst=10
726
775
 
727
776
  [Service]
728
777
  Type=simple
@@ -731,6 +780,7 @@ WorkingDirectory=${dataDir}
731
780
  ExecStart=${nodePath} --import tsx/esm ${dataDir}/supervisor/index.ts
732
781
  Restart=on-failure
733
782
  RestartSec=5
783
+ TimeoutStopSec=30
734
784
  Environment=HOME=${home}
735
785
  Environment=NODE_ENV=development
736
786
  Environment=NODE_PATH=${dataDir}/node_modules
@@ -814,34 +864,47 @@ function launchdJob() {
814
864
  return primary; // both not-loaded/unknown — report the preferred domain's view
815
865
  }
816
866
 
867
+ // Escape values interpolated into XML — a path/username containing & < > " '
868
+ // would otherwise produce a malformed plist that launchd refuses to load.
869
+ function xmlEscape(s) {
870
+ return String(s)
871
+ .replace(/&/g, '&amp;')
872
+ .replace(/</g, '&lt;')
873
+ .replace(/>/g, '&gt;')
874
+ .replace(/"/g, '&quot;')
875
+ .replace(/'/g, '&apos;');
876
+ }
877
+
817
878
  function generateLaunchdPlist({ nodePath, dataDir }) {
818
879
  const nodeBinDir = path.dirname(nodePath);
819
880
  fs.mkdirSync(LAUNCHD_LOG_DIR, { recursive: true });
881
+ const e = xmlEscape;
882
+ const pathEnv = `${nodeBinDir}:${dataDir}/node_modules/.bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin`;
820
883
  return `<?xml version="1.0" encoding="UTF-8"?>
821
884
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
822
885
  <plist version="1.0">
823
886
  <dict>
824
887
  <key>Label</key>
825
- <string>${LAUNCHD_LABEL}</string>
888
+ <string>${e(LAUNCHD_LABEL)}</string>
826
889
  <key>ProgramArguments</key>
827
890
  <array>
828
- <string>${nodePath}</string>
891
+ <string>${e(nodePath)}</string>
829
892
  <string>--import</string>
830
893
  <string>tsx/esm</string>
831
- <string>${dataDir}/supervisor/index.ts</string>
894
+ <string>${e(dataDir)}/supervisor/index.ts</string>
832
895
  </array>
833
896
  <key>WorkingDirectory</key>
834
- <string>${dataDir}</string>
897
+ <string>${e(dataDir)}</string>
835
898
  <key>EnvironmentVariables</key>
836
899
  <dict>
837
900
  <key>HOME</key>
838
- <string>${os.homedir()}</string>
901
+ <string>${e(os.homedir())}</string>
839
902
  <key>NODE_ENV</key>
840
903
  <string>development</string>
841
904
  <key>NODE_PATH</key>
842
- <string>${dataDir}/node_modules</string>
905
+ <string>${e(dataDir)}/node_modules</string>
843
906
  <key>PATH</key>
844
- <string>${nodeBinDir}:${dataDir}/node_modules/.bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
907
+ <string>${e(pathEnv)}</string>
845
908
  </dict>
846
909
  <key>RunAtLoad</key>
847
910
  <true/>
@@ -852,10 +915,14 @@ function generateLaunchdPlist({ nodePath, dataDir }) {
852
915
  </dict>
853
916
  <key>ThrottleInterval</key>
854
917
  <integer>5</integer>
918
+ <key>StandardInPath</key>
919
+ <string>/dev/null</string>
855
920
  <key>StandardOutPath</key>
856
- <string>${LAUNCHD_LOG}</string>
921
+ <string>${e(LAUNCHD_LOG)}</string>
857
922
  <key>StandardErrorPath</key>
858
- <string>${LAUNCHD_LOG}</string>
923
+ <string>${e(LAUNCHD_LOG)}</string>
924
+ <key>ExitTimeOut</key>
925
+ <integer>20</integer>
859
926
  <key>ProcessType</key>
860
927
  <string>Standard</string>
861
928
  </dict>
@@ -909,10 +976,27 @@ function isDaemonActive() {
909
976
  return daemonPid() !== null;
910
977
  }
911
978
 
979
+ /** Resolve a stable Node path to bake into the launchd plist / systemd unit.
980
+ * process.execPath under nvm/fnm/volta/asdf/mise is a per-version binary that
981
+ * vanishes when the user switches/removes that version — the daemon then fails
982
+ * to start on boot. Substitute Bloby's bundled node, then a system node, and
983
+ * only fall back to the running node (never a guessed-missing path). */
984
+ function resolveStableNodePath() {
985
+ const candidate = process.env.BLOBY_NODE_PATH || process.execPath;
986
+ const transient = /(?:[/\\]\.(?:nvm|fnm|volta|asdf)[/\\])|fnm_multishells|[/\\]fnm[/\\]node-versions[/\\]|[/\\]mise[/\\]/.test(candidate);
987
+ if (!transient) return candidate;
988
+ const bundled = path.join(DATA_DIR, 'tools', 'node', 'bin', PLATFORM === 'win32' ? 'node.exe' : 'node');
989
+ if (fs.existsSync(bundled)) return bundled;
990
+ for (const sys of ['/usr/local/bin/node', '/usr/bin/node']) {
991
+ if (fs.existsSync(sys)) return sys;
992
+ }
993
+ return candidate;
994
+ }
995
+
912
996
  /** Write the service definition (idempotent; refreshes node path / data dir drift).
913
997
  * Returns { ok, error }. */
914
998
  function installServiceFiles({ spinner } = {}) {
915
- const nodePath = process.env.BLOBY_NODE_PATH || process.execPath;
999
+ const nodePath = resolveStableNodePath();
916
1000
  if (PLATFORM === 'darwin') {
917
1001
  const dataDir = ROOT; // repo in dev, ~/.bloby in production
918
1002
  fs.mkdirSync(path.dirname(LAUNCHD_PLIST_PATH), { recursive: true });
@@ -958,6 +1042,9 @@ function startDaemonService({ spinner } = {}) {
958
1042
  if (res.status === 0) return { ok: true };
959
1043
  return { ok: false, error: launchdError(res) };
960
1044
  }
1045
+ // Enable before the first bootstrap: a stale disable override (Login Items
1046
+ // toggle, a prior bootout) otherwise makes bootstrap fail with 119/5.
1047
+ launchctl(['enable', `${launchdDomain()}/${LAUNCHD_LABEL}`]);
961
1048
  let res = launchctl(['bootstrap', launchdDomain(), LAUNCHD_PLIST_PATH]);
962
1049
  if (res.status === 0 || res.status === 37) return { ok: true }; // 37 = already bootstrapped
963
1050
  if (res.status === 5) {
@@ -980,6 +1067,13 @@ function startDaemonService({ spinner } = {}) {
980
1067
  return { ok: false, error: launchdError(res) };
981
1068
  }
982
1069
  if (PLATFORM === 'linux') {
1070
+ // A prior crash-loop can latch the unit 'failed', after which `systemctl
1071
+ // start` is rejected ("Start request repeated too quickly") until a manual
1072
+ // reset-failed. Clear it only when actually failed so the happy path keeps
1073
+ // its single sudo call (systemdShow needs no privilege).
1074
+ if (systemdShow().activeState === 'failed') {
1075
+ runPrivileged(['systemctl', 'reset-failed', SERVICE_NAME], { spinner });
1076
+ }
983
1077
  return runPrivileged(['systemctl', 'start', SERVICE_NAME], { spinner });
984
1078
  }
985
1079
  return { ok: false, error: 'No daemon support on this platform.' };
@@ -1774,14 +1868,28 @@ function cmdLogs() {
1774
1868
  function createConfig() {
1775
1869
  fs.mkdirSync(DATA_DIR, { recursive: true });
1776
1870
  if (!fs.existsSync(CONFIG_PATH)) {
1871
+ // Env-seed (additive): hosted provisioning passes the pre-registered identity
1872
+ // via BLOBY_* env vars (same contract as docker-entrypoint.sh) so the box boots
1873
+ // already owning its handle + relay token. For normal local installs these are
1874
+ // all unset, so the config is identical to before.
1777
1875
  const config = {
1778
- port: 7400,
1779
- username: '',
1780
- ai: { provider: '', model: '', apiKey: '' },
1781
- tunnel: { mode: 'quick' },
1782
- relay: { token: '', tier: '', url: '' },
1876
+ port: parseInt(process.env.BLOBY_PORT || '7400', 10),
1877
+ username: process.env.BLOBY_USERNAME || '',
1878
+ ai: {
1879
+ provider: process.env.BLOBY_AI_PROVIDER || '',
1880
+ model: process.env.BLOBY_AI_MODEL || '',
1881
+ apiKey: process.env.BLOBY_AI_API_KEY || '',
1882
+ },
1883
+ // Hosted boxes are reachable directly (public IP + Caddy + CF DNS), so they
1884
+ // need no tunnel. Honor an explicit BLOBY_TUNNEL_MODE; otherwise default quick.
1885
+ tunnel: { mode: process.env.BLOBY_TUNNEL_MODE || 'quick' },
1886
+ relay: {
1887
+ token: process.env.BLOBY_RELAY_TOKEN || '',
1888
+ tier: process.env.BLOBY_RELAY_TIER || '',
1889
+ url: process.env.BLOBY_RELAY_URL || '',
1890
+ },
1783
1891
  };
1784
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
1892
+ writeConfig(config);
1785
1893
  }
1786
1894
  }
1787
1895
 
@@ -1903,6 +2011,14 @@ function ask(question) {
1903
2011
  }
1904
2012
 
1905
2013
  async function runNamedTunnelSetup() {
2014
+ // cloudflared writes cert.pem + <uuid>.json under $HOME/.cloudflared. Under
2015
+ // sudo that's /root, which the non-root daemon can't read — the named tunnel
2016
+ // then fails silently on every boot. Refuse, mirroring `bloby update`.
2017
+ if (PLATFORM !== 'win32' && process.getuid?.() === 0) {
2018
+ console.log(`\n ${GLYPH.err} Don't run named-tunnel setup with sudo.`);
2019
+ console.log(` ${c.dim}cloudflared stores credentials under your home dir — run it as your normal user.${c.reset}\n`);
2020
+ process.exit(1);
2021
+ }
1906
2022
  const s = new Spinner().start('Checking cloudflared...');
1907
2023
  await installCloudflared();
1908
2024
  s.succeed('cloudflared ready');
@@ -1952,7 +2068,7 @@ async function runNamedTunnelSetup() {
1952
2068
 
1953
2069
  const config = readConfig() || {};
1954
2070
  const port = config.port || 7400;
1955
- const cfHome = path.join(os.homedir(), '.cloudflared');
2071
+ const cfHome = path.join(resolveRealHome(), '.cloudflared');
1956
2072
  const cfConfigPath = path.join(DATA_DIR, 'cloudflared-config.yml');
1957
2073
 
1958
2074
  const yamlContent = `tunnel: ${tunnelUuid}
@@ -1984,7 +2100,11 @@ async function cmdInit() {
1984
2100
  if (dPid || runtime) {
1985
2101
  const config = readConfig();
1986
2102
  if (HOSTED) {
1987
- console.log(`__HOSTED_READY__=${JSON.stringify({ tunnelUrl: config?.tunnelUrl || `http://localhost:${config?.port || 7400}`, status: config?.tunnelUrl ? 'ok' : 'tunnel_failed', daemon: true })}`);
2103
+ // Managed mode (tunnel off) has no tunnelUrl by design a running daemon
2104
+ // is itself "ok". Only the tunnel modes treat a missing URL as a failure.
2105
+ const noTunnel = (config?.tunnel?.mode || 'quick') === 'off';
2106
+ const reStatus = noTunnel ? 'ok' : (config?.tunnelUrl ? 'ok' : 'tunnel_failed');
2107
+ console.log(`__HOSTED_READY__=${JSON.stringify({ tunnelUrl: config?.tunnelUrl || `http://localhost:${config?.port || 7400}`, status: reStatus, daemon: true })}`);
1988
2108
  process.exit(0);
1989
2109
  }
1990
2110
  resultBox([` ${c.blue}●${c.reset} ${c.bold}Bloby is already set up and running${c.reset}`]);
@@ -1998,15 +2118,34 @@ async function cmdInit() {
1998
2118
  createConfig();
1999
2119
  writeVersionFile(pkg.version);
2000
2120
 
2001
- // --hosted: non-interactive, quick tunnel, machine-readable output
2002
- const tunnelMode = HOSTED ? 'quick' : await (async () => {
2121
+ // --hosted: non-interactive, quick tunnel, machine-readable output.
2122
+ const config = readConfig() || {};
2123
+ const existingMode = config.tunnel?.mode;
2124
+
2125
+ // --hosted: non-interactive quick tunnel.
2126
+ // Default (no -advanced): KEEP an already-configured named/private mode instead
2127
+ // of clobbering it, otherwise use the zero-config quick tunnel. The full chooser
2128
+ // (named tunnel + private network) only appears with `bloby init -advanced`.
2129
+ let tunnelMode;
2130
+ if (HOSTED) {
2131
+ // Managed boxes are reachable directly (public IP + Caddy + a per-bot CF DNS
2132
+ // record), so the default is NO tunnel — the AMI's provision.sh sets
2133
+ // BLOBY_TUNNEL_MODE=off, which createConfig() seeded here. Legacy tunnel AMIs
2134
+ // that don't set it keep the historical quick-tunnel behavior.
2135
+ tunnelMode = existingMode || 'quick';
2136
+ } else if (!ADVANCED) {
2137
+ tunnelMode = (existingMode === 'named' || existingMode === 'off') ? existingMode : 'quick';
2138
+ if (tunnelMode === 'quick') {
2139
+ console.log(`\n ${c.dim}Using the quick tunnel (random URL). For a named tunnel or private-network mode, run ${c.reset}${c.blue}bloby init -advanced${c.reset}${c.dim}.${c.reset}`);
2140
+ } else {
2141
+ const label = existingMode === 'named' ? 'named tunnel' : 'private-network';
2142
+ console.log(`\n ${c.dim}Keeping your existing ${c.reset}${c.white}${label}${c.reset}${c.dim} configuration. Run ${c.reset}${c.blue}bloby init -advanced${c.reset}${c.dim} to change it.${c.reset}`);
2143
+ }
2144
+ } else {
2003
2145
  console.log('');
2004
- const mode = await chooseTunnelMode();
2146
+ tunnelMode = await chooseTunnelMode();
2005
2147
  console.log('');
2006
- return mode;
2007
- })();
2008
-
2009
- const config = readConfig() || {};
2148
+ }
2010
2149
 
2011
2150
  // Generate USDC wallet (skip if one already exists)
2012
2151
  if (!config.wallet?.privateKey) {
@@ -2017,17 +2156,22 @@ async function cmdInit() {
2017
2156
  }
2018
2157
 
2019
2158
  if (tunnelMode === 'named') {
2020
- const setup = await runNamedTunnelSetup();
2021
- config.tunnel = {
2022
- mode: 'named',
2023
- name: setup.tunnelName,
2024
- domain: setup.domain,
2025
- configPath: setup.cfConfigPath,
2026
- };
2159
+ // Re-run the interactive cloudflared setup only on an explicit `-advanced`
2160
+ // choice; the default path reuses the already-configured named tunnel as-is.
2161
+ const reuseExisting = !ADVANCED && config.tunnel?.mode === 'named' && config.tunnel?.name;
2162
+ if (!reuseExisting) {
2163
+ const setup = await runNamedTunnelSetup();
2164
+ config.tunnel = {
2165
+ mode: 'named',
2166
+ name: setup.tunnelName,
2167
+ domain: setup.domain,
2168
+ configPath: setup.cfConfigPath,
2169
+ };
2170
+ }
2027
2171
  } else {
2028
2172
  config.tunnel = { mode: tunnelMode };
2029
2173
  }
2030
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
2174
+ writeConfig(config);
2031
2175
 
2032
2176
  const log = HOSTED ? (msg) => console.log(`[bloby] ${msg}`) : null;
2033
2177
 
@@ -2037,9 +2181,12 @@ async function cmdInit() {
2037
2181
  if (!hasDaemonSupport()) {
2038
2182
  if (HOSTED) {
2039
2183
  if (log) log('Starting server (foreground, no daemon support)...');
2184
+ const noTunnel = tunnelMode === 'off';
2040
2185
  let result;
2041
2186
  try {
2042
- await installCloudflared();
2187
+ // Managed mode (tunnel off) is reached directly via Caddy + CF DNS — no
2188
+ // cloudflared needed. Only install it when a tunnel mode is actually used.
2189
+ if (!noTunnel) await installCloudflared();
2043
2190
  result = await bootServer({
2044
2191
  onTunnelUp: (url) => { if (log && url) log(`Tunnel up: ${url}`); },
2045
2192
  });
@@ -2048,7 +2195,13 @@ async function cmdInit() {
2048
2195
  process.exit(1);
2049
2196
  }
2050
2197
  await Promise.race([result.viteWarm, new Promise(r => setTimeout(r, 30_000))]);
2051
- console.log(`__HOSTED_READY__=${JSON.stringify({ tunnelUrl: result.tunnelUrl, status: result.tunnelFailed ? 'tunnel_failed' : 'ok' })}`);
2198
+ // With no tunnel, readiness == the local server is healthy; a missing
2199
+ // tunnelUrl is expected, not a failure.
2200
+ const status = noTunnel
2201
+ ? (result.healthy === false ? 'failed' : 'ok')
2202
+ : (result.tunnelFailed ? 'tunnel_failed' : 'ok');
2203
+ const readyUrl = result.tunnelUrl || `http://localhost:${result.port || config.port || 7400}`;
2204
+ console.log(`__HOSTED_READY__=${JSON.stringify({ tunnelUrl: readyUrl, status })}`);
2052
2205
  result.child.stdout.on('data', (d) => process.stdout.write(d));
2053
2206
  result.child.stderr.on('data', (d) => process.stderr.write(d));
2054
2207
  return; // stays attached
@@ -2059,7 +2212,13 @@ async function cmdInit() {
2059
2212
 
2060
2213
  if (log) log('Starting daemon...');
2061
2214
  const initConfig = readConfig() || {};
2062
- const steps = new Steps(startStepTitles(initConfig)).start();
2215
+ const initTitles = startStepTitles(initConfig);
2216
+ // The tunnel URL answers a few seconds after registration succeeds — hold
2217
+ // before revealing it on first setup so the user's first click doesn't 404.
2218
+ // Shown as its own step so the wait doesn't look like a hang.
2219
+ const goLiveHold = !HOSTED && tunnelModeOf(initConfig) !== 'off';
2220
+ if (goLiveHold) initTitles.push('Going live');
2221
+ const steps = new Steps(initTitles).start();
2063
2222
  // Hosted provisioning treats tunnel_failed as meaningful — give cold cloud
2064
2223
  // boxes a longer window before declaring the tunnel down.
2065
2224
  const result = await startCore({ ui: steps, ...(HOSTED ? { timeoutMs: 180_000 } : {}) });
@@ -2070,14 +2229,21 @@ async function cmdInit() {
2070
2229
  }
2071
2230
  process.exit(1);
2072
2231
  }
2073
- if (result.healthy && !result.tunnelFailed && (result.tunnelMode === 'off' || result.ready)) steps.finish();
2074
- else steps.fail();
2232
+ if (result.healthy && !result.tunnelFailed && (result.tunnelMode === 'off' || result.ready)) {
2233
+ if (goLiveHold && result.tunnelUrl) await new Promise((r) => setTimeout(r, 5000));
2234
+ steps.finish();
2235
+ } else steps.fail();
2075
2236
 
2076
2237
  if (HOSTED) {
2077
2238
  if (log) log(result.healthy ? 'Daemon running' : 'Daemon starting');
2078
- // status keyed off the supervisor's explicit __TUNNEL_FAILED__ marker (same
2079
- // semantics as the no-daemon foreground path) a slow tunnel is not a failure.
2080
- const hostedStatus = result.tunnelFailed || !result.tunnelUrl ? 'tunnel_failed' : 'ok';
2239
+ // Managed mode (tunnel off) is reached directly via Caddy + CF DNS, so there is
2240
+ // no tunnelUrl readiness is just a healthy local server. For tunnel modes,
2241
+ // status keys off the supervisor's __TUNNEL_FAILED__ marker (a slow tunnel is
2242
+ // not a failure).
2243
+ const noTunnel = (result.tunnelMode || tunnelModeOf(initConfig)) === 'off';
2244
+ const hostedStatus = noTunnel
2245
+ ? (result.healthy ? 'ok' : 'starting')
2246
+ : (result.tunnelFailed || !result.tunnelUrl ? 'tunnel_failed' : 'ok');
2081
2247
  console.log(`__HOSTED_READY__=${JSON.stringify({ tunnelUrl: result.tunnelUrl || `http://localhost:${result.port}`, status: hostedStatus, daemon: true })}`);
2082
2248
  process.exit(0);
2083
2249
  }
@@ -2140,9 +2306,29 @@ async function cmdUpdate() {
2140
2306
  const tarball = path.join(tmpDir, 'bloby.tgz');
2141
2307
 
2142
2308
  try {
2143
- const res = await fetch(latest.dist.tarball, { signal: AbortSignal.timeout(300_000) });
2144
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
2145
- const buf = Buffer.from(await res.arrayBuffer());
2309
+ // Retry transient failures (network/5xx); a 4xx means the tarball genuinely
2310
+ // isn't there, so fail fast instead of burning the 300s window three times.
2311
+ let buf = null;
2312
+ for (let attempt = 1; attempt <= 3; attempt++) {
2313
+ try {
2314
+ const res = await fetch(latest.dist.tarball, { signal: AbortSignal.timeout(300_000) });
2315
+ if (res.status >= 400 && res.status < 500) throw Object.assign(new Error(`HTTP ${res.status}`), { fatal: true });
2316
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
2317
+ buf = Buffer.from(await res.arrayBuffer());
2318
+ break;
2319
+ } catch (e) {
2320
+ if (e.fatal || attempt === 3) throw e;
2321
+ await new Promise((r) => setTimeout(r, 2000 * attempt));
2322
+ }
2323
+ }
2324
+ // Integrity: match the npm registry's published sha512 (dist.integrity) before
2325
+ // this is auto-deployed onto a running, wallet-bearing agent. Zero-dep (node:crypto).
2326
+ const want = (latest.dist.integrity || '').replace(/^sha512-/, '');
2327
+ if (want) {
2328
+ const crypto = await import('crypto');
2329
+ const got = crypto.createHash('sha512').update(buf).digest('base64');
2330
+ if (got !== want) throw new Error('integrity check failed (sha512 mismatch) — refusing to install a corrupt update');
2331
+ }
2146
2332
  fs.writeFileSync(tarball, buf);
2147
2333
  execSync(`tar xzf "${tarball}" -C "${tmpDir}"`, { stdio: 'ignore' });
2148
2334
  } catch (e) {
@@ -2439,7 +2625,7 @@ async function cmdTunnel(sub) {
2439
2625
  domain: setup.domain,
2440
2626
  configPath: setup.cfConfigPath,
2441
2627
  };
2442
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
2628
+ writeConfig(config);
2443
2629
  console.log(` ${GLYPH.ok} Bloby config updated\n`);
2444
2630
 
2445
2631
  if (isDaemonActive()) {
@@ -2486,7 +2672,7 @@ async function cmdTunnel(sub) {
2486
2672
  const config = readConfig() || {};
2487
2673
  config.tunnel = { mode: 'quick' };
2488
2674
  delete config.tunnelUrl;
2489
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
2675
+ writeConfig(config);
2490
2676
  console.log(`\n ${GLYPH.ok} Tunnel mode reset to ${c.bold}quick${c.reset} (random trycloudflare.com URL).`);
2491
2677
  if (isDaemonActive()) {
2492
2678
  console.log(` ${c.dim}Restart to apply: ${c.reset}${c.blue}bloby restart${c.reset}`);