lakebed 0.0.1 → 0.0.3-alpha.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/README.md CHANGED
@@ -61,7 +61,7 @@ export default capsule({
61
61
  lakebed new <name> [--template todo]
62
62
  lakebed dev <capsule-dir> [--port 3000]
63
63
  lakebed build <capsule-dir> --target anonymous [--out .lakebed/artifacts/app.json] [--json]
64
- lakebed deploy <capsule-dir> [--ttl 7d] [--api <url>] [--json]
64
+ lakebed deploy [capsule-dir] [--ttl 7d] [--api <url>] [--json]
65
65
  lakebed anonymous-server [--port 8787] [--public-root-url <url>]
66
66
  lakebed inspect <deploy-id-or-url> [--api <url>] [--json]
67
67
  lakebed db list [deploy-id-or-url] [--port 3000]
@@ -83,12 +83,14 @@ lakebed logs [deploy-id-or-url] [--port 3000]
83
83
  Once a Lakebed deploy runner is available, deploy a capsule with:
84
84
 
85
85
  ```sh
86
- lakebed deploy my-app --api https://api.lakebed.app
86
+ cd my-app
87
+ lakebed deploy
87
88
  ```
88
89
 
89
90
  For local deploy-runner testing:
90
91
 
91
92
  ```sh
92
93
  lakebed anonymous-server --port 8787
93
- lakebed deploy my-app --api http://localhost:8787
94
+ cd my-app
95
+ lakebed deploy --api http://localhost:8787
94
96
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lakebed",
3
- "version": "0.0.1",
3
+ "version": "0.0.3-alpha.0",
4
4
  "description": "Agent-native CLI and runtime for building and deploying Lakebed capsules.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -108,6 +108,20 @@ async function readJsonBody(req, maxBytes = 2 * 1024 * 1024) {
108
108
  return JSON.parse(Buffer.concat(chunks).toString("utf8"));
109
109
  }
110
110
 
111
+ function bearerToken(req) {
112
+ const header = req.headers.authorization;
113
+ if (!header) {
114
+ return "";
115
+ }
116
+
117
+ const match = String(header).match(/^Bearer\s+(.+)$/i);
118
+ return match?.[1]?.trim() ?? "";
119
+ }
120
+
121
+ function isDeployTokenValid(deploy, token) {
122
+ return Boolean(token && deploy?.claimTokenHash && hashClaimToken(token) === deploy.claimTokenHash);
123
+ }
124
+
111
125
  function normalizePublicRootUrl(value, port) {
112
126
  const fallback = `http://localhost:${port}`;
113
127
  return String(value || fallback).replace(/\/+$/g, "");
@@ -220,6 +234,19 @@ export class MemoryAnonymousStore {
220
234
 
221
235
  async initialize() {}
222
236
 
237
+ storeArtifact({ artifact, artifactHash, clientBundleBase64, clientBundleHash, createdAt }) {
238
+ const currentArtifact = this.artifacts.get(artifactHash);
239
+ this.artifacts.set(artifactHash, {
240
+ artifact,
241
+ bytes: Buffer.byteLength(clientBundleBase64, "base64"),
242
+ clientBundleBase64,
243
+ clientBundleHash,
244
+ createdAt,
245
+ hash: artifactHash,
246
+ refCount: (currentArtifact?.refCount ?? 0) + 1
247
+ });
248
+ }
249
+
223
250
  async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl, requestedTtlSeconds }) {
224
251
  const deployId = createDeployId();
225
252
  let slug = createSlug();
@@ -233,15 +260,12 @@ export class MemoryAnonymousStore {
233
260
  const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
234
261
  const url = appUrlForSlug({ appBaseDomain, publicRootUrl, slug });
235
262
 
236
- const currentArtifact = this.artifacts.get(artifactHash);
237
- this.artifacts.set(artifactHash, {
263
+ this.storeArtifact({
238
264
  artifact,
239
- bytes: Buffer.byteLength(clientBundleBase64, "base64"),
240
265
  clientBundleBase64,
241
266
  clientBundleHash,
242
267
  createdAt,
243
- hash: artifactHash,
244
- refCount: (currentArtifact?.refCount ?? 0) + 1
268
+ artifactHash
245
269
  });
246
270
 
247
271
  const deploy = {
@@ -263,6 +287,33 @@ export class MemoryAnonymousStore {
263
287
  return { deploy, token };
264
288
  }
265
289
 
290
+ async updateDeploy({ artifact, artifactHash, clientBundleBase64, clientBundleHash, deployId, requestedTtlSeconds }) {
291
+ const currentDeploy = await this.getDeployById(deployId);
292
+ if (!currentDeploy) {
293
+ return null;
294
+ }
295
+
296
+ const createdAt = now();
297
+ const ttlSeconds = parseTtlSeconds(requestedTtlSeconds);
298
+ const deploy = {
299
+ ...currentDeploy,
300
+ artifactHash,
301
+ clientBundleHash,
302
+ expiresAt: new Date(Date.now() + ttlSeconds * 1000).toISOString(),
303
+ status: "active"
304
+ };
305
+
306
+ this.storeArtifact({
307
+ artifact,
308
+ artifactHash,
309
+ clientBundleBase64,
310
+ clientBundleHash,
311
+ createdAt
312
+ });
313
+ this.deploys.set(deployId, deploy);
314
+ return deploy;
315
+ }
316
+
266
317
  async getDeployById(id) {
267
318
  return this.deploys.get(id) ?? null;
268
319
  }
@@ -467,13 +518,7 @@ export class PostgresAnonymousStore {
467
518
  return this.pool.query(sql, params);
468
519
  }
469
520
 
470
- async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl, requestedTtlSeconds }) {
471
- const createdAt = now();
472
- const ttlSeconds = parseTtlSeconds(requestedTtlSeconds);
473
- const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
474
- const token = createClaimToken();
475
- const deployId = createDeployId();
476
-
521
+ async storeArtifact({ artifact, artifactHash, clientBundleBase64, clientBundleHash, createdAt }) {
477
522
  await this.query(
478
523
  `
479
524
  insert into artifacts(hash, artifact_json, client_bundle_base64, client_bundle_hash, bytes, created_at, ref_count)
@@ -482,6 +527,16 @@ export class PostgresAnonymousStore {
482
527
  `,
483
528
  [artifactHash, JSON.stringify(artifact), clientBundleBase64, clientBundleHash, Buffer.byteLength(clientBundleBase64, "base64"), createdAt]
484
529
  );
530
+ }
531
+
532
+ async createDeploy({ appBaseDomain, artifact, artifactHash, clientBundleBase64, clientBundleHash, publicRootUrl, requestedTtlSeconds }) {
533
+ const createdAt = now();
534
+ const ttlSeconds = parseTtlSeconds(requestedTtlSeconds);
535
+ const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
536
+ const token = createClaimToken();
537
+ const deployId = createDeployId();
538
+
539
+ await this.storeArtifact({ artifact, artifactHash, clientBundleBase64, clientBundleHash, createdAt });
485
540
 
486
541
  for (let attempt = 0; attempt < 8; attempt += 1) {
487
542
  const slug = createSlug();
@@ -536,6 +591,29 @@ export class PostgresAnonymousStore {
536
591
  throw new Error("Unable to allocate anonymous deploy slug.");
537
592
  }
538
593
 
594
+ async updateDeploy({ artifact, artifactHash, clientBundleBase64, clientBundleHash, deployId, requestedTtlSeconds }) {
595
+ const currentDeploy = await this.getDeployById(deployId);
596
+ if (!currentDeploy) {
597
+ return null;
598
+ }
599
+
600
+ const createdAt = now();
601
+ const ttlSeconds = parseTtlSeconds(requestedTtlSeconds);
602
+ const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
603
+ await this.storeArtifact({ artifact, artifactHash, clientBundleBase64, clientBundleHash, createdAt });
604
+
605
+ const result = await this.query(
606
+ `
607
+ update deploys
608
+ set status = 'active', artifact_hash = $2, client_bundle_hash = $3, expires_at = $4
609
+ where id = $1
610
+ returning *
611
+ `,
612
+ [deployId, artifactHash, clientBundleHash, expiresAt]
613
+ );
614
+ return this.rowToDeploy(result.rows[0]);
615
+ }
616
+
539
617
  rowToDeploy(row) {
540
618
  if (!row) {
541
619
  return null;
@@ -834,6 +912,20 @@ export async function startAnonymousServer({
834
912
  await resolvedStore.initialize();
835
913
  const subscriptions = new Map();
836
914
 
915
+ async function refreshDeploySubscriptions(deploy) {
916
+ const storedArtifact = await resolvedStore.getArtifact(deploy.artifactHash);
917
+ if (!storedArtifact) {
918
+ return;
919
+ }
920
+
921
+ for (const subscription of subscriptions.values()) {
922
+ if (subscription.deploy.id === deploy.id) {
923
+ subscription.artifact = storedArtifact.artifact;
924
+ subscription.deploy = deploy;
925
+ }
926
+ }
927
+ }
928
+
837
929
  async function publishDeploy(deployId) {
838
930
  for (const [ws, subscription] of subscriptions) {
839
931
  if (subscription.deploy.id !== deployId) {
@@ -884,6 +976,41 @@ export async function startAnonymousServer({
884
976
  return;
885
977
  }
886
978
 
979
+ if ((req.method === "PUT" || req.method === "PATCH") && requestUrl.pathname.startsWith("/v1/deploys/")) {
980
+ const deployId = requestUrl.pathname.slice("/v1/deploys/".length);
981
+ const currentDeploy = await resolvedStore.getDeployById(deployId);
982
+ if (!currentDeploy) {
983
+ sendJson(res, 404, { error: "Unknown deploy." });
984
+ return;
985
+ }
986
+
987
+ if (!isDeployTokenValid(currentDeploy, bearerToken(req))) {
988
+ sendJson(res, 401, { error: "Invalid deploy token." });
989
+ return;
990
+ }
991
+
992
+ const body = await readJsonBody(req);
993
+ const payload = validateAnonymousDeployPayload(body);
994
+ const deploy = await resolvedStore.updateDeploy({
995
+ artifact: payload.artifact,
996
+ artifactHash: payload.artifactHash,
997
+ clientBundleBase64: payload.clientBundleBase64,
998
+ clientBundleHash: payload.clientBundleHash,
999
+ deployId,
1000
+ requestedTtlSeconds: body.requestedTtlSeconds ?? requestUrl.searchParams.get("ttl") ?? undefined
1001
+ });
1002
+ if (!deploy) {
1003
+ sendJson(res, 404, { error: "Unknown deploy." });
1004
+ return;
1005
+ }
1006
+
1007
+ await resolvedStore.appendLog(deploy.id, "info", "anonymous deploy updated", { artifactHash: deploy.artifactHash });
1008
+ await refreshDeploySubscriptions(deploy);
1009
+ await publishDeploy(deploy.id);
1010
+ sendJson(res, 200, responseForDeploy({ deploy }));
1011
+ return;
1012
+ }
1013
+
887
1014
  if (req.method === "GET" && requestUrl.pathname.startsWith("/v1/deploys/")) {
888
1015
  const id = requestUrl.pathname.slice("/v1/deploys/".length);
889
1016
  const deploy = (await resolvedStore.getDeployById(id)) ?? (await resolvedStore.getDeployBySlug(id));
package/src/anonymous.js CHANGED
@@ -484,7 +484,7 @@ function compileServerToIr(app, schema) {
484
484
  return { diagnostics, mutations, queries };
485
485
  }
486
486
 
487
- export async function createAnonymousArtifact({ app, clientOut, sourceStore, version = "0.0.1" }) {
487
+ export async function createAnonymousArtifact({ app, clientOut, sourceStore, version = "0.0.3-alpha.0" }) {
488
488
  const sourceFiles = await readSourceFiles(sourceStore);
489
489
  const diagnostics = forbiddenSourceDiagnostics(sourceFiles);
490
490
  const { diagnostics: schemaDiagnostics, schema } = serializeSchema(app.schema);
package/src/cli.js CHANGED
@@ -21,6 +21,7 @@ const root = process.cwd();
21
21
  const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
22
22
  const packageNodeModules = resolve(packageDir, "node_modules");
23
23
  const sourceNamespace = "lakebed-source";
24
+ const defaultDeployApiUrl = "https://api.lakebed.app";
24
25
 
25
26
  function usage() {
26
27
  console.log(`lakebed
@@ -29,7 +30,7 @@ Usage:
29
30
  lakebed new <name> [--template todo]
30
31
  lakebed dev <capsule-dir> [--port 3000]
31
32
  lakebed build <capsule-dir> --target anonymous [--out .lakebed/artifacts/app.json] [--json]
32
- lakebed deploy <capsule-dir> [--ttl 7d] [--api <url>] [--json]
33
+ lakebed deploy [capsule-dir] [--ttl 7d] [--api <url>] [--json]
33
34
  lakebed anonymous-server [--port 8787] [--public-root-url <url>]
34
35
  lakebed inspect <deploy-id-or-url> [--api <url>] [--json]
35
36
  lakebed run-many <capsule-dir> [--count 20] [--base-port 4000]
@@ -614,6 +615,55 @@ function defaultArtifactPath(capsuleDir) {
614
615
  return resolve(root, ".lakebed/artifacts", `${basename(capsuleDir)}.anonymous.json`);
615
616
  }
616
617
 
618
+ function deployMetadataPath(capsuleDir) {
619
+ return resolve(capsuleDir, ".lakebed/deploy.json");
620
+ }
621
+
622
+ async function readDeployMetadata(capsuleDir) {
623
+ try {
624
+ return JSON.parse(await readFile(deployMetadataPath(capsuleDir), "utf8"));
625
+ } catch (error) {
626
+ if (error?.code === "ENOENT") {
627
+ return null;
628
+ }
629
+
630
+ throw new Error(`Unable to read Lakebed deploy metadata: ${error instanceof Error ? error.message : String(error)}`);
631
+ }
632
+ }
633
+
634
+ async function writeDeployMetadata(capsuleDir, metadata) {
635
+ const path = deployMetadataPath(capsuleDir);
636
+ await mkdir(dirname(path), { recursive: true });
637
+ await writeFile(path, `${JSON.stringify(metadata, null, 2)}\n`);
638
+ }
639
+
640
+ function claimTokenFromDeployResponse(deployed) {
641
+ if (!deployed?.claimUrl || !deployed?.deployId) {
642
+ return null;
643
+ }
644
+
645
+ try {
646
+ const url = new URL(deployed.claimUrl);
647
+ const segments = url.pathname.split("/").filter(Boolean);
648
+ if (segments[0] === "claim" && segments[1] === deployed.deployId) {
649
+ return segments[2] ?? null;
650
+ }
651
+ } catch {
652
+ return null;
653
+ }
654
+
655
+ return null;
656
+ }
657
+
658
+ function deployRequestBody(envelope, ttl) {
659
+ return JSON.stringify({
660
+ artifact: envelope.artifact,
661
+ clientBundle: envelope.clientBundle,
662
+ clientVersion: "0.0.3-alpha.0",
663
+ requestedTtlSeconds: ttl
664
+ });
665
+ }
666
+
617
667
  async function buildCommand(args) {
618
668
  const [capsuleArg] = positionals(args);
619
669
  const target = readArg(args, "--target", "anonymous");
@@ -649,10 +699,7 @@ async function buildCommand(args) {
649
699
  }
650
700
 
651
701
  function deployApiUrl(args) {
652
- return String(readArg(args, "--api", process.env.LAKEBED_DEPLOY_API ?? process.env.SPAN_DEPLOY_API ?? "http://localhost:8787")).replace(
653
- /\/+$/g,
654
- ""
655
- );
702
+ return String(readArg(args, "--api", process.env.LAKEBED_DEPLOY_API ?? process.env.SPAN_DEPLOY_API ?? defaultDeployApiUrl)).replace(/\/+$/g, "");
656
703
  }
657
704
 
658
705
  async function readResponseJson(response) {
@@ -665,32 +712,65 @@ async function readResponseJson(response) {
665
712
 
666
713
  async function deployCommand(args) {
667
714
  const [capsuleArg] = positionals(args);
668
- const envelope = await buildAnonymousEnvelope(capsuleArg);
715
+ const capsuleDir = capsuleArg ? resolveCapsuleDir(capsuleArg) : root;
716
+ const envelope = await buildAnonymousEnvelope(capsuleDir);
669
717
  const ttl = parseTtlSeconds(readArg(args, "--ttl", "7d"));
670
718
  const api = deployApiUrl(args);
671
- const response = await fetch(`${api}/v1/anonymous-deploys`, {
672
- body: JSON.stringify({
673
- artifact: envelope.artifact,
674
- clientBundle: envelope.clientBundle,
675
- clientVersion: "0.0.1",
676
- requestedTtlSeconds: ttl
677
- }),
719
+ const body = deployRequestBody(envelope, ttl);
720
+ const metadata = await readDeployMetadata(capsuleDir);
721
+ const canUpdate =
722
+ metadata?.api === api && typeof metadata?.deployId === "string" && typeof metadata?.claimToken === "string";
723
+ let mode = "created";
724
+ let response;
725
+
726
+ if (canUpdate) {
727
+ response = await fetch(`${api}/v1/deploys/${encodeURIComponent(metadata.deployId)}`, {
728
+ body,
729
+ headers: {
730
+ "Authorization": `Bearer ${metadata.claimToken}`,
731
+ "Content-Type": "application/json"
732
+ },
733
+ method: "PUT"
734
+ });
735
+
736
+ if (response.status === 404 || response.status === 410) {
737
+ mode = "created";
738
+ response = null;
739
+ } else {
740
+ mode = "updated";
741
+ }
742
+ }
743
+
744
+ response ??= await fetch(`${api}/v1/anonymous-deploys`, {
745
+ body,
678
746
  headers: {
679
747
  "Content-Type": "application/json"
680
748
  },
681
749
  method: "POST"
682
750
  });
683
751
  const deployed = await readResponseJson(response);
752
+ const claimToken = claimTokenFromDeployResponse(deployed) ?? metadata?.claimToken;
753
+ if (claimToken) {
754
+ await writeDeployMetadata(capsuleDir, {
755
+ api,
756
+ claimToken,
757
+ deployId: deployed.deployId,
758
+ updatedAt: new Date().toISOString(),
759
+ url: deployed.url
760
+ });
761
+ }
684
762
 
685
763
  if (hasFlag(args, "--json")) {
686
764
  console.log(JSON.stringify(deployed, null, 2));
687
765
  return;
688
766
  }
689
767
 
690
- console.log("Deploying anonymous preview...\n");
768
+ console.log(`${mode === "updated" ? "Updated" : "Created"} anonymous preview.\n`);
691
769
  console.log(`App: ${deployed.url}`);
692
770
  console.log(`Expires: ${deployed.expiresAt}`);
693
- console.log(`Claim: ${deployed.claimUrl}`);
771
+ if (deployed.claimUrl) {
772
+ console.log(`Claim: ${deployed.claimUrl}`);
773
+ }
694
774
  console.log(`Inspect: lakebed inspect ${deployed.deployId}`);
695
775
  console.log("\nLimits:");
696
776
  console.log(` source/artifact: ${deployed.limits.artifactBytes} bytes`);
@@ -813,9 +893,67 @@ async function dbCommand(args) {
813
893
  usage();
814
894
  }
815
895
 
896
+ function agentInstructionsTemplate() {
897
+ return `# Lakebed App Instructions
898
+
899
+ This is a Lakebed capsule. Build the app inside this directory using the Lakebed v0 contract.
900
+
901
+ ## File Layout
902
+
903
+ - \`server/index.ts\`: schema, queries, and mutations.
904
+ - \`client/index.tsx\`: Preact UI entrypoint.
905
+ - \`shared/\`: pure TypeScript shared by client and server.
906
+
907
+ ## Commands
908
+
909
+ Run locally:
910
+
911
+ \`\`\`sh
912
+ lakebed dev .
913
+ \`\`\`
914
+
915
+ Deploy:
916
+
917
+ \`\`\`sh
918
+ lakebed deploy
919
+ \`\`\`
920
+
921
+ Inspect local state while \`lakebed dev\` is running:
922
+
923
+ \`\`\`sh
924
+ lakebed db list --port 3000
925
+ lakebed db dump --port 3000
926
+ lakebed logs --port 3000
927
+ \`\`\`
928
+
929
+ ## Rules
930
+
931
+ - Use \`lakebed/server\` only from \`server/index.ts\`.
932
+ - Use \`lakebed/client\` only from \`client/index.tsx\`.
933
+ - Do not import npm packages from app code.
934
+ - Do not use Node built-ins in app code.
935
+ - Use Tailwind classes directly in JSX.
936
+ - Do not add a CSS, PostCSS, or Tailwind build pipeline.
937
+ - Use guest auth through \`ctx.auth\` on the server and \`useAuth()\` on the client.
938
+ - Keep \`shared/\` free of DOM, Node, env, and Lakebed runtime imports.
939
+
940
+ ## Current Limits
941
+
942
+ - One server entry.
943
+ - One client entry.
944
+ - Guest auth only.
945
+ - No file storage.
946
+ - No outbound fetch in anonymous deploys.
947
+ - Local state resets when \`lakebed dev\` restarts.
948
+ `;
949
+ }
950
+
816
951
  function todoTemplate(name) {
817
952
  const title = basename(name);
953
+ const agentInstructions = agentInstructionsTemplate();
818
954
  return {
955
+ "AGENTS.md": agentInstructions,
956
+ "CLAUDE.md": agentInstructions,
819
957
  "server/index.ts": `import { boolean, capsule, mutation, query, string, table } from "lakebed/server";
820
958
  import { cleanTodoText } from "../shared/todo";
821
959
 
@@ -901,6 +1039,8 @@ export function App() {
901
1039
  export function cleanTodoText(value: string): string {
902
1040
  return value.trim().slice(0, 160);
903
1041
  }
1042
+ `,
1043
+ ".gitignore": `.lakebed/
904
1044
  `,
905
1045
  "README.md": `# ${title}
906
1046