@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.
Files changed (3) hide show
  1. package/dist/bin.js +540 -114
  2. package/dist/index.js +538 -112
  3. package/package.json +1 -1
package/dist/bin.js CHANGED
@@ -380,7 +380,7 @@ var FORBIDDEN_PATH_SEGMENTS = [
380
380
  var ALLOWED_HIDDEN_PATHS = [".well-known/"];
381
381
 
382
382
  // ../core/dist/domain/version.js
383
- var SAKUPA_MCP_VERSION = "0.7.44";
383
+ var SAKUPA_MCP_VERSION = "0.7.46";
384
384
 
385
385
  // ../core/dist/domain/errors.js
386
386
  var HTTP_STATUS = {
@@ -810,8 +810,8 @@ var HttpApiClient = class {
810
810
  body: body?.slice(0, 200)
811
811
  });
812
812
  }
813
- async registerDevice() {
814
- return this.call("POST", "/v1/devices");
813
+ async registerDevice(req) {
814
+ return this.call("POST", "/v1/devices", { body: req });
815
815
  }
816
816
  async listDeviceFreeSites(deviceId, credential) {
817
817
  return this.call("GET", "/v1/devices/free-sites", {
@@ -984,7 +984,7 @@ var HttpApiClient = class {
984
984
  };
985
985
 
986
986
  // src/tools/definitions.ts
987
- import { randomUUID as randomUUID5 } from "node:crypto";
987
+ import { randomUUID as randomUUID6 } from "node:crypto";
988
988
  import { promises as fs2 } from "node:fs";
989
989
  import { join as join9, relative as relative3, resolve as resolve5, sep as sep4 } from "node:path";
990
990
  import { z as z2 } from "zod";
@@ -2318,6 +2318,7 @@ import {
2318
2318
  unlinkSync as unlinkSync2,
2319
2319
  writeFileSync as writeFileSync4
2320
2320
  } from "node:fs";
2321
+ import { randomUUID as randomUUID3 } from "node:crypto";
2321
2322
  import { homedir as homedir3 } from "node:os";
2322
2323
  import { dirname as dirname4, join as join6 } from "node:path";
2323
2324
  var DEVICE_LOCK_STALE_MS = 3e4;
@@ -2327,10 +2328,22 @@ function deviceRegistryPath() {
2327
2328
  return join6(base, ".sakupa", "devices.json");
2328
2329
  }
2329
2330
  var deviceLockPath = () => join6(dirname4(deviceRegistryPath()), "devices.lock");
2330
- function releaseDeviceLock(fd2) {
2331
+ function lockTokenAt(path) {
2332
+ try {
2333
+ const parsed = JSON.parse(readFileSync4(path, "utf8"));
2334
+ return typeof parsed.token === "string" ? parsed.token : null;
2335
+ } catch {
2336
+ return null;
2337
+ }
2338
+ }
2339
+ function ownsDeviceLock(lock) {
2340
+ return lockTokenAt(deviceLockPath()) === lock.token;
2341
+ }
2342
+ function releaseDeviceLock(lock) {
2331
2343
  try {
2332
- closeSync(fd2);
2344
+ closeSync(lock.fd);
2333
2345
  } finally {
2346
+ if (!ownsDeviceLock(lock)) return;
2334
2347
  try {
2335
2348
  unlinkSync2(deviceLockPath());
2336
2349
  } catch {
@@ -2345,9 +2358,13 @@ async function acquireDeviceLock(apiBaseUrl) {
2345
2358
  const existing = loadDeviceBinding(apiBaseUrl);
2346
2359
  if (existing) return existing;
2347
2360
  try {
2361
+ const token = randomUUID3();
2348
2362
  const fd2 = openSync(path, "wx", 384);
2349
- writeFileSync4(fd2, JSON.stringify({ pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString() }));
2350
- return fd2;
2363
+ writeFileSync4(
2364
+ fd2,
2365
+ JSON.stringify({ token, pid: process.pid, createdAt: (/* @__PURE__ */ new Date()).toISOString() })
2366
+ );
2367
+ return { fd: fd2, token };
2351
2368
  } catch {
2352
2369
  try {
2353
2370
  if (Date.now() - statSync2(path).mtimeMs >= DEVICE_LOCK_STALE_MS) {
@@ -2368,13 +2385,16 @@ async function acquireDeviceLock(apiBaseUrl) {
2368
2385
  }
2369
2386
  function readRegistry() {
2370
2387
  const path = deviceRegistryPath();
2371
- if (!existsSync5(path)) return { schemaVersion: 1, environments: {} };
2388
+ if (!existsSync5(path)) {
2389
+ return { schemaVersion: 1, environments: {}, pendingRegistrations: {} };
2390
+ }
2372
2391
  try {
2373
2392
  const parsed = JSON.parse(readFileSync4(path, "utf8"));
2374
2393
  if (parsed.schemaVersion !== 1 || !parsed.environments || typeof parsed.environments !== "object") {
2375
2394
  throw new Error("unsupported device registry schema");
2376
2395
  }
2377
- return { schemaVersion: 1, environments: parsed.environments };
2396
+ const pendingRegistrations = "pendingRegistrations" in parsed && parsed.pendingRegistrations && typeof parsed.pendingRegistrations === "object" ? parsed.pendingRegistrations : {};
2397
+ return { schemaVersion: 1, environments: parsed.environments, pendingRegistrations };
2378
2398
  } catch (error) {
2379
2399
  throw new Error(
2380
2400
  `Sakupa device registry is unreadable at ${path}: ${error instanceof Error ? error.message : String(error)}. Run help; do not search old project directories.`
@@ -2403,19 +2423,38 @@ async function ensureDeviceBinding(client, apiBaseUrl) {
2403
2423
  const existing = loadDeviceBinding(apiBaseUrl);
2404
2424
  if (existing) return existing;
2405
2425
  const lock = await acquireDeviceLock(apiBaseUrl);
2406
- if (typeof lock !== "number") return lock;
2426
+ if (!("fd" in lock)) return lock;
2407
2427
  try {
2408
2428
  const afterLock = loadDeviceBinding(apiBaseUrl);
2409
2429
  if (afterLock) return afterLock;
2410
- const created = await client.registerDevice();
2411
- const registry = readRegistry();
2430
+ let registry = readRegistry();
2431
+ let pending = registry.pendingRegistrations[apiBaseUrl];
2432
+ if (!pending) {
2433
+ pending = {
2434
+ operationId: randomUUID3(),
2435
+ deviceId: randomUUID3(),
2436
+ credential: generateCredential(),
2437
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
2438
+ };
2439
+ registry.pendingRegistrations[apiBaseUrl] = pending;
2440
+ writeRegistry(registry);
2441
+ }
2442
+ const created = await client.registerDevice({
2443
+ operationId: pending.operationId,
2444
+ deviceId: pending.deviceId,
2445
+ credential: pending.credential
2446
+ });
2412
2447
  const binding = {
2413
2448
  deviceId: created.deviceId,
2414
2449
  credential: created.credential,
2415
2450
  createdAt: created.createdAt
2416
2451
  };
2417
- registry.environments[apiBaseUrl] = binding;
2418
- writeRegistry(registry);
2452
+ if (ownsDeviceLock(lock)) {
2453
+ registry = readRegistry();
2454
+ registry.environments[apiBaseUrl] = binding;
2455
+ delete registry.pendingRegistrations[apiBaseUrl];
2456
+ writeRegistry(registry);
2457
+ }
2419
2458
  return binding;
2420
2459
  } finally {
2421
2460
  releaseDeviceLock(lock);
@@ -2733,7 +2772,7 @@ import {
2733
2772
  rmSync as rmSync2,
2734
2773
  writeFileSync as writeFileSync6
2735
2774
  } from "node:fs";
2736
- import { randomUUID as randomUUID3 } from "node:crypto";
2775
+ import { randomUUID as randomUUID4 } from "node:crypto";
2737
2776
  import { join as join8 } from "node:path";
2738
2777
  var ROTATION_FILE = "rotation.json";
2739
2778
  function credentialRotationPath(projectDir) {
@@ -2794,7 +2833,7 @@ function writeCredentialRotation(projectDir, file) {
2794
2833
  const directory = join8(projectDir, ".sakupa");
2795
2834
  mkdirSync6(directory, { recursive: true, mode: 448 });
2796
2835
  const target = credentialRotationPath(projectDir);
2797
- const temporary = join8(directory, `.rotation-${randomUUID3()}.tmp`);
2836
+ const temporary = join8(directory, `.rotation-${randomUUID4()}.tmp`);
2798
2837
  writeFileSync6(temporary, `${JSON.stringify(file, null, 2)}
2799
2838
  `, {
2800
2839
  encoding: "utf8",
@@ -3146,6 +3185,24 @@ function boundDiagnostics(processCwd, selected, snapshot = { supported: false, r
3146
3185
 
3147
3186
  // src/tools/result.ts
3148
3187
  import { z } from "zod";
3188
+ var TARGET_MCP_TOOL_NAMES = [
3189
+ "init",
3190
+ "help",
3191
+ "analyze",
3192
+ "deploy",
3193
+ "refresh",
3194
+ "status",
3195
+ "rotate",
3196
+ "plans",
3197
+ "subscribe",
3198
+ "bind",
3199
+ "billing",
3200
+ "portal",
3201
+ "recover",
3202
+ "change",
3203
+ "support",
3204
+ "report"
3205
+ ];
3149
3206
  var STRUCTURED_TOOL_OUTPUT_SCHEMA = {
3150
3207
  schemaVersion: z.literal(1),
3151
3208
  outcome: z.enum([
@@ -3161,13 +3218,41 @@ var STRUCTURED_TOOL_OUTPUT_SCHEMA = {
3161
3218
  operationId: z.string().optional(),
3162
3219
  summary: z.string(),
3163
3220
  data: z.record(z.string(), z.unknown()),
3221
+ decision: z.object({
3222
+ decisionVersion: z.literal(1),
3223
+ prompt: z.string(),
3224
+ selectionMode: z.literal("single"),
3225
+ selectionRequired: z.literal(true),
3226
+ defaultOptionId: z.null(),
3227
+ options: z.array(
3228
+ z.object({
3229
+ id: z.string(),
3230
+ label: z.string(),
3231
+ description: z.string(),
3232
+ consequences: z.array(z.string()),
3233
+ nextAction: z.discriminatedUnion("type", [
3234
+ z.object({
3235
+ type: z.literal("call_tool"),
3236
+ tool: z.enum(TARGET_MCP_TOOL_NAMES),
3237
+ arguments: z.record(z.string(), z.unknown()),
3238
+ reasonCode: z.string().optional()
3239
+ }),
3240
+ z.object({ type: z.literal("open_url"), url: z.string() }),
3241
+ z.object({ type: z.literal("none") })
3242
+ ])
3243
+ })
3244
+ )
3245
+ }).optional(),
3164
3246
  userAction: z.object({
3165
3247
  type: z.enum(["open_url", "confirm_in_mcp", "configure_dns", "select_site"]),
3166
3248
  provider: z.enum(["stripe", "sakupa"]).optional(),
3167
3249
  url: z.string().optional(),
3168
3250
  expiresAt: z.string().optional(),
3169
3251
  expectedOutcome: z.string(),
3170
- resumeWith: z.object({ tool: z.string(), arguments: z.record(z.string(), z.unknown()) }).optional(),
3252
+ resumeWith: z.object({
3253
+ tool: z.enum(TARGET_MCP_TOOL_NAMES),
3254
+ arguments: z.record(z.string(), z.unknown())
3255
+ }).optional(),
3171
3256
  options: z.array(
3172
3257
  z.object({
3173
3258
  label: z.string(),
@@ -3178,7 +3263,7 @@ var STRUCTURED_TOOL_OUTPUT_SCHEMA = {
3178
3263
  }).optional(),
3179
3264
  nextActions: z.array(
3180
3265
  z.object({
3181
- tool: z.string(),
3266
+ tool: z.enum(TARGET_MCP_TOOL_NAMES),
3182
3267
  arguments: z.record(z.string(), z.unknown()).optional(),
3183
3268
  allowed: z.boolean(),
3184
3269
  reasonCode: z.string().optional()
@@ -3193,7 +3278,7 @@ function structuredToolResult(envelope) {
3193
3278
  }
3194
3279
 
3195
3280
  // src/tools/context.ts
3196
- import { randomUUID as randomUUID4 } from "node:crypto";
3281
+ import { randomUUID as randomUUID5 } from "node:crypto";
3197
3282
  var LocalGuidanceError = class extends SakupaError {
3198
3283
  constructor(code, message) {
3199
3284
  super(code, message);
@@ -3272,7 +3357,7 @@ function reportAuthorizationStore(ctx) {
3272
3357
  return store;
3273
3358
  }
3274
3359
  function issueReportAuthorization(ctx, failedTool) {
3275
- const token = randomUUID4();
3360
+ const token = randomUUID5();
3276
3361
  reportAuthorizationStore(ctx).set(token, {
3277
3362
  failedTool,
3278
3363
  expiresAt: Date.now() + 10 * 60 * 1e3
@@ -3361,6 +3446,176 @@ function toolError(e) {
3361
3446
  return { ...result, isError: true };
3362
3447
  }
3363
3448
 
3449
+ // src/tools/decision.ts
3450
+ var FORBIDDEN_DECISION_ARGUMENT_KEYS = /* @__PURE__ */ new Set([
3451
+ "credential",
3452
+ "candidatecredential",
3453
+ "devicecredential",
3454
+ "password",
3455
+ "secret",
3456
+ "privatekey",
3457
+ "accesstoken",
3458
+ "refreshtoken"
3459
+ ]);
3460
+ function assertDecisionArgumentsSafe(value, path = "arguments") {
3461
+ if (typeof value === "string") {
3462
+ if (/^sk_[A-Za-z0-9_-]{20,}$/.test(value)) {
3463
+ throw new Error(`Decision ${path} contains a credential-like value.`);
3464
+ }
3465
+ return;
3466
+ }
3467
+ if (Array.isArray(value)) {
3468
+ value.forEach((entry, index) => assertDecisionArgumentsSafe(entry, `${path}[${index}]`));
3469
+ return;
3470
+ }
3471
+ if (typeof value !== "object" || value === null) return;
3472
+ for (const [key, entry] of Object.entries(value)) {
3473
+ const normalizedKey = key.replace(/[_-]/g, "").toLowerCase();
3474
+ if (FORBIDDEN_DECISION_ARGUMENT_KEYS.has(normalizedKey)) {
3475
+ throw new Error(`Decision ${path}.${key} contains a forbidden secret field.`);
3476
+ }
3477
+ assertDecisionArgumentsSafe(entry, `${path}.${key}`);
3478
+ }
3479
+ }
3480
+ function buildDecisionContract(prompt, options) {
3481
+ const normalizedPrompt = prompt.trim();
3482
+ if (!normalizedPrompt) throw new Error("Decision prompt must not be empty.");
3483
+ if (options.length < 2) throw new Error("A decision must contain at least two options.");
3484
+ const ids = /* @__PURE__ */ new Set();
3485
+ let actionableOptions = 0;
3486
+ let noActionOptions = 0;
3487
+ for (const option of options) {
3488
+ if (!/^[a-z][a-z0-9_]*$/.test(option.id)) {
3489
+ throw new Error(`Invalid decision option id: ${option.id}`);
3490
+ }
3491
+ if (ids.has(option.id)) throw new Error(`Duplicate decision option id: ${option.id}`);
3492
+ ids.add(option.id);
3493
+ if (!option.label.trim() || !option.description.trim()) {
3494
+ throw new Error(`Decision option ${option.id} requires a label and description.`);
3495
+ }
3496
+ if (option.nextAction.type === "none") {
3497
+ noActionOptions += 1;
3498
+ continue;
3499
+ }
3500
+ actionableOptions += 1;
3501
+ if (option.nextAction.type === "call_tool" && !TARGET_MCP_TOOL_NAMES.includes(option.nextAction.tool)) {
3502
+ throw new Error(`Unknown MCP decision tool: ${String(option.nextAction.tool)}`);
3503
+ }
3504
+ if (option.nextAction.type === "call_tool") {
3505
+ assertDecisionArgumentsSafe(option.nextAction.arguments);
3506
+ }
3507
+ }
3508
+ if (actionableOptions === 0) throw new Error("A decision requires an actionable option.");
3509
+ if (noActionOptions === 0) throw new Error("A decision requires a no-action exit option.");
3510
+ return {
3511
+ decisionVersion: 1,
3512
+ prompt: normalizedPrompt,
3513
+ selectionMode: "single",
3514
+ selectionRequired: true,
3515
+ defaultOptionId: null,
3516
+ options
3517
+ };
3518
+ }
3519
+ function formatDecisionFallback(decision) {
3520
+ const options = decision.options.map((option, index) => {
3521
+ const consequences = option.consequences.length === 0 ? "" : `
3522
+ Consequences: ${option.consequences.join(" ")}`;
3523
+ let exactAction;
3524
+ switch (option.nextAction.type) {
3525
+ case "call_tool":
3526
+ exactAction = `If the user selects this option, call ${option.nextAction.tool} with these exact arguments: ${JSON.stringify(option.nextAction.arguments)}.`;
3527
+ break;
3528
+ case "open_url":
3529
+ exactAction = `If the user selects this option, present this exact URL: ${option.nextAction.url}.`;
3530
+ break;
3531
+ case "none":
3532
+ exactAction = "If the user selects this option, call no tool and make no change.";
3533
+ break;
3534
+ }
3535
+ return `${index + 1}. [${option.id}] ${option.label}
3536
+ ${option.description}${consequences}
3537
+ ${exactAction}`;
3538
+ });
3539
+ return `USER DECISION REQUIRED: ${decision.prompt}
3540
+ 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.
3541
+
3542
+ ` + options.join("\n\n");
3543
+ }
3544
+ function decisionToolResult(input) {
3545
+ const decision = buildDecisionContract(input.prompt, input.options);
3546
+ const actionable = decision.options.filter(
3547
+ (option) => option.nextAction.type === "call_tool"
3548
+ );
3549
+ const nextActions = actionable.map((option) => ({
3550
+ tool: option.nextAction.tool,
3551
+ arguments: option.nextAction.arguments,
3552
+ allowed: true,
3553
+ ...option.nextAction.reasonCode === void 0 ? {} : { reasonCode: option.nextAction.reasonCode }
3554
+ }));
3555
+ let userAction;
3556
+ if (input.legacyUserAction?.type === "confirm_in_mcp" && actionable.length === 1) {
3557
+ const option = actionable[0];
3558
+ if (option !== void 0) {
3559
+ userAction = {
3560
+ type: "confirm_in_mcp",
3561
+ provider: input.legacyUserAction.provider,
3562
+ expectedOutcome: option.description,
3563
+ resumeWith: {
3564
+ tool: option.nextAction.tool,
3565
+ arguments: option.nextAction.arguments
3566
+ }
3567
+ };
3568
+ }
3569
+ } else if (input.legacyUserAction?.type === "select_site") {
3570
+ userAction = {
3571
+ type: "select_site",
3572
+ provider: input.legacyUserAction.provider,
3573
+ expectedOutcome: "Apply only the option explicitly selected by the user.",
3574
+ options: actionable.map((option) => ({
3575
+ label: option.label,
3576
+ value: typeof option.nextAction.arguments["reuseSiteUrl"] === "string" ? option.nextAction.arguments["reuseSiteUrl"] : option.id,
3577
+ expectedOutcome: option.description
3578
+ }))
3579
+ };
3580
+ }
3581
+ return structuredToolResult({
3582
+ schemaVersion: 1,
3583
+ outcome: input.outcome ?? "waiting_user",
3584
+ resultCode: input.resultCode,
3585
+ ...input.operationId === void 0 ? {} : { operationId: input.operationId },
3586
+ summary: `${input.summary}
3587
+
3588
+ ${formatDecisionFallback(decision)}`,
3589
+ data: input.data,
3590
+ decision,
3591
+ ...userAction === void 0 ? {} : { userAction },
3592
+ nextActions
3593
+ });
3594
+ }
3595
+ function callToolDecisionOption(input) {
3596
+ return {
3597
+ id: input.id,
3598
+ label: input.label,
3599
+ description: input.description,
3600
+ consequences: input.consequences ?? [],
3601
+ nextAction: {
3602
+ type: "call_tool",
3603
+ tool: input.tool,
3604
+ arguments: input.arguments,
3605
+ ...input.reasonCode === void 0 ? {} : { reasonCode: input.reasonCode }
3606
+ }
3607
+ };
3608
+ }
3609
+ function noActionDecisionOption(input) {
3610
+ return {
3611
+ id: input?.id ?? "cancel",
3612
+ label: input?.label ?? "Do not continue",
3613
+ description: input?.description ?? "Keep the current local and cloud state unchanged.",
3614
+ consequences: [],
3615
+ nextAction: { type: "none" }
3616
+ };
3617
+ }
3618
+
3364
3619
  // src/tools/definitions.ts
3365
3620
  function text(resultCode, t, data = {}, outcome = "completed", nextActions = []) {
3366
3621
  return structuredToolResult({
@@ -3500,17 +3755,10 @@ ${block}`, checklist: toDnsChecklist(diag.checks) };
3500
3755
  }
3501
3756
  }
3502
3757
  function freeSiteCreationBarrier(sites, deployArguments) {
3503
- const userSiteOptions = sites.map((site) => ({
3504
- label: `Replace content at ${site.url}`,
3505
- value: site.url,
3506
- expectedOutcome: "A site handoff keeps this existing free-site URL, replaces its online content, issues a fresh project credential, and revokes every previous credential."
3507
- }));
3508
3758
  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.
3509
3759
 
3510
3760
  ` + 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.";
3511
- return structuredToolResult({
3512
- schemaVersion: 1,
3513
- outcome: "waiting_user",
3761
+ return decisionToolResult({
3514
3762
  resultCode: "free_site_slot_selection_required",
3515
3763
  summary,
3516
3764
  data: {
@@ -3522,29 +3770,44 @@ function freeSiteCreationBarrier(sites, deployArguments) {
3522
3770
  cloudSiteWillBeDeleted: false,
3523
3771
  previousProjectWillBeUnbound: true
3524
3772
  },
3525
- userAction: {
3526
- type: "select_site",
3527
- provider: "sakupa",
3528
- expectedOutcome: "The selected URL remains while its content is replaced and all previous credentials are revoked.",
3529
- options: userSiteOptions
3530
- },
3531
- nextActions: sites.map((site) => ({
3532
- tool: "deploy",
3533
- arguments: {
3534
- ...deployArguments,
3535
- publicConfirmed: true,
3536
- reuseSiteUrl: site.url,
3537
- reuseConfirmed: true
3538
- },
3539
- allowed: true,
3540
- reasonCode: "user_selected_reusable_free_site"
3541
- }))
3773
+ prompt: "Choose exactly one existing free URL that the current project may take over.",
3774
+ options: [
3775
+ ...sites.map(
3776
+ (site, index) => callToolDecisionOption({
3777
+ id: `handoff_site_${index + 1}`,
3778
+ label: `Replace content at ${site.url}`,
3779
+ description: "Keep this existing free-site URL and replace its online content with the current project.",
3780
+ consequences: [
3781
+ "A fresh project credential is issued and every previous credential is revoked.",
3782
+ "The cloud site is not deleted and no previous project directory is needed."
3783
+ ],
3784
+ tool: "deploy",
3785
+ arguments: {
3786
+ ...deployArguments,
3787
+ publicConfirmed: true,
3788
+ reuseSiteUrl: site.url,
3789
+ reuseConfirmed: true
3790
+ },
3791
+ reasonCode: "user_selected_reusable_free_site"
3792
+ })
3793
+ ),
3794
+ noActionDecisionOption({
3795
+ description: "Do not take over any existing free site and create no new site."
3796
+ })
3797
+ ],
3798
+ legacyUserAction: { type: "select_site", provider: "sakupa" }
3542
3799
  });
3543
3800
  }
3544
3801
  async function discoverDeviceFreeSites(client, apiBaseUrl, device) {
3802
+ let cloudSites = (await client.listDeviceFreeSites(device.deviceId, device.credential)).sites;
3803
+ const alreadyOwned = new Set(cloudSites.map((site) => site.siteId));
3804
+ let claimedAny = false;
3545
3805
  for (const record of listRecentCreations(Date.now(), apiBaseUrl)) {
3546
3806
  const state = loadSiteFile(record.projectDir);
3547
3807
  if (state.kind !== "ok" || state.file.siteId !== record.siteId) continue;
3808
+ if (state.file.apiBaseUrl !== "" && state.file.apiBaseUrl !== apiBaseUrl) continue;
3809
+ if (alreadyOwned.has(record.siteId)) continue;
3810
+ const environmentIsKnown = record.apiBaseUrl === apiBaseUrl || state.file.apiBaseUrl === apiBaseUrl;
3548
3811
  try {
3549
3812
  await client.claimDeviceFreeSite(
3550
3813
  record.siteId,
@@ -3552,9 +3815,11 @@ async function discoverDeviceFreeSites(client, apiBaseUrl, device) {
3552
3815
  device.deviceId,
3553
3816
  device.credential
3554
3817
  );
3818
+ claimedAny = true;
3819
+ alreadyOwned.add(record.siteId);
3555
3820
  } catch (error) {
3556
3821
  if (isSakupaError(error) && ["not_found", "state_conflict"].includes(error.code)) {
3557
- removeCreation(record.siteId);
3822
+ if (environmentIsKnown) removeCreation(record.siteId);
3558
3823
  continue;
3559
3824
  }
3560
3825
  if (!isSakupaError(error) || error.code !== "unauthorized") {
@@ -3562,7 +3827,10 @@ async function discoverDeviceFreeSites(client, apiBaseUrl, device) {
3562
3827
  }
3563
3828
  }
3564
3829
  }
3565
- return (await client.listDeviceFreeSites(device.deviceId, device.credential)).sites;
3830
+ if (claimedAny) {
3831
+ cloudSites = (await client.listDeviceFreeSites(device.deviceId, device.credential)).sites;
3832
+ }
3833
+ return cloudSites;
3566
3834
  }
3567
3835
  function outputDirectoryChain(projectRoot, outputAbs) {
3568
3836
  const rel = relative3(projectRoot, outputAbs);
@@ -3659,18 +3927,39 @@ Next action: ${analysis.suggestedNextAction}`,
3659
3927
  const effectiveOutputDir = analysis.recommendedOutputDir ?? ".";
3660
3928
  const recordedOutputDir = ctx.projectMarker?.outputDir;
3661
3929
  if (recordedOutputDir !== void 0 && resolve5(ctx.projectDir, recordedOutputDir) !== resolve5(ctx.projectDir, effectiveOutputDir) && args.outputDirChangeConfirmed !== true) {
3662
- return structuredToolResult({
3663
- schemaVersion: 1,
3664
- outcome: "waiting_user",
3930
+ const confirmation = { outputDirChangeConfirmed: true };
3931
+ const confirmArguments = {
3932
+ ...args,
3933
+ outputDir: effectiveOutputDir,
3934
+ ...confirmation
3935
+ };
3936
+ return decisionToolResult({
3665
3937
  resultCode: "publish_directory_change_confirmation_required",
3666
3938
  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.`,
3667
3939
  data: {
3668
3940
  projectDir: ctx.projectDir,
3669
3941
  previousOutputDir: recordedOutputDir,
3670
3942
  requestedOutputDir: effectiveOutputDir,
3671
- confirmationField: "outputDirChangeConfirmed"
3943
+ confirmationField: "outputDirChangeConfirmed",
3944
+ confirmation,
3945
+ confirmArguments
3672
3946
  },
3673
- nextActions: [{ tool: "deploy", allowed: true, reasonCode: "explicit_confirmation" }]
3947
+ prompt: `Use the newly selected publish directory "${effectiveOutputDir}"?`,
3948
+ options: [
3949
+ callToolDecisionOption({
3950
+ id: "use_new_publish_directory",
3951
+ label: `Use ${effectiveOutputDir}`,
3952
+ description: "Publish this project from the newly selected directory.",
3953
+ consequences: [`The recorded publish directory changes from ${recordedOutputDir}.`],
3954
+ tool: "deploy",
3955
+ arguments: confirmArguments,
3956
+ reasonCode: "explicit_confirmation"
3957
+ }),
3958
+ noActionDecisionOption({
3959
+ description: "Keep the recorded publish directory and upload nothing."
3960
+ })
3961
+ ],
3962
+ legacyUserAction: { type: "confirm_in_mcp", provider: "sakupa" }
3674
3963
  });
3675
3964
  }
3676
3965
  const files = analysis.files;
@@ -3725,24 +4014,37 @@ Next action: ${analysis.suggestedNextAction}`,
3725
4014
  }
3726
4015
  if (nestedMarker.kind === "ok") {
3727
4016
  if (args.sakupaRelocationConfirmed !== true) {
3728
- return structuredToolResult({
3729
- schemaVersion: 1,
3730
- outcome: "waiting_user",
4017
+ const confirmation = { sakupaRelocationConfirmed: true };
4018
+ const confirmArguments = { ...args, ...confirmation };
4019
+ return decisionToolResult({
3731
4020
  resultCode: "sakupa_relocation_confirmation_required",
3732
4021
  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.`,
3733
4022
  data: {
3734
4023
  projectRoot: ctx.projectDir,
3735
4024
  misplacedSakupaDirectory: join9(candidateDir, ".sakupa"),
3736
4025
  targetSakupaDirectory: join9(ctx.projectDir, ".sakupa"),
3737
- confirmationField: "sakupaRelocationConfirmed"
4026
+ confirmationField: "sakupaRelocationConfirmed",
4027
+ confirmation,
4028
+ confirmArguments
3738
4029
  },
3739
- nextActions: [
3740
- {
4030
+ prompt: "Move the nested Sakupa project binding to the active workspace Root?",
4031
+ options: [
4032
+ callToolDecisionOption({
4033
+ id: "relocate_sakupa_binding",
4034
+ label: "Move the Sakupa binding to the workspace Root",
4035
+ description: "Validate and relocate the nested Sakupa binding, then continue this deployment.",
4036
+ consequences: [
4037
+ "Sakupa preserves valid credentials and refuses conflicting bindings."
4038
+ ],
3741
4039
  tool: "deploy",
3742
- allowed: true,
4040
+ arguments: confirmArguments,
3743
4041
  reasonCode: "explicit_sakupa_relocation_confirmation"
3744
- }
3745
- ]
4042
+ }),
4043
+ noActionDecisionOption({
4044
+ description: "Leave both directories unchanged and upload nothing."
4045
+ })
4046
+ ],
4047
+ legacyUserAction: { type: "confirm_in_mcp", provider: "sakupa" }
3746
4048
  });
3747
4049
  }
3748
4050
  markerRelocatedFrom.push(candidateDir);
@@ -3865,19 +4167,41 @@ Next action: ${analysis.suggestedNextAction}`,
3865
4167
  deleteProjectMarker(dir);
3866
4168
  }
3867
4169
  if (!existing && args.reuseSiteUrl === void 0 && args.publicConfirmed !== true) {
3868
- return text(
3869
- "public_deployment_confirmation_required",
3870
- `First deployment creates a public URL that anyone with the link can open. The free preview stays live for ${FREE_SITE_TTL_HOURS} hours. Explain this to the user and obtain explicit confirmation before retrying deploy with publicConfirmed: true.`,
3871
- { publicUrlLifetimeHours: FREE_SITE_TTL_HOURS, confirmationField: "publicConfirmed" },
3872
- "waiting_user"
3873
- );
4170
+ const confirmation = { publicConfirmed: true };
4171
+ const confirmArguments = { ...args, ...confirmation };
4172
+ return decisionToolResult({
4173
+ resultCode: "public_deployment_confirmation_required",
4174
+ 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.`,
4175
+ data: {
4176
+ publicUrlLifetimeHours: FREE_SITE_TTL_HOURS,
4177
+ confirmationField: "publicConfirmed",
4178
+ confirmation,
4179
+ confirmArguments
4180
+ },
4181
+ prompt: "Create the first public free-site preview for this project?",
4182
+ options: [
4183
+ callToolDecisionOption({
4184
+ id: "create_public_preview",
4185
+ label: "Create the public preview",
4186
+ description: `Publish the selected files at a public URL for ${FREE_SITE_TTL_HOURS} hours.`,
4187
+ consequences: ["Anyone with the generated URL can open the site."],
4188
+ tool: "deploy",
4189
+ arguments: confirmArguments,
4190
+ reasonCode: "explicit_public_deployment_confirmation"
4191
+ }),
4192
+ noActionDecisionOption({
4193
+ description: "Keep the project private and upload nothing."
4194
+ })
4195
+ ],
4196
+ legacyUserAction: { type: "confirm_in_mcp", provider: "sakupa" }
4197
+ });
3874
4198
  }
3875
4199
  if (!existing) {
3876
4200
  if (args.reuseSiteUrl !== void 0) {
3877
4201
  if (args.reuseConfirmed !== true) {
3878
- return structuredToolResult({
3879
- schemaVersion: 1,
3880
- outcome: "waiting_user",
4202
+ const confirmation = { reuseConfirmed: true };
4203
+ const confirmArguments = { ...args, publicConfirmed: true, ...confirmation };
4204
+ return decisionToolResult({
3881
4205
  resultCode: "free_site_reuse_confirmation_required",
3882
4206
  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.`,
3883
4207
  data: {
@@ -3885,25 +4209,29 @@ Next action: ${analysis.suggestedNextAction}`,
3885
4209
  cloudSiteWillBeDeleted: false,
3886
4210
  onlineContentWillBeReplaced: true,
3887
4211
  previousProjectWillBeUnbound: true,
3888
- confirmationField: "reuseConfirmed"
3889
- },
3890
- userAction: {
3891
- type: "confirm_in_mcp",
3892
- provider: "sakupa",
3893
- expectedOutcome: "Replace the selected free URL content and move its local project binding.",
3894
- resumeWith: {
3895
- tool: "deploy",
3896
- arguments: { ...args, publicConfirmed: true, reuseConfirmed: true }
3897
- }
4212
+ confirmationField: "reuseConfirmed",
4213
+ confirmation,
4214
+ confirmArguments
3898
4215
  },
3899
- nextActions: [
3900
- {
4216
+ prompt: `Take over ${args.reuseSiteUrl} with the current project?`,
4217
+ options: [
4218
+ callToolDecisionOption({
4219
+ id: "confirm_site_handoff",
4220
+ label: `Take over ${args.reuseSiteUrl}`,
4221
+ description: "Keep the selected URL and replace all online content with the current project.",
4222
+ consequences: [
4223
+ "A fresh credential is issued here and every previous credential is revoked.",
4224
+ "The previous project becomes unbound; the cloud site is not deleted."
4225
+ ],
3901
4226
  tool: "deploy",
3902
- arguments: { ...args, publicConfirmed: true, reuseConfirmed: true },
3903
- allowed: true,
4227
+ arguments: confirmArguments,
3904
4228
  reasonCode: "explicit_free_site_reuse_confirmation"
3905
- }
3906
- ]
4229
+ }),
4230
+ noActionDecisionOption({
4231
+ description: "Keep the selected site and current project unchanged."
4232
+ })
4233
+ ],
4234
+ legacyUserAction: { type: "confirm_in_mcp", provider: "sakupa" }
3907
4235
  });
3908
4236
  }
3909
4237
  }
@@ -4279,7 +4607,7 @@ NO content was uploaded or changed by this call \u2014 to publish new or edited
4279
4607
  {
4280
4608
  siteId: site.siteId,
4281
4609
  plan: args.plan,
4282
- idempotencyKey: randomUUID5()
4610
+ idempotencyKey: randomUUID6()
4283
4611
  },
4284
4612
  site.credential
4285
4613
  );
@@ -4730,10 +5058,59 @@ After the DNS record resolves, re-run recover with verificationId: "${res2.verif
4730
5058
  }
4731
5059
  if (args.action === "status") {
4732
5060
  const res2 = await ctx.client.getRecoveryStatus(verificationId);
5061
+ if (res2.readyToComplete) {
5062
+ const revokeArguments = {
5063
+ action: "complete",
5064
+ verificationId,
5065
+ preserveExistingCredentials: false,
5066
+ ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
5067
+ };
5068
+ const preserveArguments = {
5069
+ ...revokeArguments,
5070
+ preserveExistingCredentials: true
5071
+ };
5072
+ return decisionToolResult({
5073
+ resultCode: "domain_recovery_ready",
5074
+ 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.",
5075
+ data: {
5076
+ recovery: res2,
5077
+ credentialPolicyChoices: ["revoke_previous", "preserve_previous"]
5078
+ },
5079
+ prompt: "How should Sakupa handle the site credentials that existed before recovery?",
5080
+ options: [
5081
+ callToolDecisionOption({
5082
+ id: "revoke_previous_credentials",
5083
+ label: "Revoke all previous credentials",
5084
+ description: "Complete recovery with the new local credential and revoke every previous credential.",
5085
+ consequences: [
5086
+ "Old project folders and credential backups can no longer manage the site."
5087
+ ],
5088
+ tool: "recover",
5089
+ arguments: revokeArguments,
5090
+ reasonCode: "user_selected_secure_recovery"
5091
+ }),
5092
+ callToolDecisionOption({
5093
+ id: "preserve_previous_credentials",
5094
+ label: "Keep previous credentials valid",
5095
+ description: "Complete recovery with the new local credential without revoking existing credentials.",
5096
+ consequences: [
5097
+ "Any old project folder or leaked credential that still works retains site authority."
5098
+ ],
5099
+ tool: "recover",
5100
+ arguments: preserveArguments,
5101
+ reasonCode: "user_selected_credential_preservation"
5102
+ }),
5103
+ noActionDecisionOption({
5104
+ label: "Do not complete recovery yet",
5105
+ description: "Keep the verified recovery pending and change no credential."
5106
+ })
5107
+ ]
5108
+ });
5109
+ }
4733
5110
  return structuredToolResult({
4734
5111
  schemaVersion: 1,
4735
- outcome: res2.status === "expired" ? "expired" : res2.readyToComplete ? "completed" : "pending_provider",
4736
- resultCode: res2.status === "expired" ? "domain_recovery_expired" : res2.readyToComplete ? "domain_recovery_ready" : "domain_recovery_pending_dns",
5112
+ outcome: res2.status === "expired" ? "expired" : "pending_provider",
5113
+ resultCode: res2.status === "expired" ? "domain_recovery_expired" : "domain_recovery_pending_dns",
4737
5114
  summary: `DNS recovery verification status: ${res2.status}`,
4738
5115
  data: { recovery: res2 },
4739
5116
  nextActions: [
@@ -4744,8 +5121,8 @@ After the DNS record resolves, re-run recover with verificationId: "${res2.verif
4744
5121
  verificationId,
4745
5122
  ...args.outputDir !== void 0 ? { outputDir: args.outputDir } : {}
4746
5123
  },
4747
- allowed: res2.readyToComplete,
4748
- ...res2.readyToComplete ? {} : { reasonCode: res2.status }
5124
+ allowed: false,
5125
+ reasonCode: res2.status
4749
5126
  }
4750
5127
  ]
4751
5128
  });
@@ -4915,12 +5292,39 @@ Files: ${extracted.fileCount}; bytes: ${extracted.totalBytes}
4915
5292
  };
4916
5293
  if (args.confirmSubmit !== true) {
4917
5294
  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). ";
4918
- return textJson(
4919
- "bug_report_preview_ready",
4920
- `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}Then re-run report with confirmSubmit: true to submit.`,
4921
- payload,
4922
- "preview"
4923
- );
5295
+ const confirmation = { confirmSubmit: true };
5296
+ const confirmArguments = { ...args, ...confirmation };
5297
+ return decisionToolResult({
5298
+ resultCode: "bug_report_preview_ready",
5299
+ outcome: "preview",
5300
+ 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:
5301
+ ${JSON.stringify(payload, null, 2)}`,
5302
+ data: {
5303
+ result: payload,
5304
+ confirmation,
5305
+ confirmArguments,
5306
+ submitted: false
5307
+ },
5308
+ prompt: "Submit this exact sanitized bug report?",
5309
+ options: [
5310
+ callToolDecisionOption({
5311
+ id: "submit_bug_report",
5312
+ label: "Submit the reviewed report",
5313
+ description: "Submit exactly the sanitized payload shown above.",
5314
+ consequences: [
5315
+ args.contactEmail === void 0 ? "No contact email is attached." : "The provided contact email is attached for follow-up."
5316
+ ],
5317
+ tool: "report",
5318
+ arguments: confirmArguments,
5319
+ reasonCode: "explicit_bug_report_submission_confirmation"
5320
+ }),
5321
+ noActionDecisionOption({
5322
+ label: "Do not submit the report",
5323
+ description: "Keep the report local and send nothing to Sakupa."
5324
+ })
5325
+ ],
5326
+ legacyUserAction: { type: "confirm_in_mcp", provider: "sakupa" }
5327
+ });
4924
5328
  }
4925
5329
  const res = await baseCtx.client.reportBug(payload, site?.credential);
4926
5330
  return text(
@@ -5317,8 +5721,18 @@ function registerHelpTools(server, baseCtx) {
5317
5721
  schemaVersion: 1,
5318
5722
  outcome: "completed",
5319
5723
  resultCode: "help_overview",
5320
- 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. Use help topic:"terminology" for every site/credential distinction. On any failure call help with topic:"diagnose" before retrying, support or report.',
5321
- data: { tools: catalog, toolOrder: TOOL_TOPICS, terminology: HELP_TERMINOLOGY },
5724
+ 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.`,
5725
+ data: {
5726
+ tools: catalog,
5727
+ toolOrder: TOOL_TOPICS,
5728
+ terminology: HELP_TERMINOLOGY,
5729
+ decisionOptions: {
5730
+ selectionMode: "single",
5731
+ defaultOptionId: null,
5732
+ presentEveryOption: true,
5733
+ exactNextActionRequired: true
5734
+ }
5735
+ },
5322
5736
  nextActions: []
5323
5737
  });
5324
5738
  }
@@ -5477,9 +5891,7 @@ function registerCredentialTools(server, baseCtx) {
5477
5891
  const status = await ctx.client.getCredentialStatus(site.siteId, site.credential);
5478
5892
  const confirmation = { confirmed: true };
5479
5893
  if (args.confirmed !== true) {
5480
- return structuredToolResult({
5481
- schemaVersion: 1,
5482
- outcome: "waiting_user",
5894
+ return decisionToolResult({
5483
5895
  resultCode: "credential_rotation_confirmation_required",
5484
5896
  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.`,
5485
5897
  data: {
@@ -5492,20 +5904,26 @@ function registerCredentialTools(server, baseCtx) {
5492
5904
  previousCredentialsWillBeRevoked: true,
5493
5905
  optional: true
5494
5906
  },
5495
- userAction: {
5496
- type: "confirm_in_mcp",
5497
- provider: "sakupa",
5498
- expectedOutcome: "Generate one new local credential and revoke every previous credential for this site.",
5499
- resumeWith: { tool: "rotate", arguments: confirmation }
5500
- },
5501
- nextActions: [
5502
- {
5907
+ prompt: `Rotate the management credential for ${site.url ?? site.siteId}?`,
5908
+ options: [
5909
+ callToolDecisionOption({
5910
+ id: "rotate_credential",
5911
+ label: "Rotate the management credential",
5912
+ description: "Generate one new local credential and make it the only valid credential for this site.",
5913
+ consequences: [
5914
+ "Every previous credential is revoked, including copies in old folders and backups.",
5915
+ "The site URL and online content do not change."
5916
+ ],
5503
5917
  tool: "rotate",
5504
5918
  arguments: confirmation,
5505
- allowed: true,
5506
5919
  reasonCode: "explicit_credential_rotation_confirmation"
5507
- }
5508
- ]
5920
+ }),
5921
+ noActionDecisionOption({
5922
+ label: "Keep the current credential",
5923
+ description: "Do not rotate; deployment remains available with the current credential."
5924
+ })
5925
+ ],
5926
+ legacyUserAction: { type: "confirm_in_mcp", provider: "sakupa" }
5509
5927
  });
5510
5928
  }
5511
5929
  writeCredentialRotation(ctx.projectDir, {
@@ -5734,6 +6152,14 @@ Workflow:
5734
6152
  payment, refund and other customer-service requests. report is the LAST resort only when
5735
6153
  help explicitly recommends a product bug report, and submission still requires user review.
5736
6154
 
6155
+ Decision-options contract: when a result contains decision, present EVERY numbered option from the
6156
+ tool to the user and wait for their selection. No option is selected by default. Never choose from
6157
+ context, paraphrase a selection into different arguments, or invent another option. After the user
6158
+ selects, copy that option's exact nextAction. Legacy userAction and nextActions mirror the same
6159
+ choice for older clients; decision is the authoritative choice set. A type "none" option means call
6160
+ no tool and change nothing. Stripe-hosted links remain direct links because Stripe itself owns plan
6161
+ selection and confirmation.
6162
+
5737
6163
  Project directory contract: before the first deploy or a new recovery, initialize the intended
5738
6164
  project by calling init with NO path argument. init uses the IDE's exact MCP Root and creates the
5739
6165
  non-secret .sakupa/project.json directly there. The CLI command