shotops-mcp 0.6.1 → 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/LICENSE +97 -0
- package/README.md +22 -6
- package/THIRD_PARTY_NOTICES +156 -0
- package/dist/local.js +961 -294
- package/harness-dist/assets/{index-XdyOXKM2.js → index-DvvjdOuK.js} +171 -171
- package/harness-dist/index.html +1 -1
- package/package.json +4 -3
- package/harness-dist/assets/pixel-optimized-DZsBrOgu.glb +0 -0
package/dist/local.js
CHANGED
|
@@ -68,9 +68,9 @@ function readBundle(name) {
|
|
|
68
68
|
function widgetHtml(bundle) {
|
|
69
69
|
return `<div id="shotops-root"></div><script type="module">${bundle}</script>`;
|
|
70
70
|
}
|
|
71
|
-
function registerWidgets(
|
|
71
|
+
function registerWidgets(server) {
|
|
72
72
|
const origin = supabaseOrigin();
|
|
73
|
-
registerWidget(
|
|
73
|
+
registerWidget(server, {
|
|
74
74
|
bundleFile: "panels.js",
|
|
75
75
|
canonicalUri: PANELS_WIDGET_URI,
|
|
76
76
|
uris: PANELS_WIDGET_URIS,
|
|
@@ -79,7 +79,7 @@ function registerWidgets(server2) {
|
|
|
79
79
|
description: "Shows the rendered App Store panels in a swipeable ShotOps gallery with per-panel download actions and render details.",
|
|
80
80
|
origin
|
|
81
81
|
});
|
|
82
|
-
registerWidget(
|
|
82
|
+
registerWidget(server, {
|
|
83
83
|
bundleFile: "bundle.js",
|
|
84
84
|
canonicalUri: BUNDLE_WIDGET_URI,
|
|
85
85
|
uris: BUNDLE_WIDGET_URIS,
|
|
@@ -89,11 +89,11 @@ function registerWidgets(server2) {
|
|
|
89
89
|
origin
|
|
90
90
|
});
|
|
91
91
|
}
|
|
92
|
-
function registerWidget(
|
|
92
|
+
function registerWidget(server, spec) {
|
|
93
93
|
const bundle = readBundle(spec.bundleFile);
|
|
94
94
|
if (!bundle) return;
|
|
95
95
|
for (const uri of spec.uris) {
|
|
96
|
-
|
|
96
|
+
server.registerResource(
|
|
97
97
|
uri === spec.canonicalUri ? spec.slug : `${spec.slug}-${uri.split("/").at(-1)}`,
|
|
98
98
|
uri,
|
|
99
99
|
{
|
|
@@ -143,8 +143,8 @@ var PROJECT_SCHEMA_VERSION = 2;
|
|
|
143
143
|
var IPHONE_MODEL_ID = "iphone-17-pro";
|
|
144
144
|
var PIXEL_MODEL_ID = "pixel-10-pro";
|
|
145
145
|
var DEVICES = [
|
|
146
|
-
{ id: "iphone-6-9", label: "iPhone
|
|
147
|
-
{ id: "iphone-6-5", label: "iPhone
|
|
146
|
+
{ id: "iphone-6-9", label: "iPhone 17 Pro", panelPresetId: "r69", ascDeviceSet: "APP_IPHONE_67", modelId: IPHONE_MODEL_ID, screenFamily: "iphone" },
|
|
147
|
+
{ id: "iphone-6-5", label: "iPhone 17", panelPresetId: "r65", ascDeviceSet: "APP_IPHONE_65", modelId: IPHONE_MODEL_ID, screenFamily: "iphone" },
|
|
148
148
|
{ id: "pixel-10-pro", label: "Pixel 10 Pro", panelPresetId: "pixel69", ascDeviceSet: "ANDROID_PHONE", modelId: PIXEL_MODEL_ID, screenFamily: "android" },
|
|
149
149
|
{ id: "ipad-13", label: "iPad 13\u2033", panelPresetId: "ipad13", ascDeviceSet: "APP_IPAD_PRO_129", modelId: "ipad-pro", screenFamily: "ipad", comingSoon: true },
|
|
150
150
|
{ id: "ipad-12-9", label: "iPad 12.9\u2033", panelPresetId: "ipad129", ascDeviceSet: "APP_IPAD_PRO_3GEN_129", modelId: "ipad-pro", screenFamily: "ipad", comingSoon: true }
|
|
@@ -868,8 +868,10 @@ var SERVER_INSTRUCTIONS = [
|
|
|
868
868
|
" position, glare, reflections, lighting, flatScreen. Those three PLACEMENT fields are the ones",
|
|
869
869
|
" style.layout sets for you \u2014 prefer it and use these to adjust the result. Every numeric field",
|
|
870
870
|
" here is RANGE-CHECKED: out of range is rejected with the range and the fix, not clamped.",
|
|
871
|
-
"- style.background (the strip): mode gradient|perPanel;
|
|
872
|
-
"
|
|
871
|
+
"- style.background (the strip): mode gradient|perPanel; stops [{color,at}] (hex colour + 0\u20131",
|
|
872
|
+
" position along the gradient line \u2014 one stop is a solid, two is a classic top/bottom gradient,",
|
|
873
|
+
' more is a multi-colour sweep) and angle (degrees clockwise from "to top", CSS convention:',
|
|
874
|
+
" 90 = right, 180 = down); shadow, floorReflection; or panelColors[] (one hex per slot) for perPanel.",
|
|
873
875
|
"- style.captions[] (ONE entry per panel, in slot order; null = no caption). An entry is EITHER a",
|
|
874
876
|
" single caption object OR an array of layers stacked on that panel: [{text,\u2026},{text,\u2026}]. Fields:",
|
|
875
877
|
" text (this layer\u2019s words), subtitle (single-caption shape only \u2014 in the array shape add another",
|
|
@@ -1036,6 +1038,20 @@ var SERVER_INSTRUCTIONS = [
|
|
|
1036
1038
|
"reproduces the human-tuned design with zero manual step. Zero-custody: source screenshots are",
|
|
1037
1039
|
"ephemeral (one render, then discarded); only the styling look is persisted, never the pixels."
|
|
1038
1040
|
].join("\n");
|
|
1041
|
+
var ANONYMOUS_TOOLS = /* @__PURE__ */ new Set(["import_screenshot", "render_strip"]);
|
|
1042
|
+
var ANONYMOUS_MODE_INSTRUCTIONS = [
|
|
1043
|
+
"",
|
|
1044
|
+
"## This connection is NOT signed in",
|
|
1045
|
+
"The user has not connected a ShotOps account yet, so this connection can do exactly one thing,",
|
|
1046
|
+
"and it is worth doing: import their screenshots (import_screenshot) and render ONE preview strip",
|
|
1047
|
+
"of up to 5 panels (render_strip with `preview: true`). Do that first \u2014 showing them their own",
|
|
1048
|
+
"app as a store strip is the point. Pick their best 5 screens if they give you more.",
|
|
1049
|
+
"Every other tool \u2014 save_project, render_project, emit_bundle, the look tools, upload slots,",
|
|
1050
|
+
"delete_assets \u2014 needs them to connect ShotOps, and answers saying so. Do not pretend otherwise,",
|
|
1051
|
+
"and do not ask them to sign in BEFORE the free preview: render it, show it, and then offer the",
|
|
1052
|
+
"account for the things it unlocks (keeping the project, full-resolution panels, the fastlane",
|
|
1053
|
+
"bundle, more renders)."
|
|
1054
|
+
].join("\n");
|
|
1039
1055
|
|
|
1040
1056
|
// src/pngOptimizer.ts
|
|
1041
1057
|
import sharp from "sharp";
|
|
@@ -1069,8 +1085,15 @@ async function optimizeScreenshotPng(bytes, what = "Screenshot") {
|
|
|
1069
1085
|
|
|
1070
1086
|
// src/uploads.ts
|
|
1071
1087
|
var DOWNLOAD_URL_TTL_SECONDS = 60 * 60;
|
|
1072
|
-
|
|
1073
|
-
|
|
1088
|
+
var ANONYMOUS_USER_PREFIX = "anon-";
|
|
1089
|
+
var ANONYMOUS_ASSET_TTL_MS = 60 * 60 * 1e3;
|
|
1090
|
+
var ANONYMOUS_DIR_EXPIRY_RE = /^t(\d{13,})-/;
|
|
1091
|
+
function uploadDirFor(userId, now = Date.now()) {
|
|
1092
|
+
const random = randomBytes(12).toString("base64url");
|
|
1093
|
+
if (userId.startsWith(ANONYMOUS_USER_PREFIX)) {
|
|
1094
|
+
return `uploads/${userId}/t${now + ANONYMOUS_ASSET_TTL_MS}-${random}`;
|
|
1095
|
+
}
|
|
1096
|
+
return `uploads/${userId}/${random}`;
|
|
1074
1097
|
}
|
|
1075
1098
|
var NAME_SEGMENT_RE = /^\d+__([A-Za-z0-9_-]+)\.png$/;
|
|
1076
1099
|
function decodeNameFromRef(ref) {
|
|
@@ -1083,8 +1106,13 @@ function decodeNameFromRef(ref) {
|
|
|
1083
1106
|
return null;
|
|
1084
1107
|
}
|
|
1085
1108
|
}
|
|
1086
|
-
function refBelongsToUser(ref, userId) {
|
|
1087
|
-
|
|
1109
|
+
function refBelongsToUser(ref, userId, now = Date.now()) {
|
|
1110
|
+
const prefix = `uploads/${userId}/`;
|
|
1111
|
+
if (!ref.startsWith(prefix) || ref.includes("..")) return false;
|
|
1112
|
+
if (!userId.startsWith(ANONYMOUS_USER_PREFIX)) return true;
|
|
1113
|
+
const dir = ref.slice(prefix.length).split("/")[0];
|
|
1114
|
+
const expiry = ANONYMOUS_DIR_EXPIRY_RE.exec(dir);
|
|
1115
|
+
return expiry ? Number(expiry[1]) > now : false;
|
|
1088
1116
|
}
|
|
1089
1117
|
async function fetchUploadedRef(ref, userId) {
|
|
1090
1118
|
if (!refBelongsToUser(ref, userId)) {
|
|
@@ -1432,8 +1460,8 @@ import { z as z4 } from "zod";
|
|
|
1432
1460
|
var RENDERABLE_PANEL_PRESET_IDS = ["r69", "r65", "r55"];
|
|
1433
1461
|
var OUTPUT_DEVICE_IDS = ["iphone-6-9", "iphone-6-5"];
|
|
1434
1462
|
var OUTPUT_DEVICES = [
|
|
1435
|
-
{ id: "iphone-6-9", label: "iPhone
|
|
1436
|
-
{ id: "iphone-6-5", label: "iPhone
|
|
1463
|
+
{ id: "iphone-6-9", label: "iPhone 17 Pro", panelPresetId: "r69", ascDeviceSet: "APP_IPHONE_67" },
|
|
1464
|
+
{ id: "iphone-6-5", label: "iPhone 17", panelPresetId: "r65", ascDeviceSet: "APP_IPHONE_65" }
|
|
1437
1465
|
];
|
|
1438
1466
|
function outputDeviceFor(id) {
|
|
1439
1467
|
return OUTPUT_DEVICES.find((device) => device.id === id);
|
|
@@ -1551,6 +1579,58 @@ var SHOT_LOOK_RANGES = {
|
|
|
1551
1579
|
clearcoat: { min: 0, max: 1 }
|
|
1552
1580
|
};
|
|
1553
1581
|
|
|
1582
|
+
// ../mockup-engine/gradient.ts
|
|
1583
|
+
var HORIZONTAL_ANGLE = 90;
|
|
1584
|
+
var VERTICAL_ANGLE = 180;
|
|
1585
|
+
function angleFromDirection(dir) {
|
|
1586
|
+
return dir === "horizontal" ? HORIZONTAL_ANGLE : VERTICAL_ANGLE;
|
|
1587
|
+
}
|
|
1588
|
+
function directionFromAngle(angle) {
|
|
1589
|
+
const t = (angle % 180 + 180) % 180;
|
|
1590
|
+
return t >= 45 && t < 135 ? "horizontal" : "vertical";
|
|
1591
|
+
}
|
|
1592
|
+
function gradientFromLegacy(from, to, dir) {
|
|
1593
|
+
return {
|
|
1594
|
+
stops: [
|
|
1595
|
+
{ color: from, at: 0 },
|
|
1596
|
+
{ color: to, at: 1 }
|
|
1597
|
+
],
|
|
1598
|
+
angle: angleFromDirection(dir)
|
|
1599
|
+
};
|
|
1600
|
+
}
|
|
1601
|
+
function mirrorsOf(gradient) {
|
|
1602
|
+
const first = gradient.stops[0];
|
|
1603
|
+
const last = gradient.stops[gradient.stops.length - 1];
|
|
1604
|
+
return {
|
|
1605
|
+
gradientFrom: first?.color ?? "#000000",
|
|
1606
|
+
gradientTo: last?.color ?? first?.color ?? "#000000",
|
|
1607
|
+
gradientDir: directionFromAngle(gradient.angle)
|
|
1608
|
+
};
|
|
1609
|
+
}
|
|
1610
|
+
function normalizeGradient(rawStops, rawAngle) {
|
|
1611
|
+
if (!Array.isArray(rawStops) || rawStops.length === 0) return null;
|
|
1612
|
+
const stops = [];
|
|
1613
|
+
for (const entry of rawStops) {
|
|
1614
|
+
if (!entry || typeof entry !== "object") return null;
|
|
1615
|
+
const e = entry;
|
|
1616
|
+
if (typeof e.color !== "string" || typeof e.at !== "number" || !Number.isFinite(e.at)) return null;
|
|
1617
|
+
stops.push({ color: e.color, at: Math.min(1, Math.max(0, e.at)) });
|
|
1618
|
+
}
|
|
1619
|
+
const angle = typeof rawAngle === "number" && Number.isFinite(rawAngle) ? normalizeAngle(rawAngle) : VERTICAL_ANGLE;
|
|
1620
|
+
return { stops, angle };
|
|
1621
|
+
}
|
|
1622
|
+
function stripGradientOf(fields) {
|
|
1623
|
+
return normalizeGradient(fields.gradientStops, fields.gradientAngle) ?? gradientFromLegacy(fields.gradientFrom, fields.gradientTo, fields.gradientDir);
|
|
1624
|
+
}
|
|
1625
|
+
function normalizeAngle(angle) {
|
|
1626
|
+
return (angle % 360 + 360) % 360;
|
|
1627
|
+
}
|
|
1628
|
+
function gradientsMatch(a, b) {
|
|
1629
|
+
if (normalizeAngle(a.angle) !== normalizeAngle(b.angle)) return false;
|
|
1630
|
+
if (a.stops.length !== b.stops.length) return false;
|
|
1631
|
+
return a.stops.every((s, i) => s.color === b.stops[i].color && s.at === b.stops[i].at);
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1554
1634
|
// ../mockup-engine/appstore/designPresets.ts
|
|
1555
1635
|
var DESIGN_PRESETS = [
|
|
1556
1636
|
{
|
|
@@ -1604,7 +1684,16 @@ var DESIGN_PRESET_IDS = DESIGN_PRESETS.map((p) => p.id);
|
|
|
1604
1684
|
function designPresetToStyle(preset, captionCount = 0) {
|
|
1605
1685
|
return {
|
|
1606
1686
|
shotLook: { ...preset.shotLook },
|
|
1607
|
-
background: {
|
|
1687
|
+
background: {
|
|
1688
|
+
mode: "gradient",
|
|
1689
|
+
stops: [
|
|
1690
|
+
{ color: preset.background.gradientFrom, at: 0 },
|
|
1691
|
+
{ color: preset.background.gradientTo, at: 1 }
|
|
1692
|
+
],
|
|
1693
|
+
angle: angleFromDirection(preset.background.gradientDir),
|
|
1694
|
+
shadow: preset.background.shadow,
|
|
1695
|
+
floorReflection: preset.background.floorReflection
|
|
1696
|
+
},
|
|
1608
1697
|
...captionCount > 0 ? { captions: Array.from({ length: captionCount }, () => ({ ...preset.caption })) } : {}
|
|
1609
1698
|
};
|
|
1610
1699
|
}
|
|
@@ -1772,9 +1861,20 @@ var DESCRIBE_LOOK_CATALOG = {
|
|
|
1772
1861
|
},
|
|
1773
1862
|
background: {
|
|
1774
1863
|
description: "The strip background.",
|
|
1775
|
-
defaults: {
|
|
1864
|
+
defaults: {
|
|
1865
|
+
mode: "gradient",
|
|
1866
|
+
stops: [
|
|
1867
|
+
{ color: "#1b1b2e", at: 0 },
|
|
1868
|
+
{ color: "#0a0a14", at: 1 }
|
|
1869
|
+
],
|
|
1870
|
+
angle: 180,
|
|
1871
|
+
shadow: true,
|
|
1872
|
+
floorReflection: false
|
|
1873
|
+
},
|
|
1776
1874
|
fields: {
|
|
1777
1875
|
mode: "gradient | perPanel",
|
|
1876
|
+
stops: "gradient colour stops, in order along the line \u2014 { color (hex), at (0\u20131 position) }. Two stops is the classic top/bottom gradient, more make a multi-colour sweep, one stop is a solid fill.",
|
|
1877
|
+
angle: 'gradient angle in degrees, clockwise from "to top" (CSS linear-gradient convention): 90 = to right, 180 = to bottom (default), 270 = to left. Any value folds into 0\u2013360.',
|
|
1778
1878
|
panelColors: 'per-panel hex colours (mode "perPanel"), one entry per slot in order',
|
|
1779
1879
|
floorReflection: "boolean \u2014 a flipped, faded floor reflection under each phone"
|
|
1780
1880
|
}
|
|
@@ -1801,9 +1901,11 @@ var DESCRIBE_LOOK_CATALOG = {
|
|
|
1801
1901
|
shots: "The devices: one { panelId, look } per SCREENSHOT, flattened in panel order. Each `look` takes the SAME fields as style.shotLook. Repeat a panelId when several screenshots share one panel; each entry still has independent colour/material/angle.",
|
|
1802
1902
|
panelPresetId: "canvas size preset id (see panelPresets); the top-level panelPresetId param overrides it",
|
|
1803
1903
|
bgMode: "gradient | perPanel",
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1904
|
+
gradientStops: "canonical gradient stops \u2014 [{ color (hex), at (0\u20131 position) }, \u2026] \u2014 wins over gradientFrom/gradientTo/gradientDir when present. This is where a Studio-authored multi-stop backdrop comes back; the trio below is only ever a two-stop summary of it.",
|
|
1905
|
+
gradientAngle: 'canonical gradient angle in degrees, clockwise from "to top" (CSS convention: 90 = right, 180 = down, the gradientDir:"vertical" equivalent). Paired with gradientStops.',
|
|
1906
|
+
gradientFrom: "gradient start (hex) \u2014 a derived mirror of gradientStops[0]. Default #1b1b2e",
|
|
1907
|
+
gradientTo: "gradient end (hex) \u2014 a derived mirror of the last gradientStops entry. Default #0a0a14",
|
|
1908
|
+
gradientDir: "vertical | horizontal \u2014 a derived mirror of gradientAngle. Default vertical",
|
|
1807
1909
|
panelColors: 'per-panel flat colours keyed by panel id (mode perPanel), e.g. { "panel-1": "#123456" }',
|
|
1808
1910
|
shadow: "boolean \u2014 drop shadow under the phones. Default true",
|
|
1809
1911
|
floorReflection: "boolean \u2014 flipped, faded floor reflection. Default false",
|
|
@@ -1902,11 +2004,19 @@ var makeShotLook = (num) => z2.object({
|
|
|
1902
2004
|
}).strict();
|
|
1903
2005
|
var shotLook = makeShotLook(boundedNumeric);
|
|
1904
2006
|
var advertisedShotLook = makeShotLook(() => numeric);
|
|
2007
|
+
var gradientStop = z2.object({ color: z2.string(), at: z2.number().min(0).max(1) }).strict();
|
|
1905
2008
|
var background = z2.object({
|
|
1906
2009
|
mode: z2.enum(["gradient", "perPanel"]).optional().describe("One gradient across the strip, or a flat colour per panel."),
|
|
1907
|
-
|
|
1908
|
-
gradientTo
|
|
1909
|
-
|
|
2010
|
+
// #463/#460 decision 7 — the render_strip INPUT drops the old two-stop `gradientFrom`/
|
|
2011
|
+
// `gradientTo`/`gradientDir` shorthand entirely (removed, not deprecated): `stops` + `angle`
|
|
2012
|
+
// are the only accepted form here. The persisted-look shape below (makeLookObject) is
|
|
2013
|
+
// DIFFERENT — it keeps the trio as a derived mirror, per decision 6.
|
|
2014
|
+
stops: z2.array(gradientStop).min(1).optional().describe(
|
|
2015
|
+
'Gradient colour stops, in order along the gradient line \u2014 { color (hex), at (0\u20131 position; 0 = line start, 1 = line end) }. Two stops is the classic top/bottom gradient, more make a multi-colour sweep, one stop is a solid fill. Default a two-stop navy gradient: [{color:"#1b1b2e",at:0},{color:"#0a0a14",at:1}].'
|
|
2016
|
+
),
|
|
2017
|
+
angle: z2.number().optional().describe(
|
|
2018
|
+
'Gradient angle in degrees, clockwise from "to top" \u2014 CSS `linear-gradient()` convention: 90 = to right, 180 = to bottom (the old "vertical" default, and this field\u2019s own default), 270 = to left. Any value folds into 0\u2013360.'
|
|
2019
|
+
),
|
|
1910
2020
|
panelColors: z2.array(z2.string().nullable()).max(10).optional().describe('Per-panel flat colours (mode "perPanel"), one entry per slot in order; null = default.'),
|
|
1911
2021
|
shadow: z2.boolean().optional().describe("Drop shadow under the phones. Default true."),
|
|
1912
2022
|
floorReflection: z2.boolean().optional().describe("Flipped, faded floor reflection under each phone. Default false.")
|
|
@@ -1995,9 +2105,21 @@ var makeLookObject = (lookShot) => z2.object({
|
|
|
1995
2105
|
schema: z2.number().optional().describe("Look schema version. The server stamps it on every save (save_look); read_look returns it. Any input value is ignored \u2014 never set it yourself."),
|
|
1996
2106
|
panelPresetId: z2.string().optional().describe("Canvas size preset, e.g. r69 = 1290\xD72796. Default r69."),
|
|
1997
2107
|
bgMode: z2.enum(["gradient", "perPanel"]).optional().describe("One gradient across the strip, or a flat colour per panel. Default gradient."),
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
gradientDir
|
|
2108
|
+
// #460 decision 6 — UNLIKE render_strip's `style.background` above, the PERSISTED look keeps
|
|
2109
|
+
// both forms: `gradientStops`/`gradientAngle` are canonical (win when present), and
|
|
2110
|
+
// gradientFrom/gradientTo/gradientDir stay as a derived MIRROR the server writes alongside
|
|
2111
|
+
// them, so a reader that predates multi-stop gradients still gets a plausible two-stop
|
|
2112
|
+
// backdrop. This is why LOOK_SCHEMA_VERSION does not have to move — see docs/reference/
|
|
2113
|
+
// studio-look-system.md on what bumping it would discard.
|
|
2114
|
+
gradientStops: z2.array(gradientStop).min(1).optional().describe(
|
|
2115
|
+
"Canonical gradient stops \u2014 { color (hex), at (0\u20131 position) } \u2014 wins over gradientFrom/gradientTo/gradientDir when present. Read this back for the full stop list a Studio-authored look may carry; the trio below is only ever a two-stop summary."
|
|
2116
|
+
),
|
|
2117
|
+
gradientAngle: z2.number().optional().describe(
|
|
2118
|
+
'Canonical gradient angle in degrees, clockwise from "to top" (CSS convention: 90 = right, 180 = down, the gradientDir:"vertical" equivalent). Paired with gradientStops.'
|
|
2119
|
+
),
|
|
2120
|
+
gradientFrom: z2.string().optional().describe("Gradient start (hex) \u2014 a derived mirror of gradientStops[0]. Default #1b1b2e."),
|
|
2121
|
+
gradientTo: z2.string().optional().describe("Gradient end (hex) \u2014 a derived mirror of the last gradientStops entry. Default #0a0a14."),
|
|
2122
|
+
gradientDir: z2.enum(["horizontal", "vertical"]).optional().describe("A derived mirror of gradientAngle (nearest axis). Default vertical."),
|
|
2001
2123
|
panelColors: z2.record(z2.string(), z2.string()).optional().describe("Per-panel flat colours (mode perPanel), keyed by panel id."),
|
|
2002
2124
|
shadow: z2.boolean().optional().describe("Drop shadow under the phones. Default true."),
|
|
2003
2125
|
floorReflection: z2.boolean().optional().describe("Flipped, faded floor reflection under each phone. Default false."),
|
|
@@ -2029,6 +2151,13 @@ var lookHistoryAccess = z3.object({
|
|
|
2029
2151
|
var noteField = z3.string().optional().describe(
|
|
2030
2152
|
"Free-text advisories about THIS call, joined into one string. Written when the server did something you did not literally ask for or could not have known: a look/version fallback, an output-mode downgrade, a device this server cannot render, a `style.shotLook` a `look` overrode, or placement set by hand where a `style.layout` template would have produced the same composition. Never an error \u2014 the call succeeded. Read it, and act on it next call."
|
|
2031
2153
|
);
|
|
2154
|
+
var creditsField = z3.object({
|
|
2155
|
+
debited: z3.number(),
|
|
2156
|
+
remaining: z3.number(),
|
|
2157
|
+
refillAt: z3.string().optional()
|
|
2158
|
+
}).passthrough().optional().describe(
|
|
2159
|
+
"Credits this call spent from the account wallet, the balance left afterwards, and when that wallet is next topped up (ISO 8601; absent when no further refill is scheduled). Renders cost per panel; reads, saves, uploads and deletes are free, as is bundling panels that were already rendered."
|
|
2160
|
+
);
|
|
2032
2161
|
var renderStripOutputSchema = z3.object({
|
|
2033
2162
|
ok: z3.boolean(),
|
|
2034
2163
|
count: z3.number(),
|
|
@@ -2049,6 +2178,7 @@ var renderStripOutputSchema = z3.object({
|
|
|
2049
2178
|
tool: z3.literal("save_project"),
|
|
2050
2179
|
reason: z3.string()
|
|
2051
2180
|
}).optional(),
|
|
2181
|
+
credits: creditsField,
|
|
2052
2182
|
note: noteField,
|
|
2053
2183
|
panels: z3.array(panelOutput)
|
|
2054
2184
|
}).passthrough();
|
|
@@ -2058,6 +2188,7 @@ var emitBundleOutputSchema = z3.object({
|
|
|
2058
2188
|
bundleId: z3.string(),
|
|
2059
2189
|
panelCount: z3.number(),
|
|
2060
2190
|
output: z3.enum(["inline", "urls"]),
|
|
2191
|
+
credits: creditsField,
|
|
2061
2192
|
note: noteField
|
|
2062
2193
|
}).passthrough();
|
|
2063
2194
|
var saveProjectOutputSchema = z3.object({
|
|
@@ -2290,7 +2421,7 @@ var clip = z4.enum(["strip", "panel"]).optional().describe(
|
|
|
2290
2421
|
"How the strip handles a device that overflows its panel. 'strip' (default) = one continuous canvas \u2014 a phone can straddle the seam into the next panel (swipe-through scenes; also what hOffset \xB11 opts into). 'panel' = clip each phone to its OWN panel so it can't bleed a foreign edge into the neighbouring App Store screenshot (independent images). A render option, not part of the saved look."
|
|
2291
2422
|
);
|
|
2292
2423
|
var preview = z4.boolean().optional().describe(
|
|
2293
|
-
"Render at ~25% resolution for a fast
|
|
2424
|
+
"Render at ~25% resolution for a fast styling preview at a quarter the render cost \u2014 small enough to always come back inline. Not for final delivery: re-render without `preview` (or use emit_bundle directly) once the look is right."
|
|
2294
2425
|
);
|
|
2295
2426
|
var panelPresetId = z4.enum(RENDERABLE_PANEL_PRESET_IDS).optional().describe("App Store screenshot size. Default r69 (6.9\u2033 iPhone, 1290\xD72796). iPhone sizes only \u2014 iPad is not rendered yet.");
|
|
2296
2427
|
var useSavedLook = z4.boolean().optional().describe("If true, style the strip with the project's saved look (ignored when `look`/`style` is given).");
|
|
@@ -2987,6 +3118,9 @@ function buildMultiOutputFastlaneBundleZip(targets, opts) {
|
|
|
2987
3118
|
}
|
|
2988
3119
|
|
|
2989
3120
|
// ../mockup-engine/appstore/panel.ts
|
|
3121
|
+
function panelGradientOf(bg) {
|
|
3122
|
+
return normalizeGradient(bg.stops, bg.angle) ?? gradientFromLegacy(bg.from, bg.to, bg.dir);
|
|
3123
|
+
}
|
|
2990
3124
|
function normalizePanelBackgrounds(raw) {
|
|
2991
3125
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
2992
3126
|
const out = {};
|
|
@@ -2996,7 +3130,8 @@ function normalizePanelBackgrounds(raw) {
|
|
|
2996
3130
|
if (e.kind === "solid" && typeof e.color === "string") {
|
|
2997
3131
|
out[panelId] = { kind: "solid", color: e.color };
|
|
2998
3132
|
} else if (e.kind === "gradient" && typeof e.from === "string" && typeof e.to === "string" && (e.dir === "horizontal" || e.dir === "vertical")) {
|
|
2999
|
-
|
|
3133
|
+
const canonical = normalizeGradient(e.stops, e.angle);
|
|
3134
|
+
out[panelId] = canonical ? { kind: "gradient", stops: canonical.stops, angle: canonical.angle, from: e.from, to: e.to, dir: e.dir } : { kind: "gradient", from: e.from, to: e.to, dir: e.dir };
|
|
3000
3135
|
}
|
|
3001
3136
|
}
|
|
3002
3137
|
return out;
|
|
@@ -3087,18 +3222,22 @@ function shotsInPanelOrder(shots, panels) {
|
|
|
3087
3222
|
}
|
|
3088
3223
|
|
|
3089
3224
|
// ../mockup-engine/appstore/serialize.ts
|
|
3090
|
-
function extractStripStyle(state, captionText) {
|
|
3225
|
+
function extractStripStyle(state, captionText, baseLocale = DEFAULT_LOCALE) {
|
|
3226
|
+
const gradient = stripGradientOf(state);
|
|
3091
3227
|
return {
|
|
3092
3228
|
panelPresetId: state.panelPresetId,
|
|
3093
3229
|
bgMode: state.bgMode,
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3230
|
+
gradientStops: gradient.stops,
|
|
3231
|
+
gradientAngle: gradient.angle,
|
|
3232
|
+
...mirrorsOf(gradient),
|
|
3097
3233
|
panelColors: state.panelColors,
|
|
3098
3234
|
shadow: state.shadow,
|
|
3099
3235
|
floorReflection: state.floorReflection ?? false,
|
|
3100
3236
|
panelBackgrounds: state.panelBackgrounds ?? {},
|
|
3101
|
-
captionStyles: {
|
|
3237
|
+
captionStyles: {
|
|
3238
|
+
...normalizeCaptionLayerStyles(state.captionStyles),
|
|
3239
|
+
...captionStylesFromLayers(captionText, baseLocale)
|
|
3240
|
+
}
|
|
3102
3241
|
};
|
|
3103
3242
|
}
|
|
3104
3243
|
|
|
@@ -3146,7 +3285,10 @@ function serializeProject(state) {
|
|
|
3146
3285
|
// extractStripStyle (invariant 2/3). Normalized so a malformed/base-family key can't ride through.
|
|
3147
3286
|
// {} drops to an empty object (a pre-#88 project stays effectively unchanged).
|
|
3148
3287
|
outputCaptions: normalizeOutputCaptionText(state.outputCaptions),
|
|
3149
|
-
|
|
3288
|
+
// #94 follow-through — thread the project's real base locale into the caption-style projection
|
|
3289
|
+
// so `captionStylesFromLayers` picks THIS project's base first (deterministic), not en-US-first
|
|
3290
|
+
// falling through to Object.keys() insertion order for any project not based on en-US.
|
|
3291
|
+
...extractStripStyle(state, captionText, baseLocale)
|
|
3150
3292
|
};
|
|
3151
3293
|
}
|
|
3152
3294
|
|
|
@@ -3184,15 +3326,21 @@ function panelColorsByOrdinal(look2) {
|
|
|
3184
3326
|
function captionStylesByOrdinal(look2) {
|
|
3185
3327
|
return byPanelOrdinal(look2, look2.captionStyles);
|
|
3186
3328
|
}
|
|
3329
|
+
function comparablePanelBackground(bg) {
|
|
3330
|
+
return bg.kind === "solid" ? bg : { kind: "gradient", gradient: panelGradientOf(bg) };
|
|
3331
|
+
}
|
|
3187
3332
|
function panelBackgroundsByOrdinal(look2) {
|
|
3188
|
-
|
|
3333
|
+
const byId = byPanelOrdinal(look2, look2.panelBackgrounds);
|
|
3334
|
+
const out = {};
|
|
3335
|
+
for (const [ordinal, bg] of Object.entries(byId)) {
|
|
3336
|
+
out[Number(ordinal)] = comparablePanelBackground(bg);
|
|
3337
|
+
}
|
|
3338
|
+
return out;
|
|
3189
3339
|
}
|
|
3190
3340
|
function looksMatch(a, b) {
|
|
3191
3341
|
if (a.bgMode !== b.bgMode) return false;
|
|
3192
3342
|
if (a.panelPresetId !== b.panelPresetId) return false;
|
|
3193
|
-
if (a
|
|
3194
|
-
if (a.gradientTo !== b.gradientTo) return false;
|
|
3195
|
-
if (a.gradientDir !== b.gradientDir) return false;
|
|
3343
|
+
if (!gradientsMatch(stripGradientOf(a), stripGradientOf(b))) return false;
|
|
3196
3344
|
if (a.shadow !== b.shadow) return false;
|
|
3197
3345
|
if (!!a.floorReflection !== !!b.floorReflection) return false;
|
|
3198
3346
|
if (a.shots.length !== b.shots.length) return false;
|
|
@@ -3203,9 +3351,67 @@ function looksMatch(a, b) {
|
|
|
3203
3351
|
return deepEqual(panelColorsByOrdinal(a), panelColorsByOrdinal(b));
|
|
3204
3352
|
}
|
|
3205
3353
|
|
|
3354
|
+
// src/renderCredits.ts
|
|
3355
|
+
import { randomUUID } from "node:crypto";
|
|
3356
|
+
function openChargeScope(deps) {
|
|
3357
|
+
const charged = [];
|
|
3358
|
+
let block;
|
|
3359
|
+
let refunded = false;
|
|
3360
|
+
return {
|
|
3361
|
+
charge: async (plan) => {
|
|
3362
|
+
if (deps.anonymous) {
|
|
3363
|
+
if (plan.panels <= 0) return null;
|
|
3364
|
+
return deps.anonymous.admit(plan);
|
|
3365
|
+
}
|
|
3366
|
+
if (!deps.chargeRender || plan.panels <= 0) return null;
|
|
3367
|
+
const renderId = randomUUID();
|
|
3368
|
+
const outcome = await deps.chargeRender(deps.userId, { renderId, ...plan });
|
|
3369
|
+
if (!outcome.ok) return { error: outcome.message };
|
|
3370
|
+
const { debited, remaining, refillAt } = outcome.receipt;
|
|
3371
|
+
if (debited > 0) charged.push({ renderId, debited });
|
|
3372
|
+
block = {
|
|
3373
|
+
debited: (block?.debited ?? 0) + debited,
|
|
3374
|
+
remaining,
|
|
3375
|
+
...refillAt !== null ? { refillAt: new Date(refillAt).toISOString() } : {}
|
|
3376
|
+
};
|
|
3377
|
+
return null;
|
|
3378
|
+
},
|
|
3379
|
+
credits: () => block,
|
|
3380
|
+
refund: async (reason) => {
|
|
3381
|
+
if (deps.anonymous) return;
|
|
3382
|
+
if (refunded || !deps.refundRender) return;
|
|
3383
|
+
refunded = true;
|
|
3384
|
+
for (const entry of charged) {
|
|
3385
|
+
await deps.refundRender(deps.userId, entry.renderId, entry.debited, reason).catch(() => {
|
|
3386
|
+
});
|
|
3387
|
+
}
|
|
3388
|
+
}
|
|
3389
|
+
};
|
|
3390
|
+
}
|
|
3391
|
+
async function runCharged(scope, run) {
|
|
3392
|
+
let result;
|
|
3393
|
+
try {
|
|
3394
|
+
result = await run();
|
|
3395
|
+
} catch (err) {
|
|
3396
|
+
await scope.refund(`render failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
3397
|
+
throw err;
|
|
3398
|
+
}
|
|
3399
|
+
if (result.isError === true) await scope.refund(errorReason(result));
|
|
3400
|
+
return result;
|
|
3401
|
+
}
|
|
3402
|
+
function errorReason(result) {
|
|
3403
|
+
const first = result.content?.[0];
|
|
3404
|
+
const text = first && first.type === "text" ? first.text : "";
|
|
3405
|
+
return `render did not complete: ${(text || "unknown error").slice(0, 160)}`;
|
|
3406
|
+
}
|
|
3407
|
+
|
|
3206
3408
|
// src/toolsRender.ts
|
|
3207
|
-
|
|
3208
|
-
const
|
|
3409
|
+
function handleRenderStrip(deps, args) {
|
|
3410
|
+
const scope = openChargeScope(deps);
|
|
3411
|
+
return runCharged(scope, () => renderStrip(deps, args, scope));
|
|
3412
|
+
}
|
|
3413
|
+
async function renderStrip(deps, args, scope) {
|
|
3414
|
+
const { look: look2, style: style2, note: resolveNote } = await resolveLook(deps, args);
|
|
3209
3415
|
const note = [resolveNote, composeConflictNote(look2, style2)].filter(Boolean).join(" ") || void 0;
|
|
3210
3416
|
const locale2 = args.locale?.trim() || "en-US";
|
|
3211
3417
|
const screenshotSlots = normalizeScreenshotSlots(args.screenshots);
|
|
@@ -3216,13 +3422,18 @@ async function handleRenderStrip(deps2, args) {
|
|
|
3216
3422
|
} catch (err) {
|
|
3217
3423
|
return errorResult(err.message);
|
|
3218
3424
|
}
|
|
3425
|
+
const refused = await scope.charge({
|
|
3426
|
+
panels: entries.length,
|
|
3427
|
+
resolution: args.preview ? "preview" : "full"
|
|
3428
|
+
});
|
|
3429
|
+
if (refused) return errorResult(refused.error);
|
|
3219
3430
|
let screenshots2;
|
|
3220
3431
|
try {
|
|
3221
|
-
screenshots2 = await resolveScreenshots(entries,
|
|
3432
|
+
screenshots2 = await resolveScreenshots(entries, deps.userId, deps.allowLocalScreenshots ?? false);
|
|
3222
3433
|
} catch (err) {
|
|
3223
3434
|
return errorResult(err.message);
|
|
3224
3435
|
}
|
|
3225
|
-
const result = await
|
|
3436
|
+
const result = await deps.renderer.render({
|
|
3226
3437
|
screenshots: screenshots2,
|
|
3227
3438
|
panelPresetId: args.panelPresetId,
|
|
3228
3439
|
look: look2,
|
|
@@ -3238,11 +3449,11 @@ async function handleRenderStrip(deps2, args) {
|
|
|
3238
3449
|
return errorResult(`render failed: ${result.error ?? "unknown error"}`);
|
|
3239
3450
|
}
|
|
3240
3451
|
const totalBytes = result.panels.reduce((sum, p) => sum + estimateDecodedBytes(p), 0);
|
|
3241
|
-
const { mode: outputMode, note: outputNote } = resolveOutputMode(args.output, totalBytes,
|
|
3452
|
+
const { mode: outputMode, note: outputNote } = resolveOutputMode(args.output, totalBytes, deps.forceOutput);
|
|
3242
3453
|
const noteWithOutput = [note, outputNote].filter(Boolean).join(" ") || void 0;
|
|
3243
3454
|
let panels;
|
|
3244
3455
|
try {
|
|
3245
|
-
panels = outputMode === "urls" ? await uploadPanels(
|
|
3456
|
+
panels = outputMode === "urls" ? await uploadPanels(deps.userId, result.panels) : result.panels;
|
|
3246
3457
|
} catch (err) {
|
|
3247
3458
|
return errorResult(panelUploadFailure(err, []));
|
|
3248
3459
|
}
|
|
@@ -3258,6 +3469,9 @@ async function handleRenderStrip(deps2, args) {
|
|
|
3258
3469
|
// Only when the payload actually carried per-locale entries — a legacy payload's
|
|
3259
3470
|
// response stays byte-identical (D6).
|
|
3260
3471
|
...screenshotLocales.length > 0 ? { screenshotLocales } : {},
|
|
3472
|
+
// #437 — what this render actually cost, what is left, and when the wallet refills. Absent on
|
|
3473
|
+
// the local tier, which charges nothing.
|
|
3474
|
+
...scope.credits() ? { credits: scope.credits() } : {},
|
|
3261
3475
|
...noteWithOutput ? { note: noteWithOutput } : {},
|
|
3262
3476
|
// #213 — tell the model, in the output, to turn this render into a real project. The prose
|
|
3263
3477
|
// contract already requires it; this is the copy a client cannot drop (toolContract.ts).
|
|
@@ -3317,11 +3531,11 @@ function outputDevicesFromProjectFile(projectFile) {
|
|
|
3317
3531
|
if (!Array.isArray(outputs)) return { devices: [], dropped: [] };
|
|
3318
3532
|
return resolveOutputDevices(outputs.filter((id) => typeof id === "string"));
|
|
3319
3533
|
}
|
|
3320
|
-
async function loadProjectRecord(
|
|
3321
|
-
const resolution = await
|
|
3534
|
+
async function loadProjectRecord(deps, projectId) {
|
|
3535
|
+
const resolution = await deps.resolveProject(deps.userId, projectId);
|
|
3322
3536
|
const project2 = resolvedProject(resolution);
|
|
3323
3537
|
if (!project2) return { error: describeMiss(resolution, projectId) };
|
|
3324
|
-
const rec = await
|
|
3538
|
+
const rec = await deps.readProjectRecord(project2.id);
|
|
3325
3539
|
if (!rec) {
|
|
3326
3540
|
return {
|
|
3327
3541
|
error: `Project "${project2.name}" has no saved record yet \u2014 open it in ShotOps and save a strip first.`
|
|
@@ -3342,12 +3556,12 @@ function projectRenderVariant(projectFile, locale2, panelPresetId2) {
|
|
|
3342
3556
|
const baseLocale = projectBaseLocale(projectFile);
|
|
3343
3557
|
return canonicalVariant({ family, locale: locale2?.trim() || baseLocale }, baseLocale);
|
|
3344
3558
|
}
|
|
3345
|
-
async function resolveProjectScreens(
|
|
3559
|
+
async function resolveProjectScreens(deps, loaded, supplied) {
|
|
3346
3560
|
const baseLocale = projectBaseLocale(loaded.projectFile);
|
|
3347
3561
|
const nested = supplied.map((e) => [e]);
|
|
3348
3562
|
let resolvedBytes;
|
|
3349
3563
|
try {
|
|
3350
|
-
resolvedBytes = await resolveScreenshots(nested,
|
|
3564
|
+
resolvedBytes = await resolveScreenshots(nested, deps.userId, deps.allowLocalScreenshots ?? false);
|
|
3351
3565
|
} catch (err) {
|
|
3352
3566
|
return { error: err.message };
|
|
3353
3567
|
}
|
|
@@ -3377,11 +3591,11 @@ async function resolveProjectScreens(deps2, loaded, supplied) {
|
|
|
3377
3591
|
baseLocale
|
|
3378
3592
|
);
|
|
3379
3593
|
const storedManifest = parsed ? allManifestEntries(parsed) : [];
|
|
3380
|
-
let canReadStored = Boolean(
|
|
3594
|
+
let canReadStored = Boolean(deps.fetchHandoffBytes || deps.readProjectScreens);
|
|
3381
3595
|
const storedSources = [];
|
|
3382
|
-
if (
|
|
3596
|
+
if (deps.readProjectScreens) {
|
|
3383
3597
|
try {
|
|
3384
|
-
const listed = await
|
|
3598
|
+
const listed = await deps.readProjectScreens(loaded.project.id);
|
|
3385
3599
|
for (const screen of listed.screens) {
|
|
3386
3600
|
const variant = canonicalVariant(screen.variant, baseLocale);
|
|
3387
3601
|
let pending;
|
|
@@ -3396,9 +3610,9 @@ async function resolveProjectScreens(deps2, loaded, supplied) {
|
|
|
3396
3610
|
});
|
|
3397
3611
|
}
|
|
3398
3612
|
} catch {
|
|
3399
|
-
canReadStored = Boolean(
|
|
3613
|
+
canReadStored = Boolean(deps.fetchHandoffBytes);
|
|
3400
3614
|
}
|
|
3401
|
-
} else if (
|
|
3615
|
+
} else if (deps.fetchHandoffBytes) {
|
|
3402
3616
|
for (const entry of storedManifest) {
|
|
3403
3617
|
let pending;
|
|
3404
3618
|
storedSources.push({
|
|
@@ -3406,7 +3620,7 @@ async function resolveProjectScreens(deps2, loaded, supplied) {
|
|
|
3406
3620
|
variant: entry.variant,
|
|
3407
3621
|
source: "project",
|
|
3408
3622
|
read: () => {
|
|
3409
|
-
pending ??=
|
|
3623
|
+
pending ??= deps.fetchHandoffBytes(entry.ref).then((bytes) => Buffer.from(bytes).toString("base64"));
|
|
3410
3624
|
return pending;
|
|
3411
3625
|
}
|
|
3412
3626
|
});
|
|
@@ -3499,7 +3713,7 @@ function projectScreenUsageNote(shots) {
|
|
|
3499
3713
|
projectScreenResolutionNote(shots)
|
|
3500
3714
|
].filter(Boolean).join(" ") || void 0;
|
|
3501
3715
|
}
|
|
3502
|
-
async function renderProjectWithScreens(
|
|
3716
|
+
async function renderProjectWithScreens(deps, projectFile, screens, options = {}) {
|
|
3503
3717
|
const variant = projectRenderVariant(projectFile, options.locale, options.panelPresetId);
|
|
3504
3718
|
const resolved = await screens.resolve(variant);
|
|
3505
3719
|
if (screens.expected.length > 0 && resolved.screenManifest.length === 0) {
|
|
@@ -3508,7 +3722,7 @@ async function renderProjectWithScreens(deps2, projectFile, screens, options = {
|
|
|
3508
3722
|
error: noProjectScreensMessage(screens, options.suppliedCount ?? 0, options.toolName ?? "render_project")
|
|
3509
3723
|
};
|
|
3510
3724
|
}
|
|
3511
|
-
return
|
|
3725
|
+
return deps.renderer.renderProject({
|
|
3512
3726
|
projectFile,
|
|
3513
3727
|
screenManifest: resolved.screenManifest,
|
|
3514
3728
|
variant,
|
|
@@ -3517,12 +3731,16 @@ async function renderProjectWithScreens(deps2, projectFile, screens, options = {
|
|
|
3517
3731
|
...options.panelPresetId ? { panelPresetId: options.panelPresetId } : {}
|
|
3518
3732
|
});
|
|
3519
3733
|
}
|
|
3520
|
-
|
|
3521
|
-
const
|
|
3734
|
+
function handleRenderProject(deps, args) {
|
|
3735
|
+
const scope = openChargeScope(deps);
|
|
3736
|
+
return runCharged(scope, () => renderProject(deps, args, scope));
|
|
3737
|
+
}
|
|
3738
|
+
async function renderProject(deps, args, scope) {
|
|
3739
|
+
const loaded = await loadProjectRecord(deps, args.project);
|
|
3522
3740
|
if ("error" in loaded) return errorResult(loaded.error);
|
|
3523
3741
|
const { project: project2, projectFile } = loaded;
|
|
3524
3742
|
const supplied = args.screenshots ?? [];
|
|
3525
|
-
const screens = await resolveProjectScreens(
|
|
3743
|
+
const screens = await resolveProjectScreens(deps, loaded, supplied);
|
|
3526
3744
|
if ("error" in screens) return errorResult(screens.error);
|
|
3527
3745
|
const { unnamed } = screens;
|
|
3528
3746
|
const requested = resolveOutputDevices(args.outputs);
|
|
@@ -3535,7 +3753,7 @@ async function handleRenderProject(deps2, args) {
|
|
|
3535
3753
|
const locale2 = args.locale?.trim() || void 0;
|
|
3536
3754
|
const renderOne = async (device) => {
|
|
3537
3755
|
const panelPresetId2 = device && mayOverridePreset ? device.panelPresetId : void 0;
|
|
3538
|
-
return renderProjectWithScreens(
|
|
3756
|
+
return renderProjectWithScreens(deps, projectFile, screens, {
|
|
3539
3757
|
locale: locale2,
|
|
3540
3758
|
preview: args.preview,
|
|
3541
3759
|
panelPresetId: panelPresetId2,
|
|
@@ -3543,6 +3761,11 @@ async function handleRenderProject(deps2, args) {
|
|
|
3543
3761
|
toolName: "render_project"
|
|
3544
3762
|
});
|
|
3545
3763
|
};
|
|
3764
|
+
const refused = await scope.charge({
|
|
3765
|
+
panels: screens.expected.length * Math.max(1, ordered.length),
|
|
3766
|
+
resolution: args.preview ? "preview" : "full"
|
|
3767
|
+
});
|
|
3768
|
+
if (refused) return errorResult(refused.error);
|
|
3546
3769
|
const result = await renderOne(active);
|
|
3547
3770
|
if (!result.ok || !result.panels) {
|
|
3548
3771
|
return errorResult(`render failed: ${result.error ?? "unknown error"}`);
|
|
@@ -3572,12 +3795,12 @@ async function handleRenderProject(deps2, args) {
|
|
|
3572
3795
|
new Set(allShots.filter((shot) => shot.matched && shot.source === "project").map((shot) => shot.frameName))
|
|
3573
3796
|
);
|
|
3574
3797
|
const totalBytes = [result.panels, ...extraResults.map((e) => e.panels)].flat().reduce((sum, p) => sum + estimateDecodedBytes(p), 0);
|
|
3575
|
-
const { mode: outputMode, note: outputNote } = resolveOutputMode(args.output, totalBytes,
|
|
3798
|
+
const { mode: outputMode, note: outputNote } = resolveOutputMode(args.output, totalBytes, deps.forceOutput);
|
|
3576
3799
|
const multi = extraResults.length > 0;
|
|
3577
3800
|
const storedPanelRefs = [];
|
|
3578
3801
|
let panels;
|
|
3579
3802
|
try {
|
|
3580
|
-
panels = outputMode === "urls" ? await uploadPanels(
|
|
3803
|
+
panels = outputMode === "urls" ? await uploadPanels(deps.userId, result.panels, multi && active ? `${active.id}-` : "") : result.panels;
|
|
3581
3804
|
} catch (err) {
|
|
3582
3805
|
return errorResult(panelUploadFailure(err, storedPanelRefs));
|
|
3583
3806
|
}
|
|
@@ -3598,7 +3821,7 @@ async function handleRenderProject(deps2, args) {
|
|
|
3598
3821
|
for (const extra of extraResults) {
|
|
3599
3822
|
let extraPanels;
|
|
3600
3823
|
try {
|
|
3601
|
-
extraPanels = outputMode === "urls" ? await uploadPanels(
|
|
3824
|
+
extraPanels = outputMode === "urls" ? await uploadPanels(deps.userId, extra.panels, `${extra.device.id}-`) : extra.panels;
|
|
3602
3825
|
} catch (err) {
|
|
3603
3826
|
return errorResult(panelUploadFailure(err, storedPanelRefs));
|
|
3604
3827
|
}
|
|
@@ -3642,7 +3865,7 @@ async function handleRenderProject(deps2, args) {
|
|
|
3642
3865
|
`${unnamed} supplied screenshot(s) had no filename (name) and could not be matched \u2014 pass each screenshot's original filename via \`name\`.`
|
|
3643
3866
|
);
|
|
3644
3867
|
}
|
|
3645
|
-
const openUrl = `${
|
|
3868
|
+
const openUrl = `${deps.studioOrigin}/?project=${project2.id}`;
|
|
3646
3869
|
const deepLink = URL.canParse(openUrl) ? { openUrl } : {};
|
|
3647
3870
|
return textResult({
|
|
3648
3871
|
ok: true,
|
|
@@ -3657,6 +3880,8 @@ async function handleRenderProject(deps2, args) {
|
|
|
3657
3880
|
...deepLink,
|
|
3658
3881
|
heldVersion: project2.heldVersion,
|
|
3659
3882
|
// echoed for transparency; the render uses the record's saved look
|
|
3883
|
+
// #437 — the exact debit for this render, the balance after it, and the next refill.
|
|
3884
|
+
...scope.credits() ? { credits: scope.credits() } : {},
|
|
3660
3885
|
shots,
|
|
3661
3886
|
// [{ frameName, matched }] — which of the project's shots got developer bytes
|
|
3662
3887
|
...handoffUsed.length > 0 ? { handoffUsed } : {},
|
|
@@ -3705,7 +3930,11 @@ function projectStylingIgnoredNote(args) {
|
|
|
3705
3930
|
if (ignored.length === 0) return void 0;
|
|
3706
3931
|
return `${ignored.join(" + ")} ${ignored.length > 1 ? "were" : "was"} ignored \u2014 this bundle rendered a saved PROJECT, which carries its own styling and caption words. To change how it looks, restyle it with save_project (or in ShotOps) and bundle again; to style a one-off strip instead, pass \`screenshots\` rather than relying on the project.`;
|
|
3707
3932
|
}
|
|
3708
|
-
|
|
3933
|
+
function handleEmitBundle(deps, args) {
|
|
3934
|
+
const scope = openChargeScope(deps);
|
|
3935
|
+
return runCharged(scope, () => emitBundle(deps, args, scope));
|
|
3936
|
+
}
|
|
3937
|
+
async function emitBundle(deps, args, scope) {
|
|
3709
3938
|
if (!isValidBundleId(args.bundleId)) {
|
|
3710
3939
|
return errorResult(`invalid bundleId "${args.bundleId}" \u2014 expected reverse-DNS, e.g. com.acme.app`);
|
|
3711
3940
|
}
|
|
@@ -3717,7 +3946,7 @@ async function handleEmitBundle(deps2, args) {
|
|
|
3717
3946
|
const requestedOutputs = resolveOutputDevices(args.outputs).devices;
|
|
3718
3947
|
let loaded = null;
|
|
3719
3948
|
if (args.project && !hasPanels) {
|
|
3720
|
-
const found = await loadProjectRecord(
|
|
3949
|
+
const found = await loadProjectRecord(deps, args.project).catch((err) => ({
|
|
3721
3950
|
error: `could not read project "${args.project}": ${err.message}`
|
|
3722
3951
|
}));
|
|
3723
3952
|
if ("error" in found) {
|
|
@@ -3753,22 +3982,32 @@ async function handleEmitBundle(deps2, args) {
|
|
|
3753
3982
|
);
|
|
3754
3983
|
}
|
|
3755
3984
|
if (args.createProject) return errorResult(PROJECT_SOURCE_CREATE_PROJECT_ERROR);
|
|
3756
|
-
const screens = await resolveProjectScreens(
|
|
3985
|
+
const screens = await resolveProjectScreens(deps, loaded, []);
|
|
3757
3986
|
if ("error" in screens) return errorResult(screens.error);
|
|
3758
3987
|
const projectFile = loaded.projectFile;
|
|
3759
|
-
|
|
3988
|
+
const refused2 = await scope.charge({
|
|
3989
|
+
panels: screens.expected.length * outputDevices.length * locales.length,
|
|
3990
|
+
resolution: "full"
|
|
3991
|
+
});
|
|
3992
|
+
if (refused2) return errorResult(refused2.error);
|
|
3993
|
+
return emitMultiOutputBundle(deps, args, outputDevices, locales, projectName2, scope, {
|
|
3760
3994
|
// The handoff/missing report is the SAME one the single-output path gives (#40 F7) —
|
|
3761
3995
|
// a shot with no screenshot is a shot with no screenshot at either device count.
|
|
3762
3996
|
note: joinNotes(savedNote, projectStylingIgnoredNote(args)),
|
|
3763
|
-
renderOne: async (device, locale3) => renderProjectWithScreens(
|
|
3997
|
+
renderOne: async (device, locale3) => renderProjectWithScreens(deps, projectFile, screens, {
|
|
3764
3998
|
locale: locale3,
|
|
3765
3999
|
panelPresetId: device.panelPresetId,
|
|
3766
4000
|
toolName: "emit_bundle"
|
|
3767
4001
|
})
|
|
3768
4002
|
});
|
|
3769
4003
|
}
|
|
3770
|
-
const resolved = await resolveLook(
|
|
3771
|
-
|
|
4004
|
+
const resolved = await resolveLook(deps, args);
|
|
4005
|
+
const refused = await scope.charge({
|
|
4006
|
+
panels: screenshotSlots.length * outputDevices.length * locales.length,
|
|
4007
|
+
resolution: "full"
|
|
4008
|
+
});
|
|
4009
|
+
if (refused) return errorResult(refused.error);
|
|
4010
|
+
return emitMultiOutputBundle(deps, args, outputDevices, locales, projectName2, scope, {
|
|
3772
4011
|
// #224 — composeConflictNote had been on the single-output path only, so the SAME payload
|
|
3773
4012
|
// (a look plus a redundant style.shotLook) was explained at one device count and silent at
|
|
3774
4013
|
// two. It is the resolution that decides the conflict, not the device loop.
|
|
@@ -3782,11 +4021,11 @@ async function handleEmitBundle(deps2, args) {
|
|
|
3782
4021
|
}
|
|
3783
4022
|
let screenshots2;
|
|
3784
4023
|
try {
|
|
3785
|
-
screenshots2 = await resolveScreenshots(entries,
|
|
4024
|
+
screenshots2 = await resolveScreenshots(entries, deps.userId, deps.allowLocalScreenshots ?? false);
|
|
3786
4025
|
} catch (err) {
|
|
3787
4026
|
return { ok: false, error: err.message };
|
|
3788
4027
|
}
|
|
3789
|
-
return
|
|
4028
|
+
return deps.renderer.render({
|
|
3790
4029
|
screenshots: screenshots2,
|
|
3791
4030
|
panelPresetId: device.panelPresetId,
|
|
3792
4031
|
look: resolved.look,
|
|
@@ -3801,7 +4040,7 @@ async function handleEmitBundle(deps2, args) {
|
|
|
3801
4040
|
// buildRecord — the multi-device loop never builds a record per pass) carrying the full
|
|
3802
4041
|
// device set. The `panels` and multi-LOCALE rejections stand: their reasons still hold.
|
|
3803
4042
|
persist: args.createProject ? async () => {
|
|
3804
|
-
const record = await
|
|
4043
|
+
const record = await deps.renderer.buildRecord({
|
|
3805
4044
|
slotSizes: screenshotSlots.map((slot2) => slot2.length),
|
|
3806
4045
|
panelPresetId: outputDevices[0].panelPresetId,
|
|
3807
4046
|
look: resolved.look,
|
|
@@ -3813,7 +4052,7 @@ async function handleEmitBundle(deps2, args) {
|
|
|
3813
4052
|
outputs: outputDevices.map((d) => d.id)
|
|
3814
4053
|
});
|
|
3815
4054
|
if (record == null) return { error: "no project record was produced \u2014 not saved." };
|
|
3816
|
-
return persistProject(
|
|
4055
|
+
return persistProject(deps, args, record);
|
|
3817
4056
|
} : void 0
|
|
3818
4057
|
});
|
|
3819
4058
|
}
|
|
@@ -3824,19 +4063,29 @@ async function handleEmitBundle(deps2, args) {
|
|
|
3824
4063
|
);
|
|
3825
4064
|
}
|
|
3826
4065
|
if (loaded && !hasScreenshots && !hasPanels) {
|
|
3827
|
-
const screens = await resolveProjectScreens(
|
|
4066
|
+
const screens = await resolveProjectScreens(deps, loaded, []);
|
|
3828
4067
|
if ("error" in screens) return errorResult(screens.error);
|
|
3829
4068
|
const projectFile = loaded.projectFile;
|
|
3830
|
-
|
|
4069
|
+
const refused2 = await scope.charge({
|
|
4070
|
+
panels: screens.expected.length * requestedLocales.length,
|
|
4071
|
+
resolution: "full"
|
|
4072
|
+
});
|
|
4073
|
+
if (refused2) return errorResult(refused2.error);
|
|
4074
|
+
return emitMultiLocaleBundle(deps, args, requestedLocales, projectName2, scope, {
|
|
3831
4075
|
note: joinNotes(unexpandedSavedNote(projectSizeCovered), projectStylingIgnoredNote(args)),
|
|
3832
|
-
renderOne: (locale3) => renderProjectWithScreens(
|
|
4076
|
+
renderOne: (locale3) => renderProjectWithScreens(deps, projectFile, screens, {
|
|
3833
4077
|
locale: locale3,
|
|
3834
4078
|
panelPresetId: args.panelPresetId,
|
|
3835
4079
|
toolName: "emit_bundle"
|
|
3836
4080
|
})
|
|
3837
4081
|
});
|
|
3838
4082
|
}
|
|
3839
|
-
|
|
4083
|
+
const refused = await scope.charge({
|
|
4084
|
+
panels: hasPanels ? 0 : (screenshotSlots?.length ?? 0) * requestedLocales.length,
|
|
4085
|
+
resolution: "full"
|
|
4086
|
+
});
|
|
4087
|
+
if (refused) return errorResult(refused.error);
|
|
4088
|
+
return emitMultiLocaleBundle(deps, args, requestedLocales, projectName2, scope, {
|
|
3840
4089
|
note: unexpandedSavedNote(NOT_COVERED_BY_THIS_CALL)
|
|
3841
4090
|
});
|
|
3842
4091
|
}
|
|
@@ -3861,7 +4110,7 @@ async function handleEmitBundle(deps2, args) {
|
|
|
3861
4110
|
}
|
|
3862
4111
|
let bytesList;
|
|
3863
4112
|
try {
|
|
3864
|
-
bytesList = await Promise.all(panelEntries.map((p) => resolvePanelBytes(p,
|
|
4113
|
+
bytesList = await Promise.all(panelEntries.map((p) => resolvePanelBytes(p, deps.userId, deps.allowLocalScreenshots ?? false)));
|
|
3865
4114
|
} catch (err) {
|
|
3866
4115
|
return errorResult(`failed to read panel: ${err.message}`);
|
|
3867
4116
|
}
|
|
@@ -3869,9 +4118,11 @@ async function handleEmitBundle(deps2, args) {
|
|
|
3869
4118
|
previewPng = bytesList[0] ? Buffer.from(bytesList[0]) : null;
|
|
3870
4119
|
} else if (!hasScreenshots && loaded) {
|
|
3871
4120
|
if (args.createProject) return errorResult(PROJECT_SOURCE_CREATE_PROJECT_ERROR);
|
|
3872
|
-
const screens = await resolveProjectScreens(
|
|
4121
|
+
const screens = await resolveProjectScreens(deps, loaded, []);
|
|
3873
4122
|
if ("error" in screens) return errorResult(screens.error);
|
|
3874
|
-
const
|
|
4123
|
+
const refused = await scope.charge({ panels: screens.expected.length, resolution: "full" });
|
|
4124
|
+
if (refused) return errorResult(refused.error);
|
|
4125
|
+
const result = await renderProjectWithScreens(deps, loaded.projectFile, screens, {
|
|
3875
4126
|
locale: locale2,
|
|
3876
4127
|
panelPresetId: args.panelPresetId,
|
|
3877
4128
|
toolName: "emit_bundle"
|
|
@@ -3899,17 +4150,19 @@ async function handleEmitBundle(deps2, args) {
|
|
|
3899
4150
|
}
|
|
3900
4151
|
let screenshots2;
|
|
3901
4152
|
try {
|
|
3902
|
-
screenshots2 = await resolveScreenshots(entries,
|
|
4153
|
+
screenshots2 = await resolveScreenshots(entries, deps.userId, deps.allowLocalScreenshots ?? false);
|
|
3903
4154
|
} catch (err) {
|
|
3904
4155
|
return errorResult(err.message);
|
|
3905
4156
|
}
|
|
3906
|
-
const resolved = await resolveLook(
|
|
4157
|
+
const resolved = await resolveLook(deps, args);
|
|
3907
4158
|
note = joinNotes(
|
|
3908
4159
|
resolved.note,
|
|
3909
4160
|
composeConflictNote(resolved.look, resolved.style),
|
|
3910
4161
|
unexpandedSavedNote(NOT_COVERED_BY_THIS_CALL)
|
|
3911
4162
|
);
|
|
3912
|
-
const
|
|
4163
|
+
const refused = await scope.charge({ panels: screenshotSlots.length, resolution: "full" });
|
|
4164
|
+
if (refused) return errorResult(refused.error);
|
|
4165
|
+
const result = await deps.renderer.render({
|
|
3913
4166
|
screenshots: screenshots2,
|
|
3914
4167
|
panelPresetId: args.panelPresetId,
|
|
3915
4168
|
look: resolved.look,
|
|
@@ -3934,8 +4187,8 @@ async function handleEmitBundle(deps2, args) {
|
|
|
3934
4187
|
}
|
|
3935
4188
|
const zip = buildFastlaneBundleZip(slots, { bundleId: args.bundleId, locale: locale2, panelLabel });
|
|
3936
4189
|
const filename2 = bundleZipFilename(projectName2);
|
|
3937
|
-
const { mode: outputMode, note: outputNote } = resolveOutputMode(args.output, zip.byteLength,
|
|
3938
|
-
const zipOut = outputMode === "urls" ? await storeAsset(uploadDirFor(
|
|
4190
|
+
const { mode: outputMode, note: outputNote } = resolveOutputMode(args.output, zip.byteLength, deps.forceOutput);
|
|
4191
|
+
const zipOut = outputMode === "urls" ? await storeAsset(uploadDirFor(deps.userId), filename2, zip, "application/zip").then((a) => ({
|
|
3939
4192
|
zipUrl: a.url,
|
|
3940
4193
|
zipRef: a.ref
|
|
3941
4194
|
})) : { zipBase64: Buffer.from(zip).toString("base64") };
|
|
@@ -3943,15 +4196,15 @@ async function handleEmitBundle(deps2, args) {
|
|
|
3943
4196
|
if (args.share) {
|
|
3944
4197
|
if (!previewPng) return errorResult(shareFailure("no preview image available", zipOut));
|
|
3945
4198
|
try {
|
|
3946
|
-
shareLink = await
|
|
3947
|
-
userId:
|
|
4199
|
+
shareLink = await deps.createShareLink({
|
|
4200
|
+
userId: deps.userId,
|
|
3948
4201
|
zip,
|
|
3949
4202
|
previewPng,
|
|
3950
4203
|
projectName: projectName2,
|
|
3951
4204
|
panelCount: slots.length,
|
|
3952
4205
|
bundleId: args.bundleId,
|
|
3953
4206
|
locale: locale2,
|
|
3954
|
-
origin:
|
|
4207
|
+
origin: deps.studioOrigin
|
|
3955
4208
|
});
|
|
3956
4209
|
} catch (err) {
|
|
3957
4210
|
return errorResult(shareFailure(err.message, zipOut));
|
|
@@ -3963,7 +4216,7 @@ async function handleEmitBundle(deps2, args) {
|
|
|
3963
4216
|
if (projectRecord == null) {
|
|
3964
4217
|
persistNote = "bundle succeeded but no project record was produced \u2014 not saved.";
|
|
3965
4218
|
} else {
|
|
3966
|
-
const p = await persistProject(
|
|
4219
|
+
const p = await persistProject(deps, args, projectRecord);
|
|
3967
4220
|
if ("error" in p) persistNote = `not saved: ${p.error}`;
|
|
3968
4221
|
else persisted = p;
|
|
3969
4222
|
}
|
|
@@ -3975,6 +4228,8 @@ async function handleEmitBundle(deps2, args) {
|
|
|
3975
4228
|
locale: locale2,
|
|
3976
4229
|
panelCount: slots.length,
|
|
3977
4230
|
output: outputMode,
|
|
4231
|
+
// #437 — absent on the compose path (nothing was rendered) and on the local tier.
|
|
4232
|
+
...scope.credits() ? { credits: scope.credits() } : {},
|
|
3978
4233
|
...joinNotes(note, outputNote) ? { note: joinNotes(note, outputNote) } : {},
|
|
3979
4234
|
...shareLink ? { shareLink } : {},
|
|
3980
4235
|
...persisted ? { projectId: persisted.projectId, openUrl: persisted.openUrl } : {},
|
|
@@ -3982,7 +4237,7 @@ async function handleEmitBundle(deps2, args) {
|
|
|
3982
4237
|
...zipOut
|
|
3983
4238
|
});
|
|
3984
4239
|
}
|
|
3985
|
-
async function emitMultiOutputBundle(
|
|
4240
|
+
async function emitMultiOutputBundle(deps, args, outputs, locales, projectName2, scope, plan) {
|
|
3986
4241
|
const targets = [];
|
|
3987
4242
|
let previewPng = null;
|
|
3988
4243
|
const projectShots = [];
|
|
@@ -4015,8 +4270,8 @@ async function emitMultiOutputBundle(deps2, args, outputs, locales, projectName2
|
|
|
4015
4270
|
const zip = buildMultiOutputFastlaneBundleZip(targets, { bundleId: args.bundleId });
|
|
4016
4271
|
const filename2 = bundleZipFilename(projectName2);
|
|
4017
4272
|
const panelCount = targets[0]?.localeSlots[0]?.slots.length ?? 0;
|
|
4018
|
-
const { mode: outputMode, note: outputNote } = resolveOutputMode(args.output, zip.byteLength,
|
|
4019
|
-
const zipOut = outputMode === "urls" ? await storeAsset(uploadDirFor(
|
|
4273
|
+
const { mode: outputMode, note: outputNote } = resolveOutputMode(args.output, zip.byteLength, deps.forceOutput);
|
|
4274
|
+
const zipOut = outputMode === "urls" ? await storeAsset(uploadDirFor(deps.userId), filename2, zip, "application/zip").then((asset) => ({
|
|
4020
4275
|
zipUrl: asset.url,
|
|
4021
4276
|
zipRef: asset.ref
|
|
4022
4277
|
})) : { zipBase64: Buffer.from(zip).toString("base64") };
|
|
@@ -4024,8 +4279,8 @@ async function emitMultiOutputBundle(deps2, args, outputs, locales, projectName2
|
|
|
4024
4279
|
if (args.share) {
|
|
4025
4280
|
if (!previewPng) return errorResult(shareFailure("no preview image available", zipOut));
|
|
4026
4281
|
try {
|
|
4027
|
-
shareLink = await
|
|
4028
|
-
userId:
|
|
4282
|
+
shareLink = await deps.createShareLink({
|
|
4283
|
+
userId: deps.userId,
|
|
4029
4284
|
zip,
|
|
4030
4285
|
previewPng,
|
|
4031
4286
|
projectName: projectName2,
|
|
@@ -4034,7 +4289,7 @@ async function emitMultiOutputBundle(deps2, args, outputs, locales, projectName2
|
|
|
4034
4289
|
panelCount: panelCount * outputs.length * locales.length,
|
|
4035
4290
|
bundleId: args.bundleId,
|
|
4036
4291
|
locale: locales.join(", "),
|
|
4037
|
-
origin:
|
|
4292
|
+
origin: deps.studioOrigin
|
|
4038
4293
|
});
|
|
4039
4294
|
} catch (err) {
|
|
4040
4295
|
return errorResult(shareFailure(err.message, zipOut));
|
|
@@ -4061,6 +4316,7 @@ async function emitMultiOutputBundle(deps2, args, outputs, locales, projectName2
|
|
|
4061
4316
|
panelCount,
|
|
4062
4317
|
totalPanelCount: panelCount * outputs.length * locales.length,
|
|
4063
4318
|
output: outputMode,
|
|
4319
|
+
...scope.credits() ? { credits: scope.credits() } : {},
|
|
4064
4320
|
...note ? { note } : {},
|
|
4065
4321
|
...shareLink ? { shareLink } : {},
|
|
4066
4322
|
...persisted ? { projectId: persisted.projectId, openUrl: persisted.openUrl } : {},
|
|
@@ -4068,7 +4324,7 @@ async function emitMultiOutputBundle(deps2, args, outputs, locales, projectName2
|
|
|
4068
4324
|
...zipOut
|
|
4069
4325
|
});
|
|
4070
4326
|
}
|
|
4071
|
-
async function emitMultiLocaleBundle(
|
|
4327
|
+
async function emitMultiLocaleBundle(deps, args, locales, projectName2, scope, plan = {}) {
|
|
4072
4328
|
const screenshotSlots = args.screenshots ? normalizeScreenshotSlots(args.screenshots) : void 0;
|
|
4073
4329
|
let localeSlots;
|
|
4074
4330
|
let panelLabel;
|
|
@@ -4099,7 +4355,7 @@ async function emitMultiLocaleBundle(deps2, args, locales, projectName2, plan =
|
|
|
4099
4355
|
const key = "path" in p ? `path:${p.path}` : `ref:${p.ref}`;
|
|
4100
4356
|
let pending = bytesCache.get(key);
|
|
4101
4357
|
if (!pending) {
|
|
4102
|
-
pending = resolvePanelBytes(p,
|
|
4358
|
+
pending = resolvePanelBytes(p, deps.userId, deps.allowLocalScreenshots ?? false);
|
|
4103
4359
|
bytesCache.set(key, pending);
|
|
4104
4360
|
}
|
|
4105
4361
|
return pending;
|
|
@@ -4123,7 +4379,7 @@ async function emitMultiLocaleBundle(deps2, args, locales, projectName2, plan =
|
|
|
4123
4379
|
if (!screenshotSlots || screenshotSlots.length === 0) {
|
|
4124
4380
|
return errorResult("emit_bundle needs either `screenshots` or pre-rendered `panels` refs.");
|
|
4125
4381
|
}
|
|
4126
|
-
const resolved = await resolveLook(
|
|
4382
|
+
const resolved = await resolveLook(deps, args);
|
|
4127
4383
|
note = joinNotes(note, resolved.note, composeConflictNote(resolved.look, resolved.style));
|
|
4128
4384
|
localeSlots = [];
|
|
4129
4385
|
panelLabel = "";
|
|
@@ -4136,11 +4392,11 @@ async function emitMultiLocaleBundle(deps2, args, locales, projectName2, plan =
|
|
|
4136
4392
|
}
|
|
4137
4393
|
let screenshots2;
|
|
4138
4394
|
try {
|
|
4139
|
-
screenshots2 = await resolveScreenshots(entries,
|
|
4395
|
+
screenshots2 = await resolveScreenshots(entries, deps.userId, deps.allowLocalScreenshots ?? false);
|
|
4140
4396
|
} catch (err) {
|
|
4141
4397
|
return errorResult(err.message);
|
|
4142
4398
|
}
|
|
4143
|
-
const result = await
|
|
4399
|
+
const result = await deps.renderer.render({
|
|
4144
4400
|
screenshots: screenshots2,
|
|
4145
4401
|
panelPresetId: args.panelPresetId,
|
|
4146
4402
|
look: resolved.look,
|
|
@@ -4162,8 +4418,8 @@ async function emitMultiLocaleBundle(deps2, args, locales, projectName2, plan =
|
|
|
4162
4418
|
const zip = buildMultiLocaleFastlaneBundleZip(localeSlots, { bundleId: args.bundleId, panelLabel });
|
|
4163
4419
|
note = joinNotes(note, projectScreenUsageNote(projectShots));
|
|
4164
4420
|
const filename2 = bundleZipFilename(projectName2);
|
|
4165
|
-
const { mode: outputMode, note: outputNote } = resolveOutputMode(args.output, zip.byteLength,
|
|
4166
|
-
const zipOut = outputMode === "urls" ? await storeAsset(uploadDirFor(
|
|
4421
|
+
const { mode: outputMode, note: outputNote } = resolveOutputMode(args.output, zip.byteLength, deps.forceOutput);
|
|
4422
|
+
const zipOut = outputMode === "urls" ? await storeAsset(uploadDirFor(deps.userId), filename2, zip, "application/zip").then((a) => ({
|
|
4167
4423
|
zipUrl: a.url,
|
|
4168
4424
|
zipRef: a.ref
|
|
4169
4425
|
})) : { zipBase64: Buffer.from(zip).toString("base64") };
|
|
@@ -4171,8 +4427,8 @@ async function emitMultiLocaleBundle(deps2, args, locales, projectName2, plan =
|
|
|
4171
4427
|
if (args.share) {
|
|
4172
4428
|
if (!previewPng) return errorResult(shareFailure("no preview image available", zipOut));
|
|
4173
4429
|
try {
|
|
4174
|
-
shareLink = await
|
|
4175
|
-
userId:
|
|
4430
|
+
shareLink = await deps.createShareLink({
|
|
4431
|
+
userId: deps.userId,
|
|
4176
4432
|
zip,
|
|
4177
4433
|
previewPng,
|
|
4178
4434
|
projectName: projectName2,
|
|
@@ -4180,7 +4436,7 @@ async function emitMultiLocaleBundle(deps2, args, locales, projectName2, plan =
|
|
|
4180
4436
|
bundleId: args.bundleId,
|
|
4181
4437
|
// The share row's locale is display metadata — name every locale in the bundle.
|
|
4182
4438
|
locale: locales.join(", "),
|
|
4183
|
-
origin:
|
|
4439
|
+
origin: deps.studioOrigin
|
|
4184
4440
|
});
|
|
4185
4441
|
} catch (err) {
|
|
4186
4442
|
return errorResult(shareFailure(err.message, zipOut));
|
|
@@ -4194,6 +4450,7 @@ async function emitMultiLocaleBundle(deps2, args, locales, projectName2, plan =
|
|
|
4194
4450
|
panelCount: localeSlots[0]?.slots.length ?? 0,
|
|
4195
4451
|
// per locale — total files = panelCount × locales.length
|
|
4196
4452
|
output: outputMode,
|
|
4453
|
+
...scope.credits() ? { credits: scope.credits() } : {},
|
|
4197
4454
|
...joinNotes(note, outputNote) ? { note: joinNotes(note, outputNote) } : {},
|
|
4198
4455
|
...shareLink ? { shareLink } : {},
|
|
4199
4456
|
...zipOut
|
|
@@ -4249,10 +4506,10 @@ function logImportFailure(entry, reason) {
|
|
|
4249
4506
|
fileName: "file" in entry && entry.file.file_name || entry.name || "(none)"
|
|
4250
4507
|
});
|
|
4251
4508
|
}
|
|
4252
|
-
async function importOne(
|
|
4509
|
+
async function importOne(deps, entry) {
|
|
4253
4510
|
const source = sourceKind(entry);
|
|
4254
4511
|
if ("path" in entry) {
|
|
4255
|
-
if (!
|
|
4512
|
+
if (!deps.allowLocalScreenshots) return { ok: false, source, error: hostedPathRefusal(entry.path) };
|
|
4256
4513
|
try {
|
|
4257
4514
|
const { bytes, name } = await readLocalScreenshotFile(entry.path);
|
|
4258
4515
|
return { ok: true, source, path: entry.path, name: entry.name?.trim() || name, size: bytes.byteLength };
|
|
@@ -4260,12 +4517,12 @@ async function importOne(deps2, entry) {
|
|
|
4260
4517
|
return { ok: false, source, error: err.message };
|
|
4261
4518
|
}
|
|
4262
4519
|
}
|
|
4263
|
-
if (!
|
|
4520
|
+
if (!deps.importScreenshot) {
|
|
4264
4521
|
return { ok: false, source, error: localSourceRefusal("url" in entry ? "url" : "file") };
|
|
4265
4522
|
}
|
|
4266
4523
|
try {
|
|
4267
|
-
const imported = await
|
|
4268
|
-
|
|
4524
|
+
const imported = await deps.importScreenshot(
|
|
4525
|
+
deps.userId,
|
|
4269
4526
|
"file" in entry ? { file: entry.file } : { url: entry.url },
|
|
4270
4527
|
entry.name
|
|
4271
4528
|
);
|
|
@@ -4282,7 +4539,7 @@ function instructionsFor(results) {
|
|
|
4282
4539
|
const handle = local ? `Pass each result's { "path": "\u2026" } as that screenshot's entry` : `Pass each result's { "ref": "\u2026" } as that screenshot's entry`;
|
|
4283
4540
|
return `${handle} in render_strip or emit_bundle, in this same order. For render_project, also pass { "name": "THE_FILENAME" } so it matches the saved frame by filename.` + (failed > 0 ? ` ${failed} of ${results.length} entries did NOT import \u2014 read that entry's \`error\` and re-import only those; the successful refs above are already usable.` : "");
|
|
4284
4541
|
}
|
|
4285
|
-
async function handleImportScreenshot(
|
|
4542
|
+
async function handleImportScreenshot(deps, args) {
|
|
4286
4543
|
const entries = [
|
|
4287
4544
|
...args.file ? [{ file: args.file, ...args.name ? { name: args.name } : {} }] : [],
|
|
4288
4545
|
...args.screenshots ?? []
|
|
@@ -4292,7 +4549,7 @@ async function handleImportScreenshot(deps2, args) {
|
|
|
4292
4549
|
'import_screenshot needs something to import. Pass `screenshots`: an array whose entries each name EXACTLY ONE source \u2014 { "url": "https://\u2026" }, { "file": { "download_url": "\u2026", "file_id": "\u2026" } } (a ChatGPT attachment), or (local stdio only) { "path": "/abs/shot.png" }. In ChatGPT the attachment arrives as the top-level `file` parameter instead.'
|
|
4293
4550
|
);
|
|
4294
4551
|
}
|
|
4295
|
-
const results = await Promise.all(entries.map((entry) => importOne(
|
|
4552
|
+
const results = await Promise.all(entries.map((entry) => importOne(deps, entry)));
|
|
4296
4553
|
const imported = results.filter((r) => r.ok);
|
|
4297
4554
|
if (imported.length === 0) {
|
|
4298
4555
|
return errorResult(results.map((r) => r.error).filter(Boolean).join("\n\n"));
|
|
@@ -4308,10 +4565,10 @@ async function handleImportScreenshot(deps2, args) {
|
|
|
4308
4565
|
}
|
|
4309
4566
|
|
|
4310
4567
|
// src/toolsUploads.ts
|
|
4311
|
-
async function handleRequestScreenshotUpload(
|
|
4568
|
+
async function handleRequestScreenshotUpload(deps, args) {
|
|
4312
4569
|
let slots;
|
|
4313
4570
|
try {
|
|
4314
|
-
slots = await
|
|
4571
|
+
slots = await deps.requestScreenshotUpload(deps.userId, args.count, args.names);
|
|
4315
4572
|
} catch (err) {
|
|
4316
4573
|
return errorResult(err.message);
|
|
4317
4574
|
}
|
|
@@ -4326,14 +4583,14 @@ async function handleRequestScreenshotUpload(deps2, args) {
|
|
|
4326
4583
|
instructions: 'PUT each screenshot\'s raw PNG bytes to its uploadUrl, e.g.: curl -T screenshot.png "$UPLOAD_URL" \u2014 then pass { "ref": "THE_MATCHING_REF" } as that screenshot\'s entry in render_strip/emit_bundle\'s `screenshots` array. If you passed `names`, that ref already carries its filename \u2014 no need to also set `name` on the screenshot entry. Uploads are not auto-purged yet \u2014 treat refs as short-lived and single-use.' + (locale2 ? ` For render_strip/emit_bundle, place each ref under the matching per-locale key, e.g. { "locales": { "${locale2}": { "ref": "THE_REF" } } }.` : "") + (locale2 || family ? " For render_project, pass each slot's `variant` through with its ref so that exact device-family + locale cell overrides the project instead of its base screenshot." : "")
|
|
4327
4584
|
});
|
|
4328
4585
|
}
|
|
4329
|
-
async function handleDeleteAssets(
|
|
4330
|
-
if (!
|
|
4586
|
+
async function handleDeleteAssets(deps, args) {
|
|
4587
|
+
if (!deps.deleteAssets) {
|
|
4331
4588
|
return errorResult(
|
|
4332
4589
|
"delete_assets is only available on the hosted ShotOps connection. Local mode does not upload or retain screenshot files."
|
|
4333
4590
|
);
|
|
4334
4591
|
}
|
|
4335
4592
|
try {
|
|
4336
|
-
const deletedRefs = await
|
|
4593
|
+
const deletedRefs = await deps.deleteAssets(deps.userId, args.refs);
|
|
4337
4594
|
return textResult({
|
|
4338
4595
|
ok: true,
|
|
4339
4596
|
deletedCount: deletedRefs.length,
|
|
@@ -4466,8 +4723,8 @@ function deriveLayoutReport(source) {
|
|
|
4466
4723
|
}
|
|
4467
4724
|
|
|
4468
4725
|
// src/toolsProject.ts
|
|
4469
|
-
async function handleSaveProject(
|
|
4470
|
-
const { look: look2, style: style2, note: resolveNote } = await resolveLook(
|
|
4726
|
+
async function handleSaveProject(deps, args) {
|
|
4727
|
+
const { look: look2, style: style2, note: resolveNote } = await resolveLook(deps, args);
|
|
4471
4728
|
const note = [resolveNote, composeConflictNote(look2, style2)].filter(Boolean).join(" ") || void 0;
|
|
4472
4729
|
const locale2 = args.locale?.trim() || "en-US";
|
|
4473
4730
|
if (!Array.isArray(args.screenshots) || args.screenshots.length === 0) {
|
|
@@ -4497,7 +4754,7 @@ async function handleSaveProject(deps2, args) {
|
|
|
4497
4754
|
}
|
|
4498
4755
|
let record;
|
|
4499
4756
|
try {
|
|
4500
|
-
record = await
|
|
4757
|
+
record = await deps.renderer.buildRecord({
|
|
4501
4758
|
slotSizes,
|
|
4502
4759
|
panelPresetId: panelPresetId2,
|
|
4503
4760
|
look: look2,
|
|
@@ -4514,7 +4771,7 @@ async function handleSaveProject(deps2, args) {
|
|
|
4514
4771
|
if (record == null) {
|
|
4515
4772
|
return errorResult("no project record was produced \u2014 not saved.");
|
|
4516
4773
|
}
|
|
4517
|
-
const p = await persistProject(
|
|
4774
|
+
const p = await persistProject(deps, args, record, "unsigned-new");
|
|
4518
4775
|
if ("error" in p) return errorResult(p.error);
|
|
4519
4776
|
if ("status" in p) {
|
|
4520
4777
|
return textResult({
|
|
@@ -4549,7 +4806,7 @@ function reachFromRecord(record) {
|
|
|
4549
4806
|
const reach = record?.reach;
|
|
4550
4807
|
return typeof reach === "string" && reach.length > 0 ? reach : null;
|
|
4551
4808
|
}
|
|
4552
|
-
function storedScreenshotReport(
|
|
4809
|
+
function storedScreenshotReport(deps, record, projectFile) {
|
|
4553
4810
|
const frames = frameNamesFromProjectFile(projectFile);
|
|
4554
4811
|
const file = projectFile;
|
|
4555
4812
|
const baseLocale = typeof file.baseLocale === "string" && file.baseLocale ? file.baseLocale : Array.isArray(file.locales) && typeof file.locales[0] === "string" ? file.locales[0] : "en-US";
|
|
@@ -4591,7 +4848,7 @@ function storedScreenshotReport(deps2, record, projectFile) {
|
|
|
4591
4848
|
}
|
|
4592
4849
|
const names = frames.filter((name) => cells.some((cell) => cell.name === name && cell.available));
|
|
4593
4850
|
return {
|
|
4594
|
-
available: cells.some((cell) => cell.available) && Boolean(
|
|
4851
|
+
available: cells.some((cell) => cell.available) && Boolean(deps.fetchHandoffBytes || deps.readProjectScreens),
|
|
4595
4852
|
count: cells.filter((cell) => cell.available).length,
|
|
4596
4853
|
total: cells.length,
|
|
4597
4854
|
names,
|
|
@@ -4613,8 +4870,8 @@ function storedScreenshotSentence(report, projectId) {
|
|
|
4613
4870
|
}
|
|
4614
4871
|
return `This project holds no screenshots of its own, so render_project needs them. ${BROWSER_FIRST_ROUTE}`;
|
|
4615
4872
|
}
|
|
4616
|
-
async function handleReadProject(
|
|
4617
|
-
const resolution = await
|
|
4873
|
+
async function handleReadProject(deps, args) {
|
|
4874
|
+
const resolution = await deps.resolveProject(deps.userId, args.project);
|
|
4618
4875
|
const project2 = resolvedProject(resolution);
|
|
4619
4876
|
if (!project2) {
|
|
4620
4877
|
const miss = projectMiss(resolution);
|
|
@@ -4624,7 +4881,7 @@ async function handleReadProject(deps2, args) {
|
|
|
4624
4881
|
message: describeMiss(resolution, args.project)
|
|
4625
4882
|
});
|
|
4626
4883
|
}
|
|
4627
|
-
const rec = await
|
|
4884
|
+
const rec = await deps.readProjectRecord(project2.id);
|
|
4628
4885
|
if (!rec) {
|
|
4629
4886
|
return textResult({
|
|
4630
4887
|
saved: false,
|
|
@@ -4634,9 +4891,9 @@ async function handleReadProject(deps2, args) {
|
|
|
4634
4891
|
}
|
|
4635
4892
|
const projectFile = projectFileFromRecord(rec.record);
|
|
4636
4893
|
const layout = deriveLayoutReport(projectFile);
|
|
4637
|
-
const screenshots2 = storedScreenshotReport(
|
|
4638
|
-
const historyDecision = await
|
|
4639
|
-
subject: { kind: "account", userId:
|
|
4894
|
+
const screenshots2 = storedScreenshotReport(deps, rec.record, projectFile);
|
|
4895
|
+
const historyDecision = await deps.authorizeCapability({
|
|
4896
|
+
subject: { kind: "account", userId: deps.userId, mode: "api_token" },
|
|
4640
4897
|
capability: CAPABILITIES.LOOK_HISTORY_READ,
|
|
4641
4898
|
resource: { kind: "project", id: project2.id }
|
|
4642
4899
|
});
|
|
@@ -4644,7 +4901,7 @@ async function handleReadProject(deps2, args) {
|
|
|
4644
4901
|
let versions = [];
|
|
4645
4902
|
if (!historyDenial) {
|
|
4646
4903
|
try {
|
|
4647
|
-
versions = await
|
|
4904
|
+
versions = await deps.listVersions(project2.id);
|
|
4648
4905
|
} catch (error) {
|
|
4649
4906
|
historyDenial = capabilityDenialFromError(error);
|
|
4650
4907
|
if (!historyDenial) throw error;
|
|
@@ -4664,13 +4921,13 @@ async function handleReadProject(deps2, args) {
|
|
|
4664
4921
|
}
|
|
4665
4922
|
|
|
4666
4923
|
// src/toolsLook.ts
|
|
4667
|
-
async function handleReadLook(
|
|
4668
|
-
const resolution = await
|
|
4924
|
+
async function handleReadLook(deps, args) {
|
|
4925
|
+
const resolution = await deps.resolveProject(deps.userId, args.project);
|
|
4669
4926
|
const project2 = resolvedProject(resolution);
|
|
4670
4927
|
if (!project2) {
|
|
4671
4928
|
return textResult({ saved: false, message: describeMiss(resolution, args.project) });
|
|
4672
4929
|
}
|
|
4673
|
-
const saved = await
|
|
4930
|
+
const saved = await deps.readLook(project2.id);
|
|
4674
4931
|
if (!saved) {
|
|
4675
4932
|
return textResult({
|
|
4676
4933
|
saved: false,
|
|
@@ -4679,8 +4936,8 @@ async function handleReadLook(deps2, args) {
|
|
|
4679
4936
|
});
|
|
4680
4937
|
}
|
|
4681
4938
|
const layout = deriveLayoutReport(saved.look);
|
|
4682
|
-
const historyDecision = await
|
|
4683
|
-
subject: { kind: "account", userId:
|
|
4939
|
+
const historyDecision = await deps.authorizeCapability({
|
|
4940
|
+
subject: { kind: "account", userId: deps.userId, mode: "api_token" },
|
|
4684
4941
|
capability: CAPABILITIES.LOOK_HISTORY_READ,
|
|
4685
4942
|
resource: { kind: "project", id: project2.id }
|
|
4686
4943
|
});
|
|
@@ -4688,7 +4945,7 @@ async function handleReadLook(deps2, args) {
|
|
|
4688
4945
|
let versions = [];
|
|
4689
4946
|
if (!historyDenial) {
|
|
4690
4947
|
try {
|
|
4691
|
-
versions = await
|
|
4948
|
+
versions = await deps.listVersions(project2.id);
|
|
4692
4949
|
} catch (error) {
|
|
4693
4950
|
historyDenial = capabilityDenialFromError(error);
|
|
4694
4951
|
if (!historyDenial) throw error;
|
|
@@ -4717,19 +4974,19 @@ async function handleReadLook(deps2, args) {
|
|
|
4717
4974
|
...layout ? { layout } : {}
|
|
4718
4975
|
});
|
|
4719
4976
|
}
|
|
4720
|
-
async function handleSaveLook(
|
|
4721
|
-
const resolution = await
|
|
4977
|
+
async function handleSaveLook(deps, args) {
|
|
4978
|
+
const resolution = await deps.resolveProject(deps.userId, args.project);
|
|
4722
4979
|
const project2 = resolvedProject(resolution);
|
|
4723
4980
|
if (!project2) return errorResult(describeMiss(resolution, args.project));
|
|
4724
|
-
const edit = await
|
|
4725
|
-
subject: { kind: "account", userId:
|
|
4981
|
+
const edit = await deps.authorizeCapability({
|
|
4982
|
+
subject: { kind: "account", userId: deps.userId, mode: "api_token" },
|
|
4726
4983
|
capability: CAPABILITIES.PROJECT_EDIT_SYNCED,
|
|
4727
4984
|
resource: { kind: "project", id: project2.id }
|
|
4728
4985
|
});
|
|
4729
4986
|
const editDenial = authorizationMessage(edit);
|
|
4730
4987
|
if (editDenial) return errorResult(editDenial);
|
|
4731
|
-
const hold = await
|
|
4732
|
-
subject: { kind: "account", userId:
|
|
4988
|
+
const hold = await deps.authorizeCapability({
|
|
4989
|
+
subject: { kind: "account", userId: deps.userId, mode: "api_token" },
|
|
4733
4990
|
capability: CAPABILITIES.LOOK_VERSION_HOLD,
|
|
4734
4991
|
resource: { kind: "project", id: project2.id }
|
|
4735
4992
|
});
|
|
@@ -4740,7 +4997,7 @@ async function handleSaveLook(deps2, args) {
|
|
|
4740
4997
|
if (JSON.stringify(args.look).length > 256 * 1024) {
|
|
4741
4998
|
return errorResult("`look` is too large \u2014 a look is styling only (no screenshots, no image bytes).");
|
|
4742
4999
|
}
|
|
4743
|
-
const latest = await
|
|
5000
|
+
const latest = await deps.readLook(project2.id);
|
|
4744
5001
|
if (latest && looksMatch(args.look, latest.look)) {
|
|
4745
5002
|
return textResult({
|
|
4746
5003
|
ok: true,
|
|
@@ -4751,7 +5008,7 @@ async function handleSaveLook(deps2, args) {
|
|
|
4751
5008
|
}
|
|
4752
5009
|
let version2;
|
|
4753
5010
|
try {
|
|
4754
|
-
const saved = await
|
|
5011
|
+
const saved = await deps.saveLook(
|
|
4755
5012
|
project2.id,
|
|
4756
5013
|
args.look,
|
|
4757
5014
|
args.sourceName?.trim() || void 0,
|
|
@@ -4769,12 +5026,12 @@ async function handleSaveLook(deps2, args) {
|
|
|
4769
5026
|
message: hold.allowed ? `Saved as version ${version2} on "${project2.name}" and advanced Hold to it for default renders.` : `Saved as version ${version2} on "${project2.name}" \u2014 render it with useSavedLook.`
|
|
4770
5027
|
});
|
|
4771
5028
|
}
|
|
4772
|
-
async function handleHoldLook(
|
|
4773
|
-
const resolution = await
|
|
5029
|
+
async function handleHoldLook(deps, args) {
|
|
5030
|
+
const resolution = await deps.resolveProject(deps.userId, args.project);
|
|
4774
5031
|
const project2 = resolvedProject(resolution);
|
|
4775
5032
|
if (!project2) return errorResult(describeMiss(resolution, args.project));
|
|
4776
|
-
const decision = await
|
|
4777
|
-
subject: { kind: "account", userId:
|
|
5033
|
+
const decision = await deps.authorizeCapability({
|
|
5034
|
+
subject: { kind: "account", userId: deps.userId, mode: "api_token" },
|
|
4778
5035
|
capability: CAPABILITIES.LOOK_VERSION_HOLD,
|
|
4779
5036
|
resource: { kind: "project", id: project2.id }
|
|
4780
5037
|
});
|
|
@@ -4782,7 +5039,7 @@ async function handleHoldLook(deps2, args) {
|
|
|
4782
5039
|
if (denial) return errorResult(denial);
|
|
4783
5040
|
let versions;
|
|
4784
5041
|
try {
|
|
4785
|
-
versions = await
|
|
5042
|
+
versions = await deps.listVersions(project2.id);
|
|
4786
5043
|
} catch (error) {
|
|
4787
5044
|
const structured = capabilityDenialFromError(error);
|
|
4788
5045
|
return errorResult(structured ? capabilityDenialMessage(structured) : error.message);
|
|
@@ -4792,7 +5049,7 @@ async function handleHoldLook(deps2, args) {
|
|
|
4792
5049
|
return errorResult(`Version ${args.version} not found on project "${project2.name}" (saved: ${available}).`);
|
|
4793
5050
|
}
|
|
4794
5051
|
try {
|
|
4795
|
-
await
|
|
5052
|
+
await deps.setHeldVersion(project2.id, args.version);
|
|
4796
5053
|
} catch (err) {
|
|
4797
5054
|
const structured = capabilityDenialFromError(err);
|
|
4798
5055
|
return errorResult(structured ? capabilityDenialMessage(structured) : err.message);
|
|
@@ -4804,19 +5061,19 @@ async function handleHoldLook(deps2, args) {
|
|
|
4804
5061
|
message: `Holding v${args.version} on "${project2.name}" \u2014 agents render it by default until release_look (Follow latest).`
|
|
4805
5062
|
});
|
|
4806
5063
|
}
|
|
4807
|
-
async function handleReleaseLook(
|
|
4808
|
-
const resolution = await
|
|
5064
|
+
async function handleReleaseLook(deps, args) {
|
|
5065
|
+
const resolution = await deps.resolveProject(deps.userId, args.project);
|
|
4809
5066
|
const project2 = resolvedProject(resolution);
|
|
4810
5067
|
if (!project2) return errorResult(describeMiss(resolution, args.project));
|
|
4811
|
-
const decision = await
|
|
4812
|
-
subject: { kind: "account", userId:
|
|
5068
|
+
const decision = await deps.authorizeCapability({
|
|
5069
|
+
subject: { kind: "account", userId: deps.userId, mode: "api_token" },
|
|
4813
5070
|
capability: CAPABILITIES.LOOK_VERSION_HOLD,
|
|
4814
5071
|
resource: { kind: "project", id: project2.id }
|
|
4815
5072
|
});
|
|
4816
5073
|
const denial = authorizationMessage(decision);
|
|
4817
5074
|
if (denial) return errorResult(denial);
|
|
4818
5075
|
try {
|
|
4819
|
-
await
|
|
5076
|
+
await deps.setHeldVersion(project2.id, null);
|
|
4820
5077
|
} catch (err) {
|
|
4821
5078
|
const structured = capabilityDenialFromError(err);
|
|
4822
5079
|
return errorResult(structured ? capabilityDenialMessage(structured) : err.message);
|
|
@@ -4884,24 +5141,24 @@ function resolveOutputMode(requested, totalBytes, forced) {
|
|
|
4884
5141
|
}
|
|
4885
5142
|
return { mode: totalBytes > AUTO_INLINE_THRESHOLD_BYTES ? "urls" : "inline" };
|
|
4886
5143
|
}
|
|
4887
|
-
async function resolveLook(
|
|
4888
|
-
const resolved = await resolveLookInner(
|
|
5144
|
+
async function resolveLook(deps, args) {
|
|
5145
|
+
const resolved = await resolveLookInner(deps, args);
|
|
4889
5146
|
const nudge = layoutNudgeNote(args.style, args.look, resolved.look);
|
|
4890
5147
|
if (!nudge) return resolved;
|
|
4891
5148
|
return { ...resolved, note: [resolved.note, nudge].filter(Boolean).join(" ") };
|
|
4892
5149
|
}
|
|
4893
|
-
async function resolveLookInner(
|
|
5150
|
+
async function resolveLookInner(deps, args) {
|
|
4894
5151
|
const style2 = expandStyleLayout(args.style) ?? null;
|
|
4895
5152
|
if (args.look != null) return { look: args.look, style: style2 };
|
|
4896
5153
|
if (args.useSavedLook || args.version != null) {
|
|
4897
|
-
const project2 = resolvedProject(await
|
|
5154
|
+
const project2 = resolvedProject(await deps.resolveProject(deps.userId, args.project));
|
|
4898
5155
|
if (!project2) {
|
|
4899
5156
|
return { look: null, style: style2, note: "no matching ShotOps project found for this account \u2014 used default styling." };
|
|
4900
5157
|
}
|
|
4901
|
-
const saved = await
|
|
5158
|
+
const saved = await deps.readLook(project2.id);
|
|
4902
5159
|
if (args.version != null) {
|
|
4903
|
-
const decision = await
|
|
4904
|
-
subject: { kind: "account", userId:
|
|
5160
|
+
const decision = await deps.authorizeCapability({
|
|
5161
|
+
subject: { kind: "account", userId: deps.userId, mode: "api_token" },
|
|
4905
5162
|
capability: CAPABILITIES.LOOK_HISTORY_READ,
|
|
4906
5163
|
resource: { kind: "project", id: project2.id }
|
|
4907
5164
|
});
|
|
@@ -4912,7 +5169,7 @@ async function resolveLookInner(deps2, args) {
|
|
|
4912
5169
|
if (saved && args.version === saved.version) return { look: saved.look, style: style2 };
|
|
4913
5170
|
let v;
|
|
4914
5171
|
try {
|
|
4915
|
-
v = await
|
|
5172
|
+
v = await deps.readVersion(project2.id, args.version);
|
|
4916
5173
|
} catch (error) {
|
|
4917
5174
|
const structured = capabilityDenialFromError(error);
|
|
4918
5175
|
if (!structured) throw error;
|
|
@@ -4938,8 +5195,8 @@ async function resolveLookInner(deps2, args) {
|
|
|
4938
5195
|
};
|
|
4939
5196
|
}
|
|
4940
5197
|
if (project2.heldVersion != null && project2.heldVersion !== saved?.version) {
|
|
4941
|
-
const decision = await
|
|
4942
|
-
subject: { kind: "account", userId:
|
|
5198
|
+
const decision = await deps.authorizeCapability({
|
|
5199
|
+
subject: { kind: "account", userId: deps.userId, mode: "api_token" },
|
|
4943
5200
|
capability: CAPABILITIES.LOOK_VERSION_HOLD,
|
|
4944
5201
|
resource: { kind: "project", id: project2.id }
|
|
4945
5202
|
});
|
|
@@ -4957,7 +5214,7 @@ async function resolveLookInner(deps2, args) {
|
|
|
4957
5214
|
}
|
|
4958
5215
|
let held;
|
|
4959
5216
|
try {
|
|
4960
|
-
held = await
|
|
5217
|
+
held = await deps.readVersion(project2.id, project2.heldVersion);
|
|
4961
5218
|
} catch (error) {
|
|
4962
5219
|
const structured = capabilityDenialFromError(error);
|
|
4963
5220
|
if (!structured) throw error;
|
|
@@ -5093,10 +5350,10 @@ function allLiveScreenRefs(record) {
|
|
|
5093
5350
|
const parsed = parseScreenManifest(record?.handoffRefs);
|
|
5094
5351
|
return parsed ? allManifestEntries(parsed) : [];
|
|
5095
5352
|
}
|
|
5096
|
-
async function persistProject(
|
|
5353
|
+
async function persistProject(deps, args, record, claimPolicy = "none") {
|
|
5097
5354
|
const names = screenNamesFromRecord(record);
|
|
5098
5355
|
const source = names.length === 0 ? void 0 : args.sourceDir ? { kind: "local-path", ref: { dir: args.sourceDir, names } } : { kind: "agent", ref: { names } };
|
|
5099
|
-
const introducedRefs = handoffRefsFromScreenshots(args.screenshots, names,
|
|
5356
|
+
const introducedRefs = handoffRefsFromScreenshots(args.screenshots, names, deps.userId);
|
|
5100
5357
|
const wrapWith = (handoffRefs, existingRecord) => {
|
|
5101
5358
|
const manifest = mergeManifest(
|
|
5102
5359
|
preservedNonBaseCells(existingRecord, names),
|
|
@@ -5128,6 +5385,7 @@ async function persistProject(deps2, args, record, claimPolicy = "none") {
|
|
|
5128
5385
|
};
|
|
5129
5386
|
};
|
|
5130
5387
|
const ctx = {
|
|
5388
|
+
actorUserId: deps.userId,
|
|
5131
5389
|
screenshots: args.screenshots,
|
|
5132
5390
|
look: args.look,
|
|
5133
5391
|
// #217 — expanded here too, not just in resolveLook. Nothing reads ctx.style TODAY (the
|
|
@@ -5142,47 +5400,47 @@ async function persistProject(deps2, args, record, claimPolicy = "none") {
|
|
|
5142
5400
|
sourceDir: args.sourceDir
|
|
5143
5401
|
};
|
|
5144
5402
|
try {
|
|
5145
|
-
if (claimPolicy === "unsigned-new" &&
|
|
5403
|
+
if (claimPolicy === "unsigned-new" && deps.createPendingProjectClaim) {
|
|
5146
5404
|
if (args.project) {
|
|
5147
5405
|
return {
|
|
5148
5406
|
error: "Updating an existing ShotOps project requires the account token that owns or can access it. Remove `project` to create a new pending claim, or configure SHOTOPS_TOKEN to update this project."
|
|
5149
5407
|
};
|
|
5150
5408
|
}
|
|
5151
5409
|
const name = args.projectName?.trim() || "ShotOps render";
|
|
5152
|
-
return await
|
|
5410
|
+
return await deps.createPendingProjectClaim(name, wrapWith(introducedRefs), ctx);
|
|
5153
5411
|
}
|
|
5154
5412
|
let projectId;
|
|
5155
5413
|
if (args.project) {
|
|
5156
|
-
const resolution = await
|
|
5414
|
+
const resolution = await deps.resolveProject(deps.userId, args.project);
|
|
5157
5415
|
const project2 = resolvedProject(resolution);
|
|
5158
5416
|
if (!project2) return { error: describeMiss(resolution, args.project) };
|
|
5159
|
-
const decision = await
|
|
5160
|
-
subject: { kind: "account", userId:
|
|
5417
|
+
const decision = await deps.authorizeCapability({
|
|
5418
|
+
subject: { kind: "account", userId: deps.userId, mode: "api_token" },
|
|
5161
5419
|
capability: CAPABILITIES.PROJECT_EDIT_SYNCED,
|
|
5162
5420
|
resource: { kind: "project", id: project2.id }
|
|
5163
5421
|
});
|
|
5164
5422
|
const denial = authorizationMessage(decision);
|
|
5165
5423
|
if (denial) return { error: denial };
|
|
5166
|
-
const existing = await
|
|
5424
|
+
const existing = await deps.readProjectRecord(project2.id);
|
|
5167
5425
|
const merged = mergeHandoffRefs(handoffRefsFromRecord(existing?.record), introducedRefs, names);
|
|
5168
5426
|
if (!existing) return { error: `Project "${project2.name}" has no readable saved record.` };
|
|
5169
|
-
await
|
|
5427
|
+
await deps.updateProjectRecord(project2.id, wrapWith(merged, existing.record), existing.recordVersion ?? 1, ctx);
|
|
5170
5428
|
projectId = project2.id;
|
|
5171
5429
|
} else {
|
|
5172
5430
|
const name = args.projectName?.trim() || "ShotOps render";
|
|
5173
|
-
if (
|
|
5174
|
-
const decision = await
|
|
5175
|
-
subject: { kind: "account", userId:
|
|
5431
|
+
if (deps.authorizeAccountCapability) {
|
|
5432
|
+
const decision = await deps.authorizeAccountCapability({
|
|
5433
|
+
subject: { kind: "account", userId: deps.userId, mode: "api_token" },
|
|
5176
5434
|
capability: CAPABILITIES.PROJECT_CREATE_SYNCED,
|
|
5177
|
-
resource: { kind: "account", id:
|
|
5435
|
+
resource: { kind: "account", id: deps.userId }
|
|
5178
5436
|
});
|
|
5179
5437
|
const denial = authorizationMessage(decision);
|
|
5180
5438
|
if (denial) return { error: denial };
|
|
5181
5439
|
}
|
|
5182
|
-
const created = await
|
|
5440
|
+
const created = await deps.createProject(deps.userId, name, wrapWith(introducedRefs), ctx);
|
|
5183
5441
|
projectId = created.id;
|
|
5184
5442
|
}
|
|
5185
|
-
return { projectId, openUrl: `${
|
|
5443
|
+
return { projectId, openUrl: `${deps.studioOrigin}/?project=${projectId}` };
|
|
5186
5444
|
} catch (err) {
|
|
5187
5445
|
return { error: err.message };
|
|
5188
5446
|
}
|
|
@@ -5227,23 +5485,28 @@ var IDEMPOTENT_DESTRUCTIVE_WRITE = {
|
|
|
5227
5485
|
idempotentHint: true,
|
|
5228
5486
|
openWorldHint: false
|
|
5229
5487
|
};
|
|
5230
|
-
function
|
|
5488
|
+
function securitySchemesFor(deps, toolName) {
|
|
5489
|
+
if (deps.allowLocalScreenshots) return [{ type: "noauth" }];
|
|
5490
|
+
if (deps.anonymous && ANONYMOUS_TOOLS.has(toolName)) {
|
|
5491
|
+
return [{ type: "noauth" }, { type: "oauth2", scopes: [] }];
|
|
5492
|
+
}
|
|
5493
|
+
return [{ type: "oauth2", scopes: [] }];
|
|
5494
|
+
}
|
|
5495
|
+
function toolMeta(deps, toolName, invoking, invoked, extra = {}) {
|
|
5231
5496
|
return {
|
|
5232
|
-
|
|
5233
|
-
// same registrar accurate for the local stdio server.
|
|
5234
|
-
securitySchemes: deps2.allowLocalScreenshots ? [{ type: "noauth" }] : [{ type: "oauth2", scopes: [] }],
|
|
5497
|
+
securitySchemes: securitySchemesFor(deps, toolName),
|
|
5235
5498
|
"openai/toolInvocation/invoking": invoking,
|
|
5236
5499
|
"openai/toolInvocation/invoked": invoked,
|
|
5237
5500
|
...extra
|
|
5238
5501
|
};
|
|
5239
5502
|
}
|
|
5240
|
-
function registerTools(
|
|
5241
|
-
registerWidgets(
|
|
5242
|
-
|
|
5503
|
+
function registerTools(server, deps) {
|
|
5504
|
+
registerWidgets(server);
|
|
5505
|
+
server.registerTool(
|
|
5243
5506
|
"render_strip",
|
|
5244
5507
|
{
|
|
5245
5508
|
title: "Render App Store screenshots",
|
|
5246
|
-
description: 'Make the store screenshots: raw app screen captures in, finished marketing panels out. Each screenshot is composited into a styled 3D device mockup, laid out as an App Store screenshot strip with the headlines you pass, and returned as per-panel PNGs at the App Store panel size `panelPresetId` names. This is the tool for a strip built from screenshots you have in hand; to reproduce one a designer already saved, use render_project. In ChatGPT, call import_screenshot first and pass the returned refs in the preferred flat form: { "screenshots": [{ "ref": "\u2026" }] } \u2014 each flat entry is one panel, and nested slots only when ONE panel should hold several phones. Art-direct it with `style`/`look` or a project\'s saved look, iterate with a
|
|
5509
|
+
description: 'Make the store screenshots: raw app screen captures in, finished marketing panels out. Each screenshot is composited into a styled 3D device mockup, laid out as an App Store screenshot strip with the headlines you pass, and returned as per-panel PNGs at the App Store panel size `panelPresetId` names. This is the tool for a strip built from screenshots you have in hand; to reproduce one a designer already saved, use render_project. In ChatGPT, call import_screenshot first and pass the returned refs in the preferred flat form: { "screenshots": [{ "ref": "\u2026" }] } \u2014 each flat entry is one panel, and nested slots only when ONE panel should hold several phones. Art-direct it with `style`/`look` or a project\'s saved look, iterate with a low-res `preview` at a quarter the render cost, and take results `inline` or as `urls` for large payloads. Screenshots can vary per store locale via { "locales": \u2026 } entries \u2014 `locale` picks which variant renders (missing variants fall back to en-US). For real, full-resolution screenshots call request_screenshot_upload first \u2014 inline base64 is for small payloads only. This tool ONLY renders images: it never creates or updates a project (use save_project for that) and no store credential is involved.',
|
|
5247
5510
|
inputSchema: renderStripAdvertisedShape,
|
|
5248
5511
|
outputSchema: renderStripOutputSchema,
|
|
5249
5512
|
// ADDITIVE, not destructive: this can mint private generated assets but cannot change a
|
|
@@ -5251,15 +5514,16 @@ function registerTools(server2, deps2) {
|
|
|
5251
5514
|
// `createProject` flag, which made ChatGPT prompt for permission just to LOOK at a render.
|
|
5252
5515
|
annotations: ADDITIVE_WRITE,
|
|
5253
5516
|
_meta: toolMeta(
|
|
5254
|
-
|
|
5517
|
+
deps,
|
|
5518
|
+
"render_strip",
|
|
5255
5519
|
"Rendering ShotOps strip\u2026",
|
|
5256
5520
|
"ShotOps strip rendered",
|
|
5257
5521
|
widgetBinding(PANELS_WIDGET_URI)
|
|
5258
5522
|
)
|
|
5259
5523
|
},
|
|
5260
|
-
shapedInput("render_strip", renderStripShape, (args) => handleRenderStrip(
|
|
5524
|
+
shapedInput("render_strip", renderStripShape, (args) => handleRenderStrip(deps, args))
|
|
5261
5525
|
);
|
|
5262
|
-
|
|
5526
|
+
server.registerTool(
|
|
5263
5527
|
"emit_bundle",
|
|
5264
5528
|
{
|
|
5265
5529
|
title: "Export fastlane bundle",
|
|
@@ -5271,15 +5535,16 @@ function registerTools(server2, deps2) {
|
|
|
5271
5535
|
// than the gallery's — a zip download, what is inside it, the share link's expiry, and the
|
|
5272
5536
|
// zero-custody line stated where someone might otherwise assume we submit for them.
|
|
5273
5537
|
_meta: toolMeta(
|
|
5274
|
-
|
|
5538
|
+
deps,
|
|
5539
|
+
"emit_bundle",
|
|
5275
5540
|
"Building fastlane bundle\u2026",
|
|
5276
5541
|
"Fastlane bundle built",
|
|
5277
5542
|
widgetBinding(BUNDLE_WIDGET_URI)
|
|
5278
5543
|
)
|
|
5279
5544
|
},
|
|
5280
|
-
shapedInput("emit_bundle", emitBundleShape, (args) => handleEmitBundle(
|
|
5545
|
+
shapedInput("emit_bundle", emitBundleShape, (args) => handleEmitBundle(deps, args))
|
|
5281
5546
|
);
|
|
5282
|
-
|
|
5547
|
+
server.registerTool(
|
|
5283
5548
|
"save_project",
|
|
5284
5549
|
{
|
|
5285
5550
|
title: "Save as editable project",
|
|
@@ -5287,11 +5552,11 @@ function registerTools(server2, deps2) {
|
|
|
5287
5552
|
inputSchema: saveProjectAdvertisedShape,
|
|
5288
5553
|
outputSchema: saveProjectOutputSchema,
|
|
5289
5554
|
annotations: DESTRUCTIVE_WRITE,
|
|
5290
|
-
_meta: toolMeta(
|
|
5555
|
+
_meta: toolMeta(deps, "save_project", "Saving ShotOps project\u2026", "ShotOps project saved")
|
|
5291
5556
|
},
|
|
5292
|
-
shapedInput("save_project", saveProjectShape, (args) => handleSaveProject(
|
|
5557
|
+
shapedInput("save_project", saveProjectShape, (args) => handleSaveProject(deps, args))
|
|
5293
5558
|
);
|
|
5294
|
-
|
|
5559
|
+
server.registerTool(
|
|
5295
5560
|
"read_look",
|
|
5296
5561
|
{
|
|
5297
5562
|
title: "Read saved look",
|
|
@@ -5299,11 +5564,11 @@ function registerTools(server2, deps2) {
|
|
|
5299
5564
|
inputSchema: readLookShape,
|
|
5300
5565
|
outputSchema: readLookOutputSchema,
|
|
5301
5566
|
annotations: READ_ONLY,
|
|
5302
|
-
_meta: toolMeta(
|
|
5567
|
+
_meta: toolMeta(deps, "read_look", "Reading saved look\u2026", "Saved look read")
|
|
5303
5568
|
},
|
|
5304
|
-
shapedInput("read_look", readLookShape, (args) => handleReadLook(
|
|
5569
|
+
shapedInput("read_look", readLookShape, (args) => handleReadLook(deps, args))
|
|
5305
5570
|
);
|
|
5306
|
-
|
|
5571
|
+
server.registerTool(
|
|
5307
5572
|
"read_project",
|
|
5308
5573
|
{
|
|
5309
5574
|
title: "Read full project",
|
|
@@ -5311,11 +5576,11 @@ function registerTools(server2, deps2) {
|
|
|
5311
5576
|
inputSchema: readProjectShape,
|
|
5312
5577
|
outputSchema: readProjectOutputSchema,
|
|
5313
5578
|
annotations: READ_ONLY,
|
|
5314
|
-
_meta: toolMeta(
|
|
5579
|
+
_meta: toolMeta(deps, "read_project", "Reading ShotOps project\u2026", "ShotOps project read")
|
|
5315
5580
|
},
|
|
5316
|
-
shapedInput("read_project", readProjectShape, (args) => handleReadProject(
|
|
5581
|
+
shapedInput("read_project", readProjectShape, (args) => handleReadProject(deps, args))
|
|
5317
5582
|
);
|
|
5318
|
-
|
|
5583
|
+
server.registerTool(
|
|
5319
5584
|
"render_project",
|
|
5320
5585
|
{
|
|
5321
5586
|
title: "Render a saved project",
|
|
@@ -5329,15 +5594,16 @@ function registerTools(server2, deps2) {
|
|
|
5329
5594
|
// agent on ChatGPT mobile actually calls, so leaving it unbound was leaving raw JSON in
|
|
5330
5595
|
// front of the users most likely to see it.
|
|
5331
5596
|
_meta: toolMeta(
|
|
5332
|
-
|
|
5597
|
+
deps,
|
|
5598
|
+
"render_project",
|
|
5333
5599
|
"Rendering saved project\u2026",
|
|
5334
5600
|
"Saved project rendered",
|
|
5335
5601
|
widgetBinding(PANELS_WIDGET_URI)
|
|
5336
5602
|
)
|
|
5337
5603
|
},
|
|
5338
|
-
shapedInput("render_project", renderProjectShape, (args) => handleRenderProject(
|
|
5604
|
+
shapedInput("render_project", renderProjectShape, (args) => handleRenderProject(deps, args))
|
|
5339
5605
|
);
|
|
5340
|
-
|
|
5606
|
+
server.registerTool(
|
|
5341
5607
|
"save_look",
|
|
5342
5608
|
{
|
|
5343
5609
|
title: "Save look",
|
|
@@ -5345,11 +5611,11 @@ function registerTools(server2, deps2) {
|
|
|
5345
5611
|
inputSchema: saveLookAdvertisedShape,
|
|
5346
5612
|
outputSchema: saveLookOutputSchema,
|
|
5347
5613
|
annotations: ADDITIVE_WRITE,
|
|
5348
|
-
_meta: toolMeta(
|
|
5614
|
+
_meta: toolMeta(deps, "save_look", "Saving ShotOps look\u2026", "ShotOps look saved")
|
|
5349
5615
|
},
|
|
5350
|
-
shapedInput("save_look", saveLookShape, (args) => handleSaveLook(
|
|
5616
|
+
shapedInput("save_look", saveLookShape, (args) => handleSaveLook(deps, args))
|
|
5351
5617
|
);
|
|
5352
|
-
|
|
5618
|
+
server.registerTool(
|
|
5353
5619
|
"hold_look",
|
|
5354
5620
|
{
|
|
5355
5621
|
title: "Hold a look version",
|
|
@@ -5357,11 +5623,11 @@ function registerTools(server2, deps2) {
|
|
|
5357
5623
|
inputSchema: holdLookShape,
|
|
5358
5624
|
outputSchema: holdLookOutputSchema,
|
|
5359
5625
|
annotations: IDEMPOTENT_WRITE,
|
|
5360
|
-
_meta: toolMeta(
|
|
5626
|
+
_meta: toolMeta(deps, "hold_look", "Holding look version\u2026", "Look version held")
|
|
5361
5627
|
},
|
|
5362
|
-
shapedInput("hold_look", holdLookShape, (args) => handleHoldLook(
|
|
5628
|
+
shapedInput("hold_look", holdLookShape, (args) => handleHoldLook(deps, args))
|
|
5363
5629
|
);
|
|
5364
|
-
|
|
5630
|
+
server.registerTool(
|
|
5365
5631
|
"release_look",
|
|
5366
5632
|
{
|
|
5367
5633
|
title: "Release the held look",
|
|
@@ -5369,11 +5635,11 @@ function registerTools(server2, deps2) {
|
|
|
5369
5635
|
inputSchema: releaseLookShape,
|
|
5370
5636
|
outputSchema: releaseLookOutputSchema,
|
|
5371
5637
|
annotations: IDEMPOTENT_WRITE,
|
|
5372
|
-
_meta: toolMeta(
|
|
5638
|
+
_meta: toolMeta(deps, "release_look", "Releasing look version\u2026", "Look version released")
|
|
5373
5639
|
},
|
|
5374
|
-
shapedInput("release_look", releaseLookShape, (args) => handleReleaseLook(
|
|
5640
|
+
shapedInput("release_look", releaseLookShape, (args) => handleReleaseLook(deps, args))
|
|
5375
5641
|
);
|
|
5376
|
-
|
|
5642
|
+
server.registerTool(
|
|
5377
5643
|
"describe_look",
|
|
5378
5644
|
{
|
|
5379
5645
|
title: "Design presets & style catalog",
|
|
@@ -5381,11 +5647,11 @@ function registerTools(server2, deps2) {
|
|
|
5381
5647
|
inputSchema: describeLookShape,
|
|
5382
5648
|
outputSchema: describeLookOutputSchema,
|
|
5383
5649
|
annotations: READ_ONLY,
|
|
5384
|
-
_meta: toolMeta(
|
|
5650
|
+
_meta: toolMeta(deps, "describe_look", "Reading look field catalog\u2026", "Look field catalog read")
|
|
5385
5651
|
},
|
|
5386
5652
|
shapedInput("describe_look", describeLookShape, () => handleDescribeLook())
|
|
5387
5653
|
);
|
|
5388
|
-
|
|
5654
|
+
server.registerTool(
|
|
5389
5655
|
"request_screenshot_upload",
|
|
5390
5656
|
{
|
|
5391
5657
|
title: "Upload full-size screenshots",
|
|
@@ -5393,15 +5659,15 @@ function registerTools(server2, deps2) {
|
|
|
5393
5659
|
inputSchema: requestScreenshotUploadShape,
|
|
5394
5660
|
outputSchema: requestScreenshotUploadOutputSchema,
|
|
5395
5661
|
annotations: ADDITIVE_WRITE,
|
|
5396
|
-
_meta: toolMeta(
|
|
5662
|
+
_meta: toolMeta(deps, "request_screenshot_upload", "Preparing screenshot uploads\u2026", "Screenshot uploads ready")
|
|
5397
5663
|
},
|
|
5398
5664
|
shapedInput(
|
|
5399
5665
|
"request_screenshot_upload",
|
|
5400
5666
|
requestScreenshotUploadShape,
|
|
5401
|
-
(args) => handleRequestScreenshotUpload(
|
|
5667
|
+
(args) => handleRequestScreenshotUpload(deps, args)
|
|
5402
5668
|
)
|
|
5403
5669
|
);
|
|
5404
|
-
|
|
5670
|
+
server.registerTool(
|
|
5405
5671
|
"import_screenshot",
|
|
5406
5672
|
{
|
|
5407
5673
|
title: "Import screenshots",
|
|
@@ -5409,13 +5675,13 @@ function registerTools(server2, deps2) {
|
|
|
5409
5675
|
inputSchema: importScreenshotAdvertisedShape,
|
|
5410
5676
|
outputSchema: importScreenshotOutputSchema,
|
|
5411
5677
|
annotations: ADDITIVE_WRITE,
|
|
5412
|
-
_meta: toolMeta(
|
|
5678
|
+
_meta: toolMeta(deps, "import_screenshot", "Importing attached screenshot\u2026", "Attached screenshot imported", {
|
|
5413
5679
|
"openai/fileParams": ["file"]
|
|
5414
5680
|
})
|
|
5415
5681
|
},
|
|
5416
|
-
shapedInput("import_screenshot", importScreenshotShape, (args) => handleImportScreenshot(
|
|
5682
|
+
shapedInput("import_screenshot", importScreenshotShape, (args) => handleImportScreenshot(deps, args))
|
|
5417
5683
|
);
|
|
5418
|
-
|
|
5684
|
+
server.registerTool(
|
|
5419
5685
|
"delete_assets",
|
|
5420
5686
|
{
|
|
5421
5687
|
title: "Delete uploaded assets",
|
|
@@ -5423,9 +5689,9 @@ function registerTools(server2, deps2) {
|
|
|
5423
5689
|
inputSchema: deleteAssetsShape,
|
|
5424
5690
|
outputSchema: deleteAssetsOutputSchema,
|
|
5425
5691
|
annotations: IDEMPOTENT_DESTRUCTIVE_WRITE,
|
|
5426
|
-
_meta: toolMeta(
|
|
5692
|
+
_meta: toolMeta(deps, "delete_assets", "Deleting ShotOps assets\u2026", "ShotOps assets deleted")
|
|
5427
5693
|
},
|
|
5428
|
-
shapedInput("delete_assets", deleteAssetsShape, (args) => handleDeleteAssets(
|
|
5694
|
+
shapedInput("delete_assets", deleteAssetsShape, (args) => handleDeleteAssets(deps, args))
|
|
5429
5695
|
);
|
|
5430
5696
|
}
|
|
5431
5697
|
|
|
@@ -5490,13 +5756,22 @@ function stripFromSlots(input) {
|
|
|
5490
5756
|
let gradientFrom = FALLBACK_GRADIENT_FROM;
|
|
5491
5757
|
let gradientTo = FALLBACK_GRADIENT_TO;
|
|
5492
5758
|
let gradientDir = "vertical";
|
|
5759
|
+
let gradientStops;
|
|
5760
|
+
let gradientAngle;
|
|
5493
5761
|
let shadow = true;
|
|
5494
5762
|
let floorReflection = false;
|
|
5495
5763
|
if (opaque) {
|
|
5496
5764
|
bgMode = opaque.bgMode === "perPanel" ? "perPanel" : "gradient";
|
|
5497
|
-
|
|
5498
|
-
|
|
5499
|
-
|
|
5765
|
+
const gradient = stripGradientOf({
|
|
5766
|
+
gradientStops: Array.isArray(opaque.gradientStops) ? opaque.gradientStops : void 0,
|
|
5767
|
+
gradientAngle: typeof opaque.gradientAngle === "number" ? opaque.gradientAngle : void 0,
|
|
5768
|
+
gradientFrom: typeof opaque.gradientFrom === "string" ? opaque.gradientFrom : gradientFrom,
|
|
5769
|
+
gradientTo: typeof opaque.gradientTo === "string" ? opaque.gradientTo : gradientTo,
|
|
5770
|
+
gradientDir: opaque.gradientDir === "horizontal" ? "horizontal" : gradientDir
|
|
5771
|
+
});
|
|
5772
|
+
gradientStops = gradient.stops;
|
|
5773
|
+
gradientAngle = gradient.angle;
|
|
5774
|
+
({ gradientFrom, gradientTo, gradientDir } = mirrorsOf(gradient));
|
|
5500
5775
|
if (opaque.shadow === false) shadow = false;
|
|
5501
5776
|
if (opaque.floorReflection === true) floorReflection = true;
|
|
5502
5777
|
const sourceOrder = [];
|
|
@@ -5510,9 +5785,16 @@ function stripFromSlots(input) {
|
|
|
5510
5785
|
} else if (style2) {
|
|
5511
5786
|
const bg = style2.background ?? {};
|
|
5512
5787
|
bgMode = bg.mode === "perPanel" ? "perPanel" : "gradient";
|
|
5513
|
-
|
|
5514
|
-
|
|
5515
|
-
|
|
5788
|
+
const gradient = stripGradientOf({
|
|
5789
|
+
gradientStops: bg.stops,
|
|
5790
|
+
gradientAngle: bg.angle,
|
|
5791
|
+
gradientFrom,
|
|
5792
|
+
gradientTo,
|
|
5793
|
+
gradientDir
|
|
5794
|
+
});
|
|
5795
|
+
gradientStops = gradient.stops;
|
|
5796
|
+
gradientAngle = gradient.angle;
|
|
5797
|
+
({ gradientFrom, gradientTo, gradientDir } = mirrorsOf(gradient));
|
|
5516
5798
|
if (bg.shadow === false) shadow = false;
|
|
5517
5799
|
if (bg.floorReflection === true) floorReflection = true;
|
|
5518
5800
|
if (Array.isArray(bg.panelColors)) {
|
|
@@ -5551,6 +5833,8 @@ function stripFromSlots(input) {
|
|
|
5551
5833
|
look: {
|
|
5552
5834
|
panelPresetId: panelPresetId2,
|
|
5553
5835
|
bgMode,
|
|
5836
|
+
gradientStops,
|
|
5837
|
+
gradientAngle,
|
|
5554
5838
|
gradientFrom,
|
|
5555
5839
|
gradientTo,
|
|
5556
5840
|
gradientDir,
|
|
@@ -5640,7 +5924,7 @@ var MIME = {
|
|
|
5640
5924
|
".wasm": "application/wasm"
|
|
5641
5925
|
};
|
|
5642
5926
|
async function startStaticServer(rootDir) {
|
|
5643
|
-
const
|
|
5927
|
+
const server = createServer((req, res) => {
|
|
5644
5928
|
const reqPath = decodeURIComponent((req.url ?? "/").split("?")[0]);
|
|
5645
5929
|
const rel = reqPath === "/" ? "index.html" : reqPath.replace(/^\/+/, "");
|
|
5646
5930
|
const filePath = join2(rootDir, rel);
|
|
@@ -5663,14 +5947,14 @@ async function startStaticServer(rootDir) {
|
|
|
5663
5947
|
);
|
|
5664
5948
|
});
|
|
5665
5949
|
await new Promise((resolve, reject) => {
|
|
5666
|
-
|
|
5667
|
-
|
|
5950
|
+
server.once("error", reject);
|
|
5951
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
5668
5952
|
});
|
|
5669
|
-
const addr =
|
|
5953
|
+
const addr = server.address();
|
|
5670
5954
|
return {
|
|
5671
5955
|
url: `http://127.0.0.1:${addr.port}/`,
|
|
5672
5956
|
close: () => new Promise((resolve) => {
|
|
5673
|
-
|
|
5957
|
+
server.close(() => resolve());
|
|
5674
5958
|
})
|
|
5675
5959
|
};
|
|
5676
5960
|
}
|
|
@@ -5711,13 +5995,13 @@ var StripRenderer = class {
|
|
|
5711
5995
|
if (!this.harnessUrl) this.harnessUrl = await this.startHarnessServer();
|
|
5712
5996
|
const { chromium } = await import("playwright");
|
|
5713
5997
|
this.browser = await chromium.launch({ headless: true });
|
|
5714
|
-
const
|
|
5715
|
-
|
|
5998
|
+
const page2 = await this.browser.newPage();
|
|
5999
|
+
page2.on("console", (m) => {
|
|
5716
6000
|
if (/CONTEXT_LOST|Context Lost/i.test(m.text())) this.sawContextLost = true;
|
|
5717
6001
|
});
|
|
5718
|
-
await
|
|
5719
|
-
await
|
|
5720
|
-
this.page =
|
|
6002
|
+
await page2.goto(this.harnessUrl, { waitUntil: "load" });
|
|
6003
|
+
await page2.waitForFunction(() => window.__RENDER_READY === true, { timeout: 9e4 });
|
|
6004
|
+
this.page = page2;
|
|
5721
6005
|
}
|
|
5722
6006
|
// The one line that forks between hosted and local. Everything downstream (browser launch,
|
|
5723
6007
|
// the __RENDER_READY handshake, window.renderStrip, boot-retry, CONTEXT_LOST) is identical
|
|
@@ -5729,8 +6013,8 @@ var StripRenderer = class {
|
|
|
5729
6013
|
return this.staticServer.url;
|
|
5730
6014
|
}
|
|
5731
6015
|
const configFile = fileURLToPath3(new URL("../vite.config.mjs", import.meta.url));
|
|
5732
|
-
const { createServer:
|
|
5733
|
-
this.vite = await
|
|
6016
|
+
const { createServer: createServer3 } = await import("vite");
|
|
6017
|
+
this.vite = await createServer3({ configFile });
|
|
5734
6018
|
await this.vite.listen();
|
|
5735
6019
|
const addr = this.vite.httpServer?.address();
|
|
5736
6020
|
const port = this.vite.config.server.port || (addr && typeof addr === "object" ? addr.port : 0);
|
|
@@ -5948,11 +6232,11 @@ function parseControlPlaneResponse(op, value) {
|
|
|
5948
6232
|
// package.json
|
|
5949
6233
|
var package_default = {
|
|
5950
6234
|
name: "shotops-mcp",
|
|
5951
|
-
version: "0.
|
|
6235
|
+
version: "0.7.0",
|
|
5952
6236
|
private: false,
|
|
5953
6237
|
type: "module",
|
|
5954
6238
|
description: "The bundle-emitting MCP server over the @engine/@sync spine. Exposes ShotOps tools to ChatGPT, Claude Code, Cursor, and CI over Streamable HTTP with OAuth or personal API tokens. Zero-custody: no code path accepts, stores, or forwards a store-signing credential \u2014 upload stays the user's own fastlane. Renders via headless Playwright Chromium, the engine's native non-browser habitat (same path as mockup-mcp). Also ships as a free LOCAL stdio server (`npx shotops-mcp`) \u2014 same render, on your own machine, no account needed.",
|
|
5955
|
-
license: "
|
|
6239
|
+
license: "SEE LICENSE IN LICENSE",
|
|
5956
6240
|
homepage: "https://shotops.dev",
|
|
5957
6241
|
repository: {
|
|
5958
6242
|
type: "git",
|
|
@@ -5969,7 +6253,8 @@ var package_default = {
|
|
|
5969
6253
|
"dist",
|
|
5970
6254
|
"harness-dist",
|
|
5971
6255
|
"widget-dist",
|
|
5972
|
-
"README.md"
|
|
6256
|
+
"README.md",
|
|
6257
|
+
"THIRD_PARTY_NOTICES"
|
|
5973
6258
|
],
|
|
5974
6259
|
engines: {
|
|
5975
6260
|
node: ">=20.12"
|
|
@@ -6027,12 +6312,12 @@ var ControlPlaneOperationError = class extends Error {
|
|
|
6027
6312
|
this.requiredPlan = typeof body?.requiredPlan === "string" ? body.requiredPlan : null;
|
|
6028
6313
|
}
|
|
6029
6314
|
};
|
|
6030
|
-
async function callOp(
|
|
6315
|
+
async function callOp(token, op, args = {}) {
|
|
6031
6316
|
const res = await fetch(controlPlaneUrl(), {
|
|
6032
6317
|
method: "POST",
|
|
6033
6318
|
headers: {
|
|
6034
6319
|
"content-type": "application/json",
|
|
6035
|
-
Authorization: `Bearer ${
|
|
6320
|
+
Authorization: `Bearer ${token}`,
|
|
6036
6321
|
[SHOTOPS_CLIENT_HEADER]: `shotops-mcp/${VERSION}`
|
|
6037
6322
|
},
|
|
6038
6323
|
body: JSON.stringify({ op, ...args })
|
|
@@ -6066,58 +6351,58 @@ function localScreenPaths(screenshots2) {
|
|
|
6066
6351
|
}
|
|
6067
6352
|
return out;
|
|
6068
6353
|
}
|
|
6069
|
-
function connectHostedBridge(
|
|
6354
|
+
function connectHostedBridge(token) {
|
|
6070
6355
|
return {
|
|
6071
6356
|
async resolveProject(_userId, projectId) {
|
|
6072
|
-
const res = await callOp(
|
|
6357
|
+
const res = await callOp(token, "resolve_project", projectId ? { project: projectId } : {});
|
|
6073
6358
|
if (res.project) return res.project;
|
|
6074
6359
|
return typeof res.reason === "string" ? res : null;
|
|
6075
6360
|
},
|
|
6076
6361
|
async readLook(projectId) {
|
|
6077
|
-
const res = await callOp(
|
|
6362
|
+
const res = await callOp(token, "read_look", { project: projectId });
|
|
6078
6363
|
return res.look;
|
|
6079
6364
|
},
|
|
6080
6365
|
async readVersion(projectId, version2) {
|
|
6081
|
-
const res = await callOp(
|
|
6366
|
+
const res = await callOp(token, "read_version", { project: projectId, version: version2 });
|
|
6082
6367
|
return res.version;
|
|
6083
6368
|
},
|
|
6084
6369
|
async listVersions(projectId) {
|
|
6085
|
-
const res = await callOp(
|
|
6370
|
+
const res = await callOp(token, "list_versions", { project: projectId });
|
|
6086
6371
|
return res.versions;
|
|
6087
6372
|
},
|
|
6088
6373
|
async setHeldVersion(projectId, version2) {
|
|
6089
|
-
await callOp(
|
|
6374
|
+
await callOp(token, "set_held_version", { project: projectId, version: version2 });
|
|
6090
6375
|
},
|
|
6091
6376
|
async readProjectRecord(projectId) {
|
|
6092
|
-
const res = await callOp(
|
|
6377
|
+
const res = await callOp(token, "read_project_record", { project: projectId });
|
|
6093
6378
|
return res.record;
|
|
6094
6379
|
},
|
|
6095
6380
|
async readProjectScreens(projectId) {
|
|
6096
|
-
const res = await callOp(
|
|
6381
|
+
const res = await callOp(token, "read_project_screens", { project: projectId });
|
|
6097
6382
|
const screens = res.screens;
|
|
6098
6383
|
const untrusted = res.untrusted;
|
|
6099
6384
|
return { screens, ...untrusted && untrusted.length > 0 ? { untrusted } : {} };
|
|
6100
6385
|
},
|
|
6101
6386
|
async saveLook(projectId, look2, sourceName) {
|
|
6102
|
-
const res = await callOp(
|
|
6387
|
+
const res = await callOp(token, "save_look", { project: projectId, look: look2, ...sourceName ? { sourceName } : {} });
|
|
6103
6388
|
return { version: res.version };
|
|
6104
6389
|
},
|
|
6105
6390
|
async createProject(_userId, name, record) {
|
|
6106
|
-
const res = await callOp(
|
|
6391
|
+
const res = await callOp(token, "create_project", { name, record });
|
|
6107
6392
|
return { id: res.id, recordVersion: res.recordVersion };
|
|
6108
6393
|
},
|
|
6109
6394
|
async updateProjectRecord(projectId, record, expectedVersion) {
|
|
6110
6395
|
let version2 = expectedVersion;
|
|
6111
6396
|
if (version2 === void 0) {
|
|
6112
|
-
const current = await callOp(
|
|
6397
|
+
const current = await callOp(token, "read_project_record", { project: projectId });
|
|
6113
6398
|
version2 = current.record?.recordVersion;
|
|
6114
6399
|
}
|
|
6115
6400
|
if (version2 === void 0) throw new Error("project record version is unavailable");
|
|
6116
|
-
const res = await callOp(
|
|
6401
|
+
const res = await callOp(token, "update_project_record", { project: projectId, record, expectedVersion: version2 });
|
|
6117
6402
|
return { recordVersion: res.recordVersion };
|
|
6118
6403
|
},
|
|
6119
6404
|
async mintScreenUploads(projectId, names) {
|
|
6120
|
-
const res = await callOp(
|
|
6405
|
+
const res = await callOp(token, "mint_screen_uploads", { project: projectId, names });
|
|
6121
6406
|
return res.slots;
|
|
6122
6407
|
},
|
|
6123
6408
|
async close() {
|
|
@@ -6263,7 +6548,7 @@ async function createAnonymousProjectClaim(projectName2, record, screenshots2) {
|
|
|
6263
6548
|
import { readFile as readFile4 } from "node:fs/promises";
|
|
6264
6549
|
var SIGN_IN = "not available in local (free) mode \u2014 connect the hosted ShotOps MCP and sign in for saved looks, editable projects, and share links.";
|
|
6265
6550
|
var SHARE_LINK_NOT_BRIDGED = `Creating a share link is ${SIGN_IN} (bridging this needs a hosted rendering step, which local mode deliberately avoids \u2014 connect directly to the hosted MCP for share links.)`;
|
|
6266
|
-
function localToolDeps(
|
|
6551
|
+
function localToolDeps(token) {
|
|
6267
6552
|
const base = {
|
|
6268
6553
|
renderer: new StripRenderer({ serve: "static" }),
|
|
6269
6554
|
// Token-backed cloud writes cross the same authority in the control plane. This local handler
|
|
@@ -6286,7 +6571,7 @@ function localToolDeps(token2) {
|
|
|
6286
6571
|
// no Supabase storage locally to upload an `output: "urls"` result to
|
|
6287
6572
|
studioOrigin: studioOrigin()
|
|
6288
6573
|
};
|
|
6289
|
-
if (!
|
|
6574
|
+
if (!token) {
|
|
6290
6575
|
return {
|
|
6291
6576
|
...base,
|
|
6292
6577
|
resolveProject: async () => null,
|
|
@@ -6312,7 +6597,7 @@ function localToolDeps(token2) {
|
|
|
6312
6597
|
}
|
|
6313
6598
|
};
|
|
6314
6599
|
}
|
|
6315
|
-
const bridge = connectHostedBridge(
|
|
6600
|
+
const bridge = connectHostedBridge(token);
|
|
6316
6601
|
return {
|
|
6317
6602
|
...base,
|
|
6318
6603
|
resolveProject: (userId, projectId) => bridge.resolveProject(userId, projectId),
|
|
@@ -6417,39 +6702,421 @@ function custodyUnion(...manifests) {
|
|
|
6417
6702
|
}));
|
|
6418
6703
|
}
|
|
6419
6704
|
|
|
6705
|
+
// src/localAuth.ts
|
|
6706
|
+
import { createHash, randomBytes as randomBytes2, timingSafeEqual } from "node:crypto";
|
|
6707
|
+
import { spawn } from "node:child_process";
|
|
6708
|
+
import { createServer as createServer2 } from "node:http";
|
|
6709
|
+
|
|
6710
|
+
// src/localCredentials.ts
|
|
6711
|
+
import { chmod, mkdir, readFile as readFile5, rename, unlink, writeFile } from "node:fs/promises";
|
|
6712
|
+
import { homedir } from "node:os";
|
|
6713
|
+
import { dirname as dirname2, join as join3 } from "node:path";
|
|
6714
|
+
var EMPTY2 = { version: 1, origins: {} };
|
|
6715
|
+
function credentialsPath() {
|
|
6716
|
+
const dir = process.env.SHOTOPS_CONFIG_DIR || join3(homedir(), ".shotops");
|
|
6717
|
+
return join3(dir, "credentials.json");
|
|
6718
|
+
}
|
|
6719
|
+
async function readFileJson() {
|
|
6720
|
+
let raw;
|
|
6721
|
+
try {
|
|
6722
|
+
raw = await readFile5(credentialsPath(), "utf8");
|
|
6723
|
+
} catch {
|
|
6724
|
+
return { version: 1, origins: {} };
|
|
6725
|
+
}
|
|
6726
|
+
try {
|
|
6727
|
+
const parsed = JSON.parse(raw);
|
|
6728
|
+
if (!parsed || typeof parsed !== "object" || !parsed.origins) return { ...EMPTY2 };
|
|
6729
|
+
return { version: 1, origins: parsed.origins };
|
|
6730
|
+
} catch {
|
|
6731
|
+
return { ...EMPTY2 };
|
|
6732
|
+
}
|
|
6733
|
+
}
|
|
6734
|
+
async function writeFileJson(next) {
|
|
6735
|
+
const target = credentialsPath();
|
|
6736
|
+
await mkdir(dirname2(target), { recursive: true, mode: 448 });
|
|
6737
|
+
const tmp = `${target}.${process.pid}.tmp`;
|
|
6738
|
+
await writeFile(tmp, `${JSON.stringify(next, null, 2)}
|
|
6739
|
+
`, { mode: 384 });
|
|
6740
|
+
await chmod(tmp, 384);
|
|
6741
|
+
await rename(tmp, target);
|
|
6742
|
+
}
|
|
6743
|
+
async function readCredentials(origin) {
|
|
6744
|
+
const file = await readFileJson();
|
|
6745
|
+
return file.origins[origin] ?? {};
|
|
6746
|
+
}
|
|
6747
|
+
async function saveClient(origin, client) {
|
|
6748
|
+
const file = await readFileJson();
|
|
6749
|
+
file.origins[origin] = { ...file.origins[origin], client };
|
|
6750
|
+
await writeFileJson(file);
|
|
6751
|
+
}
|
|
6752
|
+
async function saveLogin(origin, login) {
|
|
6753
|
+
const file = await readFileJson();
|
|
6754
|
+
file.origins[origin] = { ...file.origins[origin], login };
|
|
6755
|
+
await writeFileJson(file);
|
|
6756
|
+
}
|
|
6757
|
+
async function clearLogin(origin) {
|
|
6758
|
+
const file = await readFileJson();
|
|
6759
|
+
const existing = file.origins[origin]?.login;
|
|
6760
|
+
if (!existing) return void 0;
|
|
6761
|
+
file.origins[origin] = { ...file.origins[origin], login: void 0 };
|
|
6762
|
+
await writeFileJson(file);
|
|
6763
|
+
return existing;
|
|
6764
|
+
}
|
|
6765
|
+
async function resolveToken(origin, explicit) {
|
|
6766
|
+
if (explicit) return explicit;
|
|
6767
|
+
if (process.env.SHOTOPS_TOKEN) return process.env.SHOTOPS_TOKEN;
|
|
6768
|
+
const { login } = await readCredentials(origin);
|
|
6769
|
+
return login?.token;
|
|
6770
|
+
}
|
|
6771
|
+
|
|
6772
|
+
// src/localAuth.ts
|
|
6773
|
+
var LOOPBACK_HOST = "127.0.0.1";
|
|
6774
|
+
var CALLBACK_PATH = "/shotops/callback";
|
|
6775
|
+
var PLACEHOLDER_PORT = 8765;
|
|
6776
|
+
var CLIENT_NAME = "ShotOps CLI";
|
|
6777
|
+
var LOGIN_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
6778
|
+
function authOrigin() {
|
|
6779
|
+
return studioOrigin();
|
|
6780
|
+
}
|
|
6781
|
+
function base64url(buf) {
|
|
6782
|
+
return buf.toString("base64url");
|
|
6783
|
+
}
|
|
6784
|
+
async function discoverEndpoints(origin) {
|
|
6785
|
+
const url = `${origin}/.well-known/oauth-authorization-server`;
|
|
6786
|
+
let res;
|
|
6787
|
+
try {
|
|
6788
|
+
res = await fetch(url);
|
|
6789
|
+
} catch (err) {
|
|
6790
|
+
throw new Error(`could not reach ${origin} (${err.message})`);
|
|
6791
|
+
}
|
|
6792
|
+
if (!res.ok) throw new Error(`${url} answered HTTP ${res.status} \u2014 is ${origin} a ShotOps server?`);
|
|
6793
|
+
const doc = await res.json().catch(() => null);
|
|
6794
|
+
const authorization = typeof doc?.authorization_endpoint === "string" ? doc.authorization_endpoint : void 0;
|
|
6795
|
+
const token = typeof doc?.token_endpoint === "string" ? doc.token_endpoint : void 0;
|
|
6796
|
+
const registration = typeof doc?.registration_endpoint === "string" ? doc.registration_endpoint : void 0;
|
|
6797
|
+
const revocation = typeof doc?.revocation_endpoint === "string" ? doc.revocation_endpoint : void 0;
|
|
6798
|
+
if (!authorization || !token || !registration) {
|
|
6799
|
+
throw new Error(`${url} is missing an authorization, token or registration endpoint`);
|
|
6800
|
+
}
|
|
6801
|
+
const methods = doc?.code_challenge_methods_supported;
|
|
6802
|
+
if (Array.isArray(methods) && !methods.includes("S256")) {
|
|
6803
|
+
throw new Error(`${origin} does not support PKCE S256, which this client requires`);
|
|
6804
|
+
}
|
|
6805
|
+
return { authorization, token, registration, revocation };
|
|
6806
|
+
}
|
|
6807
|
+
async function ensureClient(origin, endpoints) {
|
|
6808
|
+
const existing = (await readCredentials(origin)).client;
|
|
6809
|
+
if (existing && existing.redirectHost === LOOPBACK_HOST && existing.redirectPath === CALLBACK_PATH) {
|
|
6810
|
+
return existing;
|
|
6811
|
+
}
|
|
6812
|
+
const res = await fetch(endpoints.registration, {
|
|
6813
|
+
method: "POST",
|
|
6814
|
+
headers: { "content-type": "application/json" },
|
|
6815
|
+
body: JSON.stringify({
|
|
6816
|
+
client_name: CLIENT_NAME,
|
|
6817
|
+
redirect_uris: [`http://${LOOPBACK_HOST}:${PLACEHOLDER_PORT}${CALLBACK_PATH}`],
|
|
6818
|
+
grant_types: ["authorization_code"],
|
|
6819
|
+
response_types: ["code"],
|
|
6820
|
+
token_endpoint_auth_method: "none"
|
|
6821
|
+
})
|
|
6822
|
+
});
|
|
6823
|
+
const body = await res.json().catch(() => null);
|
|
6824
|
+
if (!res.ok || typeof body?.client_id !== "string") {
|
|
6825
|
+
throw new Error(`registration failed (HTTP ${res.status}${describeOAuthError(body)})`);
|
|
6826
|
+
}
|
|
6827
|
+
const client = {
|
|
6828
|
+
clientId: body.client_id,
|
|
6829
|
+
clientSecret: typeof body.client_secret === "string" ? body.client_secret : void 0,
|
|
6830
|
+
redirectHost: LOOPBACK_HOST,
|
|
6831
|
+
redirectPath: CALLBACK_PATH,
|
|
6832
|
+
registeredAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
6833
|
+
};
|
|
6834
|
+
await saveClient(origin, client);
|
|
6835
|
+
return client;
|
|
6836
|
+
}
|
|
6837
|
+
function describeOAuthError(body) {
|
|
6838
|
+
const code = typeof body?.error === "string" ? body.error : void 0;
|
|
6839
|
+
const detail = typeof body?.error_description === "string" ? body.error_description : void 0;
|
|
6840
|
+
if (!code && !detail) return "";
|
|
6841
|
+
return ` \u2014 ${[code, detail].filter(Boolean).join(": ")}`;
|
|
6842
|
+
}
|
|
6843
|
+
function awaitCallback(expectedState) {
|
|
6844
|
+
return new Promise((resolveServer, rejectServer) => {
|
|
6845
|
+
let settled = false;
|
|
6846
|
+
let resolveResult;
|
|
6847
|
+
let rejectResult;
|
|
6848
|
+
const result = new Promise((res, rej) => {
|
|
6849
|
+
resolveResult = res;
|
|
6850
|
+
rejectResult = rej;
|
|
6851
|
+
});
|
|
6852
|
+
const server = createServer2((req, res) => {
|
|
6853
|
+
const url = new URL(req.url ?? "/", `http://${LOOPBACK_HOST}`);
|
|
6854
|
+
if (url.pathname !== CALLBACK_PATH || settled) {
|
|
6855
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
6856
|
+
res.end("not found\n");
|
|
6857
|
+
return;
|
|
6858
|
+
}
|
|
6859
|
+
settled = true;
|
|
6860
|
+
const state = url.searchParams.get("state") ?? "";
|
|
6861
|
+
const code = url.searchParams.get("code");
|
|
6862
|
+
const error = url.searchParams.get("error");
|
|
6863
|
+
const finish = (status, heading, detail) => {
|
|
6864
|
+
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
|
|
6865
|
+
res.end(page(heading, detail));
|
|
6866
|
+
server.close();
|
|
6867
|
+
};
|
|
6868
|
+
if (!statesMatch(state, expectedState)) {
|
|
6869
|
+
finish(400, "Sign-in rejected", "This response does not belong to the sign-in you started.");
|
|
6870
|
+
rejectResult(new Error("the authorization response did not match this sign-in (state mismatch)"));
|
|
6871
|
+
return;
|
|
6872
|
+
}
|
|
6873
|
+
if (error || !code) {
|
|
6874
|
+
finish(400, "Sign-in failed", error === "access_denied" ? "Access was denied." : "No authorization code came back.");
|
|
6875
|
+
rejectResult(new Error(error ? `authorization failed: ${error}` : "no authorization code was returned"));
|
|
6876
|
+
return;
|
|
6877
|
+
}
|
|
6878
|
+
finish(200, "Signed in to ShotOps", "You can close this tab and go back to your terminal.");
|
|
6879
|
+
resolveResult({ code });
|
|
6880
|
+
});
|
|
6881
|
+
const timer = setTimeout(() => {
|
|
6882
|
+
if (settled) return;
|
|
6883
|
+
settled = true;
|
|
6884
|
+
server.close();
|
|
6885
|
+
rejectResult(new Error(`timed out after ${LOGIN_TIMEOUT_MS / 6e4} minutes waiting for the browser`));
|
|
6886
|
+
}, LOGIN_TIMEOUT_MS);
|
|
6887
|
+
timer.unref?.();
|
|
6888
|
+
void result.catch(() => {
|
|
6889
|
+
}).finally(() => clearTimeout(timer));
|
|
6890
|
+
server.on("error", (err) => rejectServer(err));
|
|
6891
|
+
server.listen(0, LOOPBACK_HOST, () => {
|
|
6892
|
+
const address = server.address();
|
|
6893
|
+
resolveServer({ port: address.port, result });
|
|
6894
|
+
});
|
|
6895
|
+
});
|
|
6896
|
+
}
|
|
6897
|
+
function statesMatch(a, b) {
|
|
6898
|
+
const left = Buffer.from(a);
|
|
6899
|
+
const right = Buffer.from(b);
|
|
6900
|
+
if (left.length !== right.length) return false;
|
|
6901
|
+
return timingSafeEqual(left, right);
|
|
6902
|
+
}
|
|
6903
|
+
function page(heading, detail) {
|
|
6904
|
+
return `<!doctype html><meta charset="utf-8"><title>${heading}</title>
|
|
6905
|
+
<body style="font:16px/1.5 system-ui,sans-serif;margin:0;display:grid;place-items:center;height:100vh">
|
|
6906
|
+
<main style="max-width:28rem;padding:2rem;text-align:center">
|
|
6907
|
+
<h1 style="font-size:1.25rem;margin:0 0 .5rem">${heading}</h1><p style="margin:0;opacity:.7">${detail}</p>
|
|
6908
|
+
</main></body>`;
|
|
6909
|
+
}
|
|
6910
|
+
function openBrowser(url) {
|
|
6911
|
+
const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
6912
|
+
try {
|
|
6913
|
+
const child = spawn(command, args, { stdio: "ignore", detached: true });
|
|
6914
|
+
child.on("error", () => {
|
|
6915
|
+
});
|
|
6916
|
+
child.unref();
|
|
6917
|
+
} catch {
|
|
6918
|
+
}
|
|
6919
|
+
}
|
|
6920
|
+
async function runLogin(origin = authOrigin(), open = openBrowser) {
|
|
6921
|
+
const endpoints = await discoverEndpoints(origin);
|
|
6922
|
+
const client = await ensureClient(origin, endpoints);
|
|
6923
|
+
const verifier = base64url(randomBytes2(32));
|
|
6924
|
+
const challenge = base64url(createHash("sha256").update(verifier).digest());
|
|
6925
|
+
const state = base64url(randomBytes2(32));
|
|
6926
|
+
const { port, result } = await awaitCallback(state);
|
|
6927
|
+
const redirectUri = `http://${LOOPBACK_HOST}:${port}${CALLBACK_PATH}`;
|
|
6928
|
+
const authorizeUrl = new URL(endpoints.authorization);
|
|
6929
|
+
authorizeUrl.searchParams.set("response_type", "code");
|
|
6930
|
+
authorizeUrl.searchParams.set("client_id", client.clientId);
|
|
6931
|
+
authorizeUrl.searchParams.set("redirect_uri", redirectUri);
|
|
6932
|
+
authorizeUrl.searchParams.set("code_challenge", challenge);
|
|
6933
|
+
authorizeUrl.searchParams.set("code_challenge_method", "S256");
|
|
6934
|
+
authorizeUrl.searchParams.set("state", state);
|
|
6935
|
+
console.log(`Opening your browser to sign in to ShotOps (${origin}).`);
|
|
6936
|
+
console.log(`If nothing opens, paste this into a browser on this machine:
|
|
6937
|
+
|
|
6938
|
+
${authorizeUrl}
|
|
6939
|
+
`);
|
|
6940
|
+
open(authorizeUrl.href);
|
|
6941
|
+
const { code } = await result;
|
|
6942
|
+
const res = await fetch(endpoints.token, {
|
|
6943
|
+
method: "POST",
|
|
6944
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
6945
|
+
body: new URLSearchParams({
|
|
6946
|
+
grant_type: "authorization_code",
|
|
6947
|
+
code,
|
|
6948
|
+
code_verifier: verifier,
|
|
6949
|
+
client_id: client.clientId,
|
|
6950
|
+
redirect_uri: redirectUri
|
|
6951
|
+
})
|
|
6952
|
+
});
|
|
6953
|
+
const body = await res.json().catch(() => null);
|
|
6954
|
+
if (!res.ok || typeof body?.access_token !== "string") {
|
|
6955
|
+
throw new Error(`token exchange failed (HTTP ${res.status}${describeOAuthError(body)})`);
|
|
6956
|
+
}
|
|
6957
|
+
const token = body.access_token;
|
|
6958
|
+
await saveLogin(origin, {
|
|
6959
|
+
token,
|
|
6960
|
+
tokenPrefix: token.slice(0, 16),
|
|
6961
|
+
signedInAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
6962
|
+
});
|
|
6963
|
+
console.log(`Signed in. Token ${token.slice(0, 16)}\u2026 saved for ${origin}.`);
|
|
6964
|
+
console.log("Run `npx shotops-mcp whoami` to see your plan, or start the MCP server as usual.");
|
|
6965
|
+
return 0;
|
|
6966
|
+
}
|
|
6967
|
+
async function runLogout(origin = authOrigin()) {
|
|
6968
|
+
const { client, login } = await readCredentials(origin);
|
|
6969
|
+
if (!login) {
|
|
6970
|
+
console.log(`Not signed in to ${origin}. Nothing to do.`);
|
|
6971
|
+
return 0;
|
|
6972
|
+
}
|
|
6973
|
+
let revoked = false;
|
|
6974
|
+
let revokeDetail = "";
|
|
6975
|
+
try {
|
|
6976
|
+
const endpoints = await discoverEndpoints(origin);
|
|
6977
|
+
if (!endpoints.revocation) throw new Error("this server publishes no revocation endpoint");
|
|
6978
|
+
if (!client) throw new Error("the registration that minted this token is no longer on disk");
|
|
6979
|
+
const params = new URLSearchParams({ token: login.token, client_id: client.clientId });
|
|
6980
|
+
if (client.clientSecret) params.set("client_secret", client.clientSecret);
|
|
6981
|
+
const res = await fetch(endpoints.revocation, {
|
|
6982
|
+
method: "POST",
|
|
6983
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
6984
|
+
body: params
|
|
6985
|
+
});
|
|
6986
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
6987
|
+
revoked = true;
|
|
6988
|
+
} catch (err) {
|
|
6989
|
+
revokeDetail = err.message;
|
|
6990
|
+
}
|
|
6991
|
+
await clearLogin(origin);
|
|
6992
|
+
if (revoked) {
|
|
6993
|
+
console.log(`Signed out of ${origin}. The token is revoked server-side and removed from this machine.`);
|
|
6994
|
+
return 0;
|
|
6995
|
+
}
|
|
6996
|
+
console.log(`Removed the saved sign-in for ${origin}, but could NOT revoke it server-side (${revokeDetail}).`);
|
|
6997
|
+
console.log(`That token ${login.tokenPrefix}\u2026 may still work. Revoke it in ShotOps \u2192 Settings \u2192 API tokens.`);
|
|
6998
|
+
return 0;
|
|
6999
|
+
}
|
|
7000
|
+
async function runWhoami(origin = authOrigin(), explicitToken2) {
|
|
7001
|
+
const token = await resolveToken(origin, explicitToken2);
|
|
7002
|
+
const source = explicitToken2 ? "--token argument" : process.env.SHOTOPS_TOKEN ? "SHOTOPS_TOKEN" : "saved browser login";
|
|
7003
|
+
if (!token) {
|
|
7004
|
+
console.log(`Not signed in to ${origin}.`);
|
|
7005
|
+
console.log("Run `npx shotops-mcp login`, or set SHOTOPS_TOKEN for CI.");
|
|
7006
|
+
return 1;
|
|
7007
|
+
}
|
|
7008
|
+
let res;
|
|
7009
|
+
try {
|
|
7010
|
+
res = await fetch(`${origin}/api/billing-entitlement`, {
|
|
7011
|
+
headers: { authorization: `Bearer ${token}` }
|
|
7012
|
+
});
|
|
7013
|
+
} catch (err) {
|
|
7014
|
+
console.error(`Could not reach ${origin} (${err.message}). Your plan is unknown, not Free.`);
|
|
7015
|
+
return 1;
|
|
7016
|
+
}
|
|
7017
|
+
if (res.status === 401) {
|
|
7018
|
+
console.log(`Token ${token.slice(0, 16)}\u2026 (${source}) is not valid \u2014 it was revoked, or it belongs to another server.`);
|
|
7019
|
+
console.log("Run `npx shotops-mcp login` to sign in again.");
|
|
7020
|
+
return 1;
|
|
7021
|
+
}
|
|
7022
|
+
if (!res.ok) {
|
|
7023
|
+
console.error(`${origin} answered HTTP ${res.status}. Your plan is unknown, not Free.`);
|
|
7024
|
+
return 1;
|
|
7025
|
+
}
|
|
7026
|
+
const body = await res.json().catch(() => null);
|
|
7027
|
+
if (!body) {
|
|
7028
|
+
console.error("The server returned an unexpected response. Your plan is unknown, not Free.");
|
|
7029
|
+
return 1;
|
|
7030
|
+
}
|
|
7031
|
+
console.log(`Signed in to ${origin}`);
|
|
7032
|
+
console.log(` token ${token.slice(0, 16)}\u2026 (${source})`);
|
|
7033
|
+
if (typeof body.userId === "string") console.log(` account ${body.userId}`);
|
|
7034
|
+
console.log(` plan ${String(body.plan ?? "unknown")}${body.status ? ` (${String(body.status)})` : ""}`);
|
|
7035
|
+
if (body.reason) console.log(` because ${String(body.reason)}`);
|
|
7036
|
+
if (body.trial?.state) {
|
|
7037
|
+
const ends = typeof body.trial.endsAt === "number" ? ` until ${new Date(body.trial.endsAt).toISOString().slice(0, 10)}` : "";
|
|
7038
|
+
console.log(` trial ${String(body.trial.state)}${ends}`);
|
|
7039
|
+
}
|
|
7040
|
+
if (body.credits && typeof body.credits === "object") {
|
|
7041
|
+
console.log(` credits ${String(body.credits.remaining ?? "?")} of ${String(body.credits.cap ?? "?")}`);
|
|
7042
|
+
} else {
|
|
7043
|
+
console.log(" credits unavailable");
|
|
7044
|
+
}
|
|
7045
|
+
return 0;
|
|
7046
|
+
}
|
|
7047
|
+
var USAGE = `shotops-mcp \u2014 App Store screenshot mockups, rendered on this machine.
|
|
7048
|
+
|
|
7049
|
+
npx shotops-mcp start the MCP server over stdio (what an MCP client runs)
|
|
7050
|
+
npx shotops-mcp login sign in through the browser and save the token
|
|
7051
|
+
npx shotops-mcp logout revoke that token and remove it from this machine
|
|
7052
|
+
npx shotops-mcp whoami show which account is signed in and its plan
|
|
7053
|
+
|
|
7054
|
+
--token <t> use this token instead of a saved login (also SHOTOPS_TOKEN)
|
|
7055
|
+
`;
|
|
7056
|
+
async function dispatchLocalCommand(argv2, explicitToken2) {
|
|
7057
|
+
const [command] = argv2;
|
|
7058
|
+
switch (command) {
|
|
7059
|
+
case "login":
|
|
7060
|
+
return await runLogin();
|
|
7061
|
+
case "logout":
|
|
7062
|
+
return await runLogout();
|
|
7063
|
+
case "whoami":
|
|
7064
|
+
return await runWhoami(authOrigin(), explicitToken2);
|
|
7065
|
+
case "help":
|
|
7066
|
+
case "--help":
|
|
7067
|
+
case "-h":
|
|
7068
|
+
console.log(USAGE);
|
|
7069
|
+
return 0;
|
|
7070
|
+
default:
|
|
7071
|
+
return null;
|
|
7072
|
+
}
|
|
7073
|
+
}
|
|
7074
|
+
|
|
6420
7075
|
// src/local.ts
|
|
6421
|
-
function tokenFromArgv(
|
|
6422
|
-
const eq =
|
|
7076
|
+
function tokenFromArgv(argv2) {
|
|
7077
|
+
const eq = argv2.find((a) => a.startsWith("--token="));
|
|
6423
7078
|
if (eq) return eq.slice("--token=".length);
|
|
6424
|
-
const idx =
|
|
6425
|
-
return idx !== -1 ?
|
|
7079
|
+
const idx = argv2.indexOf("--token");
|
|
7080
|
+
return idx !== -1 ? argv2[idx + 1] : void 0;
|
|
6426
7081
|
}
|
|
6427
|
-
var token = tokenFromArgv(process.argv.slice(2)) || process.env.SHOTOPS_TOKEN || void 0;
|
|
6428
|
-
var deps = localToolDeps(token);
|
|
6429
7082
|
var LOCAL_INTAKE = 'request_screenshot_upload is hosted-only; import_screenshot accepts { "path": "/abs/shot.png" } entries here (its { "url" } / { "file" } sources need a hosted account). Either way you can pass local files straight to render_strip / emit_bundle as { "path": "..." } screenshot entries.';
|
|
6430
|
-
|
|
7083
|
+
async function startStdioServer(token) {
|
|
7084
|
+
const deps = localToolDeps(token);
|
|
7085
|
+
const localInstructions = token ? `${SERVER_INSTRUCTIONS}
|
|
6431
7086
|
|
|
6432
7087
|
## Local mode (this connection)
|
|
6433
7088
|
Rendering runs on THIS machine \u2014 free, no timeout ceiling. A hosted token is configured, so save_project / read_look / save_look also work (they read/write your hosted ShotOps account); ${LOCAL_INTAKE}` : `${SERVER_INSTRUCTIONS}
|
|
6434
7089
|
|
|
6435
7090
|
## Local mode (this connection)
|
|
6436
7091
|
Rendering runs on THIS machine \u2014 free, no timeout ceiling. No hosted token is configured. A NEW save_project with local { "path": "..." } PNGs deliberately uploads one temporary private handoff and returns status:"pending_claim" + an openUrl: ALWAYS give that URL to the user. The user owns the project only after opening it and signing in within 7 days; no projectId exists before then. Passing an existing project requires SHOTOPS_TOKEN and is refused here. Ordinary render_strip / emit_bundle calls upload NOTHING, and an explicit request not to save means do not call save_project. read_look / save_look / read_project / render_project / share links still require an account connection. ${LOCAL_INTAKE}`;
|
|
6437
|
-
|
|
6438
|
-
|
|
6439
|
-
|
|
6440
|
-
|
|
6441
|
-
|
|
6442
|
-
|
|
6443
|
-
);
|
|
6444
|
-
registerTools(server, { userId: "local", ...deps });
|
|
6445
|
-
async function shutdown() {
|
|
6446
|
-
|
|
6447
|
-
|
|
6448
|
-
|
|
6449
|
-
}
|
|
6450
|
-
process.on("SIGINT", () => void shutdown());
|
|
6451
|
-
process.on("SIGTERM", () => void shutdown());
|
|
6452
|
-
await server.connect(new StdioServerTransport());
|
|
6453
|
-
console.error(
|
|
6454
|
-
|
|
6455
|
-
);
|
|
7092
|
+
const server = new McpServer(
|
|
7093
|
+
// #335 — the PACKAGE version, deliberately, not app.ts's deployed-build identity: this tier IS
|
|
7094
|
+
// the npm package a user installed with `npx shotops-mcp`, and it has no deploy. Its contract
|
|
7095
|
+
// moves only when the version they installed moves, so VERSION is already the honest answer.
|
|
7096
|
+
{ name: "shotops-mcp-local", version: VERSION },
|
|
7097
|
+
{ instructions: localInstructions }
|
|
7098
|
+
);
|
|
7099
|
+
registerTools(server, { userId: "local", ...deps });
|
|
7100
|
+
async function shutdown() {
|
|
7101
|
+
await deps.renderer.close();
|
|
7102
|
+
await deps.closeBridge?.();
|
|
7103
|
+
process.exit(0);
|
|
7104
|
+
}
|
|
7105
|
+
process.on("SIGINT", () => void shutdown());
|
|
7106
|
+
process.on("SIGTERM", () => void shutdown());
|
|
7107
|
+
await server.connect(new StdioServerTransport());
|
|
7108
|
+
console.error(
|
|
7109
|
+
`[shotops-mcp-local] connected over stdio \u2014 renders run on this machine.${token ? " (hosted bridge configured)" : ""}`
|
|
7110
|
+
);
|
|
7111
|
+
}
|
|
7112
|
+
var argv = process.argv.slice(2);
|
|
7113
|
+
var explicitToken = tokenFromArgv(argv);
|
|
7114
|
+
var commandExit = await dispatchLocalCommand(argv, explicitToken).catch((err) => {
|
|
7115
|
+
console.error(`shotops-mcp: ${err.message}`);
|
|
7116
|
+
return 1;
|
|
7117
|
+
});
|
|
7118
|
+
if (commandExit !== null) {
|
|
7119
|
+
process.exitCode = commandExit;
|
|
7120
|
+
} else {
|
|
7121
|
+
await startStdioServer(await resolveToken(authOrigin(), explicitToken));
|
|
7122
|
+
}
|