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.
- package/dist/main.mjs +1997 -82
- 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:
|
|
1580
|
-
const fullPath = [...
|
|
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,
|
|
1923
|
+
constructor(parent, value, path8, key) {
|
|
1889
1924
|
this._cachedPath = [];
|
|
1890
1925
|
this.parent = parent;
|
|
1891
1926
|
this.data = value;
|
|
1892
|
-
this._path =
|
|
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
|
-
/**
|
|
5294
|
-
|
|
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
|
-
|
|
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([
|
|
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,
|
|
5652
|
-
if (!
|
|
5770
|
+
function getElementAtPath(obj, path8) {
|
|
5771
|
+
if (!path8)
|
|
5653
5772
|
return obj;
|
|
5654
|
-
return
|
|
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(
|
|
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(
|
|
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(
|
|
9572
|
-
if (
|
|
9690
|
+
function getDotPath(path8) {
|
|
9691
|
+
if (path8.length === 0) {
|
|
9573
9692
|
return "object root";
|
|
9574
9693
|
}
|
|
9575
|
-
return
|
|
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(
|
|
17754
|
-
let input =
|
|
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 [
|
|
18007
|
-
wsComponent.path =
|
|
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
|
|
23223
|
-
if (
|
|
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:
|
|
23456
|
+
next: `Paths must be site-relative like "/index.html" \u2014 letters, digits, and ._~!$&'()*+,;=:@%- only, no "..".`
|
|
23230
23457
|
}
|
|
23231
23458
|
};
|
|
23232
23459
|
}
|
|
23233
|
-
if (
|
|
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(
|
|
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
|
-
|
|
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 "${
|
|
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:${
|
|
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 }
|
|
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 }
|
|
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
|
-
|
|
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(
|
|
23328
|
-
|
|
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 =
|
|
23943
|
+
let fallbackName = null;
|
|
23347
23944
|
if (files && files.length > 0) {
|
|
23348
|
-
const
|
|
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
|
-
|
|
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
|
-
"
|
|
24022
|
+
"create_upload",
|
|
23394
24023
|
{
|
|
23395
|
-
|
|
23396
|
-
|
|
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(
|
|
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
|
-
"
|
|
24049
|
+
"edit_site",
|
|
23408
24050
|
{
|
|
23409
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
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",
|
|
23435
|
-
return ok(await ctx.api.request("POST",
|
|
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",
|
|
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
|
-
|
|
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",
|
|
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,
|
|
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
|
|
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 ??
|
|
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
|
|
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
|
|
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.
|
|
26174
|
-
Me(
|
|
26175
|
-
|
|
26176
|
-
|
|
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: {
|
|
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
|
|
26284
|
-
import
|
|
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
|
|
26368
|
-
|
|
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 =
|
|
26423
|
-
const skillPath =
|
|
26424
|
-
const agentsPath =
|
|
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
|
-
|
|
26427
|
-
|
|
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,
|