@stackbone/cli 0.1.0-alpha.2 → 0.1.0-alpha.3

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,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.0-alpha.3] - 2026-05-15
11
+
12
+ ### Added
13
+
14
+ - `stackbone dev` sessions are now first-class `installation` rows
15
+ (`kind='local'`). The CLI registers the row via
16
+ `POST /api/v1/installations/local-dev` during `init`/`link`, `PATCH`es
17
+ `local_tunnel_url` on dev start, and nulls it on Ctrl-C. The dev
18
+ boot banner emits a path-aware deeplink
19
+ (`app.stackbone.ai/app/<orgSlug>/installations/<id>?stackbone-dev=<tunnel>`)
20
+ consumed in one shot by the `stackboneDevBootstrapGuard` so Studio
21
+ opens straight on the local installation. Local-dev rows skip the
22
+ provisioning saga and the tier-quota gate by construction and are
23
+ garbage-collected after 7 days of inactivity.
24
+
25
+ ### Changed
26
+
27
+ - Unified agent runtime env contract under `STACKBONE_*`. The dev
28
+ emulator now injects `STACKBONE_AGENT_ID` (the agent slug) plus
29
+ `STACKBONE_S3_ACCESS_KEY` / `STACKBONE_S3_SECRET_KEY` /
30
+ `STACKBONE_S3_ENDPOINT` / `STACKBONE_S3_BUCKET` /
31
+ `STACKBONE_S3_REGION` / `STACKBONE_POSTGRES_URL` into the agent
32
+ process, replacing the previous mix of `AWS_*` / `S3_*` /
33
+ `DATABASE_URL` names. Agents that still read the legacy names must
34
+ migrate.
35
+ - Emulator `/api/schema` now returns `{ input: null, output: null }`
36
+ (200) when the manifest declares neither schema or the agent is
37
+ unreachable — was 502 before. Mirrors the cloud `/studio/schema`
38
+ shape so the Studio Playground UI can boot before the agent is
39
+ healthy.
40
+ - `dev/fixtures-store.ts` persists JSONL records using the canonical
41
+ `Fixture` wire schema (camelCase `createdAt`, typed as `Fixture`) so
42
+ list/create return the wire type directly.
43
+
44
+ ### Fixed
45
+
46
+ - `protectedCommand` no longer triggers the device-code flow on the
47
+ user's behalf. When no valid session exists it fails fast with
48
+ `code: 'auth'` and the standard "Run stackbone login" hint, and it
49
+ no longer retries on a mid-flight 401. Reason: the HTTP adapter
50
+ caches `tokenStore.activeSession()` once per process, so the
51
+ pre-login `POST /api/auth/device/code` poisoned the cache with
52
+ `null` and every subsequent request from the same process went out
53
+ without an Authorization header.
54
+
10
55
  ## [0.1.0-alpha.2] - 2026-05-12
11
56
 
12
57
  ### Added
package/main.js CHANGED
@@ -82,6 +82,7 @@ var installationStatusSchema = z.enum([
82
82
  "deprovisioning",
83
83
  "deprovisioned"
84
84
  ]);
85
+ var installationKindSchema = z.enum(["cloud", "local"]);
85
86
  var installationProviderSchema = z.enum([
86
87
  "neon",
87
88
  "r2",
@@ -109,17 +110,30 @@ z.object({
109
110
  slug: organizationSlugSchema,
110
111
  id: z.uuid()
111
112
  });
112
- z.object({
113
+ var installationResponseSchema = z.object({
113
114
  id: z.uuid(),
114
115
  organizationId: z.uuid(),
115
116
  agentId: z.uuid(),
116
117
  agentVersion: z.string(),
117
118
  status: installationStatusSchema,
119
+ kind: installationKindSchema.default("cloud"),
120
+ localTunnelUrl: z.url().nullable().optional(),
121
+ lastActiveAt: z.iso.datetime().nullable().optional(),
118
122
  config: jsonObject(),
119
123
  createdByUserId: z.uuid(),
120
124
  createdAt: z.iso.datetime(),
121
125
  updatedAt: z.iso.datetime()
122
126
  });
127
+ z.object({
128
+ creatorOrganizationSlug: organizationSlugSchema,
129
+ agentTemplateSlug: z.string().min(1).max(80),
130
+ endUserOrganizationSlug: organizationSlugSchema.optional()
131
+ });
132
+ z.object({ localTunnelUrl: z.url().nullable() });
133
+ var localDevInstallationResponseSchema = installationResponseSchema.extend({
134
+ organizationSlug: organizationSlugSchema,
135
+ agentSlug: z.string().min(1).max(80)
136
+ });
123
137
  z.object({
124
138
  id: z.uuid(),
125
139
  installationId: z.uuid(),
@@ -468,6 +482,11 @@ z.object({
468
482
  nextCursor: z.string().nullable(),
469
483
  prevCursor: z.string().nullable()
470
484
  });
485
+ z.object({ fxtId: z.uuid() });
486
+ z.object({
487
+ input: jsonObject().nullable(),
488
+ output: jsonObject().nullable()
489
+ });
471
490
  //#endregion
472
491
  //#region ../../libs/validators/src/lib/auth/session.ts
473
492
  var sessionUserSchema = z.object({
@@ -931,12 +950,22 @@ var credentialsSchema = z.object({
931
950
  * Project-local config is written by `stackbone init`/`link` to
932
951
  * `<cwd>/.stackbone/project.json`. Tracks which agent in which organization this
933
952
  * directory points at. Excluded from git by the same `.gitignore` patch.
953
+ *
954
+ * Feature 42: `localDevInstallationId` + `organizationSlug` + `agentSlug` are
955
+ * captured at `init`/`link` time so `stackbone dev` can build the path-aware
956
+ * deeplink (`/app/<orgSlug>/installations/<id>?stackbone-dev=…`) and patch
957
+ * the tunnel URL without a separate `GET` round-trip. They are optional so
958
+ * old project.json files continue to load — a future `stackbone dev` against
959
+ * a pre-feature-42 directory simply re-registers the local-dev row.
934
960
  */
935
961
  var projectConfigSchema = z.object({
936
962
  schemaVersion: z.literal(1),
937
963
  organizationId: z.uuid(),
938
964
  agentId: z.uuid(),
939
- controlPlaneUrl: z.url()
965
+ controlPlaneUrl: z.url(),
966
+ localDevInstallationId: z.uuid().optional(),
967
+ organizationSlug: z.string().optional(),
968
+ agentSlug: z.string().optional()
940
969
  });
941
970
  /**
942
971
  * Project-shared config at `<cwd>/stackbone.config.json` (committable, unlike
@@ -1263,7 +1292,8 @@ var createHttpAdapter = (opts) => {
1263
1292
  };
1264
1293
  return {
1265
1294
  get: (path) => request("GET", path),
1266
- post: (path, body) => request("POST", path, body)
1295
+ post: (path, body) => request("POST", path, body),
1296
+ patch: (path, body) => request("PATCH", path, body)
1267
1297
  };
1268
1298
  };
1269
1299
  //#endregion
@@ -1361,7 +1391,6 @@ var flagValue = (argv, flag) => {
1361
1391
  var parseFlags = (argv, env) => ({
1362
1392
  json: detectJsonMode(argv, env),
1363
1393
  yes: flagPresent(argv, "-y") || flagPresent(argv, "--yes"),
1364
- autoLogin: env["STACKBONE_AUTO_LOGIN"] !== "0" && !flagPresent(argv, "--no-auto-login"),
1365
1394
  apiUrlOverride: flagValue(argv, "--api-url") ?? env["STACKBONE_API_URL"] ?? null,
1366
1395
  verbose: flagPresent(argv, "--verbose") || env["STACKBONE_VERBOSE"] === "1"
1367
1396
  });
@@ -1422,7 +1451,7 @@ var isVersionFlag = (rawArgs) => rawArgs.length === 1 && VERSION_FLAGS.has(rawAr
1422
1451
  var formatVersionOutput = (input) => `stackbone-cli ${input.cliVersion}\ncontract ${input.contractVersion}`;
1423
1452
  //#endregion
1424
1453
  //#region src/dev/contract.ts
1425
- var STACKBONE_CLI_BUILD_VERSION = "0.1.0-alpha.2";
1454
+ var STACKBONE_CLI_BUILD_VERSION = "0.1.0-alpha.3";
1426
1455
  /**
1427
1456
  * Capabilities the local emulator advertises on `GET /api/contract`. Typed as
1428
1457
  * `readonly Capability[]` so the compiler refuses any string outside the
@@ -1743,55 +1772,25 @@ var clearSession = async (ctx) => {
1743
1772
  };
1744
1773
  //#endregion
1745
1774
  //#region src/commands/_shared/protected.ts
1746
- var isAuthError = (err) => isStackboneCliError(err) && err.code === "auth";
1747
- var refuseWithAuthError = () => {
1748
- throw new StackboneCliError({
1749
- code: "auth",
1750
- message: "Not authenticated",
1751
- suggestion: "Run `stackbone login`"
1752
- });
1753
- };
1754
- var ensureSession = async (ctx) => {
1755
- const existing = await getCurrentSession(ctx);
1756
- if (existing) return existing;
1757
- if (!ctx.flags.autoLogin) refuseWithAuthError();
1758
- await runLoginFlow(ctx);
1759
- return await getCurrentSession(ctx) ?? refuseWithAuthError();
1760
- };
1761
1775
  /**
1762
1776
  * Wraps a command handler so it always receives a non-null `Session`.
1763
1777
  *
1764
- * If the credentials store is empty or the session expired:
1765
- * - With auto-login enabled (default): runs the device-code flow inline,
1766
- * reloads the session, and runs the handler.
1767
- * - With auto-login disabled (`STACKBONE_AUTO_LOGIN=0` or `--no-auto-login`):
1768
- * refuses immediately with `code: 'auth'` and exit 2.
1769
- *
1770
- * Retries once if the handler itself throws an auth error mid-flight
1771
- * (token expired between the pre-check and the request). If the retry
1772
- * also fails with auth, the corrupt credentials are dropped from disk so
1773
- * the next invocation does not loop through the same broken session.
1778
+ * If the credentials store is empty or the session has expired, refuses
1779
+ * immediately with `code: 'auth'` (exit 2) and points the user at
1780
+ * `stackbone login`. Auth flow and command flow are kept strictly separate
1781
+ * protected commands never trigger the device-code dance on the user's
1782
+ * behalf, and a 401 mid-flight propagates as-is so the same `stackbone
1783
+ * login` instruction is the only path back.
1774
1784
  */
1775
1785
  var protectedCommand = (handler) => {
1776
1786
  return async (ctx) => {
1777
- const session = await ensureSession(ctx);
1778
- try {
1779
- await handler(ctx, session);
1780
- } catch (err) {
1781
- if (!isAuthError(err) || !ctx.flags.autoLogin) throw err;
1782
- ctx.logger.debug("auth error mid-flight, retrying after re-login");
1783
- await runLoginFlow(ctx);
1784
- const refreshed = await getCurrentSession(ctx) ?? refuseWithAuthError();
1785
- try {
1786
- await handler(ctx, refreshed);
1787
- } catch (retryErr) {
1788
- if (isAuthError(retryErr)) {
1789
- ctx.logger.warn("auth still failing after re-login, clearing the stored session");
1790
- await clearSession(ctx);
1791
- }
1792
- throw retryErr;
1793
- }
1794
- }
1787
+ const session = await getCurrentSession(ctx);
1788
+ if (!session) throw new StackboneCliError({
1789
+ code: "auth",
1790
+ message: "Not authenticated",
1791
+ suggestion: "Run `stackbone login`"
1792
+ });
1793
+ await handler(ctx, session);
1795
1794
  };
1796
1795
  };
1797
1796
  //#endregion
@@ -2614,7 +2613,7 @@ var buildMigrateStatusCommand = (ctx) => defineCommand({
2614
2613
  env: ctx.env,
2615
2614
  json: ctx.flags.json
2616
2615
  });
2617
- })({})
2616
+ })()
2618
2617
  });
2619
2618
  /**
2620
2619
  * Pure handler for `stackbone db migrate add-rag`. Reads `agent.yaml` to
@@ -2702,7 +2701,7 @@ var buildStudioCommand = (ctx) => defineCommand({
2702
2701
  env: ctx.env,
2703
2702
  json: ctx.flags.json
2704
2703
  });
2705
- })({})
2704
+ })()
2706
2705
  });
2707
2706
  var createDbCommand = (ctx) => defineCommand({
2708
2707
  meta: {
@@ -2747,7 +2746,6 @@ var createDeployCommand = (ctx) => buildStubCommand(ctx, {
2747
2746
  });
2748
2747
  //#endregion
2749
2748
  //#region src/dev/boot-banner.ts
2750
- var STUDIO_CLOUD_BASE_URL = "https://app.stackbone.ai/app";
2751
2749
  var ANSI = {
2752
2750
  reset: "\x1B[0m",
2753
2751
  bold: "\x1B[1m",
@@ -2757,7 +2755,6 @@ var ANSI = {
2757
2755
  };
2758
2756
  var ANSI_RE = /\x1b\[[0-9;]*m/g;
2759
2757
  var visibleLength = (s) => s.replace(ANSI_RE, "").length;
2760
- var buildDeeplink = (tunnelUrl) => `${STUDIO_CLOUD_BASE_URL}?stackbone-dev=${encodeURIComponent(tunnelUrl)}`;
2761
2758
  var LABEL_WIDTH = 11;
2762
2759
  var renderRow = (row, colors) => {
2763
2760
  const marker = colors && row.marker.trim() ? `${ANSI.cyan}${row.marker}${ANSI.reset}` : row.marker;
@@ -2768,11 +2765,11 @@ var TITLE = "stackbone studio";
2768
2765
  var formatBootBanner = (input) => {
2769
2766
  const colors = input.colors ?? false;
2770
2767
  const rows = [];
2771
- if (input.tunnelUrl) {
2768
+ if (input.deeplink && input.tunnelUrl) {
2772
2769
  rows.push({
2773
2770
  marker: "▸",
2774
2771
  label: "Open Studio",
2775
- value: buildDeeplink(input.tunnelUrl),
2772
+ value: input.deeplink,
2776
2773
  primary: true
2777
2774
  });
2778
2775
  rows.push({
@@ -2790,6 +2787,23 @@ var formatBootBanner = (input) => {
2790
2787
  label: "Local",
2791
2788
  value: input.localUrl
2792
2789
  });
2790
+ } else if (input.tunnelUrl) {
2791
+ rows.push({
2792
+ marker: "▸",
2793
+ label: "Tunnel",
2794
+ value: input.tunnelUrl,
2795
+ primary: true
2796
+ });
2797
+ rows.push({
2798
+ marker: " ",
2799
+ label: "",
2800
+ value: ""
2801
+ });
2802
+ rows.push({
2803
+ marker: " ",
2804
+ label: "Local",
2805
+ value: input.localUrl
2806
+ });
2793
2807
  } else rows.push({
2794
2808
  marker: "▸",
2795
2809
  label: "Local",
@@ -2973,6 +2987,7 @@ var fetchActiveConfigValue = async (client) => {
2973
2987
  //#region src/dev/connections.ts
2974
2988
  var STACKBONE_ENV_KEYS = {
2975
2989
  contractVersion: "STACKBONE_CONTRACT_VERSION",
2990
+ agentId: "STACKBONE_AGENT_ID",
2976
2991
  postgresUrl: "STACKBONE_POSTGRES_URL",
2977
2992
  queueUrl: "STACKBONE_QUEUE_URL",
2978
2993
  s3Endpoint: "STACKBONE_S3_ENDPOINT",
@@ -2987,6 +3002,7 @@ var buildAgentEnv = (base, injection, options = {}) => {
2987
3002
  const env = {
2988
3003
  ...base,
2989
3004
  [STACKBONE_ENV_KEYS.contractVersion]: String(10),
3005
+ [STACKBONE_ENV_KEYS.agentId]: injection.agentId,
2990
3006
  [STACKBONE_ENV_KEYS.postgresUrl]: injection.services.postgresUrl,
2991
3007
  [STACKBONE_ENV_KEYS.queueUrl]: injection.services.queueUrl,
2992
3008
  [STACKBONE_ENV_KEYS.s3Endpoint]: injection.services.s3Endpoint,
@@ -4142,7 +4158,7 @@ var quoteIdent = (raw) => `"${raw.replace(/"/g, "\"\"")}"`;
4142
4158
  //#region src/dev/fixtures-store.ts
4143
4159
  var defaultFixturesDir = () => resolve(homedir(), ".stackbone", "dev", "fixtures");
4144
4160
  var filenameFor = (baseDir, agentSlug) => resolve(baseDir, `${agentSlug}.jsonl`);
4145
- var isSerializedFixture = (value) => !!value && typeof value === "object" && typeof value.id === "string" && typeof value.name === "string" && typeof value.created_at === "string";
4161
+ var isSerializedFixture = (value) => !!value && typeof value === "object" && typeof value.id === "string" && typeof value.name === "string" && typeof value.createdAt === "string";
4146
4162
  var readJsonl = async (path) => {
4147
4163
  let raw;
4148
4164
  try {
@@ -4169,7 +4185,7 @@ var createFixturesStore = (opts) => {
4169
4185
  };
4170
4186
  return {
4171
4187
  async list() {
4172
- return (await readJsonl(path)).sort((a, b) => a.created_at < b.created_at ? 1 : -1);
4188
+ return (await readJsonl(path)).sort((a, b) => a.createdAt < b.createdAt ? 1 : -1);
4173
4189
  },
4174
4190
  async create(input) {
4175
4191
  await ensureDir();
@@ -4179,7 +4195,7 @@ var createFixturesStore = (opts) => {
4179
4195
  name: input.name,
4180
4196
  input: input.input,
4181
4197
  description: input.description ?? null,
4182
- created_at: (/* @__PURE__ */ new Date()).toISOString()
4198
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
4183
4199
  };
4184
4200
  await appendFile(path, `${JSON.stringify(fixture)}\n`, "utf8");
4185
4201
  return fixture;
@@ -5426,22 +5442,25 @@ var buildEmulatorApp = (opts) => {
5426
5442
  app.get("/api/logs/stream", (c) => streamSSE(c, (stream) => {
5427
5443
  return runLogTail(stream, parseLogFilters(new URL(c.req.url).searchParams), (emit) => tailJsonlDirectory(logsDir, emit));
5428
5444
  }));
5429
- const proxyAgentJson = async (path) => {
5445
+ app.get("/api/schema", async (c) => {
5430
5446
  try {
5431
- const resp = await fetch(`${agentBaseUrl}${path}`);
5432
- const body = await resp.text();
5433
- return new Response(body, {
5434
- status: resp.status,
5435
- headers: { "content-type": "application/json" }
5447
+ const resp = await fetch(`${agentBaseUrl}/schema`);
5448
+ if (!resp.ok) return c.json({
5449
+ input: null,
5450
+ output: null
5451
+ });
5452
+ const raw = await resp.json().catch(() => null);
5453
+ return c.json({
5454
+ input: extractSchemaField(raw, "input"),
5455
+ output: extractSchemaField(raw, "output")
5436
5456
  });
5437
5457
  } catch {
5438
- return new Response(JSON.stringify({ error: "agent_unreachable" }), {
5439
- status: 502,
5440
- headers: { "content-type": "application/json" }
5458
+ return c.json({
5459
+ input: null,
5460
+ output: null
5441
5461
  });
5442
5462
  }
5443
- };
5444
- app.get("/api/schema", () => proxyAgentJson("/schema"));
5463
+ });
5445
5464
  const dbExplorerOptions = { connectionString: opts.injection.services.postgresUrl };
5446
5465
  app.get("/api/db/schemas", async (c) => {
5447
5466
  const response = await listSchemas(dbExplorerOptions);
@@ -6044,6 +6063,12 @@ var buildEmulatorApp = (opts) => {
6044
6063
  }));
6045
6064
  return app;
6046
6065
  };
6066
+ var extractSchemaField = (raw, field) => {
6067
+ if (!raw) return null;
6068
+ const value = raw[field];
6069
+ if (value && typeof value === "object" && !Array.isArray(value)) return value;
6070
+ return null;
6071
+ };
6047
6072
  var eventMatchesRunId = (event, runId) => {
6048
6073
  const data = event.data;
6049
6074
  if (!data || typeof data["run_id"] === "undefined") return true;
@@ -7176,6 +7201,7 @@ var startDevSession = async (input) => {
7176
7201
  const bus = createEventBus();
7177
7202
  const agentPort = await pickFreePort();
7178
7203
  const injection = {
7204
+ agentId: input.agent.slug,
7179
7205
  agentPort,
7180
7206
  services: DEFAULT_DEV_SERVICES
7181
7207
  };
@@ -7503,6 +7529,24 @@ var startDevSession = async (input) => {
7503
7529
  }
7504
7530
  };
7505
7531
  //#endregion
7532
+ //#region src/services/local-dev-installations.service.ts
7533
+ var parse$1 = (raw, context) => {
7534
+ const parsed = localDevInstallationResponseSchema.safeParse(raw);
7535
+ if (!parsed.success) throw new StackboneCliError({
7536
+ code: "generic",
7537
+ message: `Unexpected response shape from ${context}`,
7538
+ cause: parsed.error
7539
+ });
7540
+ return parsed.data;
7541
+ };
7542
+ var upsertLocalDevInstallation = async (ctx, body) => {
7543
+ return parse$1(await ctx.http.post("/api/v1/installations/local-dev", body), "POST /api/v1/installations/local-dev");
7544
+ };
7545
+ var patchLocalDevInstallation = async (ctx, id, body) => {
7546
+ const path = `/api/v1/installations/local-dev/${encodeURIComponent(id)}`;
7547
+ return parse$1(await ctx.http.patch(path, body), `PATCH ${path}`);
7548
+ };
7549
+ //#endregion
7506
7550
  //#region src/commands/dev/print-contract.ts
7507
7551
  var defaultWriteStdout = (chunk) => {
7508
7552
  process.stdout.write(chunk);
@@ -7599,15 +7643,37 @@ var buildHandler$2 = (ctx, flags) => protectedCommand(async (innerCtx) => {
7599
7643
  name: agent.name
7600
7644
  },
7601
7645
  emulatorPort: flags.port,
7602
- tunnel: flags.tunnel,
7646
+ tunnel: true,
7603
7647
  listen: flags.listen,
7604
7648
  verbose: flags.verbose,
7605
7649
  ...renderStage ? { onStage: renderStage } : {}
7606
7650
  });
7651
+ const activeOrg = await getActiveOrganization(innerCtx);
7652
+ let localDevId = config.localDevInstallationId;
7653
+ let installationOrgSlug = config.organizationSlug ?? activeOrg.slug;
7654
+ if (!localDevId) {
7655
+ const upserted = await upsertLocalDevInstallation(innerCtx, {
7656
+ creatorOrganizationSlug: activeOrg.slug,
7657
+ agentTemplateSlug: agent.slug
7658
+ });
7659
+ localDevId = upserted.id;
7660
+ installationOrgSlug = upserted.organizationSlug;
7661
+ }
7662
+ if (session.publicUrl) {
7663
+ const patched = await patchLocalDevInstallation(innerCtx, localDevId, { localTunnelUrl: session.publicUrl }).catch((err) => {
7664
+ innerCtx.logger.warn({ err: err.message }, "failed to PATCH local-dev tunnel URL (continuing)");
7665
+ return null;
7666
+ });
7667
+ if (patched) installationOrgSlug = patched.organizationSlug;
7668
+ }
7669
+ const studioBaseUrl = innerCtx.apiUrl.includes("localhost") ? "http://localhost:4200/app" : "https://app.stackbone.ai/app";
7670
+ const deeplink = session.publicUrl ? `${studioBaseUrl}/${installationOrgSlug}/installations/${localDevId}?stackbone-dev=${encodeURIComponent(session.publicUrl)}` : null;
7607
7671
  if (innerCtx.flags.json) emit(innerCtx.flags, () => "", {
7608
7672
  emulator_url: session.emulatorUrl,
7609
7673
  agent_url: session.agentUrl,
7610
7674
  public_url: session.publicUrl,
7675
+ deeplink,
7676
+ local_dev_installation_id: localDevId,
7611
7677
  services: {
7612
7678
  postgres_url: session.injection.services.postgresUrl,
7613
7679
  queue_url: session.injection.services.queueUrl,
@@ -7619,6 +7685,7 @@ var buildHandler$2 = (ctx, flags) => protectedCommand(async (innerCtx) => {
7619
7685
  const inlineLines = formatBootBanner({
7620
7686
  tunnelUrl: session.publicUrl,
7621
7687
  localUrl: session.emulatorUrl,
7688
+ deeplink,
7622
7689
  contractVersion: 10,
7623
7690
  capabilityCount: EMULATOR_CAPABILITIES.length,
7624
7691
  agentName: agent.name,
@@ -7630,7 +7697,13 @@ var buildHandler$2 = (ctx, flags) => protectedCommand(async (innerCtx) => {
7630
7697
  log.step("Press Ctrl-C to stop");
7631
7698
  }
7632
7699
  const sigintHandler = () => {
7633
- session.stop();
7700
+ (async () => {
7701
+ if (localDevId) await patchLocalDevInstallation(innerCtx, localDevId, { localTunnelUrl: null }).catch((err) => {
7702
+ innerCtx.logger.debug({ err: err.message }, "failed to null local-dev tunnel URL on exit (safety net will catch up)");
7703
+ return null;
7704
+ });
7705
+ await session.stop();
7706
+ })();
7634
7707
  };
7635
7708
  process.once("SIGINT", sigintHandler);
7636
7709
  process.once("SIGTERM", sigintHandler);
@@ -7648,12 +7721,6 @@ var createDevCommand = (ctx) => defineCommand({
7648
7721
  description: `Emulator port. Defaults to ${DEFAULT_EMULATOR_PORT}.`,
7649
7722
  default: String(DEFAULT_EMULATOR_PORT)
7650
7723
  },
7651
- tunnel: {
7652
- type: "boolean",
7653
- description: "Open a public tunnel to the emulator (default: on).",
7654
- negativeDescription: "Skip the public tunnel and serve the emulator only on localhost.",
7655
- default: true
7656
- },
7657
7724
  listen: {
7658
7725
  type: "boolean",
7659
7726
  description: "Bind the HTTP server to 0.0.0.0 (reachable from your LAN). Default binds to 127.0.0.1 only.",
@@ -7683,7 +7750,6 @@ var createDevCommand = (ctx) => defineCommand({
7683
7750
  });
7684
7751
  await buildHandler$2(ctx, {
7685
7752
  port,
7686
- tunnel: Boolean(a.tunnel),
7687
7753
  listen: Boolean(a.listen),
7688
7754
  verbose: Boolean(a.verbose)
7689
7755
  });
@@ -7720,10 +7786,6 @@ several exploratory calls.
7720
7786
  - \`-y, --yes\`
7721
7787
  Skip every confirmation prompt. Required in CI / non-TTY contexts.
7722
7788
 
7723
- - \`--no-auto-login\` (or \`STACKBONE_AUTO_LOGIN=0\`)
7724
- When a protected command finds no valid session, fail fast with exit
7725
- code 2 instead of triggering the device-code flow inline.
7726
-
7727
7789
  ## Exit codes
7728
7790
 
7729
7791
  - \`0\` ok
@@ -8016,7 +8078,6 @@ Branch on these instead of parsing stack traces.
8016
8078
 
8017
8079
  - \`STACKBONE_JSON=1\` — same as \`--json\`
8018
8080
  - \`STACKBONE_API_URL\` — same as \`--api-url <url>\`
8019
- - \`STACKBONE_AUTO_LOGIN=0\` — same as \`--no-auto-login\`
8020
8081
  - \`STACKBONE_LOG_LEVEL\` — \`debug\`, \`info\`, \`warn\`, \`error\` (logger goes
8021
8082
  to stderr, never pollutes the JSON payload on stdout)
8022
8083
 
@@ -8161,12 +8222,15 @@ var writeTemplateFiles = async (files, input) => {
8161
8222
  var writeAgentManifest = async (agentName, input) => {
8162
8223
  await writeFileSafe(join(input.root, "agent.yaml"), renderAgentYaml({ name: agentName }), input.force);
8163
8224
  };
8164
- var writeProjectConfig = async (ctx, agent, input) => {
8225
+ var writeProjectConfig = async (ctx, agent, input, extras = {}) => {
8165
8226
  const config = {
8166
8227
  schemaVersion: 1,
8167
8228
  organizationId: agent.organizationId,
8168
8229
  agentId: agent.id,
8169
- controlPlaneUrl: ctx.apiUrl
8230
+ controlPlaneUrl: ctx.apiUrl,
8231
+ ...extras.localDevInstallationId !== void 0 ? { localDevInstallationId: extras.localDevInstallationId } : {},
8232
+ ...extras.organizationSlug !== void 0 ? { organizationSlug: extras.organizationSlug } : {},
8233
+ ...extras.agentSlug !== void 0 ? { agentSlug: extras.agentSlug } : {}
8170
8234
  };
8171
8235
  await mkdir(join(input.root, ".stackbone"), { recursive: true });
8172
8236
  await writeFile(join(input.root, ".stackbone", "project.json"), JSON.stringify(config, null, 2));
@@ -8500,7 +8564,7 @@ var resolveInputs = async (ctx, flags, namePositional) => {
8500
8564
  targetDir
8501
8565
  };
8502
8566
  };
8503
- var writeProject = async (ctx, resolved, agent, force) => {
8567
+ var writeProject = async (ctx, resolved, agent, force, localDev) => {
8504
8568
  const writeInput = {
8505
8569
  root: resolved.targetDir,
8506
8570
  force
@@ -8512,7 +8576,11 @@ var writeProject = async (ctx, resolved, agent, force) => {
8512
8576
  });
8513
8577
  await writeTemplateFiles(templateFiles, writeInput);
8514
8578
  await writeAgentManifest(resolved.agentName, writeInput);
8515
- const projectConfig = await writeProjectConfig(ctx, agent, writeInput);
8579
+ const projectConfig = await writeProjectConfig(ctx, agent, writeInput, {
8580
+ localDevInstallationId: localDev.id,
8581
+ organizationSlug: localDev.organizationSlug,
8582
+ agentSlug: localDev.agentSlug
8583
+ });
8516
8584
  await ensureGitignore(resolved.targetDir);
8517
8585
  const skillFiles = await writeSkillFiles(ctx, agent, projectConfig, writeInput);
8518
8586
  return { filesWritten: [
@@ -8535,13 +8603,22 @@ var buildHandler$1 = (ctx, args) => protectedCommand(async (innerCtx) => {
8535
8603
  const resolved = await resolveInputs(innerCtx, flags, args.name);
8536
8604
  await ensureEmptyOrForce(resolved.targetDir, flags.force);
8537
8605
  if (!innerCtx.flags.json) log.step(`Creating "${resolved.agentName}" (template: ${resolved.templateId})`);
8606
+ const activeOrg = await getActiveOrganization(innerCtx);
8538
8607
  const agent = await createAgent(innerCtx, {
8539
- organizationSlug: (await getActiveOrganization(innerCtx)).slug,
8608
+ organizationSlug: activeOrg.slug,
8540
8609
  name: resolved.agentName,
8541
8610
  ...resolved.agentSlug !== void 0 ? { slug: resolved.agentSlug } : {},
8542
8611
  ...resolved.description !== void 0 ? { description: resolved.description } : {}
8543
8612
  });
8544
- const { filesWritten } = await writeProject(innerCtx, resolved, agent, flags.force);
8613
+ const localDev = await upsertLocalDevInstallation(innerCtx, {
8614
+ creatorOrganizationSlug: activeOrg.slug,
8615
+ agentTemplateSlug: agent.slug
8616
+ });
8617
+ const { filesWritten } = await writeProject(innerCtx, resolved, agent, flags.force, {
8618
+ id: localDev.id,
8619
+ organizationSlug: localDev.organizationSlug,
8620
+ agentSlug: localDev.agentSlug
8621
+ });
8545
8622
  emit(innerCtx.flags, () => "", {
8546
8623
  agent: {
8547
8624
  id: agent.id,
@@ -8549,6 +8626,10 @@ var buildHandler$1 = (ctx, args) => protectedCommand(async (innerCtx) => {
8549
8626
  name: agent.name
8550
8627
  },
8551
8628
  organization_id: agent.organizationId,
8629
+ local_dev_installation: {
8630
+ id: localDev.id,
8631
+ organization_slug: localDev.organizationSlug
8632
+ },
8552
8633
  target_dir: resolved.targetDir,
8553
8634
  template: resolved.templateId,
8554
8635
  files_written: filesWritten
@@ -8637,11 +8718,20 @@ var buildHandler = (ctx, args) => protectedCommand(async (innerCtx) => {
8637
8718
  }
8638
8719
  chosenSlug = value;
8639
8720
  }
8640
- const agent = await getAgentByOwnerAndSlug(innerCtx, (await getActiveOrganization(innerCtx)).slug, chosenSlug);
8721
+ const activeOrg = await getActiveOrganization(innerCtx);
8722
+ const agent = await getAgentByOwnerAndSlug(innerCtx, activeOrg.slug, chosenSlug);
8641
8723
  if (!innerCtx.flags.json) log.step(`Linking ${targetDir} to ${agent.slug}`);
8724
+ const localDev = await upsertLocalDevInstallation(innerCtx, {
8725
+ creatorOrganizationSlug: activeOrg.slug,
8726
+ agentTemplateSlug: agent.slug
8727
+ });
8642
8728
  const projectConfig = await writeProjectConfig(innerCtx, agent, {
8643
8729
  root: targetDir,
8644
8730
  force: true
8731
+ }, {
8732
+ localDevInstallationId: localDev.id,
8733
+ organizationSlug: localDev.organizationSlug,
8734
+ agentSlug: localDev.agentSlug
8645
8735
  });
8646
8736
  await ensureGitignore(targetDir);
8647
8737
  const skillFiles = await writeSkillFiles(innerCtx, agent, projectConfig, {
@@ -8655,6 +8745,10 @@ var buildHandler = (ctx, args) => protectedCommand(async (innerCtx) => {
8655
8745
  name: agent.name
8656
8746
  },
8657
8747
  organization_id: agent.organizationId,
8748
+ local_dev_installation: {
8749
+ id: localDev.id,
8750
+ organization_slug: localDev.organizationSlug
8751
+ },
8658
8752
  target_dir: targetDir,
8659
8753
  files_written: [
8660
8754
  ".stackbone/project.json",
@@ -8883,11 +8977,6 @@ var buildRootCommand = async () => {
8883
8977
  "api-url": {
8884
8978
  type: "string",
8885
8979
  description: "Override the control plane URL."
8886
- },
8887
- "no-auto-login": {
8888
- type: "boolean",
8889
- description: "Refuse to start the device-code flow when no session exists.",
8890
- default: false
8891
8980
  }
8892
8981
  },
8893
8982
  subCommands: {
@@ -0,0 +1,22 @@
1
+ -- 0004_rename_runs_partial_index.sql — clarify semantics of the is_playground partial index.
2
+ -- ADR: docs/adr/2026-05-14-playground-runs-bill-like-real-invocations.md.
3
+ --
4
+ -- Migration 0003 created the partial index `runs_billable_workspace_started_idx`
5
+ -- on `stackbone_platform.runs (workspace_id, started_at DESC NULLS LAST)
6
+ -- WHERE is_playground = false`. Its name and accompanying comments framed the
7
+ -- predicate as the "billable" set — that framing is wrong.
8
+ --
9
+ -- Playground runs DO bill: invoking from Studio consumes real LLM tokens, real
10
+ -- DB queries and real compute, and the creator's organization is charged for
11
+ -- it like any other invocation (otherwise a malicious client could mark all
12
+ -- production traffic with `X-Stackbone-Playground: 1` and dodge billing). The
13
+ -- predicate `WHERE is_playground = false` actually identifies the rows that
14
+ -- count toward Trust Layer / public marketplace aggregations (p50 latency,
15
+ -- success rate, median cost) — a slow demo run from Studio must not lower
16
+ -- the public stats of an `agent_template`.
17
+ --
18
+ -- This migration renames the index so the next reader gets the semantics
19
+ -- right. The index definition (table, columns, predicate) is unchanged.
20
+
21
+ ALTER INDEX IF EXISTS stackbone_platform.runs_billable_workspace_started_idx
22
+ RENAME TO runs_public_metrics_workspace_started_idx;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackbone/cli",
3
- "version": "0.1.0-alpha.2",
3
+ "version": "0.1.0-alpha.3",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "bin": {
Binary file
Binary file