bdy 1.23.11-stage → 1.23.13-beta

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "bdy",
3
3
  "preferGlobal": false,
4
- "version": "1.23.11-stage",
4
+ "version": "1.23.13-beta",
5
5
  "type": "commonjs",
6
6
  "license": "MIT",
7
7
  "homepage": "https://buddy.works/docs/cli",
@@ -51,7 +51,7 @@
51
51
  "@fastify/cors": "11.2.0",
52
52
  "@inquirer/prompts": "8.5.2",
53
53
  "@modelcontextprotocol/sdk": "1.30.0",
54
- "@puppeteer/browsers": "2.13.0",
54
+ "@puppeteer/browsers": "3.2.0",
55
55
  "@scalar/json-magic": "0.12.16",
56
56
  "@scalar/openapi-parser": "0.28.7",
57
57
  "@scalar/openapi-types": "0.9.1",
@@ -79,7 +79,7 @@
79
79
  "picomatch": "4.0.4",
80
80
  "pino": "10.3.1",
81
81
  "punycode": "2.3.1",
82
- "puppeteer-core": "23.11.1",
82
+ "puppeteer-core": "25.6.0",
83
83
  "range-parser": "1.2.1",
84
84
  "socket.io-client": "4.8.3",
85
85
  "ssh2": "1.17.0",
@@ -161,6 +161,10 @@ class AgentManagerClass {
161
161
  }
162
162
  const current = (0, utils_1.getVersion)();
163
163
  const latest = await (0, utils_1.getLatestVersion)();
164
+ // Equality only, deliberately not "is latest newer": this runs on an explicit remote
165
+ // update action, whose meaning is "match what the channel publishes". Refusing an
166
+ // older latest would take away the only rollback the fleet has - moving the channel
167
+ // pointer back after a bad release.
164
168
  if (!latest || latest === '0.0.0' || latest === current) {
165
169
  logger_1.default.info((0, texts_1.LOG_AGENT_UPDATE_NOT_NEEDED)(current));
166
170
  // Nothing to do is not a failure, and showing it as one would make an update of
@@ -174,12 +178,15 @@ class AgentManagerClass {
174
178
  // - this is the value that stays behind if the restart never lands.
175
179
  this.agent?.reportUpdateStatus(tunnel_1.TUNNEL_AGENT_UPDATE_STATUS.STARTED);
176
180
  this.agent?.flushUpdateStatus();
177
- const target = latest.match(/^\d+\.\d+\.\d+/);
181
+ // getLatestVersion already refuses a version it cannot parse, so this is belt and
182
+ // braces - kept because the download url is built from the triple, and shipping a
183
+ // malformed one would fail much further away from the cause.
184
+ const target = (0, utils_1.parseVersion)(latest);
178
185
  if (!target) {
179
186
  logger_1.default.error(texts_1.LOG_AGENT_UPDATE_FAILED);
180
187
  return report(tunnel_1.TUNNEL_AGENT_UPDATE_STATUS.FAILED);
181
188
  }
182
- const swapped = await this.system.swapBinaryToVersion(target[0]);
189
+ const swapped = await this.system.swapBinaryToVersion(target.version);
183
190
  if (!swapped) {
184
191
  logger_1.default.error((0, texts_1.LOG_AGENT_UPDATE_BINARY_INVALID)(latest));
185
192
  return report(tunnel_1.TUNNEL_AGENT_UPDATE_STATUS.FAILED);
@@ -46,14 +46,15 @@ const commandPre = async (_, command) => {
46
46
  if (!output_1.default.isTTY() || command.hideVersionUpdate || ['json', 'jsonl'].includes(command.opts()?.format)) {
47
47
  return;
48
48
  }
49
- const newCli = !!command.latestVersion && command.currentVersion !== command.latestVersion;
50
- const newAgent = !!command.agentStatus && !!command.latestVersion &&
51
- command.agentStatus.version !== command.latestVersion;
52
- if ((0, utils_1.isDocker)() && newCli && command.latestVersion) {
53
- output_1.default.newCliDockerVersion(command.latestVersion);
54
- }
55
- else if (newCli && command.latestVersion) {
56
- output_1.default.newCliVersion(command.latestVersion);
49
+ // No truthiness guards on the versions: an empty or unparsable one is never newer
50
+ const latest = command.latestVersion || '';
51
+ const newCli = (0, utils_1.isVersionNewer)(latest, command.currentVersion || '');
52
+ const newAgent = !!command.agentStatus && (0, utils_1.isVersionNewer)(latest, command.agentStatus.version);
53
+ if ((0, utils_1.isDocker)() && newCli) {
54
+ output_1.default.newCliDockerVersion(latest);
55
+ }
56
+ else if (newCli) {
57
+ output_1.default.newCliVersion(latest);
57
58
  }
58
59
  else if (newAgent) {
59
60
  output_1.default.newAgentVersion();
@@ -78,7 +78,7 @@ commandVersion.action(async () => {
78
78
  const currVersion = commandVersion.currentVersion || '';
79
79
  const lastVersion = commandVersion.latestVersion || '';
80
80
  const env = commandVersion.env || '';
81
- if (currVersion !== lastVersion) {
81
+ if ((0, utils_1.isVersionNewer)(lastVersion, currVersion)) {
82
82
  const platform = (0, utils_1.getPlatform)();
83
83
  const isMac = platform === 'darwin';
84
84
  const isWin = platform === 'win32';
@@ -64,7 +64,13 @@ class Logger {
64
64
  this.logPath = (0, path_1.resolve)(this.rootPath, 'cli.log');
65
65
  this.log1Path = (0, path_1.resolve)(this.rootPath, 'cli.1.log');
66
66
  try {
67
- this.logStream = fs_1.default.openSync(this.logPath, 'w');
67
+ // 'a', not 'w': every start used to truncate the log, so an agent that died and was
68
+ // restarted by its service manager came back having erased the only record of why -
69
+ // and a crash loop leaves nothing at all. Append also survives an external truncation
70
+ // (rotation, someone clearing the file) without the zero-padding a tracked write offset
71
+ // produces, because O_APPEND puts every write at the current end of the file.
72
+ // checkLogSize() below still bounds it at 5 MB.
73
+ this.logStream = fs_1.default.openSync(this.logPath, 'a');
68
74
  }
69
75
  catch {
70
76
  // A log we cannot open is not a reason to fail the command - a read-only home, a full
@@ -82,11 +82,19 @@ class ServerSftp {
82
82
  s._protocol.exitStatus(s.outgoing.id, 0);
83
83
  s._protocol.channelEOF(s.outgoing.id);
84
84
  }
85
+ // origDestroy() inside the try, not after it: it sends CHANNEL_CLOSE, and a peer that
86
+ // simply vanished leaves the channel 'open' while ssh2 has already torn the connection
87
+ // down (Protocol.cleanup on socket close). With zlib@openssh.com negotiated - which the
88
+ // java client on the other end of a deployment always picks - that write reaches a
89
+ // destroyed zlib and throws 'Invalid Zlib instance' out of ZlibPacketWriter.finalize.
90
+ // It escaped from inside a 'close' handler, so it took down the whole tunnel and, past
91
+ // the agent's uncaughtException hook, the process with it. There is nothing to salvage
92
+ // by letting it out: the channel this belongs to is already gone.
93
+ return origDestroy();
85
94
  }
86
95
  catch {
87
- // do nothing
96
+ return undefined;
88
97
  }
89
- return origDestroy();
90
98
  };
91
99
  this.sftp.on('OPEN', (reqId, fileName, flags, attrs) => this.open(reqId, fileName, flags, attrs));
92
100
  this.sftp.on('READ', (reqId, handle, offset, length) => this.read(reqId, handle, offset, length));
@@ -604,13 +612,24 @@ class ServerSftp {
604
612
  }
605
613
  destroy() {
606
614
  if (this.sftp) {
607
- this.sftp.removeAllListeners();
608
- this.sftp.end();
615
+ try {
616
+ this.sftp.removeAllListeners();
617
+ // end() writes: EOF and CHANNEL_CLOSE go out through the connection this channel sits
618
+ // on, which on the teardown path is usually already gone. Whatever that write throws,
619
+ // the handles below still have to be released - and this runs from a 'close' handler,
620
+ // where an escaping error reaches uncaughtException.
621
+ this.sftp.end();
622
+ }
623
+ catch {
624
+ // do nothing
625
+ }
609
626
  }
610
627
  if (this.openHandlers) {
611
628
  this.openHandlers.forEach((fd, id) => {
612
629
  try {
613
- fd.close();
630
+ // FileHandle.close() and Dir.close() both return a promise. try/catch cannot see a
631
+ // rejected one, and an unhandled rejection is as fatal to the agent as a throw.
632
+ Promise.resolve(fd.close()).catch(() => { });
614
633
  }
615
634
  catch {
616
635
  // do nothing
@@ -16,6 +16,8 @@ class SshClient extends events_1.default {
16
16
  password;
17
17
  keepalive;
18
18
  client;
19
+ lastError;
20
+ openedAt;
19
21
  constructor(ip, port, username, password) {
20
22
  super();
21
23
  this.ip = ip;
@@ -23,8 +25,25 @@ class SshClient extends events_1.default {
23
25
  this.username = username;
24
26
  this.password = password;
25
27
  this.keepalive = false;
28
+ this.lastError = null;
29
+ this.openedAt = 0;
26
30
  logger_1.default.debug(`New ssh client: ${username}:${password}@${ip}:${port}`);
27
31
  }
32
+ // Why the tunnel connection went away, for the one line openKeepAlive logs on its way to
33
+ // reconnecting. ssh2 emits 'error' and destroys the socket itself on everything fatal - a
34
+ // keepalive timeout, a protocol error, a socket reset - so without keeping that error the
35
+ // 'close' that follows is indistinguishable from the peer hanging up on us, and a tunnel that
36
+ // drops mid-transfer looks like nothing happened at all.
37
+ closeReason() {
38
+ const err = this.lastError;
39
+ const reason = err
40
+ ? `${err.level ? `${err.level}: ` : ''}${err.message}`
41
+ : 'closed by peer or locally, no error reported';
42
+ if (!this.openedAt)
43
+ return `SSH client closed before it was ready (${reason})`;
44
+ const age = Math.round((Date.now() - this.openedAt) / 1000);
45
+ return `SSH client closed after ${age}s (${reason})`;
46
+ }
28
47
  async open() {
29
48
  return new Promise((resolve, reject) => {
30
49
  this.close();
@@ -33,8 +52,14 @@ class SshClient extends events_1.default {
33
52
  this.client.on('ready', () => {
34
53
  if (this.client) {
35
54
  this.client.removeAllListeners();
36
- this.client.on('error', () => { });
55
+ // Kept rather than swallowed: this is the only place an error on a live tunnel
56
+ // connection is ever seen. It must still not throw, so it stays a handler that only
57
+ // records - closeReason() reads it once the 'close' lands.
58
+ this.client.on('error', (err) => {
59
+ this.lastError = err;
60
+ });
37
61
  }
62
+ this.openedAt = Date.now();
38
63
  resolve();
39
64
  });
40
65
  this.client.once('error', (err) => {
@@ -223,20 +248,26 @@ class SshClient extends events_1.default {
223
248
  openKeepAlive() {
224
249
  logger_1.default.debug(`SSH client open`);
225
250
  this.keepalive = true;
251
+ this.lastError = null;
252
+ this.openedAt = 0;
226
253
  this.open()
227
254
  .then(() => {
228
255
  logger_1.default.debug(`SSH client opened`);
229
256
  this.emit(tunnel_1.TUNNEL_SSH_EVENT.CONNECTED);
230
257
  if (this.client) {
231
258
  this.client.on('close', () => {
232
- logger_1.default.debug(`SSH client closed`);
259
+ // info, not debug: a tunnel that drops takes every connection on it with it, and the
260
+ // transfers that die with it are reported far from here - on the other side, as a
261
+ // failed handshake. One line per drop, and the log says so without debug turned on.
262
+ logger_1.default.info(this.closeReason());
233
263
  if (this.keepalive)
234
264
  this.openKeepAlive();
235
265
  });
236
266
  }
237
267
  })
238
- .catch(() => {
239
- logger_1.default.debug(`SSH client closed`);
268
+ .catch((err) => {
269
+ this.lastError = err;
270
+ logger_1.default.info(this.closeReason());
240
271
  this.emit(tunnel_1.TUNNEL_SSH_EVENT.DISCONNECTED);
241
272
  setTimeout(() => {
242
273
  if (this.keepalive)
@@ -37,7 +37,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.isStringRegExp = exports.getWorkingDir = exports.getRootDir = exports.TARGET_ONLY_PORT_REGEX = exports.TARGET_HTTP_REGEX = exports.TARGET_TCP_TLS_REGEX = exports.ApiErrorTunnelsDisabled = exports.ApiErrorWorkspaceFlagged = exports.ApiErrorTunnelLimitReached = exports.ApiErrorAgentLimitReached = exports.ApiErrorDomainRestricted = exports.ApiErrorTargetInvalid = exports.ApiErrorWrongToken = exports.ApiErrorFailedToConnect = exports.ApiErrorAgentNotFound = exports.ARTIFACT_AUTH_TYPE = exports.ARTIFACT_TYPE = exports.SANDBOX_SNAPSHOT_STATUS = exports.SANDBOX_APP_STATUS = exports.SANDBOX_SETUP_STATUS = exports.SANDBOX_EXEC_STATUS = exports.SANDBOX_EXEC_RUNTIME = exports.SANDBOX_STATUS = exports.REST_API_ENDPOINT = exports.REST_API_REGION = exports.DEFAULT_SANDBOX_CP_USER = exports.ARTIFACT_DEFAULT_VERSION = exports.SUGGESTED_BROWSER_VERSION = exports.AGENT_UPDATE_STATUS_TTL_MS = exports.AGENT_UPDATE_PROBATION_MS = exports.AGENT_UPDATE_MAX_ATTEMPTS = exports.AGENT_FETCH_RETRY_MAX = exports.AGENT_FETCH_RETRY_MIN = exports.SSH_HANDSHAKE_TIMEOUT = exports.DEFAULT_TIMEOUT = exports.getSshIdent = exports.TUNNEL_SSH_IDENT_SHARED_H2 = exports.TUNNEL_CLIENT_IP_HEADER = exports.TUNNEL_HTTP2_SHED_LOG_INTERVAL = exports.TUNNEL_HTTP2_MAX_SESSIONS = exports.TUNNEL_HTTP2_SESSION_IDLE_TIMEOUT = exports.TUNNEL_HTTP_RETRY_AFTER = exports.TUNNEL_HTTP_MAX_INFLIGHT = exports.TUNNEL_HTTP_CB_MIN_REQUESTS = exports.TUNNEL_HTTP_CB_WINDOW = exports.TUNNEL_MAX_REQUEST_SIZE_TO_SYNC = exports.TUNNEL_HTTP_LOG_MAX_REQUESTS = exports.TUNNEL_HTTP_LOG_MAX_BODY = exports.TUNNEL_HTTP_RATE_WINDOW = exports.TUNNEL_HTTP_RATE_LIMIT = void 0;
40
- exports.getGitName = exports.getGitEmail = exports.MCP_TOOL_CATEGORIES = exports.getBasicCommandTls = exports.getBasicCommandHttp = exports.getBasicCommandSandboxEndpoint = exports.getBasicCommandTcp = exports.getCurrentUser = exports.getServiceUser = exports.getRealTargetHost = exports.isWindows = exports.isLinux = exports.isOsx = exports.isDocker = exports.sleep = exports.getLatestVersion = exports.getVersionEnv = exports.getCurrentVersionWithoutEnv = exports.getVersionWithoutEnv = exports.getVersion = exports.isInstalledByChoco = exports.isInstalledByApt = exports.isInstalledByBrew = exports.isInstalledByNpm = exports.isNpmGlobalWritable = exports.isPathWritable = exports.tryGetEmail = exports.execLocally = exports.getHomeDirectory = exports.HOME_DIR_NAME = exports.chownToHomeOwner = exports.isDomainAvailable = exports.newCommand = exports.formatBytes = exports.formatHelp = exports.getPlatform = exports.getHostname = void 0;
40
+ exports.getGitName = exports.getGitEmail = exports.MCP_TOOL_CATEGORIES = exports.getBasicCommandTls = exports.getBasicCommandHttp = exports.getBasicCommandSandboxEndpoint = exports.getBasicCommandTcp = exports.getCurrentUser = exports.getServiceUser = exports.getRealTargetHost = exports.isWindows = exports.isLinux = exports.isOsx = exports.isDocker = exports.sleep = exports.getLatestVersion = exports.getVersionEnv = exports.getCurrentVersionWithoutEnv = exports.isVersionNewer = exports.getVersionWithoutEnv = exports.parseVersion = exports.getVersion = exports.isInstalledByChoco = exports.isInstalledByApt = exports.isInstalledByBrew = exports.isInstalledByNpm = exports.isNpmGlobalWritable = exports.isPathWritable = exports.tryGetEmail = exports.execLocally = exports.getHomeDirectory = exports.HOME_DIR_NAME = exports.chownToHomeOwner = exports.isDomainAvailable = exports.newCommand = exports.formatBytes = exports.formatHelp = exports.getPlatform = exports.getHostname = void 0;
41
41
  exports.apiErrorCodeToClass = apiErrorCodeToClass;
42
42
  exports.isFile = isFile;
43
43
  exports.getAppWorkspaceUrl = getAppWorkspaceUrl;
@@ -132,7 +132,7 @@ exports.AGENT_UPDATE_PROBATION_MS = 30 * 60 * 1000;
132
132
  // 15s; anything past it waits for the one at 60s, so a longer ttl here shows the outcome for
133
133
  // four times as long as it says.
134
134
  exports.AGENT_UPDATE_STATUS_TTL_MS = 10 * 1000;
135
- exports.SUGGESTED_BROWSER_VERSION = '130.0.6723.69';
135
+ exports.SUGGESTED_BROWSER_VERSION = '151.0.7922.77';
136
136
  exports.ARTIFACT_DEFAULT_VERSION = 'latest';
137
137
  exports.DEFAULT_SANDBOX_CP_USER = 'buddy';
138
138
  var REST_API_REGION;
@@ -585,19 +585,44 @@ const getVersion = () => {
585
585
  return cachedVersion;
586
586
  };
587
587
  exports.getVersion = getVersion;
588
+ // The one place that knows what a version string looks like: `x.y.z` optionally followed by
589
+ // a channel suffix. AgentSystem.verifyBinary keeps a regex of its own on purpose - it fishes
590
+ // a version out of selftest output rather than parsing one, so it must not be anchored.
591
+ const parseVersion = (version) => {
592
+ const m = (version || '').match(/^(\d+)\.(\d+)\.(\d+)/);
593
+ if (!m)
594
+ return null;
595
+ return {
596
+ version: m[0],
597
+ major: Number(m[1]),
598
+ minor: Number(m[2]),
599
+ patch: Number(m[3]),
600
+ };
601
+ };
602
+ exports.parseVersion = parseVersion;
588
603
  const getVersionWithoutEnv = (version) => {
589
- const m = version.match(/^\d+\.\d+\.\d+/);
590
- if (m)
591
- return m[0];
592
- return '0.0.0';
604
+ return (0, exports.parseVersion)(version)?.version || '0.0.0';
593
605
  };
594
606
  exports.getVersionWithoutEnv = getVersionWithoutEnv;
607
+ // An update is only an update when the other side is actually ahead. Comparing the strings
608
+ // for inequality also fired on a build that is newer than what the channel publishes - a
609
+ // local dev build, or a machine that got the release before es.buddy.works did - and told
610
+ // the user to "update" to an older version. Both sides carry the same env suffix (latest is
611
+ // fetched per env), so the numeric triple decides.
612
+ const isVersionNewer = (version, than) => {
613
+ const a = (0, exports.parseVersion)(version);
614
+ const b = (0, exports.parseVersion)(than);
615
+ if (!a || !b)
616
+ return false;
617
+ if (a.major !== b.major)
618
+ return a.major > b.major;
619
+ if (a.minor !== b.minor)
620
+ return a.minor > b.minor;
621
+ return a.patch > b.patch;
622
+ };
623
+ exports.isVersionNewer = isVersionNewer;
595
624
  const getCurrentVersionWithoutEnv = () => {
596
- const v = (0, exports.getVersion)();
597
- const m = v.match(/^\d+\.\d+\.\d+/);
598
- if (m)
599
- return m[0];
600
- return '0.0.0';
625
+ return (0, exports.getVersionWithoutEnv)((0, exports.getVersion)());
601
626
  };
602
627
  exports.getCurrentVersionWithoutEnv = getCurrentVersionWithoutEnv;
603
628
  const getVersionEnv = () => {
@@ -626,10 +651,20 @@ const getLatestVersion = async () => {
626
651
  });
627
652
  if (response.status === 200) {
628
653
  const data = await response.text();
629
- const v = data.trim();
654
+ // A 200 whose body is not a version - a padded value, an error page from something in
655
+ // front of the bucket - is nothing a caller can act on, so it fails here rather than
656
+ // downstream, where every comparison would read it as "no update". The parsed triple
657
+ // is what gets returned, not the body: the regex is unanchored at the end, so a body
658
+ // that merely starts with a version would otherwise carry its tail into the banners,
659
+ // the update log and the equality test in AgentManager.remoteUpdate.
660
+ // Only the agent's remote update surfaces this - `commandPre` swallows it, so the CLI
661
+ // stays quiet about a channel it could not read.
662
+ const parsed = (0, exports.parseVersion)(data.trim());
663
+ if (!parsed)
664
+ throw new Error(texts_1.ERR_FETCH_VERSION);
630
665
  if (env === 'prod')
631
- return v;
632
- return `${v}-${env}`;
666
+ return parsed.version;
667
+ return `${parsed.version}-${env}`;
633
668
  }
634
669
  else {
635
670
  return '0.0.0';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "bdy",
3
3
  "preferGlobal": false,
4
- "version": "1.23.11-stage",
4
+ "version": "1.23.13-beta",
5
5
  "type": "commonjs",
6
6
  "license": "MIT",
7
7
  "homepage": "https://buddy.works/docs/cli",
@@ -51,7 +51,7 @@
51
51
  "@fastify/cors": "11.2.0",
52
52
  "@inquirer/prompts": "8.5.2",
53
53
  "@modelcontextprotocol/sdk": "1.30.0",
54
- "@puppeteer/browsers": "2.13.0",
54
+ "@puppeteer/browsers": "3.2.0",
55
55
  "@scalar/json-magic": "0.12.16",
56
56
  "@scalar/openapi-parser": "0.28.7",
57
57
  "@scalar/openapi-types": "0.9.1",
@@ -79,7 +79,7 @@
79
79
  "picomatch": "4.0.4",
80
80
  "pino": "10.3.1",
81
81
  "punycode": "2.3.1",
82
- "puppeteer-core": "23.11.1",
82
+ "puppeteer-core": "25.6.0",
83
83
  "range-parser": "1.2.1",
84
84
  "socket.io-client": "4.8.3",
85
85
  "ssh2": "1.17.0",