@sandblocks/cli 0.5.0-b.8 → 0.5.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.
package/dist/cli.js CHANGED
@@ -60,6 +60,24 @@ function compileProjectGraph(manifest) {
60
60
  timeoutSeconds: service.health.timeout,
61
61
  failureThreshold: service.health.failures
62
62
  }
63
+ } : {},
64
+ ...service.development ? {
65
+ development: {
66
+ command: normalizeCommand(service.development.command),
67
+ ...service.development.health ? {
68
+ health: {
69
+ path: service.development.health.path,
70
+ intervalSeconds: service.development.health.interval,
71
+ timeoutSeconds: service.development.health.timeout,
72
+ failureThreshold: service.development.health.failures
73
+ }
74
+ } : {},
75
+ restart: service.development.restart,
76
+ ...service.development.resources ? {
77
+ memory: service.development.resources.memory,
78
+ cpus: service.development.resources.cpus
79
+ } : {}
80
+ }
63
81
  } : {}
64
82
  };
65
83
  }),
@@ -69,11 +87,19 @@ function compileProjectGraph(manifest) {
69
87
  context: check.context,
70
88
  ...check.command ? { command: check.command } : {},
71
89
  timeoutSeconds: check.timeout,
90
+ ...check.application ? { application: check.application } : {},
91
+ profile: check.profile,
92
+ inputs: check.inputs,
93
+ required: check.required,
72
94
  memory: check.resources.memory,
73
95
  cpus: check.resources.cpus
74
96
  })),
75
97
  environments: manifest.environments.map((environment) => ({
76
98
  id: environment.id,
99
+ mode: environment.mode,
100
+ managedEnvironment: environment.managedEnvironment ?? environment.id,
101
+ branches: environment.branches,
102
+ checkProfile: environment.checkProfile,
77
103
  pipeline: environment.pipeline,
78
104
  services: environment.services,
79
105
  checks: environment.checks,
@@ -4076,9 +4102,41 @@ function validateReferences(manifest, ctx) {
4076
4102
  }
4077
4103
  validateAcyclicServices(manifest, ctx);
4078
4104
  for (const [index, environment] of manifest.environments.entries()) {
4105
+ if (environment.mode === "development" && environment.checkProfile === "none") {
4106
+ ctx.addIssue({
4107
+ code: exports_external.ZodIssueCode.custom,
4108
+ path: ["environments", index, "checkProfile"],
4109
+ message: "development environments must keep a smoke or full quality gate"
4110
+ });
4111
+ }
4112
+ if (environment.id === "prod" && environment.mode !== "immutable") {
4113
+ ctx.addIssue({
4114
+ code: exports_external.ZodIssueCode.custom,
4115
+ path: ["environments", index, "mode"],
4116
+ message: "the prod environment must be immutable"
4117
+ });
4118
+ }
4079
4119
  validateReferencesTo(ctx, environment.pipeline, taskIds, ["environments", index, "pipeline"], "task");
4080
4120
  validateReferencesTo(ctx, environment.services, serviceIds, ["environments", index, "services"], "service");
4081
4121
  validateReferencesTo(ctx, environment.checks, checkIds, ["environments", index, "checks"], "check");
4122
+ if (environment.mode === "development") {
4123
+ for (const serviceId of environment.services) {
4124
+ const serviceIndex = manifest.services.findIndex((service2) => service2.id === serviceId);
4125
+ const service = manifest.services[serviceIndex];
4126
+ if (service && !service.development) {
4127
+ ctx.addIssue({
4128
+ code: exports_external.ZodIssueCode.custom,
4129
+ path: ["services", serviceIndex, "development"],
4130
+ message: `service '${serviceId}' requires a development command`
4131
+ });
4132
+ }
4133
+ }
4134
+ }
4135
+ }
4136
+ for (const [index, check] of manifest.checks.entries()) {
4137
+ if (check.application && !serviceIds.has(check.application)) {
4138
+ addReferenceIssue(ctx, ["checks", index, "application"], "service", check.application);
4139
+ }
4082
4140
  }
4083
4141
  }
4084
4142
  function validateAcyclicServices(manifest, ctx) {
@@ -4145,7 +4203,7 @@ function isSafeRepositoryPath(value) {
4145
4203
  return false;
4146
4204
  return !value.split("/").some((segment) => segment === "..");
4147
4205
  }
4148
- var id, repositoryPath, localImage, imageReference, ProjectCommandSchema, duration, resources, WorkspaceValueSchema, WorkspaceSchema, TaskValueSchema, TaskSchema, TasksV2Schema, HealthValueSchema, HealthV2Schema, ServiceValueSchema, ServicesV2Schema, CheckValueSchema, ChecksV2Schema, EnvironmentValueSchema, EnvironmentsV2Schema, HooksV2Schema, ManifestV2Fields, SandblocksManifestV2DocumentSchema, CompleteSandblocksManifestV2Schema, SandblocksManifestV2Schema;
4206
+ var id, repositoryPath, localImage, imageReference, ProjectCommandSchema, duration, resources, WorkspaceValueSchema, WorkspaceSchema, TaskValueSchema, TaskSchema, TasksV2Schema, HealthValueSchema, HealthV2Schema, DevelopmentServiceSchema, ServiceValueSchema, ServicesV2Schema, CheckValueSchema, ChecksV2Schema, EnvironmentValueSchema, EnvironmentsV2Schema, HooksV2Schema, ManifestV2Fields, SandblocksManifestV2DocumentSchema, CompleteSandblocksManifestV2Schema, SandblocksManifestV2Schema;
4149
4207
  var init_schema = __esm(() => {
4150
4208
  init_zod();
4151
4209
  id = exports_external.string().min(1).max(63).regex(/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/, "must be a lowercase kebab-case identifier");
@@ -4207,12 +4265,19 @@ var init_schema = __esm(() => {
4207
4265
  exports_external.string().startsWith("/").transform((path) => ({ path, interval: 10, timeout: 5, failures: 3 })),
4208
4266
  HealthValueSchema
4209
4267
  ]);
4268
+ DevelopmentServiceSchema = exports_external.object({
4269
+ command: ProjectCommandSchema,
4270
+ health: HealthV2Schema.optional(),
4271
+ restart: exports_external.enum(["no", "on-failure", "unless-stopped"]).default("on-failure"),
4272
+ resources: resources.optional()
4273
+ }).strict();
4210
4274
  ServiceValueSchema = exports_external.object({
4211
4275
  root: repositoryPath.default("."),
4212
4276
  command: ProjectCommandSchema.optional(),
4213
4277
  static: repositoryPath.optional(),
4214
4278
  port: exports_external.number().int().positive().max(65535).optional(),
4215
4279
  health: HealthV2Schema.optional(),
4280
+ development: DevelopmentServiceSchema.optional(),
4216
4281
  dependsOn: exports_external.array(id).default([]),
4217
4282
  resources: resources.default({})
4218
4283
  }).strict().superRefine((service, ctx) => {
@@ -4237,6 +4302,13 @@ var init_schema = __esm(() => {
4237
4302
  message: "static services use Sandblocks's managed health check"
4238
4303
  });
4239
4304
  }
4305
+ if (service.static && service.development) {
4306
+ ctx.addIssue({
4307
+ code: exports_external.ZodIssueCode.custom,
4308
+ path: ["development"],
4309
+ message: "static services cannot declare a development process"
4310
+ });
4311
+ }
4240
4312
  });
4241
4313
  ServicesV2Schema = keyedCollection(ServiceValueSchema);
4242
4314
  CheckValueSchema = exports_external.object({
@@ -4244,13 +4316,21 @@ var init_schema = __esm(() => {
4244
4316
  context: repositoryPath.default("."),
4245
4317
  command: exports_external.array(exports_external.string().min(1).max(2048)).max(64).optional(),
4246
4318
  timeout: duration.default(300),
4319
+ application: id.optional(),
4320
+ profile: exports_external.enum(["smoke", "full"]).default("full"),
4321
+ inputs: exports_external.array(repositoryPath).default([]),
4322
+ required: exports_external.boolean().default(true),
4247
4323
  resources: resources.default({ memory: "1g", cpus: "2" })
4248
4324
  }).strict();
4249
4325
  ChecksV2Schema = keyedCollection(CheckValueSchema);
4250
4326
  EnvironmentValueSchema = exports_external.object({
4327
+ mode: exports_external.enum(["immutable", "development"]).default("immutable"),
4328
+ managedEnvironment: id.optional(),
4329
+ branches: exports_external.array(exports_external.string().min(1).max(255)).max(32).default([]),
4251
4330
  pipeline: exports_external.array(id).default([]),
4252
4331
  services: exports_external.array(id).min(1),
4253
4332
  checks: exports_external.array(id).default([]),
4333
+ checkProfile: exports_external.enum(["none", "smoke", "full"]).default("full"),
4254
4334
  targetHost: exports_external.string().min(1).max(255).optional(),
4255
4335
  routing: exports_external.object({ sso: exports_external.boolean().optional() }).strict().default({})
4256
4336
  }).strict();
@@ -4295,6 +4375,8 @@ function resolveServiceSteps(manifest, environmentId) {
4295
4375
  command: graph.workspace.setup,
4296
4376
  workingDirectory: graph.workspace.root,
4297
4377
  timeoutSeconds: 1800,
4378
+ inputs: ["package.json", "bun.lock", "pnpm-lock.yaml", "yarn.lock", "package-lock.json"],
4379
+ outputs: [],
4298
4380
  required: true
4299
4381
  });
4300
4382
  }
@@ -4307,6 +4389,8 @@ function resolveServiceSteps(manifest, environmentId) {
4307
4389
  command: task.command,
4308
4390
  workingDirectory: task.root,
4309
4391
  timeoutSeconds: task.timeoutSeconds,
4392
+ inputs: task.inputs,
4393
+ outputs: task.outputs,
4310
4394
  required: true
4311
4395
  });
4312
4396
  }
@@ -4319,6 +4403,10 @@ function resolveEnvironment(manifest, id2) {
4319
4403
  const checks = new Map(graph.checks.map((check) => [check.id, check]));
4320
4404
  return {
4321
4405
  id: environment.id,
4406
+ mode: environment.mode,
4407
+ managedEnvironment: environment.managedEnvironment,
4408
+ branches: environment.branches,
4409
+ checkProfile: environment.checkProfile,
4322
4410
  goal: `${environment.id} sandbox`,
4323
4411
  deploymentType: environment.id,
4324
4412
  baseDomain: environment.baseDomain,
@@ -4334,7 +4422,7 @@ function resolveEnvironment(manifest, id2) {
4334
4422
  const service = services.get(serviceId);
4335
4423
  if (!service)
4336
4424
  throw new Error(`environment '${id2}' references unknown service '${serviceId}'`);
4337
- return executionService(service);
4425
+ return executionService(service, environment.mode);
4338
4426
  }),
4339
4427
  steps: environment.checks.map((checkId) => {
4340
4428
  const check = checks.get(checkId);
@@ -4346,6 +4434,10 @@ function resolveEnvironment(manifest, id2) {
4346
4434
  context: check.context,
4347
4435
  ...check.command ? { command: check.command } : {},
4348
4436
  timeoutSeconds: check.timeoutSeconds,
4437
+ ...check.application ? { application: check.application } : {},
4438
+ profile: check.profile,
4439
+ inputs: check.inputs,
4440
+ required: check.required,
4349
4441
  memory: check.memory,
4350
4442
  cpus: check.cpus
4351
4443
  };
@@ -4358,7 +4450,7 @@ function selectEnvironment(graph, id2) {
4358
4450
  throw new Error(`sandblocks.yml does not define environment '${id2}'`);
4359
4451
  return environment;
4360
4452
  }
4361
- function executionService(service) {
4453
+ function executionService(service, mode) {
4362
4454
  if (service.kind === "static") {
4363
4455
  return {
4364
4456
  id: service.id,
@@ -4368,15 +4460,19 @@ function executionService(service) {
4368
4460
  cpus: service.cpus
4369
4461
  };
4370
4462
  }
4463
+ const development = mode === "development" ? service.development : undefined;
4464
+ if (mode === "development" && !development) {
4465
+ throw new Error(`service '${service.id}' has no development command`);
4466
+ }
4371
4467
  return {
4372
4468
  id: service.id,
4373
4469
  kind: "process",
4374
4470
  root: service.root,
4375
- command: service.command,
4471
+ command: development?.command ?? service.command,
4376
4472
  ...service.port ? { port: service.port } : {},
4377
- ...service.health ? { healthPath: service.health.path } : {},
4378
- memory: service.memory,
4379
- cpus: service.cpus
4473
+ ...development?.health ? { healthPath: development.health.path } : service.health ? { healthPath: service.health.path } : {},
4474
+ memory: development?.memory ?? service.memory,
4475
+ cpus: development?.cpus ?? service.cpus
4380
4476
  };
4381
4477
  }
4382
4478
  var init_resolve = () => {};
@@ -11719,7 +11815,7 @@ var init_src = __esm(() => {
11719
11815
 
11720
11816
  // packages/libs/cli/src/cli.ts
11721
11817
  init_src();
11722
- import { randomUUID as randomUUID4 } from "crypto";
11818
+ import { createHash as createHash3, randomUUID as randomUUID4 } from "crypto";
11723
11819
  import { lstat, readFile as readFile8, readdir as readdir2, stat as stat3, writeFile as writeFile5 } from "fs/promises";
11724
11820
  import path6 from "path";
11725
11821
 
@@ -12508,6 +12604,42 @@ class SandblocksClient {
12508
12604
  cancelAgentSession(projectId, sandboxId, sessionId) {
12509
12605
  return this.request(`/v1/projects/${encodeURIComponent(projectId)}/sandboxes/${encodeURIComponent(sandboxId)}/agent-sessions/${encodeURIComponent(sessionId)}/cancel`, { method: "POST" });
12510
12606
  }
12607
+ listWorkers(organizationId) {
12608
+ return this.request(`/v1/organizations/${encodeURIComponent(organizationId)}/workers`);
12609
+ }
12610
+ createWorker(input) {
12611
+ return this.request(`/v1/organizations/${encodeURIComponent(input.organizationId)}/workers`, {
12612
+ method: "POST",
12613
+ body: JSON.stringify(input)
12614
+ });
12615
+ }
12616
+ moveWorkerDeployments(organizationId, workerId, targetWorkerIds) {
12617
+ return this.request(`/v1/organizations/${encodeURIComponent(organizationId)}/workers/${encodeURIComponent(workerId)}/move`, { method: "POST", body: JSON.stringify({ targetWorkerIds }) });
12618
+ }
12619
+ rotateWorkerSecret(organizationId, workerId) {
12620
+ return this.request(`/v1/organizations/${encodeURIComponent(organizationId)}/workers/${encodeURIComponent(workerId)}/rotate-credential`, { method: "POST" });
12621
+ }
12622
+ approveWorkerUpdate(organizationId, workerId, version) {
12623
+ return this.request(`/v1/organizations/${encodeURIComponent(organizationId)}/workers/${encodeURIComponent(workerId)}/approve-update`, {
12624
+ method: "POST",
12625
+ body: JSON.stringify({ version })
12626
+ });
12627
+ }
12628
+ listRegistries(organizationId) {
12629
+ return this.request(`/v1/organizations/${encodeURIComponent(organizationId)}/registries`);
12630
+ }
12631
+ createRegistry(input) {
12632
+ return this.request(`/v1/organizations/${encodeURIComponent(input.organizationId)}/registries`, {
12633
+ method: "POST",
12634
+ body: JSON.stringify(input)
12635
+ });
12636
+ }
12637
+ assignProjectRegistry(projectId, input) {
12638
+ return this.request(`/v1/projects/${encodeURIComponent(projectId)}/registries`, {
12639
+ method: "PUT",
12640
+ body: JSON.stringify(input)
12641
+ });
12642
+ }
12511
12643
  listHosts(projectId) {
12512
12644
  return this.request(`/v1/projects/${encodeURIComponent(projectId)}/hosts`);
12513
12645
  }
@@ -12635,7 +12767,12 @@ class SandblocksClient {
12635
12767
  const image = String(operation.result?.image ?? "");
12636
12768
  if (!/@sha256:[a-f0-9]{64}$/.test(image))
12637
12769
  throw new Error("Published artifact digest is invalid");
12638
- return { image, workspaceId: input.workspaceId, deploymentId: input.deploymentId, bytes: input.bundle.byteLength };
12770
+ return {
12771
+ image,
12772
+ workspaceId: input.workspaceId,
12773
+ deploymentId: input.deploymentId,
12774
+ bytes: input.bundle.byteLength
12775
+ };
12639
12776
  }
12640
12777
  async deployPrebuiltArtifact(input) {
12641
12778
  const { operation } = await this.request(`/v1/projects/${encodeURIComponent(input.projectId)}/operations`, {
@@ -12962,10 +13099,14 @@ function option(options, key) {
12962
13099
  // packages/libs/cli/src/sandbox.ts
12963
13100
  init_src();
12964
13101
  import { createHash as createHash2, randomUUID as randomUUID2 } from "crypto";
13102
+ import { watch as watchFiles } from "fs";
12965
13103
  import { chmod as chmod3, mkdir as mkdir6, readFile as readFile5, rename, rm as rm4 } from "fs/promises";
12966
13104
  import path5 from "path";
12967
13105
  var COMMANDS = new Set([
12968
13106
  "up",
13107
+ "dev",
13108
+ "sync",
13109
+ "check",
12969
13110
  "create",
12970
13111
  "status",
12971
13112
  "deploy",
@@ -12982,6 +13123,12 @@ function isStatefulSandboxCommand(command) {
12982
13123
  async function runStatefulSandboxCommand(command, args, dependencies) {
12983
13124
  if (command === "up")
12984
13125
  return up(args, dependencies);
13126
+ if (command === "dev")
13127
+ return dev(args, dependencies);
13128
+ if (command === "sync")
13129
+ return sync(args, dependencies);
13130
+ if (command === "check")
13131
+ return check(args, dependencies);
12985
13132
  if (command === "create")
12986
13133
  return create(args, dependencies);
12987
13134
  if (command === "status")
@@ -13013,6 +13160,99 @@ async function up(args, dependencies) {
13013
13160
  }
13014
13161
  });
13015
13162
  }
13163
+ async function dev(args, dependencies) {
13164
+ await up(withDefaultOption(withDefaultEnvironment(args, "develop"), "pool", "development-fleet"), dependencies);
13165
+ }
13166
+ async function sync(args, dependencies) {
13167
+ const effectiveArgs = withDefaultEnvironment(args, "develop");
13168
+ const context = await getContext(effectiveArgs, dependencies);
13169
+ if (context.state.runtimeMode !== "development") {
13170
+ throw new Error("sandbox sync requires a development environment");
13171
+ }
13172
+ let previousDigest = "";
13173
+ const apply = async () => {
13174
+ const source = await dependencies.source(context.root, context.state.sourceSyncInitialized === true ? context.state.sourceManifest : undefined);
13175
+ const digest = createHash2("sha256").update(source.bundle).digest("hex");
13176
+ if (digest === previousDigest)
13177
+ return;
13178
+ previousDigest = digest;
13179
+ context.state.sequence += 1;
13180
+ const response = await fetchWithRetry(`${context.apiUrl}/v1/projects/${encodeURIComponent(context.state.projectId)}/workspaces/${encodeURIComponent(context.state.workspaceId)}/sync`, {
13181
+ method: "POST",
13182
+ headers: {
13183
+ "content-type": "application/x-tar",
13184
+ "content-length": String(source.bundle.byteLength),
13185
+ "x-sandblocks-api-key": context.apiKey,
13186
+ "x-sandblocks-sandbox-id": context.state.sandboxId,
13187
+ ...context.state.sourceRevision ? { "x-sandblocks-base-revision": context.state.sourceRevision } : {},
13188
+ "idempotency-key": `sync:${context.state.sandboxId}:${context.state.sequence}:${digest}`
13189
+ },
13190
+ body: new Blob([Uint8Array.from(source.bundle)])
13191
+ });
13192
+ const submitted = await response.json().catch(() => ({}));
13193
+ if (!response.ok)
13194
+ throw new Error(String(submitted.error ?? `source sync failed (${response.status})`));
13195
+ const completed = await wait(context.apiUrl, context.apiKey, submitted.operation.id, 5 * 60000);
13196
+ context.state.sourceRevision = String(completed.result?.revision ?? digest);
13197
+ context.state.sourceManifest = source.manifest;
13198
+ context.state.sourceSyncInitialized = true;
13199
+ await writeState(context.file, context.state);
13200
+ console.log(`sync revision=${context.state.sourceRevision} changed=${completed.result?.changed ?? 0} deleted=${completed.result?.deleted ?? 0}`);
13201
+ if (context.options.checks !== "none") {
13202
+ const changedPaths = [
13203
+ ...Array.isArray(completed.result?.changedPaths) ? completed.result.changedPaths : [],
13204
+ ...Array.isArray(completed.result?.deletedPaths) ? completed.result.deletedPaths : []
13205
+ ].filter((value) => typeof value === "string");
13206
+ await runDevelopmentChecks(context, dependencies, undefined, changedPaths);
13207
+ }
13208
+ };
13209
+ await apply();
13210
+ if (context.options.watch !== true)
13211
+ return;
13212
+ console.log("watch waiting for source changes (Ctrl-C keeps the sandbox running)");
13213
+ while (true) {
13214
+ await waitForSourceChange(context.root);
13215
+ await apply();
13216
+ }
13217
+ }
13218
+ async function check(args, dependencies) {
13219
+ const context = await getContext(withDefaultEnvironment(args, "develop"), dependencies);
13220
+ await runDevelopmentChecks(context, dependencies, dependencies.option(context.options, "profile"));
13221
+ }
13222
+ async function waitForSourceChange(root) {
13223
+ await new Promise((resolveChange) => {
13224
+ let settled = false;
13225
+ let debounce;
13226
+ const watcher = watchFiles(root, { recursive: true }, (_event, filename) => {
13227
+ const path6 = String(filename ?? "").replaceAll("\\", "/");
13228
+ if (!path6 || path6 === ".git" || path6.startsWith(".git/") || path6 === ".sandblocks" || path6.startsWith(".sandblocks/") || path6 === "node_modules" || path6.includes("/node_modules/")) {
13229
+ return;
13230
+ }
13231
+ if (debounce)
13232
+ clearTimeout(debounce);
13233
+ debounce = setTimeout(() => {
13234
+ if (settled)
13235
+ return;
13236
+ settled = true;
13237
+ watcher.close();
13238
+ resolveChange();
13239
+ }, 150);
13240
+ });
13241
+ watcher.on("error", () => {
13242
+ if (settled)
13243
+ return;
13244
+ settled = true;
13245
+ watcher.close();
13246
+ setTimeout(resolveChange, 1000);
13247
+ });
13248
+ });
13249
+ }
13250
+ function withDefaultEnvironment(args, environment) {
13251
+ return withDefaultOption(args, "environment", environment);
13252
+ }
13253
+ function withDefaultOption(args, option2, value) {
13254
+ return args.some((candidate) => candidate === `--${option2}` || candidate.startsWith(`--${option2}=`)) ? args : [...args, `--${option2}`, value];
13255
+ }
13016
13256
  async function create(args, dependencies) {
13017
13257
  const options = dependencies.parse(args);
13018
13258
  const root = path5.resolve(options.positionals[0] ?? process.cwd());
@@ -13053,9 +13293,10 @@ async function create(args, dependencies) {
13053
13293
  source: dependencies.source,
13054
13294
  ..."image" in loaded.manifest.workspace && loaded.manifest.workspace.image ? { workspaceImage: loaded.manifest.workspace.image } : {}
13055
13295
  });
13056
- const hostId = String(imported.operation.assignedHostId ?? "");
13057
- if (!hostId)
13058
- throw new Error("workspace import did not bind a sandbox host");
13296
+ const hostId = typeof imported.operation.assignedHostId === "string" ? imported.operation.assignedHostId : undefined;
13297
+ const workerId = String(imported.operation.assignedWorkerId ?? imported.operation.assignedHostId ?? "");
13298
+ if (!workerId)
13299
+ throw new Error("workspace import did not bind a worker");
13059
13300
  const state = {
13060
13301
  version: 1,
13061
13302
  environment,
@@ -13065,14 +13306,17 @@ async function create(args, dependencies) {
13065
13306
  repository,
13066
13307
  leaseId: lease.lease.id,
13067
13308
  leaseToken: lease.token,
13068
- hostId,
13309
+ ...hostId ? { hostId } : {},
13310
+ workerId,
13311
+ sourceRevision: typeof imported.operation.result?.sha === "string" ? imported.operation.result.sha : undefined,
13312
+ sourceManifest: imported.manifest,
13069
13313
  sequence: 0,
13070
13314
  createdAt: new Date().toISOString()
13071
13315
  };
13072
13316
  await writeState(file, state);
13073
13317
  console.log(`sandbox ${sandboxId}`);
13074
13318
  console.log(`workspace ${workspaceId}`);
13075
- console.log(`host ${hostId}`);
13319
+ console.log(`worker ${workerId}`);
13076
13320
  } catch (error) {
13077
13321
  await request(apiUrl, apiKey, `/v1/projects/${encodeURIComponent(projectId)}/sandboxes/${sandboxId}`, {
13078
13322
  method: "DELETE"
@@ -13095,8 +13339,14 @@ async function status2(args, dependencies) {
13095
13339
  console.log(`state ${path5.relative(context.root, context.file)}`);
13096
13340
  console.log(`sandbox ${context.state.sandboxId} (${sandbox.sandbox.status})`);
13097
13341
  console.log(`workspace ${context.state.workspaceId}`);
13098
- console.log(`host ${context.state.hostId}`);
13342
+ console.log(`worker ${context.state.workerId}`);
13099
13343
  console.log(`lease expires ${session.session.expiresAt}`);
13344
+ if (context.state.runtimeMode)
13345
+ console.log(`mode ${context.state.runtimeMode}`);
13346
+ if (context.state.sourceRevision)
13347
+ console.log(`revision ${context.state.sourceRevision}`);
13348
+ if (context.state.quality)
13349
+ console.log(`quality ${context.state.quality.status} (${context.state.quality.sourceRevision})`);
13100
13350
  for (const route of context.state.previewUrls ?? [])
13101
13351
  console.log(`${route.service ?? "service"} ${route.url}`);
13102
13352
  }
@@ -13125,15 +13375,22 @@ async function withDeploymentLock(args, dependencies, action) {
13125
13375
  }
13126
13376
  async function deployUnlocked(args, dependencies) {
13127
13377
  const context = await getContext(args, dependencies);
13128
- if (context.state.deploymentId)
13378
+ const resume = context.options.resume === true;
13379
+ if (context.state.deploymentId && !(resume && context.state.candidate?.deploymentId === context.state.deploymentId))
13129
13380
  throw new Error("preview is already deployed; use sandbox redeploy");
13130
13381
  const loaded = await dependencies.load(context.root, dependencies.option(context.options, "config") ?? dependencies.option(context.options, "manifest"));
13131
13382
  const stack = loaded.manifest.name ?? context.state.repository;
13132
13383
  const preview = await applyManagedRoutingPolicy(context, stack, selectPreview(loaded, context.state.environment));
13384
+ await assertEnvironmentBranch(context.root, preview);
13385
+ context.state.managedEnvironment = preview.managedEnvironment;
13386
+ if (preview.mode === "development") {
13387
+ await deployDevelopmentUnlocked(context, loaded, preview);
13388
+ return;
13389
+ }
13133
13390
  const deploymentTarget = await resolveDeploymentTarget(context, process.env.SANDBLOCKS_SANDBOX_TARGET_HOST ?? preview.targetHost);
13134
13391
  const targetHost = deploymentTarget.targetHost;
13135
13392
  context.state.deploymentHostId = deploymentTarget.hostId;
13136
- const resume = context.options.resume === true;
13393
+ context.state.deploymentWorkerId = deploymentTarget.workerId;
13137
13394
  const previousCandidate = resume ? context.state.candidate : undefined;
13138
13395
  if (resume && !previousCandidate)
13139
13396
  throw new Error("no resumable candidate exists for this sandbox");
@@ -13143,13 +13400,14 @@ async function deployUnlocked(args, dependencies) {
13143
13400
  throw new Error("deployment ID must be a UUID");
13144
13401
  if (previousCandidate && requestedDeploymentId && requestedDeploymentId !== previousCandidate.deploymentId)
13145
13402
  throw new Error("--deployment-id does not match the resumable candidate");
13146
- const environmentSnapshotId = previousCandidate?.environmentSnapshotId ?? await createEnvironmentSnapshot(context);
13147
- context.state.candidate = previousCandidate ?? { deploymentId, environmentSnapshotId };
13403
+ const environmentSnapshotId = resume ? await createEnvironmentSnapshot(context) : previousCandidate?.environmentSnapshotId ?? await createEnvironmentSnapshot(context);
13404
+ context.state.candidate = previousCandidate ? { ...previousCandidate, environmentSnapshotId } : { deploymentId, environmentSnapshotId };
13148
13405
  await writeState(context.file, context.state);
13149
13406
  const serviceUrls = expectedPreviewServiceUrls(context.state.sandboxId, deploymentId, context.state.repository, preview);
13150
13407
  const serviceSteps = resolveServiceSteps(loaded.manifest, context.state.environment);
13151
13408
  let buildWorkspaceId = previousCandidate?.buildWorkspaceId;
13152
13409
  let buildHostId = previousCandidate?.buildHostId;
13410
+ let buildWorkerId = previousCandidate?.buildWorkerId;
13153
13411
  const suppliedArtifact = dependencies.option(context.options, "artifact");
13154
13412
  if (suppliedArtifact && !/@sha256:[a-f0-9]{64}$/.test(suppliedArtifact))
13155
13413
  throw new Error("--artifact must be an immutable OCI image digest");
@@ -13173,10 +13431,17 @@ async function deployUnlocked(args, dependencies) {
13173
13431
  source: dependencies.source,
13174
13432
  ..."image" in loaded.manifest.workspace && loaded.manifest.workspace.image ? { workspaceImage: loaded.manifest.workspace.image } : {}
13175
13433
  });
13176
- buildHostId = String(imported.operation.assignedHostId ?? "");
13177
- if (!buildHostId)
13178
- throw new Error("build workspace did not bind a build host");
13179
- context.state.candidate = { deploymentId, environmentSnapshotId, buildWorkspaceId, buildHostId };
13434
+ buildHostId = typeof imported.operation.assignedHostId === "string" ? imported.operation.assignedHostId : undefined;
13435
+ buildWorkerId = String(imported.operation.assignedWorkerId ?? imported.operation.assignedHostId ?? "");
13436
+ if (!buildWorkerId)
13437
+ throw new Error("build workspace did not bind a build worker");
13438
+ context.state.candidate = {
13439
+ deploymentId,
13440
+ environmentSnapshotId,
13441
+ buildWorkspaceId,
13442
+ buildHostId,
13443
+ buildWorkerId
13444
+ };
13180
13445
  await writeState(context.file, context.state);
13181
13446
  const stepOperation = await request(context.apiUrl, context.apiKey, `/v1/projects/${encodeURIComponent(context.state.projectId)}/operations`, {
13182
13447
  method: "POST",
@@ -13184,7 +13449,7 @@ async function deployUnlocked(args, dependencies) {
13184
13449
  body: JSON.stringify({
13185
13450
  kind: "workspace.step.run",
13186
13451
  stack: context.state.repository,
13187
- environmentId: context.state.environment,
13452
+ environmentId: managedEnvironmentId(context),
13188
13453
  environmentSnapshotId,
13189
13454
  payload: {
13190
13455
  workspaceId: buildWorkspaceId,
@@ -13194,7 +13459,11 @@ async function deployUnlocked(args, dependencies) {
13194
13459
  serviceUrls,
13195
13460
  steps: serviceSteps,
13196
13461
  ..."image" in loaded.manifest.workspace && loaded.manifest.workspace.image ? { workspaceImage: loaded.manifest.workspace.image } : {},
13197
- placement: { hostId: buildHostId, memoryReservationBytes: 2 * 1024 * 1024 * 1024 }
13462
+ placement: {
13463
+ workerId: buildWorkerId,
13464
+ ...buildHostId ? { hostId: buildHostId } : {},
13465
+ memoryReservationBytes: 2 * 1024 * 1024 * 1024
13466
+ }
13198
13467
  },
13199
13468
  maxAttempts: 1
13200
13469
  })
@@ -13205,22 +13474,29 @@ async function deployUnlocked(args, dependencies) {
13205
13474
  environmentSnapshotId,
13206
13475
  buildWorkspaceId,
13207
13476
  buildHostId,
13477
+ buildWorkerId,
13208
13478
  buildCompleted: true
13209
13479
  };
13210
13480
  await writeState(context.file, context.state);
13211
13481
  }
13212
- if (!buildHostId)
13213
- throw new Error("resumable build host is unavailable");
13482
+ if (!buildWorkerId)
13483
+ throw new Error("resumable build worker is unavailable");
13214
13484
  const publish = await request(context.apiUrl, context.apiKey, `/v1/projects/${encodeURIComponent(context.state.projectId)}/operations`, {
13215
13485
  method: "POST",
13216
- headers: { "idempotency-key": `preview:${deploymentId}:artifact` },
13486
+ headers: {
13487
+ "idempotency-key": resume ? `preview:${deploymentId}:artifact:resume:${randomUUID2()}` : `preview:${deploymentId}:artifact`
13488
+ },
13217
13489
  body: JSON.stringify({
13218
13490
  kind: "workspace.artifact.publish",
13219
13491
  payload: {
13220
13492
  workspaceId: buildWorkspaceId,
13221
13493
  deploymentId,
13222
13494
  stack,
13223
- placement: { hostId: buildHostId, memoryReservationBytes: 1024 * 1024 * 1024 }
13495
+ placement: {
13496
+ workerId: buildWorkerId,
13497
+ ...buildHostId ? { hostId: buildHostId } : {},
13498
+ memoryReservationBytes: 1024 * 1024 * 1024
13499
+ }
13224
13500
  },
13225
13501
  maxAttempts: 1
13226
13502
  })
@@ -13234,6 +13510,7 @@ async function deployUnlocked(args, dependencies) {
13234
13510
  environmentSnapshotId,
13235
13511
  buildWorkspaceId,
13236
13512
  buildHostId,
13513
+ buildWorkerId,
13237
13514
  buildCompleted: true,
13238
13515
  artifactImage
13239
13516
  };
@@ -13257,7 +13534,9 @@ async function deployUnlocked(args, dependencies) {
13257
13534
  if (!artifactImage) {
13258
13535
  const publish = await request(context.apiUrl, context.apiKey, `/v1/projects/${encodeURIComponent(context.state.projectId)}/operations`, {
13259
13536
  method: "POST",
13260
- headers: { "idempotency-key": `preview:${deploymentId}:artifact` },
13537
+ headers: {
13538
+ "idempotency-key": resume ? `preview:${deploymentId}:artifact:resume:${randomUUID2()}` : `preview:${deploymentId}:artifact`
13539
+ },
13261
13540
  body: JSON.stringify({
13262
13541
  kind: "workspace.artifact.publish",
13263
13542
  payload: {
@@ -13265,7 +13544,8 @@ async function deployUnlocked(args, dependencies) {
13265
13544
  deploymentId,
13266
13545
  stack,
13267
13546
  placement: {
13268
- hostId: context.state.hostId,
13547
+ workerId: context.state.workerId,
13548
+ ...context.state.hostId ? { hostId: context.state.hostId } : {},
13269
13549
  memoryReservationBytes: 1024 * 1024 * 1024
13270
13550
  }
13271
13551
  },
@@ -13282,12 +13562,12 @@ async function deployUnlocked(args, dependencies) {
13282
13562
  const submitted = await request(context.apiUrl, context.apiKey, `/v1/projects/${encodeURIComponent(context.state.projectId)}/operations`, {
13283
13563
  method: "POST",
13284
13564
  headers: {
13285
- "idempotency-key": `preview:${deploymentId}:deploy`
13565
+ "idempotency-key": resume ? `preview:${deploymentId}:deploy:resume:${randomUUID2()}` : `preview:${deploymentId}:deploy`
13286
13566
  },
13287
13567
  body: JSON.stringify({
13288
13568
  kind: "workspace.service.deploy",
13289
13569
  stack: context.state.repository,
13290
- environmentId: context.state.environment,
13570
+ environmentId: managedEnvironmentId(context),
13291
13571
  environmentSnapshotId,
13292
13572
  payload: {
13293
13573
  workspaceId: context.state.workspaceId,
@@ -13301,7 +13581,8 @@ async function deployUnlocked(args, dependencies) {
13301
13581
  serviceUrls,
13302
13582
  services: preview.services,
13303
13583
  placement: {
13304
- hostId: deploymentTarget.hostId,
13584
+ workerId: deploymentTarget.workerId,
13585
+ ...deploymentTarget.hostId ? { hostId: deploymentTarget.hostId } : {},
13305
13586
  requiredWorkerRole: "runtime",
13306
13587
  pool: deploymentTarget.pool,
13307
13588
  memoryReservationBytes: serviceMemoryReservation(preview.services)
@@ -13313,14 +13594,14 @@ async function deployUnlocked(args, dependencies) {
13313
13594
  operation = await wait(context.apiUrl, context.apiKey, submitted.operation.id, 30 * 60000);
13314
13595
  } catch (error) {
13315
13596
  const message = error instanceof Error ? error.message : String(error);
13316
- throw new Error(`${message}; retry with ` + "`sandblocks sandbox deploy . --resume`");
13597
+ throw new Error(`${message}; retry with \`sandblocks sandbox deploy . --resume\``);
13317
13598
  }
13318
13599
  context.state.deploymentId = deploymentId;
13319
13600
  await writeState(context.file, context.state);
13320
13601
  const routes = operation.result?.serviceRoutes ?? [];
13321
13602
  const images = operation.result?.images ?? [];
13322
13603
  if (typeof preview.ssoEnabled === "boolean") {
13323
- await request(context.apiUrl, context.apiKey, `/v1/projects/${encodeURIComponent(context.state.projectId)}/routing-policies/${encodeURIComponent(stack)}/${encodeURIComponent(preview.deploymentType)}`, { method: "PUT", body: JSON.stringify({ ssoEnabled: preview.ssoEnabled }) });
13604
+ await request(context.apiUrl, context.apiKey, `/v1/projects/${encodeURIComponent(context.state.projectId)}/routing-policies/${encodeURIComponent(stack)}/${encodeURIComponent(preview.managedEnvironment)}`, { method: "PUT", body: JSON.stringify({ ssoEnabled: preview.ssoEnabled }) });
13324
13605
  }
13325
13606
  let sandbox = await recordSandbox(context, loaded, preview, routes, operation.result?.healthEvidence ?? [], images, preview.steps.length ? "active" : "available");
13326
13607
  let checkEvidence = [];
@@ -13334,15 +13615,15 @@ async function deployUnlocked(args, dependencies) {
13334
13615
  route.url
13335
13616
  ]
13336
13617
  ] : []));
13337
- const check = await request(context.apiUrl, context.apiKey, `/v1/projects/${encodeURIComponent(context.state.projectId)}/operations`, {
13618
+ const check2 = await request(context.apiUrl, context.apiKey, `/v1/projects/${encodeURIComponent(context.state.projectId)}/operations`, {
13338
13619
  method: "POST",
13339
13620
  headers: {
13340
- "idempotency-key": `preview:${deploymentId}:checks`
13621
+ "idempotency-key": resume ? `preview:${deploymentId}:checks:resume:${randomUUID2()}` : `preview:${deploymentId}:checks`
13341
13622
  },
13342
13623
  body: JSON.stringify({
13343
13624
  kind: "workspace.check.run",
13344
13625
  stack: context.state.repository,
13345
- environmentId: context.state.environment,
13626
+ environmentId: managedEnvironmentId(context),
13346
13627
  environmentSnapshotId,
13347
13628
  payload: {
13348
13629
  workspaceId: buildWorkspaceId ?? context.state.workspaceId,
@@ -13351,12 +13632,15 @@ async function deployUnlocked(args, dependencies) {
13351
13632
  runtimeType: preview.deploymentType,
13352
13633
  checks: preview.steps,
13353
13634
  serviceUrls: serviceUrls2,
13354
- placement: { hostId: buildHostId ?? context.state.hostId }
13635
+ placement: {
13636
+ workerId: buildWorkerId ?? context.state.workerId,
13637
+ ...buildHostId ?? context.state.hostId ? { hostId: buildHostId ?? context.state.hostId } : {}
13638
+ }
13355
13639
  },
13356
13640
  maxAttempts: 1
13357
13641
  })
13358
13642
  });
13359
- const checked2 = await wait(context.apiUrl, context.apiKey, check.operation.id, 60 * 60000);
13643
+ const checked2 = await wait(context.apiUrl, context.apiKey, check2.operation.id, 60 * 60000);
13360
13644
  checkEvidence = checked2.result?.checks ?? [];
13361
13645
  if (checked2.result?.passed !== true) {
13362
13646
  throw new Error(`one or more post-deploy checks failed: ${JSON.stringify(checkEvidence)}`);
@@ -13367,13 +13651,6 @@ async function deployUnlocked(args, dependencies) {
13367
13651
  await recordSandbox(context, loaded, preview, routes, [...operation.result?.healthEvidence ?? [], ...checkEvidence], images, "failed").catch(() => {
13368
13652
  return;
13369
13653
  });
13370
- if (buildWorkspaceId)
13371
- await destroyDetachedWorkspace(context, buildWorkspaceId, `preview:${deploymentId}:build-cleanup`).catch(() => {
13372
- return;
13373
- });
13374
- await releaseEnvironmentSnapshot(context, environmentSnapshotId).catch(() => {
13375
- return;
13376
- });
13377
13654
  throw error;
13378
13655
  }
13379
13656
  if (buildWorkspaceId)
@@ -13388,6 +13665,219 @@ async function deployUnlocked(args, dependencies) {
13388
13665
  for (const route of previewUrls)
13389
13666
  console.log(`${route.service ?? "service"} ${route.url}`);
13390
13667
  }
13668
+ async function deployDevelopmentUnlocked(context, loaded, preview) {
13669
+ if (context.state.deploymentId)
13670
+ throw new Error("development sandbox is already running; use sandbox sync");
13671
+ const stack = loaded.manifest.name ?? context.state.repository;
13672
+ const target = await resolveDevelopmentTarget(context, preview.targetHost);
13673
+ const deploymentId = randomUUID2();
13674
+ const environmentSnapshotId = await createEnvironmentSnapshot(context);
13675
+ context.state.deploymentId = deploymentId;
13676
+ context.state.deploymentHostId = target.hostId;
13677
+ context.state.deploymentWorkerId = target.workerId;
13678
+ context.state.runtimeMode = "development";
13679
+ await writeState(context.file, context.state);
13680
+ try {
13681
+ const serviceUrls = expectedPreviewServiceUrls(context.state.sandboxId, deploymentId, context.state.repository, preview);
13682
+ const steps = resolveServiceSteps(loaded.manifest, context.state.environment);
13683
+ if (steps.length) {
13684
+ const submitted2 = await request(context.apiUrl, context.apiKey, `/v1/projects/${encodeURIComponent(context.state.projectId)}/operations`, {
13685
+ method: "POST",
13686
+ headers: { "idempotency-key": `development:${deploymentId}:setup` },
13687
+ body: JSON.stringify({
13688
+ kind: "workspace.step.run",
13689
+ stack,
13690
+ environmentId: managedEnvironmentId(context),
13691
+ environmentSnapshotId,
13692
+ payload: {
13693
+ workspaceId: context.state.workspaceId,
13694
+ sandboxId: context.state.sandboxId,
13695
+ revisionId: context.state.sourceRevision ?? deploymentId,
13696
+ runtimeType: preview.deploymentType,
13697
+ serviceUrls,
13698
+ steps,
13699
+ ..."image" in loaded.manifest.workspace && loaded.manifest.workspace.image ? { workspaceImage: loaded.manifest.workspace.image } : {},
13700
+ placement: {
13701
+ workerId: context.state.workerId,
13702
+ ...context.state.hostId ? { hostId: context.state.hostId } : {},
13703
+ requiredWorkerRole: "development"
13704
+ }
13705
+ },
13706
+ maxAttempts: 1
13707
+ })
13708
+ });
13709
+ await wait(context.apiUrl, context.apiKey, submitted2.operation.id, 30 * 60000);
13710
+ }
13711
+ const submitted = await request(context.apiUrl, context.apiKey, `/v1/projects/${encodeURIComponent(context.state.projectId)}/operations`, {
13712
+ method: "POST",
13713
+ headers: { "idempotency-key": `development:${deploymentId}:services` },
13714
+ body: JSON.stringify({
13715
+ kind: "workspace.service.deploy",
13716
+ stack,
13717
+ environmentId: managedEnvironmentId(context),
13718
+ environmentSnapshotId,
13719
+ payload: {
13720
+ workspaceId: context.state.workspaceId,
13721
+ deploymentId,
13722
+ sandboxId: context.state.sandboxId,
13723
+ runtimeType: preview.deploymentType,
13724
+ stack,
13725
+ workspace: preview.workspace,
13726
+ mutableDevelopment: true,
13727
+ sourceRevision: context.state.sourceRevision ?? deploymentId,
13728
+ targetHost: target.targetHost,
13729
+ serviceUrls,
13730
+ services: preview.services,
13731
+ placement: {
13732
+ workerId: context.state.workerId,
13733
+ ...context.state.hostId ? { hostId: context.state.hostId } : {},
13734
+ requiredWorkerRole: "development",
13735
+ memoryReservationBytes: serviceMemoryReservation(preview.services)
13736
+ }
13737
+ },
13738
+ maxAttempts: 1
13739
+ })
13740
+ });
13741
+ const operation = await wait(context.apiUrl, context.apiKey, submitted.operation.id, 30 * 60000);
13742
+ if (typeof preview.ssoEnabled === "boolean") {
13743
+ await request(context.apiUrl, context.apiKey, `/v1/projects/${encodeURIComponent(context.state.projectId)}/routing-policies/${encodeURIComponent(stack)}/${encodeURIComponent(preview.managedEnvironment)}`, { method: "PUT", body: JSON.stringify({ ssoEnabled: preview.ssoEnabled }) });
13744
+ }
13745
+ const sandbox = await recordSandbox(context, loaded, preview, operation.result?.serviceRoutes ?? [], operation.result?.healthEvidence ?? [], operation.result?.images ?? [], "available");
13746
+ const previewUrls = sandbox.previewUrls ?? [];
13747
+ context.state.previewUrls = previewUrls;
13748
+ await writeState(context.file, context.state);
13749
+ console.log(`development ${deploymentId} available`);
13750
+ for (const route of previewUrls)
13751
+ console.log(`${route.service ?? "service"} ${route.url}`);
13752
+ await runDevelopmentChecks(context, {
13753
+ parse: () => context.options,
13754
+ option: (options, key) => typeof options[key] === "string" ? options[key] : undefined,
13755
+ load: async () => loaded,
13756
+ source: async () => ({ bundle: new Uint8Array, files: 0 })
13757
+ });
13758
+ } catch (error) {
13759
+ context.state.runtimeMode = "development";
13760
+ await writeState(context.file, context.state);
13761
+ throw error;
13762
+ } finally {
13763
+ await releaseEnvironmentSnapshot(context, environmentSnapshotId).catch(() => {
13764
+ return;
13765
+ });
13766
+ }
13767
+ }
13768
+ async function runDevelopmentChecks(context, dependencies, requestedProfile, changedPaths = []) {
13769
+ if (!context.state.deploymentId || !context.state.previewUrls?.length) {
13770
+ throw new Error("development services must be available before checks run");
13771
+ }
13772
+ const loaded = await dependencies.load(context.root, dependencies.option(context.options, "config") ?? dependencies.option(context.options, "manifest"));
13773
+ const environment = resolveEnvironment(loaded.manifest, context.state.environment);
13774
+ if (environment.mode !== "development")
13775
+ throw new Error("continuous checks require a development environment");
13776
+ const profile = requestedProfile ?? environment.checkProfile;
13777
+ if (!["none", "smoke", "full"].includes(profile))
13778
+ throw new Error("check profile must be none, smoke, or full");
13779
+ const sourceRevision = context.state.sourceRevision ?? context.state.deploymentId;
13780
+ if (profile === "none") {
13781
+ context.state.quality = { sourceRevision, status: "unchecked", checks: [] };
13782
+ await writeState(context.file, context.state);
13783
+ console.log(`quality unchecked revision=${sourceRevision}`);
13784
+ return;
13785
+ }
13786
+ const profileChecks = environment.steps.filter((candidate) => profile === "full" || candidate.profile === "smoke");
13787
+ const checks = profile === "full" || !changedPaths.length ? profileChecks : profileChecks.filter((candidate) => !candidate.inputs.length || candidate.inputs.some((input) => changedPaths.some((path6) => repositoryPatternMatches(input, path6))));
13788
+ if (!profileChecks.length)
13789
+ throw new Error(`environment '${environment.id}' has no ${profile} checks`);
13790
+ if (!checks.length) {
13791
+ context.state.quality = {
13792
+ sourceRevision,
13793
+ status: context.state.quality?.status === "failed" ? "failed" : "passed",
13794
+ checks: context.state.quality?.checks ?? []
13795
+ };
13796
+ await writeState(context.file, context.state);
13797
+ console.log(`quality reused revision=${sourceRevision} (no affected application suites)`);
13798
+ return;
13799
+ }
13800
+ await waitForPublishedRoutes(context.state.previewUrls);
13801
+ const serviceUrls = Object.fromEntries(context.state.previewUrls.flatMap((route) => route.service ? [[`SANDBLOCKS_SERVICE_${route.service.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_URL`, route.url]] : []));
13802
+ context.state.sequence += 1;
13803
+ const snapshotId = await createEnvironmentSnapshot(context);
13804
+ try {
13805
+ const submitted = await request(context.apiUrl, context.apiKey, `/v1/projects/${encodeURIComponent(context.state.projectId)}/operations`, {
13806
+ method: "POST",
13807
+ headers: {
13808
+ "idempotency-key": `development:${context.state.deploymentId}:checks:${sourceRevision}:${profile}:${context.state.sequence}`
13809
+ },
13810
+ body: JSON.stringify({
13811
+ kind: "workspace.check.run",
13812
+ stack: loaded.manifest.name ?? context.state.repository,
13813
+ environmentId: managedEnvironmentId(context),
13814
+ environmentSnapshotId: snapshotId,
13815
+ payload: {
13816
+ workspaceId: context.state.workspaceId,
13817
+ deploymentId: context.state.deploymentId,
13818
+ sandboxId: context.state.sandboxId,
13819
+ runtimeType: environment.deploymentType,
13820
+ sourceRevision,
13821
+ checks,
13822
+ serviceUrls,
13823
+ placement: {
13824
+ workerId: context.state.workerId,
13825
+ ...context.state.hostId ? { hostId: context.state.hostId } : {},
13826
+ requiredWorkerRole: "development"
13827
+ }
13828
+ },
13829
+ maxAttempts: 1
13830
+ })
13831
+ });
13832
+ const completed = await wait(context.apiUrl, context.apiKey, submitted.operation.id, 60 * 60000);
13833
+ const checksResult = Array.isArray(completed.result?.checks) ? completed.result.checks : [];
13834
+ const selectedIds = new Set(checks.map((item) => item.id));
13835
+ const reused = (context.state.quality?.checks ?? []).filter((item) => item && !selectedIds.has(String(item.check ?? ""))).map((item) => ({
13836
+ ...item,
13837
+ sourceRevision,
13838
+ status: item.status === "passed" || item.status === "reused" ? "reused" : item.status,
13839
+ reusedFromRevision: item.sourceRevision
13840
+ }));
13841
+ const combined = [...checksResult, ...reused];
13842
+ const status3 = completed.result?.passed === true && combined.every((item) => item.required === false || ["passed", "reused"].includes(item.status)) ? "passed" : "failed";
13843
+ context.state.quality = { sourceRevision, status: status3, checks: combined };
13844
+ await writeState(context.file, context.state);
13845
+ console.log(`quality ${status3} revision=${sourceRevision}`);
13846
+ for (const result of checksResult) {
13847
+ const item = result;
13848
+ console.log(` ${String(item.application ?? item.check ?? "check").padEnd(12)} ${item.status} ${item.durationMs ?? 0}ms`);
13849
+ }
13850
+ } finally {
13851
+ await releaseEnvironmentSnapshot(context, snapshotId).catch(() => {
13852
+ return;
13853
+ });
13854
+ }
13855
+ }
13856
+ async function assertEnvironmentBranch(root, environment) {
13857
+ if (!environment.branches.length)
13858
+ return;
13859
+ const result = Bun.spawnSync(["git", "branch", "--show-current"], { cwd: root });
13860
+ const branch = result.exitCode === 0 ? result.stdout.toString().trim() : "";
13861
+ if (!branch)
13862
+ throw new Error(`environment '${environment.id}' requires a named Git branch`);
13863
+ const allowed = environment.branches.some((pattern) => {
13864
+ const expression = new RegExp(`^${pattern.split("*").map(escapeRegExp).join(".*")}$`);
13865
+ return expression.test(branch);
13866
+ });
13867
+ if (!allowed) {
13868
+ throw new Error(`branch '${branch}' cannot deploy to environment '${environment.id}' (allowed: ${environment.branches.join(", ")})`);
13869
+ }
13870
+ }
13871
+ function escapeRegExp(value) {
13872
+ return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
13873
+ }
13874
+ function repositoryPatternMatches(pattern, path6) {
13875
+ const normalized = pattern.replace(/^\.\//, "").replace(/\/\*\*$/, "").replace(/\/$/, "");
13876
+ if (!normalized.includes("*"))
13877
+ return path6 === normalized || path6.startsWith(`${normalized}/`);
13878
+ const expression = new RegExp(`^${normalized.split("**").map((part) => part.split("*").map(escapeRegExp).join("[^/]*")).join(".*")}(?:/.*)?$`);
13879
+ return expression.test(path6);
13880
+ }
13391
13881
  async function redeploy(args, dependencies) {
13392
13882
  const context = await getContext(args, dependencies);
13393
13883
  const routing = await request(context.apiUrl, context.apiKey, `/v1/projects/${encodeURIComponent(context.state.projectId)}/routing-aliases`);
@@ -13415,10 +13905,12 @@ async function redeploy(args, dependencies) {
13415
13905
  source: dependencies.source,
13416
13906
  ..."image" in loaded.manifest.workspace && loaded.manifest.workspace.image ? { workspaceImage: loaded.manifest.workspace.image } : {}
13417
13907
  });
13418
- const hostId = String(imported.operation.assignedHostId ?? "");
13419
- if (!hostId)
13420
- throw new Error("workspace re-import did not bind a sandbox host");
13908
+ const hostId = typeof imported.operation.assignedHostId === "string" ? imported.operation.assignedHostId : undefined;
13909
+ const workerId = String(imported.operation.assignedWorkerId ?? imported.operation.assignedHostId ?? "");
13910
+ if (!workerId)
13911
+ throw new Error("workspace re-import did not bind a worker");
13421
13912
  context.state.hostId = hostId;
13913
+ context.state.workerId = workerId;
13422
13914
  await writeState(context.file, context.state);
13423
13915
  await deploy(args, dependencies);
13424
13916
  if (previousDeploymentId)
@@ -13494,25 +13986,47 @@ async function down(args, dependencies) {
13494
13986
  await rm4(context.file, { force: true });
13495
13987
  console.log(`sandbox ${context.state.sandboxId} destroyed`);
13496
13988
  }
13989
+ async function resolveDevelopmentTarget(context, fallback) {
13990
+ const project = encodeURIComponent(context.state.projectId);
13991
+ const workerBody = await request(context.apiUrl, context.apiKey, `/v1/projects/${project}/workers`);
13992
+ const workers = Array.isArray(workerBody.workers) ? workerBody.workers : [];
13993
+ const worker = workers.find((candidate) => candidate?.id === context.state.workerId);
13994
+ if (!worker || worker.status !== "online")
13995
+ throw new Error("development workspace worker is offline");
13996
+ const configuredTarget = worker?.configuration?.targetAddress;
13997
+ const targetHost = typeof fallback === "string" && fallback.trim() ? fallback.trim() : typeof configuredTarget === "string" && configuredTarget.trim() ? configuredTarget.trim() : undefined;
13998
+ if (!targetHost)
13999
+ throw new Error("development worker targetAddress or environment targetHost is required");
14000
+ return {
14001
+ workerId: String(worker.id),
14002
+ ...worker.hostId ? { hostId: String(worker.hostId) } : {},
14003
+ targetHost
14004
+ };
14005
+ }
13497
14006
  async function resolveDeploymentTarget(context, fallback) {
13498
14007
  const project = encodeURIComponent(context.state.projectId);
13499
- const [hostBody, workerBody] = await Promise.all([
13500
- request(context.apiUrl, context.apiKey, `/v1/projects/${project}/hosts`),
13501
- request(context.apiUrl, context.apiKey, `/v1/projects/${project}/workers`)
13502
- ]);
14008
+ const workerBody = await request(context.apiUrl, context.apiKey, `/v1/projects/${project}/workers`);
13503
14009
  const workers = Array.isArray(workerBody.workers) ? workerBody.workers : [];
13504
14010
  const requestedPool = process.env.SANDBLOCKS_DEPLOYMENT_WORKER_POOL?.trim() || process.env.SANDBLOCKS_RUNTIME_WORKER_POOL?.trim();
13505
14011
  const eligible = workers.filter((worker2) => worker2?.status === "online" && Array.isArray(worker2.roles) && worker2.roles.includes("runtime"));
13506
- const worker = eligible.find((candidate) => requestedPool && candidate.pool === requestedPool) ?? eligible[0];
13507
- if (!worker?.hostId)
14012
+ const worker = eligible.find((candidate) => typeof fallback === "string" && candidate?.configuration?.targetAddress === fallback.trim()) ?? eligible.find((candidate) => requestedPool && candidate.pool === requestedPool) ?? eligible[0];
14013
+ if (!worker?.id)
13508
14014
  throw new Error("no online deployment worker is available");
13509
- const hosts = Array.isArray(hostBody.hosts) ? hostBody.hosts : [];
13510
- const host = hosts.find((candidate) => candidate?.id === worker.hostId);
13511
- const configuredTarget = worker?.configuration?.targetAddress ?? host?.labels?.targetHost;
14015
+ let configuredTarget = worker?.configuration?.targetAddress;
14016
+ if (!configuredTarget && worker.hostId) {
14017
+ const legacyHosts = await request(context.apiUrl, context.apiKey, `/v1/projects/${project}/hosts`);
14018
+ const host = Array.isArray(legacyHosts.hosts) ? legacyHosts.hosts.find((candidate) => candidate?.id === worker.hostId) : undefined;
14019
+ configuredTarget = host?.labels?.targetHost;
14020
+ }
13512
14021
  const targetHost = typeof fallback === "string" && fallback.trim() ? fallback.trim() : typeof configuredTarget === "string" && configuredTarget.trim() ? configuredTarget.trim() : undefined;
13513
14022
  if (!targetHost)
13514
- throw new Error("deployment host targetHost label or preview targetHost is required");
13515
- return { hostId: String(worker.hostId), targetHost, pool: String(worker.pool ?? "unpooled") };
14023
+ throw new Error("deployment worker targetAddress or preview targetHost is required");
14024
+ return {
14025
+ workerId: String(worker.id),
14026
+ ...worker.hostId ? { hostId: String(worker.hostId) } : {},
14027
+ targetHost,
14028
+ pool: String(worker.pool ?? "unpooled")
14029
+ };
13516
14030
  }
13517
14031
  async function recordSandbox(context, loaded, preview, serviceRoutes, healthEvidence, images, status3) {
13518
14032
  const deploymentId = context.state.deploymentId;
@@ -13525,7 +14039,7 @@ async function recordSandbox(context, loaded, preview, serviceRoutes, healthEvid
13525
14039
  goal: preview.goal,
13526
14040
  status: status3,
13527
14041
  stack: loaded.manifest.name ?? context.state.repository,
13528
- deploymentType: preview.deploymentType,
14042
+ deploymentType: preview.managedEnvironment,
13529
14043
  revisionId: deploymentId,
13530
14044
  generation: Date.now(),
13531
14045
  baseDomain: preview.baseDomain,
@@ -13553,7 +14067,7 @@ async function createEnvironmentSnapshot(context) {
13553
14067
  body: JSON.stringify({
13554
14068
  stack: context.state.repository,
13555
14069
  app: "*",
13556
- environmentId: context.state.environment
14070
+ environmentId: managedEnvironmentId(context)
13557
14071
  })
13558
14072
  });
13559
14073
  const id2 = body.snapshot?.id;
@@ -13562,6 +14076,9 @@ async function createEnvironmentSnapshot(context) {
13562
14076
  }
13563
14077
  return id2;
13564
14078
  }
14079
+ function managedEnvironmentId(context) {
14080
+ return context.state.managedEnvironment ?? context.state.environment;
14081
+ }
13565
14082
  async function releaseEnvironmentSnapshot(context, snapshotId) {
13566
14083
  await request(context.apiUrl, context.apiKey, `/v1/projects/${encodeURIComponent(context.state.projectId)}/environment-snapshots/${snapshotId}`, { method: "DELETE" });
13567
14084
  }
@@ -13581,7 +14098,13 @@ async function destroyDeploymentById(context, deploymentId) {
13581
14098
  kind: "workspace.service.destroy",
13582
14099
  payload: {
13583
14100
  deploymentId,
13584
- placement: { hostId: context.state.deploymentHostId ?? context.state.hostId }
14101
+ placement: context.state.deploymentWorkerId ? {
14102
+ workerId: context.state.deploymentWorkerId,
14103
+ requiredWorkerRole: context.state.runtimeMode === "development" ? "development" : "runtime"
14104
+ } : {
14105
+ hostId: context.state.deploymentHostId ?? context.state.hostId,
14106
+ requiredWorkerRole: context.state.runtimeMode === "development" ? "development" : "runtime"
14107
+ }
13585
14108
  },
13586
14109
  maxAttempts: 1
13587
14110
  })
@@ -13629,6 +14152,7 @@ async function importSource(input) {
13629
14152
  return {
13630
14153
  files: source.files,
13631
14154
  bytes: source.bundle.byteLength,
14155
+ manifest: source.manifest,
13632
14156
  operation: await wait(input.apiUrl, input.apiKey, submitted.operation.id)
13633
14157
  };
13634
14158
  }
@@ -13651,6 +14175,10 @@ async function getContext(args, dependencies) {
13651
14175
  let state;
13652
14176
  try {
13653
14177
  state = JSON.parse(await readFile5(file, "utf8"));
14178
+ if (!state.workerId && state.hostId)
14179
+ state.workerId = state.hostId;
14180
+ if (!state.workerId)
14181
+ throw new Error("sandbox state has no bound worker");
13654
14182
  } catch {
13655
14183
  throw new Error(`sandbox '${environment}' does not exist; use sandbox create or up`);
13656
14184
  }
@@ -13674,8 +14202,10 @@ async function request(apiUrl, apiKey, pathname, init = {}) {
13674
14202
  headers.set("content-type", "application/json");
13675
14203
  const response = await fetchWithRetry(`${apiUrl}${pathname}`, { ...init, headers });
13676
14204
  const body = await response.json().catch(() => ({}));
13677
- if (!response.ok)
13678
- throw new Error(String(body.error ?? `Sandblocks request failed (${response.status})`));
14205
+ if (!response.ok) {
14206
+ const placement = body.placement ? `: ${JSON.stringify(body.placement)}` : "";
14207
+ throw new Error(`${String(body.error ?? `Sandblocks request failed (${response.status})`)}${placement}`);
14208
+ }
13679
14209
  return body;
13680
14210
  }
13681
14211
  async function leaseRequest(context, pathname, init = {}) {
@@ -13781,7 +14311,7 @@ async function loadLocalEnvironment(root) {
13781
14311
  }
13782
14312
  }
13783
14313
  async function applyManagedRoutingPolicy(context, stack, preview) {
13784
- const body = await request(context.apiUrl, context.apiKey, `/v1/projects/${encodeURIComponent(context.state.projectId)}/routing-policies/${encodeURIComponent(stack)}/${encodeURIComponent(preview.deploymentType)}`);
14314
+ const body = await request(context.apiUrl, context.apiKey, `/v1/projects/${encodeURIComponent(context.state.projectId)}/routing-policies/${encodeURIComponent(stack)}/${encodeURIComponent(preview.managedEnvironment)}`);
13785
14315
  const policy = body.policy;
13786
14316
  const primaryDomains = Object.fromEntries(Object.entries(policy.domains ?? {}).flatMap(([service, domains]) => {
13787
14317
  const domain = Array.isArray(domains) ? domains[0] : domains;
@@ -13801,7 +14331,7 @@ function expectedPreviewServiceUrls(sandboxId, deploymentId, stack, preview) {
13801
14331
  const name = service.id.toUpperCase().replace(/[^A-Z0-9]/g, "_");
13802
14332
  const host = resolvedServiceDomain(sandboxId, deploymentId, service.id, preview);
13803
14333
  const configuredStableHosts = preview.stableDomains[service.id];
13804
- const stableHost = (Array.isArray(configuredStableHosts) ? configuredStableHosts[0] : configuredStableHosts) ?? `${preview.deploymentType}-${stack}-${service.id}.${preview.baseDomain}`;
14334
+ const stableHost = (Array.isArray(configuredStableHosts) ? configuredStableHosts[0] : configuredStableHosts) ?? `${preview.managedEnvironment}-${stack}-${service.id}.${preview.baseDomain}`;
13805
14335
  return [
13806
14336
  [`SANDBLOCKS_SERVICE_${name}_URL`, `https://${host}`],
13807
14337
  [`SANDBLOCKS_STABLE_SERVICE_${name}_URL`, `https://${stableHost}`]
@@ -13809,7 +14339,7 @@ function expectedPreviewServiceUrls(sandboxId, deploymentId, stack, preview) {
13809
14339
  }));
13810
14340
  }
13811
14341
  async function managedServiceDomains(context, stack, preview, deploymentId) {
13812
- const body = await request(context.apiUrl, context.apiKey, `/v1/projects/${encodeURIComponent(context.state.projectId)}/routing-policies/${encodeURIComponent(stack)}/${encodeURIComponent(preview.deploymentType)}`);
14342
+ const body = await request(context.apiUrl, context.apiKey, `/v1/projects/${encodeURIComponent(context.state.projectId)}/routing-policies/${encodeURIComponent(stack)}/${encodeURIComponent(preview.managedEnvironment)}`);
13813
14343
  const domains = body.policy.domains ?? {};
13814
14344
  return Object.keys(domains).length ? domains : resolvedServiceDomains(context.state.sandboxId, deploymentId, preview);
13815
14345
  }
@@ -13820,7 +14350,7 @@ function resolvedServiceDomains(sandboxId, deploymentId, preview) {
13820
14350
  ]));
13821
14351
  }
13822
14352
  function resolvedServiceDomain(sandboxId, deploymentId, service, preview) {
13823
- const routeId = `${sandboxId.replaceAll("-", "").slice(0, 8)}-${createHash2("sha256").update(`${sandboxId}/${deploymentId}/${service}`).digest("hex").slice(0, 8)}`;
14353
+ const routeId = `${sandboxId.replaceAll("-", "").slice(0, 8)}-${createHash2("sha256").update(`${sandboxId}/${service}`).digest("hex").slice(0, 8)}`;
13824
14354
  const template = preview.domains[service] ?? `{route}.${preview.baseDomain}`;
13825
14355
  return template.replaceAll("{route}", routeId).replaceAll("{sandbox}", sandboxId.replaceAll("-", "").slice(0, 8)).replaceAll("{revision}", deploymentId.replaceAll("-", "").slice(0, 8)).replaceAll("{service}", service).toLowerCase();
13826
14356
  }
@@ -15120,7 +15650,16 @@ var HELP = `Sandblocks CLI
15120
15650
  sandblocks secret <list|set|delete> --project <id> --stack <id> --app <id>
15121
15651
  [--environment <id>] [--name <name>] [--value <value>] [--id <id>] [--json]
15122
15652
  sandblocks artifact push <directory> --project <id> --stack <id> [--pool <pool>] [--json]
15653
+ sandblocks worker <list|create|rotate-secret|drain|resume|approve-update> --organization <id>
15654
+ [--worker <uuid>] [--name <name>] [--type <build|deployment>]
15655
+ [--pool-id <uuid>] [--project <id>] [--version <version>] [--json]
15656
+ sandblocks registry <list|create|assign> --organization <id> [--project <id>]
15657
+ [--name <name>] [--endpoint <url>] [--registry <uuid>]
15658
+ [--purpose <build-cache|artifact-publication|deployment-pull>] [--json]
15123
15659
  sandblocks sandbox up [directory] [--environment <id>] [--project <id>] [--build-pool <pool>]
15660
+ sandblocks sandbox dev [directory] [--environment <id>] [--checks <smoke|full|none>]
15661
+ sandblocks sandbox sync [directory] [--environment <id>] [--watch] [--checks <smoke|full|none>]
15662
+ sandblocks sandbox check [directory] [--environment <id>] [--profile <smoke|full>]
15124
15663
  sandblocks sandbox create [directory] [--environment <id>] [--project <id>]
15125
15664
  sandblocks sandbox deploy [directory] [--environment <id>] [--build-pool <pool>]
15126
15665
  [--artifact <image@sha256:digest>] [--deployment-id <uuid>] [--resume]
@@ -15179,10 +15718,7 @@ async function run3(argv = process.argv.slice(2)) {
15179
15718
  parse: parse2,
15180
15719
  option: option2,
15181
15720
  load,
15182
- source: async (root) => {
15183
- const bundle = await createSourceTar(root);
15184
- return { bundle, files: sourceFileCount };
15185
- }
15721
+ source: createSourceTar
15186
15722
  })
15187
15723
  });
15188
15724
  else if (command === "runtime")
@@ -15193,6 +15729,10 @@ async function run3(argv = process.argv.slice(2)) {
15193
15729
  await secret(args);
15194
15730
  else if (command === "artifact")
15195
15731
  await artifact(args);
15732
+ else if (command === "worker")
15733
+ await worker(args);
15734
+ else if (command === "registry")
15735
+ await registry(args);
15196
15736
  else if (command === "sandbox")
15197
15737
  await sandbox(args);
15198
15738
  else if (command === "sdk")
@@ -15303,21 +15843,21 @@ async function doctor(args) {
15303
15843
  { label: "artifact publish", kind: "workspace.artifact.publish", role: "build", memory: 1024 ** 3 },
15304
15844
  { label: "service deploy", kind: "workspace.service.deploy", role: "runtime", memory: 6 * 1024 ** 3 }
15305
15845
  ];
15306
- const explanations = await Promise.all(checks.map(async (check) => {
15846
+ const explanations = await Promise.all(checks.map(async (check2) => {
15307
15847
  const query = new URLSearchParams({
15308
- kind: check.kind,
15309
- role: check.role,
15310
- memoryReservationBytes: String(check.memory)
15848
+ kind: check2.kind,
15849
+ role: check2.role,
15850
+ memoryReservationBytes: String(check2.memory)
15311
15851
  });
15312
15852
  const result = await sandblocksRequest(apiUrl, apiKey, `/v1/projects/${encodeURIComponent(projectId)}/placement/explain?${query}`);
15313
- return { ...check, explanation: result.explanation };
15853
+ return { ...check2, explanation: result.explanation };
15314
15854
  }));
15315
15855
  if (options.json)
15316
15856
  console.log(JSON.stringify({ projectId, explanations }, null, 2));
15317
15857
  else
15318
- for (const check of explanations) {
15319
- const explanation = objectRecord(check.explanation);
15320
- console.log(`${check.label.padEnd(16)} ${explanation.eligible ? "eligible" : "blocked"}`);
15858
+ for (const check2 of explanations) {
15859
+ const explanation = objectRecord(check2.explanation);
15860
+ console.log(`${check2.label.padEnd(16)} ${explanation.eligible ? "eligible" : "blocked"}`);
15321
15861
  if (!explanation.eligible && Array.isArray(explanation.candidates))
15322
15862
  for (const candidate of explanation.candidates) {
15323
15863
  const row = objectRecord(candidate);
@@ -15479,6 +16019,102 @@ async function artifact(args) {
15479
16019
  console.log(`deploy with: sandblocks sandbox deploy . --artifact ${result.image} --deployment-id ${result.deploymentId}`);
15480
16020
  }
15481
16021
  }
16022
+ async function worker(args) {
16023
+ const [action = "list", ...rest] = args;
16024
+ const options = parse2(rest);
16025
+ const organizationId = option2(options, "organization");
16026
+ const apiUrl = (option2(options, "api-url") ?? process.env.SANDBLOCKS_API_URL ?? "").replace(/\/$/, "");
16027
+ const apiKey = option2(options, "api-key") ?? process.env.SANDBLOCKS_API_KEY;
16028
+ if (!organizationId)
16029
+ throw new Error("worker command requires --organization");
16030
+ if (!apiUrl || !apiKey)
16031
+ throw new Error("worker command requires Sandblocks API URL and key");
16032
+ const client2 = new SandblocksClient({ baseUrl: apiUrl, apiKey });
16033
+ let body;
16034
+ if (action === "list")
16035
+ body = await client2.listWorkers(organizationId);
16036
+ else if (action === "create") {
16037
+ const name = option2(options, "name");
16038
+ const type = option2(options, "type");
16039
+ if (!name || type !== "build" && type !== "deployment")
16040
+ throw new Error("worker create requires --name and --type build|deployment");
16041
+ body = await client2.createWorker({
16042
+ organizationId,
16043
+ name,
16044
+ type,
16045
+ ...option2(options, "pool-id") ? { poolId: option2(options, "pool-id") } : {},
16046
+ ...option2(options, "project") ? { projectIds: [option2(options, "project")] } : {}
16047
+ });
16048
+ } else {
16049
+ const workerId = option2(options, "worker");
16050
+ if (!workerId)
16051
+ throw new Error(`worker ${action} requires --worker`);
16052
+ if (action === "rotate-secret")
16053
+ body = await client2.rotateWorkerSecret(organizationId, workerId);
16054
+ else if (action === "move") {
16055
+ const targetWorkerIds = (option2(options, "target-workers") ?? "").split(",").map((value) => value.trim()).filter(Boolean);
16056
+ if (!targetWorkerIds.length)
16057
+ throw new Error("worker move requires --target-workers <id,id,...>");
16058
+ body = await sandblocksRequest(apiUrl, apiKey, `/v1/organizations/${encodeURIComponent(organizationId)}/workers/${encodeURIComponent(workerId)}/move`, { method: "POST", body: JSON.stringify({ targetWorkerIds }) });
16059
+ } else if (action === "approve-update") {
16060
+ const version = option2(options, "version");
16061
+ if (!version)
16062
+ throw new Error("worker approve-update requires --version");
16063
+ body = await client2.approveWorkerUpdate(organizationId, workerId, version);
16064
+ } else if (action === "drain" || action === "resume")
16065
+ body = await sandblocksRequest(apiUrl, apiKey, `/v1/organizations/${encodeURIComponent(organizationId)}/workers/${encodeURIComponent(workerId)}/${action}`, { method: "POST" });
16066
+ else
16067
+ throw new Error(`unsupported worker command '${action}'`);
16068
+ }
16069
+ if (options.json || action !== "list")
16070
+ console.log(JSON.stringify(body, null, 2));
16071
+ else
16072
+ for (const candidate of Array.isArray(body.workers) ? body.workers : []) {
16073
+ const row = objectRecord(candidate);
16074
+ console.log(`${row.id ?? "unknown"} ${row.name ?? "unknown"} ${row.type ?? "unknown"} ${row.state ?? "unknown"}`);
16075
+ }
16076
+ }
16077
+ async function registry(args) {
16078
+ const [action = "list", ...rest] = args;
16079
+ const options = parse2(rest);
16080
+ const organizationId = option2(options, "organization");
16081
+ const apiUrl = (option2(options, "api-url") ?? process.env.SANDBLOCKS_API_URL ?? "").replace(/\/$/, "");
16082
+ const apiKey = option2(options, "api-key") ?? process.env.SANDBLOCKS_API_KEY;
16083
+ if (!organizationId)
16084
+ throw new Error("registry command requires --organization");
16085
+ if (!apiUrl || !apiKey)
16086
+ throw new Error("registry command requires Sandblocks API URL and key");
16087
+ const client2 = new SandblocksClient({ baseUrl: apiUrl, apiKey });
16088
+ let body;
16089
+ if (action === "list")
16090
+ body = await client2.listRegistries(organizationId);
16091
+ else if (action === "create") {
16092
+ const name = option2(options, "name");
16093
+ const endpoint = option2(options, "endpoint");
16094
+ if (!name || !endpoint)
16095
+ throw new Error("registry create requires --name and --endpoint");
16096
+ body = await client2.createRegistry({ organizationId, name, endpoint });
16097
+ } else if (action === "assign") {
16098
+ const projectId = option2(options, "project");
16099
+ const registryId = option2(options, "registry");
16100
+ const purpose = option2(options, "purpose");
16101
+ if (!projectId || !registryId || !["build-cache", "artifact-publication", "deployment-pull"].includes(purpose ?? ""))
16102
+ throw new Error("registry assign requires --project, --registry, and a valid --purpose");
16103
+ body = await client2.assignProjectRegistry(projectId, {
16104
+ purpose,
16105
+ registryId,
16106
+ ...option2(options, "repository-prefix") ? { repositoryPrefix: option2(options, "repository-prefix") } : {}
16107
+ });
16108
+ } else
16109
+ throw new Error(`unsupported registry command '${action}'`);
16110
+ if (options.json || action !== "list")
16111
+ console.log(JSON.stringify(body, null, 2));
16112
+ else
16113
+ for (const candidate of Array.isArray(body.registries) ? body.registries : []) {
16114
+ const row = objectRecord(candidate);
16115
+ console.log(`${row.id ?? "unknown"} ${row.name ?? "unknown"} ${row.endpoint ?? "unknown"}`);
16116
+ }
16117
+ }
15482
16118
  function printResources(kind, action, body) {
15483
16119
  if (action === "settings") {
15484
16120
  console.log(JSON.stringify(body.settings ?? {}, null, 2));
@@ -15505,10 +16141,7 @@ async function sandbox(args) {
15505
16141
  parse: parse2,
15506
16142
  option: option2,
15507
16143
  load,
15508
- source: async (root2) => {
15509
- const bundle2 = await createSourceTar(root2);
15510
- return { bundle: bundle2, files: sourceFileCount };
15511
- }
16144
+ source: createSourceTar
15512
16145
  });
15513
16146
  }
15514
16147
  if (subcommand === "deploy")
@@ -15533,7 +16166,8 @@ async function sandbox(args) {
15533
16166
  throw new Error("sandbox import requires --api-url or SANDBLOCKS_API_URL");
15534
16167
  if (!apiKey)
15535
16168
  throw new Error("sandbox import requires --api-key or SANDBLOCKS_API_KEY");
15536
- const bundle = await createSourceTar(root);
16169
+ const source = await createSourceTar(root);
16170
+ const bundle = source.bundle;
15537
16171
  const response = await fetch(`${apiUrl}/v1/projects/${encodeURIComponent(projectId)}/workspaces/import`, {
15538
16172
  method: "POST",
15539
16173
  headers: {
@@ -15550,11 +16184,11 @@ async function sandbox(args) {
15550
16184
  if (!response.ok)
15551
16185
  throw new Error(String(submitted.error ?? `sandbox import failed (${response.status})`));
15552
16186
  const operation = options.wait ? await waitForOperation(apiUrl, apiKey, String(submitted.operation.id)) : submitted.operation;
15553
- const output = { workspaceId, files: sourceFileCount, bytes: bundle.byteLength, operation };
16187
+ const output = { workspaceId, files: source.files, bytes: bundle.byteLength, operation };
15554
16188
  if (options.json)
15555
16189
  console.log(JSON.stringify(output, null, 2));
15556
16190
  else {
15557
- console.log(`source ${sourceFileCount} files (${bundle.byteLength} bytes)`);
16191
+ console.log(`source ${source.files} files (${bundle.byteLength} bytes)`);
15558
16192
  console.log(`workspace ${workspaceId}`);
15559
16193
  console.log(`operation ${operation.id} (${operation.state})`);
15560
16194
  if (operation.result?.container)
@@ -15647,7 +16281,6 @@ async function sandboxPromote(args) {
15647
16281
  else
15648
16282
  console.log(`promoted ${sandboxId} to production`);
15649
16283
  }
15650
- var sourceFileCount = 0;
15651
16284
  async function createPrebuiltTar(root) {
15652
16285
  const names = [];
15653
16286
  const visit2 = async (directory, prefix = "") => {
@@ -15693,7 +16326,7 @@ async function createPrebuiltTar(root) {
15693
16326
  }
15694
16327
  return output;
15695
16328
  }
15696
- async function createSourceTar(root) {
16329
+ async function createSourceTar(root, previous) {
15697
16330
  const process2 = Bun.spawn(["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
15698
16331
  cwd: root,
15699
16332
  stdout: "pipe",
@@ -15714,6 +16347,7 @@ async function createSourceTar(root) {
15714
16347
  if (names.length > 20000)
15715
16348
  throw new Error("local source contains more than 20000 files");
15716
16349
  const chunks = [];
16350
+ const manifest = {};
15717
16351
  let total = 1024;
15718
16352
  for (const name of names) {
15719
16353
  if (name.includes("\x00") || name.includes("\\") || name.startsWith("/") || name.split("/").includes("..")) {
@@ -15733,6 +16367,11 @@ async function createSourceTar(root) {
15733
16367
  if (!info.isFile())
15734
16368
  continue;
15735
16369
  const contents = new Uint8Array(await readFile8(file));
16370
+ const mode = info.mode & 511;
16371
+ const sha256 = createHash3("sha256").update(contents).digest("hex");
16372
+ manifest[name] = { sha256, mode, bytes: contents.byteLength };
16373
+ if (previous?.[name]?.sha256 === sha256 && previous[name]?.mode === mode)
16374
+ continue;
15736
16375
  const header = tarHeader(name, contents.byteLength, info.mode, Math.floor(info.mtimeMs / 1000));
15737
16376
  const padding = (512 - contents.byteLength % 512) % 512;
15738
16377
  total += 512 + contents.byteLength + padding;
@@ -15740,9 +16379,15 @@ async function createSourceTar(root) {
15740
16379
  throw new Error("local source bundle exceeds 256 MiB");
15741
16380
  chunks.push(header, contents, new Uint8Array(padding));
15742
16381
  }
15743
- sourceFileCount = chunks.length / 3;
15744
- if (!sourceFileCount)
16382
+ if (!Object.keys(manifest).length)
15745
16383
  throw new Error("local directory has no regular source files");
16384
+ if (previous) {
16385
+ const contents = new TextEncoder().encode(JSON.stringify({ version: 1, files: manifest }));
16386
+ const name = ".sandblocks-sync-manifest.json";
16387
+ const padding = (512 - contents.byteLength % 512) % 512;
16388
+ total += 512 + contents.byteLength + padding;
16389
+ chunks.push(tarHeader(name, contents.byteLength, 384, Math.floor(Date.now() / 1000)), contents, new Uint8Array(padding));
16390
+ }
15746
16391
  chunks.push(new Uint8Array(1024));
15747
16392
  const output = new Uint8Array(total);
15748
16393
  let offset = 0;
@@ -15750,7 +16395,7 @@ async function createSourceTar(root) {
15750
16395
  output.set(chunk, offset);
15751
16396
  offset += chunk.byteLength;
15752
16397
  }
15753
- return output;
16398
+ return { bundle: output, files: Object.keys(manifest).length, manifest };
15754
16399
  }
15755
16400
  async function gitIgnoredFiles(root, names) {
15756
16401
  if (!names.length)
@@ -15936,7 +16581,8 @@ function parse2(args) {
15936
16581
  "allow-host",
15937
16582
  "rootless",
15938
16583
  "allow-unpinned-images",
15939
- "read-only-root"
16584
+ "read-only-root",
16585
+ "resume"
15940
16586
  ].includes(rawKey)) {
15941
16587
  output[rawKey] = inline === undefined ? true : inline !== "false";
15942
16588
  continue;
@@ -16003,4 +16649,4 @@ export {
16003
16649
  parse2 as parse
16004
16650
  };
16005
16651
 
16006
- //# debugId=E7F4E37BF2F49E8E64756E2164756E21
16652
+ //# debugId=4AF73097AAEEBCD964756E2164756E21