@cloudgrid-io/mcp 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/package.json +2 -1
- package/src/corpus/cookbook.md +22 -4
- package/src/corpus/examples/dashboard/index.html +121 -0
- package/src/corpus/examples/landing-page/index.html +98 -0
- package/src/corpus/examples/report/index.html +112 -0
- package/src/corpus/examples/web-app/index.html +125 -0
- package/src/corpus/install.md +5 -1
- package/src/corpus/skill-claim.md +9 -3
- package/src/corpus/skill-drop.md +59 -24
- package/src/corpus/skills-readme.md +14 -7
- package/src/corpus/templates/dashboard/index.html +118 -0
- package/src/corpus/templates/landing-page/index.html +97 -0
- package/src/corpus/templates/report/index.html +93 -0
- package/src/corpus/templates/web-app/index.html +99 -0
- package/src/corpus/usage.md +1 -1
- package/src/corpus/workflows/dashboard.md +33 -0
- package/src/corpus/workflows/landing-page.md +31 -0
- package/src/corpus/workflows/presentation.md +2 -0
- package/src/corpus/workflows/report.md +33 -0
- package/src/corpus/workflows/web-app.md +33 -0
- package/src/tools.js +810 -81
package/src/tools.js
CHANGED
|
@@ -200,7 +200,8 @@ const MIN_CLI_VERSION = "0.10.1";
|
|
|
200
200
|
// The drift-guard test imports this and asserts every verb exists in `cloudgrid --help`.
|
|
201
201
|
export const CLI_TOOL_VERBS = {
|
|
202
202
|
cloudgrid_init: ["init"],
|
|
203
|
-
cloudgrid_plug:
|
|
203
|
+
// cloudgrid_plug is NOT here: gridctl_plug is now a direct-API tool
|
|
204
|
+
// (POST /api/v2/plug, spec v2 §3), not a CLI wrapper.
|
|
204
205
|
cloudgrid_logs: ["logs"],
|
|
205
206
|
cloudgrid_share: ["visibility"],
|
|
206
207
|
cloudgrid_feedback: ["feedback"],
|
|
@@ -466,10 +467,14 @@ function looksLikeFullHtml(s) {
|
|
|
466
467
|
// the public URL hangs off (https://guest.cloudgrid.io/<slug>).
|
|
467
468
|
const GUEST_ORG_SLUG = "guest";
|
|
468
469
|
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
//
|
|
470
|
+
// FALLBACK-ONLY URL composition. Since the unified plug contract (spec v2 /
|
|
471
|
+
// the unified plug spec), `/api/v2/plug` returns a server-composed canonical `url` on every
|
|
472
|
+
// path (create + edit, anon + authed) — flat-arch-aware per grid, matching the
|
|
473
|
+
// host that actually serves. ALWAYS prefer `data.url` (see resolvePlugUrl);
|
|
474
|
+
// this client-side derivation exists only for the rare response where `url`
|
|
475
|
+
// came back empty (the server composes it best-effort). It mirrors the legacy
|
|
476
|
+
// `entityUrl()` rules and is WRONG on flat-arch grids (which serve
|
|
477
|
+
// `<slug>--<grid>.cloudgrid.io`), so it must never be the primary source:
|
|
473
478
|
// - inspiration (HTML drops): path-based at the org apex
|
|
474
479
|
// https://<grid>.cloudgrid.io/<slug>
|
|
475
480
|
// - runtime (app/agent): subdomain
|
|
@@ -488,7 +493,18 @@ function composePlugUrl(data) {
|
|
|
488
493
|
return `https://${grid}.cloudgrid.io/${slug}`;
|
|
489
494
|
}
|
|
490
495
|
|
|
491
|
-
|
|
496
|
+
// The public URL of a `/plug` response: the server-composed `url` verbatim
|
|
497
|
+
// (canonical, flat-arch-aware — the unified plug spec), falling back to client-side composition
|
|
498
|
+
// ONLY when the server left it empty (its composition is best-effort).
|
|
499
|
+
export function resolvePlugUrl(data) {
|
|
500
|
+
if (typeof data?.url === "string" && data.url.length > 0) return data.url;
|
|
501
|
+
return composePlugUrl(data);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export async function runDrop(
|
|
505
|
+
ctx,
|
|
506
|
+
{ html, path: filePath, filename, anonymous, org, fresh, entity_id, owner_token },
|
|
507
|
+
) {
|
|
492
508
|
// Defensive: the web edition schema excludes `path`, but if a model still
|
|
493
509
|
// passes one (e.g. from a cached tool description), reject early with a
|
|
494
510
|
// clear explanation.
|
|
@@ -526,44 +542,103 @@ async function runDrop(ctx, { html, path: filePath, filename, anonymous, org, fr
|
|
|
526
542
|
throw new Error("Provide either `html` (inline content) or `path` (a local file).");
|
|
527
543
|
}
|
|
528
544
|
|
|
529
|
-
|
|
545
|
+
// The caller's authed identity (unless they force an anonymous drop).
|
|
546
|
+
let authToken = null;
|
|
530
547
|
let orgSlug = null;
|
|
531
548
|
if (anonymous !== true) {
|
|
532
|
-
|
|
533
|
-
if (
|
|
534
|
-
headers["Authorization"] = `Bearer ${token}`;
|
|
549
|
+
authToken = await ctx.getToken();
|
|
550
|
+
if (authToken) {
|
|
535
551
|
orgSlug = org || (await ctx.getActiveOrg());
|
|
536
|
-
if (orgSlug) headers["X-CloudGrid-Org"] = orgSlug;
|
|
537
552
|
}
|
|
538
553
|
}
|
|
539
554
|
|
|
540
|
-
//
|
|
541
|
-
//
|
|
542
|
-
|
|
555
|
+
// ── Re-plug targeting (unified plug contract / spec v2) ──────
|
|
556
|
+
// A `target_entity_id` on the wire UPDATES THAT SAME ENTITY in place — same
|
|
557
|
+
// entity_id, same slug, same URL, deploy history preserved. Absent → create.
|
|
558
|
+
// The session remembers the last drop, so a re-drop updates it by default;
|
|
559
|
+
// `fresh: true` forces a create; an explicit `entity_id` targets any earlier
|
|
560
|
+
// drop (the durable re-plug handle a stateless caller persisted).
|
|
561
|
+
if (fresh === true && entity_id) {
|
|
562
|
+
throw new Error("`fresh: true` forces a new drop — do not pass `entity_id` with it.");
|
|
563
|
+
}
|
|
564
|
+
const sessionDrop = ctx.state.lastDrop;
|
|
565
|
+
let targetId = null;
|
|
566
|
+
let ownerToken = typeof owner_token === "string" && owner_token.length > 0 ? owner_token : null;
|
|
567
|
+
if (fresh !== true) {
|
|
568
|
+
targetId = entity_id || sessionDrop?.entity_id || null;
|
|
569
|
+
// An anon-minted drop is edited with its OWNER TOKEN (a bearer capability,
|
|
570
|
+
// the anon owner-token contract). Recover it from session state when not passed.
|
|
571
|
+
if (targetId && !ownerToken) {
|
|
572
|
+
if (sessionDrop?.entity_id === targetId && sessionDrop.owner_token) {
|
|
573
|
+
ownerToken = sessionDrop.owner_token;
|
|
574
|
+
} else if (ctx.state.lastAnonClaim?.entity_id === targetId && ctx.state.lastAnonClaim.token) {
|
|
575
|
+
ownerToken = ctx.state.lastAnonClaim.token;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Pick the edit wire. An anon-owned (Guest-Grid, unclaimed) drop is edited via
|
|
581
|
+
// the owner-token wire EVEN IF the caller is signed in — the entity still lives
|
|
582
|
+
// in the Guest Grid, so an authed edit of it would 404. An authed edit targets
|
|
583
|
+
// an entity the caller owns in their grid.
|
|
584
|
+
let mode = "create"; // "create" | "authed-edit" | "anon-edit"
|
|
585
|
+
if (targetId) {
|
|
586
|
+
if (ownerToken) {
|
|
587
|
+
mode = "anon-edit";
|
|
588
|
+
} else if (authToken) {
|
|
589
|
+
mode = "authed-edit";
|
|
590
|
+
} else if (entity_id) {
|
|
591
|
+
throw new Error(
|
|
592
|
+
"Re-plugging a drop anonymously needs its owner_token (returned when it was created). " +
|
|
593
|
+
"Pass owner_token, sign in if you own it, or pass fresh: true to publish a new drop.",
|
|
594
|
+
);
|
|
595
|
+
} else {
|
|
596
|
+
// Session target, but no way to authorize an edit — fall back to create.
|
|
597
|
+
targetId = null;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
const isAnonymousWire = mode === "anon-edit" || !authToken;
|
|
601
|
+
|
|
602
|
+
const headers = {};
|
|
603
|
+
if (!isAnonymousWire) {
|
|
604
|
+
headers["Authorization"] = `Bearer ${authToken}`;
|
|
605
|
+
if (orgSlug) headers["X-CloudGrid-Org"] = orgSlug;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Hosted server: attach the trusted-server credential on EVERY anonymous call
|
|
609
|
+
// (creates AND owner-token edits — anon edits consume the same daily anon cap,
|
|
610
|
+
// re-keyed per end user). Falls back gracefully server-side if absent. Only
|
|
611
|
+
// the web edition sets ctx.trustedServer.
|
|
612
|
+
if (isAnonymousWire && ctx.trustedServer?.secret && ctx.trustedServer?.endUserId) {
|
|
543
613
|
headers["X-CloudGrid-Trusted-Server-Auth"] = ctx.trustedServer.secret;
|
|
544
614
|
headers["X-CloudGrid-Trusted-Server-End-User"] = ctx.trustedServer.endUserId;
|
|
545
615
|
}
|
|
546
616
|
|
|
547
|
-
const isAnonymousCall = !headers["Authorization"];
|
|
548
|
-
|
|
549
617
|
// Ownership continuity: replay the platform's anon-session cookie across drops in
|
|
550
618
|
// this session, so cookie-class callers can redrop (and claim) what they dropped.
|
|
551
|
-
if (
|
|
619
|
+
if (isAnonymousWire && ctx.state.anonCookie) {
|
|
552
620
|
headers["Cookie"] = ctx.state.anonCookie;
|
|
553
621
|
}
|
|
554
622
|
|
|
555
623
|
const form = new FormData();
|
|
556
|
-
// The unified `/plug` create path is the same orchestrator `/drop/auto` used,
|
|
557
|
-
// but it is CREATE-ONLY for the drop flow: an anonymous caller naming a
|
|
558
|
-
// `target_entity_id` is rejected 401, and the authed create path mints a NEW
|
|
559
|
-
// entity. So `/plug` has NO in-place redrop / `previous_id` concept — every
|
|
560
|
-
// drop creates a fresh entity (new URL). `fresh` is accepted for backward
|
|
561
|
-
// compatibility but is now a no-op. See PR notes: the in-place-update +
|
|
562
|
-
// `202 unchanged` no-op semantics from `/drop/auto` are gone with M7.
|
|
563
|
-
void fresh;
|
|
564
624
|
// The artifact part name is unchanged from `/drop/auto` (`artifact`); the plug
|
|
565
|
-
// create path
|
|
625
|
+
// create path — and the inspiration edit paths — treat every
|
|
626
|
+
// non-`cloudgrid.yaml` part as raw artifact bytes.
|
|
566
627
|
form.append("artifact", new Blob([bytes], { type }), name);
|
|
628
|
+
if (mode !== "create") {
|
|
629
|
+
form.append("target_entity_id", targetId);
|
|
630
|
+
}
|
|
631
|
+
if (mode === "anon-edit") {
|
|
632
|
+
// Anon owner-token contract: ownership is proven by HOLDING the token (a form
|
|
633
|
+
// field, NOT an Authorization header). The response re-mints it.
|
|
634
|
+
form.append("owner_token", ownerToken);
|
|
635
|
+
}
|
|
636
|
+
if (mode === "authed-edit") {
|
|
637
|
+
// The authed update path requires a `cloudgrid.yaml` part on the wire
|
|
638
|
+
// (materializePlugTarball). An inspiration edit ignores its content, so an
|
|
639
|
+
// empty part satisfies the contract without changing the entity's config.
|
|
640
|
+
form.append("cloudgrid.yaml", new Blob([""], { type: "text/plain" }), "cloudgrid.yaml");
|
|
641
|
+
}
|
|
567
642
|
// `/plug` resolves the authed org from the `X-CloudGrid-Org` header (set
|
|
568
643
|
// above), not a form field — so `org_slug` is no longer sent.
|
|
569
644
|
|
|
@@ -584,6 +659,21 @@ async function runDrop(ctx, { html, path: filePath, filename, anonymous, org, fr
|
|
|
584
659
|
if (!res.ok) {
|
|
585
660
|
const msg = data?.error?.message || data?.message || raw || `HTTP ${res.status}`;
|
|
586
661
|
const hint = data?.error?.details?.[0]?.hint;
|
|
662
|
+
// An explicit edit NEVER silently creates — surface the reason clearly.
|
|
663
|
+
if (mode !== "create" && res.status === 409) {
|
|
664
|
+
throw new Error(
|
|
665
|
+
`Re-plug rejected (HTTP 409): ${msg} ` +
|
|
666
|
+
"The drop can no longer be edited in place (expired, archived, claimed, or a deploy is running). " +
|
|
667
|
+
"Pass fresh: true to publish a new drop instead.",
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
if (mode === "anon-edit" && res.status === 401) {
|
|
671
|
+
throw new Error(
|
|
672
|
+
`Re-plug rejected (HTTP 401): ${msg} ` +
|
|
673
|
+
"The owner_token did not authorize this entity (wrong entity, expired with the drop, or already claimed). " +
|
|
674
|
+
"Pass fresh: true to publish a new drop instead.",
|
|
675
|
+
);
|
|
676
|
+
}
|
|
587
677
|
throw new Error(`Drop failed (HTTP ${res.status}): ${msg}${hint ? ` ${hint}` : ""}`);
|
|
588
678
|
}
|
|
589
679
|
|
|
@@ -597,33 +687,59 @@ async function runDrop(ctx, { html, path: filePath, filename, anonymous, org, fr
|
|
|
597
687
|
.find((c) => c.startsWith("cg_anon_session="));
|
|
598
688
|
if (anonCookie) ctx.state.anonCookie = anonCookie;
|
|
599
689
|
|
|
600
|
-
//
|
|
601
|
-
|
|
690
|
+
// The server composes the canonical URL; compose client-side only as
|
|
691
|
+
// a fallback when it came back empty.
|
|
692
|
+
const url = resolvePlugUrl(data);
|
|
693
|
+
const isEdit = mode !== "create";
|
|
694
|
+
|
|
695
|
+
// The anon owner token: the create response mints it; every anon edit
|
|
696
|
+
// RE-MINTS it to the reset TTL (replace the stored one — the freshest token
|
|
697
|
+
// is the one that lives as long as the drop). Same JWT the claim uses.
|
|
698
|
+
let freshOwnerToken = typeof data.owner_token === "string" && data.owner_token.length > 0
|
|
699
|
+
? data.owner_token
|
|
700
|
+
: null;
|
|
701
|
+
if (!freshOwnerToken && data.claim_url) {
|
|
702
|
+
try {
|
|
703
|
+
freshOwnerToken = new URL(data.claim_url).searchParams.get("token");
|
|
704
|
+
} catch {
|
|
705
|
+
freshOwnerToken = null;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
602
708
|
|
|
603
|
-
// Remember the drop for session continuity — any caller class.
|
|
709
|
+
// Remember the drop for session continuity — any caller class. The
|
|
710
|
+
// `{entity_id, owner_token}` pair is the stateless anon re-plug handle.
|
|
604
711
|
if (data.entity_id || url) {
|
|
605
712
|
ctx.state.lastDrop = {
|
|
606
713
|
entity_id: data.entity_id ?? ctx.state.lastDrop?.entity_id ?? null,
|
|
607
714
|
url: url ?? ctx.state.lastDrop?.url ?? null,
|
|
715
|
+
owner_token: isAnonymousWire
|
|
716
|
+
? (freshOwnerToken ?? (mode === "anon-edit" ? ownerToken : null))
|
|
717
|
+
: null,
|
|
608
718
|
};
|
|
609
719
|
}
|
|
610
720
|
|
|
611
|
-
// Authenticated create
|
|
612
|
-
|
|
613
|
-
// sent, so distinguish on the call class rather than a (now-absent) `owned_by`.
|
|
614
|
-
if (!isAnonymousCall) {
|
|
721
|
+
// Authenticated wire (create or in-place edit): the entity is owned by the caller.
|
|
722
|
+
if (!isAnonymousWire) {
|
|
615
723
|
ctx.state.lastAnonClaim = null;
|
|
616
|
-
const lines = ctx.edition === "web"
|
|
617
|
-
? [`Your app is live: ${url}`]
|
|
618
|
-
: [`Published to your org: ${url}`, "Owned by you."];
|
|
619
724
|
const structured = {
|
|
620
725
|
url,
|
|
621
|
-
status: "created",
|
|
726
|
+
status: isEdit ? "updated" : "created",
|
|
622
727
|
owned_by: "authenticated",
|
|
728
|
+
...(data.entity_id ? { entity_id: data.entity_id } : {}),
|
|
623
729
|
};
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
730
|
+
const lines = [];
|
|
731
|
+
if (isEdit) {
|
|
732
|
+
lines.push(`Updated in place: ${url}`);
|
|
733
|
+
lines.push("Same link, new content. The expiry timer was reset.");
|
|
734
|
+
} else if (ctx.edition === "web") {
|
|
735
|
+
lines.push(`Your app is live: ${url}`);
|
|
736
|
+
} else {
|
|
737
|
+
lines.push(`Published to your org: ${url}`, "Owned by you.");
|
|
738
|
+
}
|
|
739
|
+
if (ctx.edition === "web" && !isEdit) {
|
|
740
|
+
// Default authed web CREATES to "link" visibility so the URL is shareable
|
|
741
|
+
// and the console thumbnail renders without a sign-in wall. An edit keeps
|
|
742
|
+
// the entity's existing visibility untouched.
|
|
627
743
|
if (data.entity_id) {
|
|
628
744
|
await upgradeVisibilityToLink(ctx, data.entity_id, orgSlug);
|
|
629
745
|
}
|
|
@@ -637,33 +753,41 @@ async function runDrop(ctx, { html, path: filePath, filename, anonymous, org, fr
|
|
|
637
753
|
return { text: lines.join("\n"), structured };
|
|
638
754
|
}
|
|
639
755
|
|
|
640
|
-
// Anonymous
|
|
641
|
-
//
|
|
642
|
-
// real `entity_id
|
|
756
|
+
// Anonymous wire — Guest-Grid inspiration (create 201, or owner-token edit).
|
|
757
|
+
// The response carries the reward fields (`claim_url` + `claim_message`), the
|
|
758
|
+
// real `entity_id`, and the OWNER TOKEN — the bearer capability that
|
|
759
|
+
// authorizes BOTH a later anonymous re-plug and the claim (pickup-by-id).
|
|
643
760
|
if (data.claim_url || data.entity_id) {
|
|
644
|
-
let token = null;
|
|
645
|
-
try {
|
|
646
|
-
token = data.claim_url ? new URL(data.claim_url).searchParams.get("token") : null;
|
|
647
|
-
} catch {
|
|
648
|
-
token = null;
|
|
649
|
-
}
|
|
650
761
|
ctx.state.lastAnonClaim = {
|
|
651
|
-
token,
|
|
652
|
-
entity_id: data.entity_id ?? null,
|
|
762
|
+
token: freshOwnerToken,
|
|
763
|
+
entity_id: data.entity_id ?? ctx.state.lastAnonClaim?.entity_id ?? null,
|
|
653
764
|
url,
|
|
654
765
|
};
|
|
655
766
|
}
|
|
656
|
-
const lines = [
|
|
767
|
+
const lines = [];
|
|
768
|
+
if (isEdit) {
|
|
769
|
+
lines.push(`Updated in place: ${url}`);
|
|
770
|
+
lines.push("Same link, new content. The expiry timer (and owner_token) were refreshed.");
|
|
771
|
+
} else {
|
|
772
|
+
lines.push(ctx.edition === "web" ? `Your app is live: ${url}` : `Live: ${url}`);
|
|
773
|
+
}
|
|
657
774
|
if (data.claim_message) {
|
|
658
775
|
lines.push(data.claim_message);
|
|
659
776
|
} else if (data.claim_url) {
|
|
660
|
-
lines.push("Sign in, then run gridctl_claim to keep it past
|
|
777
|
+
lines.push("Sign in, then run gridctl_claim to keep it past the expiry window.");
|
|
778
|
+
}
|
|
779
|
+
if (data.entity_id && freshOwnerToken) {
|
|
780
|
+
lines.push(
|
|
781
|
+
`Re-plug handle (persist to update or claim this drop in a later session): entity_id=${data.entity_id}`,
|
|
782
|
+
);
|
|
661
783
|
}
|
|
662
784
|
return {
|
|
663
785
|
text: lines.join("\n"),
|
|
664
786
|
structured: {
|
|
665
787
|
url,
|
|
666
|
-
status: "created",
|
|
788
|
+
status: isEdit ? "updated" : "created",
|
|
789
|
+
...(data.entity_id ? { entity_id: data.entity_id } : {}),
|
|
790
|
+
...(freshOwnerToken ? { owner_token: freshOwnerToken } : {}),
|
|
667
791
|
},
|
|
668
792
|
};
|
|
669
793
|
}
|
|
@@ -678,7 +802,10 @@ async function runClaim(ctx, { claim_token, claim_url, entity_id }) {
|
|
|
678
802
|
// claim now runs through `POST /api/v2/entities/:id/pickup`, which takes the
|
|
679
803
|
// entity id in the PATH and the `claim_token` in the body, and re-homes the
|
|
680
804
|
// Guest-Org inspiration into the caller's grid (ownership transfer). The
|
|
681
|
-
//
|
|
805
|
+
// claim token IS the anonymous owner token (Anon owner-token contract: one bearer
|
|
806
|
+
// capability for both edit and claim); an anon edit re-mints it, and the
|
|
807
|
+
// session state always holds the freshest one. The anon-session cookie is the
|
|
808
|
+
// token-less alternative auth.
|
|
682
809
|
let claimToken = claim_token;
|
|
683
810
|
if (!claimToken && claim_url) {
|
|
684
811
|
try {
|
|
@@ -691,7 +818,14 @@ async function runClaim(ctx, { claim_token, claim_url, entity_id }) {
|
|
|
691
818
|
|
|
692
819
|
// Pickup needs the target entity id in the URL path. Prefer an explicit one,
|
|
693
820
|
// else the entity remembered from this session's anonymous drop.
|
|
694
|
-
const targetId =
|
|
821
|
+
const targetId =
|
|
822
|
+
entity_id ||
|
|
823
|
+
ctx.state.lastAnonClaim?.entity_id ||
|
|
824
|
+
// The last drop counts only when it is anon-owned (it holds an owner token).
|
|
825
|
+
(ctx.state.lastDrop?.owner_token ? ctx.state.lastDrop.entity_id : null);
|
|
826
|
+
if (!claimToken && targetId && ctx.state.lastDrop?.entity_id === targetId) {
|
|
827
|
+
claimToken = ctx.state.lastDrop.owner_token ?? null;
|
|
828
|
+
}
|
|
695
829
|
if (!targetId) {
|
|
696
830
|
throw new Error(
|
|
697
831
|
"No drop to claim. Pass entity_id (or drop something anonymously first in this session), " +
|
|
@@ -744,6 +878,12 @@ async function runClaim(ctx, { claim_token, claim_url, entity_id }) {
|
|
|
744
878
|
}
|
|
745
879
|
|
|
746
880
|
ctx.state.lastAnonClaim = null;
|
|
881
|
+
// The entity is authed-owned now — the anon owner token is dead weight (a
|
|
882
|
+
// claimed drop can no longer be edited anonymously). Future re-drops of it
|
|
883
|
+
// must ride the authed wire.
|
|
884
|
+
if (ctx.state.lastDrop?.entity_id === targetId) {
|
|
885
|
+
ctx.state.lastDrop.owner_token = null;
|
|
886
|
+
}
|
|
747
887
|
const url = data?.url || data?.redirect_url || ctx.state.lastDrop?.url || null;
|
|
748
888
|
const lines = ["Claimed 1, now yours:"];
|
|
749
889
|
lines.push(`${url ?? ""}${data?.new_expires_at ? ` (expires ${data.new_expires_at})` : ""}`.trim());
|
|
@@ -757,6 +897,453 @@ async function runClaim(ctx, { claim_token, claim_url, entity_id }) {
|
|
|
757
897
|
}
|
|
758
898
|
|
|
759
899
|
|
|
900
|
+
// ── gridctl_plug — the unified create/re-plug verb (spec v2 §3) ──────────────
|
|
901
|
+
|
|
902
|
+
// Total upload budget mirrors the server's multipart cap (100 MB).
|
|
903
|
+
const PLUG_MAX_TOTAL_BYTES = 100 * 1024 * 1024;
|
|
904
|
+
const PLUG_MAX_FILES = 2000;
|
|
905
|
+
|
|
906
|
+
// Directories never worth uploading, regardless of ignore files.
|
|
907
|
+
const PLUG_ALWAYS_SKIP = new Set([".git", "node_modules", ".DS_Store", ".cloudgrid"]);
|
|
908
|
+
|
|
909
|
+
// Compile one .gitignore/.cloudgridignore pattern into a matcher over
|
|
910
|
+
// repo-relative paths. A pragmatic subset (no negation `!` — those lines are
|
|
911
|
+
// skipped): `#` comments, `*`/`?`/`**` globs, a leading `/` anchors to the
|
|
912
|
+
// root, a trailing `/` matches directories only, and a bare name matches at
|
|
913
|
+
// any depth (standard gitignore semantics for patterns without a slash).
|
|
914
|
+
function compileIgnorePattern(line) {
|
|
915
|
+
let pat = line.trim();
|
|
916
|
+
if (!pat || pat.startsWith("#") || pat.startsWith("!")) return null;
|
|
917
|
+
const dirOnly = pat.endsWith("/");
|
|
918
|
+
if (dirOnly) pat = pat.slice(0, -1);
|
|
919
|
+
// A pattern containing a slash is anchored to the root (gitignore rule).
|
|
920
|
+
const anchored = pat.includes("/");
|
|
921
|
+
if (pat.startsWith("/")) pat = pat.slice(1);
|
|
922
|
+
const rx = pat
|
|
923
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
924
|
+
.replace(/\*\*/g, "")
|
|
925
|
+
.replace(/\*/g, "[^/]*")
|
|
926
|
+
.replace(/\?/g, "[^/]")
|
|
927
|
+
.replace(//g, ".*");
|
|
928
|
+
const body = anchored ? `^${rx}` : `(^|/)${rx}`;
|
|
929
|
+
const re = new RegExp(`${body}(/|$)`);
|
|
930
|
+
return { re, dirOnly };
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function loadIgnoreMatchers(rootDir) {
|
|
934
|
+
const patterns = [];
|
|
935
|
+
for (const f of [".gitignore", ".cloudgridignore"]) {
|
|
936
|
+
try {
|
|
937
|
+
const p = join(rootDir, f);
|
|
938
|
+
if (existsSync(p)) {
|
|
939
|
+
for (const line of readFileSync(p, "utf-8").split("\n")) {
|
|
940
|
+
const compiled = compileIgnorePattern(line);
|
|
941
|
+
if (compiled) patterns.push(compiled);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
} catch {
|
|
945
|
+
/* unreadable ignore file — upload everything */
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
return (relPath, isDir) =>
|
|
949
|
+
patterns.some((p) => (!p.dirOnly || isDir) && p.re.test(relPath));
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// Walk a local folder into `[{path, buffer}]` artifacts (repo-relative paths),
|
|
953
|
+
// honoring .gitignore/.cloudgridignore at the root plus the always-skip set.
|
|
954
|
+
// A single file becomes one artifact named by its basename.
|
|
955
|
+
function collectPathArtifacts(srcPath) {
|
|
956
|
+
const abs = resolve(srcPath);
|
|
957
|
+
if (!existsSync(abs)) throw new Error(`Path does not exist: ${abs}`);
|
|
958
|
+
const st = statSync(abs);
|
|
959
|
+
if (st.isFile()) {
|
|
960
|
+
return [{ path: basename(abs), buffer: readFileSync(abs) }];
|
|
961
|
+
}
|
|
962
|
+
if (!st.isDirectory()) throw new Error(`Not a file or directory: ${abs}`);
|
|
963
|
+
const isIgnored = loadIgnoreMatchers(abs);
|
|
964
|
+
const out = [];
|
|
965
|
+
let total = 0;
|
|
966
|
+
const walk = (dir, rel) => {
|
|
967
|
+
for (const nm of readdirSync(dir)) {
|
|
968
|
+
if (PLUG_ALWAYS_SKIP.has(nm)) continue;
|
|
969
|
+
const childAbs = join(dir, nm);
|
|
970
|
+
const childRel = rel ? `${rel}/${nm}` : nm;
|
|
971
|
+
let cst;
|
|
972
|
+
try {
|
|
973
|
+
cst = statSync(childAbs);
|
|
974
|
+
} catch {
|
|
975
|
+
continue; // broken symlink etc.
|
|
976
|
+
}
|
|
977
|
+
if (isIgnored(childRel, cst.isDirectory())) continue;
|
|
978
|
+
if (cst.isDirectory()) {
|
|
979
|
+
walk(childAbs, childRel);
|
|
980
|
+
} else if (cst.isFile()) {
|
|
981
|
+
if (out.length >= PLUG_MAX_FILES) {
|
|
982
|
+
throw new Error(
|
|
983
|
+
`The folder has more than ${PLUG_MAX_FILES} files after ignores — too large to plug inline. Trim it or add a .cloudgridignore.`,
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
total += cst.size;
|
|
987
|
+
if (total > PLUG_MAX_TOTAL_BYTES) {
|
|
988
|
+
throw new Error("The upload exceeds the 100MB plug limit. Trim the folder or add a .cloudgridignore.");
|
|
989
|
+
}
|
|
990
|
+
out.push({ path: childRel, buffer: readFileSync(childAbs) });
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
};
|
|
994
|
+
walk(abs, "");
|
|
995
|
+
if (out.length === 0) throw new Error(`Nothing to upload in ${abs} (everything ignored or empty).`);
|
|
996
|
+
return out;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// Map friendly plug error statuses to actionable messages (spec v2 §3.3).
|
|
1000
|
+
function plugErrorMessage(status, code, msg) {
|
|
1001
|
+
const base = `Plug failed (HTTP ${status}${code ? ` ${code}` : ""}): ${msg}`;
|
|
1002
|
+
if (status === 409) {
|
|
1003
|
+
return `${base} — the entity cannot be updated right now (a deploy is in progress, or it is archived/expired/claimed). An explicit re-plug never silently creates; retry later or omit target_entity_id to create a new entity.`;
|
|
1004
|
+
}
|
|
1005
|
+
if (status === 401) {
|
|
1006
|
+
return `${base} — sign in (gridctl_login), or for an anon-minted drop pass its owner_token.`;
|
|
1007
|
+
}
|
|
1008
|
+
if (status === 403) {
|
|
1009
|
+
return `${base} — you lack the role to plug this target. To re-plug someone else's entity, pick it up first (gridctl_pickup / gridctl_claim).`;
|
|
1010
|
+
}
|
|
1011
|
+
if (status === 429) {
|
|
1012
|
+
return `${base} — the daily anonymous cap was reached (anon edits share the create cap). Sign in to publish more, or retry tomorrow.`;
|
|
1013
|
+
}
|
|
1014
|
+
return base;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* The unified create/re-plug verb (spec v2 §3). Two intents on one tool, keyed
|
|
1019
|
+
* by `target_entity_id`:
|
|
1020
|
+
* - absent → CREATE: mint a new entity from the artifact (server detection
|
|
1021
|
+
* decides the kind unless hinted). Authed → the caller's grid; anon → a
|
|
1022
|
+
* Guest-Grid drop with a claim_url + owner_token.
|
|
1023
|
+
* - present → RE-PLUG: update the SAME entity in place (same id, slug, URL,
|
|
1024
|
+
* history). Authed for entities in your grid; `owner_token` for a drop
|
|
1025
|
+
* minted anonymously (anon owner-token contract).
|
|
1026
|
+
* Source is `path` (local edition — the folder/file is read and uploaded) XOR
|
|
1027
|
+
* `artifact_files` (hosted — inline file entries).
|
|
1028
|
+
*/
|
|
1029
|
+
export async function runPlug(ctx, input) {
|
|
1030
|
+
const {
|
|
1031
|
+
path: srcPath,
|
|
1032
|
+
artifact_files,
|
|
1033
|
+
cloudgrid_yaml,
|
|
1034
|
+
target_entity_id,
|
|
1035
|
+
grid,
|
|
1036
|
+
hints,
|
|
1037
|
+
anon,
|
|
1038
|
+
owner_token,
|
|
1039
|
+
} = input || {};
|
|
1040
|
+
|
|
1041
|
+
// ── Source: path XOR artifact_files ────────────────────────────────────────
|
|
1042
|
+
if (srcPath && Array.isArray(artifact_files) && artifact_files.length > 0) {
|
|
1043
|
+
throw new Error("Pass either `path` or `artifact_files`, not both.");
|
|
1044
|
+
}
|
|
1045
|
+
if (ctx.edition === "web" && srcPath) {
|
|
1046
|
+
throw new Error(
|
|
1047
|
+
"The hosted server cannot read local files — pass the source inline via `artifact_files`.",
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
let artifacts;
|
|
1051
|
+
if (srcPath) {
|
|
1052
|
+
artifacts = collectPathArtifacts(srcPath);
|
|
1053
|
+
} else if (Array.isArray(artifact_files) && artifact_files.length > 0) {
|
|
1054
|
+
let total = 0;
|
|
1055
|
+
artifacts = artifact_files.map((f) => {
|
|
1056
|
+
if (!f || typeof f.path !== "string" || typeof f.content !== "string") {
|
|
1057
|
+
throw new Error("Each artifact_files entry needs `path` and `content`.");
|
|
1058
|
+
}
|
|
1059
|
+
const buffer = Buffer.from(f.content, f.encoding === "base64" ? "base64" : "utf8");
|
|
1060
|
+
total += buffer.byteLength;
|
|
1061
|
+
if (total > PLUG_MAX_TOTAL_BYTES) {
|
|
1062
|
+
throw new Error("The upload exceeds the 100MB plug limit.");
|
|
1063
|
+
}
|
|
1064
|
+
return { path: f.path, buffer };
|
|
1065
|
+
});
|
|
1066
|
+
} else {
|
|
1067
|
+
throw new Error(
|
|
1068
|
+
ctx.edition === "web"
|
|
1069
|
+
? "Provide the source via `artifact_files`."
|
|
1070
|
+
: "Provide the source via `path` (a local file or folder) or `artifact_files`.",
|
|
1071
|
+
);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
const isEdit = typeof target_entity_id === "string" && target_entity_id.length > 0;
|
|
1075
|
+
|
|
1076
|
+
// An inspiration edit content-versions the FIRST uploaded artifact — when a
|
|
1077
|
+
// multi-file folder rides a re-plug, put the primary entry first so the edit
|
|
1078
|
+
// swaps the right file (index.html > any .html > everything else).
|
|
1079
|
+
if (isEdit && artifacts.length > 1) {
|
|
1080
|
+
const prio = (a) =>
|
|
1081
|
+
a.path === "index.html" ? 0 : /\.html?$/i.test(a.path) ? 1 : a.path.startsWith(".") ? 3 : 2;
|
|
1082
|
+
artifacts = artifacts
|
|
1083
|
+
.map((a, i) => ({ a, i }))
|
|
1084
|
+
.sort((x, y) => prio(x.a) - prio(y.a) || x.i - y.i)
|
|
1085
|
+
.map((x) => x.a);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// ── Auth wire selection ─────────────────────────────────────────────────────
|
|
1089
|
+
const authToken = anon === true ? null : await ctx.getToken();
|
|
1090
|
+
let ownerToken = typeof owner_token === "string" && owner_token.length > 0 ? owner_token : null;
|
|
1091
|
+
if (isEdit && !ownerToken) {
|
|
1092
|
+
// Recover the owner token from session state when re-plugging the drop this
|
|
1093
|
+
// session made anonymously.
|
|
1094
|
+
if (ctx.state.lastDrop?.entity_id === target_entity_id && ctx.state.lastDrop.owner_token) {
|
|
1095
|
+
ownerToken = ctx.state.lastDrop.owner_token;
|
|
1096
|
+
} else if (
|
|
1097
|
+
ctx.state.lastAnonClaim?.entity_id === target_entity_id &&
|
|
1098
|
+
ctx.state.lastAnonClaim.token
|
|
1099
|
+
) {
|
|
1100
|
+
ownerToken = ctx.state.lastAnonClaim.token;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
// An anon-minted (Guest-Grid) drop is edited via the owner-token wire even
|
|
1104
|
+
// when signed in — the entity is not in the caller's grid, so an authed edit
|
|
1105
|
+
// of it would 404. Otherwise an edit needs the authed wire.
|
|
1106
|
+
const useAnonWire = isEdit ? Boolean(ownerToken) : !authToken;
|
|
1107
|
+
if (isEdit && !ownerToken && !authToken) {
|
|
1108
|
+
throw new Error(
|
|
1109
|
+
"Re-plugging needs authorization: sign in (gridctl_login) for an entity in your grid, or pass the " +
|
|
1110
|
+
"owner_token that came back when the drop was created anonymously.",
|
|
1111
|
+
);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
const headers = {};
|
|
1115
|
+
if (!useAnonWire && authToken) {
|
|
1116
|
+
headers["Authorization"] = `Bearer ${authToken}`;
|
|
1117
|
+
// On create, `grid` picks where the entity lands. On re-plug the entity's
|
|
1118
|
+
// home grid is authoritative (it never moves) — but the API still resolves
|
|
1119
|
+
// the caller's membership from this header and requires it to MATCH the
|
|
1120
|
+
// entity's grid, so pass `grid` here too when the target lives outside the
|
|
1121
|
+
// active grid.
|
|
1122
|
+
const orgSlug = grid || (await ctx.getActiveOrg());
|
|
1123
|
+
if (orgSlug) headers["X-CloudGrid-Org"] = orgSlug;
|
|
1124
|
+
}
|
|
1125
|
+
if (useAnonWire && ctx.trustedServer?.secret && ctx.trustedServer?.endUserId) {
|
|
1126
|
+
headers["X-CloudGrid-Trusted-Server-Auth"] = ctx.trustedServer.secret;
|
|
1127
|
+
headers["X-CloudGrid-Trusted-Server-End-User"] = ctx.trustedServer.endUserId;
|
|
1128
|
+
}
|
|
1129
|
+
if (useAnonWire && ctx.state.anonCookie) headers["Cookie"] = ctx.state.anonCookie;
|
|
1130
|
+
|
|
1131
|
+
// ── Wire assembly ───────────────────────────────────────────────────────────
|
|
1132
|
+
const form = new FormData();
|
|
1133
|
+
for (const a of artifacts) {
|
|
1134
|
+
form.append("artifact", new Blob([a.buffer], { type: "application/octet-stream" }), a.path);
|
|
1135
|
+
}
|
|
1136
|
+
if (isEdit) {
|
|
1137
|
+
form.append("target_entity_id", target_entity_id);
|
|
1138
|
+
if (useAnonWire) {
|
|
1139
|
+
form.append("owner_token", ownerToken);
|
|
1140
|
+
} else {
|
|
1141
|
+
// The authed update path requires a `cloudgrid.yaml` part
|
|
1142
|
+
// (materializePlugTarball); an inspiration edit ignores its content.
|
|
1143
|
+
form.append(
|
|
1144
|
+
"cloudgrid.yaml",
|
|
1145
|
+
new Blob([cloudgrid_yaml || ""], { type: "text/plain" }),
|
|
1146
|
+
"cloudgrid.yaml",
|
|
1147
|
+
);
|
|
1148
|
+
}
|
|
1149
|
+
} else if (cloudgrid_yaml) {
|
|
1150
|
+
// On CREATE the manifest rides as a regular artifact file (the create wire
|
|
1151
|
+
// filters a part FIELD-named `cloudgrid.yaml`, but a file named
|
|
1152
|
+
// cloudgrid.yaml lands in the detected source tree).
|
|
1153
|
+
form.append(
|
|
1154
|
+
"artifact",
|
|
1155
|
+
new Blob([cloudgrid_yaml], { type: "text/plain" }),
|
|
1156
|
+
"cloudgrid.yaml",
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1159
|
+
if (hints?.kind) {
|
|
1160
|
+
// `kind_hint` is what the create orchestrator reads; `hints_kind` is the
|
|
1161
|
+
// route's structured field on the update path. Send both — each path
|
|
1162
|
+
// ignores the other's.
|
|
1163
|
+
form.append("kind_hint", hints.kind);
|
|
1164
|
+
form.append("hints_kind", hints.kind);
|
|
1165
|
+
}
|
|
1166
|
+
if (hints?.yaml) form.append("hints_yaml", hints.yaml);
|
|
1167
|
+
|
|
1168
|
+
let res;
|
|
1169
|
+
try {
|
|
1170
|
+
res = await fetch(`${API_BASE}/api/v2/plug`, { method: "POST", headers, body: form });
|
|
1171
|
+
} catch (err) {
|
|
1172
|
+
throw new Error(`Could not reach CloudGrid at ${API_BASE}: ${err.message}`);
|
|
1173
|
+
}
|
|
1174
|
+
const raw = await res.text();
|
|
1175
|
+
let data = null;
|
|
1176
|
+
try {
|
|
1177
|
+
data = JSON.parse(raw);
|
|
1178
|
+
} catch {
|
|
1179
|
+
/* handled below */
|
|
1180
|
+
}
|
|
1181
|
+
if (!res.ok) {
|
|
1182
|
+
const msg = data?.error?.message || data?.message || raw || `HTTP ${res.status}`;
|
|
1183
|
+
throw new Error(plugErrorMessage(res.status, data?.error?.code, msg));
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// Anon-session cookie continuity (mirrors runDrop).
|
|
1187
|
+
const setCookies = res.headers.getSetCookie
|
|
1188
|
+
? res.headers.getSetCookie()
|
|
1189
|
+
: [res.headers.get("set-cookie")].filter(Boolean);
|
|
1190
|
+
const anonCookie = (setCookies || [])
|
|
1191
|
+
.map((c) => (c || "").split(";")[0])
|
|
1192
|
+
.find((c) => c.startsWith("cg_anon_session="));
|
|
1193
|
+
if (anonCookie) ctx.state.anonCookie = anonCookie;
|
|
1194
|
+
|
|
1195
|
+
const url = resolvePlugUrl(data);
|
|
1196
|
+
let freshOwnerToken = typeof data.owner_token === "string" && data.owner_token.length > 0
|
|
1197
|
+
? data.owner_token
|
|
1198
|
+
: null;
|
|
1199
|
+
if (!freshOwnerToken && data.claim_url) {
|
|
1200
|
+
try {
|
|
1201
|
+
freshOwnerToken = new URL(data.claim_url).searchParams.get("token");
|
|
1202
|
+
} catch {
|
|
1203
|
+
freshOwnerToken = null;
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
// Session continuity — the same state runDrop keeps.
|
|
1208
|
+
if (data.entity_id || url) {
|
|
1209
|
+
ctx.state.lastDrop = {
|
|
1210
|
+
entity_id: data.entity_id ?? null,
|
|
1211
|
+
url: url ?? null,
|
|
1212
|
+
owner_token: useAnonWire ? (freshOwnerToken ?? ownerToken ?? null) : null,
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
if (useAnonWire && (data.claim_url || freshOwnerToken)) {
|
|
1216
|
+
ctx.state.lastAnonClaim = {
|
|
1217
|
+
token: freshOwnerToken,
|
|
1218
|
+
entity_id: data.entity_id ?? null,
|
|
1219
|
+
url,
|
|
1220
|
+
};
|
|
1221
|
+
} else if (!useAnonWire) {
|
|
1222
|
+
ctx.state.lastAnonClaim = null;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
const structured = {
|
|
1226
|
+
...(data.entity_id ? { entity_id: data.entity_id } : {}),
|
|
1227
|
+
...(data.slug ? { slug: data.slug } : {}),
|
|
1228
|
+
grid: data.grid ?? null,
|
|
1229
|
+
...(url ? { url } : {}),
|
|
1230
|
+
...(data.poll_url ? { poll_url: data.poll_url } : {}),
|
|
1231
|
+
status: data.status ?? (isEdit ? "updated" : "created"),
|
|
1232
|
+
...(data.claim_url ? { claim_url: data.claim_url } : {}),
|
|
1233
|
+
...(data.claim_message ? { claim_message: data.claim_message } : {}),
|
|
1234
|
+
// Spec v2 omits owner_token from the output block — a spec bug (the anon
|
|
1235
|
+
// wire cannot re-plug without it). Included deliberately; flagged upstream.
|
|
1236
|
+
...(freshOwnerToken ? { owner_token: freshOwnerToken } : {}),
|
|
1237
|
+
};
|
|
1238
|
+
|
|
1239
|
+
const lines = [];
|
|
1240
|
+
lines.push(isEdit ? `Updated in place: ${url}` : `Live: ${url}`);
|
|
1241
|
+
if (data.status === "building" && data.poll_url) {
|
|
1242
|
+
lines.push(`Build dispatched — poll ${data.poll_url} (trace ${data.trace_id ?? "n/a"}).`);
|
|
1243
|
+
}
|
|
1244
|
+
if (data.entity_id) {
|
|
1245
|
+
lines.push(
|
|
1246
|
+
`Re-plug handle: entity_id=${data.entity_id} — persist it (with the url) and pass it back as target_entity_id to update this entity later.`,
|
|
1247
|
+
);
|
|
1248
|
+
}
|
|
1249
|
+
if (data.claim_message) lines.push(data.claim_message);
|
|
1250
|
+
if (isEdit && useAnonWire) {
|
|
1251
|
+
lines.push("The owner_token was re-minted for the reset expiry — replace the stored one.");
|
|
1252
|
+
}
|
|
1253
|
+
return { text: lines.join("\n"), structured };
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// ── gridctl_fork / gridctl_download — direct-API verbs (spec v2 §5–6) ────────
|
|
1257
|
+
|
|
1258
|
+
async function authedApiCall(ctx, { method, pathName, body, verb }) {
|
|
1259
|
+
const token = await ctx.getToken();
|
|
1260
|
+
if (!token) {
|
|
1261
|
+
throw new Error(`${verb} requires sign-in. Run gridctl_login first.`);
|
|
1262
|
+
}
|
|
1263
|
+
const headers = { Authorization: `Bearer ${token}` };
|
|
1264
|
+
const orgSlug = await ctx.getActiveOrg();
|
|
1265
|
+
if (orgSlug) headers["X-CloudGrid-Org"] = orgSlug;
|
|
1266
|
+
if (body !== undefined) headers["Content-Type"] = "application/json";
|
|
1267
|
+
let res;
|
|
1268
|
+
try {
|
|
1269
|
+
res = await fetch(`${API_BASE}${pathName}`, {
|
|
1270
|
+
method,
|
|
1271
|
+
headers,
|
|
1272
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
1273
|
+
});
|
|
1274
|
+
} catch (err) {
|
|
1275
|
+
throw new Error(`Could not reach CloudGrid at ${API_BASE}: ${err.message}`);
|
|
1276
|
+
}
|
|
1277
|
+
const raw = await res.text();
|
|
1278
|
+
let data = null;
|
|
1279
|
+
try {
|
|
1280
|
+
data = JSON.parse(raw);
|
|
1281
|
+
} catch {
|
|
1282
|
+
/* handled below */
|
|
1283
|
+
}
|
|
1284
|
+
if (!res.ok) {
|
|
1285
|
+
const msg = data?.error?.message || data?.message || raw || `HTTP ${res.status}`;
|
|
1286
|
+
const code = data?.error?.code ? ` ${data.error.code}` : "";
|
|
1287
|
+
throw new Error(`${verb} failed (HTTP ${res.status}${code}): ${msg}`);
|
|
1288
|
+
}
|
|
1289
|
+
return data;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
// Fork: start a NEW entity from an existing runtime, copy-on-write with lineage.
|
|
1293
|
+
async function runFork(ctx, { id, into_org_slug, name, source_version_id }) {
|
|
1294
|
+
const data = await authedApiCall(ctx, {
|
|
1295
|
+
method: "POST",
|
|
1296
|
+
pathName: `/api/v2/runtimes/${encodeURIComponent(id)}/fork`,
|
|
1297
|
+
body: {
|
|
1298
|
+
...(into_org_slug ? { into_org_slug } : {}),
|
|
1299
|
+
...(name ? { name } : {}),
|
|
1300
|
+
...(source_version_id ? { source_version_id } : {}),
|
|
1301
|
+
},
|
|
1302
|
+
verb: "Fork",
|
|
1303
|
+
});
|
|
1304
|
+
const gridSlug = data?.grid_slug ?? data?.org?.slug ?? null;
|
|
1305
|
+
const lines = [
|
|
1306
|
+
`Forked: ${data?.name ?? id} (entity_id=${data?.entity_id ?? "?"})${gridSlug ? ` in grid ${gridSlug}` : ""}`,
|
|
1307
|
+
`Lineage: forked_from=${data?.forked_from ?? "?"}${data?.forked_from_version_id ? ` @ ${data.forked_from_version_id}` : ""}`,
|
|
1308
|
+
];
|
|
1309
|
+
return {
|
|
1310
|
+
text: lines.join("\n"),
|
|
1311
|
+
structured: {
|
|
1312
|
+
entity_id: data?.entity_id ?? null,
|
|
1313
|
+
name: data?.name ?? null,
|
|
1314
|
+
kind: data?.kind ?? null,
|
|
1315
|
+
grid_slug: gridSlug,
|
|
1316
|
+
forked_from: data?.forked_from ?? null,
|
|
1317
|
+
forked_from_version_id: data?.forked_from_version_id ?? null,
|
|
1318
|
+
current_version_id: data?.current_version_id ?? null,
|
|
1319
|
+
},
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// Download: signed, time-limited (15-minute) source-bundle URLs. No entity is
|
|
1324
|
+
// created and no registry state changes.
|
|
1325
|
+
async function runDownload(ctx, { id, version }) {
|
|
1326
|
+
const qs = version ? `?version=${encodeURIComponent(version)}` : "";
|
|
1327
|
+
const data = await authedApiCall(ctx, {
|
|
1328
|
+
method: "GET",
|
|
1329
|
+
pathName: `/api/v2/runtimes/${encodeURIComponent(id)}/source${qs}`,
|
|
1330
|
+
verb: "Download",
|
|
1331
|
+
});
|
|
1332
|
+
const services = data?.services && typeof data.services === "object" ? data.services : {};
|
|
1333
|
+
const lines = [`Source bundle URLs for ${data?.name ?? id} (valid ~15 minutes):`];
|
|
1334
|
+
for (const [svc, u] of Object.entries(services)) lines.push(` ${svc}: ${u}`);
|
|
1335
|
+
if (Object.keys(services).length === 0) lines.push(" (no services returned)");
|
|
1336
|
+
return {
|
|
1337
|
+
text: lines.join("\n"),
|
|
1338
|
+
structured: {
|
|
1339
|
+
entity_id: data?.entity_id ?? null,
|
|
1340
|
+
name: data?.name ?? null,
|
|
1341
|
+
services,
|
|
1342
|
+
domain: data?.domain ?? null,
|
|
1343
|
+
},
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
1346
|
+
|
|
760
1347
|
// Change an inspiration's visibility. Authed, direct API — works on the hosted
|
|
761
1348
|
// edition where the CLI-wrapping share tool is unavailable. Defaults to the drop
|
|
762
1349
|
// made in this session, so "make it private" needs no ids.
|
|
@@ -867,6 +1454,11 @@ export function registerTools(server, ctx) {
|
|
|
867
1454
|
// Drop — both editions, but the input schema is edition-aware: the web
|
|
868
1455
|
// edition removes `path` (the cloud server cannot read local files) and
|
|
869
1456
|
// strengthens `html` so the model always pastes the full document inline.
|
|
1457
|
+
const dropReplugParams = {
|
|
1458
|
+
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."),
|
|
1459
|
+
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."),
|
|
1460
|
+
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."),
|
|
1461
|
+
};
|
|
870
1462
|
const dropInputSchema = ctx.edition === "web"
|
|
871
1463
|
? {
|
|
872
1464
|
html: z.string().optional().describe(
|
|
@@ -877,7 +1469,7 @@ export function registerTools(server, ctx) {
|
|
|
877
1469
|
filename: z.string().optional().describe("Filename to present. Defaults to index.html for inline HTML."),
|
|
878
1470
|
anonymous: z.boolean().optional().describe("Force an anonymous drop even if the user is signed in."),
|
|
879
1471
|
org: z.string().optional().describe("Leave unset; the tool will ask the user which org to publish into. Only set this after the user picks from the list the tool returns."),
|
|
880
|
-
|
|
1472
|
+
...dropReplugParams,
|
|
881
1473
|
}
|
|
882
1474
|
: {
|
|
883
1475
|
html: z.string().optional().describe("Inline HTML to publish. A fragment is wrapped into a full document."),
|
|
@@ -885,17 +1477,19 @@ export function registerTools(server, ctx) {
|
|
|
885
1477
|
filename: z.string().optional().describe("Filename to present. Defaults to index.html for inline HTML."),
|
|
886
1478
|
anonymous: z.boolean().optional().describe("Force an anonymous drop even if the user is signed in."),
|
|
887
1479
|
org: z.string().optional().describe("Leave unset; the tool will ask the user which org to publish into. Only set this after the user picks from the list the tool returns."),
|
|
888
|
-
|
|
1480
|
+
...dropReplugParams,
|
|
889
1481
|
};
|
|
890
1482
|
|
|
891
1483
|
reg(
|
|
892
1484
|
"gridctl_drop",
|
|
893
1485
|
{
|
|
894
|
-
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
|
|
1486
|
+
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. Calls POST /api/v2/plug directly.",
|
|
895
1487
|
inputSchema: dropInputSchema,
|
|
896
1488
|
outputSchema: {
|
|
897
|
-
url: z.string().optional().describe("The public URL of the drop."),
|
|
1489
|
+
url: z.string().optional().describe("The public URL of the drop (stable across re-drops of the same entity)."),
|
|
898
1490
|
status: z.enum(["created", "updated", "unchanged"]).optional().describe("What happened to the drop."),
|
|
1491
|
+
entity_id: z.string().optional().describe("The entity's durable id — pass back as entity_id to update it in place later."),
|
|
1492
|
+
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."),
|
|
899
1493
|
owned_by: z.string().optional().describe("Ownership class, e.g. 'authenticated'."),
|
|
900
1494
|
expires_at: z.string().optional().describe("Expiry timestamp, if any."),
|
|
901
1495
|
console_url: z.string().optional().describe("URL to manage all apps in the grid."),
|
|
@@ -1016,9 +1610,9 @@ export function registerTools(server, ctx) {
|
|
|
1016
1610
|
reg(
|
|
1017
1611
|
"gridctl_claim",
|
|
1018
1612
|
{
|
|
1019
|
-
description: "Claim an anonymous drop into the signed-in account, so it becomes owned and stops expiring
|
|
1613
|
+
description: "Claim an anonymous drop into the signed-in account, so it becomes owned and stops expiring on the anonymous schedule. Use after the user signs in to keep something they dropped anonymously. The public URL does not change. The claim token IS the drop's owner_token (one bearer capability for both edit and claim — anonymous edits refresh it, so always use the newest). Requires sign-in (gridctl_login). Calls the API directly.",
|
|
1020
1614
|
inputSchema: {
|
|
1021
|
-
claim_token: z.string().optional().describe("The claim token from an anonymous drop."),
|
|
1615
|
+
claim_token: z.string().optional().describe("The claim/owner token from an anonymous drop (owner_token in the drop result; also embedded in claim_url)."),
|
|
1022
1616
|
claim_url: z.string().optional().describe("The claim_url from an anonymous drop; the token is read from it."),
|
|
1023
1617
|
entity_id: z.string().optional().describe("The entity id of the anonymous drop to claim. Defaults to this session's last anonymous drop."),
|
|
1024
1618
|
},
|
|
@@ -1037,6 +1631,157 @@ export function registerTools(server, ctx) {
|
|
|
1037
1631
|
},
|
|
1038
1632
|
);
|
|
1039
1633
|
|
|
1634
|
+
// ── gridctl_plug — the unified create/re-plug verb (spec v2 §3) ────────────
|
|
1635
|
+
// Direct-API on BOTH editions (POST /api/v2/plug). Replaces the former
|
|
1636
|
+
// CLI-wrapping gridctl_plug: create and re-plug are one verb, keyed by
|
|
1637
|
+
// target_entity_id, and work identically on the hosted transport.
|
|
1638
|
+
const plugInputSchema = {
|
|
1639
|
+
...(ctx.edition === "web"
|
|
1640
|
+
? {}
|
|
1641
|
+
: {
|
|
1642
|
+
path: z.string().optional().describe(
|
|
1643
|
+
"Local edition: path to the entity folder (or a single file) to upload. A folder is read " +
|
|
1644
|
+
"recursively, honoring .gitignore/.cloudgridignore (plus .git/node_modules always skipped). " +
|
|
1645
|
+
"Mutually exclusive with artifact_files.",
|
|
1646
|
+
),
|
|
1647
|
+
}),
|
|
1648
|
+
artifact_files: z.array(z.object({
|
|
1649
|
+
path: z.string().describe("Repo-relative path, e.g. index.html or services/web/index.js."),
|
|
1650
|
+
content: z.string().describe("File content. Base64 when encoding is base64, otherwise UTF-8 text."),
|
|
1651
|
+
encoding: z.enum(["utf8", "base64"]).optional().describe("Content encoding. Default utf8."),
|
|
1652
|
+
})).optional().describe(
|
|
1653
|
+
"The source inline, one entry per file — for hosted/no-filesystem transports (an HTML deck, a one-file app)." +
|
|
1654
|
+
(ctx.edition === "web" ? "" : " Prefer `path` on the local edition."),
|
|
1655
|
+
),
|
|
1656
|
+
cloudgrid_yaml: z.string().optional().describe(
|
|
1657
|
+
"Inline cloudgrid.yaml (the entity manifest). Optional — server auto-detection applies when omitted. " +
|
|
1658
|
+
"On re-plug, a name: change is a warning only; it never renames the entity or moves the URL.",
|
|
1659
|
+
),
|
|
1660
|
+
target_entity_id: z.string().optional().describe(
|
|
1661
|
+
"Present → RE-PLUG: update this exact entity in place (same entity_id, slug, URL, deploy history). " +
|
|
1662
|
+
"Absent → CREATE a new entity. This is the durable handle a previous plug returned — persist it. " +
|
|
1663
|
+
"Re-plugging an anonymously-created drop needs its owner_token instead of sign-in.",
|
|
1664
|
+
),
|
|
1665
|
+
grid: z.string().optional().describe(
|
|
1666
|
+
"On create: the grid slug to plug into (omit to use the caller's active grid). On re-plug the entity " +
|
|
1667
|
+
"never moves grids, but pass its home grid here when it differs from your active grid (the API " +
|
|
1668
|
+
"checks your membership in the entity's grid). Anonymous → always the Guest grid.",
|
|
1669
|
+
),
|
|
1670
|
+
hints: z.object({
|
|
1671
|
+
kind: z.enum(["inspiration", "app", "agent"]).optional().describe("Force the detected kind; omit to let the server auto-detect."),
|
|
1672
|
+
yaml: z.string().optional().describe("An inline cloudgrid.yaml override used as a detection hint."),
|
|
1673
|
+
}).optional().describe("Classification hints for the CREATE path (not entity targeting — that's target_entity_id)."),
|
|
1674
|
+
anon: z.boolean().optional().describe(
|
|
1675
|
+
"Create an anonymous Guest-Grid drop (no auth). The response carries claim_url + owner_token; persist " +
|
|
1676
|
+
"entity_id + owner_token as the stateless re-plug/claim handle.",
|
|
1677
|
+
),
|
|
1678
|
+
owner_token: z.string().optional().describe(
|
|
1679
|
+
"The owner token of an anonymously-created drop — authorizes an anonymous re-plug (with " +
|
|
1680
|
+
"target_entity_id). Re-minted on every anonymous edit; always persist the newest one from the result.",
|
|
1681
|
+
),
|
|
1682
|
+
};
|
|
1683
|
+
|
|
1684
|
+
reg(
|
|
1685
|
+
"gridctl_plug",
|
|
1686
|
+
{
|
|
1687
|
+
description:
|
|
1688
|
+
"Surface a creation onto the grid — the unified create/re-plug verb (POST /api/v2/plug). " +
|
|
1689
|
+
"No target_entity_id → CREATE a new entity (inspiration/app/agent, auto-detected or hinted); " +
|
|
1690
|
+
"with target_entity_id → RE-PLUG: update the SAME entity in place — same entity_id, same URL, same " +
|
|
1691
|
+
"deploy history, expiry reset. The returned entity_id + url are the durable re-plug handle; persist " +
|
|
1692
|
+
"them (plus owner_token for anonymous drops) to update the entity in later sessions. " +
|
|
1693
|
+
(ctx.edition === "web"
|
|
1694
|
+
? "Pass the source inline via artifact_files."
|
|
1695
|
+
: "Pass the source as a local `path` (folder or file) or inline via artifact_files.") +
|
|
1696
|
+
" Note: in-place re-plug currently supports inspirations (HTML/static drops); to rebuild a deployed " +
|
|
1697
|
+
"app/agent, use the CloudGrid CLI (`cloudgrid plug`) in its linked folder.",
|
|
1698
|
+
inputSchema: plugInputSchema,
|
|
1699
|
+
outputSchema: {
|
|
1700
|
+
entity_id: z.string().optional().describe("Globally unique — pass back as target_entity_id to re-plug."),
|
|
1701
|
+
slug: z.string().optional().describe("Grid-scoped slug."),
|
|
1702
|
+
grid: z.string().nullable().optional().describe("Home grid slug; null for an anonymous Guest-Grid drop."),
|
|
1703
|
+
url: z.string().optional().describe("Canonical serving URL (stable across re-plugs; server-composed, flat-arch-aware)."),
|
|
1704
|
+
poll_url: z.string().optional().describe("Deploy status path while building (runtimes only)."),
|
|
1705
|
+
status: z.string().optional().describe("live | building | created | updated …"),
|
|
1706
|
+
claim_url: z.string().optional().describe("Anon create only: sign-in link to claim ownership."),
|
|
1707
|
+
claim_message: z.string().optional().describe("Anon create only: the claim nudge to relay."),
|
|
1708
|
+
owner_token: z.string().optional().describe("Anonymous drops: the bearer owner token (re-plug + claim). Re-minted on every anonymous edit — persist the newest."),
|
|
1709
|
+
},
|
|
1710
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
1711
|
+
},
|
|
1712
|
+
async (input) => {
|
|
1713
|
+
try {
|
|
1714
|
+
return okResult(await runPlug(ctx, input || {}));
|
|
1715
|
+
} catch (err) {
|
|
1716
|
+
return fail(err.message);
|
|
1717
|
+
}
|
|
1718
|
+
},
|
|
1719
|
+
);
|
|
1720
|
+
|
|
1721
|
+
// ── gridctl_fork / gridctl_download — direct-API verbs (spec v2 §5–6) ──────
|
|
1722
|
+
reg(
|
|
1723
|
+
"gridctl_fork",
|
|
1724
|
+
{
|
|
1725
|
+
description:
|
|
1726
|
+
"Start a NEW entity from an existing runtime (copy-on-write, lineage recorded). Lands in the " +
|
|
1727
|
+
"source's home grid by default; cross-grid only for system templates or forkable:'public' sources. " +
|
|
1728
|
+
"Requires sign-in. Calls POST /api/v2/runtimes/:id/fork directly.",
|
|
1729
|
+
inputSchema: {
|
|
1730
|
+
id: z.string().describe("The source runtime: a canonical UUID or <grid-slug>/<entity-slug>."),
|
|
1731
|
+
into_org_slug: z.string().optional().describe("Destination grid slug. Required only when you belong to more than one grid."),
|
|
1732
|
+
name: z.string().optional().describe("Slug for the new entity. Omit to derive one from the source."),
|
|
1733
|
+
source_version_id: z.string().optional().describe("Fork an older version instead of HEAD, e.g. v_a1b2c3d."),
|
|
1734
|
+
},
|
|
1735
|
+
outputSchema: {
|
|
1736
|
+
entity_id: z.string().nullable().describe("The new entity's id."),
|
|
1737
|
+
name: z.string().nullable().describe("The new entity's slug."),
|
|
1738
|
+
kind: z.string().nullable().describe("app | agent | inspiration."),
|
|
1739
|
+
grid_slug: z.string().nullable().describe("The grid the fork landed in."),
|
|
1740
|
+
forked_from: z.string().nullable().describe("Source entity_id."),
|
|
1741
|
+
forked_from_version_id: z.string().nullable().describe("Source version, when a specific one was forked."),
|
|
1742
|
+
current_version_id: z.string().nullable().describe("The fork's current version id."),
|
|
1743
|
+
},
|
|
1744
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
1745
|
+
},
|
|
1746
|
+
async (input) => {
|
|
1747
|
+
try {
|
|
1748
|
+
if (!input?.id) return fail("`id` is required (a canonical UUID or <grid-slug>/<entity-slug>).");
|
|
1749
|
+
return okResult(await runFork(ctx, input));
|
|
1750
|
+
} catch (err) {
|
|
1751
|
+
return fail(err.message);
|
|
1752
|
+
}
|
|
1753
|
+
},
|
|
1754
|
+
);
|
|
1755
|
+
|
|
1756
|
+
reg(
|
|
1757
|
+
"gridctl_download",
|
|
1758
|
+
{
|
|
1759
|
+
description:
|
|
1760
|
+
"Fetch the source bundle last deployed for a runtime: one signed, time-limited (15-minute) read URL " +
|
|
1761
|
+
"per service tarball. No entity is created and no registry state changes. Requires sign-in. Calls " +
|
|
1762
|
+
"GET /api/v2/runtimes/:id/source directly.",
|
|
1763
|
+
inputSchema: {
|
|
1764
|
+
id: z.string().describe("The runtime to download: a canonical UUID or <grid-slug>/<entity-slug>."),
|
|
1765
|
+
version: z.string().optional().describe("Download an older version's bundle instead of HEAD, e.g. v_a1b2c3d."),
|
|
1766
|
+
},
|
|
1767
|
+
outputSchema: {
|
|
1768
|
+
entity_id: z.string().nullable().describe("The runtime's entity id."),
|
|
1769
|
+
name: z.string().nullable().describe("The runtime's slug."),
|
|
1770
|
+
services: z.record(z.string()).describe("Service name → signed read URL (valid ~15 minutes)."),
|
|
1771
|
+
domain: z.string().nullable().optional().describe("The runtime's domain, if any."),
|
|
1772
|
+
},
|
|
1773
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
1774
|
+
},
|
|
1775
|
+
async (input) => {
|
|
1776
|
+
try {
|
|
1777
|
+
if (!input?.id) return fail("`id` is required (a canonical UUID or <grid-slug>/<entity-slug>).");
|
|
1778
|
+
return okResult(await runDownload(ctx, input));
|
|
1779
|
+
} catch (err) {
|
|
1780
|
+
return fail(err.message);
|
|
1781
|
+
}
|
|
1782
|
+
},
|
|
1783
|
+
);
|
|
1784
|
+
|
|
1040
1785
|
// Login — both editions. Local opens a browser and saves to the credentials
|
|
1041
1786
|
// file; web returns the URL and saves to the session.
|
|
1042
1787
|
reg(
|
|
@@ -1310,25 +2055,9 @@ export function registerTools(server, ctx) {
|
|
|
1310
2055
|
}, { cwdParam: true }),
|
|
1311
2056
|
);
|
|
1312
2057
|
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
{
|
|
1317
|
-
target: z.string().optional().describe("Path or URL. Omit to deploy the entity linked to the current directory."),
|
|
1318
|
-
org: z.string().optional().describe("Pick or override the org."),
|
|
1319
|
-
no_deploy: z.boolean().optional().describe("Register the entity but do not build or deploy."),
|
|
1320
|
-
cwd: z.string().optional().describe("Working directory. The CLI runs in this directory. Defaults to the MCP server's working directory."),
|
|
1321
|
-
},
|
|
1322
|
-
{ readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
1323
|
-
cliTool(({ target, org, no_deploy }) => {
|
|
1324
|
-
const args = ["plug"];
|
|
1325
|
-
if (target) args.push(target);
|
|
1326
|
-
if (org) args.push("--org", org);
|
|
1327
|
-
if (no_deploy) args.push("--no-deploy");
|
|
1328
|
-
args.push("--auto", "--no-clipboard", "--no-notify");
|
|
1329
|
-
return args;
|
|
1330
|
-
}, { cwdParam: true }),
|
|
1331
|
-
);
|
|
2058
|
+
// NOTE: gridctl_plug is no longer CLI-wrapping — the unified direct-API verb
|
|
2059
|
+
// (create + re-plug via POST /api/v2/plug) is registered above for BOTH
|
|
2060
|
+
// editions, per spec v2 §3.
|
|
1332
2061
|
|
|
1333
2062
|
regTool(
|
|
1334
2063
|
"gridctl_logs",
|