@sandblocks/cli 0.2.0 → 0.4.0-b.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
@@ -11117,7 +11117,7 @@ var EnvironmentValueSchema = exports_external.object({
11117
11117
  routing: exports_external.object({
11118
11118
  baseDomain: exports_external.string().min(1).max(253).default("sandblocks.dev"),
11119
11119
  domains: exports_external.record(id, exports_external.string().min(1).max(253)).default({}),
11120
- stableDomains: exports_external.record(id, exports_external.string().min(1).max(253)).default({}),
11120
+ stableDomains: exports_external.record(id, exports_external.union([exports_external.string().min(1).max(253), exports_external.array(exports_external.string().min(1).max(253)).min(1)])).default({}),
11121
11121
  sso: exports_external.boolean().optional()
11122
11122
  }).strict().default({})
11123
11123
  }).strict();
@@ -11153,17 +11153,19 @@ function validateReferences(manifest, ctx) {
11153
11153
  validateReferencesTo(ctx, environment.checks, checkIds, ["environments", index, "checks"], "check");
11154
11154
  validateReferencesTo(ctx, Object.keys(environment.routing.domains), new Set(environment.services), ["environments", index, "routing", "domains"], "environment service");
11155
11155
  validateReferencesTo(ctx, Object.keys(environment.routing.stableDomains), new Set(environment.services), ["environments", index, "routing", "stableDomains"], "environment service");
11156
- for (const [service, template] of Object.entries({
11156
+ for (const [service, configured] of Object.entries({
11157
11157
  ...environment.routing.domains,
11158
11158
  ...environment.routing.stableDomains
11159
11159
  })) {
11160
- const expanded = template.replaceAll(/\{(?:sandbox|revision|service)\}/g, "preview");
11161
- if (expanded.includes("{") || !/^[a-z0-9.-]+$/.test(expanded.toLowerCase())) {
11162
- ctx.addIssue({
11163
- code: exports_external.ZodIssueCode.custom,
11164
- path: ["environments", index, "routing", "domains", service],
11165
- message: "domain template may use only {sandbox}, {revision}, and {service}"
11166
- });
11160
+ for (const template of Array.isArray(configured) ? configured : [configured]) {
11161
+ const expanded = template.replaceAll(/\{(?:sandbox|revision|service)\}/g, "preview");
11162
+ if (expanded.includes("{") || !/^[a-z0-9.-]+$/.test(expanded.toLowerCase())) {
11163
+ ctx.addIssue({
11164
+ code: exports_external.ZodIssueCode.custom,
11165
+ path: ["environments", index, "routing", "domains", service],
11166
+ message: "domain template may use only {sandbox}, {revision}, and {service}"
11167
+ });
11168
+ }
11167
11169
  }
11168
11170
  }
11169
11171
  }
@@ -11707,9 +11709,11 @@ async function up(args, dependencies) {
11707
11709
  try {
11708
11710
  await deployUnlocked(args, dependencies);
11709
11711
  } catch (error) {
11710
- await down(args, dependencies).catch(() => {
11711
- return;
11712
- });
11712
+ if (process.env.SANDBLOCKS_RETAIN_FAILED !== "true") {
11713
+ await down(args, dependencies).catch(() => {
11714
+ return;
11715
+ });
11716
+ }
11713
11717
  throw error;
11714
11718
  }
11715
11719
  });
@@ -11831,9 +11835,9 @@ async function deployUnlocked(args, dependencies) {
11831
11835
  const loaded = await dependencies.load(context.root, dependencies.option(context.options, "config") ?? dependencies.option(context.options, "manifest"));
11832
11836
  const stack = loaded.manifest.name ?? context.state.repository;
11833
11837
  const preview = await applyManagedRoutingPolicy(context, stack, selectPreview(loaded, context.state.environment));
11834
- const targetHost = process.env.SANDBLOCKS_SANDBOX_TARGET_HOST ?? preview.targetHost;
11838
+ const targetHost = await resolveAssignedTargetHost(context, process.env.SANDBLOCKS_SANDBOX_TARGET_HOST ?? preview.targetHost);
11835
11839
  if (!targetHost)
11836
- throw new Error("preview targetHost or SANDBLOCKS_SANDBOX_TARGET_HOST is required");
11840
+ throw new Error("assigned host targetHost label or preview targetHost is required");
11837
11841
  const deploymentId = randomUUID();
11838
11842
  const environmentSnapshotId = await createEnvironmentSnapshot(context);
11839
11843
  const serviceUrls = expectedPreviewServiceUrls(context.state.sandboxId, deploymentId, context.state.repository, preview);
@@ -11901,6 +11905,7 @@ async function deployUnlocked(args, dependencies) {
11901
11905
  try {
11902
11906
  if (preview.steps.length) {
11903
11907
  const checkRoutes = sandbox.previewUrls ?? [];
11908
+ await waitForPublishedRoutes(checkRoutes);
11904
11909
  const serviceUrls2 = Object.fromEntries(checkRoutes.flatMap((route) => route.service ? [
11905
11910
  [
11906
11911
  `SANDBLOCKS_SERVICE_${route.service.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_URL`,
@@ -11931,8 +11936,9 @@ async function deployUnlocked(args, dependencies) {
11931
11936
  });
11932
11937
  const checked = await wait(context.apiUrl, context.apiKey, check.operation.id, 60 * 60000);
11933
11938
  checkEvidence = checked.result?.checks ?? [];
11934
- if (checked.result?.passed !== true)
11935
- throw new Error("one or more post-deploy checks failed");
11939
+ if (checked.result?.passed !== true) {
11940
+ throw new Error(`one or more post-deploy checks failed: ${JSON.stringify(checkEvidence)}`);
11941
+ }
11936
11942
  sandbox = await recordSandbox(context, loaded, preview, routes, [...operation.result?.healthEvidence ?? [], ...checkEvidence], images, "available");
11937
11943
  }
11938
11944
  } catch (error) {
@@ -12054,6 +12060,14 @@ async function down(args, dependencies) {
12054
12060
  await rm(context.file, { force: true });
12055
12061
  console.log(`sandbox ${context.state.sandboxId} destroyed`);
12056
12062
  }
12063
+ async function resolveAssignedTargetHost(context, fallback) {
12064
+ if (fallback?.trim())
12065
+ return fallback.trim();
12066
+ const body = await request(context.apiUrl, context.apiKey, "/v1/hosts");
12067
+ const host = Array.isArray(body.hosts) ? body.hosts.find((candidate) => candidate?.id === context.state.hostId) : undefined;
12068
+ const targetHost = host?.labels?.targetHost;
12069
+ return typeof targetHost === "string" && targetHost.trim() ? targetHost.trim() : undefined;
12070
+ }
12057
12071
  async function recordSandbox(context, loaded, preview, serviceRoutes, healthEvidence, images, status2) {
12058
12072
  const body = await request(context.apiUrl, context.apiKey, `/v1/projects/${encodeURIComponent(context.state.projectId)}/sandboxes`, {
12059
12073
  method: "POST",
@@ -12122,7 +12136,7 @@ async function destroyDeploymentById(context, deploymentId) {
12122
12136
  await wait(context.apiUrl, context.apiKey, body.operation.id);
12123
12137
  }
12124
12138
  async function destroyWorkspace(context, idempotencyKey) {
12125
- const response = await fetch(`${context.apiUrl}/v1/projects/${encodeURIComponent(context.state.projectId)}/workspaces/${encodeURIComponent(context.state.workspaceId)}`, {
12139
+ const response = await fetchWithRetry(`${context.apiUrl}/v1/projects/${encodeURIComponent(context.state.projectId)}/workspaces/${encodeURIComponent(context.state.workspaceId)}`, {
12126
12140
  method: "DELETE",
12127
12141
  headers: {
12128
12142
  "content-type": "application/json",
@@ -12139,7 +12153,7 @@ async function destroyWorkspace(context, idempotencyKey) {
12139
12153
  }
12140
12154
  async function importSource(input) {
12141
12155
  const source = await input.source(input.root);
12142
- const response = await fetch(`${input.apiUrl}/v1/projects/${encodeURIComponent(input.projectId)}/workspaces/import`, {
12156
+ const response = await fetchWithRetry(`${input.apiUrl}/v1/projects/${encodeURIComponent(input.projectId)}/workspaces/import`, {
12143
12157
  method: "POST",
12144
12158
  headers: {
12145
12159
  "content-type": "application/x-tar",
@@ -12202,7 +12216,7 @@ async function request(apiUrl, apiKey, pathname, init = {}) {
12202
12216
  headers.set("x-sandblocks-api-key", apiKey);
12203
12217
  if (init.body)
12204
12218
  headers.set("content-type", "application/json");
12205
- const response = await fetch(`${apiUrl}${pathname}`, { ...init, headers });
12219
+ const response = await fetchWithRetry(`${apiUrl}${pathname}`, { ...init, headers });
12206
12220
  const body = await response.json().catch(() => ({}));
12207
12221
  if (!response.ok)
12208
12222
  throw new Error(String(body.error ?? `Sandblocks request failed (${response.status})`));
@@ -12213,12 +12227,61 @@ async function leaseRequest(context, pathname, init = {}) {
12213
12227
  headers.set("authorization", `Bearer ${context.state.leaseToken}`);
12214
12228
  if (init.body)
12215
12229
  headers.set("content-type", "application/json");
12216
- const response = await fetch(`${context.apiUrl}${pathname}`, { ...init, headers });
12230
+ const response = await fetchWithRetry(`${context.apiUrl}${pathname}`, { ...init, headers });
12217
12231
  const body = await response.json().catch(() => ({}));
12218
12232
  if (!response.ok)
12219
12233
  throw new Error(String(body.error ?? `Sandblocks lease request failed (${response.status})`));
12220
12234
  return body;
12221
12235
  }
12236
+ async function waitForPublishedRoutes(routes, timeoutMs = 90000) {
12237
+ const deadline = Date.now() + timeoutMs;
12238
+ let pending = routes.map((route) => ({
12239
+ label: route.url,
12240
+ url: route.service === "api" ? new URL("/health", route.url).toString() : route.url
12241
+ }));
12242
+ while (pending.length && Date.now() < deadline) {
12243
+ const checks = await Promise.all(pending.map(async (route) => {
12244
+ try {
12245
+ const response = await fetch(route.url, { redirect: "manual" });
12246
+ await response.body?.cancel().catch(() => {
12247
+ return;
12248
+ });
12249
+ return response.status >= 200 && response.status < 400 ? undefined : route;
12250
+ } catch {
12251
+ return route;
12252
+ }
12253
+ }));
12254
+ pending = checks.filter((route) => typeof route === "object" && route !== null);
12255
+ if (pending.length)
12256
+ await Bun.sleep(2000);
12257
+ }
12258
+ if (pending.length) {
12259
+ throw new Error(`preview routes did not become ready: ${pending.map((route) => route.label).join(", ")}`);
12260
+ }
12261
+ }
12262
+ async function fetchWithRetry(url, init = {}) {
12263
+ const method = (init.method ?? "GET").toUpperCase();
12264
+ const headers = new Headers(init.headers);
12265
+ const safeToRetry = method === "GET" || method === "HEAD" || headers.has("idempotency-key");
12266
+ const attempts = safeToRetry ? 8 : 1;
12267
+ let lastError;
12268
+ for (let attempt = 0;attempt < attempts; attempt += 1) {
12269
+ try {
12270
+ const response = await fetch(url, init);
12271
+ if (![409, 429, 502, 503, 504].includes(response.status) || attempt === attempts - 1)
12272
+ return response;
12273
+ await response.body?.cancel().catch(() => {
12274
+ return;
12275
+ });
12276
+ } catch (error) {
12277
+ lastError = error;
12278
+ if (attempt === attempts - 1)
12279
+ throw error;
12280
+ }
12281
+ await Bun.sleep(Math.min(5000, 250 * 2 ** attempt));
12282
+ }
12283
+ throw lastError instanceof Error ? lastError : new Error("Sandblocks request failed");
12284
+ }
12222
12285
  async function wait(apiUrl, apiKey, operationId, timeoutMs = 30 * 60000) {
12223
12286
  const deadline = Date.now() + timeoutMs;
12224
12287
  while (Date.now() < deadline) {
@@ -12277,7 +12340,8 @@ function expectedPreviewServiceUrls(sandboxId, deploymentId, stack, preview) {
12277
12340
  return [];
12278
12341
  const name = service.id.toUpperCase().replace(/[^A-Z0-9]/g, "_");
12279
12342
  const host = resolvedServiceDomain(sandboxId, deploymentId, service.id, preview);
12280
- const stableHost = preview.stableDomains[service.id] ?? `${preview.deploymentType}-${stack}-${service.id}.${preview.baseDomain}`;
12343
+ const configuredStableHosts = preview.stableDomains[service.id];
12344
+ const stableHost = (Array.isArray(configuredStableHosts) ? configuredStableHosts[0] : configuredStableHosts) ?? `${preview.deploymentType}-${stack}-${service.id}.${preview.baseDomain}`;
12281
12345
  return [
12282
12346
  [`SANDBLOCKS_SERVICE_${name}_URL`, `https://${host}`],
12283
12347
  [`SANDBLOCKS_STABLE_SERVICE_${name}_URL`, `https://${stableHost}`]
@@ -14266,11 +14330,13 @@ var HELP = `Sandblocks CLI
14266
14330
  sandblocks register [directory] --project <id> [--repository-url <url>]
14267
14331
  [--api-url <url>] [--api-key <key>] [--json]
14268
14332
  sandblocks doctor [directory] [--api-url <url>]
14333
+ sandblocks whoami [directory] [--api-url <url>] [--api-key <key>] [--json]
14269
14334
  sandblocks sandbox up [directory] [--environment <id>] [--project <id>]
14270
14335
  sandblocks sandbox create [directory] [--environment <id>] [--project <id>]
14271
14336
  sandblocks sandbox deploy [directory] [--environment <id>]
14272
14337
  sandblocks sandbox redeploy [directory] [--environment <id>]
14273
14338
  sandblocks sandbox status [directory] [--environment <id>] [--json]
14339
+ sandblocks sandbox promote --project <id> --sandbox <id> [--json]
14274
14340
  sandblocks sandbox renew [directory] [--environment <id>] [--ttl-seconds <n>]
14275
14341
  sandblocks sandbox exec [directory] [--environment <id>] -- <typed-command>
14276
14342
  sandblocks sandbox git [directory] [--environment <id>] <status|diff|stage|checkout>
@@ -14311,6 +14377,8 @@ async function run2(argv = process.argv.slice(2)) {
14311
14377
  await register(args);
14312
14378
  else if (command === "doctor")
14313
14379
  await doctor(args);
14380
+ else if (command === "whoami")
14381
+ await whoami(args);
14314
14382
  else if (command === "sandbox")
14315
14383
  await sandbox(args);
14316
14384
  else if (command === "sdk")
@@ -14415,6 +14483,30 @@ async function doctor(args) {
14415
14483
  const gitDetected = await stat3(path4.join(root, ".git")).then(() => true).catch(() => false);
14416
14484
  console.log(`git ${gitDetected ? "ok" : "not detected"}`);
14417
14485
  }
14486
+ async function whoami(args) {
14487
+ const options = parse(args);
14488
+ const apiUrl = (option(options, "api-url") ?? process.env.SANDBLOCKS_API_URL ?? "").replace(/\/$/, "");
14489
+ const apiKey = option(options, "api-key") ?? process.env.SANDBLOCKS_API_KEY;
14490
+ if (!apiUrl)
14491
+ throw new Error("whoami requires --api-url or SANDBLOCKS_API_URL");
14492
+ if (!apiKey)
14493
+ throw new Error("whoami requires --api-key or SANDBLOCKS_API_KEY");
14494
+ const response = await fetch(`${apiUrl}/v1/session`, {
14495
+ headers: { "x-sandblocks-api-key": apiKey }
14496
+ });
14497
+ const body = await response.json().catch(() => ({}));
14498
+ if (!response.ok)
14499
+ throw new Error(String(body.error ?? `session lookup failed (${response.status})`));
14500
+ if (options.json) {
14501
+ console.log(JSON.stringify(body, null, 2));
14502
+ return;
14503
+ }
14504
+ const session = body.session ?? {};
14505
+ console.log(`subject ${session.subject ?? "unknown"}`);
14506
+ console.log(`source ${session.source ?? "unknown"}`);
14507
+ console.log(`project ${session.projectId ?? "unscoped"}`);
14508
+ console.log(`scopes ${(session.scopes ?? []).join(",") || "none"}`);
14509
+ }
14418
14510
  async function sandbox(args) {
14419
14511
  const [subcommand, ...rest] = args;
14420
14512
  if (isStatefulSandboxCommand(subcommand) && !(subcommand === "deploy" && rest.includes("--workspace"))) {
@@ -14430,6 +14522,8 @@ async function sandbox(args) {
14430
14522
  }
14431
14523
  if (subcommand === "deploy")
14432
14524
  return sandboxDeploy(rest);
14525
+ if (subcommand === "promote")
14526
+ return sandboxPromote(rest);
14433
14527
  if (subcommand !== "import")
14434
14528
  throw new Error("unsupported sandbox command");
14435
14529
  const options = parse(rest);
@@ -14474,6 +14568,26 @@ async function sandbox(args) {
14474
14568
  console.log(`container ${operation.result.container}`);
14475
14569
  }
14476
14570
  }
14571
+ async function sandboxPromote(args) {
14572
+ const options = parse(args);
14573
+ const projectId = option(options, "project");
14574
+ const sandboxId = option(options, "sandbox");
14575
+ const apiUrl = (option(options, "api-url") ?? process.env.SANDBLOCKS_API_URL ?? "").replace(/\/$/, "");
14576
+ const apiKey = option(options, "api-key") ?? process.env.SANDBLOCKS_API_KEY;
14577
+ if (!projectId)
14578
+ throw new Error("sandbox promote requires --project");
14579
+ if (!sandboxId)
14580
+ throw new Error("sandbox promote requires --sandbox");
14581
+ if (!apiUrl)
14582
+ throw new Error("sandbox promote requires --api-url or SANDBLOCKS_API_URL");
14583
+ if (!apiKey)
14584
+ throw new Error("sandbox promote requires --api-key or SANDBLOCKS_API_KEY");
14585
+ const result = await sandblocksRequest(apiUrl, apiKey, `/v1/projects/${encodeURIComponent(projectId)}/routing-aliases/promote`, { method: "POST", body: JSON.stringify({ sandboxId }) });
14586
+ if (options.json)
14587
+ console.log(JSON.stringify(result, null, 2));
14588
+ else
14589
+ console.log(`promoted ${sandboxId} to production`);
14590
+ }
14477
14591
  var sourceFileCount = 0;
14478
14592
  async function createSourceTar(root) {
14479
14593
  const process2 = Bun.spawn(["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
@@ -14785,4 +14899,4 @@ export {
14785
14899
  parse
14786
14900
  };
14787
14901
 
14788
- //# debugId=AE8677F9C15C750D64756E2164756E21
14902
+ //# debugId=D1104053F8254F7864756E2164756E21