@cloudgrid-io/mcp 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/corpus/capability-map.md +12 -6
- package/src/corpus/cloudgrid-yaml.md +7 -5
- package/src/corpus/cookbook.md +4 -4
- package/src/corpus/install-for-agents.md +1 -2
- package/src/corpus/skill-plug.md +69 -6
- package/src/corpus/skills-readme.md +9 -9
- package/src/corpus/templates/api-docs/README.md +1 -1
- package/src/corpus/templates/api-docs/index.md +2 -3
- package/src/corpus/templates/appointment-booking/AGENTS.md +1 -1
- package/src/corpus/templates/changelog/README.md +1 -1
- package/src/corpus/templates/changelog/index.md +2 -3
- package/src/corpus/templates/company-website/README.md +2 -3
- package/src/corpus/templates/company-website/cloudgrid.yaml +1 -1
- package/src/corpus/templates/company-website/index.md +2 -3
- package/src/corpus/templates/dashboard/cloudgrid.yaml +1 -1
- package/src/corpus/templates/dashboard/index.md +2 -3
- package/src/corpus/templates/deck/cloudgrid.yaml +1 -1
- package/src/corpus/templates/deck/index.md +2 -3
- package/src/corpus/templates/docs-site/README.md +1 -1
- package/src/corpus/templates/docs-site/index.md +2 -3
- package/src/corpus/templates/landing-page/cloudgrid.yaml +1 -1
- package/src/corpus/templates/landing-page/index.md +2 -3
- package/src/corpus/templates/membership-site/AGENTS.md +1 -1
- package/src/corpus/templates/portfolio/README.md +1 -1
- package/src/corpus/templates/portfolio/index.md +2 -3
- package/src/corpus/templates/product-launch/README.md +2 -3
- package/src/corpus/templates/product-launch/cloudgrid.yaml +1 -1
- package/src/corpus/templates/product-launch/index.md +2 -3
- package/src/corpus/templates/report/cloudgrid.yaml +1 -1
- package/src/corpus/templates/report/index.md +2 -3
- package/src/corpus/templates/saas-marketing/README.md +1 -1
- package/src/corpus/templates/saas-marketing/index.md +2 -3
- package/src/corpus/templates/status-page/README.md +1 -1
- package/src/corpus/templates/status-page/index.md +2 -3
- package/src/corpus/templates/subscription-management/AGENTS.md +1 -1
- package/src/corpus/templates/waitlist/README.md +1 -1
- package/src/corpus/templates/waitlist/index.md +2 -3
- package/src/corpus/templates/web-app/cloudgrid.yaml +1 -1
- package/src/corpus/templates/web-app/index.md +2 -3
- package/src/corpus/usage.md +2 -2
- package/src/corpus/workflows/api-docs.md +1 -1
- package/src/corpus/workflows/changelog.md +1 -1
- package/src/corpus/workflows/company-website.md +1 -1
- package/src/corpus/workflows/dashboard.md +1 -1
- package/src/corpus/workflows/docs-site.md +1 -1
- package/src/corpus/workflows/landing-page.md +1 -1
- package/src/corpus/workflows/portfolio.md +1 -1
- package/src/corpus/workflows/presentation.md +1 -1
- package/src/corpus/workflows/product-launch.md +1 -1
- package/src/corpus/workflows/report.md +1 -1
- package/src/corpus/workflows/saas-marketing.md +1 -1
- package/src/corpus/workflows/status-page.md +1 -1
- package/src/corpus/workflows/waitlist.md +1 -1
- package/src/corpus/workflows/web-app.md +1 -1
- package/src/tools.js +225 -630
- package/src/web.js +1 -1
- package/src/widgets/org-picker.html +3 -3
- package/src/corpus/skill-drop.md +0 -140
package/src/tools.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Two editions register from here:
|
|
4
4
|
// - local (stdio): full toolset, including the CLI-wrapping tools. Identity
|
|
5
5
|
// comes from ~/.cloudgrid/credentials.
|
|
6
|
-
// - web (HTTP, hosted): the light, CLI-free toolset (
|
|
6
|
+
// - web (HTTP, hosted): the light, CLI-free toolset (plug, claim, login).
|
|
7
7
|
// Identity is a per-session token held in memory.
|
|
8
8
|
//
|
|
9
9
|
// The difference is injected as a `ctx` object, so the tool logic is written once.
|
|
@@ -44,6 +44,14 @@ const ANON_HTML_MAX_BYTES = 2_000_000;
|
|
|
44
44
|
const AUTHED_HTML_MAX_BYTES = 25_000_000;
|
|
45
45
|
const CONSOLE_URL = "https://console.cloudgrid.io/";
|
|
46
46
|
|
|
47
|
+
const VISIBILITY_LABELS = {
|
|
48
|
+
private: "Only you",
|
|
49
|
+
org: "Your org",
|
|
50
|
+
authenticated: "Anyone signed in",
|
|
51
|
+
space: "A space",
|
|
52
|
+
link: "Anyone with the link",
|
|
53
|
+
};
|
|
54
|
+
|
|
47
55
|
// ── Widget resources (ChatGPT Apps SDK, web edition only) ────────────────────
|
|
48
56
|
// The Apps-SDK UI widgets (openai/outputTemplate → a ui:// html resource) render
|
|
49
57
|
// as a broken black frame in ChatGPT today, hiding the plain-text result. Gate
|
|
@@ -64,14 +72,6 @@ const WIDGET_CSP = {
|
|
|
64
72
|
resourceDomains: ["https://*.cloudgrid.io"],
|
|
65
73
|
};
|
|
66
74
|
|
|
67
|
-
const VISIBILITY_LABELS = {
|
|
68
|
-
private: "Only you",
|
|
69
|
-
org: "Your org",
|
|
70
|
-
authenticated: "Anyone signed in",
|
|
71
|
-
space: "A space",
|
|
72
|
-
link: "Anyone with the link",
|
|
73
|
-
};
|
|
74
|
-
|
|
75
75
|
function ok(text) {
|
|
76
76
|
return { content: [{ type: "text", text }] };
|
|
77
77
|
}
|
|
@@ -94,14 +94,14 @@ Operating rules:
|
|
|
94
94
|
6. If a signed-in publish fails with a server error, do not fall back to anonymous publishing (it burns the anonymous quota and downgrades ownership); surface the error, use the CLI fallback if offered, or ask the user.
|
|
95
95
|
7. When signed in and the user has more than one grid, do not assume a target — the publish tools will ask; relay the choice to the user and pass the chosen grid.
|
|
96
96
|
8. When a build/deploy fails unexpectedly, offer to report it to the CloudGrid team — only with the user's explicit consent (ask first). Send just the error + the failed request by default (call grid_report), and never send the whole conversation unless the user agrees (include_conversation). Respect privacy.
|
|
97
|
-
9. To modify an existing
|
|
98
|
-
10. Publishing a heavy or
|
|
97
|
+
9. To modify an existing page when you don't already have its HTML in context, first call grid_source to fetch the current HTML, apply your change, then call grid_plug with target_entity_id (the entity_id) to update the SAME URL in place. Do not ask the user to paste the HTML back.
|
|
98
|
+
10. Publishing a single HTML page: pass it inline as grid_plug's html parameter (a full self-contained document). For a heavy or image-heavy page in the local edition, pass the path parameter instead so it is read from disk (no inline size limit); never base64-encode HTML and never pass a file path (or an @-prefixed path) as html. If a page looks empty, use grid_source to check what was actually published, then re-plug with the real HTML/path and target_entity_id.
|
|
99
99
|
11. Persistence check: if the user needs to SAVE data, share state across users/sessions, log in, or store submissions, that's a runtime app-with-data (Mongo-backed), NOT a static page — static templates keep state only in memory and lose it on refresh. Use the app-with-data workflow. This requires the LOCAL edition (Claude Desktop/Code or the CLI); on the hosted edition, tell the user persistence isn't available there and offer a static version.
|
|
100
|
-
12. To choose what to build: match the request against the workflow when: triggers and the capability-map (grid_fetch({kind:"doc", name:"capability-map"})). Pick the template whose needs: matches what the app requires (persistence → database; scheduled → cron; etc.).
|
|
100
|
+
12. To choose what to build: match the request against the workflow when: triggers and the capability-map (grid_fetch({kind:"doc", name:"capability-map"})). Pick the template whose needs: matches what the app requires (persistence → database; scheduled → cron; etc.). Classify the ARTIFACT to pick the deploy: ONE self-contained HTML page (a single file — CSS+JS inline, images/fonts as data: URIs; that is the normal hosted output) → an inspiration — instant, ANY edition, deploy via grid_plug with the inline html param. Only genuinely SEPARATE files/folders (a real assets/ dir, separate .css/.js files, multiple pages, a SPA build) — OR anything needing needs: (data/server/LLM/cron) → a runtime app — grid_plug on a linked folder with a cloudgrid.yaml, local edition only, async build.
|
|
101
101
|
13. Before writing a cloudgrid.yaml, fetch the reference: grid_fetch({kind:"doc", name:"cloudgrid-yaml"}) — it has the full schema and the needs: vocabulary. Declare infrastructure with needs: (the deployer injects from it): needs: { database: true } → Mongo (DATABASE_MONGODB_URL); needs: { cache: true } → Redis; scheduled work → a service of type: cron (Python or Node). Use needs: OR requires:, never both — declaring both is rejected.
|
|
102
102
|
14. Databases — CloudGrid supports both, so never tell the user to self-host. Managed: needs: { database: true } provisions Mongo and injects DATABASE_MONGODB_URL. Bring-your-own (they already run Postgres / MySQL / MongoDB / Supabase / Neon / PlanetScale / etc.): needs: { database: { tier: external, secret: MY_DB } } plus grid secrets set MY_DB=<connection-string> — the connection string lives in env SECRETS, never committed. If asked "what databases does CloudGrid support?": all of them — the managed CloudGrid database out of the box, or bring your own via keys — ask which they want.
|
|
103
103
|
|
|
104
|
-
Deploy is edition
|
|
104
|
+
Deploy is via grid_plug on every edition: for a single HTML page pass it inline as the html param (works on the hosted MCP too); for a multi-file app write the files and pass a folder path (local MCP / CLI). A single HTML page deploys synchronously as an inspiration, so you get a URL right away.`;
|
|
105
105
|
|
|
106
106
|
// The corpus subdirectories that grid_fetch serves, keyed by `kind`. Each
|
|
107
107
|
// lives in its own subtree of src/corpus/ (populated by scripts/snapshot-corpus.mjs
|
|
@@ -710,9 +710,9 @@ async function fetchUserOrgs(token) {
|
|
|
710
710
|
}
|
|
711
711
|
}
|
|
712
712
|
|
|
713
|
-
// ── Shared grid disambiguation (
|
|
714
|
-
// The stateless "which grid?" ask
|
|
715
|
-
//
|
|
713
|
+
// ── Shared grid disambiguation (grid_plug) ──────────────────
|
|
714
|
+
// The stateless "which grid?" ask on an authed create. Given the caller's token
|
|
715
|
+
// and a supplied grid, it decides:
|
|
716
716
|
// - supplied grid matches a membership → { proceed: true, grid }
|
|
717
717
|
// - >1 grid and none supplied → { picker } (a ready-to-return result)
|
|
718
718
|
// - exactly one grid → { single: annotatedOrg } — the caller
|
|
@@ -771,31 +771,6 @@ export async function resolveGridOrAsk(ctx, { token, suppliedGrid, edition }, de
|
|
|
771
771
|
return { proceed: true };
|
|
772
772
|
}
|
|
773
773
|
|
|
774
|
-
// After an authenticated web drop, upgrade visibility to "link" so the artifact
|
|
775
|
-
// is shareable and its preview renders without a sign-in wall. Best-effort — a
|
|
776
|
-
// failure here does not fail the drop; the user can always call grid_visibility.
|
|
777
|
-
async function upgradeVisibilityToLink(ctx, entityId, orgSlug) {
|
|
778
|
-
const token = await ctx.getToken();
|
|
779
|
-
if (!token || !entityId) return false;
|
|
780
|
-
try {
|
|
781
|
-
const hdrs = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
|
|
782
|
-
// Send the grid-native header; keep X-CloudGrid-Org in parallel (same value)
|
|
783
|
-
// during the org→grid soak. Never send conflicting values → 400 GRID_HEADER_CONFLICT.
|
|
784
|
-
if (orgSlug) {
|
|
785
|
-
hdrs["X-CloudGrid-Grid"] = orgSlug;
|
|
786
|
-
hdrs["X-CloudGrid-Org"] = orgSlug;
|
|
787
|
-
}
|
|
788
|
-
const res = await fetch(`${API_BASE}/api/v2/inspirations/${encodeURIComponent(entityId)}`, {
|
|
789
|
-
method: "PATCH",
|
|
790
|
-
headers: hdrs,
|
|
791
|
-
body: JSON.stringify({ visibility: "link" }),
|
|
792
|
-
});
|
|
793
|
-
return res.ok;
|
|
794
|
-
} catch {
|
|
795
|
-
return false;
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
|
|
799
774
|
// ── Direct-API tools (both editions) ───────────────────────────────────────────
|
|
800
775
|
function looksLikeFullHtml(s) {
|
|
801
776
|
const head = s.replace(/^/, "").trimStart().slice(0, 256).toLowerCase();
|
|
@@ -846,6 +821,55 @@ function looksLikePath(s) {
|
|
|
846
821
|
return /^(~|\.{0,2}\/|[A-Za-z]:[\\/]|\/)/.test(t) || /\.[A-Za-z0-9]{1,8}$/.test(t);
|
|
847
822
|
}
|
|
848
823
|
|
|
824
|
+
// Normalize an inline `html` string — grid_plug's ergonomic single-file publish
|
|
825
|
+
// path — into ONE index.html artifact, reusing the same hardening the old drop
|
|
826
|
+
// verb used (decodeIfBase64Html, the @-path/file-path rejection, the base64
|
|
827
|
+
// guard, and the small-fragment wrap). Returns { path, buffer, type }. Throws on
|
|
828
|
+
// a file-path-looking or non-HTML input so a path/base64 blob is never published
|
|
829
|
+
// as page content. The auth-aware inline size cap is enforced later in runPlug
|
|
830
|
+
// (it depends on the anon-vs-authed wire).
|
|
831
|
+
function htmlToArtifact(html, filename) {
|
|
832
|
+
if (typeof html !== "string" || html.length === 0) {
|
|
833
|
+
throw new Error("`html` must be the complete HTML document as a string.");
|
|
834
|
+
}
|
|
835
|
+
// Strip an invented `@`-prefix shorthand, then reject a bare file path — the
|
|
836
|
+
// inline html path takes HTML CONTENT, not a path (use `path` for a file).
|
|
837
|
+
let candidate = html.startsWith("@") ? html.slice(1) : html;
|
|
838
|
+
if (!looksLikeFullHtml(candidate) && looksLikePath(candidate)) {
|
|
839
|
+
throw new Error(
|
|
840
|
+
`This looks like a file path (\`${candidate.trim()}\`), not HTML. Pass the raw HTML inline as ` +
|
|
841
|
+
"`html`, or pass the file/folder via `path` — do not pass a path as `html`.",
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
// Rescue a base64-of-HTML blob passed as `html` (real user repro): decode it
|
|
845
|
+
// rather than publishing a wall of base64 text.
|
|
846
|
+
const { html: resolved } = decodeIfBase64Html(candidate);
|
|
847
|
+
let content = resolved;
|
|
848
|
+
if (!looksLikeFullHtml(content)) {
|
|
849
|
+
const stripped = content.replace(/\s+/g, "");
|
|
850
|
+
const looksBase64 =
|
|
851
|
+
stripped.length >= 64 && stripped.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(stripped);
|
|
852
|
+
const isShortFragment = Buffer.byteLength(content, "utf8") <= 8192;
|
|
853
|
+
const hasTag = /<[a-z][\s\S]*>/i.test(content);
|
|
854
|
+
if (isShortFragment && (hasTag || (!looksBase64 && !looksLikePath(content)))) {
|
|
855
|
+
// Legit "share this snippet" — wrap a small text/markup fragment into a
|
|
856
|
+
// full document (preserve the old drop's friendly behavior).
|
|
857
|
+
content =
|
|
858
|
+
`<!doctype html>\n<html lang="en">\n<head><meta charset="utf-8">` +
|
|
859
|
+
`<title>Shared on CloudGrid</title></head>\n<body>\n${content}\n</body>\n</html>\n`;
|
|
860
|
+
} else {
|
|
861
|
+
// Large, or base64 that failed to decode to HTML, or a bare file path:
|
|
862
|
+
// refuse instead of silently publishing garbage.
|
|
863
|
+
const kind = looksBase64 ? "base64" : looksLikePath(content) ? "a file path" : "raw data";
|
|
864
|
+
throw new Error(
|
|
865
|
+
`This doesn't look like an HTML document (it looks like ${kind}). Pass the raw HTML as \`html\`, ` +
|
|
866
|
+
"or in the local edition pass `path` to the .html file — do NOT base64-encode it.",
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
return { path: filename || "index.html", buffer: Buffer.from(content, "utf8"), type: "text/html" };
|
|
871
|
+
}
|
|
872
|
+
|
|
849
873
|
// Anonymous drops are owned by the platform Guest Org, whose slug is the apex
|
|
850
874
|
// the public URL hangs off (https://guest.cloudgrid.io/<slug>).
|
|
851
875
|
const GUEST_ORG_SLUG = "guest";
|
|
@@ -908,430 +932,6 @@ export function resolvePlugUrl(data) {
|
|
|
908
932
|
return composePlugUrl(data);
|
|
909
933
|
}
|
|
910
934
|
|
|
911
|
-
export async function runDrop(
|
|
912
|
-
ctx,
|
|
913
|
-
{ html, path: filePath, filename, anonymous, org, grid, fresh, entity_id, owner_token },
|
|
914
|
-
deps = {},
|
|
915
|
-
) {
|
|
916
|
-
// `grid` is an accepted alias for `org` (Gilad's org→grid rename); `org` still works.
|
|
917
|
-
const targetGrid = org ?? grid;
|
|
918
|
-
// The `deps` seam (readFile/exists) exists ONLY so the content-handling
|
|
919
|
-
// regression tests can drive the `path` / `@`-path routes offline. Production
|
|
920
|
-
// always calls this with no deps.
|
|
921
|
-
const { readFile: readFileImpl = readFile, fsExists = existsSync } = deps;
|
|
922
|
-
|
|
923
|
-
// Defensive: the web edition schema excludes `path`, but if a model still
|
|
924
|
-
// passes one (e.g. from a cached tool description), reject early with a
|
|
925
|
-
// clear explanation.
|
|
926
|
-
if (ctx.edition === "web" && filePath) {
|
|
927
|
-
throw new Error(
|
|
928
|
-
"The hosted server cannot read local files — pass the full document as `html` instead of a `path`.",
|
|
929
|
-
);
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
// An agent may pass an `@`-prefixed path (invented `@/home/claude/...`
|
|
933
|
-
// shorthand) or a bare filesystem path as `html`. Normalise: strip a leading
|
|
934
|
-
// `@`, and in the local edition promote a path-looking `html` to the `path`
|
|
935
|
-
// route (read from disk) when the file exists. On the hosted server there is
|
|
936
|
-
// no disk, so a path-looking `html` is a clear error.
|
|
937
|
-
let effectivePath = filePath;
|
|
938
|
-
let htmlInput = typeof html === "string" ? html : undefined;
|
|
939
|
-
if (!effectivePath && typeof htmlInput === "string") {
|
|
940
|
-
let candidate = htmlInput;
|
|
941
|
-
if (candidate.startsWith("@")) candidate = candidate.slice(1);
|
|
942
|
-
if (!looksLikeFullHtml(candidate) && looksLikePath(candidate)) {
|
|
943
|
-
if (ctx.edition === "web") {
|
|
944
|
-
throw new Error(
|
|
945
|
-
`This looks like a file path (\`${candidate.trim()}\`), but the hosted server cannot read local ` +
|
|
946
|
-
`files — pass the raw HTML inline as \`html\`.`,
|
|
947
|
-
);
|
|
948
|
-
}
|
|
949
|
-
const resolvedPath = candidate.trim();
|
|
950
|
-
if (fsExists(resolvedPath)) {
|
|
951
|
-
effectivePath = resolvedPath;
|
|
952
|
-
htmlInput = undefined;
|
|
953
|
-
} else {
|
|
954
|
-
throw new Error(
|
|
955
|
-
`The path \`${resolvedPath}\` does not exist. Pass a valid \`path\` to the .html file, or pass ` +
|
|
956
|
-
`the raw HTML inline as \`html\` (do not base64-encode it, and there is no artifact_files parameter).`,
|
|
957
|
-
);
|
|
958
|
-
}
|
|
959
|
-
} else if (candidate !== htmlInput) {
|
|
960
|
-
// Leading `@` on genuine inline content — strip it and keep the content.
|
|
961
|
-
htmlInput = candidate;
|
|
962
|
-
}
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
let bytes;
|
|
966
|
-
let name;
|
|
967
|
-
let type;
|
|
968
|
-
// Anonymous drops are inline-capped hard; signed-in drops get a larger cap.
|
|
969
|
-
// The cap is enforced AFTER auth is resolved (see below), so only inline
|
|
970
|
-
// routes flag themselves here — `path` (local, read from disk) is uncapped.
|
|
971
|
-
let enforceInlineCap = false;
|
|
972
|
-
|
|
973
|
-
if (effectivePath) {
|
|
974
|
-
const raw = await readFileImpl(effectivePath);
|
|
975
|
-
const asText = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw);
|
|
976
|
-
const { html: decoded, wasBase64 } = decodeIfBase64Html(asText);
|
|
977
|
-
const lowerName = String(effectivePath).toLowerCase();
|
|
978
|
-
const nameIsHtml = lowerName.endsWith(".html") || lowerName.endsWith(".htm");
|
|
979
|
-
if (wasBase64) {
|
|
980
|
-
// A base64 `.txt` (or similar) that decoded to real HTML → publish the
|
|
981
|
-
// decoded HTML as text/html under an index.html name.
|
|
982
|
-
bytes = Buffer.from(decoded, "utf8");
|
|
983
|
-
type = "text/html";
|
|
984
|
-
name = filename || (nameIsHtml ? basename(effectivePath) : "index.html");
|
|
985
|
-
} else {
|
|
986
|
-
bytes = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
|
|
987
|
-
// Content-type sniff (fix #4): serve HTML as text/html so it renders as a
|
|
988
|
-
// page instead of downloading as a blob.
|
|
989
|
-
if (looksLikeFullHtml(asText) || nameIsHtml) {
|
|
990
|
-
type = "text/html";
|
|
991
|
-
name = filename || (nameIsHtml ? basename(effectivePath) : "index.html");
|
|
992
|
-
} else {
|
|
993
|
-
type = "application/octet-stream";
|
|
994
|
-
name = filename || basename(effectivePath);
|
|
995
|
-
}
|
|
996
|
-
}
|
|
997
|
-
} else if (typeof htmlInput === "string" && htmlInput.length > 0) {
|
|
998
|
-
// Rescue a base64-of-HTML blob passed as `html` (real user repro).
|
|
999
|
-
const { html: resolved } = decodeIfBase64Html(htmlInput);
|
|
1000
|
-
let content = resolved;
|
|
1001
|
-
if (!looksLikeFullHtml(content)) {
|
|
1002
|
-
const stripped = content.replace(/\s+/g, "");
|
|
1003
|
-
const looksBase64 =
|
|
1004
|
-
stripped.length >= 64 && stripped.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(stripped);
|
|
1005
|
-
const isShortFragment = Buffer.byteLength(content, "utf8") <= 8192;
|
|
1006
|
-
const hasTag = /<[a-z][\s\S]*>/i.test(content);
|
|
1007
|
-
if (isShortFragment && (hasTag || (!looksBase64 && !looksLikePath(content)))) {
|
|
1008
|
-
// Legit "share this snippet" — wrap a small text/markup fragment into a
|
|
1009
|
-
// full document (preserve existing friendly behavior).
|
|
1010
|
-
content =
|
|
1011
|
-
`<!doctype html>\n<html lang="en">\n<head><meta charset="utf-8">` +
|
|
1012
|
-
`<title>Shared on CloudGrid</title></head>\n<body>\n${content}\n</body>\n</html>\n`;
|
|
1013
|
-
} else {
|
|
1014
|
-
// Large, or base64 that failed to decode to HTML, or a bare file path:
|
|
1015
|
-
// refuse instead of silently publishing garbage.
|
|
1016
|
-
const kind = looksBase64
|
|
1017
|
-
? "base64"
|
|
1018
|
-
: looksLikePath(content)
|
|
1019
|
-
? "a file path"
|
|
1020
|
-
: "raw data";
|
|
1021
|
-
throw new Error(
|
|
1022
|
-
`This doesn't look like an HTML document (it looks like ${kind}). Pass the raw HTML as \`html\`, ` +
|
|
1023
|
-
`or in the local edition pass \`path\` to the .html file — do NOT base64-encode it. ` +
|
|
1024
|
-
`There is no artifact_files parameter.`,
|
|
1025
|
-
);
|
|
1026
|
-
}
|
|
1027
|
-
}
|
|
1028
|
-
bytes = Buffer.from(content, "utf8");
|
|
1029
|
-
name = filename || "index.html";
|
|
1030
|
-
type = "text/html";
|
|
1031
|
-
enforceInlineCap = true;
|
|
1032
|
-
} else {
|
|
1033
|
-
throw new Error("Provide either `html` (inline content) or `path` (a local file).");
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
// The caller's authed identity (unless they force an anonymous drop).
|
|
1037
|
-
let authToken = null;
|
|
1038
|
-
let orgSlug = null;
|
|
1039
|
-
if (anonymous !== true) {
|
|
1040
|
-
authToken = await ctx.getToken();
|
|
1041
|
-
if (authToken) {
|
|
1042
|
-
orgSlug = targetGrid || (await ctx.getActiveGrid());
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
// ── Re-plug targeting (unified plug contract / spec v2) ──────
|
|
1047
|
-
// A `target_entity_id` on the wire UPDATES THAT SAME ENTITY in place — same
|
|
1048
|
-
// entity_id, same slug, same URL, deploy history preserved. Absent → create.
|
|
1049
|
-
// The session remembers the last drop, so a re-drop updates it by default;
|
|
1050
|
-
// `fresh: true` forces a create; an explicit `entity_id` targets any earlier
|
|
1051
|
-
// drop (the durable re-plug handle a stateless caller persisted).
|
|
1052
|
-
if (fresh === true && entity_id) {
|
|
1053
|
-
throw new Error("`fresh: true` forces a new drop — do not pass `entity_id` with it.");
|
|
1054
|
-
}
|
|
1055
|
-
const sessionDrop = ctx.state.lastDrop;
|
|
1056
|
-
let targetId = null;
|
|
1057
|
-
let ownerToken = typeof owner_token === "string" && owner_token.length > 0 ? owner_token : null;
|
|
1058
|
-
if (fresh !== true) {
|
|
1059
|
-
targetId = entity_id || sessionDrop?.entity_id || null;
|
|
1060
|
-
// An anon-minted drop is edited with its OWNER TOKEN (a bearer capability,
|
|
1061
|
-
// the anon owner-token contract). Recover it from session state when not passed.
|
|
1062
|
-
if (targetId && !ownerToken) {
|
|
1063
|
-
if (sessionDrop?.entity_id === targetId && sessionDrop.owner_token) {
|
|
1064
|
-
ownerToken = sessionDrop.owner_token;
|
|
1065
|
-
} else if (ctx.state.lastAnonClaim?.entity_id === targetId && ctx.state.lastAnonClaim.token) {
|
|
1066
|
-
ownerToken = ctx.state.lastAnonClaim.token;
|
|
1067
|
-
}
|
|
1068
|
-
}
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
// Pick the edit wire. An anon-owned (Guest-Grid, unclaimed) drop is edited via
|
|
1072
|
-
// the owner-token wire EVEN IF the caller is signed in — the entity still lives
|
|
1073
|
-
// in the Guest Grid, so an authed edit of it would 404. An authed edit targets
|
|
1074
|
-
// an entity the caller owns in their grid.
|
|
1075
|
-
let mode = "create"; // "create" | "authed-edit" | "anon-edit"
|
|
1076
|
-
if (targetId) {
|
|
1077
|
-
if (ownerToken) {
|
|
1078
|
-
mode = "anon-edit";
|
|
1079
|
-
} else if (authToken) {
|
|
1080
|
-
mode = "authed-edit";
|
|
1081
|
-
} else if (entity_id) {
|
|
1082
|
-
throw new Error(
|
|
1083
|
-
"Re-plugging a drop anonymously needs its owner_token (returned when it was created). " +
|
|
1084
|
-
"Pass owner_token, sign in if you own it, or pass fresh: true to publish a new drop.",
|
|
1085
|
-
);
|
|
1086
|
-
} else {
|
|
1087
|
-
// Session target, but no way to authorize an edit — fall back to create.
|
|
1088
|
-
targetId = null;
|
|
1089
|
-
}
|
|
1090
|
-
}
|
|
1091
|
-
const isAnonymousWire = mode === "anon-edit" || !authToken;
|
|
1092
|
-
|
|
1093
|
-
// Auth-aware inline size cap (relocated here — auth is only known now). Anon
|
|
1094
|
-
// inline drops stay capped at 2MB; signed-in inline drops get the larger
|
|
1095
|
-
// AUTHED cap. `path` (read from disk) is uncapped and does not set the flag.
|
|
1096
|
-
if (enforceInlineCap) {
|
|
1097
|
-
if (isAnonymousWire) {
|
|
1098
|
-
if (bytes.byteLength > ANON_HTML_MAX_BYTES) {
|
|
1099
|
-
throw new Error(
|
|
1100
|
-
`This HTML is ${(bytes.byteLength / 1e6).toFixed(2)} MB. Anonymous drops are capped at 2 MB. ` +
|
|
1101
|
-
`Trim it, or sign in to publish larger.`,
|
|
1102
|
-
);
|
|
1103
|
-
}
|
|
1104
|
-
} else if (bytes.byteLength > AUTHED_HTML_MAX_BYTES) {
|
|
1105
|
-
throw new Error(
|
|
1106
|
-
`This HTML is ${(bytes.byteLength / 1e6).toFixed(2)} MB. Inline drops are capped at ` +
|
|
1107
|
-
`${(AUTHED_HTML_MAX_BYTES / 1e6).toFixed(0)} MB. In the local edition pass \`path\` to the ` +
|
|
1108
|
-
`.html file (read from disk — no inline size limit) instead of pasting it inline.`,
|
|
1109
|
-
);
|
|
1110
|
-
}
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
const headers = {};
|
|
1114
|
-
if (!isAnonymousWire) {
|
|
1115
|
-
headers["Authorization"] = `Bearer ${authToken}`;
|
|
1116
|
-
// Grid-native header + X-CloudGrid-Org alias (same slug) during the soak.
|
|
1117
|
-
if (orgSlug) {
|
|
1118
|
-
headers["X-CloudGrid-Grid"] = orgSlug;
|
|
1119
|
-
headers["X-CloudGrid-Org"] = orgSlug;
|
|
1120
|
-
}
|
|
1121
|
-
}
|
|
1122
|
-
|
|
1123
|
-
// Hosted server: attach the trusted-server credential on EVERY anonymous call
|
|
1124
|
-
// (creates AND owner-token edits — anon edits consume the same daily anon cap,
|
|
1125
|
-
// re-keyed per end user). Falls back gracefully server-side if absent. Only
|
|
1126
|
-
// the web edition sets ctx.trustedServer.
|
|
1127
|
-
if (isAnonymousWire && ctx.trustedServer?.secret && ctx.trustedServer?.endUserId) {
|
|
1128
|
-
headers["X-CloudGrid-Trusted-Server-Auth"] = ctx.trustedServer.secret;
|
|
1129
|
-
headers["X-CloudGrid-Trusted-Server-End-User"] = ctx.trustedServer.endUserId;
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
// Ownership continuity: replay the platform's anon-session cookie across drops in
|
|
1133
|
-
// this session, so cookie-class callers can redrop (and claim) what they dropped.
|
|
1134
|
-
if (isAnonymousWire && ctx.state.anonCookie) {
|
|
1135
|
-
headers["Cookie"] = ctx.state.anonCookie;
|
|
1136
|
-
}
|
|
1137
|
-
|
|
1138
|
-
const form = new FormData();
|
|
1139
|
-
// The artifact part name is unchanged from `/drop/auto` (`artifact`); the plug
|
|
1140
|
-
// create path — and the inspiration edit paths — treat every
|
|
1141
|
-
// non-`cloudgrid.yaml` part as raw artifact bytes.
|
|
1142
|
-
form.append("artifact", new Blob([bytes], { type }), name);
|
|
1143
|
-
if (mode !== "create") {
|
|
1144
|
-
form.append("target_entity_id", targetId);
|
|
1145
|
-
}
|
|
1146
|
-
if (mode === "anon-edit") {
|
|
1147
|
-
// Anon owner-token contract: ownership is proven by HOLDING the token (a form
|
|
1148
|
-
// field, NOT an Authorization header). The response re-mints it.
|
|
1149
|
-
form.append("owner_token", ownerToken);
|
|
1150
|
-
}
|
|
1151
|
-
if (mode === "authed-edit") {
|
|
1152
|
-
// The authed update path requires a `cloudgrid.yaml` part on the wire
|
|
1153
|
-
// (materializePlugTarball). An inspiration edit ignores its content, so an
|
|
1154
|
-
// empty part satisfies the contract without changing the entity's config.
|
|
1155
|
-
form.append("cloudgrid.yaml", new Blob([""], { type: "text/plain" }), "cloudgrid.yaml");
|
|
1156
|
-
}
|
|
1157
|
-
// `/plug` resolves the authed org from the `X-CloudGrid-Org` header (set
|
|
1158
|
-
// above), not a form field — so `org_slug` is no longer sent.
|
|
1159
|
-
|
|
1160
|
-
let res;
|
|
1161
|
-
try {
|
|
1162
|
-
res = await fetch(`${API_BASE}/api/v2/plug`, { method: "POST", headers, body: form });
|
|
1163
|
-
} catch (err) {
|
|
1164
|
-
throw new Error(`Could not reach CloudGrid at ${API_BASE}: ${err.message}`);
|
|
1165
|
-
}
|
|
1166
|
-
|
|
1167
|
-
const raw = await res.text();
|
|
1168
|
-
let data = null;
|
|
1169
|
-
try {
|
|
1170
|
-
data = JSON.parse(raw);
|
|
1171
|
-
} catch {
|
|
1172
|
-
/* handled below */
|
|
1173
|
-
}
|
|
1174
|
-
if (!res.ok) {
|
|
1175
|
-
const code = data?.error?.code;
|
|
1176
|
-
const msg = data?.error?.message || data?.message || raw || `HTTP ${res.status}`;
|
|
1177
|
-
const hint = data?.error?.details?.[0]?.hint;
|
|
1178
|
-
const isEdit = mode !== "create";
|
|
1179
|
-
// Self-heal rung: a signed-in CREATE that hits the known 400 SCOPE_INVALID
|
|
1180
|
-
// platform bug is retried through the bundled CLI — LOCAL edition only,
|
|
1181
|
-
// create only, never anonymous. (Mirrors runPlug.)
|
|
1182
|
-
if (
|
|
1183
|
-
res.status === 400 &&
|
|
1184
|
-
code === "SCOPE_INVALID" &&
|
|
1185
|
-
ctx.edition === "local" &&
|
|
1186
|
-
mode === "create" &&
|
|
1187
|
-
!isAnonymousWire &&
|
|
1188
|
-
authToken
|
|
1189
|
-
) {
|
|
1190
|
-
return plugViaCliFallback(ctx, [{ path: name, buffer: bytes }], deps);
|
|
1191
|
-
}
|
|
1192
|
-
// An explicit edit NEVER silently creates — surface the reason clearly.
|
|
1193
|
-
if (isEdit && res.status === 409) {
|
|
1194
|
-
throw new Error(
|
|
1195
|
-
`Re-plug rejected (HTTP 409): ${msg} ` +
|
|
1196
|
-
"The drop can no longer be edited in place (expired, archived, claimed, or a deploy is running). " +
|
|
1197
|
-
"Pass fresh: true to publish a new drop instead.",
|
|
1198
|
-
);
|
|
1199
|
-
}
|
|
1200
|
-
if (mode === "anon-edit" && res.status === 401) {
|
|
1201
|
-
throw new Error(
|
|
1202
|
-
`Re-plug rejected (HTTP 401): ${msg} ` +
|
|
1203
|
-
"The owner_token did not authorize this entity (wrong entity, expired with the drop, or already claimed). " +
|
|
1204
|
-
"Pass fresh: true to publish a new drop instead.",
|
|
1205
|
-
);
|
|
1206
|
-
}
|
|
1207
|
-
// Known-code guidance (SCOPE_INVALID on web, 429 anon cap, etc.); unknown
|
|
1208
|
-
// codes pass through as the bare error line, unchanged.
|
|
1209
|
-
const guidance = errorGuidance({
|
|
1210
|
-
status: res.status,
|
|
1211
|
-
code,
|
|
1212
|
-
edition: ctx.edition,
|
|
1213
|
-
isEdit,
|
|
1214
|
-
isAnon: isAnonymousWire,
|
|
1215
|
-
signedIn: Boolean(authToken),
|
|
1216
|
-
});
|
|
1217
|
-
const baseLine = `Drop failed (HTTP ${res.status}): ${msg}${hint ? ` ${hint}` : ""}`;
|
|
1218
|
-
throw new Error(guidance ? `${baseLine} — ${guidance}` : baseLine);
|
|
1219
|
-
}
|
|
1220
|
-
|
|
1221
|
-
// Persist the platform's anon-session cookie for ownership continuity (so a
|
|
1222
|
-
// cookie-class caller can claim — via pickup — what it dropped).
|
|
1223
|
-
const setCookies = res.headers.getSetCookie
|
|
1224
|
-
? res.headers.getSetCookie()
|
|
1225
|
-
: [res.headers.get("set-cookie")].filter(Boolean);
|
|
1226
|
-
const anonCookie = (setCookies || [])
|
|
1227
|
-
.map((c) => (c || "").split(";")[0])
|
|
1228
|
-
.find((c) => c.startsWith("cg_anon_session="));
|
|
1229
|
-
if (anonCookie) ctx.state.anonCookie = anonCookie;
|
|
1230
|
-
|
|
1231
|
-
// The server composes the canonical URL; compose client-side only as
|
|
1232
|
-
// a fallback when it came back empty.
|
|
1233
|
-
const url = resolvePlugUrl(data);
|
|
1234
|
-
const isEdit = mode !== "create";
|
|
1235
|
-
|
|
1236
|
-
// The anon owner token: the create response mints it; every anon edit
|
|
1237
|
-
// RE-MINTS it to the reset TTL (replace the stored one — the freshest token
|
|
1238
|
-
// is the one that lives as long as the drop). Same JWT the claim uses.
|
|
1239
|
-
let freshOwnerToken = typeof data.owner_token === "string" && data.owner_token.length > 0
|
|
1240
|
-
? data.owner_token
|
|
1241
|
-
: null;
|
|
1242
|
-
if (!freshOwnerToken && data.claim_url) {
|
|
1243
|
-
try {
|
|
1244
|
-
freshOwnerToken = new URL(data.claim_url).searchParams.get("token");
|
|
1245
|
-
} catch {
|
|
1246
|
-
freshOwnerToken = null;
|
|
1247
|
-
}
|
|
1248
|
-
}
|
|
1249
|
-
|
|
1250
|
-
// Remember the drop for session continuity — any caller class. The
|
|
1251
|
-
// `{entity_id, owner_token}` pair is the stateless anon re-plug handle.
|
|
1252
|
-
if (data.entity_id || url) {
|
|
1253
|
-
ctx.state.lastDrop = {
|
|
1254
|
-
entity_id: data.entity_id ?? ctx.state.lastDrop?.entity_id ?? null,
|
|
1255
|
-
url: url ?? ctx.state.lastDrop?.url ?? null,
|
|
1256
|
-
owner_token: isAnonymousWire
|
|
1257
|
-
? (freshOwnerToken ?? (mode === "anon-edit" ? ownerToken : null))
|
|
1258
|
-
: null,
|
|
1259
|
-
};
|
|
1260
|
-
}
|
|
1261
|
-
|
|
1262
|
-
// Authenticated wire (create or in-place edit): the entity is owned by the caller.
|
|
1263
|
-
if (!isAnonymousWire) {
|
|
1264
|
-
ctx.state.lastAnonClaim = null;
|
|
1265
|
-
const structured = {
|
|
1266
|
-
url,
|
|
1267
|
-
status: isEdit ? "updated" : "created",
|
|
1268
|
-
owned_by: "authenticated",
|
|
1269
|
-
...(data.entity_id ? { entity_id: data.entity_id } : {}),
|
|
1270
|
-
};
|
|
1271
|
-
const lines = [];
|
|
1272
|
-
if (isEdit) {
|
|
1273
|
-
lines.push(`Updated in place: ${url}`);
|
|
1274
|
-
lines.push("Same link, new content. The expiry timer was reset.");
|
|
1275
|
-
} else if (ctx.edition === "web") {
|
|
1276
|
-
lines.push(`Your app is live: ${url}`);
|
|
1277
|
-
} else {
|
|
1278
|
-
lines.push(`Published to your org: ${url}`, "Owned by you.");
|
|
1279
|
-
}
|
|
1280
|
-
if (ctx.edition === "web" && !isEdit) {
|
|
1281
|
-
// Default authed web CREATES to "link" visibility so the URL is shareable
|
|
1282
|
-
// and the console thumbnail renders without a sign-in wall. An edit keeps
|
|
1283
|
-
// the entity's existing visibility untouched.
|
|
1284
|
-
if (data.entity_id) {
|
|
1285
|
-
await upgradeVisibilityToLink(ctx, data.entity_id, orgSlug);
|
|
1286
|
-
}
|
|
1287
|
-
lines.push(`See and manage all your apps in your grid: ${CONSOLE_URL}`);
|
|
1288
|
-
const vis = "link";
|
|
1289
|
-
lines.push(`Visible to: ${VISIBILITY_LABELS[vis]}. Want to restrict access? I can set it to only you or your org.`);
|
|
1290
|
-
structured.console_url = CONSOLE_URL;
|
|
1291
|
-
structured.current_visibility = vis;
|
|
1292
|
-
structured.visibility_options = Object.entries(VISIBILITY_LABELS).map(([v, l]) => ({ value: v, label: l }));
|
|
1293
|
-
}
|
|
1294
|
-
return { text: lines.join("\n"), structured };
|
|
1295
|
-
}
|
|
1296
|
-
|
|
1297
|
-
// Anonymous wire — Guest-Grid inspiration (create 201, or owner-token edit).
|
|
1298
|
-
// The response carries the reward fields (`claim_url` + `claim_message`), the
|
|
1299
|
-
// real `entity_id`, and the OWNER TOKEN — the bearer capability that
|
|
1300
|
-
// authorizes BOTH a later anonymous re-plug and the claim (pickup-by-id).
|
|
1301
|
-
if (data.claim_url || data.entity_id) {
|
|
1302
|
-
ctx.state.lastAnonClaim = {
|
|
1303
|
-
token: freshOwnerToken,
|
|
1304
|
-
entity_id: data.entity_id ?? ctx.state.lastAnonClaim?.entity_id ?? null,
|
|
1305
|
-
url,
|
|
1306
|
-
};
|
|
1307
|
-
}
|
|
1308
|
-
const lines = [];
|
|
1309
|
-
if (isEdit) {
|
|
1310
|
-
lines.push(`Updated in place: ${url}`);
|
|
1311
|
-
lines.push("Same link, new content. The expiry timer (and owner_token) were refreshed.");
|
|
1312
|
-
} else {
|
|
1313
|
-
lines.push(ctx.edition === "web" ? `Your app is live: ${url}` : `Live: ${url}`);
|
|
1314
|
-
}
|
|
1315
|
-
if (data.claim_message) {
|
|
1316
|
-
lines.push(data.claim_message);
|
|
1317
|
-
} else if (data.claim_url) {
|
|
1318
|
-
lines.push("Sign in, then run grid_claim to keep it past the expiry window.");
|
|
1319
|
-
}
|
|
1320
|
-
if (data.entity_id && freshOwnerToken) {
|
|
1321
|
-
lines.push(
|
|
1322
|
-
`Re-plug handle (persist to update or claim this drop in a later session): entity_id=${data.entity_id}`,
|
|
1323
|
-
);
|
|
1324
|
-
}
|
|
1325
|
-
return {
|
|
1326
|
-
text: lines.join("\n"),
|
|
1327
|
-
structured: {
|
|
1328
|
-
url,
|
|
1329
|
-
status: isEdit ? "updated" : "created",
|
|
1330
|
-
...(data.entity_id ? { entity_id: data.entity_id } : {}),
|
|
1331
|
-
...(freshOwnerToken ? { owner_token: freshOwnerToken } : {}),
|
|
1332
|
-
},
|
|
1333
|
-
};
|
|
1334
|
-
}
|
|
1335
935
|
|
|
1336
936
|
// ── Consent-gated error reporting (Task 34 / 0.8.1) ──────────────────────────
|
|
1337
937
|
// Key names that look like they carry a secret. Mirrors the server's
|
|
@@ -1873,6 +1473,32 @@ export async function plugViaCliFallback(ctx, artifacts, deps = {}) {
|
|
|
1873
1473
|
}
|
|
1874
1474
|
}
|
|
1875
1475
|
|
|
1476
|
+
// After an authenticated WEB inspiration create, upgrade visibility to "link" so
|
|
1477
|
+
// the page is shareable and its preview renders without a sign-in wall — the
|
|
1478
|
+
// whole point of the hosted "share a link with a friend" flow. Best-effort: a
|
|
1479
|
+
// failure here never fails the publish (the user can always call grid_visibility).
|
|
1480
|
+
async function upgradeVisibilityToLink(ctx, entityId, orgSlug) {
|
|
1481
|
+
const token = await ctx.getToken();
|
|
1482
|
+
if (!token || !entityId) return false;
|
|
1483
|
+
try {
|
|
1484
|
+
const hdrs = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
|
|
1485
|
+
// Grid-native header + X-CloudGrid-Org alias (same value) during the soak.
|
|
1486
|
+
// Never send conflicting values → 400 GRID_HEADER_CONFLICT.
|
|
1487
|
+
if (orgSlug) {
|
|
1488
|
+
hdrs["X-CloudGrid-Grid"] = orgSlug;
|
|
1489
|
+
hdrs["X-CloudGrid-Org"] = orgSlug;
|
|
1490
|
+
}
|
|
1491
|
+
const res = await fetch(`${API_BASE}/api/v2/inspirations/${encodeURIComponent(entityId)}`, {
|
|
1492
|
+
method: "PATCH",
|
|
1493
|
+
headers: hdrs,
|
|
1494
|
+
body: JSON.stringify({ visibility: "link" }),
|
|
1495
|
+
});
|
|
1496
|
+
return res.ok;
|
|
1497
|
+
} catch {
|
|
1498
|
+
return false;
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1876
1502
|
/**
|
|
1877
1503
|
* The unified create/re-plug verb (spec v2 §3). Two intents on one tool, keyed
|
|
1878
1504
|
* by `target_entity_id`:
|
|
@@ -1889,6 +1515,8 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
1889
1515
|
const {
|
|
1890
1516
|
path: srcPath,
|
|
1891
1517
|
artifact_files,
|
|
1518
|
+
html,
|
|
1519
|
+
filename,
|
|
1892
1520
|
cloudgrid_yaml,
|
|
1893
1521
|
target_entity_id,
|
|
1894
1522
|
grid,
|
|
@@ -1897,19 +1525,35 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
1897
1525
|
owner_token,
|
|
1898
1526
|
} = input || {};
|
|
1899
1527
|
|
|
1900
|
-
// ── Source:
|
|
1901
|
-
|
|
1902
|
-
|
|
1528
|
+
// ── Source: exactly one of html | artifact_files | path ─────────────────────
|
|
1529
|
+
const hasHtml = typeof html === "string" && html.length > 0;
|
|
1530
|
+
const hasArtifacts = Array.isArray(artifact_files) && artifact_files.length > 0;
|
|
1531
|
+
const hasPath = Boolean(srcPath);
|
|
1532
|
+
if ((hasHtml ? 1 : 0) + (hasArtifacts ? 1 : 0) + (hasPath ? 1 : 0) > 1) {
|
|
1533
|
+
throw new Error(
|
|
1534
|
+
"Pass exactly one source: `html` (a single inline HTML document), `artifact_files` " +
|
|
1535
|
+
"(multiple inline files), or `path` (a local file/folder).",
|
|
1536
|
+
);
|
|
1903
1537
|
}
|
|
1904
|
-
if (ctx.edition === "web" &&
|
|
1538
|
+
if (ctx.edition === "web" && hasPath) {
|
|
1905
1539
|
throw new Error(
|
|
1906
|
-
"The hosted server cannot read local files — pass the source inline via `artifact_files`.",
|
|
1540
|
+
"The hosted server cannot read local files — pass the source inline via `html` or `artifact_files`.",
|
|
1907
1541
|
);
|
|
1908
1542
|
}
|
|
1909
1543
|
let artifacts;
|
|
1910
|
-
|
|
1544
|
+
// Set on the single-file `html` path so the auth-aware inline size cap can be
|
|
1545
|
+
// enforced once the anon-vs-authed wire is known (see below).
|
|
1546
|
+
let inlineHtmlBytes = null;
|
|
1547
|
+
if (hasHtml) {
|
|
1548
|
+
// The ergonomic single-file publish path (the old drop verb): one self-
|
|
1549
|
+
// contained HTML document → one index.html artifact, with the shared
|
|
1550
|
+
// hardening (base64 rescue, @-path/file-path rejection, fragment wrap).
|
|
1551
|
+
const art = htmlToArtifact(html, filename);
|
|
1552
|
+
inlineHtmlBytes = art.buffer.byteLength;
|
|
1553
|
+
artifacts = [art];
|
|
1554
|
+
} else if (hasPath) {
|
|
1911
1555
|
artifacts = collectPathArtifacts(srcPath);
|
|
1912
|
-
} else if (
|
|
1556
|
+
} else if (hasArtifacts) {
|
|
1913
1557
|
let total = 0;
|
|
1914
1558
|
artifacts = artifact_files.map((f) => {
|
|
1915
1559
|
if (!f || typeof f.path !== "string" || typeof f.content !== "string") {
|
|
@@ -1925,8 +1569,9 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
1925
1569
|
} else {
|
|
1926
1570
|
throw new Error(
|
|
1927
1571
|
ctx.edition === "web"
|
|
1928
|
-
? "Provide the source via `artifact_files`."
|
|
1929
|
-
: "Provide the source via `
|
|
1572
|
+
? "Provide the source via `html` (a single inline HTML document) or `artifact_files`."
|
|
1573
|
+
: "Provide the source via `html` (a single inline HTML document), `path` (a local file " +
|
|
1574
|
+
"or folder), or `artifact_files`.",
|
|
1930
1575
|
);
|
|
1931
1576
|
}
|
|
1932
1577
|
|
|
@@ -1970,7 +1615,30 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
1970
1615
|
);
|
|
1971
1616
|
}
|
|
1972
1617
|
|
|
1618
|
+
// Auth-aware inline size cap for the single-file `html` path (the old drop
|
|
1619
|
+
// cap). Anonymous inline pages stay capped at 2 MB; signed-in inline pages get
|
|
1620
|
+
// the larger AUTHED cap. `path` (read from disk) and `artifact_files` are
|
|
1621
|
+
// bounded by PLUG_MAX_TOTAL_BYTES instead, so they never set inlineHtmlBytes.
|
|
1622
|
+
if (inlineHtmlBytes != null) {
|
|
1623
|
+
if (useAnonWire) {
|
|
1624
|
+
if (inlineHtmlBytes > ANON_HTML_MAX_BYTES) {
|
|
1625
|
+
throw new Error(
|
|
1626
|
+
`This HTML is ${(inlineHtmlBytes / 1e6).toFixed(2)} MB. Anonymous drops are capped at 2 MB. ` +
|
|
1627
|
+
"Trim it, or sign in to publish larger.",
|
|
1628
|
+
);
|
|
1629
|
+
}
|
|
1630
|
+
} else if (inlineHtmlBytes > AUTHED_HTML_MAX_BYTES) {
|
|
1631
|
+
throw new Error(
|
|
1632
|
+
`This HTML is ${(inlineHtmlBytes / 1e6).toFixed(2)} MB. Inline drops are capped at ` +
|
|
1633
|
+
`${(AUTHED_HTML_MAX_BYTES / 1e6).toFixed(0)} MB. In the local edition pass \`path\` to the ` +
|
|
1634
|
+
".html file (read from disk — no inline size limit) instead of pasting it inline.",
|
|
1635
|
+
);
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1973
1639
|
const headers = {};
|
|
1640
|
+
// The authed target grid — also reused for the post-create visibility upgrade.
|
|
1641
|
+
let orgSlug = null;
|
|
1974
1642
|
if (!useAnonWire && authToken) {
|
|
1975
1643
|
headers["Authorization"] = `Bearer ${authToken}`;
|
|
1976
1644
|
// On create, `grid` picks where the entity lands. On re-plug the entity's
|
|
@@ -1978,7 +1646,7 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
1978
1646
|
// the caller's membership from this header and requires it to MATCH the
|
|
1979
1647
|
// entity's grid, so pass `grid` here too when the target lives outside the
|
|
1980
1648
|
// active grid.
|
|
1981
|
-
|
|
1649
|
+
orgSlug = grid || (await ctx.getActiveGrid());
|
|
1982
1650
|
// Grid-native header + X-CloudGrid-Org alias (same slug) during the soak.
|
|
1983
1651
|
if (orgSlug) {
|
|
1984
1652
|
headers["X-CloudGrid-Grid"] = orgSlug;
|
|
@@ -2012,7 +1680,10 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
2012
1680
|
// ── Wire assembly ───────────────────────────────────────────────────────────
|
|
2013
1681
|
const form = new FormData();
|
|
2014
1682
|
for (const a of artifacts) {
|
|
2015
|
-
|
|
1683
|
+
// Folder-walk / artifact_files parts ride as octet-stream (server sniffs by
|
|
1684
|
+
// name); the single-file `html` path carries text/html so a bare inline page
|
|
1685
|
+
// renders instead of downloading (the old drop behavior).
|
|
1686
|
+
form.append("artifact", new Blob([a.buffer], { type: a.type || "application/octet-stream" }), a.path);
|
|
2016
1687
|
}
|
|
2017
1688
|
if (isEdit) {
|
|
2018
1689
|
form.append("target_entity_id", target_entity_id);
|
|
@@ -2086,7 +1757,7 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
2086
1757
|
throw new Error(plugErrorMessage(res.status, code, msg, flags));
|
|
2087
1758
|
}
|
|
2088
1759
|
|
|
2089
|
-
// Anon-session cookie continuity
|
|
1760
|
+
// Anon-session cookie continuity.
|
|
2090
1761
|
const setCookies = res.headers.getSetCookie
|
|
2091
1762
|
? res.headers.getSetCookie()
|
|
2092
1763
|
: [res.headers.get("set-cookie")].filter(Boolean);
|
|
@@ -2107,7 +1778,7 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
2107
1778
|
}
|
|
2108
1779
|
}
|
|
2109
1780
|
|
|
2110
|
-
// Session continuity — the
|
|
1781
|
+
// Session continuity — remember the last plug for re-plug handles.
|
|
2111
1782
|
if (data.entity_id || url) {
|
|
2112
1783
|
ctx.state.lastDrop = {
|
|
2113
1784
|
entity_id: data.entity_id ?? null,
|
|
@@ -2146,6 +1817,20 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
2146
1817
|
// the terminal states get the live wording; anything still building points at
|
|
2147
1818
|
// the poll_url and grid_status.
|
|
2148
1819
|
const isBuilding = data.status === "building" || Boolean(data.poll_url);
|
|
1820
|
+
|
|
1821
|
+
// An authed CREATE of an INSPIRATION (the single-file `html` path, or a server-
|
|
1822
|
+
// detected inspiration — NOT a runtime app/agent, NOT a build in flight, NOT an
|
|
1823
|
+
// anon guest drop). This is the case that (on the hosted edition) must be made
|
|
1824
|
+
// link-visible so the shared URL renders without a sign-in wall.
|
|
1825
|
+
const detectedKind = data.detection?.kind;
|
|
1826
|
+
const isInspirationCreate =
|
|
1827
|
+
!isEdit &&
|
|
1828
|
+
!useAnonWire &&
|
|
1829
|
+
!isBuilding &&
|
|
1830
|
+
detectedKind !== "app" &&
|
|
1831
|
+
detectedKind !== "agent" &&
|
|
1832
|
+
(inlineHtmlBytes != null || detectedKind === "inspiration");
|
|
1833
|
+
|
|
2149
1834
|
const lines = [];
|
|
2150
1835
|
if (isBuilding) {
|
|
2151
1836
|
lines.push(
|
|
@@ -2158,8 +1843,14 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
2158
1843
|
? `Poll ${data.poll_url} or run grid_status until it is ready (trace ${data.trace_id ?? "n/a"}). Do not report it as live until then.`
|
|
2159
1844
|
: "Run grid_status until it is ready. Do not report it as live until then.",
|
|
2160
1845
|
);
|
|
1846
|
+
} else if (isEdit) {
|
|
1847
|
+
lines.push(`Updated in place: ${url}`);
|
|
1848
|
+
} else if (isInspirationCreate) {
|
|
1849
|
+
// Authed inspiration create — owned by the caller. Wording mirrors the drop verb.
|
|
1850
|
+
lines.push(ctx.edition === "web" ? `Your app is live: ${url}` : `Published to your org: ${url}`);
|
|
1851
|
+
if (ctx.edition !== "web") lines.push("Owned by you.");
|
|
2161
1852
|
} else {
|
|
2162
|
-
lines.push(
|
|
1853
|
+
lines.push(`Live: ${url}`);
|
|
2163
1854
|
}
|
|
2164
1855
|
if (data.entity_id) {
|
|
2165
1856
|
lines.push(
|
|
@@ -2170,6 +1861,22 @@ export async function runPlug(ctx, input, deps = {}) {
|
|
|
2170
1861
|
if (isEdit && useAnonWire) {
|
|
2171
1862
|
lines.push("The owner_token was re-minted for the reset expiry — replace the stored one.");
|
|
2172
1863
|
}
|
|
1864
|
+
|
|
1865
|
+
// Hosted "share a link with a friend": a signed-in WEB inspiration create is
|
|
1866
|
+
// upgraded to "link" visibility so its preview renders without a sign-in wall.
|
|
1867
|
+
// Scoped EXACTLY to web + create + inspiration — never runtime apps/agents,
|
|
1868
|
+
// edits, anon drops, or the local edition (which stays owned/org-scoped).
|
|
1869
|
+
if (isInspirationCreate && ctx.edition === "web" && data.entity_id) {
|
|
1870
|
+
await upgradeVisibilityToLink(ctx, data.entity_id, orgSlug);
|
|
1871
|
+
const vis = "link";
|
|
1872
|
+
structured.console_url = CONSOLE_URL;
|
|
1873
|
+
structured.current_visibility = vis;
|
|
1874
|
+
structured.visibility_options = Object.entries(VISIBILITY_LABELS).map(([v, l]) => ({ value: v, label: l }));
|
|
1875
|
+
lines.push(`See and manage all your apps in your grid: ${CONSOLE_URL}`);
|
|
1876
|
+
lines.push(
|
|
1877
|
+
`Visible to: ${VISIBILITY_LABELS[vis]}. Want to restrict access? I can set it to only you or your org.`,
|
|
1878
|
+
);
|
|
1879
|
+
}
|
|
2173
1880
|
return { text: lines.join("\n"), structured };
|
|
2174
1881
|
}
|
|
2175
1882
|
|
|
@@ -2521,144 +2228,6 @@ export function registerTools(server, ctx) {
|
|
|
2521
2228
|
|
|
2522
2229
|
// ── Direct-API tools (both editions) ──────────────────────────────────────
|
|
2523
2230
|
|
|
2524
|
-
// Drop — both editions, but the input schema is edition-aware: the web
|
|
2525
|
-
// edition removes `path` (the cloud server cannot read local files) and
|
|
2526
|
-
// strengthens `html` so the model always pastes the full document inline.
|
|
2527
|
-
const dropReplugParams = {
|
|
2528
|
-
fresh: z.boolean().optional().describe("Force a brand-new drop (new URL) even if you already dropped in this session. Default: a re-drop updates the session's drop in place — same URL, new content."),
|
|
2529
|
-
entity_id: z.string().optional().describe("Re-plug a SPECIFIC entity by id (the durable handle a previous drop returned) — updates it in place, same URL. Defaults to this session's last drop."),
|
|
2530
|
-
owner_token: z.string().optional().describe("The owner token of an anonymously-created drop (returned when it was created; refreshed on every anonymous edit). Needed to re-plug or claim it from a new session."),
|
|
2531
|
-
};
|
|
2532
|
-
const dropInputSchema = ctx.edition === "web"
|
|
2533
|
-
? {
|
|
2534
|
-
html: z.string().optional().describe(
|
|
2535
|
-
"The COMPLETE HTML document to publish — paste the full file contents here (not a path). " +
|
|
2536
|
-
"For a game, include all HTML/CSS/JS inline so it runs standalone. " +
|
|
2537
|
-
"A fragment is wrapped into a full document automatically.",
|
|
2538
|
-
),
|
|
2539
|
-
filename: z.string().optional().describe("Filename to present. Defaults to index.html for inline HTML."),
|
|
2540
|
-
anonymous: z.boolean().optional().describe("Force an anonymous drop even if the user is signed in."),
|
|
2541
|
-
grid: z.string().optional().describe("Leave unset; the tool will ask the user which grid to publish into. Only set this after the user picks from the list the tool returns."),
|
|
2542
|
-
org: z.string().optional().describe("Deprecated alias for `grid` (kept for compatibility). Prefer `grid`."),
|
|
2543
|
-
...dropReplugParams,
|
|
2544
|
-
}
|
|
2545
|
-
: {
|
|
2546
|
-
html: z.string().optional().describe("Inline HTML to publish (a small fragment is wrapped into a full document). Pass the RAW HTML — do NOT base64-encode it, and do NOT pass an `@`-prefixed path or a file path here. For a large or image-heavy document, use `path` instead."),
|
|
2547
|
-
path: z.string().optional().describe("Path to a local .html file to upload instead of inline HTML — read from disk with no inline size limit. Preferred for large or image-heavy documents. There is no `artifact_files` parameter."),
|
|
2548
|
-
filename: z.string().optional().describe("Filename to present. Defaults to index.html for inline HTML."),
|
|
2549
|
-
anonymous: z.boolean().optional().describe("Force an anonymous drop even if the user is signed in."),
|
|
2550
|
-
grid: z.string().optional().describe("Leave unset; the tool will ask the user which grid to publish into. Only set this after the user picks from the list the tool returns."),
|
|
2551
|
-
org: z.string().optional().describe("Deprecated alias for `grid` (kept for compatibility). Prefer `grid`."),
|
|
2552
|
-
...dropReplugParams,
|
|
2553
|
-
};
|
|
2554
|
-
|
|
2555
|
-
reg(
|
|
2556
|
-
"grid_drop",
|
|
2557
|
-
{
|
|
2558
|
-
description: "Publish an HTML page or file to CloudGrid and get a public shareable URL. Use when the user wants to share, publish, send, or 'deploy' an artifact, or wants a link to send a friend. Re-drops in the same session UPDATE THE SAME entity in place — same link, new content, expiry reset (pass fresh: true to force a new drop, or entity_id to target a specific earlier drop). If signed in, it publishes into the user's grid as an owned inspiration; if not, it drops anonymously into the Guest Grid, claimable later — the result includes an entity_id + owner_token to persist as the re-plug/claim handle for later sessions. Drops expire per the platform default (7 days) unless claimed/owned; every in-place edit resets the timer. If you want to edit an existing drop but no longer have its HTML, call grid_source first to retrieve it, then re-plug with target_entity_id. For a large or image-heavy document in the local edition, pass `path` to the .html file (read from disk — no inline size limit); do NOT base64-encode the content and do NOT pass an `@`-prefixed path or a file path as `html`. There is no `artifact_files` parameter. Calls POST /api/v2/plug directly.",
|
|
2559
|
-
inputSchema: dropInputSchema,
|
|
2560
|
-
outputSchema: {
|
|
2561
|
-
url: z.string().optional().describe("The public URL of the drop (stable across re-drops of the same entity)."),
|
|
2562
|
-
status: z.enum(["created", "updated", "unchanged"]).optional().describe("What happened to the drop."),
|
|
2563
|
-
entity_id: z.string().optional().describe("The entity's durable id — pass back as entity_id to update it in place later."),
|
|
2564
|
-
owner_token: z.string().optional().describe("Anonymous drops only: the bearer owner token for later re-plug/claim. Refreshed on every anonymous edit — always persist the newest one."),
|
|
2565
|
-
owned_by: z.string().optional().describe("Ownership class, e.g. 'authenticated'."),
|
|
2566
|
-
expires_at: z.string().optional().describe("Expiry timestamp, if any."),
|
|
2567
|
-
console_url: z.string().optional().describe("URL to manage all apps in the grid."),
|
|
2568
|
-
current_visibility: z.string().optional().describe("Current visibility of the drop."),
|
|
2569
|
-
visibility_options: z.array(z.object({
|
|
2570
|
-
value: z.string().describe("Visibility value to pass to grid_visibility."),
|
|
2571
|
-
label: z.string().describe("Human-readable label."),
|
|
2572
|
-
})).optional().describe("Available visibility levels."),
|
|
2573
|
-
needs_grid: z.boolean().optional().describe("True when the user must choose a grid before dropping."),
|
|
2574
|
-
needs_org: z.boolean().optional().describe("Deprecated alias of needs_grid (kept so the org-picker widget keeps working)."),
|
|
2575
|
-
grids: z.array(z.object({
|
|
2576
|
-
slug: z.string().describe("Grid slug to pass as the grid parameter."),
|
|
2577
|
-
name: z.string().describe("Human-readable grid name."),
|
|
2578
|
-
role: z.string().describe("User's role in the grid."),
|
|
2579
|
-
is_active: z.boolean().optional().describe("True if this is the user's currently active grid."),
|
|
2580
|
-
})).optional().describe("The user's grids, when a grid choice is needed."),
|
|
2581
|
-
orgs: z.array(z.object({
|
|
2582
|
-
slug: z.string().describe("Grid slug to pass as the grid parameter."),
|
|
2583
|
-
name: z.string().describe("Human-readable grid name."),
|
|
2584
|
-
role: z.string().describe("User's role in the grid."),
|
|
2585
|
-
is_active: z.boolean().optional().describe("True if this is the user's currently active grid."),
|
|
2586
|
-
})).optional().describe("Deprecated alias of grids (kept so the org-picker widget keeps working)."),
|
|
2587
|
-
needs_sign_in: z.boolean().optional().describe("True when sign-in is needed before dropping."),
|
|
2588
|
-
login_url: z.string().optional().describe("Sign-in URL when authentication is needed."),
|
|
2589
|
-
},
|
|
2590
|
-
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
2591
|
-
...(ctx.edition === "web" && APPS_WIDGETS_ENABLED ? {
|
|
2592
|
-
_meta: {
|
|
2593
|
-
ui: { resourceUri: LIVE_RESULT_URI, csp: WIDGET_CSP },
|
|
2594
|
-
"openai/outputTemplate": LIVE_RESULT_URI,
|
|
2595
|
-
},
|
|
2596
|
-
} : {}),
|
|
2597
|
-
},
|
|
2598
|
-
async (input) => {
|
|
2599
|
-
try {
|
|
2600
|
-
// Web edition: reject `path` early — the hosted server cannot read
|
|
2601
|
-
// local files. The schema already omits it, but a model with a
|
|
2602
|
-
// cached tool description might still send one.
|
|
2603
|
-
if (ctx.edition === "web" && input?.path) {
|
|
2604
|
-
return fail(
|
|
2605
|
-
"The hosted server cannot read local files — pass the full document as `html` instead of a `path`.",
|
|
2606
|
-
);
|
|
2607
|
-
}
|
|
2608
|
-
|
|
2609
|
-
// Web edition: sign-in guidance when unauthenticated.
|
|
2610
|
-
if (ctx.edition === "web" && input?.anonymous !== true) {
|
|
2611
|
-
const token = await ctx.getToken();
|
|
2612
|
-
if (!token) {
|
|
2613
|
-
const url = buildLoginUrl(newLoginCode());
|
|
2614
|
-
return okResult({
|
|
2615
|
-
text: `Sign in to publish to your org.\n${url}`,
|
|
2616
|
-
structured: { needs_sign_in: true, login_url: url },
|
|
2617
|
-
});
|
|
2618
|
-
}
|
|
2619
|
-
}
|
|
2620
|
-
|
|
2621
|
-
// Stateless grid disambiguation — both editions, when authenticated.
|
|
2622
|
-
// No dependency on prior-call state so it works even when the client
|
|
2623
|
-
// reconnects on every tool call (ChatGPT Apps SDK behaviour). `grid` is
|
|
2624
|
-
// accepted as an alias for `org` (Gilad's rename); `org` still works.
|
|
2625
|
-
if (input?.anonymous !== true) {
|
|
2626
|
-
const token = await ctx.getToken();
|
|
2627
|
-
if (token) {
|
|
2628
|
-
const suppliedGrid = input?.grid || input?.org;
|
|
2629
|
-
const decision = await resolveGridOrAsk(ctx, {
|
|
2630
|
-
token,
|
|
2631
|
-
suppliedGrid,
|
|
2632
|
-
edition: ctx.edition,
|
|
2633
|
-
});
|
|
2634
|
-
if (decision.picker) {
|
|
2635
|
-
return okResult(decision.picker);
|
|
2636
|
-
}
|
|
2637
|
-
if (decision.single) {
|
|
2638
|
-
if (!decision.single.render_ready) {
|
|
2639
|
-
// Single grid but not set up — tell the agent so it can warn
|
|
2640
|
-
// the user and offer anonymous drop as fallback. (drop-specific:
|
|
2641
|
-
// drop blocks here; plug proceeds with a warning.)
|
|
2642
|
-
const annotated = [{ ...decision.single, render_ready: false }];
|
|
2643
|
-
return okResult({
|
|
2644
|
-
text: `Your only grid "${decision.single.slug}" isn't fully set up yet — pages published there may not load. You can drop anonymously (set anonymous: true) or wait until provisioning completes, then re-run.`,
|
|
2645
|
-
structured: { needs_grid: true, needs_org: true, grid_not_ready: true, org_not_ready: true, grids: annotated, orgs: annotated },
|
|
2646
|
-
});
|
|
2647
|
-
}
|
|
2648
|
-
input = { ...(input || {}), org: decision.single.slug };
|
|
2649
|
-
} else if (decision.grid) {
|
|
2650
|
-
// Supplied grid matched — normalize onto `org` for runDrop.
|
|
2651
|
-
input = { ...(input || {}), org: decision.grid };
|
|
2652
|
-
}
|
|
2653
|
-
}
|
|
2654
|
-
}
|
|
2655
|
-
return okResult(await runDrop(ctx, input || {}));
|
|
2656
|
-
} catch (err) {
|
|
2657
|
-
return fail(err.message);
|
|
2658
|
-
}
|
|
2659
|
-
},
|
|
2660
|
-
);
|
|
2661
|
-
|
|
2662
2231
|
// Claim — both editions.
|
|
2663
2232
|
reg(
|
|
2664
2233
|
"grid_claim",
|
|
@@ -2689,13 +2258,21 @@ export function registerTools(server, ctx) {
|
|
|
2689
2258
|
// CLI-wrapping grid_plug: create and re-plug are one verb, keyed by
|
|
2690
2259
|
// target_entity_id, and work identically on the hosted transport.
|
|
2691
2260
|
const plugInputSchema = {
|
|
2261
|
+
html: z.string().optional().describe(
|
|
2262
|
+
"A single self-contained HTML document to publish as an inspiration — the fast single-file path. " +
|
|
2263
|
+
"Pass the COMPLETE raw HTML inline (CSS+JS inline, images/fonts as data: URIs); a small fragment is " +
|
|
2264
|
+
"wrapped into a full document. Do NOT base64-encode it, and do NOT pass an `@`-prefixed path or a " +
|
|
2265
|
+
"file path here. Mutually exclusive with `path` and `artifact_files`. Materialized as one index.html " +
|
|
2266
|
+
"and published instantly on any edition, anonymously (claimable) or into your grid when signed in.",
|
|
2267
|
+
),
|
|
2268
|
+
filename: z.string().optional().describe("Filename for the single-file `html` path. Defaults to index.html."),
|
|
2692
2269
|
...(ctx.edition === "web"
|
|
2693
2270
|
? {}
|
|
2694
2271
|
: {
|
|
2695
2272
|
path: z.string().optional().describe(
|
|
2696
2273
|
"Local edition: path to the entity folder (or a single file) to upload. A folder is read " +
|
|
2697
2274
|
"recursively, honoring .gitignore/.cloudgridignore (plus .git/node_modules always skipped). " +
|
|
2698
|
-
"Mutually exclusive with artifact_files
|
|
2275
|
+
"Mutually exclusive with `html` and `artifact_files`.",
|
|
2699
2276
|
),
|
|
2700
2277
|
}),
|
|
2701
2278
|
artifact_files: z.array(z.object({
|
|
@@ -2703,8 +2280,10 @@ export function registerTools(server, ctx) {
|
|
|
2703
2280
|
content: z.string().describe("File content. Base64 when encoding is base64, otherwise UTF-8 text."),
|
|
2704
2281
|
encoding: z.enum(["utf8", "base64"]).optional().describe("Content encoding. Default utf8."),
|
|
2705
2282
|
})).optional().describe(
|
|
2706
|
-
"The source inline, one entry per file — for hosted/no-filesystem transports (
|
|
2707
|
-
|
|
2283
|
+
"The source inline, one entry per file — for hosted/no-filesystem transports (a multi-file app). " +
|
|
2284
|
+
"For a single HTML page prefer `html`." +
|
|
2285
|
+
(ctx.edition === "web" ? "" : " Prefer `path` on the local edition.") +
|
|
2286
|
+
" Mutually exclusive with `html` and `path`.",
|
|
2708
2287
|
),
|
|
2709
2288
|
cloudgrid_yaml: z.string().optional().describe(
|
|
2710
2289
|
"Inline cloudgrid.yaml (the entity manifest). Optional — server auto-detection applies when omitted. " +
|
|
@@ -2738,17 +2317,21 @@ export function registerTools(server, ctx) {
|
|
|
2738
2317
|
"grid_plug",
|
|
2739
2318
|
{
|
|
2740
2319
|
description:
|
|
2741
|
-
"
|
|
2320
|
+
"Deploy or share a creation on CloudGrid and get a public URL — the ONE create/re-plug verb " +
|
|
2321
|
+
"(POST /api/v2/plug). Use it whenever the user wants to share, publish, send, ship, 'deploy', go " +
|
|
2322
|
+
"live, or get a link to send a friend — for a single HTML page OR a full app. " +
|
|
2323
|
+
"Sources (pass exactly one): `html` — a single self-contained HTML page (the fast path: instant, any " +
|
|
2324
|
+
"edition, anonymous+claimable or into your grid when signed in); " +
|
|
2325
|
+
(ctx.edition === "web"
|
|
2326
|
+
? "or `artifact_files` — a multi-file app inline. "
|
|
2327
|
+
: "`path` — a local folder/file (a multi-file app); or `artifact_files` — inline files. ") +
|
|
2742
2328
|
"No target_entity_id → CREATE a new entity (inspiration/app/agent, auto-detected or hinted); " +
|
|
2743
2329
|
"with target_entity_id → RE-PLUG: update the SAME entity in place — same entity_id, same URL, same " +
|
|
2744
2330
|
"deploy history, expiry reset. The returned entity_id + url are the durable re-plug handle; persist " +
|
|
2745
|
-
"them (plus owner_token for anonymous
|
|
2746
|
-
(
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
" Note: in-place re-plug currently supports inspirations (HTML/static drops); to rebuild a deployed " +
|
|
2750
|
-
"app/agent, use the CloudGrid CLI (`cloudgrid plug`) in its linked folder." +
|
|
2751
|
-
" If you want to edit an existing drop but no longer have its HTML, call grid_source first to " +
|
|
2331
|
+
"them (plus owner_token for anonymous pages) to update the entity in later sessions. " +
|
|
2332
|
+
"Note: in-place re-plug currently supports inspirations (HTML/static pages); to rebuild a deployed " +
|
|
2333
|
+
"app/agent, use the CloudGrid CLI (`cloudgrid plug`) in its linked folder. " +
|
|
2334
|
+
"If you want to edit an existing page but no longer have its HTML, call grid_source first to " +
|
|
2752
2335
|
"retrieve it, then re-plug with target_entity_id.",
|
|
2753
2336
|
inputSchema: plugInputSchema,
|
|
2754
2337
|
outputSchema: {
|
|
@@ -2761,12 +2344,24 @@ export function registerTools(server, ctx) {
|
|
|
2761
2344
|
claim_url: z.string().optional().describe("Anon create only: sign-in link to claim ownership."),
|
|
2762
2345
|
claim_message: z.string().optional().describe("Anon create only: the claim nudge to relay."),
|
|
2763
2346
|
owner_token: z.string().optional().describe("Anonymous drops: the bearer owner token (re-plug + claim). Re-minted on every anonymous edit — persist the newest."),
|
|
2347
|
+
console_url: z.string().optional().describe("Web authed inspiration create: URL to manage all apps in the grid."),
|
|
2348
|
+
current_visibility: z.string().optional().describe("Web authed inspiration create: the visibility set after publish (link)."),
|
|
2349
|
+
visibility_options: z.array(z.object({
|
|
2350
|
+
value: z.string().describe("Visibility value to pass to grid_visibility."),
|
|
2351
|
+
label: z.string().describe("Human-readable label."),
|
|
2352
|
+
})).optional().describe("Web authed inspiration create: available visibility levels."),
|
|
2764
2353
|
},
|
|
2765
2354
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
2355
|
+
...(ctx.edition === "web" && APPS_WIDGETS_ENABLED ? {
|
|
2356
|
+
_meta: {
|
|
2357
|
+
ui: { resourceUri: LIVE_RESULT_URI, csp: WIDGET_CSP },
|
|
2358
|
+
"openai/outputTemplate": LIVE_RESULT_URI,
|
|
2359
|
+
},
|
|
2360
|
+
} : {}),
|
|
2766
2361
|
},
|
|
2767
2362
|
async (input) => {
|
|
2768
2363
|
try {
|
|
2769
|
-
// Grid-picker
|
|
2364
|
+
// Grid-picker: a signed-in user with >1 grid is
|
|
2770
2365
|
// ASKED which grid to publish to on every CREATE. Only for authed creates
|
|
2771
2366
|
// (no target_entity_id, not anon). Edits NEVER ask — the grid is fixed by
|
|
2772
2367
|
// the entity. Anon proceeds as a Guest-Grid drop. Explicit valid grid
|
|
@@ -3174,7 +2769,7 @@ export function registerTools(server, ctx) {
|
|
|
3174
2769
|
.describe("Short summary of what failed (required). Do not paste the whole conversation here."),
|
|
3175
2770
|
context: z
|
|
3176
2771
|
.object({
|
|
3177
|
-
tool: z.string().optional().describe("The CloudGrid tool that failed, e.g.
|
|
2772
|
+
tool: z.string().optional().describe("The CloudGrid tool that failed, e.g. grid_plug."),
|
|
3178
2773
|
inputs: z.any().optional().describe("The failing inputs (e.g. the HTML/args). Keep it minimal; secrets are scrubbed."),
|
|
3179
2774
|
grid: z.string().optional().describe("The grid/org slug involved, if any."),
|
|
3180
2775
|
original_request: z.string().optional().describe("What the user asked for, in one line."),
|