@superblocksteam/sdk 2.0.129 → 2.0.130-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/cli-replacement/automatic-upgrades.d.ts.map +1 -1
  3. package/dist/cli-replacement/automatic-upgrades.js +235 -42
  4. package/dist/cli-replacement/automatic-upgrades.js.map +1 -1
  5. package/dist/cli-replacement/automatic-upgrades.test.js +406 -3
  6. package/dist/cli-replacement/automatic-upgrades.test.js.map +1 -1
  7. package/dist/cli-replacement/dev.d.mts.map +1 -1
  8. package/dist/cli-replacement/dev.mjs +8 -9
  9. package/dist/cli-replacement/dev.mjs.map +1 -1
  10. package/dist/cli-replacement/home-npmrc.d.mts +6 -0
  11. package/dist/cli-replacement/home-npmrc.d.mts.map +1 -1
  12. package/dist/cli-replacement/home-npmrc.mjs +22 -0
  13. package/dist/cli-replacement/home-npmrc.mjs.map +1 -1
  14. package/dist/client.billing-usage.test.d.ts +2 -0
  15. package/dist/client.billing-usage.test.d.ts.map +1 -0
  16. package/dist/client.billing-usage.test.js +66 -0
  17. package/dist/client.billing-usage.test.js.map +1 -0
  18. package/dist/client.d.ts +41 -0
  19. package/dist/client.d.ts.map +1 -1
  20. package/dist/client.js +15 -1
  21. package/dist/client.js.map +1 -1
  22. package/dist/dev-utils/dev-server-metrics.boot.test.d.mts +2 -0
  23. package/dist/dev-utils/dev-server-metrics.boot.test.d.mts.map +1 -0
  24. package/dist/dev-utils/dev-server-metrics.boot.test.mjs +55 -0
  25. package/dist/dev-utils/dev-server-metrics.boot.test.mjs.map +1 -0
  26. package/dist/dev-utils/dev-server-metrics.d.mts +32 -0
  27. package/dist/dev-utils/dev-server-metrics.d.mts.map +1 -1
  28. package/dist/dev-utils/dev-server-metrics.mjs +60 -0
  29. package/dist/dev-utils/dev-server-metrics.mjs.map +1 -1
  30. package/dist/dev-utils/dev-server-metrics.test.mjs +18 -1
  31. package/dist/dev-utils/dev-server-metrics.test.mjs.map +1 -1
  32. package/dist/dev-utils/dev-server.d.mts +28 -2
  33. package/dist/dev-utils/dev-server.d.mts.map +1 -1
  34. package/dist/dev-utils/dev-server.mjs +68 -7
  35. package/dist/dev-utils/dev-server.mjs.map +1 -1
  36. package/dist/dev-utils/dev-server.status.test.mjs +28 -1
  37. package/dist/dev-utils/dev-server.status.test.mjs.map +1 -1
  38. package/dist/flag.d.ts +14 -0
  39. package/dist/flag.d.ts.map +1 -1
  40. package/dist/flag.js +17 -0
  41. package/dist/flag.js.map +1 -1
  42. package/dist/flag.test.d.ts +2 -0
  43. package/dist/flag.test.d.ts.map +1 -0
  44. package/dist/flag.test.js +54 -0
  45. package/dist/flag.test.js.map +1 -0
  46. package/dist/index.d.ts +2 -1
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +3 -0
  49. package/dist/index.js.map +1 -1
  50. package/dist/sdk.d.ts +3 -1
  51. package/dist/sdk.d.ts.map +1 -1
  52. package/dist/sdk.js +11 -1
  53. package/dist/sdk.js.map +1 -1
  54. package/dist/types/common.d.ts +1 -0
  55. package/dist/types/common.d.ts.map +1 -1
  56. package/dist/types/common.js.map +1 -1
  57. package/package.json +8 -8
  58. package/src/cli-replacement/automatic-upgrades.test.ts +556 -3
  59. package/src/cli-replacement/automatic-upgrades.ts +251 -54
  60. package/src/cli-replacement/dev.mts +9 -10
  61. package/src/cli-replacement/home-npmrc.mts +45 -0
  62. package/src/client.billing-usage.test.ts +73 -0
  63. package/src/client.ts +66 -1
  64. package/src/dev-utils/dev-server-metrics.boot.test.mts +83 -0
  65. package/src/dev-utils/dev-server-metrics.mts +65 -0
  66. package/src/dev-utils/dev-server-metrics.test.mts +24 -1
  67. package/src/dev-utils/dev-server.mts +96 -7
  68. package/src/dev-utils/dev-server.status.test.mts +35 -1
  69. package/src/flag.test.ts +63 -0
  70. package/src/flag.ts +24 -0
  71. package/src/index.ts +9 -0
  72. package/src/sdk.ts +20 -0
  73. package/src/types/common.ts +1 -0
  74. package/tsconfig.tsbuildinfo +1 -1
  75. package/turbo.json +2 -1
@@ -0,0 +1,83 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import type { devServerMetrics as DevServerMetricsSingleton } from "./dev-server-metrics.mjs";
4
+
5
+ /**
6
+ * Boot-completion-signal emission tests.
7
+ *
8
+ * These exercise the real `DevServerMetrics.recordBoot` / `flush` logic against
9
+ * a fake meter captured at the `getMeter()` boundary — i.e. we "listen" to
10
+ * exactly the metric increments and labels that would be flushed to the OTLP
11
+ * exporter, without standing up a network collector. The only substitutions are
12
+ * the two telemetry-bootstrap boundaries the module depends on
13
+ * (`isTelemetryInitialized`, `getMeter`).
14
+ */
15
+
16
+ type Add = { name: string; value: number; attrs: Record<string, unknown> };
17
+
18
+ const h = vi.hoisted(() => ({
19
+ adds: [] as Add[],
20
+ telemetryUp: true,
21
+ }));
22
+
23
+ vi.mock("@superblocksteam/telemetry", () => ({
24
+ isTelemetryInitialized: () => h.telemetryUp,
25
+ }));
26
+
27
+ vi.mock("../telemetry/index.js", () => ({
28
+ getMeter: () => ({
29
+ createCounter: (name: string) => ({
30
+ add: (value: number, attrs: Record<string, unknown>) =>
31
+ h.adds.push({ name, value, attrs }),
32
+ }),
33
+ createHistogram: () => ({ record: () => {} }),
34
+ }),
35
+ }));
36
+
37
+ const bootAdds = () => h.adds.filter((a) => a.name === "dev_server_boot_total");
38
+
39
+ describe("devServerMetrics.recordBoot (in-place-restart completion signal)", () => {
40
+ let devServerMetrics: typeof DevServerMetricsSingleton;
41
+
42
+ beforeEach(async () => {
43
+ h.adds = [];
44
+ h.telemetryUp = true;
45
+ // Fresh module (and fresh singleton) per test so the once-per-process
46
+ // boot guard does not leak across cases.
47
+ vi.resetModules();
48
+ ({ devServerMetrics } = await import("./dev-server-metrics.mjs"));
49
+ });
50
+
51
+ it("emits dev_server_boot_total once with the flip_restart boot kind", () => {
52
+ devServerMetrics.recordBoot("flip_restart");
53
+
54
+ const boots = bootAdds();
55
+ expect(boots).toHaveLength(1);
56
+ expect(boots[0].value).toBe(1);
57
+ expect(boots[0].attrs).toEqual({ boot_kind: "flip_restart" });
58
+ });
59
+
60
+ it("emits at most one boot per process even when called from multiple sites", () => {
61
+ // initializeTelemetry runs on cold boot, token hot-reload, and warm
62
+ // activation, and createDevServer may also call it — the boot signal must
63
+ // still increment exactly once for the process.
64
+ devServerMetrics.recordBoot("flip_restart");
65
+ devServerMetrics.recordBoot("flip_restart");
66
+ devServerMetrics.recordBoot(undefined);
67
+
68
+ expect(bootAdds()).toHaveLength(1);
69
+ });
70
+
71
+ it("buffers the boot when telemetry is down and replays it once on flush", () => {
72
+ h.telemetryUp = false;
73
+ devServerMetrics.recordBoot("flip_restart");
74
+ expect(bootAdds()).toHaveLength(0);
75
+
76
+ h.telemetryUp = true;
77
+ devServerMetrics.flush();
78
+
79
+ const boots = bootAdds();
80
+ expect(boots).toHaveLength(1);
81
+ expect(boots[0].attrs).toEqual({ boot_kind: "flip_restart" });
82
+ });
83
+ });
@@ -12,6 +12,7 @@
12
12
  * warm-standby observations are not silently dropped into the no-op meter.
13
13
  */
14
14
 
15
+ import { RESTART_REASON_FLIP_RESTART } from "@superblocksteam/library-shared/restart-contract";
15
16
  import { isTelemetryInitialized } from "@superblocksteam/telemetry";
16
17
 
17
18
  import { getMeter } from "../telemetry/index.js";
@@ -72,6 +73,29 @@ export function normalizeNpmErrorCodeTag(code: string | undefined): string {
72
73
  return KNOWN_NPM_ERROR_CODES.has(collapsed) ? collapsed : "other";
73
74
  }
74
75
 
76
+ /**
77
+ * Normalize the CLI parent's `SUPERBLOCKS_RESTART_REASON` env into a bounded
78
+ * `boot_kind` metric label for `dev_server_boot_total`:
79
+ * - absent/empty/whitespace -> `"cold_start"` (first boot or a non-restart
80
+ * respawn; the parent only stamps the reason on a post-flip respawn)
81
+ * - `RESTART_REASON_FLIP_RESTART` -> passes through (the post-flip in-place
82
+ * restart we want to prove actually booted)
83
+ * - anything else -> `"other"`
84
+ *
85
+ * The reason comes from process env, which an operator or a stale value could
86
+ * set to anything, so the unknown branch collapses to `"other"` to keep the
87
+ * `boot_kind` series count bounded (cardinality guard).
88
+ */
89
+ export function normalizeBootKindTag(
90
+ restartReason: string | undefined,
91
+ ): string {
92
+ const trimmed = restartReason?.trim();
93
+ if (!trimmed) return "cold_start";
94
+ return trimmed === RESTART_REASON_FLIP_RESTART
95
+ ? RESTART_REASON_FLIP_RESTART
96
+ : "other";
97
+ }
98
+
75
99
  /** Dev-server endpoints that produce per-request metrics. */
76
100
  export type DevServerEndpoint =
77
101
  | "_sb_health"
@@ -150,6 +174,10 @@ class DevServerMetrics {
150
174
  */
151
175
  private bufferedRecorders: Array<() => void> = [];
152
176
 
177
+ /** Set once `recordBoot` has been called, so the boot metric is emitted at
178
+ * most once per process regardless of how many init paths invoke it. */
179
+ private booted = false;
180
+
153
181
  /**
154
182
  * Routes a recorder to the live OTel meter when telemetry is up, or buffers
155
183
  * it for replay when telemetry has not yet initialized.
@@ -193,6 +221,43 @@ class DevServerMetrics {
193
221
  }
194
222
  }
195
223
 
224
+ /**
225
+ * Records a dev-server process boot, tagged by `boot_kind`.
226
+ *
227
+ * Emitted once per process at startup. Pass the raw
228
+ * `SUPERBLOCKS_RESTART_REASON` env value (the CLI parent stamps
229
+ * `RESTART_REASON_FLIP_RESTART` on a post-flip in-place respawn and leaves it
230
+ * unset otherwise); it is normalized to a bounded `boot_kind` label
231
+ * (`cold_start` | `flip_restart` | `other`).
232
+ *
233
+ * This is the completion half of the in-place-restart telemetry: the
234
+ * server-side decision metric proves we *chose* to self-restart, and a
235
+ * `dev_server_boot_total{boot_kind="flip_restart"}` increment proves the
236
+ * child actually booted afterward, so the two can be compared in Datadog.
237
+ */
238
+ recordBoot(restartReason: string | undefined): void {
239
+ // Once per process. `recordBoot` is driven from every telemetry-init path
240
+ // (cold boot, post-flip respawn, warm activation, and auth hot-reload all
241
+ // call `initializeTelemetry`, which can run more than once per process), so
242
+ // the guard keeps `dev_server_boot_total` at exactly one increment per boot
243
+ // no matter how many call sites or re-inits fire. The first call wins and
244
+ // captures the boot kind; whether it emits immediately or buffers for
245
+ // `flush()`, later calls are no-ops.
246
+ if (this.booted) {
247
+ return;
248
+ }
249
+ this.booted = true;
250
+ const bootKind = normalizeBootKindTag(restartReason);
251
+ this.record(() => {
252
+ getMeter()
253
+ .createCounter("dev_server_boot_total", {
254
+ description:
255
+ "Count of dev-server process boots by boot kind (cold_start | flip_restart | other).",
256
+ })
257
+ .add(1, { boot_kind: bootKind });
258
+ });
259
+ }
260
+
196
261
  /**
197
262
  * Records the outcome of a dev-server HTTP endpoint request.
198
263
  */
@@ -1,6 +1,29 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
 
3
- import { normalizeNpmErrorCodeTag } from "./dev-server-metrics.mjs";
3
+ import {
4
+ normalizeBootKindTag,
5
+ normalizeNpmErrorCodeTag,
6
+ } from "./dev-server-metrics.mjs";
7
+
8
+ describe("normalizeBootKindTag (metric cardinality bound)", () => {
9
+ it("passes through the known flip-restart reason", () => {
10
+ expect(normalizeBootKindTag("flip_restart")).toBe("flip_restart");
11
+ });
12
+
13
+ it("maps an absent reason to 'cold_start' (no parent restart marker)", () => {
14
+ expect(normalizeBootKindTag(undefined)).toBe("cold_start");
15
+ expect(normalizeBootKindTag("")).toBe("cold_start");
16
+ expect(normalizeBootKindTag(" ")).toBe("cold_start");
17
+ });
18
+
19
+ it("maps unknown / arbitrary / injected tokens to 'other' (cardinality guard)", () => {
20
+ // SUPERBLOCKS_RESTART_REASON is process env, so an operator or a stale
21
+ // value could set anything; it must never mint a new time series.
22
+ expect(normalizeBootKindTag("auto_upgrade")).toBe("other");
23
+ expect(normalizeBootKindTag("some-random-token")).toBe("other");
24
+ expect(normalizeBootKindTag("`;rm -rf /#")).toBe("other");
25
+ });
26
+ });
4
27
 
5
28
  describe("normalizeNpmErrorCodeTag (metric cardinality bound)", () => {
6
29
  it("passes through known npm codes", () => {
@@ -1,4 +1,5 @@
1
1
  import * as child_process from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { existsSync, readdirSync } from "node:fs";
3
4
  import type http from "node:http";
4
5
  import net from "node:net";
@@ -14,6 +15,10 @@ import type { HmrOptions, Plugin, UserConfig } from "vite";
14
15
  import type { ViteDevServer } from "vite";
15
16
  import tsconfigPaths from "vite-tsconfig-paths";
16
17
 
18
+ import {
19
+ RESTART_EXIT_CODE,
20
+ RESTART_REASON_ENV,
21
+ } from "@superblocksteam/library-shared/restart-contract";
17
22
  import type { ServerError } from "@superblocksteam/library-shared/types";
18
23
  import {
19
24
  JwtVerifier,
@@ -35,6 +40,7 @@ import type { OperationQueue } from "@superblocksteam/vite-plugin-file-sync/oper
35
40
  import type { SyncService } from "@superblocksteam/vite-plugin-file-sync/sync-service";
36
41
 
37
42
  import pkg from "../../package.json" with { type: "json" };
43
+ import { flagOrEnv } from "../flag.js";
38
44
  import type { SuperblocksSdk } from "../sdk.js";
39
45
  import { getTracer } from "../telemetry/index.js";
40
46
  import { getErrorMeta, getLogger } from "../telemetry/logging.js";
@@ -353,7 +359,10 @@ export function isWorkspacePersistUploadHeaders(
353
359
  return true;
354
360
  }
355
361
 
356
- export const RESTART_EXIT_CODE = 98;
362
+ // Re-exported so existing consumers (sdk index, dev.mts) keep importing
363
+ // RESTART_EXIT_CODE from the SDK; the canonical definition lives in
364
+ // `@superblocksteam/library-shared/restart-contract`.
365
+ export { RESTART_EXIT_CODE };
357
366
 
358
367
  function getJwksUriWithBaseUrl(superblocksBaseUrl?: string): string {
359
368
  if (process.env.SUPERBLOCKS_JWKS_URI) {
@@ -494,6 +503,43 @@ export function buildStatusPayload<T extends object>(
494
503
  return { ...base, serverErrors: devServerStatus?.serverErrors ?? [] };
495
504
  }
496
505
 
506
+ /**
507
+ * Stable per-process identity for the dev-server runtime. `bootId` is generated
508
+ * once at module load, so it changes on every process start (including an
509
+ * in-place restart). Together with an unchanged sandbox/port, these let an e2e
510
+ * spec prove "same sandbox, new process" after a post-flip restart without log
511
+ * scraping (testability hook for the post-flip restart e2e spec).
512
+ */
513
+ export const DEV_SERVER_BOOT_ID = randomUUID();
514
+ export const DEV_SERVER_STARTED_AT = new Date().toISOString();
515
+
516
+ /**
517
+ * Builds the `/_sb_health` payload, including the per-process boot identity.
518
+ * Extracted as a pure function so the boot-identity contract can be unit
519
+ * tested without booting the full dev server.
520
+ */
521
+ export function buildHealthResponse(args: {
522
+ applicationId: string;
523
+ branch: string | null | undefined;
524
+ cliVersion: string;
525
+ }): {
526
+ status: "healthy";
527
+ applicationId: string;
528
+ branch: string | null | undefined;
529
+ cliVersion: string;
530
+ bootId: string;
531
+ startedAt: string;
532
+ } {
533
+ return {
534
+ status: "healthy",
535
+ applicationId: args.applicationId,
536
+ branch: args.branch,
537
+ cliVersion: args.cliVersion,
538
+ bootId: DEV_SERVER_BOOT_ID,
539
+ startedAt: DEV_SERVER_STARTED_AT,
540
+ };
541
+ }
542
+
497
543
  // The "Dev Server" is a long-running HTTP server that manages the Vite server
498
544
  export async function createDevServer({
499
545
  root,
@@ -527,13 +573,47 @@ export async function createDevServer({
527
573
  }
528
574
  const currentBranch = await getCurrentGitBranchIfGit();
529
575
 
530
- const healthResponse = {
531
- status: "healthy",
576
+ const healthResponse = buildHealthResponse({
532
577
  applicationId: resourceConfig.id,
533
578
  branch: currentBranch,
534
- // TODO(code-mode): add branch name
535
579
  cliVersion: pkg.version,
536
- };
580
+ });
581
+
582
+ // Anchor the per-process boot identity in stdout so operators can correlate
583
+ // log lines from before and after an in-place restart (same sandbox, new
584
+ // process) without polling the /_sb_health endpoint.
585
+ logger.info(
586
+ `Dev server process started (bootId=${healthResponse.bootId}, startedAt=${healthResponse.startedAt})`,
587
+ );
588
+
589
+ // Emit a boot metric tagged with how this process started. The CLI parent
590
+ // stamps SUPERBLOCKS_RESTART_REASON=flip_restart when it respawns the child
591
+ // after an in-place v2->v3 template flip, so a
592
+ // `dev_server_boot_total{boot_kind="flip_restart"}` increment proves the child
593
+ // actually booted back up after the flip — the completion signal that pairs
594
+ // with the server-side "decided to self-restart" metric.
595
+ //
596
+ // NOTE: this child-side metric is BEST-EFFORT, not durable. recordBoot buffers
597
+ // when telemetry isn't up yet, but the buffer only flushes if a later
598
+ // initializeTelemetry() call succeeds; if the child stalls or exits before
599
+ // telemetry initializes, the buffered increment is lost (the exact gap we saw
600
+ // in Datadog). The reliable completion signal is the parent-emitted
601
+ // `dev_server_child_respawn_completed_total`, driven by the devServerStarted
602
+ // IPC sent just below — keep both so server/parent/child can be cross-checked.
603
+ devServerMetrics.recordBoot(process.env[RESTART_REASON_ENV]);
604
+
605
+ // Tell the supervising CLI parent (dev-parent.mts) we reached dev-server boot.
606
+ // The parent emits the RELIABLE in-place-restart-completed metric from this
607
+ // signal, because the child's own `dev_server_boot_total` is buffered through
608
+ // the telemetry pipeline and is lost if telemetry never initializes (the gap
609
+ // we saw in Datadog: a respawned child went dark before its boot metric ever
610
+ // flushed). `process.send` is only defined when spawned with an IPC channel —
611
+ // i.e. under the lightweight parent supervisor; warm single-process and
612
+ // shell-supervised pods have no IPC parent and simply skip this. Sent as a
613
+ // plain object; the parent owns the typed `ChildToParentMessage` contract.
614
+ if (process.send) {
615
+ process.send({ type: "devServerStarted" });
616
+ }
537
617
 
538
618
  let viteCreationResults: {
539
619
  viteServer: ViteDevServer;
@@ -1136,6 +1216,12 @@ export async function createDevServer({
1136
1216
  superblocksBaseUrl: explicitBaseUrl || localToken?.superblocksBaseUrl,
1137
1217
  features: {
1138
1218
  enableSessionRecording: featureFlags?.enableSessionRecording() ?? false,
1219
+ // Gate the in-place restart on the LD flag OR the env escape hatch
1220
+ // (cloud-prem/local), mirroring the memory-metrics pattern below.
1221
+ inPlaceTemplateRestartEnabled: flagOrEnv(
1222
+ featureFlags?.inPlaceTemplateRestartEnabled(),
1223
+ "SUPERBLOCKS_DEV_SERVER_IN_PLACE_TEMPLATE_RESTART_ENABLED",
1224
+ ),
1139
1225
  },
1140
1226
  });
1141
1227
  // Note: vitePromise itself gets a no-op .catch() at its construction site
@@ -1154,8 +1240,10 @@ export async function createDevServer({
1154
1240
  // Start memory metrics only for claimed pods (after Vite init, not warm standby).
1155
1241
  // Gated by either the LD flag (cloud) or the SABS-injected env var (cloud-prem/local).
1156
1242
  if (
1157
- featureFlags?.devServerMemoryMetricsEnabled() ||
1158
- process.env.SUPERBLOCKS_DEV_SERVER_MEMORY_METRICS_ENABLED === "true"
1243
+ flagOrEnv(
1244
+ featureFlags?.devServerMemoryMetricsEnabled(),
1245
+ "SUPERBLOCKS_DEV_SERVER_MEMORY_METRICS_ENABLED",
1246
+ )
1159
1247
  ) {
1160
1248
  startMemoryMetrics();
1161
1249
  }
@@ -1205,6 +1293,7 @@ async function startVite({
1205
1293
  checkAuthorization: FileSyncPluginParams["checkAuthorization"];
1206
1294
  features: {
1207
1295
  enableSessionRecording: boolean;
1296
+ inPlaceTemplateRestartEnabled: boolean;
1208
1297
  };
1209
1298
  }): Promise<{
1210
1299
  viteServer: ViteDevServer;
@@ -5,7 +5,12 @@ import type { ServerError } from "@superblocksteam/library-shared/types";
5
5
  // Static import so the slow `dev-server.mjs` module graph (vite, react plugin,
6
6
  // workspace deps) loads during file evaluation rather than inside each test,
7
7
  // where the cold-import cost easily exceeds the default 5s test timeout.
8
- import { buildStatusPayload } from "./dev-server.mjs";
8
+ import {
9
+ buildHealthResponse,
10
+ buildStatusPayload,
11
+ DEV_SERVER_BOOT_ID,
12
+ DEV_SERVER_STARTED_AT,
13
+ } from "./dev-server.mjs";
9
14
 
10
15
  describe("buildStatusPayload", () => {
11
16
  it("merges serverErrors from devServerStatus onto the base payload", () => {
@@ -56,3 +61,32 @@ describe("buildStatusPayload", () => {
56
61
  });
57
62
  });
58
63
  });
64
+
65
+ describe("buildHealthResponse", () => {
66
+ it("includes the per-process boot identity so an e2e can prove 'same sandbox, new process'", () => {
67
+ const health = buildHealthResponse({
68
+ applicationId: "app-1",
69
+ branch: "main",
70
+ cliVersion: "1.2.3",
71
+ });
72
+ expect(health).toEqual({
73
+ status: "healthy",
74
+ applicationId: "app-1",
75
+ branch: "main",
76
+ cliVersion: "1.2.3",
77
+ bootId: DEV_SERVER_BOOT_ID,
78
+ startedAt: DEV_SERVER_STARTED_AT,
79
+ });
80
+ });
81
+
82
+ it("passes through an undefined branch (non-git checkout)", () => {
83
+ const health = buildHealthResponse({
84
+ applicationId: "app-2",
85
+ branch: undefined,
86
+ cliVersion: "9.9.9",
87
+ });
88
+ expect(health.branch).toBeUndefined();
89
+ expect(health.bootId).toBe(DEV_SERVER_BOOT_ID);
90
+ expect(health.startedAt).toBe(DEV_SERVER_STARTED_AT);
91
+ });
92
+ });
@@ -0,0 +1,63 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+
3
+ import { FeatureFlags, flagOrEnv } from "./flag.js";
4
+
5
+ describe("flagOrEnv", () => {
6
+ const ENV = "SUPERBLOCKS_TEST_FLAG_OR_ENV";
7
+ let original: string | undefined;
8
+
9
+ beforeEach(() => {
10
+ original = process.env[ENV];
11
+ delete process.env[ENV];
12
+ });
13
+
14
+ afterEach(() => {
15
+ if (original === undefined) {
16
+ delete process.env[ENV];
17
+ } else {
18
+ process.env[ENV] = original;
19
+ }
20
+ });
21
+
22
+ it("returns false when the flag is undefined and the env is unset", () => {
23
+ expect(flagOrEnv(undefined, ENV)).toBe(false);
24
+ });
25
+
26
+ it("returns true when the flag value is true", () => {
27
+ expect(flagOrEnv(true, ENV)).toBe(true);
28
+ });
29
+
30
+ it("returns true when the flag is falsy but the env is exactly 'true'", () => {
31
+ process.env[ENV] = "true";
32
+ expect(flagOrEnv(false, ENV)).toBe(true);
33
+ expect(flagOrEnv(undefined, ENV)).toBe(true);
34
+ });
35
+
36
+ it("returns false when the env is set to anything other than 'true'", () => {
37
+ process.env[ENV] = "1";
38
+ expect(flagOrEnv(false, ENV)).toBe(false);
39
+ process.env[ENV] = "TRUE";
40
+ expect(flagOrEnv(undefined, ENV)).toBe(false);
41
+ });
42
+ });
43
+
44
+ describe("FeatureFlags.inPlaceTemplateRestartEnabled", () => {
45
+ it("defaults to false when the flag is absent", () => {
46
+ const flags = new FeatureFlags({});
47
+ expect(flags.inPlaceTemplateRestartEnabled()).toBe(false);
48
+ });
49
+
50
+ it("returns true when the flag is enabled", () => {
51
+ const flags = new FeatureFlags({
52
+ "superblocks.dev-server.in-place-template-restart.enabled": true,
53
+ });
54
+ expect(flags.inPlaceTemplateRestartEnabled()).toBe(true);
55
+ });
56
+
57
+ it("returns false when the flag is explicitly disabled", () => {
58
+ const flags = new FeatureFlags({
59
+ "superblocks.dev-server.in-place-template-restart.enabled": false,
60
+ });
61
+ expect(flags.inPlaceTemplateRestartEnabled()).toBe(false);
62
+ });
63
+ });
package/src/flag.ts CHANGED
@@ -2,6 +2,17 @@ import { DEFAULT_LINES_FOR_LARGE_STEPS } from "@superblocksteam/util";
2
2
 
3
3
  import type { FlagBootstrap } from "./types/index.js";
4
4
 
5
+ /**
6
+ * Resolves a feature gate from its LaunchDarkly flag value OR a process-env
7
+ * escape hatch (used by cloud-prem/local, where LD is unavailable). Centralizes
8
+ * the `flag ?? false || env === "true"` shape so each gate names its
9
+ * flag-getter/env-name pairing in exactly one place at the call site.
10
+ */
11
+ export const flagOrEnv = (
12
+ flagValue: boolean | undefined,
13
+ envName: string,
14
+ ): boolean => (flagValue ?? false) || process.env[envName] === "true";
15
+
5
16
  export const signingEnabled = (flags: FlagBootstrap): boolean => {
6
17
  return flags["ui.enable-resource-signing"] ?? false;
7
18
  };
@@ -47,4 +58,17 @@ export class FeatureFlags {
47
58
  devServerMemoryMetricsEnabled(): boolean {
48
59
  return this.flags["superblocks.dev-server.memory-metrics.enabled"] ?? false;
49
60
  }
61
+
62
+ /**
63
+ * Gates the in-place dev-server process restart after a v2->v3 template
64
+ * flip. When enabled (and a restart supervisor is present), the
65
+ * dev server self-restarts its own process so the pod stays alive instead of
66
+ * being terminated and replaced. Flag off restores today's pod-swap behavior.
67
+ */
68
+ inPlaceTemplateRestartEnabled(): boolean {
69
+ return (
70
+ this.flags["superblocks.dev-server.in-place-template-restart.enabled"] ??
71
+ false
72
+ );
73
+ }
50
74
  }
package/src/index.ts CHANGED
@@ -8,7 +8,9 @@ export {
8
8
  type CreateApplicationResult,
9
9
  type CurrentBranch,
10
10
  type BillingUsageLimitsResponse,
11
+ type BillingUsageRecordsResponse,
11
12
  type DailyUsageRow,
13
+ type DollarLedgerRow,
12
14
  type GetCommitsResponseBody,
13
15
  type IntegrationFilters,
14
16
  type IntegrationSummary,
@@ -68,6 +70,13 @@ export {
68
70
 
69
71
  export { AUTO_UPGRADE_EXIT_CODE } from "./cli-replacement/automatic-upgrades.js";
70
72
  export { RESTART_EXIT_CODE } from "./dev-utils/dev-server.mjs";
73
+ // Surfaces the canonical restart-contract markers alongside RESTART_EXIT_CODE so
74
+ // the builtins-only CLI parent copies can be drift-checked against it in tests.
75
+ export {
76
+ RESTART_REASON_ENV,
77
+ RESTART_REASON_FLIP_RESTART,
78
+ RESTART_SUPERVISED_ENV,
79
+ } from "@superblocksteam/library-shared/restart-contract";
71
80
 
72
81
  export { createDevServer, preWarmViteCache } from "./dev-utils/dev-server.mjs";
73
82
  export {
package/src/sdk.ts CHANGED
@@ -41,6 +41,7 @@ import {
41
41
  fetchBillingUsageDaily,
42
42
  fetchBillingUsageLimits,
43
43
  fetchBillingUsageRecords,
44
+ fetchBillingUsageRecordsResponse,
44
45
  fetchCurrentUser,
45
46
  fetchFact,
46
47
  fetchFacts,
@@ -76,6 +77,7 @@ import type {
76
77
  CreateApplicationResult,
77
78
  CreateRbacAssignmentsRequest,
78
79
  BillingUsageLimitsResponse,
80
+ BillingUsageRecordsResponse,
79
81
  DailyUsageRow,
80
82
  FolderMutationPayload,
81
83
  FolderSummary,
@@ -108,6 +110,11 @@ import type { DeploymentHistoryEntryDto, UserMeDto } from "./types/index.js";
108
110
 
109
111
  // Exporting here instead of index.ts because only sdk.ts is exposed outside in package.json
110
112
  export * from "./errors.js";
113
+ export {
114
+ RESTART_REASON_ENV,
115
+ RESTART_REASON_FLIP_RESTART,
116
+ RESTART_SUPERVISED_ENV,
117
+ } from "@superblocksteam/library-shared/restart-contract";
111
118
 
112
119
  export class SuperblocksSdk {
113
120
  token = "";
@@ -891,6 +898,19 @@ export class SuperblocksSdk {
891
898
  });
892
899
  }
893
900
 
901
+ async fetchBillingUsageRecordsResponse(
902
+ startDate: string,
903
+ endDate: string,
904
+ ): Promise<BillingUsageRecordsResponse> {
905
+ return fetchBillingUsageRecordsResponse({
906
+ cliVersion: this.cliVersion,
907
+ token: this.token,
908
+ superblocksBaseUrl: this.superblocksBaseUrl,
909
+ startDate,
910
+ endDate,
911
+ });
912
+ }
913
+
894
914
  async fetchBillingPlanSummary(): Promise<PlanSummary> {
895
915
  return fetchBillingPlanSummary({
896
916
  cliVersion: this.cliVersion,
@@ -18,6 +18,7 @@ export interface FlagBootstrap {
18
18
  "superblocks.dev-server.inactivity-timeout.local"?: number;
19
19
  "superblocks.dev-server.llmobs.enabled"?: boolean;
20
20
  "superblocks.dev-server.memory-metrics.enabled"?: boolean;
21
+ "superblocks.dev-server.in-place-template-restart.enabled"?: boolean;
21
22
  "superblocks.deploy.include-dot-superblocks"?: boolean;
22
23
  }
23
24