drivethrough-cli 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/main.mjs +1997 -82
  2. package/package.json +1 -1
package/dist/main.mjs CHANGED
@@ -956,7 +956,8 @@ var init_config = __esm({
956
956
  var api_exports = {};
957
957
  __export(api_exports, {
958
958
  ApiError: () => ApiError,
959
- api: () => api
959
+ api: () => api,
960
+ apiBytes: () => apiBytes
960
961
  });
961
962
  async function api(method, pathname, options = {}) {
962
963
  const headers = {};
@@ -1007,6 +1008,40 @@ async function api(method, pathname, options = {}) {
1007
1008
  }
1008
1009
  return json;
1009
1010
  }
1011
+ async function apiBytes(pathname) {
1012
+ const t2 = token();
1013
+ if (!t2) {
1014
+ throw new ApiError(401, {
1015
+ error: {
1016
+ code: "not_logged_in",
1017
+ message: "You are not logged in on this machine.",
1018
+ next: "Run `drivethrough login` first."
1019
+ }
1020
+ });
1021
+ }
1022
+ let res;
1023
+ try {
1024
+ res = await fetch(`${apiUrl()}${pathname}`, { headers: { Authorization: `Bearer ${t2}` } });
1025
+ } catch (cause) {
1026
+ throw new ApiError(0, {
1027
+ error: {
1028
+ code: "network_error",
1029
+ message: `Could not reach the Drivethrough API at ${apiUrl()}: ${cause.message}`,
1030
+ next: "Check your connection (or, in local dev, that `pnpm dev` is running)."
1031
+ }
1032
+ });
1033
+ }
1034
+ if (!res.ok) {
1035
+ let body;
1036
+ try {
1037
+ body = await res.json();
1038
+ } catch {
1039
+ body = { error: { code: "http_error", message: `API request failed (${res.status}).` } };
1040
+ }
1041
+ throw new ApiError(res.status, body);
1042
+ }
1043
+ return Buffer.from(await res.arrayBuffer());
1044
+ }
1010
1045
  var ApiError;
1011
1046
  var init_api = __esm({
1012
1047
  "src/api.ts"() {
@@ -1576,8 +1611,8 @@ var init_parseUtil = __esm({
1576
1611
  init_errors();
1577
1612
  init_en();
1578
1613
  makeIssue = (params) => {
1579
- const { data, path: path5, errorMaps, issueData } = params;
1580
- const fullPath = [...path5, ...issueData.path || []];
1614
+ const { data, path: path8, errorMaps, issueData } = params;
1615
+ const fullPath = [...path8, ...issueData.path || []];
1581
1616
  const fullIssue = {
1582
1617
  ...issueData,
1583
1618
  path: fullPath
@@ -1885,11 +1920,11 @@ var init_types = __esm({
1885
1920
  init_parseUtil();
1886
1921
  init_util();
1887
1922
  ParseInputLazyPath = class {
1888
- constructor(parent, value, path5, key) {
1923
+ constructor(parent, value, path8, key) {
1889
1924
  this._cachedPath = [];
1890
1925
  this.parent = parent;
1891
1926
  this.data = value;
1892
- this._path = path5;
1927
+ this._path = path8;
1893
1928
  this._key = key;
1894
1929
  }
1895
1930
  get path() {
@@ -5282,16 +5317,28 @@ function parseSiteConfig(input) {
5282
5317
  }
5283
5318
  return { ok: true, config: result.data };
5284
5319
  }
5285
- var FieldSpec, RateLimit, TypeConfig, IndexDecl, SiteConfig;
5320
+ var FieldSpec, RateLimit, TYPE_NAME, TypeConfig, IndexDecl, SiteConfig;
5286
5321
  var init_site_config = __esm({
5287
5322
  "../schema/src/site-config.ts"() {
5288
5323
  "use strict";
5289
5324
  init_zod();
5290
5325
  FieldSpec = external_exports.enum(["string", "string?", "number", "number?", "boolean", "boolean?"]);
5291
5326
  RateLimit = external_exports.string().regex(/^\d+\/(minute|hour|day)$/, 'rate_limit must look like "5/hour"');
5327
+ TYPE_NAME = external_exports.string().regex(/^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$/, "type names are lowercase letters, numbers, hyphens and underscores").max(64);
5292
5328
  TypeConfig = external_exports.object({
5293
- /** Who may create rows of this type. Defaults to owner-only. */
5294
- write: external_exports.enum(["public", "viewers", "owner"]).default("owner"),
5329
+ /**
5330
+ * Who may create rows of this type. Absent means owner-only — see
5331
+ * `effectiveWrite`. Left optional rather than `.default("owner")` so
5332
+ * `effectiveRead` can tell "the author said owner" from "the author said
5333
+ * nothing", which are the same for writes and must not be for reads.
5334
+ */
5335
+ write: external_exports.enum(["public", "viewers", "owner"]).optional(),
5336
+ /**
5337
+ * Who may read rows of this type. Omitted → `effectiveRead` decides from
5338
+ * `write`: owner-authored content stays readable, collected submissions do
5339
+ * not. Set explicitly to publish submissions (a public guestbook).
5340
+ */
5341
+ read: external_exports.enum(["public", "viewers", "owner"]).optional(),
5295
5342
  fields: external_exports.record(FieldSpec).default({}),
5296
5343
  /** "session" → an RSVP edits itself, not duplicates (upsert per viewer session). */
5297
5344
  unique_per: external_exports.enum(["session"]).optional(),
@@ -5309,7 +5356,11 @@ var init_site_config = __esm({
5309
5356
  site: external_exports.string().regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/, "site is lowercase letters, numbers and hyphens").optional(),
5310
5357
  /** Human title, shown on the share interstitial ("Tim shared *Trip to Japan 2027*"). */
5311
5358
  title: external_exports.string().min(1).max(120).optional(),
5312
- types: external_exports.record(TypeConfig).default({}),
5359
+ // Type names are constrained, not free-form: they become part of generated
5360
+ // index names and — for unique_per — of a partial index predicate that
5361
+ // cannot be parameterised. Keeping them to this alphabet is what makes that
5362
+ // interpolation safe, rather than relying on escaping at each use.
5363
+ types: external_exports.record(TYPE_NAME, TypeConfig).default({}),
5313
5364
  indexes: external_exports.array(IndexDecl).default([]),
5314
5365
  /** Pro (§3): additive custom-schema SQL applied to the site database. */
5315
5366
  schema: external_exports.string().max(2e4).optional()
@@ -5436,7 +5487,75 @@ var init_bundle2 = __esm({
5436
5487
  "src/bundle.ts"() {
5437
5488
  "use strict";
5438
5489
  init_src();
5439
- IGNORED = /* @__PURE__ */ new Set(["node_modules", ".git", ".DS_Store", "drivethrough.json", ".dev.vars", ".env"]);
5490
+ IGNORED = /* @__PURE__ */ new Set([
5491
+ "node_modules",
5492
+ ".git",
5493
+ ".DS_Store",
5494
+ "drivethrough.json",
5495
+ ".dev.vars",
5496
+ ".env",
5497
+ // Local sync state (state.ts) — never part of the site.
5498
+ ".drivethrough"
5499
+ ]);
5500
+ }
5501
+ });
5502
+
5503
+ // src/state.ts
5504
+ var state_exports = {};
5505
+ __export(state_exports, {
5506
+ hashLocalFiles: () => hashLocalFiles,
5507
+ readState: () => readState,
5508
+ writeState: () => writeState
5509
+ });
5510
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync3, readdirSync as readdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
5511
+ import path3 from "node:path";
5512
+ function statePath(dir) {
5513
+ return path3.join(path3.resolve(dir), ".drivethrough", "state.json");
5514
+ }
5515
+ function readState(dir) {
5516
+ try {
5517
+ const parsed = JSON.parse(readFileSync3(statePath(dir), "utf8"));
5518
+ return typeof parsed.site_id === "string" && Number.isInteger(parsed.version) ? parsed : null;
5519
+ } catch {
5520
+ return null;
5521
+ }
5522
+ }
5523
+ function writeState(dir, state) {
5524
+ mkdirSync2(path3.dirname(statePath(dir)), { recursive: true });
5525
+ writeFileSync2(statePath(dir), JSON.stringify(state, null, 2) + "\n");
5526
+ }
5527
+ function hashLocalFiles(assetDir) {
5528
+ const files = /* @__PURE__ */ new Map();
5529
+ if (!existsSync2(assetDir)) return files;
5530
+ const walk = (current, prefix) => {
5531
+ for (const entry of readdirSync2(current, { withFileTypes: true })) {
5532
+ if (IGNORED2.has(entry.name)) continue;
5533
+ const full = path3.join(current, entry.name);
5534
+ const rel = `${prefix}/${entry.name}`;
5535
+ if (entry.isDirectory()) walk(full, rel);
5536
+ else if (entry.isFile()) {
5537
+ const bytes = readFileSync3(full);
5538
+ files.set(rel, { sha256: sha256(bytes), size: bytes.length });
5539
+ }
5540
+ }
5541
+ };
5542
+ walk(assetDir, "");
5543
+ return files;
5544
+ }
5545
+ var IGNORED2;
5546
+ var init_state = __esm({
5547
+ "src/state.ts"() {
5548
+ "use strict";
5549
+ init_bundle2();
5550
+ IGNORED2 = /* @__PURE__ */ new Set([
5551
+ "node_modules",
5552
+ ".git",
5553
+ ".DS_Store",
5554
+ "drivethrough.json",
5555
+ ".dev.vars",
5556
+ ".env",
5557
+ ".drivethrough"
5558
+ ]);
5440
5559
  }
5441
5560
  });
5442
5561
 
@@ -5648,10 +5767,10 @@ function assignProp(target, prop, value) {
5648
5767
  configurable: true
5649
5768
  });
5650
5769
  }
5651
- function getElementAtPath(obj, path5) {
5652
- if (!path5)
5770
+ function getElementAtPath(obj, path8) {
5771
+ if (!path8)
5653
5772
  return obj;
5654
- return path5.reduce((acc, key) => acc?.[key], obj);
5773
+ return path8.reduce((acc, key) => acc?.[key], obj);
5655
5774
  }
5656
5775
  function promiseAllObject(promisesObj) {
5657
5776
  const keys = Object.keys(promisesObj);
@@ -5900,11 +6019,11 @@ function aborted(x3, startIndex = 0) {
5900
6019
  }
5901
6020
  return false;
5902
6021
  }
5903
- function prefixIssues(path5, issues) {
6022
+ function prefixIssues(path8, issues) {
5904
6023
  return issues.map((iss) => {
5905
6024
  var _a;
5906
6025
  (_a = iss).path ?? (_a.path = []);
5907
- iss.path.unshift(path5);
6026
+ iss.path.unshift(path8);
5908
6027
  return iss;
5909
6028
  });
5910
6029
  }
@@ -9568,11 +9687,11 @@ function normalizeObjectSchema(schema) {
9568
9687
  }
9569
9688
  return void 0;
9570
9689
  }
9571
- function getDotPath(path5) {
9572
- if (path5.length === 0) {
9690
+ function getDotPath(path8) {
9691
+ if (path8.length === 0) {
9573
9692
  return "object root";
9574
9693
  }
9575
- return path5.reduce((acc, seg, index) => {
9694
+ return path8.reduce((acc, seg, index) => {
9576
9695
  if (index === 0) {
9577
9696
  return String(seg);
9578
9697
  }
@@ -17750,8 +17869,8 @@ var require_utils = __commonJS({
17750
17869
  }
17751
17870
  return ind;
17752
17871
  }
17753
- function removeDotSegments(path5) {
17754
- let input = path5;
17872
+ function removeDotSegments(path8) {
17873
+ let input = path8;
17755
17874
  const output = [];
17756
17875
  let nextSlash = -1;
17757
17876
  let len = 0;
@@ -18003,8 +18122,8 @@ var require_schemes = __commonJS({
18003
18122
  wsComponent.secure = void 0;
18004
18123
  }
18005
18124
  if (wsComponent.resourceName) {
18006
- const [path5, query] = wsComponent.resourceName.split("?");
18007
- wsComponent.path = path5 && path5 !== "/" ? path5 : void 0;
18125
+ const [path8, query] = wsComponent.resourceName.split("?");
18126
+ wsComponent.path = path8 && path8 !== "/" ? path8 : void 0;
18008
18127
  wsComponent.query = query;
18009
18128
  wsComponent.resourceName = void 0;
18010
18129
  }
@@ -23181,6 +23300,7 @@ var init_stdio2 = __esm({
23181
23300
 
23182
23301
  // ../mcp-tools/src/api.ts
23183
23302
  function apiErrorBody(error2) {
23303
+ if (typeof error2 !== "object" || error2 === null) return null;
23184
23304
  const body = error2.body;
23185
23305
  if (body && typeof body === "object" && body.error && typeof body.error.message === "string") {
23186
23306
  return body;
@@ -23202,8 +23322,108 @@ var init_api3 = __esm({
23202
23322
  }
23203
23323
  });
23204
23324
 
23325
+ // ../mcp-tools/src/edits.ts
23326
+ function escapeRegex2(s2) {
23327
+ return s2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
23328
+ }
23329
+ function nearMatch(content, find) {
23330
+ const parts = find.split(/\s+/).filter(Boolean).map(escapeRegex2);
23331
+ if (parts.length === 0) return null;
23332
+ let pattern;
23333
+ try {
23334
+ pattern = new RegExp(parts.join("\\s+"));
23335
+ } catch {
23336
+ return null;
23337
+ }
23338
+ const match = pattern.exec(content);
23339
+ if (!match || match[0].length > NEAR_MATCH_CAP) return null;
23340
+ return match[0];
23341
+ }
23342
+ function countOccurrences(haystack, needle) {
23343
+ let count = 0;
23344
+ let index = haystack.indexOf(needle);
23345
+ while (index !== -1) {
23346
+ count += 1;
23347
+ index = haystack.indexOf(needle, index + needle.length);
23348
+ }
23349
+ return count;
23350
+ }
23351
+ function applyEdit(content, edit, label) {
23352
+ if (edit.find === "") {
23353
+ return {
23354
+ ok: false,
23355
+ problem: {
23356
+ code: "edit_invalid",
23357
+ message: `${label}: \`find\` is empty \u2014 there is nothing to locate in ${edit.path}.`,
23358
+ next: "Pass the exact text currently in the file as `find`."
23359
+ }
23360
+ };
23361
+ }
23362
+ if (edit.find === edit.replace) {
23363
+ return {
23364
+ ok: false,
23365
+ problem: {
23366
+ code: "edit_invalid",
23367
+ message: `${label}: \`find\` and \`replace\` are identical \u2014 the edit would change nothing in ${edit.path}.`,
23368
+ next: "Make `replace` the new text you want, or drop this edit."
23369
+ }
23370
+ };
23371
+ }
23372
+ const count = countOccurrences(content, edit.find);
23373
+ if (count === 0) {
23374
+ const near = nearMatch(content, edit.find);
23375
+ if (near !== null) {
23376
+ return {
23377
+ ok: false,
23378
+ problem: {
23379
+ code: "edit_not_found",
23380
+ message: `${label}: no exact match in ${edit.path}, but the deployed file has a near match that differs only in whitespace/line breaks. The exact deployed text is in \`found\`.`,
23381
+ next: "Retry with `find` set to the `found` text verbatim.",
23382
+ found: near
23383
+ }
23384
+ };
23385
+ }
23386
+ return {
23387
+ ok: false,
23388
+ problem: {
23389
+ code: "edit_not_found",
23390
+ message: `${label}: the \`find\` text does not occur in ${edit.path} as deployed.`,
23391
+ next: `The live file may differ from what you have in context. Call get_site with paths ["${edit.path}"] and copy the exact text \u2014 whitespace included \u2014 then retry. Nothing was deployed.`
23392
+ }
23393
+ };
23394
+ }
23395
+ if (count > 1 && !edit.replace_all) {
23396
+ return {
23397
+ ok: false,
23398
+ problem: {
23399
+ code: "edit_ambiguous",
23400
+ message: `${label}: the \`find\` text occurs ${count} times in ${edit.path} \u2014 unclear which one to change.`,
23401
+ next: `Include more surrounding context in \`find\` to pin down one occurrence, or set replace_all: true to change all ${count}. Nothing was deployed.`
23402
+ }
23403
+ };
23404
+ }
23405
+ if (edit.replace_all) {
23406
+ return { ok: true, content: content.split(edit.find).join(edit.replace), replacements: count };
23407
+ }
23408
+ const index = content.indexOf(edit.find);
23409
+ return {
23410
+ ok: true,
23411
+ content: content.slice(0, index) + edit.replace + content.slice(index + edit.find.length),
23412
+ replacements: 1
23413
+ };
23414
+ }
23415
+ var NEAR_MATCH_CAP;
23416
+ var init_edits = __esm({
23417
+ "../mcp-tools/src/edits.ts"() {
23418
+ "use strict";
23419
+ NEAR_MATCH_CAP = 600;
23420
+ }
23421
+ });
23422
+
23205
23423
  // ../mcp-tools/src/inline-files.ts
23206
23424
  function decode(file) {
23425
+ if (file.bytes) return file.bytes;
23426
+ if (file.content === void 0) return null;
23207
23427
  if (file.encoding === "base64") {
23208
23428
  try {
23209
23429
  return Uint8Array.from(atob(file.content.replace(/\s/g, "")), (c3) => c3.charCodeAt(0));
@@ -23213,27 +23433,82 @@ function decode(file) {
23213
23433
  }
23214
23434
  return new TextEncoder().encode(file.content);
23215
23435
  }
23436
+ function configText(file) {
23437
+ if (file.content !== void 0 && file.encoding !== "base64") return file.content;
23438
+ const bytes = decode(file);
23439
+ return bytes === null ? null : new TextDecoder().decode(bytes);
23440
+ }
23216
23441
  function assembleInlineBundle(files, maxMb) {
23442
+ const maxBase64Chars = Math.ceil(MAX_INLINE_BASE64_MB * 1024 * 1024 * 4 / 3) + 4;
23217
23443
  const form = new FormData();
23444
+ const seen = /* @__PURE__ */ new Set();
23218
23445
  let config2 = null;
23219
23446
  let totalBytes = 0;
23220
23447
  let fileCount = 0;
23221
23448
  for (const file of files) {
23222
- const path5 = file.path.startsWith("/") ? file.path : `/${file.path}`;
23223
- if (path5.includes("..")) {
23449
+ const path8 = file.path.startsWith("/") ? file.path : `/${file.path}`;
23450
+ if (path8.includes("..") || !SAFE_PATH.test(path8)) {
23224
23451
  return {
23225
23452
  ok: false,
23226
23453
  problem: {
23227
23454
  code: "invalid_file_path",
23228
23455
  message: `File path "${file.path}" is not allowed.`,
23229
- next: 'Paths must be site-relative like "/index.html" with no "..".'
23456
+ next: `Paths must be site-relative like "/index.html" \u2014 letters, digits, and ._~!$&'()*+,;=:@%- only, no "..".`
23230
23457
  }
23231
23458
  };
23232
23459
  }
23233
- if (path5 === "/drivethrough.json") {
23460
+ if (seen.has(path8)) {
23461
+ return {
23462
+ ok: false,
23463
+ problem: {
23464
+ code: "duplicate_file_path",
23465
+ message: `File path "${path8}" appears more than once in this deploy.`,
23466
+ next: "Send each path exactly once \u2014 which copy wins would otherwise be arbitrary."
23467
+ }
23468
+ };
23469
+ }
23470
+ seen.add(path8);
23471
+ if (file.upload_id !== void 0 || file.from_path !== void 0) {
23472
+ if (path8 === "/drivethrough.json") {
23473
+ return {
23474
+ ok: false,
23475
+ problem: {
23476
+ code: "invalid_drivethrough_json",
23477
+ message: "drivethrough.json is the site's config and can't come from an upload or a copy.",
23478
+ next: "Send drivethrough.json as inline text content."
23479
+ }
23480
+ };
23481
+ }
23482
+ if (file.upload_id !== void 0) form.append(`upload:${path8}`, file.upload_id);
23483
+ else form.append(`copy:${path8}`, file.from_path.startsWith("/") ? file.from_path : `/${file.from_path}`);
23484
+ fileCount += 1;
23485
+ continue;
23486
+ }
23487
+ if (file.encoding === "base64" && file.content !== void 0 && file.content.length > maxBase64Chars) {
23488
+ return {
23489
+ ok: false,
23490
+ problem: {
23491
+ code: "inline_file_too_large",
23492
+ message: `"${path8}" is ~${(file.content.length * 3 / 4 / 1024 / 1024).toFixed(1)} MB of base64 \u2014 inline base64 content is capped at ${MAX_INLINE_BASE64_MB} MB per file.`,
23493
+ next: "Inline base64 is only for tiny pasted assets (a favicon). If the file is on the public web, pass {path, source_url} \u2014 the platform fetches it. If it's a local file, call create_upload for a signed URL the user uploads it to, then reference it as {path, upload_id}."
23494
+ }
23495
+ };
23496
+ }
23497
+ if (path8 === "/drivethrough.json") {
23498
+ const text = configText(file);
23499
+ if (text === null) {
23500
+ return {
23501
+ ok: false,
23502
+ problem: {
23503
+ code: "invalid_file_encoding",
23504
+ message: `File "${path8}" is marked base64 but its content is not valid base64.`,
23505
+ next: 'Base64-encode binary files; send text files with encoding "utf8" (the default).'
23506
+ }
23507
+ };
23508
+ }
23234
23509
  let parsed;
23235
23510
  try {
23236
- parsed = JSON.parse(file.content);
23511
+ parsed = JSON.parse(text);
23237
23512
  } catch {
23238
23513
  return {
23239
23514
  ok: false,
@@ -23252,7 +23527,8 @@ function assembleInlineBundle(files, maxMb) {
23252
23527
  };
23253
23528
  }
23254
23529
  config2 = result.config;
23255
- form.append("drivethrough.json", file.content);
23530
+ totalBytes += new TextEncoder().encode(text).length;
23531
+ form.append("drivethrough.json", text);
23256
23532
  continue;
23257
23533
  }
23258
23534
  const bytes = decode(file);
@@ -23261,16 +23537,16 @@ function assembleInlineBundle(files, maxMb) {
23261
23537
  ok: false,
23262
23538
  problem: {
23263
23539
  code: "invalid_file_encoding",
23264
- message: `File "${path5}" is marked base64 but its content is not valid base64.`,
23540
+ message: `File "${path8}" is marked base64 but its content is not valid base64.`,
23265
23541
  next: 'Base64-encode binary files; send text files with encoding "utf8" (the default).'
23266
23542
  }
23267
23543
  };
23268
23544
  }
23269
23545
  totalBytes += bytes.length;
23270
23546
  fileCount += 1;
23271
- form.append(`file:${path5}`, new Blob([bytes]), path5.split("/").pop() ?? "file");
23547
+ form.append(`file:${path8}`, new Blob([bytes]), path8.split("/").pop() ?? "file");
23272
23548
  }
23273
- if (fileCount === 0) {
23549
+ if (fileCount === 0 && config2 === null) {
23274
23550
  return {
23275
23551
  ok: false,
23276
23552
  problem: {
@@ -23292,31 +23568,312 @@ function assembleInlineBundle(files, maxMb) {
23292
23568
  }
23293
23569
  return { ok: true, bundle: { form, fileCount, totalBytes, config: config2 } };
23294
23570
  }
23571
+ var SAFE_PATH, MAX_INLINE_BASE64_MB;
23295
23572
  var init_inline_files = __esm({
23296
23573
  "../mcp-tools/src/inline-files.ts"() {
23297
23574
  "use strict";
23298
23575
  init_src();
23576
+ SAFE_PATH = /^\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]+$/;
23577
+ MAX_INLINE_BASE64_MB = 1;
23578
+ }
23579
+ });
23580
+
23581
+ // ../mcp-tools/src/remote-assets.ts
23582
+ function isPrivateIpv4(host) {
23583
+ const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
23584
+ if (!match) return false;
23585
+ const [a2, b4] = [Number(match[1]), Number(match[2])];
23586
+ if (a2 === 0 || a2 === 10 || a2 === 127) return true;
23587
+ if (a2 === 169 && b4 === 254) return true;
23588
+ if (a2 === 172 && b4 >= 16 && b4 <= 31) return true;
23589
+ if (a2 === 192 && b4 === 168) return true;
23590
+ if (a2 === 100 && b4 >= 64 && b4 <= 127) return true;
23591
+ if (a2 === 192 && b4 === 0) return true;
23592
+ if (a2 === 198 && (b4 === 18 || b4 === 19)) return true;
23593
+ return a2 >= 224;
23594
+ }
23595
+ function isBlockedHost(hostname2) {
23596
+ const host = hostname2.toLowerCase().replace(/\.$/, "");
23597
+ if (host === "localhost" || host === "" || BLOCKED_HOST_SUFFIXES.some((s2) => host.endsWith(s2))) return true;
23598
+ if (isPrivateIpv4(host)) return true;
23599
+ if (host.includes(":") || host.startsWith("[")) {
23600
+ const v6 = host.replace(/^\[|\]$/g, "");
23601
+ return !/^[23][0-9a-f]{3}:/i.test(v6);
23602
+ }
23603
+ return false;
23604
+ }
23605
+ async function resolveViaDoh(hostname2) {
23606
+ const addresses = [];
23607
+ for (const type of ["A", "AAAA"]) {
23608
+ const res = await fetch(
23609
+ `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(hostname2)}&type=${type}`,
23610
+ { headers: { Accept: "application/dns-json" }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }
23611
+ );
23612
+ if (!res.ok) throw new Error(`DNS lookup returned HTTP ${res.status}`);
23613
+ const body = await res.json();
23614
+ for (const answer of body.Answer ?? []) {
23615
+ if (answer.type === 1 || answer.type === 28) addresses.push(answer.data);
23616
+ }
23617
+ }
23618
+ return addresses;
23619
+ }
23620
+ async function resolutionProblem(hostname2) {
23621
+ const host = hostname2.toLowerCase().replace(/\.$/, "");
23622
+ if (IP_LITERAL.test(host) || host.includes(":") || host.startsWith("[")) return null;
23623
+ let addresses;
23624
+ try {
23625
+ addresses = await resolveAddresses(host);
23626
+ } catch (error2) {
23627
+ return `its address couldn't be verified (${error2 instanceof Error ? error2.message : String(error2)})`;
23628
+ }
23629
+ if (addresses.length === 0) return "it doesn't resolve to any address";
23630
+ const blocked = addresses.find((address) => isBlockedHost(address));
23631
+ if (blocked) return `it resolves to a private or reserved address (${blocked})`;
23632
+ return null;
23633
+ }
23634
+ function urlProblem(url, why) {
23635
+ return {
23636
+ code: "invalid_source_url",
23637
+ message: `source_url ${JSON.stringify(url)} is not fetchable: ${why}`,
23638
+ next: "Use a public http(s) URL. For anything not publicly reachable, pass the bytes inline as base64 `content`."
23639
+ };
23640
+ }
23641
+ function checkUrl(raw) {
23642
+ let url;
23643
+ try {
23644
+ url = new URL(raw);
23645
+ } catch {
23646
+ return { ok: false, problem: urlProblem(raw, "not a valid absolute URL") };
23647
+ }
23648
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
23649
+ return { ok: false, problem: urlProblem(raw, `scheme "${url.protocol}" is not allowed`) };
23650
+ }
23651
+ if (isBlockedHost(url.hostname)) {
23652
+ return { ok: false, problem: urlProblem(raw, "the host is private or internal") };
23653
+ }
23654
+ return { ok: true, url };
23655
+ }
23656
+ async function fetchRemote(path8, raw, budget) {
23657
+ const maxBytes = budget.maxBytes;
23658
+ const capMessage = budget.whole ? `it would take this deploy past the ${MAX_BUNDLE_TOTAL_MB} MB bundle limit` : `source_url files are capped at ${MAX_REMOTE_FILE_MB} MB`;
23659
+ const capNext = budget.whole ? 'Deploy the rest in a follow-up mode:"patch" call \u2014 unchanged files carry forward.' : "Use a smaller asset (resize/compress images before hosting them).";
23660
+ let current = raw;
23661
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
23662
+ const checked = checkUrl(current);
23663
+ if (!checked.ok) return checked;
23664
+ const unresolvable = await resolutionProblem(new URL(checked.url).hostname);
23665
+ if (unresolvable) return { ok: false, problem: urlProblem(current, unresolvable) };
23666
+ let res;
23667
+ try {
23668
+ res = await fetch(checked.url, {
23669
+ redirect: "manual",
23670
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
23671
+ });
23672
+ } catch (error2) {
23673
+ return {
23674
+ ok: false,
23675
+ problem: {
23676
+ code: "source_url_fetch_failed",
23677
+ message: `Fetching ${JSON.stringify(current)} for "${path8}" failed: ${error2 instanceof Error ? error2.message : String(error2)}`,
23678
+ next: "Check the URL is reachable, or pass the bytes inline as base64 `content`."
23679
+ }
23680
+ };
23681
+ }
23682
+ if (res.status >= 300 && res.status < 400) {
23683
+ const location = res.headers.get("location");
23684
+ if (!location) return { ok: false, problem: urlProblem(current, `redirect (${res.status}) with no Location`) };
23685
+ current = new URL(location, checked.url).toString();
23686
+ continue;
23687
+ }
23688
+ if (!res.ok) {
23689
+ return {
23690
+ ok: false,
23691
+ problem: {
23692
+ code: "source_url_fetch_failed",
23693
+ message: `Fetching ${JSON.stringify(current)} for "${path8}" returned HTTP ${res.status}.`,
23694
+ next: "Check the URL serves the asset publicly (no auth), or pass the bytes inline as base64 `content`."
23695
+ }
23696
+ };
23697
+ }
23698
+ const declared = Number(res.headers.get("content-length") ?? 0);
23699
+ if (declared > maxBytes) {
23700
+ return {
23701
+ ok: false,
23702
+ problem: {
23703
+ code: "source_url_too_large",
23704
+ message: `"${path8}" is ${(declared / 1024 / 1024).toFixed(1)} MB; ${capMessage}.`,
23705
+ next: capNext
23706
+ }
23707
+ };
23708
+ }
23709
+ const tooLarge = {
23710
+ ok: false,
23711
+ problem: {
23712
+ code: "source_url_too_large",
23713
+ message: `"${path8}" is too large \u2014 ${capMessage}.`,
23714
+ next: capNext
23715
+ }
23716
+ };
23717
+ const emptyBody = {
23718
+ ok: false,
23719
+ problem: {
23720
+ code: "source_url_empty",
23721
+ message: `Fetching ${JSON.stringify(current)} for "${path8}" returned no content.`,
23722
+ next: "Check the URL actually serves the asset, or pass the bytes inline as base64 `content`."
23723
+ }
23724
+ };
23725
+ if (!res.body) return emptyBody;
23726
+ const reader = res.body.getReader();
23727
+ const chunks = [];
23728
+ let received = 0;
23729
+ try {
23730
+ for (; ; ) {
23731
+ const { done, value } = await reader.read();
23732
+ if (done) break;
23733
+ received += value.length;
23734
+ if (received > maxBytes) {
23735
+ await reader.cancel();
23736
+ return tooLarge;
23737
+ }
23738
+ chunks.push(value);
23739
+ }
23740
+ } catch (error2) {
23741
+ return {
23742
+ ok: false,
23743
+ problem: {
23744
+ code: "source_url_fetch_failed",
23745
+ message: `Reading ${JSON.stringify(current)} for "${path8}" failed: ${error2 instanceof Error ? error2.message : String(error2)}`,
23746
+ next: "Check the URL is reachable, or pass the bytes inline as base64 `content`."
23747
+ }
23748
+ };
23749
+ } finally {
23750
+ reader.releaseLock();
23751
+ }
23752
+ if (received === 0) return emptyBody;
23753
+ const bytes = new Uint8Array(received);
23754
+ let offset = 0;
23755
+ for (const chunk of chunks) {
23756
+ bytes.set(chunk, offset);
23757
+ offset += chunk.length;
23758
+ }
23759
+ return { ok: true, file: { path: path8, bytes }, size: received };
23760
+ }
23761
+ return { ok: false, problem: urlProblem(raw, `more than ${MAX_REDIRECTS} redirects`) };
23762
+ }
23763
+ async function resolveDeployFiles(files, opts = {}) {
23764
+ const remoteCount = files.filter((f3) => f3.source_url !== void 0).length;
23765
+ if (remoteCount > MAX_REMOTE_FILES) {
23766
+ return {
23767
+ ok: false,
23768
+ problem: {
23769
+ code: "too_many_source_urls",
23770
+ message: `This deploy names ${remoteCount} source_url files; the cap is ${MAX_REMOTE_FILES} per deploy.`,
23771
+ next: 'Deploy the rest in a follow-up mode:"patch" call \u2014 unchanged files carry forward.'
23772
+ }
23773
+ };
23774
+ }
23775
+ const maxTotal = opts.maxTotalBytes ?? MAX_BUNDLE_TOTAL_MB * 1024 * 1024;
23776
+ const perFile = MAX_REMOTE_FILE_MB * 1024 * 1024;
23777
+ let spent = files.reduce((total, file) => {
23778
+ if (file.content === void 0) return total;
23779
+ return total + (file.encoding === "base64" ? Math.floor(file.content.length * 3 / 4) : file.content.length);
23780
+ }, 0);
23781
+ const out = [];
23782
+ for (const file of files) {
23783
+ const sources = [
23784
+ file.content !== void 0 && "content",
23785
+ file.source_url !== void 0 && "source_url",
23786
+ file.upload_id !== void 0 && "upload_id",
23787
+ file.from_path !== void 0 && "from_path"
23788
+ ].filter((s2) => s2 !== false);
23789
+ if (sources.length > 1) {
23790
+ return {
23791
+ ok: false,
23792
+ problem: {
23793
+ code: "ambiguous_file_source",
23794
+ message: `File "${file.path}" has ${sources.join(" and ")} \u2014 unclear which to use.`,
23795
+ next: "Pass exactly one of content | source_url | upload_id | from_path per file."
23796
+ }
23797
+ };
23798
+ }
23799
+ if (file.upload_id !== void 0 || file.from_path !== void 0) {
23800
+ out.push({ path: file.path, upload_id: file.upload_id, from_path: file.from_path });
23801
+ } else if (file.source_url !== void 0) {
23802
+ const remaining = maxTotal - spent;
23803
+ if (remaining <= 0) {
23804
+ return {
23805
+ ok: false,
23806
+ problem: {
23807
+ code: "bundle_too_large",
23808
+ message: `This deploy is already at the ${MAX_BUNDLE_TOTAL_MB} MB bundle limit before fetching "${file.path}".`,
23809
+ next: 'Deploy the rest in a follow-up mode:"patch" call \u2014 unchanged files carry forward.'
23810
+ }
23811
+ };
23812
+ }
23813
+ const whole = remaining < perFile;
23814
+ const fetched = await fetchRemote(file.path, file.source_url, {
23815
+ maxBytes: Math.min(perFile, remaining),
23816
+ whole
23817
+ });
23818
+ if (!fetched.ok) return fetched;
23819
+ spent += fetched.size;
23820
+ out.push(fetched.file);
23821
+ } else if (file.content !== void 0) {
23822
+ out.push({ path: file.path, content: file.content, encoding: file.encoding });
23823
+ } else {
23824
+ return {
23825
+ ok: false,
23826
+ problem: {
23827
+ code: "missing_file_content",
23828
+ message: `File "${file.path}" has no \`content\`, \`source_url\`, \`upload_id\`, or \`from_path\`.`,
23829
+ next: "Pass the file's content inline, a public URL to fetch it from, the upload_id of a completed create_upload, or the from_path of a deployed file to copy."
23830
+ }
23831
+ };
23832
+ }
23833
+ }
23834
+ return { ok: true, files: out };
23835
+ }
23836
+ var MAX_REMOTE_FILES, MAX_REMOTE_FILE_MB, MAX_BUNDLE_TOTAL_MB, MAX_REDIRECTS, FETCH_TIMEOUT_MS, BLOCKED_HOST_SUFFIXES, resolveAddresses, IP_LITERAL;
23837
+ var init_remote_assets = __esm({
23838
+ "../mcp-tools/src/remote-assets.ts"() {
23839
+ "use strict";
23840
+ MAX_REMOTE_FILES = 20;
23841
+ MAX_REMOTE_FILE_MB = 10;
23842
+ MAX_BUNDLE_TOTAL_MB = 50;
23843
+ MAX_REDIRECTS = 3;
23844
+ FETCH_TIMEOUT_MS = 2e4;
23845
+ BLOCKED_HOST_SUFFIXES = [".localhost", ".local", ".internal", ".home.arpa"];
23846
+ resolveAddresses = resolveViaDoh;
23847
+ IP_LITERAL = /^\d{1,3}(\.\d{1,3}){3}$/;
23299
23848
  }
23300
23849
  });
23301
23850
 
23302
23851
  // ../mcp-tools/src/tools.ts
23303
23852
  function ok(data) {
23304
- return { content: [{ type: "text", text: JSON.stringify({ ok: true, ...data }, null, 2) }] };
23853
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true, ...data }) }] };
23305
23854
  }
23306
23855
  function toolError(error2) {
23307
23856
  const body = apiErrorBody(error2) ?? {
23308
23857
  error: { code: "mcp_error", message: error2 instanceof Error ? error2.message : String(error2) }
23309
23858
  };
23310
- return { content: [{ type: "text", text: JSON.stringify({ ok: false, ...body }, null, 2) }], isError: true };
23859
+ return { content: [{ type: "text", text: JSON.stringify({ ok: false, ...body }) }], isError: true };
23311
23860
  }
23312
23861
  function problemError(problem) {
23313
23862
  return new ToolApiError(422, { error: problem });
23314
23863
  }
23864
+ function sitePath(site) {
23865
+ return `/v1/sites/${encodeURIComponent(site)}`;
23866
+ }
23315
23867
  function registerTools(server, ctx) {
23316
23868
  server.registerTool(
23317
23869
  "deploy_site",
23318
23870
  {
23319
- description: "Deploy a site to Drivethrough. Idempotent: creates the site if needed, else ships a new version. Sites are private by default. Pass `files` with generated content (the platform assembles the bundle server-side \u2014 no local filesystem or build toolchain needed), or `directory` when this tool runs on the user's machine.",
23871
+ title: "Deploy site",
23872
+ description: "Deploy a site to Drivethrough. Idempotent: creates the site if needed, else ships a new version. Sites are private by default. Pass `files` with generated content (the platform assembles the bundle server-side \u2014 no local filesystem or build toolchain needed), or `directory` when this tool runs on the user's machine. Images and other binary assets \u2014 pick the route by where the bytes live: already at a public URL \u2192 {path, source_url} (fetched server-side); a local file on the user's machine and no local filesystem here \u2192 create_upload first, then {path, upload_id}; a local project directory with filesystem access \u2192 `directory` carries binaries by itself; already deployed on this site \u2192 {path, from_path} copies it server-side (with delete_paths, a move). Never emit base64 by hand \u2014 inline base64 content is capped at 1 MB and is only for tiny pasted assets. To rework an already-deployed site, read it back with get_site first, then deploy with mode \"patch\" \u2014 only the files that change. For small text changes to existing files, use edit_site instead: it sends find/replace pairs, never whole files. Every deploy's version number is a restore point: prior versions stay listed (get_site history), diffable, viewable (preview_site), and returnable (revert_site).",
23873
+ // Destructive: the default "replace" mode drops every deployed file the
23874
+ // call doesn't re-send, and delete_paths removes files outright. Prior
23875
+ // versions stay revertible, but the live site does lose content.
23876
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
23320
23877
  inputSchema: {
23321
23878
  directory: external_exports.string().optional().describe(
23322
23879
  "Path to a local project directory to deploy (stdio/CLI transport only; uses ./dist inside it when present)"
@@ -23324,31 +23881,85 @@ function registerTools(server, ctx) {
23324
23881
  files: external_exports.array(
23325
23882
  external_exports.object({
23326
23883
  path: external_exports.string().describe('Site-relative path like "/index.html"'),
23327
- content: external_exports.string().describe("File contents (text, or base64 for binary)"),
23328
- encoding: external_exports.enum(["utf8", "base64"]).optional().describe('How content is encoded; defaults to "utf8"')
23884
+ content: external_exports.string().optional().describe(
23885
+ "File contents (text, or base64 for tiny binary \u22641 MB). Give exactly one of content | source_url | upload_id | from_path."
23886
+ ),
23887
+ encoding: external_exports.enum(["utf8", "base64"]).optional().describe('How content is encoded; defaults to "utf8"'),
23888
+ source_url: external_exports.string().optional().describe(
23889
+ "Public http(s) URL to fetch this file from, server-side. The way to ship a binary asset that is already hosted somewhere \u2014 never generate base64 content by hand."
23890
+ ),
23891
+ upload_id: external_exports.string().optional().describe(
23892
+ "Id of a completed direct upload (create_upload) \u2014 the way to ship a large local file the user uploaded. The platform copies the uploaded bytes into this deploy. Single-use: once deployed, reuse the file via from_path instead."
23893
+ ),
23894
+ from_path: external_exports.string().optional().describe(
23895
+ `Path of a file in the site's current version to copy here, server-side \u2014 rename or duplicate a deployed file (especially a large binary) without re-sending bytes. Move = from_path + the old path in delete_paths (mode "patch").`
23896
+ )
23329
23897
  })
23330
23898
  ).optional().describe(
23331
23899
  "Site files passed inline \u2014 use when there is no local filesystem (chat/remote agents). Include a drivethrough.json entry to declare data types."
23332
23900
  ),
23333
23901
  site: external_exports.string().optional().describe('Site name (defaults to drivethrough.json "site" or the folder name)'),
23334
- title: external_exports.string().optional().describe("Human title shown to guests")
23902
+ title: external_exports.string().optional().describe("Human title shown to guests"),
23903
+ mode: external_exports.enum(["replace", "patch"]).optional().describe(
23904
+ '"patch" carries every unchanged file forward from the currently deployed version \u2014 send only the files that change (and/or delete_paths). Default "replace": the files given are the whole site.'
23905
+ ),
23906
+ delete_paths: external_exports.array(external_exports.string()).optional().describe('Files to remove from the site in a "patch" deploy, e.g. ["/old-page.html"]'),
23907
+ base_version: external_exports.number().int().min(0).optional().describe(
23908
+ "The deployed version this deploy builds on \u2014 pass it ONLY when you already know it from this conversation (an earlier get_site or deploy). If the site has moved past it (edits from another session), the deploy is refused with a summary of what changed instead of overwriting it. Never make an extra call just to learn the version \u2014 omit this instead."
23909
+ )
23335
23910
  }
23336
23911
  },
23337
23912
  async ({
23338
23913
  directory,
23339
23914
  files,
23340
23915
  site,
23341
- title
23916
+ title,
23917
+ mode,
23918
+ delete_paths,
23919
+ base_version
23342
23920
  }) => {
23343
23921
  try {
23922
+ const patch = mode === "patch";
23923
+ if (files && files.length > 0 && directory) {
23924
+ throw new ToolApiError(422, {
23925
+ error: {
23926
+ code: "ambiguous_source",
23927
+ message: "This deploy has both inline `files` and a `directory` \u2014 unclear which is the site.",
23928
+ next: "Pass exactly one of the two."
23929
+ }
23930
+ });
23931
+ }
23932
+ if (patch && directory) {
23933
+ throw new ToolApiError(422, {
23934
+ error: {
23935
+ code: "patch_needs_files",
23936
+ message: "Patch deploys take inline `files`, not a directory \u2014 a directory already is the whole site.",
23937
+ next: "Deploy the directory without mode, or pass the changed files inline."
23938
+ }
23939
+ });
23940
+ }
23344
23941
  let form;
23345
23942
  let config2;
23346
- let fallbackName = "site";
23943
+ let fallbackName = null;
23347
23944
  if (files && files.length > 0) {
23348
- const assembled = assembleInlineBundle(files, MAX_BUNDLE_MB);
23945
+ const resolved = await resolveDeployFiles(files);
23946
+ if (!resolved.ok) throw problemError(resolved.problem);
23947
+ const assembled = assembleInlineBundle(resolved.files, MAX_BUNDLE_MB);
23349
23948
  if (!assembled.ok) throw problemError(assembled.problem);
23949
+ if (!patch && assembled.bundle.fileCount === 0) {
23950
+ throw new ToolApiError(422, {
23951
+ error: {
23952
+ code: "config_only_replace",
23953
+ message: "This deploy is only drivethrough.json \u2014 as a full replace it would leave the site with no files.",
23954
+ next: 'Use mode "patch" to update the config while the deployed files carry forward.'
23955
+ }
23956
+ });
23957
+ }
23350
23958
  form = assembled.bundle.form;
23351
23959
  config2 = assembled.bundle.config;
23960
+ } else if (patch && delete_paths && delete_paths.length > 0) {
23961
+ form = new FormData();
23962
+ config2 = null;
23352
23963
  } else if (directory) {
23353
23964
  if (!ctx.localDirectories) {
23354
23965
  throw new ToolApiError(422, {
@@ -23374,13 +23985,31 @@ function registerTools(server, ctx) {
23374
23985
  });
23375
23986
  }
23376
23987
  const sitename = site ?? config2?.site ?? fallbackName;
23988
+ if (!sitename) {
23989
+ throw new ToolApiError(422, {
23990
+ error: {
23991
+ code: "missing_site",
23992
+ message: "Inline deploys must name the site \u2014 otherwise unrelated deploys would collide on a default.",
23993
+ next: 'Pass `site` (see list_sites for existing names), or include a drivethrough.json with a "site" field.'
23994
+ }
23995
+ });
23996
+ }
23997
+ if (patch) {
23998
+ form.set("mode", "patch");
23999
+ if (delete_paths && delete_paths.length > 0) {
24000
+ form.set("delete_paths", JSON.stringify(delete_paths));
24001
+ }
24002
+ }
24003
+ if (base_version !== void 0) {
24004
+ form.set("base_version", String(base_version));
24005
+ }
23377
24006
  const finalTitle = title ?? config2?.title;
23378
24007
  const siteRes = await ctx.api.request("POST", "/v1/sites", {
23379
24008
  body: { sitename, ...finalTitle ? { title: finalTitle } : {} }
23380
24009
  });
23381
24010
  const deploy = await ctx.api.request(
23382
24011
  "POST",
23383
- `/v1/sites/${siteRes.site.id}/deployments`,
24012
+ `${sitePath(siteRes.site.id)}/deployments`,
23384
24013
  { form }
23385
24014
  );
23386
24015
  return ok({ site: siteRes.site, deploy, created: siteRes.created });
@@ -23390,31 +24019,464 @@ function registerTools(server, ctx) {
23390
24019
  }
23391
24020
  );
23392
24021
  server.registerTool(
23393
- "create_db",
24022
+ "create_upload",
23394
24023
  {
23395
- description: "Provision the per-site database (\xA76). Idempotent. Deploying a drivethrough.json that declares types does this automatically.",
23396
- inputSchema: { site: external_exports.string().describe("Site name or id") }
24024
+ title: "Create upload URL",
24025
+ description: `Mint a short-lived signed URL the USER can upload one local file to \u2014 the route for large binary assets (photos, PDFs, audio, video) when you have no local filesystem and the file isn't hosted anywhere. The bytes never pass through you: relay the returned upload command to the user, wait for them to run it, then reference the file in deploy_site as {path, upload_id} (mode "patch" adds it to a live site). Choosing a route for a binary asset: already on the public web \u2192 deploy_site {path, source_url}; local file + this server runs on the user's machine \u2192 deploy the directory; local file + chat-only connection \u2192 this tool. Tiny pasted assets (\u22641 MB, e.g. a favicon) may still go inline as base64 content. Each link takes exactly one file, is capped at 25 MB, and expires in 15 minutes. Upload ids are single-use: the deploy that references one consumes it (the bundle keeps the bytes; the staged copy is removed and its quota refunded) \u2014 to rename or duplicate the file later, use deploy_site {path, from_path}. An upload no deploy references within 24 hours is deleted, so deploy soon after the user confirms. Pass size_bytes when the user told you the file size \u2014 quota errors then surface here instead of failing the user's upload.`,
24026
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
24027
+ inputSchema: {
24028
+ site: external_exports.string().describe("Site name or id the upload belongs to"),
24029
+ filename: external_exports.string().optional().describe(`The file's name, e.g. "dog.jpg" \u2014 its extension names the stored object`),
24030
+ size_bytes: external_exports.number().int().optional().describe("Expected file size in bytes, if known \u2014 checked against the 25 MB cap and storage quota now")
24031
+ }
23397
24032
  },
23398
- async ({ site }) => {
24033
+ async ({ site, filename, size_bytes }) => {
23399
24034
  try {
23400
- return ok(await ctx.api.request("POST", `/v1/sites/${site}/db`));
24035
+ return ok(
24036
+ await ctx.api.request("POST", `${sitePath(site)}/uploads`, {
24037
+ body: {
24038
+ ...filename !== void 0 ? { filename } : {},
24039
+ ...size_bytes !== void 0 ? { size_bytes } : {}
24040
+ }
24041
+ })
24042
+ );
23401
24043
  } catch (error2) {
23402
24044
  return toolError(error2);
23403
24045
  }
23404
24046
  }
23405
24047
  );
23406
24048
  server.registerTool(
23407
- "set_privacy",
24049
+ "edit_site",
23408
24050
  {
23409
- description: "Set a site's visibility to public or private (\xA78). Privacy is a setting \u2014 nothing redeploys.",
24051
+ title: "Edit site files",
24052
+ description: "Edit a deployed site's text files in place \u2014 the efficient way to make small changes (wording, values, a link, a style tweak) to a live site. Send only find/replace pairs, never whole file contents: the platform reads the current version, applies the edits, and ships a new version with everything else carried forward. Prefer this over get_site + deploy_site whenever the change is small relative to the file. Each `find` must match the deployed text exactly (whitespace included) and exactly once \u2014 add surrounding context to pin down one occurrence, or set replace_all. All-or-nothing: if any edit fails to match, nothing deploys and the error says how to recover, so you can try an edit without reading the file first. Edits are never lossy: the pre-edit version stays restorable via revert_site, so to undo an edit, revert \u2014 don't hand-invert it. After visual changes, preview_site shows what actually rendered.",
24053
+ // Destructive: find/replace overwrites text that is currently live.
24054
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
23410
24055
  inputSchema: {
23411
24056
  site: external_exports.string().describe("Site name or id"),
23412
- visibility: external_exports.enum(["public", "private"]).describe("Who can see the site")
24057
+ edits: external_exports.array(
24058
+ external_exports.object({
24059
+ path: external_exports.string().describe('File to edit, site-relative like "/index.html"'),
24060
+ find: external_exports.string().describe("Exact text currently in the deployed file \u2014 copy it verbatim, whitespace matters"),
24061
+ replace: external_exports.string().describe('The new text ("" removes the matched text)'),
24062
+ replace_all: external_exports.boolean().optional().describe("Replace every occurrence of `find` instead of requiring exactly one match")
24063
+ })
24064
+ ).min(1).describe("Edits applied in order; later edits see earlier results")
24065
+ }
24066
+ },
24067
+ async ({ site, edits }) => {
24068
+ try {
24069
+ const normalized = edits.map((e3) => ({ ...e3, path: e3.path.startsWith("/") ? e3.path : `/${e3.path}` }));
24070
+ for (const edit of normalized) {
24071
+ if (edit.path === "/drivethrough.json") {
24072
+ throw new ToolApiError(422, {
24073
+ error: {
24074
+ code: "cannot_edit_config",
24075
+ message: "drivethrough.json is the site's config, not a served file \u2014 it can't be string-edited.",
24076
+ next: 'Read the current config with get_site, then deploy a full drivethrough.json with mode "patch".'
24077
+ }
24078
+ });
24079
+ }
24080
+ }
24081
+ const paths = [...new Set(normalized.map((e3) => e3.path))];
24082
+ const query = paths.map((p2) => `path=${encodeURIComponent(p2)}`).join("&");
24083
+ const read = await ctx.api.request("GET", `${sitePath(site)}/files?${query}`);
24084
+ const texts = /* @__PURE__ */ new Map();
24085
+ for (const path8 of paths) {
24086
+ const file = read.files.find((f3) => f3.path === path8);
24087
+ if (!file) {
24088
+ throw new ToolApiError(422, {
24089
+ error: {
24090
+ code: "edit_path_not_found",
24091
+ message: `${path8} is not in the deployed version.`,
24092
+ next: "Check the exact path with get_site {manifest_only: true}."
24093
+ }
24094
+ });
24095
+ }
24096
+ if (file.content === void 0 || file.encoding !== "utf8") {
24097
+ throw new ToolApiError(422, {
24098
+ error: {
24099
+ code: "edit_not_text",
24100
+ message: `${path8} (${file.content_type}) is not an editable text file.`,
24101
+ next: 'Only text files can be string-edited. Redeploy this file wholesale with deploy_site mode "patch".'
24102
+ }
24103
+ });
24104
+ }
24105
+ texts.set(path8, file.content);
24106
+ }
24107
+ let replacements = 0;
24108
+ for (const [index, edit] of normalized.entries()) {
24109
+ const outcome = applyEdit(texts.get(edit.path), edit, `edits[${index}]`);
24110
+ if (!outcome.ok) throw problemError(outcome.problem);
24111
+ texts.set(edit.path, outcome.content);
24112
+ replacements += outcome.replacements;
24113
+ }
24114
+ const assembled = assembleInlineBundle(
24115
+ paths.map((path8) => ({ path: path8, content: texts.get(path8) })),
24116
+ MAX_BUNDLE_MB
24117
+ );
24118
+ if (!assembled.ok) throw problemError(assembled.problem);
24119
+ assembled.bundle.form.set("mode", "patch");
24120
+ assembled.bundle.form.set("base_version", String(read.version));
24121
+ assembled.bundle.form.set("edit_script", JSON.stringify(normalized));
24122
+ const deploy = await ctx.api.request(
24123
+ "POST",
24124
+ `${sitePath(read.site_id)}/deployments`,
24125
+ { form: assembled.bundle.form }
24126
+ );
24127
+ return ok({
24128
+ site: { id: read.site_id, sitename: read.sitename },
24129
+ deploy,
24130
+ files_edited: paths.length,
24131
+ replacements
24132
+ });
24133
+ } catch (error2) {
24134
+ return toolError(error2);
24135
+ }
24136
+ }
24137
+ );
24138
+ server.registerTool(
24139
+ "get_site",
24140
+ {
24141
+ title: "Read site files & history",
24142
+ description: 'Read back a deployed site: file manifest, file contents, drivethrough.json \u2014 and the site\'s version history. Use this to resume work on a site from an earlier conversation. Read the minimum you need: `find` locates text without returning file contents, `manifest_only` lists what exists, `paths` fetches only named files \u2014 a bare call inlines every text file, so reserve it for genuinely reading a whole small site. Versions: every deploy is a numbered version the platform keeps. `history: true` lists them with what each one changed (the map for revert_site), `compare: {from, to}` returns line diffs between two versions ("what did v9\u2192v10 change?"), and `version` pins any read mode to a prior version instead of the live one. Modes are exclusive per call \u2014 compare wins over history, history over find/manifest_only. Entries come back deploy_site-compatible ({path, content, encoding}); binary files are listed but only inlined when named in `paths`. For small text changes you may not need this at all \u2014 edit_site applies find/replace to the deployed files directly.',
24143
+ annotations: { readOnlyHint: true, openWorldHint: false },
24144
+ inputSchema: {
24145
+ site: external_exports.string().describe("Site name or id"),
24146
+ paths: external_exports.array(external_exports.string()).optional().describe(
24147
+ 'Return only these files, e.g. ["/index.html"] \u2014 naming a binary file inlines it as base64. With `find`, restricts the search to these files.'
24148
+ ),
24149
+ find: external_exports.string().optional().describe(
24150
+ "Search the deployed text files for this literal string instead of returning contents. Matches come back as byte-exact lines with path + line number \u2014 usable directly as edit_site `find` context. The cheap way to locate text before editing."
24151
+ ),
24152
+ manifest_only: external_exports.boolean().optional().describe("List files (path, size, type, sha256) without any contents \u2014 a cheap inventory of what's deployed"),
24153
+ version: external_exports.number().int().optional().describe(
24154
+ "Read a prior version instead of the live one \u2014 works with paths, find, and manifest_only. The response's current_version shows what's actually live."
24155
+ ),
24156
+ history: external_exports.union([external_exports.boolean(), external_exports.number().int()]).optional().describe(
24157
+ "true: list the most recent versions \u2014 number, date, size, and which files each one added/changed/removed. Pass a version number instead to page further back (versions before it). The cheap first call when the user wants something undone."
24158
+ ),
24159
+ compare: external_exports.object({
24160
+ from: external_exports.number().int().describe("Older version"),
24161
+ to: external_exports.number().int().describe("Newer version")
24162
+ }).optional().describe(
24163
+ "Line-diff two versions: per-file added/removed/changed status plus compact unified diffs for changed text files. Use before revert_site to see exactly what going back would change."
24164
+ )
24165
+ }
24166
+ },
24167
+ async ({
24168
+ site,
24169
+ paths,
24170
+ find,
24171
+ manifest_only,
24172
+ version: version2,
24173
+ history,
24174
+ compare
24175
+ }) => {
24176
+ try {
24177
+ if (compare) {
24178
+ return ok(await ctx.api.request("GET", `${sitePath(site)}/diff?from=${compare.from}&to=${compare.to}`));
24179
+ }
24180
+ if (history !== void 0 && history !== false) {
24181
+ if (typeof history === "number" && history < 1) {
24182
+ throw new ToolApiError(422, {
24183
+ error: {
24184
+ code: "invalid_history",
24185
+ message: "`history` as a number pages back from that version \u2014 it must be 1 or greater.",
24186
+ next: "Pass history: true for the most recent versions."
24187
+ }
24188
+ });
24189
+ }
24190
+ const before = typeof history === "number" ? `?before=${history}` : "";
24191
+ return ok(await ctx.api.request("GET", `${sitePath(site)}/versions${before}`));
24192
+ }
24193
+ if (find !== void 0 && find.length === 0) {
24194
+ throw new ToolApiError(422, {
24195
+ error: {
24196
+ code: "empty_find",
24197
+ message: "`find` is empty \u2014 an empty search matches nothing.",
24198
+ next: "Pass the literal text to locate, or use manifest_only to list files."
24199
+ }
24200
+ });
24201
+ }
24202
+ const params = new URLSearchParams();
24203
+ for (const p2 of paths ?? []) params.append("path", p2);
24204
+ if (find !== void 0) params.set("find", find);
24205
+ else if (manifest_only) params.set("manifest_only", "1");
24206
+ if (version2 !== void 0) params.set("version", String(version2));
24207
+ const query = params.toString();
24208
+ return ok(await ctx.api.request("GET", `${sitePath(site)}/files${query ? `?${query}` : ""}`));
24209
+ } catch (error2) {
24210
+ return toolError(error2);
24211
+ }
24212
+ }
24213
+ );
24214
+ server.registerTool(
24215
+ "preview_site",
24216
+ {
24217
+ title: "Preview site (screenshot)",
24218
+ description: "See a deployed site the way a visitor does: renders the page in a real browser server-side and returns a screenshot image you can look at directly. You have no browser of your own \u2014 this is your only view of rendered results, so use it to verify visual work (layout, styling, animations, responsive behavior) actually shipped correctly instead of assuming it did, and to eyeball a prior `version` before reverting to it. Works on private sites; nothing becomes public. Takes a few seconds \u2014 look once after a coherent batch of changes, not after every edit. Animations: the capture happens at page load; pass wait_ms to let one settle or reach mid-cycle.",
24219
+ annotations: { readOnlyHint: true, openWorldHint: false },
24220
+ inputSchema: {
24221
+ site: external_exports.string().describe("Site name or id"),
24222
+ path: external_exports.string().optional().describe('Page to render, like "/about.html" (default "/")'),
24223
+ version: external_exports.number().int().optional().describe("Render a prior version instead of the live one \u2014 see it before you revert_site to it"),
24224
+ viewport: external_exports.enum(["desktop", "mobile"]).optional().describe('"desktop" 1280\xD7800 (default) or "mobile" 390\xD7844 for responsive checks'),
24225
+ wait_ms: external_exports.number().int().optional().describe("Extra milliseconds after page load before capturing (max 5000) \u2014 for animations/transitions"),
24226
+ full_page: external_exports.boolean().optional().describe("Capture the full page height, not just the viewport \u2014 costlier to read, use when below-the-fold matters")
24227
+ }
24228
+ },
24229
+ async ({
24230
+ site,
24231
+ path: path8,
24232
+ version: version2,
24233
+ viewport,
24234
+ wait_ms,
24235
+ full_page
24236
+ }) => {
24237
+ try {
24238
+ const { image_base64, content_type, ...meta } = await ctx.api.request("POST", `${sitePath(site)}/preview`, {
24239
+ body: {
24240
+ ...path8 !== void 0 ? { path: path8 } : {},
24241
+ ...version2 !== void 0 ? { version: version2 } : {},
24242
+ ...viewport !== void 0 ? { viewport } : {},
24243
+ ...wait_ms !== void 0 ? { wait_ms } : {},
24244
+ ...full_page !== void 0 ? { full_page } : {}
24245
+ }
24246
+ });
24247
+ return {
24248
+ content: [
24249
+ { type: "image", data: image_base64, mimeType: content_type },
24250
+ { type: "text", text: JSON.stringify({ ok: true, ...meta }) }
24251
+ ]
24252
+ };
24253
+ } catch (error2) {
24254
+ return toolError(error2);
24255
+ }
24256
+ }
24257
+ );
24258
+ server.registerTool(
24259
+ "revert_site",
24260
+ {
24261
+ title: "Revert site to a version",
24262
+ description: `Return a site to a prior version, byte-exactly, in one call \u2014 the safe way to undo. When the user says "undo that" or "put it back how it was", use this; never reconstruct the old state by hand with inverse edits, which risks leaving the site neither old nor new. The platform redeploys the target version's exact stored files as a new version \u2014 history is preserved, so a revert can itself be reverted \u2014 and the response proves the restore: byte_identical: true means the new version's content hash equals the target's. Find the target with get_site {history: true} (each entry shows what that version changed), or use the version number a deploy/edit response reported. Undoing just one file of a multi-file change? Read that file at the old version (get_site {version, paths}) and deploy it with mode "patch" instead.`,
24263
+ // Destructive: the live site's current content is replaced by the older
24264
+ // version's. Recoverable (the replaced version is itself kept), but the
24265
+ // visible site does change out from under whatever was there.
24266
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
24267
+ inputSchema: {
24268
+ site: external_exports.string().describe("Site name or id"),
24269
+ to_version: external_exports.number().int().min(1).describe("The version to return to (see get_site {history: true})")
24270
+ }
24271
+ },
24272
+ async ({ site, to_version }) => {
24273
+ try {
24274
+ return ok(await ctx.api.request("POST", `${sitePath(site)}/revert`, { body: { to_version } }));
24275
+ } catch (error2) {
24276
+ return toolError(error2);
24277
+ }
24278
+ }
24279
+ );
24280
+ server.registerTool(
24281
+ "create_db",
24282
+ {
24283
+ title: "Create site database",
24284
+ description: "Provision the per-site database. Idempotent. Deploying a drivethrough.json that declares types does this automatically.",
24285
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
24286
+ inputSchema: { site: external_exports.string().describe("Site name or id") }
24287
+ },
24288
+ async ({ site }) => {
24289
+ try {
24290
+ return ok(await ctx.api.request("POST", `${sitePath(site)}/db`));
24291
+ } catch (error2) {
24292
+ return toolError(error2);
24293
+ }
24294
+ }
24295
+ );
24296
+ server.registerTool(
24297
+ "query_db",
24298
+ {
24299
+ title: "Query site database",
24300
+ description: "Read the site's database as its owner (read-only \u2014 writes go through write_db): list the types present with row counts, list rows of a type, or fetch one row by id. Every row is {id, parent_id, type, json, created_at} \u2014 json is the document. Owner access skips visitor-side permission checks.",
24301
+ annotations: { readOnlyHint: true, openWorldHint: false },
24302
+ inputSchema: {
24303
+ site: external_exports.string().describe("Site name or id"),
24304
+ action: external_exports.enum(["types", "list", "get"]).describe("types = row counts per type; list = rows of a type; get = one row by id"),
24305
+ type: external_exports.string().optional().describe("Row type \u2014 required for list"),
24306
+ id: external_exports.string().optional().describe("Row id \u2014 required for get"),
24307
+ parent_id: external_exports.string().optional().describe("list: return only rows whose parent is this row id"),
24308
+ limit: external_exports.number().int().optional().describe("list: max rows (default 100, cap 500)"),
24309
+ offset: external_exports.number().int().optional().describe("list: rows to skip, for pagination")
24310
+ }
24311
+ },
24312
+ async ({
24313
+ site,
24314
+ action,
24315
+ type,
24316
+ id,
24317
+ parent_id,
24318
+ limit,
24319
+ offset
24320
+ }) => {
24321
+ try {
24322
+ switch (action) {
24323
+ case "types":
24324
+ return ok(await ctx.api.request("GET", `${sitePath(site)}/data/types`));
24325
+ case "list": {
24326
+ if (parent_id !== void 0 && parent_id === "") {
24327
+ throw new ToolApiError(422, {
24328
+ error: {
24329
+ code: "parent_filter_unsupported",
24330
+ message: "Filtering to rows WITHOUT a parent isn't supported \u2014 `parent_id` must be a row id.",
24331
+ next: "List the type without parent_id and select the parent_id === null rows from the result."
24332
+ }
24333
+ });
24334
+ }
24335
+ const params = new URLSearchParams();
24336
+ if (type) params.set("type", type);
24337
+ if (parent_id !== void 0) params.set("parent", parent_id);
24338
+ if (limit !== void 0) params.set("limit", String(limit));
24339
+ if (offset !== void 0) params.set("offset", String(offset));
24340
+ return ok(await ctx.api.request("GET", `${sitePath(site)}/data?${params}`));
24341
+ }
24342
+ case "get": {
24343
+ if (!id) {
24344
+ throw new ToolApiError(422, {
24345
+ error: {
24346
+ code: "missing_id",
24347
+ message: 'query_db action "get" needs `id`.',
24348
+ next: 'Find row ids with action "list".'
24349
+ }
24350
+ });
24351
+ }
24352
+ return ok(await ctx.api.request("GET", `${sitePath(site)}/data?id=${encodeURIComponent(id)}`));
24353
+ }
24354
+ }
24355
+ } catch (error2) {
24356
+ return toolError(error2);
24357
+ }
24358
+ }
24359
+ );
24360
+ server.registerTool(
24361
+ "write_db",
24362
+ {
24363
+ title: "Write site database",
24364
+ description: "Write to the site's database as its owner: insert, update, or delete rows (reads go through query_db). To change a few fields of a row, `update` with `merge` \u2014 only the fields that change, no fetch needed. Reserve `json` (wholesale replacement) for rewriting the whole document. Writes are validated against the types declared in the site's drivethrough.json (unknown types and mismatched fields are rejected); pass validate: false only for a deliberate migration. Deleted and overwritten rows are gone for good \u2014 there is no version history for data, so read a row (query_db) before destructive rewrites you might need to undo. Daily write quotas apply.",
24365
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
24366
+ inputSchema: {
24367
+ site: external_exports.string().describe("Site name or id"),
24368
+ action: external_exports.enum(["insert", "update", "delete"]).describe("What to do with the row"),
24369
+ type: external_exports.string().optional().describe("Row type \u2014 required for insert"),
24370
+ id: external_exports.string().optional().describe("Row id \u2014 required for update and delete"),
24371
+ parent_id: external_exports.string().nullable().optional().describe("Parent row id to set on insert/update (null clears it)"),
24372
+ json: external_exports.record(external_exports.unknown()).optional().describe("The row's full document \u2014 required for insert; a wholesale replacement on update (prefer `merge`)"),
24373
+ merge: external_exports.record(external_exports.unknown()).optional().describe(
24374
+ "update: change only these fields, merged into the row's existing document (RFC 7386: nested objects merge, null deletes a field). The cheap way to update \u2014 no fetch-modify-resend."
24375
+ ),
24376
+ validate: external_exports.boolean().optional().describe("insert/update: set false to bypass declared-type validation \u2014 only for deliberate migrations")
24377
+ }
24378
+ },
24379
+ async ({
24380
+ site,
24381
+ action,
24382
+ type,
24383
+ id,
24384
+ parent_id,
24385
+ json,
24386
+ merge: merge2,
24387
+ validate: validate2
24388
+ }) => {
24389
+ try {
24390
+ const needId = () => {
24391
+ if (!id) {
24392
+ throw new ToolApiError(422, {
24393
+ error: {
24394
+ code: "missing_id",
24395
+ message: `write_db action "${action}" needs \`id\`.`,
24396
+ next: 'Find row ids with query_db action "list".'
24397
+ }
24398
+ });
24399
+ }
24400
+ return id;
24401
+ };
24402
+ switch (action) {
24403
+ case "insert": {
24404
+ if (merge2 !== void 0) {
24405
+ throw new ToolApiError(422, {
24406
+ error: {
24407
+ code: "merge_not_for_insert",
24408
+ message: "`merge` applies to update only \u2014 insert takes the row's full document in `json`.",
24409
+ next: "Pass the document as `json`."
24410
+ }
24411
+ });
24412
+ }
24413
+ if (!type) {
24414
+ throw new ToolApiError(422, {
24415
+ error: {
24416
+ code: "missing_type",
24417
+ message: 'write_db action "insert" needs `type`.',
24418
+ next: 'See the types present with query_db action "types", or declare them in drivethrough.json.'
24419
+ }
24420
+ });
24421
+ }
24422
+ if (json === void 0) {
24423
+ throw new ToolApiError(422, {
24424
+ error: {
24425
+ code: "missing_json",
24426
+ message: 'write_db action "insert" needs `json` \u2014 the row\'s document.',
24427
+ next: "Pass json: {} explicitly if the row is deliberately empty."
24428
+ }
24429
+ });
24430
+ }
24431
+ return ok(
24432
+ await ctx.api.request("POST", `${sitePath(site)}/data`, {
24433
+ body: { type, parent_id, json, ...validate2 !== void 0 ? { validate: validate2 } : {} }
24434
+ })
24435
+ );
24436
+ }
24437
+ case "update":
24438
+ if (json !== void 0 && merge2 !== void 0) {
24439
+ throw new ToolApiError(422, {
24440
+ error: {
24441
+ code: "ambiguous_update",
24442
+ message: "Pass either `json` (full replacement) or `merge` (partial), not both.",
24443
+ next: "For a small change, `merge` with just the fields that change is enough."
24444
+ }
24445
+ });
24446
+ }
24447
+ return ok(
24448
+ await ctx.api.request("PATCH", `${sitePath(site)}/data/${encodeURIComponent(needId())}`, {
24449
+ body: {
24450
+ ...type !== void 0 ? { type } : {},
24451
+ ...parent_id !== void 0 ? { parent_id } : {},
24452
+ ...json !== void 0 ? { json } : {},
24453
+ ...merge2 !== void 0 ? { merge: merge2 } : {},
24454
+ ...validate2 !== void 0 ? { validate: validate2 } : {}
24455
+ }
24456
+ })
24457
+ );
24458
+ case "delete":
24459
+ return ok(await ctx.api.request("DELETE", `${sitePath(site)}/data/${encodeURIComponent(needId())}`));
24460
+ }
24461
+ } catch (error2) {
24462
+ return toolError(error2);
24463
+ }
24464
+ }
24465
+ );
24466
+ server.registerTool(
24467
+ "set_privacy",
24468
+ {
24469
+ title: "Set site visibility",
24470
+ description: "Set a site's visibility to public or private. Privacy is a setting \u2014 nothing redeploys.",
24471
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
24472
+ inputSchema: {
24473
+ site: external_exports.string().describe("Site name or id"),
24474
+ visibility: external_exports.enum(["public", "private"]).describe("Who can see the site")
23413
24475
  }
23414
24476
  },
23415
24477
  async ({ site, visibility }) => {
23416
24478
  try {
23417
- return ok(await ctx.api.request("POST", `/v1/sites/${site}/privacy`, { body: { visibility } }));
24479
+ return ok(await ctx.api.request("POST", `${sitePath(site)}/privacy`, { body: { visibility } }));
23418
24480
  } catch (error2) {
23419
24481
  return toolError(error2);
23420
24482
  }
@@ -23423,7 +24485,9 @@ function registerTools(server, ctx) {
23423
24485
  server.registerTool(
23424
24486
  "share_site",
23425
24487
  {
23426
- description: "Create a share link for a private site, or send a magic-link invitation by email (\xA78). Anyone with the link can view; guests never make accounts.",
24488
+ title: "Share site",
24489
+ description: "Create a share link for a private site, or send a magic-link invitation by email. Anyone with the link can view; guests never make accounts.",
24490
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
23427
24491
  inputSchema: {
23428
24492
  site: external_exports.string().describe("Site name or id"),
23429
24493
  email: external_exports.string().email().optional().describe("Send a magic-link invitation to this email instead of returning a link")
@@ -23431,8 +24495,8 @@ function registerTools(server, ctx) {
23431
24495
  },
23432
24496
  async ({ site, email: email2 }) => {
23433
24497
  try {
23434
- if (email2) return ok(await ctx.api.request("POST", `/v1/sites/${site}/invite`, { body: { email: email2 } }));
23435
- return ok(await ctx.api.request("POST", `/v1/sites/${site}/share`));
24498
+ if (email2) return ok(await ctx.api.request("POST", `${sitePath(site)}/invite`, { body: { email: email2 } }));
24499
+ return ok(await ctx.api.request("POST", `${sitePath(site)}/share`));
23436
24500
  } catch (error2) {
23437
24501
  return toolError(error2);
23438
24502
  }
@@ -23441,7 +24505,9 @@ function registerTools(server, ctx) {
23441
24505
  server.registerTool(
23442
24506
  "list_sites",
23443
24507
  {
24508
+ title: "List sites",
23444
24509
  description: "List the authenticated account's sites with their addresses, visibility, and status. Pass include_deleted to also see recently deleted sites (recoverable for 30 days via restore_site).",
24510
+ annotations: { readOnlyHint: true, openWorldHint: false },
23445
24511
  inputSchema: {
23446
24512
  include_deleted: external_exports.boolean().optional().describe("Also list deleted sites still inside their 30-day recovery window")
23447
24513
  }
@@ -23457,12 +24523,74 @@ function registerTools(server, ctx) {
23457
24523
  server.registerTool(
23458
24524
  "delete_site",
23459
24525
  {
24526
+ title: "Delete site",
23460
24527
  description: "Delete a site. Two-phase and safe: the site leaves the driveway immediately (guests lose access, share links are revoked, the name frees up) but stays recoverable for 30 days via restore_site. Only after 30 days is it destroyed for good.",
24528
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
23461
24529
  inputSchema: { site: external_exports.string().describe("Site name or id") }
23462
24530
  },
23463
24531
  async ({ site }) => {
23464
24532
  try {
23465
- return ok(await ctx.api.request("DELETE", `/v1/sites/${site}`));
24533
+ return ok(await ctx.api.request("DELETE", sitePath(site)));
24534
+ } catch (error2) {
24535
+ return toolError(error2);
24536
+ }
24537
+ }
24538
+ );
24539
+ server.registerTool(
24540
+ "check_domain",
24541
+ {
24542
+ title: "Check custom domain",
24543
+ description: `Check a site's custom domain, read-only. "status": live verification progress \u2014 pending_dns (waiting on DNS records) \u2192 pending_ssl (certificate issuing) \u2192 active (serving with TLS); poll after setup and tell the user when it's live. "connect": look up one-click setup at the user's registrar (GoDaddy, Cloudflare, IONOS, Squarespace, \u2026) \u2014 when a URL comes back, relay it and their registrar creates the records in one click; otherwise relay the copy-paste records manage_domain attach returned.`,
24544
+ // Not readOnlyHint despite being the "read" half of the domain pair:
24545
+ // "status" re-polls the certificate authority and caches the transition
24546
+ // it observes, and "connect" queries the user's registrar. Both reach
24547
+ // outside and one writes, so neither claim would be true.
24548
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
24549
+ inputSchema: {
24550
+ site: external_exports.string().describe("Site name or id"),
24551
+ action: external_exports.enum(["status", "connect"]).describe('"status" = verification progress; "connect" = one-click registrar setup lookup')
24552
+ }
24553
+ },
24554
+ async ({ site, action }) => {
24555
+ try {
24556
+ if (action === "connect") return ok(await ctx.api.request("GET", `${sitePath(site)}/domain/connect`));
24557
+ return ok(await ctx.api.request("GET", `${sitePath(site)}/domain`));
24558
+ } catch (error2) {
24559
+ return toolError(error2);
24560
+ }
24561
+ }
24562
+ );
24563
+ server.registerTool(
24564
+ "manage_domain",
24565
+ {
24566
+ title: "Attach/detach custom domain",
24567
+ description: 'Attach or detach a custom domain the user already owns (paid plans). "attach": connect the domain to the site \u2014 returns the exact DNS records to add; afterwards use check_domain {action: "connect"} for a one-click registrar link and check_domain {action: "status"} to poll until active. "detach": remove the domain from the site. The site keeps its drivethrough.site address throughout.',
24568
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
24569
+ inputSchema: {
24570
+ action: external_exports.enum(["attach", "detach"]).describe("Connect the domain, or remove it"),
24571
+ site: external_exports.string().describe("Site name or id"),
24572
+ domain: external_exports.string().optional().describe('The domain, e.g. "timsfossils.com" \u2014 required for attach')
24573
+ }
24574
+ },
24575
+ async ({
24576
+ action,
24577
+ site,
24578
+ domain
24579
+ }) => {
24580
+ try {
24581
+ if (action === "attach") {
24582
+ if (!domain) {
24583
+ throw new ToolApiError(422, {
24584
+ error: {
24585
+ code: "missing_domain",
24586
+ message: 'manage_domain action "attach" needs `domain`.',
24587
+ next: 'Pass a bare domain like "timsfossils.com".'
24588
+ }
24589
+ });
24590
+ }
24591
+ return ok(await ctx.api.request("POST", `${sitePath(site)}/domain`, { body: { domain } }));
24592
+ }
24593
+ return ok(await ctx.api.request("DELETE", `${sitePath(site)}/domain`));
23466
24594
  } catch (error2) {
23467
24595
  return toolError(error2);
23468
24596
  }
@@ -23471,7 +24599,9 @@ function registerTools(server, ctx) {
23471
24599
  server.registerTool(
23472
24600
  "restore_site",
23473
24601
  {
23474
- description: "Restore a deleted site within its 30-day recovery window. It comes back exactly as it was \u2014 content, data, visibility \u2014 minus guest access (share links stay revoked; create new ones). If the old name is taken, pass `sitename` to restore under a new name.",
24602
+ title: "Restore deleted site",
24603
+ description: "Restore a deleted site within its 30-day recovery window. It comes back exactly as it was \u2014 content, data, visibility \u2014 minus guest access (share links stay revoked; create new ones). If the old name is taken, pass `sitename` to restore under a new name. (Undoing changes to a live site is different \u2014 that's revert_site.)",
24604
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
23475
24605
  inputSchema: {
23476
24606
  site: external_exports.string().describe("The deleted site's original name, or its site id"),
23477
24607
  sitename: external_exports.string().optional().describe("Restore under this new name instead (when the old name is taken)")
@@ -23479,8 +24609,17 @@ function registerTools(server, ctx) {
23479
24609
  },
23480
24610
  async ({ site, sitename }) => {
23481
24611
  try {
24612
+ if (sitename !== void 0 && sitename.length === 0) {
24613
+ throw new ToolApiError(422, {
24614
+ error: {
24615
+ code: "invalid_sitename",
24616
+ message: "`sitename` is empty \u2014 pass a real name, or omit it to restore under the original name.",
24617
+ next: "Omit sitename unless the old name is taken."
24618
+ }
24619
+ });
24620
+ }
23482
24621
  return ok(
23483
- await ctx.api.request("POST", `/v1/sites/${site}/restore`, {
24622
+ await ctx.api.request("POST", `${sitePath(site)}/restore`, {
23484
24623
  body: sitename ? { sitename } : {}
23485
24624
  })
23486
24625
  );
@@ -23490,14 +24629,31 @@ function registerTools(server, ctx) {
23490
24629
  }
23491
24630
  );
23492
24631
  }
23493
- var MAX_BUNDLE_MB;
24632
+ var MAX_BUNDLE_MB, SERVER_INSTRUCTIONS;
23494
24633
  var init_tools = __esm({
23495
24634
  "../mcp-tools/src/tools.ts"() {
23496
24635
  "use strict";
23497
24636
  init_zod();
23498
24637
  init_api3();
24638
+ init_edits();
23499
24639
  init_inline_files();
24640
+ init_remote_assets();
23500
24641
  MAX_BUNDLE_MB = 50;
24642
+ SERVER_INSTRUCTIONS = `Drivethrough hosts websites: deploy, share, collect data. Sites are private by default; guests never need accounts.
24643
+
24644
+ Work token-efficiently \u2014 generated output is your scarcest resource:
24645
+ - LOCATE, don't read: to find where text lives on a deployed site, call get_site with \`find\` \u2014 it returns byte-exact matching lines. Use \`manifest_only\` to see what files exist. Read full contents (\`paths\`) only when you genuinely need them; a bare get_site inlines every text file.
24646
+ - EDIT, don't resend: change existing files with edit_site (exact find/replace). Never re-emit a whole file to change a small part. Use deploy_site mode "patch" for new files or wholesale rewrites \u2014 unchanged files carry forward automatically.
24647
+ - REFERENCE, don't inline: never generate base64 by hand. To ship an image or other binary asset, pick the route by where the bytes live: already at a public URL \u2192 deploy_site {path, source_url}, fetched server-side; a local file on the user's machine \u2192 create_upload mints a signed URL the user uploads it to, then deploy_site {path, upload_id}. (Agents with a local filesystem deploy the directory instead \u2014 binaries ride along.) To move or duplicate an ALREADY-DEPLOYED file, {path, from_path} copies it server-side \u2014 never re-upload unchanged bytes. Inline base64 content is capped at 1 MB and exists only for tiny pasted assets like a favicon.
24648
+ - MERGE, don't replace: to change a few fields of a database row, write_db update with \`merge\` (null deletes a field). No need to fetch the row first.
24649
+ - UNDO by version, never by inverse edits: every deploy is a numbered version the platform keeps. When the user wants a change undone, call revert_site \u2014 it restores the exact bytes of any prior version in one call and proves it. Reconstructing old content from memory with edit_site is how sites end up in a state that is neither old nor new.
24650
+ - LOOK at what shipped: you have no browser, but preview_site returns a real rendered screenshot of any version \u2014 your only way to see visual results. Use it after layout/style/animation work, or to eyeball an old version before reverting.
24651
+ - DON'T overwrite unseen work: when you already know the deployed version (an earlier get_site or deploy in THIS conversation), pass it to deploy_site as \`base_version\` \u2014 if the site moved past it (another session deployed), the deploy is refused with a summary of what changed instead of clobbering it. Never make an extra call just to learn the version; omit it instead. edit_site guards itself. On a \`remote_changes\` refusal, read what changed (get_site {history: true}), fold it in, then redeploy with the current version.
24652
+ - Trust the errors: every failure returns {code, message, next} \u2014 and sometimes \`found\` with the exact deployed text to retry with. Follow \`next\` instead of guessing.
24653
+
24654
+ Typical small-change flow, two calls total: get_site {find: "old phrase"} \u2192 edit_site {edits: [{path, find, replace}]}. If you already know the exact deployed text, edit_site alone is enough \u2014 a miss fails safely with recovery instructions. For visual changes, add preview_site to see the result; to undo anything, get_site {history: true} \u2192 revert_site.
24655
+
24656
+ Custom domains (paid plans): manage_domain attaches a domain the user already owns. After attaching, check_domain {action: "connect"} looks up one-click setup at their registrar (relay the URL when offered, else the exact DNS records from attach), and check_domain {action: "status"} polls until "active" \u2014 DNS seen, then TLS issued.`;
23501
24657
  }
23502
24658
  });
23503
24659
 
@@ -23506,7 +24662,9 @@ var init_src2 = __esm({
23506
24662
  "../mcp-tools/src/index.ts"() {
23507
24663
  "use strict";
23508
24664
  init_api3();
24665
+ init_edits();
23509
24666
  init_inline_files();
24667
+ init_remote_assets();
23510
24668
  init_tools();
23511
24669
  }
23512
24670
  });
@@ -23518,9 +24676,9 @@ __export(mcp_exports, {
23518
24676
  runMcpServer: () => runMcpServer
23519
24677
  });
23520
24678
  function buildMcpServer() {
23521
- const server = new McpServer({ name: "drivethrough", version: "0.1.0" });
24679
+ const server = new McpServer({ name: "drivethrough", version: "0.1.0" }, { instructions: SERVER_INSTRUCTIONS });
23522
24680
  registerTools(server, {
23523
- api: { request: (method, path5, options) => api(method, path5, options) },
24681
+ api: { request: (method, path8, options) => api(method, path8, options) },
23524
24682
  localDirectories
23525
24683
  });
23526
24684
  return server;
@@ -25104,7 +26262,7 @@ async function runMain(cmd, opts = {}) {
25104
26262
  }
25105
26263
 
25106
26264
  // src/commands/deploy.ts
25107
- import path3 from "node:path";
26265
+ import path4 from "node:path";
25108
26266
 
25109
26267
  // ../../node_modules/.pnpm/@clack+prompts@0.10.1/node_modules/@clack/prompts/dist/index.mjs
25110
26268
  import { stripVTControlCharacters as S4 } from "node:util";
@@ -25636,6 +26794,10 @@ var deployCommand = defineCommand({
25636
26794
  dir: { type: "positional", description: "Project directory (uses ./dist inside it when present)", required: false },
25637
26795
  site: { type: "string", description: 'Site name (defaults to drivethrough.json "site", then the folder name)' },
25638
26796
  title: { type: "string", description: "Site title shown to guests" },
26797
+ force: {
26798
+ type: "boolean",
26799
+ description: "Deploy even if the site changed remotely since the last sync (skips the base_version guard)"
26800
+ },
25639
26801
  json: { type: "boolean", description: "Machine-readable output" }
25640
26802
  },
25641
26803
  async run({ args }) {
@@ -25649,7 +26811,7 @@ var deployCommand = defineCommand({
25649
26811
  throw new ApiError2(422, { error: assembled.problem });
25650
26812
  }
25651
26813
  const bundle = assembled.bundle;
25652
- const sitename = args.site ?? bundle.config?.site ?? path3.basename(path3.resolve(dir)).toLowerCase().replace(/[^a-z0-9-]+/g, "-");
26814
+ const sitename = args.site ?? bundle.config?.site ?? path4.basename(path4.resolve(dir)).toLowerCase().replace(/[^a-z0-9-]+/g, "-");
25653
26815
  const title = args.title ?? bundle.config?.title;
25654
26816
  if (!isJsonMode(jsonArgs)) Ie("drivethrough deploy");
25655
26817
  const siteRes = await api("POST", "/v1/sites", {
@@ -25661,8 +26823,22 @@ var deployCommand = defineCommand({
25661
26823
  );
25662
26824
  M2.step(`Uploading ${bundle.files.length} files (${(bundle.totalBytes / 1024).toFixed(0)} kB)\u2026`);
25663
26825
  }
26826
+ const { readState: readState2, writeState: writeState2 } = await Promise.resolve().then(() => (init_state(), state_exports));
26827
+ const root = path4.resolve(dir);
26828
+ const state = readState2(root);
26829
+ const form = bundleForm2(bundle);
26830
+ if (!args.force && state && state.site_id === siteRes.site.id) {
26831
+ form.set("base_version", String(state.version));
26832
+ }
25664
26833
  const deploy = await api("POST", `/v1/sites/${siteRes.site.id}/deployments`, {
25665
- form: bundleForm2(bundle)
26834
+ form
26835
+ });
26836
+ writeState2(root, {
26837
+ site_id: deploy.site_id,
26838
+ sitename,
26839
+ version: deploy.version,
26840
+ bundle_hash: deploy.bundle_hash,
26841
+ synced_at: (/* @__PURE__ */ new Date()).toISOString()
25666
26842
  });
25667
26843
  emit(jsonArgs, { site: siteRes.site, deploy, created: siteRes.created }, () => {
25668
26844
  M2.success(`Version ${deploy.version} is live.`);
@@ -26119,13 +27295,135 @@ var dbCommand = defineCommand({
26119
27295
  subCommands: { create: createCommand }
26120
27296
  });
26121
27297
 
27298
+ // src/commands/data.ts
27299
+ init_api();
27300
+ var typesCommand = defineCommand({
27301
+ meta: { name: "types", description: "List the row types in the site's database, with counts" },
27302
+ args: {
27303
+ site: { type: "positional", description: "Site name", required: true },
27304
+ json: { type: "boolean", description: "Machine-readable output" }
27305
+ },
27306
+ async run({ args }) {
27307
+ const jsonArgs = { json: args.json };
27308
+ try {
27309
+ const res = await api(
27310
+ "GET",
27311
+ `/v1/sites/${args.site}/data/types`
27312
+ );
27313
+ emit(jsonArgs, { ...res }, () => {
27314
+ if (res.types.length === 0) {
27315
+ M2.message("The database is empty.");
27316
+ return;
27317
+ }
27318
+ for (const t2 of res.types) M2.message(` ${t2.type}: ${t2.count} row(s)`);
27319
+ });
27320
+ } catch (error2) {
27321
+ fail(jsonArgs, error2);
27322
+ }
27323
+ }
27324
+ });
27325
+ var listCommand = defineCommand({
27326
+ meta: { name: "list", description: "List rows of a type" },
27327
+ args: {
27328
+ site: { type: "positional", description: "Site name", required: true },
27329
+ type: { type: "positional", description: "Row type", required: true },
27330
+ parent: { type: "string", description: "Only rows under this parent id" },
27331
+ limit: { type: "string", description: "Max rows (default 100, cap 500)" },
27332
+ offset: { type: "string", description: "Rows to skip (pagination)" },
27333
+ json: { type: "boolean", description: "Machine-readable output" }
27334
+ },
27335
+ async run({ args }) {
27336
+ const jsonArgs = { json: args.json };
27337
+ try {
27338
+ const params = new URLSearchParams({ type: args.type });
27339
+ if (args.parent) params.set("parent", args.parent);
27340
+ if (args.limit) params.set("limit", args.limit);
27341
+ if (args.offset) params.set("offset", args.offset);
27342
+ const res = await api("GET", `/v1/sites/${args.site}/data?${params}`);
27343
+ emit(jsonArgs, { ...res }, () => {
27344
+ if (res.items.length === 0) {
27345
+ M2.message(`No "${args.type}" rows.`);
27346
+ return;
27347
+ }
27348
+ for (const row of res.items) {
27349
+ M2.message(` ${row.id} ${JSON.stringify(row.json)}`);
27350
+ }
27351
+ });
27352
+ } catch (error2) {
27353
+ fail(jsonArgs, error2);
27354
+ }
27355
+ }
27356
+ });
27357
+ var getCommand = defineCommand({
27358
+ meta: { name: "get", description: "Fetch one row by id" },
27359
+ args: {
27360
+ site: { type: "positional", description: "Site name", required: true },
27361
+ id: { type: "positional", description: "Row id", required: true },
27362
+ json: { type: "boolean", description: "Machine-readable output" }
27363
+ },
27364
+ async run({ args }) {
27365
+ const jsonArgs = { json: args.json };
27366
+ try {
27367
+ const res = await api(
27368
+ "GET",
27369
+ `/v1/sites/${args.site}/data?id=${encodeURIComponent(args.id)}`
27370
+ );
27371
+ emit(jsonArgs, { item: res.items[0] }, () => {
27372
+ M2.message(JSON.stringify(res.items[0], null, 2));
27373
+ });
27374
+ } catch (error2) {
27375
+ fail(jsonArgs, error2);
27376
+ }
27377
+ }
27378
+ });
27379
+ var dataCommand = defineCommand({
27380
+ meta: { name: "data", description: "Inspect the site's database" },
27381
+ subCommands: { types: typesCommand, list: listCommand, get: getCommand }
27382
+ });
27383
+
27384
+ // src/commands/files.ts
27385
+ init_api();
27386
+ var filesCommand = defineCommand({
27387
+ meta: { name: "files", description: "Read back a deployed site's files" },
27388
+ args: {
27389
+ site: { type: "positional", description: "Site name", required: true },
27390
+ path: { type: "positional", description: 'A single file to print (e.g. "/index.html")', required: false },
27391
+ json: { type: "boolean", description: "Machine-readable output" }
27392
+ },
27393
+ async run({ args }) {
27394
+ const jsonArgs = { json: args.json };
27395
+ try {
27396
+ const query = args.path ? `?path=${encodeURIComponent(args.path)}` : "";
27397
+ const res = await api("GET", `/v1/sites/${args.site}/files${query}`);
27398
+ if (args.path && !isJsonMode(jsonArgs)) {
27399
+ const file = res.files[0];
27400
+ if (file?.content !== void 0) {
27401
+ const out = file.encoding === "base64" ? Buffer.from(file.content, "base64") : Buffer.from(file.content, "utf8");
27402
+ process.stdout.write(out);
27403
+ } else {
27404
+ M2.warn(`${file?.path ?? args.path}: content not inlined (${file?.content_omitted ?? "unknown"}).`);
27405
+ }
27406
+ return;
27407
+ }
27408
+ emit(jsonArgs, { ...res }, () => {
27409
+ M2.message(`${res.sitename} \u2014 version ${res.version}, ${(res.total_bytes / 1024).toFixed(1)} KB`);
27410
+ for (const file of res.files) {
27411
+ M2.message(` ${file.path} (${file.content_type}, ${file.size} bytes)`);
27412
+ }
27413
+ if (res.note) M2.info(res.note);
27414
+ });
27415
+ } catch (error2) {
27416
+ fail(jsonArgs, error2);
27417
+ }
27418
+ }
27419
+ });
27420
+
26122
27421
  // src/commands/upgrade.ts
26123
27422
  init_api();
26124
27423
  var upgradeCommand = defineCommand({
26125
27424
  meta: { name: "upgrade", description: "Get a checkout link to upgrade your plan" },
26126
27425
  args: {
26127
27426
  plan: { type: "string", description: '"personal" or "pro"', default: "personal" },
26128
- domain: { type: "string", description: "Custom domain to bundle into a Personal checkout" },
26129
27427
  interval: { type: "string", description: '"monthly" or "yearly" (Personal headline is yearly)' },
26130
27428
  json: { type: "boolean", description: "Machine-readable output" }
26131
27429
  },
@@ -26136,14 +27434,12 @@ var upgradeCommand = defineCommand({
26136
27434
  const res = await api("POST", "/v1/billing/checkout", {
26137
27435
  body: {
26138
27436
  plan,
26139
- ...args.domain ? { domain: args.domain } : {},
26140
27437
  ...args.interval ? { interval: args.interval } : {}
26141
27438
  }
26142
27439
  });
26143
27440
  emit(jsonArgs, { checkout_url: res.url, plan }, () => {
26144
27441
  M2.success(`Here's your secure checkout link (${plan} plan):`);
26145
27442
  Me(res.url, "open to finish");
26146
- if (args.domain) M2.info(`Your domain ${args.domain} is bundled into this one approval.`);
26147
27443
  });
26148
27444
  } catch (error2) {
26149
27445
  fail(jsonArgs, error2);
@@ -26153,8 +27449,17 @@ var upgradeCommand = defineCommand({
26153
27449
 
26154
27450
  // src/commands/domain.ts
26155
27451
  init_api();
27452
+ function recordLines(records) {
27453
+ return records.map((r3) => `${r3.type} ${r3.name} \u2192 ${r3.value}${r3.note ? `
27454
+ (${r3.note})` : ""}`).join("\n");
27455
+ }
27456
+ var STATUS_HUMAN = {
27457
+ pending_dns: "waiting for DNS to point at us",
27458
+ pending_ssl: "DNS looks right \u2014 issuing the TLS certificate",
27459
+ active: "live with TLS"
27460
+ };
26156
27461
  var addCommand = defineCommand({
26157
- meta: { name: "add", description: "Attach a custom domain to a site" },
27462
+ meta: { name: "add", description: "Attach a domain you already own to a site" },
26158
27463
  args: {
26159
27464
  site: { type: "positional", description: "Site name", required: true },
26160
27465
  domain: { type: "positional", description: "Domain, e.g. timsfossils.com", required: true },
@@ -26169,11 +27474,11 @@ var addCommand = defineCommand({
26169
27474
  { body: { domain: args.domain } }
26170
27475
  );
26171
27476
  emit(jsonArgs, { ...res }, () => {
26172
- M2.success(`${res.domain} is attached to ${args.site}.`);
26173
- if (res.dns) {
26174
- Me(
26175
- Object.entries(res.dns).map(([k4, v3]) => `${k4}: ${v3}`).join("\n"),
26176
- "point your DNS here"
27477
+ M2.success(`${res.domain} is attached to ${args.site} (${STATUS_HUMAN[res.status] ?? res.status}).`);
27478
+ if (res.status !== "active") {
27479
+ Me(recordLines(res.records), "add these DNS records at your registrar");
27480
+ M2.info(
27481
+ `Try one-click setup first: \`drivethrough domain connect ${args.site}\` \u2014 then \`drivethrough domain status ${args.site}\` until it's live.`
26177
27482
  );
26178
27483
  }
26179
27484
  });
@@ -26182,9 +27487,80 @@ var addCommand = defineCommand({
26182
27487
  }
26183
27488
  }
26184
27489
  });
27490
+ var statusCommand = defineCommand({
27491
+ meta: { name: "status", description: "Check a site's domain: DNS seen, certificate issued, live" },
27492
+ args: {
27493
+ site: { type: "positional", description: "Site name", required: true },
27494
+ json: { type: "boolean", description: "Machine-readable output" }
27495
+ },
27496
+ async run({ args }) {
27497
+ const jsonArgs = { json: args.json };
27498
+ try {
27499
+ const res = await api(
27500
+ "GET",
27501
+ `/v1/sites/${args.site}/domain`
27502
+ );
27503
+ emit(jsonArgs, { ...res }, () => {
27504
+ M2.info(`${res.domain}: ${STATUS_HUMAN[res.status] ?? res.status}`);
27505
+ if (res.status === "pending_dns") {
27506
+ Me(recordLines(res.records), "still waiting on these records");
27507
+ }
27508
+ });
27509
+ } catch (error2) {
27510
+ fail(jsonArgs, error2);
27511
+ }
27512
+ }
27513
+ });
27514
+ var connectCommand = defineCommand({
27515
+ meta: { name: "connect", description: "One-click DNS setup at supported registrars (GoDaddy, Cloudflare, \u2026)" },
27516
+ args: {
27517
+ site: { type: "positional", description: "Site name", required: true },
27518
+ json: { type: "boolean", description: "Machine-readable output" }
27519
+ },
27520
+ async run({ args }) {
27521
+ const jsonArgs = { json: args.json };
27522
+ try {
27523
+ const res = await api("GET", `/v1/sites/${args.site}/domain/connect`);
27524
+ emit(jsonArgs, { ...res }, () => {
27525
+ if (res.supported && res.url) {
27526
+ M2.success(`${res.provider} can set up ${res.domain} in one click:`);
27527
+ Me(res.url, "open to approve the DNS changes");
27528
+ } else {
27529
+ M2.info(`${res.domain}'s registrar doesn't offer one-click setup \u2014 add these records by hand:`);
27530
+ Me(recordLines(res.records), "DNS records");
27531
+ }
27532
+ });
27533
+ } catch (error2) {
27534
+ fail(jsonArgs, error2);
27535
+ }
27536
+ }
27537
+ });
27538
+ var removeCommand = defineCommand({
27539
+ meta: { name: "remove", description: "Detach the custom domain from a site" },
27540
+ args: {
27541
+ site: { type: "positional", description: "Site name", required: true },
27542
+ json: { type: "boolean", description: "Machine-readable output" }
27543
+ },
27544
+ async run({ args }) {
27545
+ const jsonArgs = { json: args.json };
27546
+ try {
27547
+ const res = await api("DELETE", `/v1/sites/${args.site}/domain`);
27548
+ emit(jsonArgs, { ...res }, () => {
27549
+ M2.success(`${res.detached} is detached. The site still serves at its drivethrough.site address.`);
27550
+ });
27551
+ } catch (error2) {
27552
+ fail(jsonArgs, error2);
27553
+ }
27554
+ }
27555
+ });
26185
27556
  var domainCommand = defineCommand({
26186
- meta: { name: "domain", description: "Custom domains" },
26187
- subCommands: { add: addCommand }
27557
+ meta: { name: "domain", description: "Custom domains: connect one you own" },
27558
+ subCommands: {
27559
+ add: addCommand,
27560
+ status: statusCommand,
27561
+ connect: connectCommand,
27562
+ remove: removeCommand
27563
+ }
26188
27564
  });
26189
27565
 
26190
27566
  // src/commands/collaborators.ts
@@ -26280,8 +27656,8 @@ var mcpCommand = defineCommand({
26280
27656
  });
26281
27657
 
26282
27658
  // src/commands/agent-setup.ts
26283
- import { writeFileSync as writeFileSync2 } from "node:fs";
26284
- import path4 from "node:path";
27659
+ import { writeFileSync as writeFileSync3 } from "node:fs";
27660
+ import path5 from "node:path";
26285
27661
 
26286
27662
  // src/agent-docs.ts
26287
27663
  var SKILL_MD = `---
@@ -26337,6 +27713,10 @@ Declare data types the site collects. The platform owns the write path.
26337
27713
  }
26338
27714
  \`\`\`
26339
27715
  - \`write\`: "public" | "viewers" | "owner". Public writes require Turnstile.
27716
+ - \`read\`: "public" | "viewers" | "owner". Defaults follow \`write\` \u2014 owner-authored
27717
+ types read "public", \`write: "viewers"\` reads "viewers", and \`write: "public"\`
27718
+ reads "owner" (form submissions belong to whoever typed them). Set
27719
+ \`"read": "public"\` explicitly to publish submissions, e.g. a public guestbook.
26340
27720
  - \`unique_per: "session"\`: the row edits itself (an RSVP updates, never duplicates).
26341
27721
  - \`notify: "owner-email"\`: the owner gets an email on each entry (leads/RSVPs).
26342
27722
  - \`indexes\`: [{ "type": "event", "path": "$.date" }] for larger datasets.
@@ -26351,6 +27731,81 @@ await data.ofType("rsvp").create({ name, attending: true });
26351
27731
  const rows = await data.ofType("rsvp").list();
26352
27732
  \`\`\`
26353
27733
 
27734
+ ## Resuming work on a deployed site
27735
+ Every deployed site can be read back \u2014 files and database \u2014 so work can
27736
+ continue in a later session:
27737
+ - \`drivethrough files <site> --json\` \u2014 the deployed version's manifest and
27738
+ file contents (or one file: \`drivethrough files <site> /index.html\`).
27739
+ - \`drivethrough data types <site>\` / \`data list <site> <type>\` /
27740
+ \`data get <site> <id>\` \u2014 inspect the site's database rows.
27741
+ - MCP: \`get_site\` and \`query_db\` do the same. Reads and writes are separate
27742
+ tools: \`query_db\` lists types, lists rows, and fetches one row; \`write_db\`
27743
+ inserts, updates, and deletes. Writes are validated against the site's
27744
+ declared types \u2014 pass \`validate: false\` only for deliberate migrations \u2014
27745
+ and update with \`merge\` to change a few fields without resending the
27746
+ document.
27747
+ - Locate before you read: \`get_site\` with \`find\` searches the deployed text
27748
+ files and returns byte-exact matching lines; \`manifest_only\` lists files
27749
+ without contents. Read whole files only when you truly need them.
27750
+ - Redeploy only what changed: \`deploy_site\` with \`mode: "patch"\` carries
27751
+ every unchanged file forward from the current version (and \`delete_paths\`
27752
+ removes files). Unchanged images never need re-uploading.
27753
+ - Small text changes: the \`edit_site\` MCP tool applies exact find/replace
27754
+ pairs to the deployed files server-side \u2014 never resend a whole file to
27755
+ change a phrase.
27756
+ - Images/binary: one decision, three routes \u2014 pick by where the bytes live.
27757
+ Already at a public URL \u2192 deploy_site \`{path, source_url}\` (fetched
27758
+ server-side). Local file and you have the filesystem \u2192 deploy the directory
27759
+ (binaries ride along). Local file but chat-only (remote MCP) \u2192
27760
+ \`create_upload\` mints a signed URL the user uploads the file to, then
27761
+ deploy_site \`{path, upload_id}\`. To rename or duplicate an already-deployed
27762
+ file, deploy_site \`{path, from_path}\` copies it server-side (with
27763
+ \`delete_paths\`, a move). Never hand-generate base64 \u2014 inline base64 is
27764
+ capped at 1 MB, for tiny pasted assets only.
27765
+ - Versions are restore points: every deploy is kept. MCP \`get_site\` lists
27766
+ history (\`history: true\`), diffs versions (\`compare\`), and reads old ones
27767
+ (\`version\`); \`revert_site\` returns to any version byte-exactly \u2014 the safe
27768
+ "undo", never hand-inverted edits. \`preview_site\` renders a real browser
27769
+ screenshot of any version \u2014 the way to verify visual work actually shipped.
27770
+
27771
+ ## Returning to a repo: status \u2192 sync \u2192 deploy
27772
+
27773
+ A deployed site may have moved while the repo sat still \u2014 the user edits from
27774
+ chat, from other machines, from anywhere. So on returning to a repo-born
27775
+ site, ALWAYS sync down before editing:
27776
+
27777
+ 1. \`drivethrough status <dir> --json\` \u2014 hashes the local tree against the
27778
+ deployed manifests. Reports: \`in_sync\` (just work), \`behind\` (remote
27779
+ versions landed \u2014 listed with what each changed), \`local_changes\`
27780
+ (undeployed local work), or \`fork\` (BOTH moved \u2014 conflicted files listed).
27781
+ No state file needed: a fresh clone is matched to its version by hashing.
27782
+ 2. \`drivethrough pull <dir> --json\` \u2014 brings the tree to the deployed
27783
+ version byte-exactly, fetching only changed hashes, applying remote
27784
+ deletions, and updating drivethrough.json. It NEVER overwrites a locally
27785
+ modified file: conflicts are listed and nothing is touched \u2014 fold the
27786
+ remote change in yourself, then deploy. For built sites (dist/) it
27787
+ touches nothing and instead emits the remote edits as replayable
27788
+ find/replace scripts + line diffs (port them into src/, rebuild) and
27789
+ stages new binary assets under \`.drivethrough/incoming/\`. Report any
27790
+ edit you cannot locate in src/ \u2014 never guess.
27791
+ Pulling into an EMPTY directory materializes a chat-born site + its
27792
+ config \u2014 how a chat-first site graduates to a repo.
27793
+ 3. \`drivethrough diff <dir> --json\` \u2014 proves local(\u2261 build) matches the
27794
+ deployed version by hashing alone, zero bytes uploaded. Exit 0 = parity.
27795
+ 4. Then edit, build, \`drivethrough deploy\`. The CLI records
27796
+ \`.drivethrough/state.json\` (gitignore it) and sends \`base_version\`
27797
+ automatically: a deploy built on a stale version is refused with a 409
27798
+ \`remote_changes\` naming every version since \u2014 sync down (steps 1\u20133) and
27799
+ redeploy. \`--force\` overwrites anyway; every version stays recoverable
27800
+ via revert.
27801
+
27802
+ **Ground truth rule:** the repo wins **after sync-down**. Read-back is for
27803
+ inspection; do NOT overwrite repo \`src/\` with read-back content \u2014 the
27804
+ deployed bundle is built output, and clobbering the repo with it loses
27805
+ source. Sync down (status \u2192 pull \u2192 port), then edit the repo, rebuild,
27806
+ redeploy. Read-back-then-patch-deploy is the workflow for sites that have
27807
+ no repo (built in chat).
27808
+
26354
27809
  ## Sharing (the flagship)
26355
27810
  - \`drivethrough share <site> --json\` \u2192 a share link. Guests open an
26356
27811
  interstitial, then view \u2014 no account. Free links expire in 30 days.
@@ -26364,8 +27819,8 @@ CLI limit errors are machine-readable and include an \`url\` to relay:
26364
27819
  "message": "...", "next": "...", "url": "https://drivethrough.dev/upgrade?reason=public_sites" } }
26365
27820
  \`\`\`
26366
27821
  Relay that upgrade link once, at the moment of intent \u2014 don't nag.
26367
- Custom domain: \`drivethrough domain add <site> example.com\` (bundled into a
26368
- Personal upgrade: \`drivethrough upgrade --plan personal --domain example.com\`).
27822
+ Custom domain (one the user already owns): \`drivethrough domain add <site> example.com\`,
27823
+ then \`drivethrough domain connect <site>\` for one-click setup where supported.
26369
27824
 
26370
27825
  ## Every command supports --json
26371
27826
  Use \`--json\` for machine-readable output and non-interactive operation.
@@ -26374,9 +27829,22 @@ var AGENTS_MD = `# Drivethrough
26374
27829
 
26375
27830
  This project deploys to Drivethrough. Use the \`drivethrough\` CLI (alias \`dt\`).
26376
27831
 
27832
+ - **Returning to this repo? Sync down FIRST**: \`drivethrough status . --json\`
27833
+ (are we behind? forked?), then \`drivethrough pull . --json\` (fast-forwards
27834
+ no-build sites byte-exactly; for built sites emits the remote edits to port
27835
+ into src/), then \`drivethrough diff . --json\` to prove parity. The site may
27836
+ have been edited from chat while the repo sat still.
26377
27837
  - Deploy: \`drivethrough deploy . --json\` (builds from ./dist if present).
27838
+ Sends \`base_version\` from \`.drivethrough/state.json\` (gitignore that dir)
27839
+ so a deploy over unseen remote changes is refused (409 remote_changes) \u2014
27840
+ sync down and redeploy; \`--force\` overwrites (revert can restore).
26378
27841
  - Sites are private by default; share with \`drivethrough share <site>\`.
26379
27842
  - Declare data behavior in \`drivethrough.json\` (types, fields, write policy).
27843
+ - Read back what's live: \`drivethrough files <site> --json\` (deployed bundle)
27844
+ and \`drivethrough data types|list|get\` (database rows). Inspection only \u2014
27845
+ this repo wins AFTER sync-down; never overwrite repo \`src/\` with read-back
27846
+ content (the deployed bundle is built output). Sync, edit here, rebuild,
27847
+ redeploy.
26380
27848
  - Every command supports \`--json\` and non-interactive mode.
26381
27849
  - Full guide: run \`drivethrough agent-setup --skill\` for SKILL.md.
26382
27850
  `;
@@ -26419,12 +27887,12 @@ var agentSetupCommand = defineCommand({
26419
27887
  if (args.skill) return void process.stdout.write(SKILL_MD);
26420
27888
  if (args.agents) return void process.stdout.write(AGENTS_MD);
26421
27889
  if (args.mcp) return void process.stdout.write(mcpConfigSnippet() + "\n");
26422
- const dir = path4.resolve(args.dir ?? ".");
26423
- const skillPath = path4.join(dir, "SKILL.md");
26424
- const agentsPath = path4.join(dir, "AGENTS.md");
27890
+ const dir = path5.resolve(args.dir ?? ".");
27891
+ const skillPath = path5.join(dir, "SKILL.md");
27892
+ const agentsPath = path5.join(dir, "AGENTS.md");
26425
27893
  if (!isJsonMode(jsonArgs)) Ie("drivethrough agent-setup");
26426
- writeFileSync2(skillPath, SKILL_MD);
26427
- writeFileSync2(agentsPath, AGENTS_MD);
27894
+ writeFileSync3(skillPath, SKILL_MD);
27895
+ writeFileSync3(agentsPath, AGENTS_MD);
26428
27896
  emit(
26429
27897
  jsonArgs,
26430
27898
  {
@@ -26445,6 +27913,448 @@ var agentSetupCommand = defineCommand({
26445
27913
  }
26446
27914
  });
26447
27915
 
27916
+ // src/commands/status.ts
27917
+ import { existsSync as existsSync3, readFileSync as readFileSync4, statSync as statSync2 } from "node:fs";
27918
+ import path6 from "node:path";
27919
+ init_api();
27920
+ init_state();
27921
+
27922
+ // src/sync.ts
27923
+ init_src();
27924
+ init_api();
27925
+ init_state();
27926
+ function fetchManifest(siteRef, version2) {
27927
+ const pin = version2 !== void 0 ? `&version=${version2}` : "";
27928
+ return api("GET", `/v1/sites/${encodeURIComponent(siteRef)}/files?manifest_only=1${pin}`);
27929
+ }
27930
+ function treesEqual(local, manifest) {
27931
+ if (local.size !== manifest.files.length) return false;
27932
+ for (const entry of manifest.files) {
27933
+ if (local.get(entry.path)?.sha256 !== entry.sha256) return false;
27934
+ }
27935
+ return true;
27936
+ }
27937
+ var BASE_MATCH_DEPTH = 20;
27938
+ async function resolveBase(dir, siteRef, local, current) {
27939
+ const state = readState(dir);
27940
+ if (state && state.site_id === current.site_id) {
27941
+ if (state.version === current.version) return { version: current.version, manifest: current, source: "state" };
27942
+ try {
27943
+ const manifest = await fetchManifest(siteRef, state.version);
27944
+ return { version: state.version, manifest, source: "state" };
27945
+ } catch {
27946
+ return null;
27947
+ }
27948
+ }
27949
+ if (local.size === 0) return null;
27950
+ if (treesEqual(local, current)) return { version: current.version, manifest: current, source: "hash_match" };
27951
+ for (let v3 = current.version - 1; v3 >= Math.max(1, current.version - BASE_MATCH_DEPTH + 1); v3--) {
27952
+ try {
27953
+ const manifest = await fetchManifest(siteRef, v3);
27954
+ if (treesEqual(local, manifest)) return { version: v3, manifest, source: "hash_match" };
27955
+ } catch {
27956
+ break;
27957
+ }
27958
+ }
27959
+ return null;
27960
+ }
27961
+ function planPull(local, base, current) {
27962
+ const baseByPath = new Map((base?.files ?? []).map((f3) => [f3.path, f3]));
27963
+ const plan = { writes: [], deletes: [], conflicts: [], local_only: [], in_sync: [] };
27964
+ for (const remote of current.files) {
27965
+ const localFile = local.get(remote.path);
27966
+ const baseFile = baseByPath.get(remote.path);
27967
+ if (localFile?.sha256 === remote.sha256) {
27968
+ plan.in_sync.push(remote.path);
27969
+ } else if (!localFile) {
27970
+ if (baseFile && baseFile.sha256 === remote.sha256) plan.local_only.push(remote.path);
27971
+ else plan.writes.push(remote);
27972
+ } else if (baseFile && baseFile.sha256 === remote.sha256) {
27973
+ plan.local_only.push(remote.path);
27974
+ } else if (baseFile && localFile.sha256 === baseFile.sha256) {
27975
+ plan.writes.push(remote);
27976
+ } else {
27977
+ plan.conflicts.push(remote.path);
27978
+ }
27979
+ }
27980
+ for (const [path8, baseFile] of baseByPath) {
27981
+ if (current.files.some((f3) => f3.path === path8)) continue;
27982
+ const localFile = local.get(path8);
27983
+ if (!localFile) continue;
27984
+ if (localFile.sha256 === baseFile.sha256) plan.deletes.push(path8);
27985
+ else plan.conflicts.push(path8);
27986
+ }
27987
+ for (const path8 of local.keys()) {
27988
+ if (!current.files.some((f3) => f3.path === path8) && !baseByPath.has(path8)) {
27989
+ plan.local_only.push(path8);
27990
+ }
27991
+ }
27992
+ return plan;
27993
+ }
27994
+ function canonicalConfig(value) {
27995
+ const result = parseSiteConfig(value);
27996
+ return result.ok ? JSON.stringify(result.config) : null;
27997
+ }
27998
+ function planConfig(localRaw, baseConfig, remoteConfig) {
27999
+ const remote = remoteConfig == null ? null : canonicalConfig(remoteConfig);
28000
+ if (remote === null) return { action: "none" };
28001
+ const local = localRaw === null ? null : (() => {
28002
+ try {
28003
+ return canonicalConfig(JSON.parse(localRaw));
28004
+ } catch {
28005
+ return null;
28006
+ }
28007
+ })();
28008
+ if (local === remote) return { action: "none" };
28009
+ const base = baseConfig == null ? null : canonicalConfig(baseConfig);
28010
+ if (local === null || local === base) {
28011
+ return { action: "write", json: JSON.stringify(remoteConfig, null, 2) + "\n" };
28012
+ }
28013
+ return { action: "conflict" };
28014
+ }
28015
+ function stateFor(manifest, sitename) {
28016
+ return {
28017
+ site_id: manifest.site_id,
28018
+ sitename,
28019
+ version: manifest.version,
28020
+ bundle_hash: manifest.bundle_hash ?? "",
28021
+ synced_at: (/* @__PURE__ */ new Date()).toISOString()
28022
+ };
28023
+ }
28024
+
28025
+ // src/commands/status.ts
28026
+ function projectDirs(dir) {
28027
+ const root = path6.resolve(dir);
28028
+ const dist = path6.join(root, "dist");
28029
+ const built = existsSync3(dist) && statSync2(dist).isDirectory();
28030
+ return { root, assetDir: built ? dist : root, built };
28031
+ }
28032
+ function localConfigRaw(root) {
28033
+ const configPath2 = path6.join(root, "drivethrough.json");
28034
+ return existsSync3(configPath2) ? readFileSync4(configPath2, "utf8") : null;
28035
+ }
28036
+ function resolveSitename(root, explicit) {
28037
+ if (explicit) return explicit;
28038
+ const raw = localConfigRaw(root);
28039
+ if (raw) {
28040
+ try {
28041
+ const site = JSON.parse(raw).site;
28042
+ if (site) return site;
28043
+ } catch {
28044
+ }
28045
+ }
28046
+ return path6.basename(root).toLowerCase().replace(/[^a-z0-9-]+/g, "-");
28047
+ }
28048
+ var statusCommand2 = defineCommand({
28049
+ meta: { name: "status", description: "Compare the local tree with what's deployed" },
28050
+ args: {
28051
+ dir: { type: "positional", description: "Project directory", required: false },
28052
+ site: { type: "string", description: 'Site name (defaults to drivethrough.json "site", then the folder name)' },
28053
+ json: { type: "boolean", description: "Machine-readable output" }
28054
+ },
28055
+ async run({ args }) {
28056
+ const jsonArgs = { json: args.json };
28057
+ try {
28058
+ const { root, assetDir, built } = projectDirs(args.dir ?? ".");
28059
+ const sitename = resolveSitename(root, args.site);
28060
+ const current = await fetchManifest(sitename);
28061
+ const local = hashLocalFiles(assetDir);
28062
+ const base = await resolveBase(root, sitename, local, current);
28063
+ const inSync = treesEqual(local, current);
28064
+ const localClean = base !== null && treesEqual(local, base.manifest);
28065
+ const behind = base !== null && base.version < current.version;
28066
+ const configDiffers = canonicalConfig(current.config ?? {}) !== (localConfigRaw(root) !== null ? (() => {
28067
+ try {
28068
+ return canonicalConfig(JSON.parse(localConfigRaw(root)));
28069
+ } catch {
28070
+ return null;
28071
+ }
28072
+ })() : canonicalConfig({}));
28073
+ const relation = inSync ? "in_sync" : behind ? localClean ? "behind" : "fork" : base ? "local_changes" : "unknown";
28074
+ let versionsSince = [];
28075
+ if (behind) {
28076
+ const history = await api(
28077
+ "GET",
28078
+ `/v1/sites/${encodeURIComponent(sitename)}/versions?limit=${Math.min(current.version - base.version, 100)}`
28079
+ );
28080
+ versionsSince = history.versions.filter((v3) => v3.version > base.version);
28081
+ }
28082
+ const plan = planPull(local, base?.manifest ?? null, current);
28083
+ emit(
28084
+ jsonArgs,
28085
+ {
28086
+ sitename,
28087
+ site_id: current.site_id,
28088
+ current_version: current.version,
28089
+ base_version: base?.version ?? null,
28090
+ base_source: base?.source ?? null,
28091
+ built_site: built,
28092
+ relation,
28093
+ config_differs: configDiffers,
28094
+ conflicts: plan.conflicts,
28095
+ local_only: plan.local_only,
28096
+ versions_since: versionsSince,
28097
+ next: relation === "in_sync" ? "Nothing to do \u2014 edit, build, deploy." : relation === "behind" ? "Run `dt pull` to bring the remote changes down, then continue working." : relation === "fork" ? "Both sides changed. Run `dt pull` \u2014 clean files fast-forward; conflicted files are listed for you to merge." : relation === "local_changes" ? "Local edits not yet deployed. Deploy when ready \u2014 the current base_version will be sent automatically." : "No provable relation to the deployed site (no state, no hash match). `dt diff` shows per-file differences."
28098
+ },
28099
+ () => {
28100
+ M2.message(`${sitename} \u2014 deployed v${current.version}${base ? `, local base v${base.version} (${base.source === "state" ? "state file" : "matched by hashing"})` : ", local base unknown"}`);
28101
+ if (relation === "in_sync") M2.success(`In sync with v${current.version}.`);
28102
+ if (relation === "behind") {
28103
+ M2.warn(`Behind by ${current.version - (base?.version ?? 0)} version(s) \u2014 remote sessions deployed while you were away:`);
28104
+ for (const v3 of versionsSince) M2.message(` v${v3.version} (${v3.created_at}): ${JSON.stringify(v3.changes)}`);
28105
+ M2.info("Run `dt pull` to sync down.");
28106
+ }
28107
+ if (relation === "local_changes") M2.info("Local edits not yet deployed.");
28108
+ if (relation === "fork") {
28109
+ M2.warn("TRUE FORK: the site moved remotely AND the local tree has its own changes.");
28110
+ if (plan.conflicts.length) M2.message(` conflicted: ${plan.conflicts.join(", ")}`);
28111
+ M2.info("Run `dt pull` \u2014 clean files fast-forward, conflicts are listed, nothing is overwritten.");
28112
+ }
28113
+ if (relation === "unknown") M2.warn("No provable relation to the deployed site \u2014 `dt diff` shows per-file differences.");
28114
+ if (configDiffers) M2.warn("drivethrough.json differs from the deployed config.");
28115
+ }
28116
+ );
28117
+ } catch (error2) {
28118
+ fail(jsonArgs, error2);
28119
+ }
28120
+ }
28121
+ });
28122
+
28123
+ // src/commands/pull.ts
28124
+ import { mkdirSync as mkdirSync3, rmSync, writeFileSync as writeFileSync4 } from "node:fs";
28125
+ import path7 from "node:path";
28126
+ init_api();
28127
+ init_state();
28128
+ init_bundle2();
28129
+ init_state();
28130
+ var pullCommand = defineCommand({
28131
+ meta: { name: "pull", description: "Sync remote changes into the local project (never overwrites local edits)" },
28132
+ args: {
28133
+ dir: { type: "positional", description: "Project directory (empty dir adopts the whole site)", required: false },
28134
+ site: { type: "string", description: 'Site name (defaults to drivethrough.json "site", then the folder name)' },
28135
+ json: { type: "boolean", description: "Machine-readable output" }
28136
+ },
28137
+ async run({ args }) {
28138
+ const jsonArgs = { json: args.json };
28139
+ try {
28140
+ const { root, assetDir, built } = projectDirs(args.dir ?? ".");
28141
+ const sitename = resolveSitename(root, args.site);
28142
+ const current = await fetchManifest(sitename);
28143
+ const local = hashLocalFiles(assetDir);
28144
+ const base = await resolveBase(root, sitename, local, current);
28145
+ if (built) {
28146
+ return await builtSitePull(jsonArgs, { root, sitename, current, base });
28147
+ }
28148
+ const plan = planPull(local, base?.manifest ?? null, current);
28149
+ const configAction = planConfig(localConfigRaw(root), base?.manifest.config ?? null, current.config);
28150
+ const conflicts = [...plan.conflicts, ...configAction.action === "conflict" ? ["/drivethrough.json"] : []];
28151
+ if (conflicts.length > 0) {
28152
+ throw new ApiError(409, {
28153
+ error: {
28154
+ code: "sync_conflict",
28155
+ message: `These local files changed since the last sync AND changed remotely \u2014 pull will not overwrite local work: ${conflicts.join(", ")}.`,
28156
+ next: "Merge by hand (or with your agent): compare each conflicted file against the deployed one (dt files / get_site), fold the remote change in, then deploy. Nothing was modified."
28157
+ }
28158
+ });
28159
+ }
28160
+ for (const file of plan.writes) {
28161
+ const bytes = await apiBytes(`/v1/sites/${encodeURIComponent(current.site_id)}/blobs/${file.sha256}`);
28162
+ if (sha256(bytes) !== file.sha256) {
28163
+ throw new ApiError(500, {
28164
+ error: {
28165
+ code: "blob_hash_mismatch",
28166
+ message: `Downloaded content for ${file.path} does not hash to ${file.sha256} \u2014 refusing to write it.`,
28167
+ next: "Retry the pull; if it persists, report it \u2014 the stored blob may be corrupt."
28168
+ }
28169
+ });
28170
+ }
28171
+ const target = path7.join(assetDir, file.path);
28172
+ mkdirSync3(path7.dirname(target), { recursive: true });
28173
+ writeFileSync4(target, bytes);
28174
+ }
28175
+ for (const p2 of plan.deletes) {
28176
+ rmSync(path7.join(assetDir, p2), { force: true });
28177
+ }
28178
+ if (configAction.action === "write") {
28179
+ writeFileSync4(path7.join(root, "drivethrough.json"), configAction.json);
28180
+ }
28181
+ const after = hashLocalFiles(assetDir);
28182
+ const parity = plan.writes.every((f3) => after.get(f3.path)?.sha256 === f3.sha256) && plan.deletes.every((p2) => !after.has(p2));
28183
+ if (!parity) {
28184
+ throw new ApiError(500, {
28185
+ error: {
28186
+ code: "sync_incomplete",
28187
+ message: "After applying remote changes the local tree still differs from the deployed manifest.",
28188
+ next: "Run `dt diff` to see the per-file differences."
28189
+ }
28190
+ });
28191
+ }
28192
+ writeState(root, stateFor(current, sitename));
28193
+ const exact = treesEqual(after, current);
28194
+ emit(
28195
+ jsonArgs,
28196
+ {
28197
+ sitename,
28198
+ version: current.version,
28199
+ pulled: plan.writes.map((f3) => f3.path),
28200
+ deleted: plan.deletes,
28201
+ config_updated: configAction.action === "write",
28202
+ unchanged: plan.in_sync.length,
28203
+ local_only: plan.local_only,
28204
+ parity: true,
28205
+ byte_exact: exact
28206
+ },
28207
+ () => {
28208
+ M2.success(`Local tree now matches deployed v${current.version}.`);
28209
+ for (const f3 of plan.writes) M2.message(` pulled ${f3.path}`);
28210
+ for (const p2 of plan.deletes) M2.message(` deleted ${p2}`);
28211
+ if (configAction.action === "write") M2.message(" updated drivethrough.json");
28212
+ if (plan.local_only.length) M2.info(`Untouched local-only files: ${plan.local_only.join(", ")}`);
28213
+ }
28214
+ );
28215
+ } catch (error2) {
28216
+ fail(jsonArgs, error2);
28217
+ }
28218
+ }
28219
+ });
28220
+ async function builtSitePull(jsonArgs, ctx) {
28221
+ const { root, sitename, current, base } = ctx;
28222
+ if (!base) {
28223
+ throw new ApiError(409, {
28224
+ error: {
28225
+ code: "no_sync_base",
28226
+ message: "This is a built site (dist/) with no provable relation to a deployed version \u2014 there is no way to know which remote changes are new.",
28227
+ next: "Rebuild so dist/ matches what you last deployed and run `dt pull` again (the version is matched by hashing), or run `dt diff` to compare the current build against the deployed site."
28228
+ }
28229
+ });
28230
+ }
28231
+ if (base.version === current.version) {
28232
+ emit(jsonArgs, { sitename, version: current.version, in_sync: true, ported: false }, () => {
28233
+ M2.success(`Deployed v${current.version} is what you left \u2014 nothing to port.`);
28234
+ });
28235
+ return;
28236
+ }
28237
+ const history = await api(
28238
+ "GET",
28239
+ `/v1/sites/${encodeURIComponent(sitename)}/versions?limit=${Math.min(current.version - base.version, 100)}&edit_scripts=1`
28240
+ );
28241
+ const since = history.versions.filter((v3) => v3.version > base.version).sort((a2, b4) => a2.version - b4.version);
28242
+ const scripts = since.filter((v3) => v3.edit_script?.length).map((v3) => ({ version: v3.version, edits: v3.edit_script }));
28243
+ const scriptedPaths = new Set(scripts.flatMap((s2) => s2.edits.map((e3) => e3.path)));
28244
+ const diff = await api(
28245
+ "GET",
28246
+ `/v1/sites/${encodeURIComponent(sitename)}/diff?from=${base.version}&to=${current.version}`
28247
+ );
28248
+ const textish = (ct) => ct.startsWith("text/") || ct.includes("json") || ct.includes("javascript") || ct.includes("xml") || ct.includes("svg");
28249
+ const diffs = diff.files.filter(
28250
+ (f3) => f3.status === "changed" && textish(f3.content_type) && !scriptedPaths.has(f3.path) && f3.diff
28251
+ );
28252
+ const toStage = diff.files.filter(
28253
+ (f3) => f3.status === "added" || f3.status === "changed" && (!textish(f3.content_type) || !f3.diff && !scriptedPaths.has(f3.path))
28254
+ );
28255
+ const stagingDir = path7.join(root, ".drivethrough", "incoming");
28256
+ rmSync(stagingDir, { recursive: true, force: true });
28257
+ const staged = [];
28258
+ for (const f3 of toStage) {
28259
+ const entry = current.files.find((m3) => m3.path === f3.path);
28260
+ if (!entry) continue;
28261
+ const bytes = await apiBytes(`/v1/sites/${encodeURIComponent(current.site_id)}/blobs/${entry.sha256}`);
28262
+ const target = path7.join(stagingDir, f3.path);
28263
+ mkdirSync3(path7.dirname(target), { recursive: true });
28264
+ writeFileSync4(target, bytes);
28265
+ staged.push(f3.path);
28266
+ }
28267
+ const removed = diff.files.filter((f3) => f3.status === "removed").map((f3) => f3.path);
28268
+ emit(
28269
+ jsonArgs,
28270
+ {
28271
+ sitename,
28272
+ built_site: true,
28273
+ base_version: base.version,
28274
+ current_version: current.version,
28275
+ edit_scripts: scripts,
28276
+ diffs: diffs.map((f3) => ({ path: f3.path, diff: f3.diff })),
28277
+ staged_files: staged,
28278
+ staging_dir: staged.length ? path7.join(".drivethrough", "incoming") : null,
28279
+ removed_paths: removed,
28280
+ next: "These remote changes landed on BUILT OUTPUT. Port them into src/ (apply the edit scripts' find/replace to the matching source text; fold in the diffs; place staged files), rebuild, then prove parity with `dt diff` before deploying. Report any edit you cannot locate in src/ \u2014 never guess."
28281
+ },
28282
+ () => {
28283
+ M2.warn(
28284
+ `Deployed moved v${base.version} \u2192 v${current.version} while this built site was away. Nothing was modified locally.`
28285
+ );
28286
+ for (const s2 of scripts) {
28287
+ M2.message(` v${s2.version} edit script:`);
28288
+ for (const e3 of s2.edits) M2.message(` ${e3.path}: "${e3.find}" \u2192 "${e3.replace}"${e3.replace_all ? " (all)" : ""}`);
28289
+ }
28290
+ for (const d4 of diffs) M2.message(` changed (diff available): ${d4.path}`);
28291
+ for (const s2 of staged) M2.message(` staged: .drivethrough/incoming${s2}`);
28292
+ if (removed.length) M2.message(` removed remotely: ${removed.join(", ")}`);
28293
+ M2.info("Port into src/, rebuild, then `dt diff` to prove parity before deploying.");
28294
+ }
28295
+ );
28296
+ }
28297
+
28298
+ // src/commands/diff.ts
28299
+ init_state();
28300
+ var diffCommand = defineCommand({
28301
+ meta: { name: "diff", description: "Prove (or disprove) that the local build matches the deployed site \u2014 no upload" },
28302
+ args: {
28303
+ dir: { type: "positional", description: "Project directory (compares ./dist when present)", required: false },
28304
+ site: { type: "string", description: 'Site name (defaults to drivethrough.json "site", then the folder name)' },
28305
+ json: { type: "boolean", description: "Machine-readable output" }
28306
+ },
28307
+ async run({ args }) {
28308
+ const jsonArgs = { json: args.json };
28309
+ try {
28310
+ const { root, assetDir } = projectDirs(args.dir ?? ".");
28311
+ const sitename = resolveSitename(root, args.site);
28312
+ const current = await fetchManifest(sitename);
28313
+ const local = hashLocalFiles(assetDir);
28314
+ const same = [];
28315
+ const changed = [];
28316
+ const missing_locally = [];
28317
+ for (const entry of current.files) {
28318
+ const localFile = local.get(entry.path);
28319
+ if (!localFile) missing_locally.push(entry.path);
28320
+ else if (localFile.sha256 === entry.sha256) same.push(entry.path);
28321
+ else changed.push(entry.path);
28322
+ }
28323
+ const extra_locally = [...local.keys()].filter((p2) => !current.files.some((f3) => f3.path === p2));
28324
+ const parity = treesEqual(local, current);
28325
+ if (parity) {
28326
+ writeState(root, stateFor(current, sitename));
28327
+ }
28328
+ emit(
28329
+ jsonArgs,
28330
+ {
28331
+ sitename,
28332
+ version: current.version,
28333
+ parity,
28334
+ same: same.length,
28335
+ changed,
28336
+ missing_locally,
28337
+ extra_locally,
28338
+ bytes_uploaded: 0
28339
+ },
28340
+ () => {
28341
+ if (parity) {
28342
+ M2.success(`Local \u2261 deployed v${current.version} \u2014 ${same.length} files, byte-identical.`);
28343
+ } else {
28344
+ M2.warn(`Local differs from deployed v${current.version}:`);
28345
+ for (const p2 of changed) M2.message(` changed: ${p2}`);
28346
+ for (const p2 of missing_locally) M2.message(` missing local: ${p2}`);
28347
+ for (const p2 of extra_locally) M2.message(` extra local: ${p2}`);
28348
+ }
28349
+ }
28350
+ );
28351
+ if (!parity) process.exitCode = 1;
28352
+ } catch (error2) {
28353
+ fail(jsonArgs, error2);
28354
+ }
28355
+ }
28356
+ });
28357
+
26448
28358
  // src/main.ts
26449
28359
  var main = defineCommand({
26450
28360
  meta: {
@@ -26457,12 +28367,17 @@ var main = defineCommand({
26457
28367
  logout: logoutCommand,
26458
28368
  whoami: whoamiCommand,
26459
28369
  deploy: deployCommand,
28370
+ status: statusCommand2,
28371
+ pull: pullCommand,
28372
+ diff: diffCommand,
26460
28373
  sites: sitesCommand,
26461
28374
  delete: deleteCommand,
26462
28375
  restore: restoreCommand,
26463
28376
  share: shareCommand,
26464
28377
  privacy: privacyCommand,
26465
28378
  db: dbCommand,
28379
+ data: dataCommand,
28380
+ files: filesCommand,
26466
28381
  upgrade: upgradeCommand,
26467
28382
  domain: domainCommand,
26468
28383
  collaborators: collaboratorsCommand,