lakebed 0.0.3 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -93,6 +93,7 @@ lakebed new <name> [--template todo]
93
93
  lakebed dev <capsule-dir> [--port 3000]
94
94
  lakebed build <capsule-dir> --target anonymous [--out .lakebed/artifacts/app.json] [--json]
95
95
  lakebed deploy [capsule-dir] [--ttl 7d] [--api <url>] [--json]
96
+ lakebed claim [capsule-dir] [--api <url>] [--json]
96
97
  lakebed anonymous-server [--port 8787] [--public-root-url <url>] [--app-base-domain <domain>]
97
98
  lakebed inspect <deploy-id-or-url> [--api <url>] [--json]
98
99
  lakebed db list [deploy-id-or-url] [--port 3000]
@@ -143,7 +144,7 @@ LAKEBED_GITHUB_CLIENT_SECRET=...
143
144
  LAKEBED_SESSION_SECRET=...
144
145
  ```
145
146
 
146
- Claimed deploys are listed at `/deploys` on the deploy API origin. They keep the same resource limits as anonymous deploys. The current hosted runner still uses the anonymous interpreter artifact, so outbound `fetch` remains blocked until Lakebed has a claimed runtime target that can safely execute server code.
147
+ Claimed deploys are listed at `/deploys` on the deploy API origin. They keep the same resource limits as anonymous deploys. Anonymous deploys cannot use outbound `fetch`; after a deploy is claimed, `lakebed deploy` can update it with a source-backed server artifact that supports async handlers and server-side fetch.
147
148
 
148
149
  ## Admin Dashboard
149
150
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lakebed",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "Agent-native CLI and runtime for building and deploying Lakebed capsules.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -2794,7 +2794,7 @@ export async function startAnonymousServer({
2794
2794
  }
2795
2795
 
2796
2796
  const body = await readJsonBody(req);
2797
- const payload = validateAnonymousDeployPayload(body);
2797
+ const payload = validateAnonymousDeployPayload(body, { allowClaimedSource: Boolean(currentDeploy.ownerId) });
2798
2798
  const deploy = await resolvedStore.updateDeploy({
2799
2799
  appBaseDomain: resolvedAppBaseDomain,
2800
2800
  artifact: payload.artifact,
package/src/anonymous.js CHANGED
@@ -17,6 +17,7 @@ export const DEFAULT_ANONYMOUS_LIMITS = {
17
17
 
18
18
  const expressionOps = new Set(["arg", "auth", "call", "row"]);
19
19
  const authFields = new Set(["displayName", "email", "emailVerified", "isAuthenticated", "isGuest", "name", "picture", "provider", "userId"]);
20
+ const sourceModuleCache = new Map();
20
21
 
21
22
  export class AnonymousCompilerError extends Error {
22
23
  constructor(diagnostics) {
@@ -555,6 +556,77 @@ export async function createAnonymousArtifact({ app, clientOut, sourceStore, ver
555
556
  };
556
557
  }
557
558
 
559
+ export async function createClaimedArtifact({ app, clientOut, serverOut, sourceStore, version = "0.0.3" }) {
560
+ if (!serverOut) {
561
+ throw new AnonymousCompilerError([diagnostic("server/index.ts", "Claimed deploys require a bundled server module.")]);
562
+ }
563
+
564
+ const sourceFiles = await readSourceFiles(sourceStore);
565
+ const diagnostics = forbiddenSourceDiagnostics(sourceFiles).filter(
566
+ (entry) =>
567
+ entry.message !== "Outbound fetch is disabled for anonymous deploys." &&
568
+ entry.message !== "Async server handlers are not part of the anonymous IR yet. Use synchronous Lakebed database operations."
569
+ );
570
+ const { diagnostics: schemaDiagnostics, schema } = serializeSchema(app.schema);
571
+ diagnostics.push(...schemaDiagnostics);
572
+ if (diagnostics.length > 0) {
573
+ throw new AnonymousCompilerError(diagnostics);
574
+ }
575
+
576
+ const clientBundle = await readFile(clientOut);
577
+ const serverBundle = await readFile(serverOut);
578
+ const clientBundleBase64 = clientBundle.toString("base64");
579
+ const clientBundleHash = sha256(clientBundle);
580
+ const serverBundleBase64 = serverBundle.toString("base64");
581
+ const serverBundleHash = sha256(serverBundle);
582
+ const sourceManifest = sourceFiles.map(({ bytes, hash, path }) => ({ bytes, hash, path }));
583
+ const sourceSnapshotHash = sha256(stableStringify(sourceManifest));
584
+ const artifact = {
585
+ name: app.name ?? "Lakebed Capsule",
586
+ client: {
587
+ bundleHash: clientBundleHash,
588
+ bytes: clientBundle.byteLength,
589
+ entry: "/client.js"
590
+ },
591
+ createdWith: {
592
+ compiler: "0.1.0",
593
+ lakebed: version
594
+ },
595
+ deployTarget: "claimed-source",
596
+ format: ANONYMOUS_ARTIFACT_FORMAT,
597
+ limits: {
598
+ instructionBudget: DEFAULT_ANONYMOUS_LIMITS.instructionBudget,
599
+ maxRowsReturned: DEFAULT_ANONYMOUS_LIMITS.rowsReturned,
600
+ maxValueBytes: DEFAULT_ANONYMOUS_LIMITS.maxValueBytes
601
+ },
602
+ server: {
603
+ helpers: {},
604
+ imports: ["lakebed/server"],
605
+ mutations: Object.fromEntries(Object.keys(app.mutations ?? {}).map((name) => [name, { op: "source" }])),
606
+ queries: Object.fromEntries(Object.keys(app.queries ?? {}).map((name) => [name, { op: "source" }])),
607
+ schema,
608
+ source: {
609
+ bytes: serverBundle.byteLength,
610
+ bundle: serverBundleBase64,
611
+ bundleHash: serverBundleHash,
612
+ entry: "/server.mjs"
613
+ }
614
+ },
615
+ source: {
616
+ files: sourceManifest,
617
+ snapshotHash: sourceSnapshotHash
618
+ }
619
+ };
620
+ const artifactHash = sha256(stableStringify(artifact));
621
+
622
+ return {
623
+ artifact,
624
+ artifactHash,
625
+ clientBundle: clientBundleBase64,
626
+ clientBundleHash
627
+ };
628
+ }
629
+
558
630
  function validateExpression(expr, path, diagnostics) {
559
631
  if (!isExpression(expr)) {
560
632
  diagnostics.push(diagnostic(path, "Expected an anonymous IR expression."));
@@ -614,6 +686,10 @@ function validateValue(value, path, diagnostics) {
614
686
  }
615
687
 
616
688
  function validateQuery(query, path, schema, diagnostics) {
689
+ if (query?.op === "source") {
690
+ return;
691
+ }
692
+
617
693
  if (!isPlainObject(query) || query.op !== "table.all" || !schema[query.table]) {
618
694
  diagnostics.push(diagnostic(path, "Invalid anonymous query IR."));
619
695
  return;
@@ -636,7 +712,7 @@ function validateQuery(query, path, schema, diagnostics) {
636
712
  }
637
713
  }
638
714
 
639
- export function validateAnonymousArtifact(artifact) {
715
+ export function validateAnonymousArtifact(artifact, { allowClaimedSource = false } = {}) {
640
716
  const diagnostics = [];
641
717
 
642
718
  if (!isPlainObject(artifact)) {
@@ -647,7 +723,7 @@ export function validateAnonymousArtifact(artifact) {
647
723
  diagnostics.push(diagnostic("artifact.format", `Expected ${ANONYMOUS_ARTIFACT_FORMAT}.`));
648
724
  }
649
725
 
650
- if (artifact.deployTarget !== "anonymous-interpreter") {
726
+ if (artifact.deployTarget !== "anonymous-interpreter" && !(allowClaimedSource && artifact.deployTarget === "claimed-source")) {
651
727
  diagnostics.push(diagnostic("artifact.deployTarget", "Anonymous deploys require deployTarget anonymous-interpreter."));
652
728
  }
653
729
 
@@ -669,9 +745,49 @@ export function validateAnonymousArtifact(artifact) {
669
745
 
670
746
  for (const [name, query] of Object.entries(artifact.server?.queries ?? {})) {
671
747
  validateQuery(query, `artifact.server.queries.${name}`, schema ?? {}, diagnostics);
748
+ if (query?.op === "source" && artifact.server?.source === undefined) {
749
+ diagnostics.push(diagnostic(`artifact.server.queries.${name}`, "Source query requires artifact.server.source."));
750
+ }
751
+ if (query?.op === "source" && artifact.deployTarget !== "claimed-source") {
752
+ diagnostics.push(diagnostic(`artifact.server.queries.${name}`, "Source query requires deployTarget claimed-source."));
753
+ }
754
+ }
755
+
756
+ if (artifact.server?.source !== undefined) {
757
+ if (artifact.deployTarget !== "claimed-source") {
758
+ diagnostics.push(diagnostic("artifact.server.source", "Server source bundles require deployTarget claimed-source."));
759
+ }
760
+ const source = artifact.server.source;
761
+ if (
762
+ !isPlainObject(source) ||
763
+ typeof source.bundle !== "string" ||
764
+ typeof source.bundleHash !== "string" ||
765
+ !Number.isInteger(source.bytes) ||
766
+ source.bytes <= 0
767
+ ) {
768
+ diagnostics.push(diagnostic("artifact.server.source", "Invalid anonymous server source bundle."));
769
+ } else {
770
+ const bundle = Buffer.from(source.bundle, "base64");
771
+ if (bundle.byteLength !== source.bytes) {
772
+ diagnostics.push(diagnostic("artifact.server.source.bytes", "Server bundle byte count does not match artifact.server.source.bytes."));
773
+ }
774
+ if (sha256(bundle) !== source.bundleHash) {
775
+ diagnostics.push(diagnostic("artifact.server.source.bundleHash", "Server bundle hash does not match artifact.server.source.bundleHash."));
776
+ }
777
+ }
672
778
  }
673
779
 
674
780
  for (const [name, mutation] of Object.entries(artifact.server?.mutations ?? {})) {
781
+ if (mutation?.op === "source") {
782
+ if (artifact.server?.source === undefined) {
783
+ diagnostics.push(diagnostic(`artifact.server.mutations.${name}`, "Source mutation requires artifact.server.source."));
784
+ }
785
+ if (artifact.deployTarget !== "claimed-source") {
786
+ diagnostics.push(diagnostic(`artifact.server.mutations.${name}`, "Source mutation requires deployTarget claimed-source."));
787
+ }
788
+ continue;
789
+ }
790
+
675
791
  if (mutation?.op !== "mutation" || !Array.isArray(mutation.body)) {
676
792
  diagnostics.push(diagnostic(`artifact.server.mutations.${name}`, "Invalid mutation IR."));
677
793
  continue;
@@ -704,12 +820,12 @@ export function validateAnonymousArtifact(artifact) {
704
820
  return diagnostics;
705
821
  }
706
822
 
707
- export function validateAnonymousDeployPayload(payload) {
823
+ export function validateAnonymousDeployPayload(payload, options = {}) {
708
824
  if (!payload || typeof payload !== "object") {
709
825
  throw new Error("Deploy payload must be a JSON object.");
710
826
  }
711
827
 
712
- const diagnostics = validateAnonymousArtifact(payload.artifact);
828
+ const diagnostics = validateAnonymousArtifact(payload.artifact, options);
713
829
  if (diagnostics.length > 0) {
714
830
  throw new AnonymousCompilerError(diagnostics);
715
831
  }
@@ -939,7 +1055,124 @@ async function executeQuerySpec({ args = [], artifact, auth, query, state, deplo
939
1055
  return results.slice(0, limit);
940
1056
  }
941
1057
 
1058
+ async function loadSourceApp(artifact) {
1059
+ const source = artifact.server?.source;
1060
+ if (!source) {
1061
+ return null;
1062
+ }
1063
+
1064
+ if (!sourceModuleCache.has(source.bundleHash)) {
1065
+ const url = `data:text/javascript;base64,${source.bundle}`;
1066
+ sourceModuleCache.set(source.bundleHash, import(url).then((module) => module.default));
1067
+ }
1068
+ return sourceModuleCache.get(source.bundleHash);
1069
+ }
1070
+
1071
+ function createSourceQuery(rows, tableName) {
1072
+ return {
1073
+ filters: [],
1074
+ limitValue: null,
1075
+ orderByValue: null,
1076
+ tableName,
1077
+ where(field, value) {
1078
+ return { ...this, filters: [...this.filters, { field, value }] };
1079
+ },
1080
+ orderBy(field, direction = "asc") {
1081
+ return { ...this, orderByValue: { field, direction: direction === "desc" ? "desc" : "asc" } };
1082
+ },
1083
+ limit(count) {
1084
+ return { ...this, limitValue: Number(count) };
1085
+ },
1086
+ all() {
1087
+ let results = [...rows[this.tableName]].map((row) => ({ ...row }));
1088
+ for (const filter of this.filters) {
1089
+ results = results.filter((row) => row[filter.field] === filter.value);
1090
+ }
1091
+ if (this.orderByValue) {
1092
+ const direction = this.orderByValue.direction === "desc" ? -1 : 1;
1093
+ results.sort((left, right) => compareValues(left[this.orderByValue.field], right[this.orderByValue.field]) * direction);
1094
+ }
1095
+ return results.slice(0, this.limitValue ?? results.length);
1096
+ }
1097
+ };
1098
+ }
1099
+
1100
+ async function createSourceContext({ artifact, auth, deployId, state }) {
1101
+ const rows = {};
1102
+ const operations = [];
1103
+ for (const tableName of Object.keys(artifact.server.schema ?? {})) {
1104
+ rows[tableName] = await state.listRows(deployId, tableName);
1105
+ }
1106
+
1107
+ const db = {};
1108
+ for (const tableName of Object.keys(artifact.server.schema ?? {})) {
1109
+ db[tableName] = {
1110
+ ...createSourceQuery(rows, tableName),
1111
+ get(id) {
1112
+ return rows[tableName].find((row) => row.id === id) ?? null;
1113
+ },
1114
+ insert(value) {
1115
+ const row = prepareInsert(artifact.server.schema, tableName, value);
1116
+ rows[tableName].push(row);
1117
+ operations.push({ op: "insert", row, table: tableName });
1118
+ return { ...row };
1119
+ },
1120
+ update(id, patch) {
1121
+ const index = rows[tableName].findIndex((row) => row.id === id);
1122
+ if (index === -1) {
1123
+ return;
1124
+ }
1125
+ const cleanPatch = preparePatch(artifact.server.schema, tableName, patch);
1126
+ rows[tableName][index] = { ...rows[tableName][index], ...cleanPatch };
1127
+ operations.push({ id, op: "update", patch: cleanPatch, table: tableName });
1128
+ },
1129
+ delete(id) {
1130
+ const index = rows[tableName].findIndex((row) => row.id === id);
1131
+ if (index === -1) {
1132
+ return;
1133
+ }
1134
+ rows[tableName].splice(index, 1);
1135
+ operations.push({ id, op: "delete", table: tableName });
1136
+ }
1137
+ };
1138
+ }
1139
+
1140
+ return {
1141
+ ctx: {
1142
+ auth,
1143
+ db,
1144
+ env: {},
1145
+ log: {
1146
+ error() {},
1147
+ info() {},
1148
+ warn() {}
1149
+ }
1150
+ },
1151
+ async flush(tx) {
1152
+ for (const operation of operations) {
1153
+ if (operation.op === "insert") {
1154
+ await tx.insertRow(deployId, operation.table, operation.row);
1155
+ } else if (operation.op === "update") {
1156
+ await tx.updateRow(deployId, operation.table, operation.id, operation.patch);
1157
+ } else if (operation.op === "delete") {
1158
+ await tx.deleteRow(deployId, operation.table, operation.id);
1159
+ }
1160
+ }
1161
+ }
1162
+ };
1163
+ }
1164
+
942
1165
  export async function executeAnonymousQuery({ args = [], artifact, auth, deployId, name, state }) {
1166
+ const sourceApp = await loadSourceApp(artifact);
1167
+ if (sourceApp) {
1168
+ const handler = sourceApp.queries?.[name];
1169
+ if (!handler) {
1170
+ throw new Error(`Unknown query: ${name}`);
1171
+ }
1172
+ const { ctx } = await createSourceContext({ artifact, auth, deployId, state });
1173
+ return handler(ctx, ...args);
1174
+ }
1175
+
943
1176
  const query = artifact.server.queries?.[name];
944
1177
  if (!query) {
945
1178
  throw new Error(`Unknown query: ${name}`);
@@ -958,6 +1191,20 @@ async function checkUpdateGuards({ auth, guards = [], row }) {
958
1191
  }
959
1192
 
960
1193
  export async function executeAnonymousMutation({ args = [], artifact, auth, deployId, name, state }) {
1194
+ const sourceApp = await loadSourceApp(artifact);
1195
+ if (sourceApp) {
1196
+ const handler = sourceApp.mutations?.[name];
1197
+ if (!handler) {
1198
+ throw new Error(`Unknown mutation: ${name}`);
1199
+ }
1200
+ return state.transaction(deployId, async (tx) => {
1201
+ const source = await createSourceContext({ artifact, auth, deployId, state: tx });
1202
+ const result = await handler(source.ctx, ...args);
1203
+ await source.flush(tx);
1204
+ return result ?? null;
1205
+ });
1206
+ }
1207
+
961
1208
  const mutation = artifact.server.mutations?.[name];
962
1209
  if (!mutation) {
963
1210
  throw new Error(`Unknown mutation: ${name}`);
package/src/cli.js CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  ANONYMOUS_ARTIFACT_MEDIA_TYPE,
11
11
  AnonymousCompilerError,
12
12
  createAnonymousArtifact,
13
+ createClaimedArtifact,
13
14
  parseTtlSeconds,
14
15
  stableStringify
15
16
  } from "./anonymous.js";
@@ -32,6 +33,7 @@ Usage:
32
33
  lakebed dev <capsule-dir> [--port 3000]
33
34
  lakebed build <capsule-dir> --target anonymous [--out .lakebed/artifacts/app.json] [--json]
34
35
  lakebed deploy [capsule-dir] [--ttl 7d] [--api <url>] [--json]
36
+ lakebed claim [capsule-dir] [--api <url>] [--json]
35
37
  lakebed anonymous-server [--port 8787] [--public-root-url <url>] [--app-base-domain <domain>]
36
38
  lakebed inspect <deploy-id-or-url> [--api <url>] [--json]
37
39
  lakebed run-many <capsule-dir> [--count 20] [--base-port 4000]
@@ -299,6 +301,7 @@ export async function buildCapsule({ capsuleDir, sourceStore, capsuleId = "dev"
299
301
  buildDir,
300
302
  clientOut,
301
303
  env: await readServerEnv(workingStore),
304
+ serverOut,
302
305
  sourceStore: workingStore
303
306
  };
304
307
  }
@@ -598,6 +601,30 @@ async function buildAnonymousEnvelope(capsuleArg) {
598
601
  };
599
602
  }
600
603
 
604
+ async function buildClaimedEnvelope(capsuleArg) {
605
+ const capsuleDir = resolveCapsuleDir(capsuleArg);
606
+ const sourceStore = await createMemorySourceStoreFromDirectory(capsuleDir);
607
+ const built = await buildCapsule({
608
+ capsuleDir,
609
+ capsuleId: `claimed-${Date.now()}`,
610
+ sourceStore
611
+ });
612
+ const artifact = await createClaimedArtifact({
613
+ app: built.app,
614
+ clientOut: built.clientOut,
615
+ serverOut: built.serverOut,
616
+ sourceStore
617
+ });
618
+
619
+ return {
620
+ artifact: artifact.artifact,
621
+ artifactHash: artifact.artifactHash,
622
+ clientBundle: artifact.clientBundle,
623
+ clientBundleHash: artifact.clientBundleHash,
624
+ mediaType: ANONYMOUS_ARTIFACT_MEDIA_TYPE
625
+ };
626
+ }
627
+
601
628
  function defaultArtifactPath(capsuleDir) {
602
629
  return resolve(root, ".lakebed/artifacts", `${basename(capsuleDir)}.anonymous.json`);
603
630
  }
@@ -642,6 +669,14 @@ function claimTokenFromDeployResponse(deployed) {
642
669
  return null;
643
670
  }
644
671
 
672
+ function claimUrlFromDeployMetadata(metadata) {
673
+ if (!metadata?.api || !metadata?.deployId || !metadata?.claimToken) {
674
+ return null;
675
+ }
676
+
677
+ return `${String(metadata.api).replace(/\/+$/g, "")}/claim/${encodeURIComponent(metadata.deployId)}/${encodeURIComponent(metadata.claimToken)}`;
678
+ }
679
+
645
680
  function deployRequestBody(envelope, ttl) {
646
681
  return JSON.stringify({
647
682
  artifact: envelope.artifact,
@@ -700,13 +735,31 @@ async function readResponseJson(response) {
700
735
  async function deployCommand(args) {
701
736
  const [capsuleArg] = positionals(args);
702
737
  const capsuleDir = capsuleArg ? resolveCapsuleDir(capsuleArg) : root;
703
- const envelope = await buildAnonymousEnvelope(capsuleDir);
704
738
  const ttl = parseTtlSeconds(readArg(args, "--ttl", "7d"));
705
739
  const api = deployApiUrl(args);
706
- const body = deployRequestBody(envelope, ttl);
707
740
  const metadata = await readDeployMetadata(capsuleDir);
708
741
  const canUpdate =
709
742
  metadata?.api === api && typeof metadata?.deployId === "string" && typeof metadata?.claimToken === "string";
743
+ let currentDeploy = null;
744
+ if (canUpdate) {
745
+ const currentResponse = await fetch(`${api}/v1/deploys/${encodeURIComponent(metadata.deployId)}`);
746
+ if (currentResponse.ok) {
747
+ currentDeploy = await currentResponse.json();
748
+ }
749
+ }
750
+
751
+ let envelope;
752
+ try {
753
+ envelope = currentDeploy?.claimed ? await buildClaimedEnvelope(capsuleDir) : await buildAnonymousEnvelope(capsuleDir);
754
+ } catch (error) {
755
+ if (error instanceof AnonymousCompilerError && canUpdate && !currentDeploy?.claimed) {
756
+ throw new Error(
757
+ `${error.message}\n\nThis deploy is still anonymous. Claim it first, then run lakebed deploy again to use server-side fetch.`
758
+ );
759
+ }
760
+ throw error;
761
+ }
762
+ const body = deployRequestBody(envelope, ttl);
710
763
  let mode = "created";
711
764
  let response;
712
765
 
@@ -764,7 +817,60 @@ async function deployCommand(args) {
764
817
  console.log(` state: ${deployed.limits.stateBytes} bytes`);
765
818
  console.log(` requests: ${deployed.limits.requestsPerDay} / day`);
766
819
  console.log(` mutations: ${deployed.limits.mutationsPerDay} / day`);
767
- console.log(" outbound fetch: disabled");
820
+ console.log(` outbound fetch: ${envelope.artifact.deployTarget === "claimed-source" ? "enabled" : "disabled"}`);
821
+ }
822
+
823
+ async function claimCommand(args) {
824
+ const [capsuleArg] = positionals(args);
825
+ const capsuleDir = capsuleArg ? resolveCapsuleDir(capsuleArg) : root;
826
+ const api = deployApiUrl(args);
827
+ const metadata = await readDeployMetadata(capsuleDir);
828
+
829
+ if (!metadata) {
830
+ throw new Error(`No Lakebed deploy metadata found at ${deployMetadataPath(capsuleDir)}. Run lakebed deploy from this project first.`);
831
+ }
832
+
833
+ if (metadata.api !== api) {
834
+ throw new Error(`Saved deploy metadata is for ${metadata.api}, but this command is using ${api}. Pass --api ${metadata.api} to claim it.`);
835
+ }
836
+
837
+ const claimUrl = claimUrlFromDeployMetadata(metadata);
838
+ if (!claimUrl) {
839
+ throw new Error("This project does not have a saved claim token. Redeploy to create a new claim URL.");
840
+ }
841
+
842
+ let deploy = null;
843
+ try {
844
+ const response = await fetch(`${api}/v1/deploys/${encodeURIComponent(metadata.deployId)}`);
845
+ if (response.ok) {
846
+ deploy = await response.json();
847
+ }
848
+ } catch {
849
+ deploy = null;
850
+ }
851
+
852
+ const result = {
853
+ claimed: Boolean(deploy?.claimed),
854
+ claimUrl,
855
+ deployId: metadata.deployId,
856
+ url: deploy?.url ?? metadata.url
857
+ };
858
+
859
+ if (hasFlag(args, "--json")) {
860
+ console.log(JSON.stringify(result, null, 2));
861
+ return;
862
+ }
863
+
864
+ if (result.claimed) {
865
+ console.log(`Deploy ${result.deployId} is already claimed.`);
866
+ if (result.url) {
867
+ console.log(`App: ${result.url}`);
868
+ }
869
+ return;
870
+ }
871
+
872
+ console.log("Open this URL to claim the current project's deploy:");
873
+ console.log(claimUrl);
768
874
  }
769
875
 
770
876
  async function anonymousServerCommand(args) {
@@ -931,7 +1037,7 @@ lakebed logs --port 3000
931
1037
  - One client entry.
932
1038
  - Guest auth locally, with built-in Google sign-in through Shoo.
933
1039
  - No file storage.
934
- - No outbound fetch in anonymous deploys.
1040
+ - No outbound fetch in anonymous deploys. Claim the deploy before using server-side fetch.
935
1041
  - Local state resets when \`lakebed dev\` restarts.
936
1042
  `;
937
1043
  }
@@ -1146,6 +1252,11 @@ async function main() {
1146
1252
  return;
1147
1253
  }
1148
1254
 
1255
+ if (command === "claim") {
1256
+ await claimCommand(args);
1257
+ return;
1258
+ }
1259
+
1149
1260
  if (command === "anonymous-server") {
1150
1261
  await anonymousServerCommand(args);
1151
1262
  return;