@stackbone/cli 0.5.3 → 0.5.4

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/CHANGELOG.md CHANGED
@@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.4] - 2026-10-01
11
+
12
+ ### Added
13
+
14
+ - **`stackbone dev` says why the tunnel closed.** When `frpc` stops after the
15
+ tunnel came up, `stackbone dev` keeps running and prints the cause: the
16
+ ticket reached its 8-hour limit, the control plane revoked the grant, or
17
+ another cause, with the last lines `frpc` printed. Each message says to
18
+ restart `stackbone dev` for a new URL. Before, the tunnel closed without a
19
+ word (PLA-1892).
20
+
21
+ ### Changed
22
+
23
+ - **`frpc` now sends the grant's signed ticket to the relay.** The ticket is
24
+ written to the `frpc` config as global metadata, with a heartbeat every 30
25
+ seconds so the relay sees it on every ping as well as on login. The shared
26
+ token is still sent. A control plane that mints no ticket still opens the
27
+ tunnel on the shared token alone (PLA-1892).
28
+
29
+ ### Fixed
30
+
31
+ - **An expired tunnel grant could clear the tunnel URL of an unrelated
32
+ `stackbone dev` session.** The control plane matched a lapsed grant to local
33
+ installations by searching their URL text for its subdomain, so one grant
34
+ could null another session's URL, in any organization. `stackbone dev` now
35
+ registers its local-dev installation before the tunnel opens, or registers it
36
+ again when the id in `.stackbone/project.json` no longer resolves, and sends
37
+ that id with the grant request. The control plane writes the tunnel URL when
38
+ it mints the grant and clears it on revoke or expiry, so the CLI no longer
39
+ PATCHes the URL on start or on Ctrl-C. If no installation can be registered,
40
+ the tunnel still opens, with an unbound grant (PLA-1691).
41
+
42
+ - **`stackbone logs --since 5` read the number as a date in 2001 and filtered
43
+ nothing.** A bare number is a duration with the unit forgotten; `--since`
44
+ and `--until` now reject it and suggest one, e.g. `5m` (PLA-1796).
45
+
10
46
  ## [0.5.3] - 2026-09-25
11
47
 
12
48
  ### Fixed
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @stackbone/cli
2
2
 
3
- The CLI for [Stackbone](https://stackbone.ai) — the marketplace and runtime for containerized AI agents.
3
+ The CLI for [Stackbone](https://stackbone.ai): scaffold, run and deploy agents and workflows written in TypeScript, on Stackbone Cloud or your own infrastructure.
4
4
 
5
5
  > **Status:** `alpha`. The contract may break between releases. Pin a version or rely on the `alpha` dist-tag.
6
6
 
@@ -15,7 +15,7 @@ cd my-workspace && npm install
15
15
  npx @stackbone/cli dev
16
16
  ```
17
17
 
18
- That's it. `init` scaffolds a new workspace shell (agents + workflows + coding-agent skills) and links it to your organization, so you sign in first; `dev` boots the whole workspace locally — Postgres, Redis, MinIO and Stackbone Studio at `http://localhost:4242` — so you can iterate without publishing.
18
+ That's it. `init` scaffolds a new workspace shell (agents + workflows + coding-agent skills) and links it to your organization, so you sign in first; `dev` boots the whole workspace locally (Postgres, Redis, MinIO and Stackbone Studio at `http://localhost:4242`) so you can iterate before you deploy.
19
19
 
20
20
  ## Commands
21
21
 
@@ -45,7 +45,7 @@ Run any command with `--help` for the full flag reference. The full documentatio
45
45
  ## Links
46
46
 
47
47
  - Documentation: <https://docs.stackbone.ai>
48
- - Marketplace: <https://stackbone.ai>
48
+ - Website: <https://stackbone.ai>
49
49
  - Issues & feedback: contact the team directly.
50
50
 
51
51
  ## License
package/main.js CHANGED
@@ -9,7 +9,7 @@ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep }
9
9
  import semver from "semver";
10
10
  import pino from "pino";
11
11
  import pretty from "pino-pretty";
12
- import { exec, execFile, spawn, spawnSync } from "node:child_process";
12
+ import { execFile, spawn, spawnSync } from "node:child_process";
13
13
  import { fileURLToPath, pathToFileURL } from "node:url";
14
14
  import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
15
15
  import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
@@ -37,9 +37,9 @@ import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
37
37
  import PgQueryFactory from "pg-query-emscripten";
38
38
  import { createRemoteJWKSet, errors, jwtVerify } from "jose";
39
39
  import { CLIENT_CAPABILITIES_META_KEY, McpServer, createMcpHandler, inputRequired, inputResponse } from "@modelcontextprotocol/server";
40
+ import { COMPOSED_HANDLER } from "hono/utils/constants";
40
41
  import Ajv from "ajv/dist/2020.js";
41
42
  import addFormats from "ajv-formats";
42
- import { COMPOSED_HANDLER } from "hono/utils/constants";
43
43
  import { parse } from "yaml";
44
44
  import { Queue, Worker } from "bullmq";
45
45
  import Ajv$1 from "ajv";
@@ -2045,12 +2045,15 @@ var init_user_code = __esmMin((() => {
2045
2045
  if (clean.length !== 8) return raw;
2046
2046
  return `${clean.slice(0, 4)}-${clean.slice(4)}`;
2047
2047
  };
2048
- })), DEV_TUNNEL_GRANT_MAX_TTL_SECONDS, devTunnelGrantResponseSchema;
2048
+ })), DEV_TUNNEL_GRANT_HARD_CAP_SECONDS, DEV_TUNNEL_GRANT_MAX_TTL_SECONDS, buildCanonicalTunnelUrl, devTunnelGrantResponseSchema, devTunnelRevocationListResponseSchema;
2049
2049
  var init_dev_tunnel = __esmMin((() => {
2050
2050
  init_organizations();
2051
+ DEV_TUNNEL_GRANT_HARD_CAP_SECONDS = 480 * 60;
2051
2052
  DEV_TUNNEL_GRANT_MAX_TTL_SECONDS = 86400;
2053
+ buildCanonicalTunnelUrl = (subdomain, relayHost) => `https://${subdomain}.${relayHost}`;
2052
2054
  z.object({
2053
2055
  agentSlug: agentSlugSchema,
2056
+ installationId: z.uuid().optional(),
2054
2057
  ttlSeconds: z.number().int().positive().max(DEV_TUNNEL_GRANT_MAX_TTL_SECONDS, { message: `ttlSeconds must not exceed ${DEV_TUNNEL_GRANT_MAX_TTL_SECONDS}` }).optional()
2055
2058
  });
2056
2059
  z.object({ subdomain: z.string().min(8).max(64).regex(/^[a-z0-9-]+$/, "subdomain must be lowercase alphanumeric with optional hyphens") });
@@ -2059,8 +2062,18 @@ var init_dev_tunnel = __esmMin((() => {
2059
2062
  relayPort: z.number().int().positive(),
2060
2063
  subdomain: z.string().min(1),
2061
2064
  frpcToken: z.string().min(1),
2065
+ ticket: z.string().min(1),
2062
2066
  expiresAt: z.iso.datetime()
2063
2067
  });
2068
+ devTunnelRevocationListResponseSchema = z.object({ revocationList: z.string().min(1) });
2069
+ z.object({ keys: z.array(z.object({
2070
+ kty: z.literal("OKP"),
2071
+ crv: z.literal("Ed25519"),
2072
+ x: z.string().min(1),
2073
+ kid: z.string().min(1),
2074
+ alg: z.literal("EdDSA"),
2075
+ use: z.literal("sig")
2076
+ })) });
2064
2077
  }));
2065
2078
  //#endregion
2066
2079
  //#region ../../libs/shared/validators/src/lib/studio/studio-config.ts
@@ -4846,7 +4859,7 @@ var maybeWarnCliUpgrade = (opts) => {
4846
4859
  if (!required && !belowMinimum) return;
4847
4860
  const channel = channelForVersion$1(cliVersion);
4848
4861
  const target = minVersion ?? "the latest release";
4849
- logger.warn(`Your Stackbone CLI (${cliVersion}) is older than the minimum supported by the control plane (${target}). Upgrade with \`npm i -g @stackbone/cli@${channel}\`.`);
4862
+ logger.warn(`Your Stackbone CLI (${cliVersion}) is older than the minimum supported by the control plane (${target}). Upgrade with \`pnpm add -g @stackbone/cli@${channel}\`.`);
4850
4863
  };
4851
4864
  //#endregion
4852
4865
  //#region src/infra/url.ts
@@ -4991,7 +5004,7 @@ var statusToError = (status, body) => {
4991
5004
  default: code = "generic";
4992
5005
  }
4993
5006
  const inferredMessage = withIssueLines(typeof body === "object" && body !== null && "message" in body ? String(body.message) : `HTTP ${status}`, body);
4994
- const serverUpgrade = readUpgradeCommand(body) ?? "npm i -g @stackbone/cli";
5007
+ const serverUpgrade = readUpgradeCommand(body) ?? "pnpm add -g @stackbone/cli";
4995
5008
  const suggestion = code === "auth" ? "Run `stackbone login`" : code === "cli_upgrade_required" ? `Upgrade with \`${serverUpgrade}\`, then retry.` : void 0;
4996
5009
  return new StackboneCliError({
4997
5010
  code,
@@ -5273,7 +5286,7 @@ var formatUnsupportedPlatformBanner = (colors) => {
5273
5286
  ` ${dim("•")} Git Bash: open a Git Bash terminal and re-run the command`,
5274
5287
  ` ${dim("•")} WSL2:`,
5275
5288
  ` ${dim("1.")} wsl --install -d Ubuntu ${dim("# one-time, requires reboot")}`,
5276
- ` ${dim("2.")} (in the Ubuntu shell) pnpm install -g @stackbone/cli`,
5289
+ ` ${dim("2.")} (in the Ubuntu shell) pnpm add -g @stackbone/cli`,
5277
5290
  ` ${dim("3.")} stackbone <command>`,
5278
5291
  ""
5279
5292
  ].join("\n");
@@ -5391,7 +5404,7 @@ var isNewerVersion = (latest, current) => {
5391
5404
  }
5392
5405
  };
5393
5406
  /** Discreet two-line banner, terminated with a newline. */
5394
- var formatUpdateBanner = (current, latest, channel) => `\nA new release of the Stackbone CLI is available: ${current} → ${latest}\nUpgrade with: npm i -g @stackbone/cli@${channel}\n`;
5407
+ var formatUpdateBanner = (current, latest, channel) => `\nA new release of the Stackbone CLI is available: ${current} → ${latest}\nUpgrade with: pnpm add -g @stackbone/cli@${channel}\n`;
5395
5408
  var isSilenced = (opts) => {
5396
5409
  const { env } = opts;
5397
5410
  if (env["STACKBONE_NO_UPDATE_CHECK"]) return true;
@@ -15739,7 +15752,7 @@ var emulatorCors = (allowlist) => cors({
15739
15752
  var STACKBONE_CLI_BUILD_VERSION, EMULATOR_CAPABILITY_ROUTES, EMULATOR_CAPABILITIES, buildContractResponse;
15740
15753
  var init_contract = __esmMin((() => {
15741
15754
  init_src$19();
15742
- STACKBONE_CLI_BUILD_VERSION = "0.5.3";
15755
+ STACKBONE_CLI_BUILD_VERSION = "0.5.4";
15743
15756
  EMULATOR_CAPABILITY_ROUTES = {
15744
15757
  "database.postgres_direct": "/api/db",
15745
15758
  "rag.basic": "/api/rag",
@@ -23226,7 +23239,7 @@ var renderStudioStub = (input) => {
23226
23239
  <li><b>GET</b> <a href="/api/docs">/api/docs</a> <span>· full API reference (Scalar, generated from this box's own route table)</span></li>
23227
23240
  <li><b>GET</b> <a href="/api/contract">/api/contract</a> <span>· Stackbone Agent Protocol handshake</span></li>
23228
23241
  <li><b>GET</b> <a href="/api/health">/api/health</a> <span>· emulator status</span></li>
23229
- <li><b>GET</b> <a href="/api/services">/api/services</a> <span>· postgres / s3 connection info</span></li>
23242
+ <li><b>GET</b> <a href="/api/services">/api/services</a> <span>· postgres / s3 endpoints, password removed (a deployed box asks for a credential)</span></li>
23230
23243
  <li><b>GET</b> <a href="/api/discovery">/api/discovery</a> <span>· deep agents + workflows in this workspace</span></li>
23231
23244
  <li><b>POST</b> <code>/openai/v1/chat/completions</code> <span>· chat with a deep agent (OpenAI-compatible; pick the agent via <code>model</code>)</span></li>
23232
23245
  <li><b>POST</b> <code>/anthropic/v1/messages</code> <span>· chat with a deep agent (Anthropic-compatible)</span></li>
@@ -23239,19 +23252,71 @@ var renderStudioStub = (input) => {
23239
23252
  <h2>Try it</h2>
23240
23253
  <p>Chat with a deep agent from any OpenAI-compatible client — paste this base URL + any bearer key, and select the agent by its name in the <code>model</code> field:</p>
23241
23254
  <p><code>curl -N -X POST ${e(input.emulatorUrl)}/openai/v1/chat/completions -H 'content-type: application/json' -H 'authorization: Bearer dev' -d '{"model":"&lt;name&gt;","messages":[{"role":"user","content":"hi"}],"stream":true}'</code></p>
23242
-
23243
- <h2>Connections</h2>
23244
- <ul>
23245
- <li><b>Postgres</b> <span>· ${e(input.postgresUrl)}</span></li>
23246
- <li><b>S3 (MinIO)</b> <span>· ${e(input.s3Endpoint)}</span></li>
23247
- <li><b>Agent</b> <span>· ${e(input.agentUrl)}</span></li>
23248
- </ul>
23249
23255
  </main>
23250
23256
  </body>
23251
23257
  </html>
23252
23258
  `;
23253
23259
  };
23254
23260
  //#endregion
23261
+ //#region ../../libs/runtime/workspace-runtime/src/lib/db/postgres-dsn.ts
23262
+ /** Query parameters libpq reads a password from. */
23263
+ var PASSWORD_PARAM = /^(ssl)?password=/i;
23264
+ /**
23265
+ * The DSN without the userinfo password and the `password` / `sslpassword`
23266
+ * query parameters; everything else stays byte for byte.
23267
+ *
23268
+ * Answers `''` when `URL` cannot parse it (a multi-host DSN) or misread the
23269
+ * userinfo (an unencoded `#`, `?` or `/` in the password), since the password
23270
+ * would survive in the raw string.
23271
+ */
23272
+ var withoutPostgresPassword = (dsn) => {
23273
+ const url = URL.parse(dsn);
23274
+ if (url === null || url.username === "" && dsn.includes("@")) return "";
23275
+ url.password = "";
23276
+ url.search = url.search.slice(1).split("&").filter((pair) => !PASSWORD_PARAM.test(pair)).join("&");
23277
+ return url.toString();
23278
+ };
23279
+ //#endregion
23280
+ //#region ../../libs/runtime/workspace-runtime/src/lib/http/require-capability.ts
23281
+ var CAPABILITY_NATURE, CAPABILITY_GATE, requireCapability, capabilityGateOf, identityHolds;
23282
+ var init_require_capability = __esmMin((() => {
23283
+ init_box_auth_gate();
23284
+ CAPABILITY_NATURE = {
23285
+ "activity:read": "read",
23286
+ "db:query": "read",
23287
+ "logs:read": "read",
23288
+ "secrets:reveal": "read",
23289
+ "api-keys:manage": "write",
23290
+ "config:write": "write",
23291
+ "connections:manage": "write",
23292
+ "evals:run": "write",
23293
+ "hitl:decide": "write",
23294
+ "rag:write": "write",
23295
+ "runs:invoke": "write",
23296
+ "secrets:rotate": "write"
23297
+ };
23298
+ CAPABILITY_GATE = Symbol("stackbone.capabilityGate");
23299
+ requireCapability = (capability) => {
23300
+ const gate = async (c, next) => {
23301
+ const identity = readVerifiedIdentity(c);
23302
+ if (!identity) return next();
23303
+ if (!identityHolds(identity, capability)) return c.json({
23304
+ code: "forbidden",
23305
+ message: `This action requires the ${capability} capability.`
23306
+ }, 403);
23307
+ return next();
23308
+ };
23309
+ return Object.assign(gate, { [CAPABILITY_GATE]: capability });
23310
+ };
23311
+ capabilityGateOf = (handler) => {
23312
+ if (typeof handler !== "function") return void 0;
23313
+ if (CAPABILITY_GATE in handler) return handler[CAPABILITY_GATE];
23314
+ const composed = handler[COMPOSED_HANDLER];
23315
+ return composed === void 0 ? void 0 : capabilityGateOf(composed);
23316
+ };
23317
+ identityHolds = (identity, capability) => identity.caps?.includes(capability) ?? false;
23318
+ }));
23319
+ //#endregion
23255
23320
  //#region ../../libs/runtime/workspace-runtime/src/lib/agent-contract/log-tail.ts
23256
23321
  init_src$19();
23257
23322
  function recordMatchesFilters(record, filters) {
@@ -25325,6 +25390,7 @@ var SimulatedMailbox = class {
25325
25390
  from: draft.from,
25326
25391
  to: [...draft.to],
25327
25392
  body: draft.body,
25393
+ headers: [...draft.headers ?? []],
25328
25394
  receivedAt: this.now().toISOString()
25329
25395
  };
25330
25396
  const sent = this.runs.get(runId) ?? [];
@@ -25423,7 +25489,8 @@ var OutlookDouble = class {
25423
25489
  from: mailboxOwner(args),
25424
25490
  to: recipients,
25425
25491
  subject: textOf(message["subject"]),
25426
- body: bodyContent(message["body"])
25492
+ body: bodyContent(message["body"]),
25493
+ headers: internetHeaders(message["internetMessageHeaders"])
25427
25494
  });
25428
25495
  return {
25429
25496
  ok: true,
@@ -25446,7 +25513,8 @@ var OutlookDouble = class {
25446
25513
  from: mailboxOwner(args),
25447
25514
  to: [parent.from],
25448
25515
  subject: replySubject(parent.subject),
25449
- body
25516
+ body,
25517
+ headers: internetHeaders(draft["internetMessageHeaders"])
25450
25518
  });
25451
25519
  return {
25452
25520
  ok: true,
@@ -25486,9 +25554,18 @@ function toGraphMessage(message) {
25486
25554
  },
25487
25555
  from: { emailAddress: { address: message.from } },
25488
25556
  toRecipients: message.to.map((address) => ({ emailAddress: { address } })),
25489
- receivedDateTime: message.receivedAt
25557
+ receivedDateTime: message.receivedAt,
25558
+ internetMessageHeaders: message.headers.length > 0 ? message.headers.map((h) => ({ ...h })) : null
25490
25559
  };
25491
25560
  }
25561
+ /** `[{ name, value }]` → the headers, dropping malformed entries. */
25562
+ function internetHeaders(value) {
25563
+ if (!Array.isArray(value)) return [];
25564
+ return value.map((entry) => ({
25565
+ name: textOf(asObject$2(entry)["name"]),
25566
+ value: textOf(asObject$2(entry)["value"])
25567
+ })).filter((header) => header.name !== "");
25568
+ }
25492
25569
  /**
25493
25570
  * Whose mailbox this is: Graph names it in the `{user-id}` path segment, so the
25494
25571
  * double sends AS that address, the way the real provider would.
@@ -26037,46 +26114,6 @@ var isMutationOutsideCatalog = (method, path) => matches(MUTATIONS_OUTSIDE_CATAL
26037
26114
  /** Whether `method`+`path` is a read that happens to travel as POST. Exact, never a prefix. */
26038
26115
  var isPostShapedRead = (method, path) => matches(POST_SHAPED_READS, method, path);
26039
26116
  //#endregion
26040
- //#region ../../libs/runtime/workspace-runtime/src/lib/http/require-capability.ts
26041
- var CAPABILITY_NATURE, CAPABILITY_GATE, requireCapability, capabilityGateOf, identityHolds;
26042
- var init_require_capability = __esmMin((() => {
26043
- init_box_auth_gate();
26044
- CAPABILITY_NATURE = {
26045
- "activity:read": "read",
26046
- "db:query": "read",
26047
- "logs:read": "read",
26048
- "secrets:reveal": "read",
26049
- "api-keys:manage": "write",
26050
- "config:write": "write",
26051
- "connections:manage": "write",
26052
- "evals:run": "write",
26053
- "hitl:decide": "write",
26054
- "rag:write": "write",
26055
- "runs:invoke": "write",
26056
- "secrets:rotate": "write"
26057
- };
26058
- CAPABILITY_GATE = Symbol("stackbone.capabilityGate");
26059
- requireCapability = (capability) => {
26060
- const gate = async (c, next) => {
26061
- const identity = readVerifiedIdentity(c);
26062
- if (!identity) return next();
26063
- if (!identityHolds(identity, capability)) return c.json({
26064
- code: "forbidden",
26065
- message: `This action requires the ${capability} capability.`
26066
- }, 403);
26067
- return next();
26068
- };
26069
- return Object.assign(gate, { [CAPABILITY_GATE]: capability });
26070
- };
26071
- capabilityGateOf = (handler) => {
26072
- if (typeof handler !== "function") return void 0;
26073
- if (CAPABILITY_GATE in handler) return handler[CAPABILITY_GATE];
26074
- const composed = handler[COMPOSED_HANDLER];
26075
- return composed === void 0 ? void 0 : capabilityGateOf(composed);
26076
- };
26077
- identityHolds = (identity, capability) => identity.caps?.includes(capability) ?? false;
26078
- }));
26079
- //#endregion
26080
26117
  //#region ../../libs/runtime/workspace-runtime/src/lib/server/route-registry-generation.ts
26081
26118
  var generations = /* @__PURE__ */ new WeakMap();
26082
26119
  /** The generation `app`'s registry is at. Zero until something bumps it. */
@@ -72090,6 +72127,7 @@ init_invocation_guard();
72090
72127
  init_well_known();
72091
72128
  init_contract();
72092
72129
  init_error_boundary();
72130
+ init_require_capability();
72093
72131
  init_route_helpers();
72094
72132
  init_resource_ledger();
72095
72133
  init_teardown_order();
@@ -72137,11 +72175,12 @@ var SUBSYSTEM_HEALTH = named("SubsystemHealth", z.object({
72137
72175
  * spell out (a shape a SECOND author reads goes to `@stackbone/validators` beside
72138
72176
  * its surface) has nothing to bind here.
72139
72177
  *
72140
- * The secret key is deliberately NOT in it: `s3SecretKey` is on the endpoints
72141
- * object this reads from, and the answer carries the access key alone.
72178
+ * No secret is in it. `s3SecretKey` is on the endpoints object this reads from,
72179
+ * and the answer carries the access key alone; the Postgres DSN goes out through
72180
+ * {@link withoutPostgresPassword} (PLA-1754).
72142
72181
  */
72143
72182
  var boxServicesSchema = z.object({
72144
- postgres: z.object({ url: z.string().openapi({ description: "The Postgres DSN the agent was given, credentials included. Under `stackbone dev` it points at the local stack; on a deployed box it is whatever the operator configured." }) }),
72183
+ postgres: z.object({ url: z.string().openapi({ description: "The Postgres DSN the agent was given, with the password removed. Under `stackbone dev` it points at the local stack; on a deployed box it is whatever the operator configured. Empty when the box cannot tell where a password in it ends." }) }),
72145
72184
  storage: z.object({
72146
72185
  endpoint: z.string().openapi({ description: "The S3-compatible endpoint the agent writes to." }),
72147
72186
  bucket: z.string(),
@@ -72190,10 +72229,7 @@ var buildEmulatorApp = (opts, hooks = {}) => {
72190
72229
  app.get("/", (c) => c.html(renderStudioStub({
72191
72230
  agentSlug: opts.agent.slug,
72192
72231
  agentName: opts.agent.name,
72193
- emulatorUrl: ctx.emulatorBaseUrl,
72194
- agentUrl: ctx.agentBaseUrl,
72195
- postgresUrl: opts.injection.services.postgresUrl,
72196
- s3Endpoint: opts.injection.services.s3Endpoint
72232
+ emulatorUrl: new URL(c.req.url).origin
72197
72233
  })));
72198
72234
  const hostKind = opts.host;
72199
72235
  app.openapi(createRoute({
@@ -72258,15 +72294,20 @@ var buildEmulatorApp = (opts, hooks = {}) => {
72258
72294
  checks
72259
72295
  }, 503);
72260
72296
  });
72297
+ const servicesPostgresUrl = withoutPostgresPassword(opts.injection.services.postgresUrl);
72261
72298
  app.openapi(createRoute({
72262
72299
  method: "get",
72263
72300
  path: "/api/services",
72264
72301
  tags: [LIFECYCLE_TAG],
72302
+ middleware: [requireCapability("db:query")],
72265
72303
  summary: "Injected service endpoints",
72266
- description: "The Postgres, object-storage and agent-process endpoints this box injected into the agent, read back. A creator uses it to point `psql` or an S3 client at the same stack their agent is running against. The storage SECRET key is not in the answer; the access key is.",
72267
- responses: { 200: jsonResponse(boxServicesSchema, "The endpoints this box handed the agent.") }
72304
+ description: "The Postgres, object-storage and agent-process endpoints this box injected into the agent, read back. A creator uses it to point `psql` or an S3 client at the same stack their agent is running against. No secret is in the answer: the Postgres password is removed from the DSN, and the storage secret key is left out (the access key is in).",
72305
+ responses: {
72306
+ 200: jsonResponse(boxServicesSchema, "The endpoints this box handed the agent."),
72307
+ 403: errorResponse("The caller's role does not grant `db:query`. Only a browser-direct identity token carries a role; a signed call and the self-host operator token are not checked against one.")
72308
+ }
72268
72309
  }), (c) => c.json({
72269
- postgres: { url: opts.injection.services.postgresUrl },
72310
+ postgres: { url: servicesPostgresUrl },
72270
72311
  storage: {
72271
72312
  endpoint: opts.injection.services.s3Endpoint,
72272
72313
  bucket: opts.injection.services.s3Bucket,
@@ -73665,7 +73706,7 @@ var createAddCommand = defineGroup({
73665
73706
  }),
73666
73707
  workflow: defineVerb({
73667
73708
  name: "workflow",
73668
- description: "Add a durable workflow (dev-only; never touches the control plane).",
73709
+ description: "Add a durable workflow to the current workspace (offline scaffold).",
73669
73710
  args: {
73670
73711
  name: {
73671
73712
  type: "positional",
@@ -80121,11 +80162,23 @@ var EVAL_RUN_SCREEN_PATH = "/studio/eval-runs";
80121
80162
  /**
80122
80163
  * Which Studio a given control plane belongs to.
80123
80164
  *
80124
- * One ternary, in one place: `stackbone dev` prints it in its banner and
80165
+ * One resolver, in one place: `stackbone dev` prints it in its banner and
80125
80166
  * `stackbone eval` prints it beside a run's result, and two copies would drift
80126
80167
  * the first time either host changed.
80168
+ *
80169
+ * `STACKBONE_WEB_URL` (the web origin, no `/app`) wins when set, even against a
80170
+ * cloud API: `scripts/worktree-env.sh` exports it next to `WEB_PORT`, because a
80171
+ * worktree with a port offset serves its web on `4200 + offset*10` and the
80172
+ * literal 4200 would open another checkout's web. The `STACKBONE_` prefix keeps
80173
+ * it apart from an `APP_URL` a creator's workspace `.env` defines for its own
80174
+ * app. Unset, a local control plane maps to `localhost:4200`, anything else to
80175
+ * the cloud Studio.
80127
80176
  */
80128
- var resolveStudioBaseUrl = (apiUrl) => apiUrl.includes("localhost") ? "http://localhost:4200/app" : "https://app.stackbone.ai/app";
80177
+ var resolveStudioBaseUrl = (apiUrl, env = {}) => {
80178
+ const webUrl = env["STACKBONE_WEB_URL"]?.trim().replace(/\/+$/, "");
80179
+ if (webUrl) return `${webUrl}/app`;
80180
+ return apiUrl.includes("localhost") ? "http://localhost:4200/app" : "https://app.stackbone.ai/app";
80181
+ };
80129
80182
  /**
80130
80183
  * The link to one eval run, with NO `?stackbone-dev=` pin.
80131
80184
  *
@@ -81833,7 +81886,13 @@ var getAgentByOwnerAndSlug = async (ctx, ownerOrgSlug, templateSlug) => {
81833
81886
  init_src$19();
81834
81887
  var GRANT_PATH = "/api/dev-tunnel/grants";
81835
81888
  var DEFAULT_STATUS_URL = "https://status.stackbone.ai";
81836
- var REVOKE_TIMEOUT_MS = 3e3;
81889
+ var SIDE_CALL_TIMEOUT_MS = 3e3;
81890
+ var REVOCATIONS_PATH = "/api/dev-tunnel/revocations";
81891
+ var cliHeaders = (cliVersion) => ({
81892
+ Accept: "application/json",
81893
+ "User-Agent": `stackbone-cli/${cliVersion}`
81894
+ });
81895
+ var cliGrantResponseSchema = devTunnelGrantResponseSchema.extend({ ticket: devTunnelGrantResponseSchema.shape.ticket.optional() });
81837
81896
  /** Relay status page from the canonical envelope. */
81838
81897
  var readRelayStatusUrl = (body) => {
81839
81898
  if (typeof body !== "object" || body === null) return null;
@@ -81855,17 +81914,17 @@ var requestDevTunnelGrant = async (input) => {
81855
81914
  const url = composeUrl(input.ctx.apiUrl, GRANT_PATH);
81856
81915
  const body = {
81857
81916
  agentSlug: input.agentSlug,
81858
- ...input.ttlSeconds !== void 0 ? { ttlSeconds: input.ttlSeconds } : {}
81917
+ ...input.ttlSeconds !== void 0 ? { ttlSeconds: input.ttlSeconds } : {},
81918
+ ...input.installationId !== void 0 ? { installationId: input.installationId } : {}
81859
81919
  };
81860
81920
  let response;
81861
81921
  try {
81862
81922
  response = await fetchImpl(url, {
81863
81923
  method: "POST",
81864
81924
  headers: {
81925
+ ...cliHeaders(input.ctx.cliVersion),
81865
81926
  "Content-Type": "application/json",
81866
- Accept: "application/json",
81867
- Authorization: `Bearer ${session.session.token}`,
81868
- "User-Agent": `stackbone-cli/${input.ctx.cliVersion}`
81927
+ Authorization: `Bearer ${session.session.token}`
81869
81928
  },
81870
81929
  body: JSON.stringify(body)
81871
81930
  });
@@ -81910,7 +81969,7 @@ var requestDevTunnelGrant = async (input) => {
81910
81969
  message: `Unexpected ${response.status} response from ${GRANT_PATH}`,
81911
81970
  cause: parsedBody
81912
81971
  });
81913
- return parseResponse$1(devTunnelGrantResponseSchema, parsedBody, GRANT_PATH);
81972
+ return parseResponse$1(cliGrantResponseSchema, parsedBody, GRANT_PATH);
81914
81973
  };
81915
81974
  var revokeDevTunnelGrant = async (input) => {
81916
81975
  const session = await input.ctx.tokenStore.activeSession(input.ctx.apiUrl).catch(() => null);
@@ -81925,11 +81984,10 @@ var revokeDevTunnelGrant = async (input) => {
81925
81984
  response = await fetchImpl(url, {
81926
81985
  method: "DELETE",
81927
81986
  headers: {
81928
- Accept: "application/json",
81929
- Authorization: `Bearer ${session.session.token}`,
81930
- "User-Agent": `stackbone-cli/${input.ctx.cliVersion}`
81987
+ ...cliHeaders(input.ctx.cliVersion),
81988
+ Authorization: `Bearer ${session.session.token}`
81931
81989
  },
81932
- signal: AbortSignal.timeout(REVOKE_TIMEOUT_MS)
81990
+ signal: AbortSignal.timeout(SIDE_CALL_TIMEOUT_MS)
81933
81991
  });
81934
81992
  } catch (err) {
81935
81993
  input.ctx.logger.warn({
@@ -81946,9 +82004,29 @@ var revokeDevTunnelGrant = async (input) => {
81946
82004
  subdomain: input.subdomain
81947
82005
  }, "Unexpected response while revoking dev-tunnel grant (slot will free on TTL)");
81948
82006
  };
82007
+ var fetchDevTunnelRevocationList = async (input) => {
82008
+ const fetchImpl = input.fetchImpl ?? fetch;
82009
+ const url = composeUrl(input.ctx.apiUrl, REVOCATIONS_PATH);
82010
+ try {
82011
+ const response = await fetchImpl(url, {
82012
+ method: "GET",
82013
+ headers: cliHeaders(input.ctx.cliVersion),
82014
+ signal: AbortSignal.timeout(SIDE_CALL_TIMEOUT_MS)
82015
+ });
82016
+ if (!response.ok) {
82017
+ input.ctx.logger.debug({ status: response.status }, "dev-tunnel revocation list request was not answered 200");
82018
+ return null;
82019
+ }
82020
+ const parsed = devTunnelRevocationListResponseSchema.safeParse(await response.json());
82021
+ return parsed.success ? parsed.data.revocationList : null;
82022
+ } catch (err) {
82023
+ input.ctx.logger.debug({ err }, "Could not fetch the dev-tunnel revocation list");
82024
+ return null;
82025
+ }
82026
+ };
81949
82027
  //#endregion
81950
82028
  //#region src/dev/frpc-bin.ts
81951
- var execp = promisify(exec);
82029
+ var execFilep = promisify(execFile);
81952
82030
  var PINNED_FRPC_VERSION = "0.68.1";
81953
82031
  var assetForPlatform = (platform, arch) => {
81954
82032
  const version = PINNED_FRPC_VERSION;
@@ -81997,9 +82075,27 @@ var streamToFile = async (body, path) => {
81997
82075
  });
81998
82076
  };
81999
82077
  var archiveStem = (asset) => asset.name.replace(/\.(tar\.gz|zip)$/, "");
82078
+ var psQuote = (value) => `'${value.replaceAll("'", "''")}'`;
82079
+ var extractCommand = (archive, archivePath, destDir) => archive === "tgz" ? {
82080
+ file: "tar",
82081
+ args: [
82082
+ "-xzf",
82083
+ archivePath,
82084
+ "-C",
82085
+ destDir
82086
+ ]
82087
+ } : {
82088
+ file: "powershell",
82089
+ args: [
82090
+ "-NoProfile",
82091
+ "-NonInteractive",
82092
+ "-Command",
82093
+ `Expand-Archive -LiteralPath ${psQuote(archivePath)} -DestinationPath ${psQuote(destDir)} -Force`
82094
+ ]
82095
+ };
82000
82096
  var extractTarball = async (archivePath, asset, destBinary) => {
82001
- if (asset.archive === "tgz") await execp(`tar -xzf "${archivePath}" -C "${cacheDir()}"`);
82002
- else await execp(`unzip -o -q "${archivePath}" -d "${cacheDir()}"`);
82097
+ const { file, args } = extractCommand(asset.archive, archivePath, cacheDir());
82098
+ await execFilep(file, args);
82003
82099
  await rename(join(cacheDir(), archiveStem(asset), process.platform === "win32" ? "frpc.exe" : "frpc"), destBinary);
82004
82100
  };
82005
82101
  var resolveFrpcBin = async (input) => {
@@ -82022,24 +82118,27 @@ var resolveFrpcBin = async (input) => {
82022
82118
  const archivePath = `${cached}.${asset.archive === "tgz" ? "tar.gz" : "zip"}`;
82023
82119
  const s = spinner();
82024
82120
  s.start(`Downloading frpc ${version} (${asset.name})`);
82121
+ let stage = "download";
82025
82122
  try {
82026
82123
  const res = await fetch(url, { redirect: "follow" });
82027
82124
  if (!res.ok || !res.body) throw new Error(`HTTP ${res.status} ${res.statusText} fetching ${url}`);
82028
82125
  await streamToFile(res.body, archivePath);
82126
+ stage = "extract";
82029
82127
  await extractTarball(archivePath, asset, cached);
82030
82128
  if (process.platform !== "win32") await chmod(cached, 493);
82031
82129
  s.stop(`Downloaded frpc ${version}`);
82032
82130
  return cached;
82033
82131
  } catch (err) {
82034
- s.error("Failed to download frpc");
82132
+ s.error(stage === "download" ? "Failed to download frpc" : "Failed to extract frpc");
82035
82133
  await Promise.all([unlink(cached).catch(() => void 0), rm(join(cacheDir(), archiveStem(asset)), {
82036
82134
  recursive: true,
82037
82135
  force: true
82038
82136
  }).catch(() => void 0)]);
82137
+ const manualInstall = `download frp ${version} from https://github.com/fatedier/frp/releases, and put its frpc binary at ${cached} (or point STACKBONE_FRPC_BIN at it) before re-running.`;
82039
82138
  throw new StackboneCliError({
82040
82139
  code: "generic",
82041
- message: "Could not auto-fetch frpc",
82042
- suggestion: "Check your network, or install frpc manually from https://github.com/fatedier/frp/releases and point STACKBONE_FRPC_BIN at the binary before re-running.",
82140
+ message: stage === "download" ? `Could not download frpc ${version}` : `Downloaded frpc ${version} but could not extract it`,
82141
+ suggestion: stage === "download" ? `Check your network, or ${manualInstall}` : `Extract ${asset.name} by hand: ${manualInstall}`,
82043
82142
  cause: err
82044
82143
  });
82045
82144
  } finally {
@@ -82115,10 +82214,85 @@ var clearLastTunnelGrant = async (projectRoot, logger) => {
82115
82214
  }
82116
82215
  };
82117
82216
  //#endregion
82217
+ //#region src/dev/tunnel-drop.ts
82218
+ init_src$19();
82219
+ var BASE64URL_SEGMENT = /^[A-Za-z0-9_-]+$/;
82220
+ var decodeJwtPayload = (token) => {
82221
+ const segments = token.split(".");
82222
+ const payload = segments[1];
82223
+ if (segments.length !== 3 || payload === void 0 || !BASE64URL_SEGMENT.test(payload)) return null;
82224
+ try {
82225
+ const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
82226
+ return typeof claims === "object" && claims !== null && !Array.isArray(claims) ? claims : null;
82227
+ } catch {
82228
+ return null;
82229
+ }
82230
+ };
82231
+ var MAX_EPOCH_SECONDS = 1e10;
82232
+ var readTicketClaims = (ticket) => {
82233
+ const claims = decodeJwtPayload(ticket);
82234
+ if (!claims) return null;
82235
+ const { jti, exp } = claims;
82236
+ if (typeof jti !== "string" || jti.length === 0) return null;
82237
+ if (typeof exp !== "number" || !Number.isFinite(exp) || exp <= 0 || exp >= MAX_EPOCH_SECONDS) return null;
82238
+ return {
82239
+ jti,
82240
+ exp
82241
+ };
82242
+ };
82243
+ var readRevokedIds = (revocationList) => {
82244
+ const claims = decodeJwtPayload(revocationList);
82245
+ return claims && Array.isArray(claims["revoked"]) ? claims["revoked"] : null;
82246
+ };
82247
+ var classifyTunnelDrop = async (input) => {
82248
+ const claims = input.ticket === void 0 ? null : readTicketClaims(input.ticket);
82249
+ if (!claims) return "other";
82250
+ if (input.nowMs / 1e3 > claims.exp) return "cap";
82251
+ const list = await input.fetchRevocationList();
82252
+ return (list === null ? null : readRevokedIds(list))?.some((jti) => jti === claims.jti) ? "revoked" : "other";
82253
+ };
82254
+ var CAP_HOURS = DEV_TUNNEL_GRANT_HARD_CAP_SECONDS / 3600;
82255
+ var RESTART_HINT = "`stackbone dev` is still running, but its public URL no longer works. Restart `stackbone dev` to get a new URL.";
82256
+ var describeExit = (code, signal) => code !== null ? `code ${code}` : `signal ${signal ?? "unknown"}`;
82257
+ var describeCause = (input) => {
82258
+ if (input.cause === "cap") return [`The dev tunnel closed: its ticket reached the ${CAP_HOURS}-hour limit.`];
82259
+ if (input.cause === "revoked") return ["The dev tunnel closed: the control plane revoked its grant."];
82260
+ const tail = input.lastOutput.trim();
82261
+ return [`The dev tunnel closed: frpc exited with ${describeExit(input.code, input.signal)}.`, ...tail.length > 0 ? ["last frpc output:", tail] : []];
82262
+ };
82263
+ var reportTunnelDrop = async (input, code, signal) => {
82264
+ const cause = await classifyTunnelDrop({
82265
+ ticket: input.ticket,
82266
+ nowMs: Date.now(),
82267
+ fetchRevocationList: () => fetchDevTunnelRevocationList({ ctx: input.ctx })
82268
+ }).catch(() => "other");
82269
+ try {
82270
+ const lines = describeCause({
82271
+ cause,
82272
+ code,
82273
+ signal,
82274
+ lastOutput: input.lastOutput()
82275
+ });
82276
+ input.ctx.logger.warn({
82277
+ cause,
82278
+ code,
82279
+ signal
82280
+ }, [...lines, RESTART_HINT].join("\n"));
82281
+ } catch {}
82282
+ };
82283
+ var watchTunnelDrop = (input) => {
82284
+ input.child.once("close", (code, signal) => {
82285
+ if (input.isStopping()) return;
82286
+ reportTunnelDrop(input, code, signal);
82287
+ });
82288
+ };
82289
+ //#endregion
82118
82290
  //#region src/dev/tunnel.ts
82291
+ init_src$19();
82119
82292
  var STARTUP_TIMEOUT_MS = 3e4;
82120
82293
  var STOP_GRACE_MS = 3e3;
82121
82294
  var TAIL_BYTES = 4 * 1024;
82295
+ var FRPC_HEARTBEAT_INTERVAL_SECONDS = 30;
82122
82296
  var TUNNEL_HOST_SUFFIX = "tun.stackbone.ai";
82123
82297
  var TUNNEL_OPEN_DEFAULT_ATTEMPTS = 3;
82124
82298
  var TUNNEL_OPEN_DEFAULT_RETRY_DELAY_MS = 2500;
@@ -82131,13 +82305,21 @@ var frpcNotInstalledError = (cause) => new StackboneCliError({
82131
82305
  suggestion: "Install frpc from https://github.com/fatedier/frp/releases, or set STACKBONE_FRPC_BIN to point at an existing binary. The tunnel is mandatory — there is no opt-out flag.",
82132
82306
  cause
82133
82307
  });
82308
+ var frpcBlockedError = (binPath, cause) => new StackboneCliError({
82309
+ code: "permission",
82310
+ message: `The OS refused to run frpc (${binPath})`,
82311
+ suggestion: process.platform === "win32" ? "Windows is blocking the binary. Unblock it (right-click > Properties > Unblock), check Smart App Control and Defender exclusions, or run the CLI from WSL2. See https://docs.stackbone.ai/docs/cli/guides/windows" : `Make it executable with \`chmod +x ${binPath}\`, or point STACKBONE_FRPC_BIN at a binary you can run.`,
82312
+ cause
82313
+ });
82134
82314
  var buildFrpcConfig = (grant, targetUrl) => {
82135
82315
  const port = targetUrl.port ? Number.parseInt(targetUrl.port, 10) : targetUrl.protocol === "https:" ? 443 : 80;
82316
+ const ticketLines = grant.ticket === void 0 ? [] : [`transport.heartbeatInterval = ${FRPC_HEARTBEAT_INTERVAL_SECONDS}`, `metadatas.ticket = "${grant.ticket}"`];
82136
82317
  return [
82137
82318
  `serverAddr = "${grant.relayHost}"`,
82138
82319
  `serverPort = ${grant.relayPort}`,
82139
82320
  `auth.method = "token"`,
82140
82321
  `auth.token = "${grant.frpcToken}"`,
82322
+ ...ticketLines,
82141
82323
  "",
82142
82324
  "[[proxies]]",
82143
82325
  `name = "stackbone-dev-${grant.subdomain}"`,
@@ -82188,7 +82370,7 @@ var spawnFrpc = async (input) => {
82188
82370
  child.once("exit", () => process.off("exit", killOnParentExit));
82189
82371
  const tail = createTail();
82190
82372
  const successRegex = /start proxy success/i;
82191
- const expectedPublicUrl = `https://${input.expectedSubdomain}.${TUNNEL_HOST_SUFFIX}`;
82373
+ const expectedPublicUrl = buildCanonicalTunnelUrl(input.expectedSubdomain, TUNNEL_HOST_SUFFIX);
82192
82374
  const publicUrl = await new Promise((resolve, reject) => {
82193
82375
  const timer = setTimeout(() => {
82194
82376
  child.kill("SIGINT");
@@ -82215,6 +82397,10 @@ var spawnFrpc = async (input) => {
82215
82397
  reject(frpcNotInstalledError(err));
82216
82398
  return;
82217
82399
  }
82400
+ if (err.code === "EACCES" || err.code === "EPERM") {
82401
+ reject(frpcBlockedError(input.binPath, err));
82402
+ return;
82403
+ }
82218
82404
  reject(new StackboneCliError({
82219
82405
  code: "generic",
82220
82406
  message: "frpc failed to start",
@@ -82241,7 +82427,8 @@ var spawnFrpc = async (input) => {
82241
82427
  });
82242
82428
  return {
82243
82429
  child,
82244
- publicUrl
82430
+ publicUrl,
82431
+ lastOutput: () => tail.snapshot()
82245
82432
  };
82246
82433
  };
82247
82434
  var stopChild = async (child) => {
@@ -82274,7 +82461,8 @@ var stopChild = async (child) => {
82274
82461
  * `auth_required`, `workspace_tunnel_cap_reached`, or
82275
82462
  * `tunnel_relay_unhealthy` to the thrown StackboneCliError before
82276
82463
  * propagation, and `spawnFrpc` adds the captured output tail when frpc
82277
- * dies before opening the tunnel.
82464
+ * dies before opening the tunnel. A frpc exit after the tunnel is up never
82465
+ * throws: `watchTunnelDrop` logs its cause and the session keeps running.
82278
82466
  */
82279
82467
  var openDevTunnel = async (input) => {
82280
82468
  const targetUrl = new URL(input.targetUrl);
@@ -82302,19 +82490,29 @@ var openDevTunnel = async (input) => {
82302
82490
  try {
82303
82491
  const grant = await requestDevTunnelGrant({
82304
82492
  ctx: input.ctx,
82305
- agentSlug
82493
+ agentSlug,
82494
+ ...input.installationId !== void 0 ? { installationId: input.installationId } : {}
82306
82495
  });
82307
82496
  await writeLastTunnelGrant(input.projectRoot, grant.subdomain, input.ctx.logger);
82308
82497
  try {
82309
- const { child, publicUrl } = await spawnFrpc({
82498
+ const { child, publicUrl, lastOutput } = await spawnFrpc({
82310
82499
  binPath,
82311
82500
  config: buildFrpcConfig(grant, targetUrl),
82312
82501
  logger: input.ctx.logger,
82313
82502
  expectedSubdomain: grant.subdomain
82314
82503
  });
82504
+ let stopping = false;
82505
+ watchTunnelDrop({
82506
+ child,
82507
+ ctx: input.ctx,
82508
+ ticket: grant.ticket,
82509
+ lastOutput,
82510
+ isStopping: () => stopping
82511
+ });
82315
82512
  return {
82316
82513
  publicUrl,
82317
82514
  stop: async () => {
82515
+ stopping = true;
82318
82516
  try {
82319
82517
  await revokeDevTunnelGrant({
82320
82518
  ctx: input.ctx,
@@ -82985,7 +83183,8 @@ var startDevSession = async (input) => {
82985
83183
  tunnel = await openDevTunnel({
82986
83184
  targetUrl: emulator.url,
82987
83185
  ctx: input.ctx,
82988
- projectRoot: input.projectRoot
83186
+ projectRoot: input.projectRoot,
83187
+ ...input.installationId !== void 0 ? { installationId: input.installationId } : {}
82989
83188
  });
82990
83189
  onStage({
82991
83190
  stage: "tunnel",
@@ -83115,10 +83314,6 @@ init_src$19();
83115
83314
  var upsertLocalDevInstallation = async (ctx, body) => {
83116
83315
  return parseResponse$1(localDevInstallationResponseSchema, await ctx.http.post("/api/v1/installations/local-dev", body), "POST /api/v1/installations/local-dev");
83117
83316
  };
83118
- var patchLocalDevInstallation = async (ctx, id, body) => {
83119
- const path = `/api/v1/installations/local-dev/${encodeURIComponent(id)}`;
83120
- return parseResponse$1(localDevInstallationResponseSchema, await ctx.http.patch(path, body), `PATCH ${path}`);
83121
- };
83122
83317
  var heartbeatLocalDevInstallation = async (ctx, id) => {
83123
83318
  const path = `/api/v1/installations/local-dev/${encodeURIComponent(id)}/heartbeat`;
83124
83319
  await ctx.http.post(path, {});
@@ -83441,6 +83636,62 @@ var printContract = (deps = {}) => {
83441
83636
  writeStdout(`${JSON.stringify(payload)}\n`);
83442
83637
  };
83443
83638
  //#endregion
83639
+ //#region src/commands/dev/resolve-local-dev-installation.ts
83640
+ /**
83641
+ * - No stored id: register (upsert) and persist the id to project.json.
83642
+ * - A stored id: heartbeat it once. `not_found` means the row is gone (GC'd,
83643
+ * dev DB wiped) or is not the caller's, so register again, once. Any other
83644
+ * failure says nothing about the row: warn and keep the stored id.
83645
+ * - A failed registration warns and resolves no id instead of throwing.
83646
+ */
83647
+ var resolveLocalDevInstallation = async (ctx, config, agent) => {
83648
+ const linkedOrgSlug = async () => config.organizationSlug ?? (await getActiveOrganization(ctx)).slug;
83649
+ const register = async () => {
83650
+ let upserted;
83651
+ try {
83652
+ upserted = await upsertLocalDevInstallation(ctx, {
83653
+ creatorOrganizationSlug: await linkedOrgSlug(),
83654
+ agentTemplateSlug: agent.slug
83655
+ });
83656
+ } catch (err) {
83657
+ ctx.logger.warn({ err: err.message }, "failed to register the local-dev installation, the tunnel opens unbound (continuing)");
83658
+ return {
83659
+ id: void 0,
83660
+ orgSlug: config.organizationSlug
83661
+ };
83662
+ }
83663
+ try {
83664
+ await ctx.projectConfig.write({
83665
+ ...config,
83666
+ localDevInstallationId: upserted.id,
83667
+ organizationSlug: upserted.organizationSlug,
83668
+ agentSlug: upserted.agentSlug
83669
+ });
83670
+ } catch (err) {
83671
+ ctx.logger.warn({ err: err.message }, "registered the local-dev installation but could not persist its id to project.json (continuing)");
83672
+ }
83673
+ return {
83674
+ id: upserted.id,
83675
+ orgSlug: upserted.organizationSlug
83676
+ };
83677
+ };
83678
+ const storedId = config.localDevInstallationId;
83679
+ if (!storedId) return register();
83680
+ try {
83681
+ await heartbeatLocalDevInstallation(ctx, storedId);
83682
+ } catch (err) {
83683
+ if (err instanceof StackboneCliError && err.code === "not_found") {
83684
+ ctx.logger.warn("local-dev row not found, re-registering");
83685
+ return register();
83686
+ }
83687
+ ctx.logger.warn({ err: err.message }, "local-dev pre-flight heartbeat failed, keeping the stored id (continuing)");
83688
+ }
83689
+ return {
83690
+ id: storedId,
83691
+ orgSlug: await linkedOrgSlug()
83692
+ };
83693
+ };
83694
+ //#endregion
83444
83695
  //#region src/commands/dev/dev.command.ts
83445
83696
  init_src$19();
83446
83697
  var STAGE_LABELS = {
@@ -83552,6 +83803,7 @@ var buildHandler$2 = (ctx, flags) => protectedCommand(async (innerCtx) => {
83552
83803
  suggestion: "Run `stackbone link --force --agent <slug> --installation <installationId> --url <url> --secret <secret> --tag <tag>` to re-link to a different agent (see `stackbone link --help`)"
83553
83804
  });
83554
83805
  await assertSdkCompatible(innerCtx, { mode: "warn" });
83806
+ const { id: localDevId, orgSlug: installationOrgSlug } = await resolveLocalDevInstallation(innerCtx, config, agent);
83555
83807
  const renderStage = innerCtx.flags.json ? null : createStageRenderer();
83556
83808
  const renderReload = innerCtx.flags.json ? null : createReloadRenderer();
83557
83809
  let republishPointer = null;
@@ -83565,6 +83817,7 @@ var buildHandler$2 = (ctx, flags) => protectedCommand(async (innerCtx) => {
83565
83817
  },
83566
83818
  emulatorPort: flags.port,
83567
83819
  tunnel: true,
83820
+ ...localDevId ? { installationId: localDevId } : {},
83568
83821
  listen: flags.listen,
83569
83822
  verbose: flags.verbose,
83570
83823
  ...flags.autoMigrate !== void 0 ? { autoMigrateOverride: flags.autoMigrate } : {},
@@ -83576,48 +83829,6 @@ var buildHandler$2 = (ctx, flags) => protectedCommand(async (innerCtx) => {
83576
83829
  }
83577
83830
  });
83578
83831
  declaredPieces = session.declaredPieces;
83579
- const registerLocalDev = async () => {
83580
- const upserted = await upsertLocalDevInstallation(innerCtx, {
83581
- creatorOrganizationSlug: config.organizationSlug ?? (await getActiveOrganization(innerCtx)).slug,
83582
- agentTemplateSlug: agent.slug
83583
- });
83584
- await innerCtx.projectConfig.write({
83585
- ...config,
83586
- localDevInstallationId: upserted.id,
83587
- organizationSlug: upserted.organizationSlug,
83588
- agentSlug: upserted.agentSlug
83589
- });
83590
- return {
83591
- id: upserted.id,
83592
- orgSlug: upserted.organizationSlug
83593
- };
83594
- };
83595
- let localDevId = config.localDevInstallationId;
83596
- let installationOrgSlug = config.organizationSlug;
83597
- if (!localDevId) {
83598
- const registered = await registerLocalDev();
83599
- localDevId = registered.id;
83600
- installationOrgSlug = registered.orgSlug;
83601
- } else if (!installationOrgSlug) installationOrgSlug = (await getActiveOrganization(innerCtx)).slug;
83602
- const publicUrl = session.publicUrl;
83603
- if (publicUrl) {
83604
- const patchTunnel = (id) => patchLocalDevInstallation(innerCtx, id, { localTunnelUrl: publicUrl });
83605
- const patched = await patchTunnel(localDevId).catch(async (err) => {
83606
- if (err instanceof StackboneCliError && err.code === "not_found") {
83607
- innerCtx.logger.warn("local-dev row not found, re-registering");
83608
- const registered = await registerLocalDev();
83609
- localDevId = registered.id;
83610
- installationOrgSlug = registered.orgSlug;
83611
- return patchTunnel(localDevId).catch((retryErr) => {
83612
- innerCtx.logger.warn({ err: retryErr.message }, "failed to PATCH local-dev tunnel URL after re-register (continuing)");
83613
- return null;
83614
- });
83615
- }
83616
- innerCtx.logger.warn({ err: err.message }, "failed to PATCH local-dev tunnel URL (continuing)");
83617
- return null;
83618
- });
83619
- if (patched) installationOrgSlug = patched.organizationSlug;
83620
- }
83621
83832
  if (localDevId) {
83622
83833
  const installId = localDevId;
83623
83834
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -83633,7 +83844,7 @@ var buildHandler$2 = (ctx, flags) => protectedCommand(async (innerCtx) => {
83633
83844
  }, innerCtx.logger);
83634
83845
  await republishPointer();
83635
83846
  }
83636
- const studioBaseUrl = resolveStudioBaseUrl(innerCtx.apiUrl);
83847
+ const studioBaseUrl = resolveStudioBaseUrl(innerCtx.apiUrl, innerCtx.env);
83637
83848
  const linkTo = (screenPath) => studioDeeplink({
83638
83849
  studioBaseUrl,
83639
83850
  orgSlug: installationOrgSlug,
@@ -83697,15 +83908,7 @@ var buildHandler$2 = (ctx, flags) => protectedCommand(async (innerCtx) => {
83697
83908
  const sigintHandler = () => {
83698
83909
  if (heartbeatTimer) clearInterval(heartbeatTimer);
83699
83910
  shutdownAndExit(async () => {
83700
- const patch = localDevId ? patchLocalDevInstallation(innerCtx, localDevId, { localTunnelUrl: null }).catch((err) => {
83701
- innerCtx.logger.debug({ err: err.message }, "failed to null local-dev tunnel URL on exit (safety net will catch up)");
83702
- return null;
83703
- }) : Promise.resolve(null);
83704
- await Promise.allSettled([
83705
- patch,
83706
- session.stop(),
83707
- clearDevSession(projectRoot, innerCtx.logger)
83708
- ]);
83911
+ await Promise.allSettled([session.stop(), clearDevSession(projectRoot, innerCtx.logger)]);
83709
83912
  goodbye();
83710
83913
  }, { onForced: () => {
83711
83914
  if (!innerCtx.flags.json) log.warn("Shutdown did not finish in time — leaving anyway.");
@@ -84376,7 +84579,7 @@ var runEval = async (ctx, args, deps = {}) => {
84376
84579
  };
84377
84580
  const launched = await launch(callCtx, basePath, suite.id, body);
84378
84581
  const url = evalRunUrl({
84379
- studioBaseUrl: resolveStudioBaseUrl(ctx.apiUrl),
84582
+ studioBaseUrl: resolveStudioBaseUrl(ctx.apiUrl, ctx.env),
84380
84583
  orgSlug,
84381
84584
  installationId,
84382
84585
  evalRunId: launched.id
@@ -86381,16 +86584,29 @@ var parseLimit = (raw) => {
86381
86584
  * a relative duration (`15m`, `2h`, `30s`, `1d`) measured back from `now`, or an
86382
86585
  * absolute ISO timestamp. Returns `undefined` when the flag is absent. A value
86383
86586
  * that is neither a known duration nor a parseable date is a CLI-side error.
86587
+ *
86588
+ * A bare number is rejected before `Date.parse`, which would read it as a date
86589
+ * (`5` is April 2001, `2026` is 2026-01-01) and leave the window filtering
86590
+ * nothing. It is a duration with the unit forgotten.
86384
86591
  */
86385
86592
  var parseTimeBound = (flag, raw, now) => {
86386
86593
  if (raw === void 0) return void 0;
86387
- const duration = /^(\d+)(s|m|h|d)$/.exec(raw.trim());
86594
+ const trimmed = raw.trim();
86595
+ const duration = /^(\d+)(s|m|h|d)$/.exec(trimmed);
86388
86596
  if (duration) return now - Number(duration[1]) * ({
86389
86597
  s: 1e3,
86390
86598
  m: 6e4,
86391
86599
  h: 36e5,
86392
86600
  d: 864e5
86393
86601
  }[duration[2] ?? ""] ?? 0);
86602
+ if (/^[+-]?(\d+\.?\d*|\.\d+)$/.test(trimmed)) {
86603
+ const example = /^\d+$/.test(trimmed) ? `${trimmed}m` : "15m";
86604
+ throw new StackboneCliError({
86605
+ code: "generic",
86606
+ message: `Invalid ${flag} '${raw}'. A duration needs a unit: s, m, h or d.`,
86607
+ suggestion: `Pass e.g. \`${flag} ${example}\`, or an ISO timestamp such as \`${flag} 2026-05-22T00:00:00Z\`.`
86608
+ });
86609
+ }
86394
86610
  const ms = Date.parse(raw);
86395
86611
  if (Number.isNaN(ms)) throw new StackboneCliError({
86396
86612
  code: "generic",
@@ -88357,7 +88573,7 @@ var buildRootCommand = (ctx) => defineCommand({
88357
88573
  meta: {
88358
88574
  name: "stackbone",
88359
88575
  version: STACKBONE_CLI_BUILD_VERSION,
88360
- description: "Marketplace + hosting for AI agents — see `stackbone docs`."
88576
+ description: "Build, run and deploy Stackbone agents and workflows. See `stackbone docs`."
88361
88577
  },
88362
88578
  args: {
88363
88579
  json: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackbone/cli",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "bin": {
Binary file
Binary file