@bubblebrain-ai/bubble 0.0.14 → 0.0.15

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/dist/cli.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * CLI argument parsing.
3
3
  */
4
4
  import type { PermissionMode, ThinkingLevel } from "./types.js";
5
- export type CliCommand = "default" | "serve";
5
+ export type CliCommand = "default" | "serve" | "update";
6
6
  export interface CliArgs {
7
7
  command: CliCommand;
8
8
  model?: string;
@@ -22,6 +22,8 @@ export interface CliArgs {
22
22
  killOld?: boolean;
23
23
  /** `serve` subcommand: connect then exit. */
24
24
  dryRun?: boolean;
25
+ /** `update` subcommand: only report whether an update exists, don't install. */
26
+ checkOnly?: boolean;
25
27
  }
26
28
  export declare function parseArgs(argv: string[]): CliArgs;
27
29
  export declare function printHelp(): void;
package/dist/cli.js CHANGED
@@ -14,6 +14,10 @@ export function parseArgs(argv) {
14
14
  args.command = "serve";
15
15
  startIndex = 1;
16
16
  }
17
+ else if (argv[0] === "update" || argv[0] === "upgrade") {
18
+ args.command = "update";
19
+ startIndex = 1;
20
+ }
17
21
  }
18
22
  for (let i = startIndex; i < argv.length; i++) {
19
23
  const arg = argv[i];
@@ -72,6 +76,9 @@ export function parseArgs(argv) {
72
76
  case "--dry-run":
73
77
  args.dryRun = true;
74
78
  break;
79
+ case "--check":
80
+ args.checkOnly = true;
81
+ break;
75
82
  default:
76
83
  if (!arg.startsWith("-") && !args.prompt) {
77
84
  args.prompt = arg;
@@ -85,6 +92,7 @@ export function printHelp() {
85
92
  console.log(`
86
93
  Usage:
87
94
  bubble [options] [prompt] Start interactive TUI
95
+ bubble update [--check] Update to the latest version (alias: upgrade)
88
96
  bubble serve --feishu [options] Run as a Feishu bot host
89
97
 
90
98
  Options (default):
@@ -99,8 +107,12 @@ Options (default):
99
107
  --dangerously-skip-permissions
100
108
  Enable bypass mode (auto-approve EVERY tool; disables all safety prompts)
101
109
  -p, --print Non-interactive mode (single prompt)
110
+ -v, --version Print the installed version and exit
102
111
  -h, --help Show this help
103
112
 
113
+ Options (update):
114
+ --check Only report whether a newer version exists
115
+
104
116
  Options (serve --feishu):
105
117
  --setup Force the wizard (scan QR + bind first scope)
106
118
  --kill-old Kill any conflicting bubble instance for the same App ID
package/dist/main.js CHANGED
@@ -34,6 +34,16 @@ async function main() {
34
34
  printHelp();
35
35
  process.exit(0);
36
36
  }
37
+ if (process.argv.includes("-v") || process.argv.includes("--version")) {
38
+ const { getCurrentVersion } = await import("./update/index.js");
39
+ console.log(`v${getCurrentVersion()}`);
40
+ process.exit(0);
41
+ }
42
+ if (args.command === "update") {
43
+ const { runUpdateCommand } = await import("./update/index.js");
44
+ const code = await runUpdateCommand({ checkOnly: args.checkOnly });
45
+ process.exit(code);
46
+ }
37
47
  if (args.command === "serve") {
38
48
  if (args.serveHost !== "feishu") {
39
49
  console.error(chalk.red("Usage: bubble serve --feishu [--setup | --kill-old | --dry-run]"));
@@ -475,6 +485,8 @@ async function main() {
475
485
  runMemorySummary,
476
486
  runMemoryRefresh,
477
487
  };
488
+ const { getStartupUpdateNotice } = await import("./update/index.js");
489
+ const updateNotice = await getStartupUpdateNotice();
478
490
  const { runTui } = await import("./tui/run.js");
479
491
  await runTui(agent, args, {
480
492
  ...commonOptions,
@@ -482,6 +494,7 @@ async function main() {
482
494
  themeOverrides: themeConfig.overrides,
483
495
  detectedTheme,
484
496
  onThemeModeChange: (mode) => userConfig.setThemeMode(mode),
497
+ updateNotice: updateNotice ?? undefined,
485
498
  });
486
499
  if (sessionManager) {
487
500
  printOpenTuiExitSummary(sessionManager, {
package/dist/tui/run.d.ts CHANGED
@@ -41,5 +41,7 @@ export interface RunTuiOptions {
41
41
  runMemoryCompaction?: () => Promise<string>;
42
42
  runMemorySummary?: (scope?: MemoryScope) => Promise<string>;
43
43
  runMemoryRefresh?: (scope?: MemoryScope) => Promise<string>;
44
+ /** One-line "update available" notice shown on the home screen, if any. */
45
+ updateNotice?: string;
44
46
  }
45
47
  export declare function runTui(agent: Agent, args: CliArgs, options?: RunTuiOptions): Promise<void>;
package/dist/tui/run.js CHANGED
@@ -13,6 +13,7 @@ import { summarizeAgentEventForTrace, summarizeTraceError, summarizeTraceValue,
13
13
  import { BUILTIN_PROVIDERS, decodeModel, displayModel, isUserVisibleProvider } from "../provider-registry.js";
14
14
  import { calculateUsageCost } from "../model-pricing.js";
15
15
  import { getAvailableThinkingLevels } from "../provider-transform.js";
16
+ import { getCurrentVersion } from "../update/index.js";
16
17
  import { collectUsageStatsBundle, formatStatsPanelBody } from "../stats/usage.js";
17
18
  import { parseSkillInvocation } from "../skills/invocation.js";
18
19
  import { registry as slashRegistry } from "../slash-commands/index.js";
@@ -181,7 +182,7 @@ const PROMPT_SCANNER_IDLE_FRAMES = [" "];
181
182
  const PROMPT_SCANNER_INTERVAL_MS = 80;
182
183
  const SESSION_SIDEBAR_WIDTH = 42;
183
184
  const SESSION_SIDEBAR_AUTO_WIDTH = 120;
184
- const PROVIDER_DIALOG_ROWS = 11;
185
+ const PROVIDER_DIALOG_ROWS = 13;
185
186
  const QUESTION_MAX_TABS = 4;
186
187
  const QUESTION_MAX_OPTIONS = 10;
187
188
  const QUESTION_MAX_CONFIRM_ROWS = 3;
@@ -1104,7 +1105,7 @@ function OpenTuiApp(props) {
1104
1105
  if (!safeSetText(ref, promptModeBadge()))
1105
1106
  promptModeLabels.delete(ref);
1106
1107
  };
1107
- const promptModelTitle = () => displayModel(props.agent.model) || "no model";
1108
+ const promptModelTitle = () => displayModelWithThinking(props.agent.model, props.agent.thinking) || "no model";
1108
1109
  const syncModelChrome = () => {
1109
1110
  if (uiDisposed)
1110
1111
  return;
@@ -3046,6 +3047,31 @@ function OpenTuiApp(props) {
3046
3047
  // Keep the already-rendered local catalog when remote model discovery fails.
3047
3048
  }
3048
3049
  }
3050
+ function providerDialogMatchScore(item, query) {
3051
+ const label = (item.label || "").toLowerCase();
3052
+ const value = (item.value || "").toLowerCase();
3053
+ const haystack = [
3054
+ item.label,
3055
+ item.detail,
3056
+ item.value,
3057
+ item.category,
3058
+ item.footer,
3059
+ ].filter(Boolean).join(" ").toLowerCase();
3060
+ if (label.startsWith(query))
3061
+ return 100;
3062
+ if (label.includes(query))
3063
+ return 80;
3064
+ if (value.includes(query))
3065
+ return 60;
3066
+ if (haystack.includes(query))
3067
+ return 40;
3068
+ // Fuzzy (subsequence) match is a last resort, and only against label+value
3069
+ // so long provider descriptions (e.g. "platform.moonshot.cn") don't produce
3070
+ // spurious hits like "gpt" matching "kimi-k2-thinking".
3071
+ if (fuzzyMatch(`${label} ${value}`, query))
3072
+ return 20;
3073
+ return 0;
3074
+ }
3049
3075
  function providerDialogFilteredItems(state = providerDialog) {
3050
3076
  if (!state || state.step === "key")
3051
3077
  return [];
@@ -3053,16 +3079,12 @@ function OpenTuiApp(props) {
3053
3079
  const query = state.query.trim().toLowerCase();
3054
3080
  if (!query)
3055
3081
  return items;
3056
- return items.filter((item) => {
3057
- const haystack = [
3058
- item.label,
3059
- item.detail,
3060
- item.value,
3061
- item.category,
3062
- item.footer,
3063
- ].filter(Boolean).join(" ").toLowerCase();
3064
- return haystack.includes(query) || fuzzyMatch(haystack, query);
3065
- });
3082
+ const scored = items
3083
+ .map((item, order) => ({ item, order, score: providerDialogMatchScore(item, query) }))
3084
+ .filter((entry) => entry.score > 0);
3085
+ // Stable sort by score desc, preserving original catalog order within a tier.
3086
+ scored.sort((a, b) => b.score - a.score || a.order - b.order);
3087
+ return scored.map((entry) => entry.item);
3066
3088
  }
3067
3089
  function providerDialogVisibleRows(state = providerDialog) {
3068
3090
  if (!state)
@@ -3103,7 +3125,7 @@ function OpenTuiApp(props) {
3103
3125
  providerDialogRoot.requestRender();
3104
3126
  return;
3105
3127
  }
3106
- const width = Math.max(48, Math.min(60, dimensions().width - 2));
3128
+ const width = Math.max(56, Math.min(76, dimensions().width - 4));
3107
3129
  const height = PROVIDER_DIALOG_ROWS + 7;
3108
3130
  providerDialogRoot.visible = true;
3109
3131
  providerDialogRoot.width = dimensions().width;
@@ -5494,6 +5516,10 @@ function OpenTuiApp(props) {
5494
5516
  paddingRight: 2,
5495
5517
  }, [
5496
5518
  h("box", { flexShrink: 0, flexDirection: "column", alignItems: "center" }, ...logoLines.map((line) => renderHomeLogoLine(line))),
5519
+ h("box", { flexShrink: 0, flexDirection: "column", alignItems: "center", paddingTop: 1 }, h("text", { fg: theme.textMuted, content: `v${getCurrentVersion()}` })),
5520
+ ...(props.options.updateNotice
5521
+ ? [h("box", { flexShrink: 0, flexDirection: "column", alignItems: "center" }, h("text", { fg: theme.accent, content: props.options.updateNotice }))]
5522
+ : []),
5497
5523
  h("box", { height: 1, minHeight: 0, flexShrink: 1 }),
5498
5524
  h("box", {
5499
5525
  ref: (ref) => {
@@ -5996,7 +6022,8 @@ function OpenTuiApp(props) {
5996
6022
  width: "100%",
5997
6023
  value: "",
5998
6024
  placeholder: "",
5999
- fg: theme.text,
6025
+ textColor: theme.text,
6026
+ focusedTextColor: theme.text,
6000
6027
  backgroundColor: theme.backgroundElement,
6001
6028
  focusedBackgroundColor: theme.backgroundElement,
6002
6029
  cursorColor: theme.primary,
@@ -6141,7 +6168,7 @@ function OpenTuiApp(props) {
6141
6168
  },
6142
6169
  visible: false,
6143
6170
  position: "absolute",
6144
- width: 60,
6171
+ width: 76,
6145
6172
  height: PROVIDER_DIALOG_ROWS + 7,
6146
6173
  backgroundColor: theme.backgroundPanel,
6147
6174
  flexDirection: "column",
@@ -6178,7 +6205,8 @@ function OpenTuiApp(props) {
6178
6205
  width: "100%",
6179
6206
  value: "",
6180
6207
  placeholder: "Search",
6181
- fg: theme.textMuted,
6208
+ textColor: theme.text,
6209
+ focusedTextColor: theme.text,
6182
6210
  backgroundColor: theme.backgroundPanel,
6183
6211
  focusedBackgroundColor: theme.backgroundPanel,
6184
6212
  cursorColor: theme.primary,
@@ -6191,15 +6219,11 @@ function OpenTuiApp(props) {
6191
6219
  providerDialog = { ...state, apiKey: value, error: undefined };
6192
6220
  }
6193
6221
  else {
6222
+ const query = value.trim().toLowerCase();
6194
6223
  const items = providerDialogItemsFor(state.step, state.providerId).filter((item) => {
6195
- const query = value.trim().toLowerCase();
6196
6224
  if (!query)
6197
6225
  return true;
6198
- const haystack = [item.label, item.detail, item.value, item.category, item.footer]
6199
- .filter(Boolean)
6200
- .join(" ")
6201
- .toLowerCase();
6202
- return haystack.includes(query) || fuzzyMatch(haystack, query);
6226
+ return providerDialogMatchScore(item, query) > 0;
6203
6227
  });
6204
6228
  providerDialog = {
6205
6229
  ...state,
@@ -7054,7 +7078,7 @@ function renderMarkdownContent(content, syntaxStyle, options) {
7054
7078
  bg: theme.background,
7055
7079
  width: "100%",
7056
7080
  tableOptions: {
7057
- widthMode: "full",
7081
+ widthMode: "content",
7058
7082
  columnFitter: "balanced",
7059
7083
  wrapMode: "word",
7060
7084
  cellPadding: 1,
@@ -7274,7 +7298,13 @@ function syncMarkdownRenderable(markdown, content, streaming) {
7274
7298
  return;
7275
7299
  markdown.content = content;
7276
7300
  markdown.streaming = streaming;
7277
- markdown.clearCache();
7301
+ // While streaming, let OpenTUI's incremental markdown/code-block rendering do
7302
+ // its job — clearing the parse cache every delta forces the (syntax-
7303
+ // highlighted) code blocks to be rebuilt and re-highlighted on every token,
7304
+ // which is the source of the visible flicker on streamed code blocks. Clear
7305
+ // the cache only once streaming ends, to fully reparse the finalized content.
7306
+ if (!streaming)
7307
+ markdown.clearCache();
7278
7308
  }
7279
7309
  function updateAssistantPartEntries(entry, parts, options, streaming) {
7280
7310
  const partsBox = entry.refs.partsBox;
@@ -7613,7 +7643,7 @@ function createMarkdown(ctx, content, syntaxStyle, options) {
7613
7643
  width: "100%",
7614
7644
  flexShrink: 0,
7615
7645
  tableOptions: {
7616
- widthMode: "full",
7646
+ widthMode: "content",
7617
7647
  columnFitter: "balanced",
7618
7648
  wrapMode: "word",
7619
7649
  cellPadding: 1,
@@ -8293,7 +8323,7 @@ function renderTool(tool, syntaxStyle, width = 80) {
8293
8323
  const color = toolColor(tool);
8294
8324
  const diff = extractToolDiff(tool);
8295
8325
  if (diff && !tool.resultCollapsed && !tool.isError && (tool.name === "edit" || tool.name === "apply_patch")) {
8296
- return h("box", { paddingLeft: 3, marginTop: 1, flexDirection: "column", flexShrink: 0 }, h("text", { fg: color }, `${icon} ${displayToolName(tool.name)}${toolHeader(tool) ? ` ${toolHeader(tool)}` : ""}`), h("box", { paddingLeft: 1, marginTop: 1, border: ["left"], borderColor: theme.borderSubtle, flexDirection: "column", flexShrink: 0 }, renderDiffContent(diff, toolPath(tool), syntaxStyle, width)));
8326
+ return h("box", { paddingLeft: 3, marginTop: 1, flexDirection: "column", flexShrink: 0 }, h("text", { fg: color }, `${icon} ${displayToolName(tool.name)}${toolHeader(tool) ? ` ${toolHeader(tool)}` : ""}`), h("box", { paddingLeft: 1, marginTop: 1, border: ["left"], borderColor: theme.borderSubtle, flexDirection: "column", flexShrink: 0, backgroundColor: theme.diffContextBg }, renderDiffContent(diff, toolPath(tool), syntaxStyle, width)));
8297
8327
  }
8298
8328
  if (!tool.resultCollapsed && isWritePreviewTool(tool)) {
8299
8329
  const hasContent = typeof tool.args.content === "string";
@@ -8401,10 +8431,18 @@ function pickerTitle(kind, providerId) {
8401
8431
  }
8402
8432
  }
8403
8433
  function getModelPickerReasoningLevels(providerId, modelId) {
8404
- if (providerId !== "deepseek" || (modelId !== "deepseek-v4-flash" && modelId !== "deepseek-v4-pro")) {
8434
+ // Only expand into one picker row per effort for models that genuinely have a
8435
+ // reasoning-effort spectrum: OpenAI's reasoning models (codex gpt-5.x:
8436
+ // off/minimal/low/medium/high/xhigh) and DeepSeek's v4 models. Other providers
8437
+ // (e.g. GLM, Moonshot/Kimi) only have a thinking on/off toggle, not an effort
8438
+ // control, so they stay as a single row.
8439
+ const isOpenAIReasoning = providerId === "openai" || providerId === "openai-codex";
8440
+ const isDeepseekReasoning = providerId === "deepseek" && (modelId === "deepseek-v4-flash" || modelId === "deepseek-v4-pro");
8441
+ if (!isOpenAIReasoning && !isDeepseekReasoning)
8405
8442
  return [];
8406
- }
8407
- return getAvailableThinkingLevels(providerId, modelId);
8443
+ const levels = getAvailableThinkingLevels(providerId, modelId);
8444
+ // gpt-4o and friends report only ["off"] — keep those as a single row too.
8445
+ return levels.length > 1 ? levels : [];
8408
8446
  }
8409
8447
  function displayModelWithThinking(model, thinkingLevel) {
8410
8448
  if (!model)
@@ -8412,7 +8450,10 @@ function displayModelWithThinking(model, thinkingLevel) {
8412
8450
  const { providerId, modelId } = decodeModel(model);
8413
8451
  if (!providerId)
8414
8452
  return displayModel(model);
8415
- const levels = getAvailableThinkingLevels(providerId, modelId);
8453
+ // Use the same scoping as the picker: only models with a real reasoning-effort
8454
+ // spectrum (OpenAI codex gpt-5.x, deepseek v4) get the "(level)" suffix. The
8455
+ // on/off thinking toggle on GLM / Moonshot(Kimi) is not an effort control.
8456
+ const levels = getModelPickerReasoningLevels(providerId, modelId);
8416
8457
  if (levels.length > 1 && thinkingLevel !== "off") {
8417
8458
  return `${displayModel(model)} (${thinkingLevel})`;
8418
8459
  }
@@ -9066,11 +9107,18 @@ function toolPath(tool) {
9066
9107
  ?? (Array.isArray(tool.metadata?.paths) ? tool.metadata.paths[0] : undefined);
9067
9108
  return typeof value === "string" ? value : undefined;
9068
9109
  }
9110
+ // Strip only leading/trailing newlines — NOT a full .trim(). A blank context
9111
+ // line in a unified diff is a single space (" "); plain .trim() would delete a
9112
+ // trailing blank context line, leaving the hunk body shorter than its @@ header
9113
+ // count and breaking the diff parser ("Added line count did not match").
9114
+ function stripDiffEdgeNewlines(diff) {
9115
+ return diff.replace(/^\n+/, "").replace(/\n+$/, "");
9116
+ }
9069
9117
  function extractToolDiff(tool) {
9070
9118
  if (tool.resultCollapsed)
9071
9119
  return undefined;
9072
9120
  if (typeof tool.metadata?.diff === "string" && tool.metadata.diff.trim().length > 0) {
9073
- return tool.metadata.diff.trim();
9121
+ return stripDiffEdgeNewlines(tool.metadata.diff);
9074
9122
  }
9075
9123
  if (!tool.result)
9076
9124
  return undefined;
@@ -9086,10 +9134,14 @@ function extractToolDiff(tool) {
9086
9134
  const rawDiff = tool.result.slice(index + marker.length);
9087
9135
  const diagnosticsIndex = rawDiff.search(/\n\nLSP diagnostics in /);
9088
9136
  const diff = diagnosticsIndex === -1 ? rawDiff : rawDiff.slice(0, diagnosticsIndex);
9089
- return diff.trim().length > 0 ? diff : undefined;
9090
- }
9091
- function diffViewMode(width = 80) {
9092
- return width > 120 ? "split" : "unified";
9137
+ return diff.trim().length > 0 ? stripDiffEdgeNewlines(diff) : undefined;
9138
+ }
9139
+ function diffViewMode(_width = 80) {
9140
+ // Always unified: split view pads the shorter side with empty filler rows that
9141
+ // OpenTUI's DiffRenderable leaves uncolored, which shows up as bright white
9142
+ // blocks in light mode. Unified view has no filler rows — every line is
9143
+ // add/remove/context and gets a background — so the edit area stays uniform.
9144
+ return "unified";
9093
9145
  }
9094
9146
  function filetype(filePath) {
9095
9147
  if (!filePath)
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Self-update: `bubble update` / `bubble upgrade`, plus a cached startup
3
+ * "update available" check.
4
+ *
5
+ * Bubble ships as the npm package `@bubblebrain-ai/bubble`. Updating just means
6
+ * re-installing it globally with whatever package manager put it there, so we
7
+ * detect the install method and run the matching command.
8
+ */
9
+ export declare const PACKAGE_NAME = "@bubblebrain-ai/bubble";
10
+ export declare function getCurrentVersion(): string;
11
+ export declare function fetchLatestVersion(timeoutMs?: number): Promise<string | null>;
12
+ /**
13
+ * Compare two semver-ish strings. Returns 1 if a > b, -1 if a < b, 0 if equal.
14
+ * Handles `x.y.z` and a single pre-release tag (`x.y.z-beta.1`); a release
15
+ * always outranks a pre-release of the same numeric version.
16
+ */
17
+ export declare function compareVersions(a: string, b: string): number;
18
+ export type PackageManager = "npm" | "bun" | "pnpm" | "yarn" | "homebrew" | "unknown";
19
+ export interface InstallInfo {
20
+ manager: PackageManager;
21
+ isGlobal: boolean;
22
+ isLocalCheckout: boolean;
23
+ installPath: string;
24
+ }
25
+ /**
26
+ * Figure out how this copy of Bubble was installed by inspecting the real path
27
+ * of the package directory (two levels up from this module: dist/update -> pkg).
28
+ */
29
+ export declare function detectInstall(): InstallInfo;
30
+ export declare function upgradeCommandFor(manager: PackageManager): {
31
+ cmd: string;
32
+ args: string[];
33
+ } | null;
34
+ /**
35
+ * `bubble update` entry point. Returns a process exit code.
36
+ */
37
+ export declare function runUpdateCommand(opts?: {
38
+ checkOnly?: boolean;
39
+ }): Promise<number>;
40
+ /**
41
+ * Returns a one-line "update available" notice if the cached latest version is
42
+ * newer than the running one. Reads only a local cache file (fast, no network
43
+ * on the hot path); a stale cache triggers a fire-and-forget refresh so the
44
+ * next launch is accurate. Never throws.
45
+ */
46
+ export declare function getStartupUpdateNotice(): Promise<string | null>;
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Self-update: `bubble update` / `bubble upgrade`, plus a cached startup
3
+ * "update available" check.
4
+ *
5
+ * Bubble ships as the npm package `@bubblebrain-ai/bubble`. Updating just means
6
+ * re-installing it globally with whatever package manager put it there, so we
7
+ * detect the install method and run the matching command.
8
+ */
9
+ import { spawn } from "node:child_process";
10
+ import { createRequire } from "node:module";
11
+ import { fileURLToPath } from "node:url";
12
+ import { realpathSync } from "node:fs";
13
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
14
+ import { dirname, join } from "node:path";
15
+ import { getBubbleHome } from "../bubble-home.js";
16
+ const require = createRequire(import.meta.url);
17
+ export const PACKAGE_NAME = "@bubblebrain-ai/bubble";
18
+ const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
19
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // once a day
20
+ export function getCurrentVersion() {
21
+ try {
22
+ const pkg = require("../../package.json");
23
+ return pkg.version ?? "0.0.0";
24
+ }
25
+ catch {
26
+ return "0.0.0";
27
+ }
28
+ }
29
+ export async function fetchLatestVersion(timeoutMs = 5000) {
30
+ try {
31
+ const controller = new AbortController();
32
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
33
+ try {
34
+ const res = await fetch(REGISTRY_URL, {
35
+ signal: controller.signal,
36
+ headers: { accept: "application/json" },
37
+ });
38
+ if (!res.ok)
39
+ return null;
40
+ const data = (await res.json());
41
+ return data.version ?? null;
42
+ }
43
+ finally {
44
+ clearTimeout(timer);
45
+ }
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ /**
52
+ * Compare two semver-ish strings. Returns 1 if a > b, -1 if a < b, 0 if equal.
53
+ * Handles `x.y.z` and a single pre-release tag (`x.y.z-beta.1`); a release
54
+ * always outranks a pre-release of the same numeric version.
55
+ */
56
+ export function compareVersions(a, b) {
57
+ const parse = (v) => {
58
+ const cleaned = v.trim().replace(/^v/, "");
59
+ const [core, pre] = cleaned.split("-", 2);
60
+ const nums = core.split(".").map((n) => parseInt(n, 10) || 0);
61
+ while (nums.length < 3)
62
+ nums.push(0);
63
+ return { nums, pre: pre ?? "" };
64
+ };
65
+ const pa = parse(a);
66
+ const pb = parse(b);
67
+ for (let i = 0; i < 3; i++) {
68
+ if (pa.nums[i] > pb.nums[i])
69
+ return 1;
70
+ if (pa.nums[i] < pb.nums[i])
71
+ return -1;
72
+ }
73
+ // Equal numeric core: no pre-release beats a pre-release.
74
+ if (pa.pre === pb.pre)
75
+ return 0;
76
+ if (!pa.pre)
77
+ return 1;
78
+ if (!pb.pre)
79
+ return -1;
80
+ return pa.pre > pb.pre ? 1 : -1;
81
+ }
82
+ /**
83
+ * Figure out how this copy of Bubble was installed by inspecting the real path
84
+ * of the package directory (two levels up from this module: dist/update -> pkg).
85
+ */
86
+ export function detectInstall() {
87
+ const rawRoot = fileURLToPath(new URL("../../", import.meta.url));
88
+ let installPath = rawRoot;
89
+ try {
90
+ installPath = realpathSync(rawRoot);
91
+ }
92
+ catch {
93
+ // keep rawRoot
94
+ }
95
+ const lower = installPath.replace(/\\/g, "/").toLowerCase();
96
+ const isUnderNodeModules = lower.includes("/node_modules/");
97
+ // A dev/source checkout has src/ alongside dist/ and isn't under node_modules.
98
+ const isLocalCheckout = !isUnderNodeModules;
99
+ let manager = "unknown";
100
+ if (lower.includes("/cellar/") || lower.includes("/homebrew/")) {
101
+ manager = "homebrew";
102
+ }
103
+ else if (lower.includes("/.bun/") || lower.includes("/bun/install/")) {
104
+ manager = "bun";
105
+ }
106
+ else if (lower.includes("/pnpm/") || lower.includes("/.pnpm/")) {
107
+ manager = "pnpm";
108
+ }
109
+ else if (lower.includes("/.yarn/") || lower.includes("/yarn/global")) {
110
+ manager = "yarn";
111
+ }
112
+ else if (isUnderNodeModules) {
113
+ manager = "npm";
114
+ }
115
+ return { manager, isGlobal: isUnderNodeModules, isLocalCheckout, installPath };
116
+ }
117
+ export function upgradeCommandFor(manager) {
118
+ const spec = `${PACKAGE_NAME}@latest`;
119
+ switch (manager) {
120
+ case "npm":
121
+ case "unknown": // default to npm
122
+ return { cmd: "npm", args: ["install", "-g", spec] };
123
+ case "bun":
124
+ return { cmd: "bun", args: ["add", "-g", spec] };
125
+ case "pnpm":
126
+ return { cmd: "pnpm", args: ["add", "-g", spec] };
127
+ case "yarn":
128
+ return { cmd: "yarn", args: ["global", "add", spec] };
129
+ case "homebrew":
130
+ return null; // handled separately (brew upgrade)
131
+ }
132
+ }
133
+ function spawnInherit(cmd, args) {
134
+ return new Promise((resolve) => {
135
+ const child = spawn(cmd, args, { stdio: "inherit", env: process.env });
136
+ child.on("error", () => resolve(127));
137
+ child.on("exit", (code) => resolve(code ?? 1));
138
+ });
139
+ }
140
+ /**
141
+ * `bubble update` entry point. Returns a process exit code.
142
+ */
143
+ export async function runUpdateCommand(opts = {}) {
144
+ const current = getCurrentVersion();
145
+ process.stdout.write(`Bubble v${current}\n`);
146
+ process.stdout.write("Checking npm for the latest version…\n");
147
+ const latest = await fetchLatestVersion();
148
+ if (!latest) {
149
+ process.stderr.write("Could not reach the npm registry. Check your connection and try again.\n");
150
+ return 1;
151
+ }
152
+ if (compareVersions(latest, current) <= 0) {
153
+ process.stdout.write(`You're already on the latest version (v${current}).\n`);
154
+ return 0;
155
+ }
156
+ process.stdout.write(`Update available: v${current} → v${latest}\n`);
157
+ if (opts.checkOnly) {
158
+ process.stdout.write("Run `bubble update` to install it.\n");
159
+ return 0;
160
+ }
161
+ const info = detectInstall();
162
+ if (info.isLocalCheckout) {
163
+ process.stderr.write("This looks like a local/development checkout, not a global install.\n" +
164
+ "Update it with:\n git pull && npm run build\n");
165
+ return 1;
166
+ }
167
+ if (info.manager === "homebrew") {
168
+ process.stderr.write("Bubble was installed via Homebrew. Update it with:\n brew upgrade bubble\n");
169
+ return 1;
170
+ }
171
+ const command = upgradeCommandFor(info.manager);
172
+ if (!command) {
173
+ process.stderr.write(`Couldn't determine how to update automatically. Run:\n npm install -g ${PACKAGE_NAME}@latest\n`);
174
+ return 1;
175
+ }
176
+ process.stdout.write(`\nUpdating via ${command.cmd}…\n\n`);
177
+ const code = await spawnInherit(command.cmd, command.args);
178
+ if (code === 0) {
179
+ process.stdout.write(`\n✓ Updated to v${latest}. Restart bubble to use the new version.\n`);
180
+ return 0;
181
+ }
182
+ process.stderr.write(`\nUpdate failed (exit ${code}). Try running it manually:\n ${command.cmd} ${command.args.join(" ")}\n` +
183
+ "If this is a permissions error, you may need elevated privileges or to fix your global install prefix.\n");
184
+ return code;
185
+ }
186
+ function cacheFile() {
187
+ return join(getBubbleHome(), "update-check.json");
188
+ }
189
+ async function readCache() {
190
+ try {
191
+ const raw = await readFile(cacheFile(), "utf8");
192
+ const data = JSON.parse(raw);
193
+ if (typeof data.lastCheck === "number" && typeof data.latest === "string") {
194
+ return { lastCheck: data.lastCheck, latest: data.latest };
195
+ }
196
+ return null;
197
+ }
198
+ catch {
199
+ return null;
200
+ }
201
+ }
202
+ async function writeCache(cache) {
203
+ try {
204
+ const file = cacheFile();
205
+ await mkdir(dirname(file), { recursive: true });
206
+ await writeFile(file, JSON.stringify(cache), "utf8");
207
+ }
208
+ catch {
209
+ // best-effort; never fail startup over a cache write
210
+ }
211
+ }
212
+ async function refreshCacheInBackground(now) {
213
+ const latest = await fetchLatestVersion(4000);
214
+ if (latest) {
215
+ await writeCache({ lastCheck: now, latest });
216
+ }
217
+ }
218
+ /**
219
+ * Returns a one-line "update available" notice if the cached latest version is
220
+ * newer than the running one. Reads only a local cache file (fast, no network
221
+ * on the hot path); a stale cache triggers a fire-and-forget refresh so the
222
+ * next launch is accurate. Never throws.
223
+ */
224
+ export async function getStartupUpdateNotice() {
225
+ try {
226
+ const current = getCurrentVersion();
227
+ const now = Date.now();
228
+ const cache = await readCache();
229
+ if (!cache || now - cache.lastCheck > CHECK_INTERVAL_MS) {
230
+ void refreshCacheInBackground(now);
231
+ }
232
+ if (cache && compareVersions(cache.latest, current) > 0) {
233
+ return `Update available: v${current} → v${cache.latest} · run \`bubble update\``;
234
+ }
235
+ return null;
236
+ }
237
+ catch {
238
+ return null;
239
+ }
240
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bubblebrain-ai/bubble",
3
- "version": "0.0.14",
3
+ "version": "0.0.15",
4
4
  "description": "A terminal coding agent",
5
5
  "type": "module",
6
6
  "engines": {