@sakupa/mcp 0.7.44 → 0.7.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin.js +540 -114
- package/dist/index.js +538 -112
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -125,7 +125,7 @@ var FORBIDDEN_PATH_SEGMENTS = [
|
|
|
125
125
|
var ALLOWED_HIDDEN_PATHS = [".well-known/"];
|
|
126
126
|
|
|
127
127
|
// ../core/dist/domain/version.js
|
|
128
|
-
var SAKUPA_MCP_VERSION = "0.7.
|
|
128
|
+
var SAKUPA_MCP_VERSION = "0.7.46";
|
|
129
129
|
|
|
130
130
|
// ../core/dist/domain/errors.js
|
|
131
131
|
var HTTP_STATUS = {
|
|
@@ -736,8 +736,8 @@ var HttpApiClient = class {
|
|
|
736
736
|
body: body?.slice(0, 200)
|
|
737
737
|
});
|
|
738
738
|
}
|
|
739
|
-
async registerDevice() {
|
|
740
|
-
return this.call("POST", "/v1/devices");
|
|
739
|
+
async registerDevice(req) {
|
|
740
|
+
return this.call("POST", "/v1/devices", { body: req });
|
|
741
741
|
}
|
|
742
742
|
async listDeviceFreeSites(deviceId, credential) {
|
|
743
743
|
return this.call("GET", "/v1/devices/free-sites", {
|
|
@@ -1995,6 +1995,24 @@ function boundDiagnostics(processCwd, selected, snapshot = { supported: false, r
|
|
|
1995
1995
|
|
|
1996
1996
|
// src/tools/result.ts
|
|
1997
1997
|
import { z } from "zod";
|
|
1998
|
+
var TARGET_MCP_TOOL_NAMES = [
|
|
1999
|
+
"init",
|
|
2000
|
+
"help",
|
|
2001
|
+
"analyze",
|
|
2002
|
+
"deploy",
|
|
2003
|
+
"refresh",
|
|
2004
|
+
"status",
|
|
2005
|
+
"rotate",
|
|
2006
|
+
"plans",
|
|
2007
|
+
"subscribe",
|
|
2008
|
+
"bind",
|
|
2009
|
+
"billing",
|
|
2010
|
+
"portal",
|
|
2011
|
+
"recover",
|
|
2012
|
+
"change",
|
|
2013
|
+
"support",
|
|
2014
|
+
"report"
|
|
2015
|
+
];
|
|
1998
2016
|
var STRUCTURED_TOOL_OUTPUT_SCHEMA = {
|
|
1999
2017
|
schemaVersion: z.literal(1),
|
|
2000
2018
|
outcome: z.enum([
|
|
@@ -2010,13 +2028,41 @@ var STRUCTURED_TOOL_OUTPUT_SCHEMA = {
|
|
|
2010
2028
|
operationId: z.string().optional(),
|
|
2011
2029
|
summary: z.string(),
|
|
2012
2030
|
data: z.record(z.string(), z.unknown()),
|
|
2031
|
+
decision: z.object({
|
|
2032
|
+
decisionVersion: z.literal(1),
|
|
2033
|
+
prompt: z.string(),
|
|
2034
|
+
selectionMode: z.literal("single"),
|
|
2035
|
+
selectionRequired: z.literal(true),
|
|
2036
|
+
defaultOptionId: z.null(),
|
|
2037
|
+
options: z.array(
|
|
2038
|
+
z.object({
|
|
2039
|
+
id: z.string(),
|
|
2040
|
+
label: z.string(),
|
|
2041
|
+
description: z.string(),
|
|
2042
|
+
consequences: z.array(z.string()),
|
|
2043
|
+
nextAction: z.discriminatedUnion("type", [
|
|
2044
|
+
z.object({
|
|
2045
|
+
type: z.literal("call_tool"),
|
|
2046
|
+
tool: z.enum(TARGET_MCP_TOOL_NAMES),
|
|
2047
|
+
arguments: z.record(z.string(), z.unknown()),
|
|
2048
|
+
reasonCode: z.string().optional()
|
|
2049
|
+
}),
|
|
2050
|
+
z.object({ type: z.literal("open_url"), url: z.string() }),
|
|
2051
|
+
z.object({ type: z.literal("none") })
|
|
2052
|
+
])
|
|
2053
|
+
})
|
|
2054
|
+
)
|
|
2055
|
+
}).optional(),
|
|
2013
2056
|
userAction: z.object({
|
|
2014
2057
|
type: z.enum(["open_url", "confirm_in_mcp", "configure_dns", "select_site"]),
|
|
2015
2058
|
provider: z.enum(["stripe", "sakupa"]).optional(),
|
|
2016
2059
|
url: z.string().optional(),
|
|
2017
2060
|
expiresAt: z.string().optional(),
|
|
2018
2061
|
expectedOutcome: z.string(),
|
|
2019
|
-
resumeWith: z.object({
|
|
2062
|
+
resumeWith: z.object({
|
|
2063
|
+
tool: z.enum(TARGET_MCP_TOOL_NAMES),
|
|
2064
|
+
arguments: z.record(z.string(), z.unknown())
|
|
2065
|
+
}).optional(),
|
|
2020
2066
|
options: z.array(
|
|
2021
2067
|
z.object({
|
|
2022
2068
|
label: z.string(),
|
|
@@ -2027,7 +2073,7 @@ var STRUCTURED_TOOL_OUTPUT_SCHEMA = {
|
|
|
2027
2073
|
}).optional(),
|
|
2028
2074
|
nextActions: z.array(
|
|
2029
2075
|
z.object({
|
|
2030
|
-
tool: z.
|
|
2076
|
+
tool: z.enum(TARGET_MCP_TOOL_NAMES),
|
|
2031
2077
|
arguments: z.record(z.string(), z.unknown()).optional(),
|
|
2032
2078
|
allowed: z.boolean(),
|
|
2033
2079
|
reasonCode: z.string().optional()
|
|
@@ -2211,7 +2257,7 @@ function toolError(e) {
|
|
|
2211
2257
|
}
|
|
2212
2258
|
|
|
2213
2259
|
// src/tools/definitions.ts
|
|
2214
|
-
import { randomUUID as
|
|
2260
|
+
import { randomUUID as randomUUID6 } from "node:crypto";
|
|
2215
2261
|
import { promises as fs2 } from "node:fs";
|
|
2216
2262
|
import { join as join9, relative as relative3, resolve as resolve5, sep as sep4 } from "node:path";
|
|
2217
2263
|
import { z as z2 } from "zod";
|
|
@@ -2939,6 +2985,7 @@ import {
|
|
|
2939
2985
|
unlinkSync as unlinkSync2,
|
|
2940
2986
|
writeFileSync as writeFileSync4
|
|
2941
2987
|
} from "node:fs";
|
|
2988
|
+
import { randomUUID as randomUUID4 } from "node:crypto";
|
|
2942
2989
|
import { homedir as homedir3 } from "node:os";
|
|
2943
2990
|
import { dirname as dirname4, join as join6 } from "node:path";
|
|
2944
2991
|
var DEVICE_LOCK_STALE_MS = 3e4;
|
|
@@ -2948,10 +2995,22 @@ function deviceRegistryPath() {
|
|
|
2948
2995
|
return join6(base, ".sakupa", "devices.json");
|
|
2949
2996
|
}
|
|
2950
2997
|
var deviceLockPath = () => join6(dirname4(deviceRegistryPath()), "devices.lock");
|
|
2951
|
-
function
|
|
2998
|
+
function lockTokenAt(path) {
|
|
2952
2999
|
try {
|
|
2953
|
-
|
|
3000
|
+
const parsed = JSON.parse(readFileSync4(path, "utf8"));
|
|
3001
|
+
return typeof parsed.token === "string" ? parsed.token : null;
|
|
3002
|
+
} catch {
|
|
3003
|
+
return null;
|
|
3004
|
+
}
|
|
3005
|
+
}
|
|
3006
|
+
function ownsDeviceLock(lock) {
|
|
3007
|
+
return lockTokenAt(deviceLockPath()) === lock.token;
|
|
3008
|
+
}
|
|
3009
|
+
function releaseDeviceLock(lock) {
|
|
3010
|
+
try {
|
|
3011
|
+
closeSync(lock.fd);
|
|
2954
3012
|
} finally {
|
|
3013
|
+
if (!ownsDeviceLock(lock)) return;
|
|
2955
3014
|
try {
|
|
2956
3015
|
unlinkSync2(deviceLockPath());
|
|
2957
3016
|
} catch {
|
|
@@ -2966,9 +3025,13 @@ async function acquireDeviceLock(apiBaseUrl) {
|
|
|
2966
3025
|
const existing = loadDeviceBinding(apiBaseUrl);
|
|
2967
3026
|
if (existing) return existing;
|
|
2968
3027
|
try {
|
|
3028
|
+
const token = randomUUID4();
|
|
2969
3029
|
const fd2 = openSync(path, "wx", 384);
|
|
2970
|
-
writeFileSync4(
|
|
2971
|
-
|
|
3030
|
+
writeFileSync4(
|
|
3031
|
+
fd2,
|
|
3032
|
+
JSON.stringify({ token, pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString() })
|
|
3033
|
+
);
|
|
3034
|
+
return { fd: fd2, token };
|
|
2972
3035
|
} catch {
|
|
2973
3036
|
try {
|
|
2974
3037
|
if (Date.now() - statSync2(path).mtimeMs >= DEVICE_LOCK_STALE_MS) {
|
|
@@ -2989,13 +3052,16 @@ async function acquireDeviceLock(apiBaseUrl) {
|
|
|
2989
3052
|
}
|
|
2990
3053
|
function readRegistry() {
|
|
2991
3054
|
const path = deviceRegistryPath();
|
|
2992
|
-
if (!existsSync5(path))
|
|
3055
|
+
if (!existsSync5(path)) {
|
|
3056
|
+
return { schemaVersion: 1, environments: {}, pendingRegistrations: {} };
|
|
3057
|
+
}
|
|
2993
3058
|
try {
|
|
2994
3059
|
const parsed = JSON.parse(readFileSync4(path, "utf8"));
|
|
2995
3060
|
if (parsed.schemaVersion !== 1 || !parsed.environments || typeof parsed.environments !== "object") {
|
|
2996
3061
|
throw new Error("unsupported device registry schema");
|
|
2997
3062
|
}
|
|
2998
|
-
|
|
3063
|
+
const pendingRegistrations = "pendingRegistrations" in parsed && parsed.pendingRegistrations && typeof parsed.pendingRegistrations === "object" ? parsed.pendingRegistrations : {};
|
|
3064
|
+
return { schemaVersion: 1, environments: parsed.environments, pendingRegistrations };
|
|
2999
3065
|
} catch (error) {
|
|
3000
3066
|
throw new Error(
|
|
3001
3067
|
`Sakupa device registry is unreadable at ${path}: ${error instanceof Error ? error.message : String(error)}. Run help; do not search old project directories.`
|
|
@@ -3024,19 +3090,38 @@ async function ensureDeviceBinding(client, apiBaseUrl) {
|
|
|
3024
3090
|
const existing = loadDeviceBinding(apiBaseUrl);
|
|
3025
3091
|
if (existing) return existing;
|
|
3026
3092
|
const lock = await acquireDeviceLock(apiBaseUrl);
|
|
3027
|
-
if (
|
|
3093
|
+
if (!("fd" in lock)) return lock;
|
|
3028
3094
|
try {
|
|
3029
3095
|
const afterLock = loadDeviceBinding(apiBaseUrl);
|
|
3030
3096
|
if (afterLock) return afterLock;
|
|
3031
|
-
|
|
3032
|
-
|
|
3097
|
+
let registry = readRegistry();
|
|
3098
|
+
let pending = registry.pendingRegistrations[apiBaseUrl];
|
|
3099
|
+
if (!pending) {
|
|
3100
|
+
pending = {
|
|
3101
|
+
operationId: randomUUID4(),
|
|
3102
|
+
deviceId: randomUUID4(),
|
|
3103
|
+
credential: generateCredential(),
|
|
3104
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3105
|
+
};
|
|
3106
|
+
registry.pendingRegistrations[apiBaseUrl] = pending;
|
|
3107
|
+
writeRegistry(registry);
|
|
3108
|
+
}
|
|
3109
|
+
const created = await client.registerDevice({
|
|
3110
|
+
operationId: pending.operationId,
|
|
3111
|
+
deviceId: pending.deviceId,
|
|
3112
|
+
credential: pending.credential
|
|
3113
|
+
});
|
|
3033
3114
|
const binding = {
|
|
3034
3115
|
deviceId: created.deviceId,
|
|
3035
3116
|
credential: created.credential,
|
|
3036
3117
|
createdAt: created.createdAt
|
|
3037
3118
|
};
|
|
3038
|
-
|
|
3039
|
-
|
|
3119
|
+
if (ownsDeviceLock(lock)) {
|
|
3120
|
+
registry = readRegistry();
|
|
3121
|
+
registry.environments[apiBaseUrl] = binding;
|
|
3122
|
+
delete registry.pendingRegistrations[apiBaseUrl];
|
|
3123
|
+
writeRegistry(registry);
|
|
3124
|
+
}
|
|
3040
3125
|
return binding;
|
|
3041
3126
|
} finally {
|
|
3042
3127
|
releaseDeviceLock(lock);
|
|
@@ -3321,7 +3406,7 @@ import {
|
|
|
3321
3406
|
rmSync as rmSync2,
|
|
3322
3407
|
writeFileSync as writeFileSync6
|
|
3323
3408
|
} from "node:fs";
|
|
3324
|
-
import { randomUUID as
|
|
3409
|
+
import { randomUUID as randomUUID5 } from "node:crypto";
|
|
3325
3410
|
import { join as join8 } from "node:path";
|
|
3326
3411
|
var ROTATION_FILE = "rotation.json";
|
|
3327
3412
|
function credentialRotationPath(projectDir) {
|
|
@@ -3382,7 +3467,7 @@ function writeCredentialRotation(projectDir, file) {
|
|
|
3382
3467
|
const directory = join8(projectDir, ".sakupa");
|
|
3383
3468
|
mkdirSync6(directory, { recursive: true, mode: 448 });
|
|
3384
3469
|
const target = credentialRotationPath(projectDir);
|
|
3385
|
-
const temporary = join8(directory, `.rotation-${
|
|
3470
|
+
const temporary = join8(directory, `.rotation-${randomUUID5()}.tmp`);
|
|
3386
3471
|
writeFileSync6(temporary, `${JSON.stringify(file, null, 2)}
|
|
3387
3472
|
`, {
|
|
3388
3473
|
encoding: "utf8",
|
|
@@ -3477,6 +3562,176 @@ async function resumeCredentialRotation(client, projectDir, site, apiBaseUrl) {
|
|
|
3477
3562
|
};
|
|
3478
3563
|
}
|
|
3479
3564
|
|
|
3565
|
+
// src/tools/decision.ts
|
|
3566
|
+
var FORBIDDEN_DECISION_ARGUMENT_KEYS = /* @__PURE__ */ new Set([
|
|
3567
|
+
"credential",
|
|
3568
|
+
"candidatecredential",
|
|
3569
|
+
"devicecredential",
|
|
3570
|
+
"password",
|
|
3571
|
+
"secret",
|
|
3572
|
+
"privatekey",
|
|
3573
|
+
"accesstoken",
|
|
3574
|
+
"refreshtoken"
|
|
3575
|
+
]);
|
|
3576
|
+
function assertDecisionArgumentsSafe(value, path = "arguments") {
|
|
3577
|
+
if (typeof value === "string") {
|
|
3578
|
+
if (/^sk_[A-Za-z0-9_-]{20,}$/.test(value)) {
|
|
3579
|
+
throw new Error(`Decision ${path} contains a credential-like value.`);
|
|
3580
|
+
}
|
|
3581
|
+
return;
|
|
3582
|
+
}
|
|
3583
|
+
if (Array.isArray(value)) {
|
|
3584
|
+
value.forEach((entry, index) => assertDecisionArgumentsSafe(entry, `${path}[${index}]`));
|
|
3585
|
+
return;
|
|
3586
|
+
}
|
|
3587
|
+
if (typeof value !== "object" || value === null) return;
|
|
3588
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
3589
|
+
const normalizedKey = key.replace(/[_-]/g, "").toLowerCase();
|
|
3590
|
+
if (FORBIDDEN_DECISION_ARGUMENT_KEYS.has(normalizedKey)) {
|
|
3591
|
+
throw new Error(`Decision ${path}.${key} contains a forbidden secret field.`);
|
|
3592
|
+
}
|
|
3593
|
+
assertDecisionArgumentsSafe(entry, `${path}.${key}`);
|
|
3594
|
+
}
|
|
3595
|
+
}
|
|
3596
|
+
function buildDecisionContract(prompt, options) {
|
|
3597
|
+
const normalizedPrompt = prompt.trim();
|
|
3598
|
+
if (!normalizedPrompt) throw new Error("Decision prompt must not be empty.");
|
|
3599
|
+
if (options.length < 2) throw new Error("A decision must contain at least two options.");
|
|
3600
|
+
const ids = /* @__PURE__ */ new Set();
|
|
3601
|
+
let actionableOptions = 0;
|
|
3602
|
+
let noActionOptions = 0;
|
|
3603
|
+
for (const option of options) {
|
|
3604
|
+
if (!/^[a-z][a-z0-9_]*$/.test(option.id)) {
|
|
3605
|
+
throw new Error(`Invalid decision option id: ${option.id}`);
|
|
3606
|
+
}
|
|
3607
|
+
if (ids.has(option.id)) throw new Error(`Duplicate decision option id: ${option.id}`);
|
|
3608
|
+
ids.add(option.id);
|
|
3609
|
+
if (!option.label.trim() || !option.description.trim()) {
|
|
3610
|
+
throw new Error(`Decision option ${option.id} requires a label and description.`);
|
|
3611
|
+
}
|
|
3612
|
+
if (option.nextAction.type === "none") {
|
|
3613
|
+
noActionOptions += 1;
|
|
3614
|
+
continue;
|
|
3615
|
+
}
|
|
3616
|
+
actionableOptions += 1;
|
|
3617
|
+
if (option.nextAction.type === "call_tool" && !TARGET_MCP_TOOL_NAMES.includes(option.nextAction.tool)) {
|
|
3618
|
+
throw new Error(`Unknown MCP decision tool: ${String(option.nextAction.tool)}`);
|
|
3619
|
+
}
|
|
3620
|
+
if (option.nextAction.type === "call_tool") {
|
|
3621
|
+
assertDecisionArgumentsSafe(option.nextAction.arguments);
|
|
3622
|
+
}
|
|
3623
|
+
}
|
|
3624
|
+
if (actionableOptions === 0) throw new Error("A decision requires an actionable option.");
|
|
3625
|
+
if (noActionOptions === 0) throw new Error("A decision requires a no-action exit option.");
|
|
3626
|
+
return {
|
|
3627
|
+
decisionVersion: 1,
|
|
3628
|
+
prompt: normalizedPrompt,
|
|
3629
|
+
selectionMode: "single",
|
|
3630
|
+
selectionRequired: true,
|
|
3631
|
+
defaultOptionId: null,
|
|
3632
|
+
options
|
|
3633
|
+
};
|
|
3634
|
+
}
|
|
3635
|
+
function formatDecisionFallback(decision) {
|
|
3636
|
+
const options = decision.options.map((option, index) => {
|
|
3637
|
+
const consequences = option.consequences.length === 0 ? "" : `
|
|
3638
|
+
Consequences: ${option.consequences.join(" ")}`;
|
|
3639
|
+
let exactAction;
|
|
3640
|
+
switch (option.nextAction.type) {
|
|
3641
|
+
case "call_tool":
|
|
3642
|
+
exactAction = `If the user selects this option, call ${option.nextAction.tool} with these exact arguments: ${JSON.stringify(option.nextAction.arguments)}.`;
|
|
3643
|
+
break;
|
|
3644
|
+
case "open_url":
|
|
3645
|
+
exactAction = `If the user selects this option, present this exact URL: ${option.nextAction.url}.`;
|
|
3646
|
+
break;
|
|
3647
|
+
case "none":
|
|
3648
|
+
exactAction = "If the user selects this option, call no tool and make no change.";
|
|
3649
|
+
break;
|
|
3650
|
+
}
|
|
3651
|
+
return `${index + 1}. [${option.id}] ${option.label}
|
|
3652
|
+
${option.description}${consequences}
|
|
3653
|
+
${exactAction}`;
|
|
3654
|
+
});
|
|
3655
|
+
return `USER DECISION REQUIRED: ${decision.prompt}
|
|
3656
|
+
No option is selected by default. Present every option to the user, do not choose on their behalf, and never reconstruct or guess tool arguments.
|
|
3657
|
+
|
|
3658
|
+
` + options.join("\n\n");
|
|
3659
|
+
}
|
|
3660
|
+
function decisionToolResult(input) {
|
|
3661
|
+
const decision = buildDecisionContract(input.prompt, input.options);
|
|
3662
|
+
const actionable = decision.options.filter(
|
|
3663
|
+
(option) => option.nextAction.type === "call_tool"
|
|
3664
|
+
);
|
|
3665
|
+
const nextActions = actionable.map((option) => ({
|
|
3666
|
+
tool: option.nextAction.tool,
|
|
3667
|
+
arguments: option.nextAction.arguments,
|
|
3668
|
+
allowed: true,
|
|
3669
|
+
...option.nextAction.reasonCode === void 0 ? {} : { reasonCode: option.nextAction.reasonCode }
|
|
3670
|
+
}));
|
|
3671
|
+
let userAction;
|
|
3672
|
+
if (input.legacyUserAction?.type === "confirm_in_mcp" && actionable.length === 1) {
|
|
3673
|
+
const option = actionable[0];
|
|
3674
|
+
if (option !== void 0) {
|
|
3675
|
+
userAction = {
|
|
3676
|
+
type: "confirm_in_mcp",
|
|
3677
|
+
provider: input.legacyUserAction.provider,
|
|
3678
|
+
expectedOutcome: option.description,
|
|
3679
|
+
resumeWith: {
|
|
3680
|
+
tool: option.nextAction.tool,
|
|
3681
|
+
arguments: option.nextAction.arguments
|
|
3682
|
+
}
|
|
3683
|
+
};
|
|
3684
|
+
}
|
|
3685
|
+
} else if (input.legacyUserAction?.type === "select_site") {
|
|
3686
|
+
userAction = {
|
|
3687
|
+
type: "select_site",
|
|
3688
|
+
provider: input.legacyUserAction.provider,
|
|
3689
|
+
expectedOutcome: "Apply only the option explicitly selected by the user.",
|
|
3690
|
+
options: actionable.map((option) => ({
|
|
3691
|
+
label: option.label,
|
|
3692
|
+
value: typeof option.nextAction.arguments["reuseSiteUrl"] === "string" ? option.nextAction.arguments["reuseSiteUrl"] : option.id,
|
|
3693
|
+
expectedOutcome: option.description
|
|
3694
|
+
}))
|
|
3695
|
+
};
|
|
3696
|
+
}
|
|
3697
|
+
return structuredToolResult({
|
|
3698
|
+
schemaVersion: 1,
|
|
3699
|
+
outcome: input.outcome ?? "waiting_user",
|
|
3700
|
+
resultCode: input.resultCode,
|
|
3701
|
+
...input.operationId === void 0 ? {} : { operationId: input.operationId },
|
|
3702
|
+
summary: `${input.summary}
|
|
3703
|
+
|
|
3704
|
+
${formatDecisionFallback(decision)}`,
|
|
3705
|
+
data: input.data,
|
|
3706
|
+
decision,
|
|
3707
|
+
...userAction === void 0 ? {} : { userAction },
|
|
3708
|
+
nextActions
|
|
3709
|
+
});
|
|
3710
|
+
}
|
|
3711
|
+
function callToolDecisionOption(input) {
|
|
3712
|
+
return {
|
|
3713
|
+
id: input.id,
|
|
3714
|
+
label: input.label,
|
|
3715
|
+
description: input.description,
|
|
3716
|
+
consequences: input.consequences ?? [],
|
|
3717
|
+
nextAction: {
|
|
3718
|
+
type: "call_tool",
|
|
3719
|
+
tool: input.tool,
|
|
3720
|
+
arguments: input.arguments,
|
|
3721
|
+
...input.reasonCode === void 0 ? {} : { reasonCode: input.reasonCode }
|
|
3722
|
+
}
|
|
3723
|
+
};
|
|
3724
|
+
}
|
|
3725
|
+
function noActionDecisionOption(input) {
|
|
3726
|
+
return {
|
|
3727
|
+
id: input?.id ?? "cancel",
|
|
3728
|
+
label: input?.label ?? "Do not continue",
|
|
3729
|
+
description: input?.description ?? "Keep the current local and cloud state unchanged.",
|
|
3730
|
+
consequences: [],
|
|
3731
|
+
nextAction: { type: "none" }
|
|
3732
|
+
};
|
|
3733
|
+
}
|
|
3734
|
+
|
|
3480
3735
|
// src/tools/definitions.ts
|
|
3481
3736
|
function text(resultCode, t, data = {}, outcome = "completed", nextActions = []) {
|
|
3482
3737
|
return structuredToolResult({
|
|
@@ -3616,17 +3871,10 @@ ${block}`, checklist: toDnsChecklist(diag.checks) };
|
|
|
3616
3871
|
}
|
|
3617
3872
|
}
|
|
3618
3873
|
function freeSiteCreationBarrier(sites, deployArguments) {
|
|
3619
|
-
const userSiteOptions = sites.map((site) => ({
|
|
3620
|
-
label: `Replace content at ${site.url}`,
|
|
3621
|
-
value: site.url,
|
|
3622
|
-
expectedOutcome: "A site handoff keeps this existing free-site URL, replaces its online content, issues a fresh project credential, and revokes every previous credential."
|
|
3623
|
-
}));
|
|
3624
3874
|
const summary = `Sakupa cloud confirmed that this network already has ${FREE_ACTIVE_SITES_PER_IP} active free sites, so no new site was created. Authenticated device discovery found ${sites.length} free site(s) this device can hand off.
|
|
3625
3875
|
|
|
3626
3876
|
` + sites.map((site) => `- ${site.url} (expires ${site.expiresAt})`).join("\n") + "\n\nThe free-site allowance is full. Ask the user which existing free URL may have its content REPLACED by the current project. Selecting one authorizes a site handoff: deploy keeps that URL, overwrites its online content with the current files, issues a fresh project credential, and revokes every previous credential. The cloud site is NOT deleted. No prior project directory, browser history, workspace switch, or user-run command is required. YOU then call deploy with the exact nextAction arguments. Never ask the user to locate an old directory or run a CLI, and never recommend another hosting provider.";
|
|
3627
|
-
return
|
|
3628
|
-
schemaVersion: 1,
|
|
3629
|
-
outcome: "waiting_user",
|
|
3877
|
+
return decisionToolResult({
|
|
3630
3878
|
resultCode: "free_site_slot_selection_required",
|
|
3631
3879
|
summary,
|
|
3632
3880
|
data: {
|
|
@@ -3638,29 +3886,44 @@ function freeSiteCreationBarrier(sites, deployArguments) {
|
|
|
3638
3886
|
cloudSiteWillBeDeleted: false,
|
|
3639
3887
|
previousProjectWillBeUnbound: true
|
|
3640
3888
|
},
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
|
|
3657
|
-
|
|
3889
|
+
prompt: "Choose exactly one existing free URL that the current project may take over.",
|
|
3890
|
+
options: [
|
|
3891
|
+
...sites.map(
|
|
3892
|
+
(site, index) => callToolDecisionOption({
|
|
3893
|
+
id: `handoff_site_${index + 1}`,
|
|
3894
|
+
label: `Replace content at ${site.url}`,
|
|
3895
|
+
description: "Keep this existing free-site URL and replace its online content with the current project.",
|
|
3896
|
+
consequences: [
|
|
3897
|
+
"A fresh project credential is issued and every previous credential is revoked.",
|
|
3898
|
+
"The cloud site is not deleted and no previous project directory is needed."
|
|
3899
|
+
],
|
|
3900
|
+
tool: "deploy",
|
|
3901
|
+
arguments: {
|
|
3902
|
+
...deployArguments,
|
|
3903
|
+
publicConfirmed: true,
|
|
3904
|
+
reuseSiteUrl: site.url,
|
|
3905
|
+
reuseConfirmed: true
|
|
3906
|
+
},
|
|
3907
|
+
reasonCode: "user_selected_reusable_free_site"
|
|
3908
|
+
})
|
|
3909
|
+
),
|
|
3910
|
+
noActionDecisionOption({
|
|
3911
|
+
description: "Do not take over any existing free site and create no new site."
|
|
3912
|
+
})
|
|
3913
|
+
],
|
|
3914
|
+
legacyUserAction: { type: "select_site", provider: "sakupa" }
|
|
3658
3915
|
});
|
|
3659
3916
|
}
|
|
3660
3917
|
async function discoverDeviceFreeSites(client, apiBaseUrl, device) {
|
|
3918
|
+
let cloudSites = (await client.listDeviceFreeSites(device.deviceId, device.credential)).sites;
|
|
3919
|
+
const alreadyOwned = new Set(cloudSites.map((site) => site.siteId));
|
|
3920
|
+
let claimedAny = false;
|
|
3661
3921
|
for (const record of listRecentCreations(Date.now(), apiBaseUrl)) {
|
|
3662
3922
|
const state = loadSiteFile(record.projectDir);
|
|
3663
3923
|
if (state.kind !== "ok" || state.file.siteId !== record.siteId) continue;
|
|
3924
|
+
if (state.file.apiBaseUrl !== "" && state.file.apiBaseUrl !== apiBaseUrl) continue;
|
|
3925
|
+
if (alreadyOwned.has(record.siteId)) continue;
|
|
3926
|
+
const environmentIsKnown = record.apiBaseUrl === apiBaseUrl || state.file.apiBaseUrl === apiBaseUrl;
|
|
3664
3927
|
try {
|
|
3665
3928
|
await client.claimDeviceFreeSite(
|
|
3666
3929
|
record.siteId,
|
|
@@ -3668,9 +3931,11 @@ async function discoverDeviceFreeSites(client, apiBaseUrl, device) {
|
|
|
3668
3931
|
device.deviceId,
|
|
3669
3932
|
device.credential
|
|
3670
3933
|
);
|
|
3934
|
+
claimedAny = true;
|
|
3935
|
+
alreadyOwned.add(record.siteId);
|
|
3671
3936
|
} catch (error) {
|
|
3672
3937
|
if (isSakupaError(error) && ["not_found", "state_conflict"].includes(error.code)) {
|
|
3673
|
-
removeCreation(record.siteId);
|
|
3938
|
+
if (environmentIsKnown) removeCreation(record.siteId);
|
|
3674
3939
|
continue;
|
|
3675
3940
|
}
|
|
3676
3941
|
if (!isSakupaError(error) || error.code !== "unauthorized") {
|
|
@@ -3678,7 +3943,10 @@ async function discoverDeviceFreeSites(client, apiBaseUrl, device) {
|
|
|
3678
3943
|
}
|
|
3679
3944
|
}
|
|
3680
3945
|
}
|
|
3681
|
-
|
|
3946
|
+
if (claimedAny) {
|
|
3947
|
+
cloudSites = (await client.listDeviceFreeSites(device.deviceId, device.credential)).sites;
|
|
3948
|
+
}
|
|
3949
|
+
return cloudSites;
|
|
3682
3950
|
}
|
|
3683
3951
|
function outputDirectoryChain(projectRoot, outputAbs) {
|
|
3684
3952
|
const rel = relative3(projectRoot, outputAbs);
|
|
@@ -3775,18 +4043,39 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
3775
4043
|
const effectiveOutputDir = analysis.recommendedOutputDir ?? ".";
|
|
3776
4044
|
const recordedOutputDir = ctx.projectMarker?.outputDir;
|
|
3777
4045
|
if (recordedOutputDir !== void 0 && resolve5(ctx.projectDir, recordedOutputDir) !== resolve5(ctx.projectDir, effectiveOutputDir) && args.outputDirChangeConfirmed !== true) {
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
4046
|
+
const confirmation = { outputDirChangeConfirmed: true };
|
|
4047
|
+
const confirmArguments = {
|
|
4048
|
+
...args,
|
|
4049
|
+
outputDir: effectiveOutputDir,
|
|
4050
|
+
...confirmation
|
|
4051
|
+
};
|
|
4052
|
+
return decisionToolResult({
|
|
3781
4053
|
resultCode: "publish_directory_change_confirmation_required",
|
|
3782
4054
|
summary: `This initialized project last published from "${recordedOutputDir}", but this request selected "${effectiveOutputDir}". Nothing was uploaded and the site was not changed. Show both paths to the user; only after explicit confirmation call deploy again with outputDirChangeConfirmed: true.`,
|
|
3783
4055
|
data: {
|
|
3784
4056
|
projectDir: ctx.projectDir,
|
|
3785
4057
|
previousOutputDir: recordedOutputDir,
|
|
3786
4058
|
requestedOutputDir: effectiveOutputDir,
|
|
3787
|
-
confirmationField: "outputDirChangeConfirmed"
|
|
4059
|
+
confirmationField: "outputDirChangeConfirmed",
|
|
4060
|
+
confirmation,
|
|
4061
|
+
confirmArguments
|
|
3788
4062
|
},
|
|
3789
|
-
|
|
4063
|
+
prompt: `Use the newly selected publish directory "${effectiveOutputDir}"?`,
|
|
4064
|
+
options: [
|
|
4065
|
+
callToolDecisionOption({
|
|
4066
|
+
id: "use_new_publish_directory",
|
|
4067
|
+
label: `Use ${effectiveOutputDir}`,
|
|
4068
|
+
description: "Publish this project from the newly selected directory.",
|
|
4069
|
+
consequences: [`The recorded publish directory changes from ${recordedOutputDir}.`],
|
|
4070
|
+
tool: "deploy",
|
|
4071
|
+
arguments: confirmArguments,
|
|
4072
|
+
reasonCode: "explicit_confirmation"
|
|
4073
|
+
}),
|
|
4074
|
+
noActionDecisionOption({
|
|
4075
|
+
description: "Keep the recorded publish directory and upload nothing."
|
|
4076
|
+
})
|
|
4077
|
+
],
|
|
4078
|
+
legacyUserAction: { type: "confirm_in_mcp", provider: "sakupa" }
|
|
3790
4079
|
});
|
|
3791
4080
|
}
|
|
3792
4081
|
const files = analysis.files;
|
|
@@ -3841,24 +4130,37 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
3841
4130
|
}
|
|
3842
4131
|
if (nestedMarker.kind === "ok") {
|
|
3843
4132
|
if (args.sakupaRelocationConfirmed !== true) {
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
4133
|
+
const confirmation = { sakupaRelocationConfirmed: true };
|
|
4134
|
+
const confirmArguments = { ...args, ...confirmation };
|
|
4135
|
+
return decisionToolResult({
|
|
3847
4136
|
resultCode: "sakupa_relocation_confirmation_required",
|
|
3848
4137
|
summary: `A nested Sakupa project marker exists at ${candidateDir}/.sakupa, but the active MCP Root is ${ctx.projectDir}. Nothing was moved or deployed. Show both paths to the user; after confirmation retry deploy with sakupaRelocationConfirmed:true. Sakupa will preserve credentials and refuse conflicts.`,
|
|
3849
4138
|
data: {
|
|
3850
4139
|
projectRoot: ctx.projectDir,
|
|
3851
4140
|
misplacedSakupaDirectory: join9(candidateDir, ".sakupa"),
|
|
3852
4141
|
targetSakupaDirectory: join9(ctx.projectDir, ".sakupa"),
|
|
3853
|
-
confirmationField: "sakupaRelocationConfirmed"
|
|
4142
|
+
confirmationField: "sakupaRelocationConfirmed",
|
|
4143
|
+
confirmation,
|
|
4144
|
+
confirmArguments
|
|
3854
4145
|
},
|
|
3855
|
-
|
|
3856
|
-
|
|
4146
|
+
prompt: "Move the nested Sakupa project binding to the active workspace Root?",
|
|
4147
|
+
options: [
|
|
4148
|
+
callToolDecisionOption({
|
|
4149
|
+
id: "relocate_sakupa_binding",
|
|
4150
|
+
label: "Move the Sakupa binding to the workspace Root",
|
|
4151
|
+
description: "Validate and relocate the nested Sakupa binding, then continue this deployment.",
|
|
4152
|
+
consequences: [
|
|
4153
|
+
"Sakupa preserves valid credentials and refuses conflicting bindings."
|
|
4154
|
+
],
|
|
3857
4155
|
tool: "deploy",
|
|
3858
|
-
|
|
4156
|
+
arguments: confirmArguments,
|
|
3859
4157
|
reasonCode: "explicit_sakupa_relocation_confirmation"
|
|
3860
|
-
}
|
|
3861
|
-
|
|
4158
|
+
}),
|
|
4159
|
+
noActionDecisionOption({
|
|
4160
|
+
description: "Leave both directories unchanged and upload nothing."
|
|
4161
|
+
})
|
|
4162
|
+
],
|
|
4163
|
+
legacyUserAction: { type: "confirm_in_mcp", provider: "sakupa" }
|
|
3862
4164
|
});
|
|
3863
4165
|
}
|
|
3864
4166
|
markerRelocatedFrom.push(candidateDir);
|
|
@@ -3981,19 +4283,41 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
3981
4283
|
deleteProjectMarker(dir);
|
|
3982
4284
|
}
|
|
3983
4285
|
if (!existing && args.reuseSiteUrl === void 0 && args.publicConfirmed !== true) {
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
|
|
3989
|
-
|
|
4286
|
+
const confirmation = { publicConfirmed: true };
|
|
4287
|
+
const confirmArguments = { ...args, ...confirmation };
|
|
4288
|
+
return decisionToolResult({
|
|
4289
|
+
resultCode: "public_deployment_confirmation_required",
|
|
4290
|
+
summary: `First deployment creates a public URL that anyone with the link can open. The free preview stays live for ${FREE_SITE_TTL_HOURS} hours. Nothing has been uploaded or made public yet. The exact confirmation field is publicConfirmed: true.`,
|
|
4291
|
+
data: {
|
|
4292
|
+
publicUrlLifetimeHours: FREE_SITE_TTL_HOURS,
|
|
4293
|
+
confirmationField: "publicConfirmed",
|
|
4294
|
+
confirmation,
|
|
4295
|
+
confirmArguments
|
|
4296
|
+
},
|
|
4297
|
+
prompt: "Create the first public free-site preview for this project?",
|
|
4298
|
+
options: [
|
|
4299
|
+
callToolDecisionOption({
|
|
4300
|
+
id: "create_public_preview",
|
|
4301
|
+
label: "Create the public preview",
|
|
4302
|
+
description: `Publish the selected files at a public URL for ${FREE_SITE_TTL_HOURS} hours.`,
|
|
4303
|
+
consequences: ["Anyone with the generated URL can open the site."],
|
|
4304
|
+
tool: "deploy",
|
|
4305
|
+
arguments: confirmArguments,
|
|
4306
|
+
reasonCode: "explicit_public_deployment_confirmation"
|
|
4307
|
+
}),
|
|
4308
|
+
noActionDecisionOption({
|
|
4309
|
+
description: "Keep the project private and upload nothing."
|
|
4310
|
+
})
|
|
4311
|
+
],
|
|
4312
|
+
legacyUserAction: { type: "confirm_in_mcp", provider: "sakupa" }
|
|
4313
|
+
});
|
|
3990
4314
|
}
|
|
3991
4315
|
if (!existing) {
|
|
3992
4316
|
if (args.reuseSiteUrl !== void 0) {
|
|
3993
4317
|
if (args.reuseConfirmed !== true) {
|
|
3994
|
-
|
|
3995
|
-
|
|
3996
|
-
|
|
4318
|
+
const confirmation = { reuseConfirmed: true };
|
|
4319
|
+
const confirmArguments = { ...args, publicConfirmed: true, ...confirmation };
|
|
4320
|
+
return decisionToolResult({
|
|
3997
4321
|
resultCode: "free_site_reuse_confirmation_required",
|
|
3998
4322
|
summary: `Nothing was changed. Reusing ${args.reuseSiteUrl} will replace all online content at that URL with the current project, issue a fresh credential here, and revoke every previous credential automatically. No old directory is needed. Show these consequences and call deploy with reuseConfirmed:true only after the user explicitly selects this URL.`,
|
|
3999
4323
|
data: {
|
|
@@ -4001,25 +4325,29 @@ Next action: ${analysis.suggestedNextAction}`,
|
|
|
4001
4325
|
cloudSiteWillBeDeleted: false,
|
|
4002
4326
|
onlineContentWillBeReplaced: true,
|
|
4003
4327
|
previousProjectWillBeUnbound: true,
|
|
4004
|
-
confirmationField: "reuseConfirmed"
|
|
4328
|
+
confirmationField: "reuseConfirmed",
|
|
4329
|
+
confirmation,
|
|
4330
|
+
confirmArguments
|
|
4005
4331
|
},
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
|
|
4009
|
-
|
|
4010
|
-
|
|
4332
|
+
prompt: `Take over ${args.reuseSiteUrl} with the current project?`,
|
|
4333
|
+
options: [
|
|
4334
|
+
callToolDecisionOption({
|
|
4335
|
+
id: "confirm_site_handoff",
|
|
4336
|
+
label: `Take over ${args.reuseSiteUrl}`,
|
|
4337
|
+
description: "Keep the selected URL and replace all online content with the current project.",
|
|
4338
|
+
consequences: [
|
|
4339
|
+
"A fresh credential is issued here and every previous credential is revoked.",
|
|
4340
|
+
"The previous project becomes unbound; the cloud site is not deleted."
|
|
4341
|
+
],
|
|
4011
4342
|
tool: "deploy",
|
|
4012
|
-
arguments:
|
|
4013
|
-
}
|
|
4014
|
-
},
|
|
4015
|
-
nextActions: [
|
|
4016
|
-
{
|
|
4017
|
-
tool: "deploy",
|
|
4018
|
-
arguments: { ...args, publicConfirmed: true, reuseConfirmed: true },
|
|
4019
|
-
allowed: true,
|
|
4343
|
+
arguments: confirmArguments,
|
|
4020
4344
|
reasonCode: "explicit_free_site_reuse_confirmation"
|
|
4021
|
-
}
|
|
4022
|
-
|
|
4345
|
+
}),
|
|
4346
|
+
noActionDecisionOption({
|
|
4347
|
+
description: "Keep the selected site and current project unchanged."
|
|
4348
|
+
})
|
|
4349
|
+
],
|
|
4350
|
+
legacyUserAction: { type: "confirm_in_mcp", provider: "sakupa" }
|
|
4023
4351
|
});
|
|
4024
4352
|
}
|
|
4025
4353
|
}
|
|
@@ -4395,7 +4723,7 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
|
|
|
4395
4723
|
{
|
|
4396
4724
|
siteId: site.siteId,
|
|
4397
4725
|
plan: args.plan,
|
|
4398
|
-
idempotencyKey:
|
|
4726
|
+
idempotencyKey: randomUUID6()
|
|
4399
4727
|
},
|
|
4400
4728
|
site.credential
|
|
4401
4729
|
);
|
|
@@ -4846,10 +5174,59 @@ After the DNS record resolves, re-run recover with verificationId: "${res2.verif
|
|
|
4846
5174
|
}
|
|
4847
5175
|
if (args.action === "status") {
|
|
4848
5176
|
const res2 = await ctx.client.getRecoveryStatus(verificationId);
|
|
5177
|
+
if (res2.readyToComplete) {
|
|
5178
|
+
const revokeArguments = {
|
|
5179
|
+
action: "complete",
|
|
5180
|
+
verificationId,
|
|
5181
|
+
preserveExistingCredentials: false,
|
|
5182
|
+
...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
|
|
5183
|
+
};
|
|
5184
|
+
const preserveArguments = {
|
|
5185
|
+
...revokeArguments,
|
|
5186
|
+
preserveExistingCredentials: true
|
|
5187
|
+
};
|
|
5188
|
+
return decisionToolResult({
|
|
5189
|
+
resultCode: "domain_recovery_ready",
|
|
5190
|
+
summary: "DNS control is verified and recovery is ready to complete. Nothing was completed yet. The user must choose whether previous site credentials remain valid.",
|
|
5191
|
+
data: {
|
|
5192
|
+
recovery: res2,
|
|
5193
|
+
credentialPolicyChoices: ["revoke_previous", "preserve_previous"]
|
|
5194
|
+
},
|
|
5195
|
+
prompt: "How should Sakupa handle the site credentials that existed before recovery?",
|
|
5196
|
+
options: [
|
|
5197
|
+
callToolDecisionOption({
|
|
5198
|
+
id: "revoke_previous_credentials",
|
|
5199
|
+
label: "Revoke all previous credentials",
|
|
5200
|
+
description: "Complete recovery with the new local credential and revoke every previous credential.",
|
|
5201
|
+
consequences: [
|
|
5202
|
+
"Old project folders and credential backups can no longer manage the site."
|
|
5203
|
+
],
|
|
5204
|
+
tool: "recover",
|
|
5205
|
+
arguments: revokeArguments,
|
|
5206
|
+
reasonCode: "user_selected_secure_recovery"
|
|
5207
|
+
}),
|
|
5208
|
+
callToolDecisionOption({
|
|
5209
|
+
id: "preserve_previous_credentials",
|
|
5210
|
+
label: "Keep previous credentials valid",
|
|
5211
|
+
description: "Complete recovery with the new local credential without revoking existing credentials.",
|
|
5212
|
+
consequences: [
|
|
5213
|
+
"Any old project folder or leaked credential that still works retains site authority."
|
|
5214
|
+
],
|
|
5215
|
+
tool: "recover",
|
|
5216
|
+
arguments: preserveArguments,
|
|
5217
|
+
reasonCode: "user_selected_credential_preservation"
|
|
5218
|
+
}),
|
|
5219
|
+
noActionDecisionOption({
|
|
5220
|
+
label: "Do not complete recovery yet",
|
|
5221
|
+
description: "Keep the verified recovery pending and change no credential."
|
|
5222
|
+
})
|
|
5223
|
+
]
|
|
5224
|
+
});
|
|
5225
|
+
}
|
|
4849
5226
|
return structuredToolResult({
|
|
4850
5227
|
schemaVersion: 1,
|
|
4851
|
-
outcome: res2.status === "expired" ? "expired" :
|
|
4852
|
-
resultCode: res2.status === "expired" ? "domain_recovery_expired" :
|
|
5228
|
+
outcome: res2.status === "expired" ? "expired" : "pending_provider",
|
|
5229
|
+
resultCode: res2.status === "expired" ? "domain_recovery_expired" : "domain_recovery_pending_dns",
|
|
4853
5230
|
summary: `DNS recovery verification status: ${res2.status}`,
|
|
4854
5231
|
data: { recovery: res2 },
|
|
4855
5232
|
nextActions: [
|
|
@@ -4860,8 +5237,8 @@ After the DNS record resolves, re-run recover with verificationId: "${res2.verif
|
|
|
4860
5237
|
verificationId,
|
|
4861
5238
|
...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
|
|
4862
5239
|
},
|
|
4863
|
-
allowed:
|
|
4864
|
-
|
|
5240
|
+
allowed: false,
|
|
5241
|
+
reasonCode: res2.status
|
|
4865
5242
|
}
|
|
4866
5243
|
]
|
|
4867
5244
|
});
|
|
@@ -5031,12 +5408,39 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
|
|
|
5031
5408
|
};
|
|
5032
5409
|
if (args.confirmSubmit !== true) {
|
|
5033
5410
|
const contactNote = args.contactEmail !== void 0 ? "note that their contact email is attached for follow-up. " : "ASK THEM ONCE whether they want to attach a contact email for follow-up (optional \u2014 omit if declined; include it as contactEmail when they do). ";
|
|
5034
|
-
|
|
5035
|
-
|
|
5036
|
-
|
|
5037
|
-
|
|
5038
|
-
"preview"
|
|
5039
|
-
|
|
5411
|
+
const confirmation = { confirmSubmit: true };
|
|
5412
|
+
const confirmArguments = { ...args, ...confirmation };
|
|
5413
|
+
return decisionToolResult({
|
|
5414
|
+
resultCode: "bug_report_preview_ready",
|
|
5415
|
+
outcome: "preview",
|
|
5416
|
+
summary: `Bug report prepared but NOT submitted. This is the exact payload that would be sent (structured diagnostics only \u2014 no file contents, source code or secrets). Show it to the user, and ${contactNote}Exact payload:
|
|
5417
|
+
${JSON.stringify(payload, null, 2)}`,
|
|
5418
|
+
data: {
|
|
5419
|
+
result: payload,
|
|
5420
|
+
confirmation,
|
|
5421
|
+
confirmArguments,
|
|
5422
|
+
submitted: false
|
|
5423
|
+
},
|
|
5424
|
+
prompt: "Submit this exact sanitized bug report?",
|
|
5425
|
+
options: [
|
|
5426
|
+
callToolDecisionOption({
|
|
5427
|
+
id: "submit_bug_report",
|
|
5428
|
+
label: "Submit the reviewed report",
|
|
5429
|
+
description: "Submit exactly the sanitized payload shown above.",
|
|
5430
|
+
consequences: [
|
|
5431
|
+
args.contactEmail === void 0 ? "No contact email is attached." : "The provided contact email is attached for follow-up."
|
|
5432
|
+
],
|
|
5433
|
+
tool: "report",
|
|
5434
|
+
arguments: confirmArguments,
|
|
5435
|
+
reasonCode: "explicit_bug_report_submission_confirmation"
|
|
5436
|
+
}),
|
|
5437
|
+
noActionDecisionOption({
|
|
5438
|
+
label: "Do not submit the report",
|
|
5439
|
+
description: "Keep the report local and send nothing to Sakupa."
|
|
5440
|
+
})
|
|
5441
|
+
],
|
|
5442
|
+
legacyUserAction: { type: "confirm_in_mcp", provider: "sakupa" }
|
|
5443
|
+
});
|
|
5040
5444
|
}
|
|
5041
5445
|
const res = await baseCtx.client.reportBug(payload, site?.credential);
|
|
5042
5446
|
return text(
|
|
@@ -5436,8 +5840,18 @@ function registerHelpTools(server, baseCtx) {
|
|
|
5436
5840
|
schemaVersion: 1,
|
|
5437
5841
|
outcome: "completed",
|
|
5438
5842
|
resultCode: "help_overview",
|
|
5439
|
-
summary:
|
|
5440
|
-
data: {
|
|
5843
|
+
summary: `Sakupa tool overview and parameter names returned. Site handoff moves an existing free URL to the current project and replaces its credential as a safety consequence; credential rotation changes the credential in place solely for security. Both revoke prior values. When a result contains decision, present every option, select none by default, and copy only the user's selected option nextAction exactly. Use help topic:"terminology" for every site/credential distinction. On any failure call help with topic:"diagnose" before retrying, support or report.`,
|
|
5844
|
+
data: {
|
|
5845
|
+
tools: catalog,
|
|
5846
|
+
toolOrder: TOOL_TOPICS,
|
|
5847
|
+
terminology: HELP_TERMINOLOGY,
|
|
5848
|
+
decisionOptions: {
|
|
5849
|
+
selectionMode: "single",
|
|
5850
|
+
defaultOptionId: null,
|
|
5851
|
+
presentEveryOption: true,
|
|
5852
|
+
exactNextActionRequired: true
|
|
5853
|
+
}
|
|
5854
|
+
},
|
|
5441
5855
|
nextActions: []
|
|
5442
5856
|
});
|
|
5443
5857
|
}
|
|
@@ -5596,9 +6010,7 @@ function registerCredentialTools(server, baseCtx) {
|
|
|
5596
6010
|
const status = await ctx.client.getCredentialStatus(site.siteId, site.credential);
|
|
5597
6011
|
const confirmation = { confirmed: true };
|
|
5598
6012
|
if (args.confirmed !== true) {
|
|
5599
|
-
return
|
|
5600
|
-
schemaVersion: 1,
|
|
5601
|
-
outcome: "waiting_user",
|
|
6013
|
+
return decisionToolResult({
|
|
5602
6014
|
resultCode: "credential_rotation_confirmation_required",
|
|
5603
6015
|
summary: `Nothing was changed. Rotating the management credential for ${site.url ?? site.siteId} will generate a new credential locally, save it as the current credential in this project .sakupa/site.json, and revoke EVERY previous credential for this site\u2014including copies in old folders and backups. Rotation is optional and deploy remains available. Current credential created at: ${status.credentialCreatedAt}. Exact confirm arguments: ${JSON.stringify(confirmation)}. Ask the user for explicit approval; never expose credential values.`,
|
|
5604
6016
|
data: {
|
|
@@ -5611,20 +6023,26 @@ function registerCredentialTools(server, baseCtx) {
|
|
|
5611
6023
|
previousCredentialsWillBeRevoked: true,
|
|
5612
6024
|
optional: true
|
|
5613
6025
|
},
|
|
5614
|
-
|
|
5615
|
-
|
|
5616
|
-
|
|
5617
|
-
|
|
5618
|
-
|
|
5619
|
-
|
|
5620
|
-
|
|
5621
|
-
|
|
6026
|
+
prompt: `Rotate the management credential for ${site.url ?? site.siteId}?`,
|
|
6027
|
+
options: [
|
|
6028
|
+
callToolDecisionOption({
|
|
6029
|
+
id: "rotate_credential",
|
|
6030
|
+
label: "Rotate the management credential",
|
|
6031
|
+
description: "Generate one new local credential and make it the only valid credential for this site.",
|
|
6032
|
+
consequences: [
|
|
6033
|
+
"Every previous credential is revoked, including copies in old folders and backups.",
|
|
6034
|
+
"The site URL and online content do not change."
|
|
6035
|
+
],
|
|
5622
6036
|
tool: "rotate",
|
|
5623
6037
|
arguments: confirmation,
|
|
5624
|
-
allowed: true,
|
|
5625
6038
|
reasonCode: "explicit_credential_rotation_confirmation"
|
|
5626
|
-
}
|
|
5627
|
-
|
|
6039
|
+
}),
|
|
6040
|
+
noActionDecisionOption({
|
|
6041
|
+
label: "Keep the current credential",
|
|
6042
|
+
description: "Do not rotate; deployment remains available with the current credential."
|
|
6043
|
+
})
|
|
6044
|
+
],
|
|
6045
|
+
legacyUserAction: { type: "confirm_in_mcp", provider: "sakupa" }
|
|
5628
6046
|
});
|
|
5629
6047
|
}
|
|
5630
6048
|
writeCredentialRotation(ctx.projectDir, {
|
|
@@ -5702,6 +6120,14 @@ Workflow:
|
|
|
5702
6120
|
payment, refund and other customer-service requests. report is the LAST resort only when
|
|
5703
6121
|
help explicitly recommends a product bug report, and submission still requires user review.
|
|
5704
6122
|
|
|
6123
|
+
Decision-options contract: when a result contains decision, present EVERY numbered option from the
|
|
6124
|
+
tool to the user and wait for their selection. No option is selected by default. Never choose from
|
|
6125
|
+
context, paraphrase a selection into different arguments, or invent another option. After the user
|
|
6126
|
+
selects, copy that option's exact nextAction. Legacy userAction and nextActions mirror the same
|
|
6127
|
+
choice for older clients; decision is the authoritative choice set. A type "none" option means call
|
|
6128
|
+
no tool and change nothing. Stripe-hosted links remain direct links because Stripe itself owns plan
|
|
6129
|
+
selection and confirmation.
|
|
6130
|
+
|
|
5705
6131
|
Project directory contract: before the first deploy or a new recovery, initialize the intended
|
|
5706
6132
|
project by calling init with NO path argument. init uses the IDE's exact MCP Root and creates the
|
|
5707
6133
|
non-secret .sakupa/project.json directly there. The CLI command
|