@pipeshub-ai/mcp 2.3.1 → 2.3.2

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.
@@ -89,10 +89,147 @@ RUN npm install -g "@pipeshub-ai/mcp@${version}" \\
89
89
  # ----------------------------------------------------------------------------
90
90
  `;
91
91
 
92
+ /**
93
+ * Strip JSONC comments without mangling `//` inside string values.
94
+ *
95
+ * The naive version eats the second slash of `"https://example.com"` and turns
96
+ * a valid config into a parse error, which is a maddening failure to debug in
97
+ * a file the operator did not know we read.
98
+ */
99
+ export function stripJsonComments(src: string): string {
100
+ let out = "";
101
+ let inString = false;
102
+ let inLine = false;
103
+ let inBlock = false;
104
+ for (let i = 0; i < src.length; i++) {
105
+ const c = src[i];
106
+ const next = src[i + 1];
107
+ if (inLine) {
108
+ if (c === "\n") { inLine = false; out += c; }
109
+ continue;
110
+ }
111
+ if (inBlock) {
112
+ if (c === "*" && next === "/") { inBlock = false; i++; }
113
+ continue;
114
+ }
115
+ if (inString) {
116
+ out += c;
117
+ if (c === "\\") { out += next ?? ""; i++; continue; }
118
+ if (c === '"') inString = false;
119
+ continue;
120
+ }
121
+ if (c === '"') { inString = true; out += c; continue; }
122
+ if (c === "/" && next === "/") { inLine = true; i++; continue; }
123
+ if (c === "/" && next === "*") { inBlock = true; i++; continue; }
124
+ out += c;
125
+ }
126
+ return out;
127
+ }
128
+
129
+ /**
130
+ * Drop trailing commas before `}` or `]`. JSONC allows them and hand-edited
131
+ * configs collect them; `JSON.parse` does not. Without this a stray comma makes
132
+ * the config unreadable, which fails open and writes the Dockerfile we were
133
+ * trying not to write.
134
+ */
135
+ export function dropTrailingCommas(src: string): string {
136
+ let out = "";
137
+ let inString = false;
138
+ for (let i = 0; i < src.length; i++) {
139
+ const c = src[i];
140
+ if (inString) {
141
+ out += c;
142
+ if (c === "\\") { out += src[i + 1] ?? ""; i++; continue; }
143
+ if (c === '"') inString = false;
144
+ continue;
145
+ }
146
+ if (c === '"') { inString = true; out += c; continue; }
147
+ if (c === ",") {
148
+ let j = i + 1;
149
+ while (j < src.length && /\s/.test(src[j] as string)) j++;
150
+ if (src[j] === "}" || src[j] === "]") continue; // drop it
151
+ }
152
+ out += c;
153
+ }
154
+ return out;
155
+ }
156
+
157
+ export interface DeploymentShape {
158
+ target?: string | undefined;
159
+ backend?: string | undefined;
160
+ }
161
+
162
+ /**
163
+ * Read `target` and `sandbox.backend` from the operator's existing
164
+ * qm.config.jsonc. Returns null when there is no readable config — a fresh
165
+ * directory, or a file we cannot parse. Never throws: this only decides how
166
+ * much to scaffold, and a config we cannot read must not stop the scaffold.
167
+ */
168
+ export async function readDeploymentShape(
169
+ dest: string,
170
+ ): Promise<DeploymentShape | null> {
171
+ try {
172
+ const raw = await readFile(join(dest, "qm.config.jsonc"), "utf8");
173
+ const cfg = JSON.parse(dropTrailingCommas(stripJsonComments(raw))) as {
174
+ target?: unknown;
175
+ sandbox?: { backend?: unknown };
176
+ };
177
+ const target = typeof cfg.target === "string" ? cfg.target : undefined;
178
+ const backend = typeof cfg.sandbox?.backend === "string"
179
+ ? cfg.sandbox.backend
180
+ : undefined;
181
+ if (!target && !backend) return null;
182
+ return { target, backend };
183
+ } catch {
184
+ return null;
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Why a custom sandbox image cannot boot for this deployment, or null when it
190
+ * can. A reason rather than a boolean so the report can never describe a
191
+ * deployment as something it is not.
192
+ *
193
+ * `backend` is what decides where sandboxes run, not `target`: the CLI requires
194
+ * `"backend": "aws"` to have `target: "aws"` but not the reverse, so an AWS
195
+ * control plane running Sprites sandboxes is a supported and different thing
196
+ * from Lambda MicroVMs (`config.js:1106-1112`).
197
+ *
198
+ * This describes QM as it behaves today. If yc-software/qm#272 is fixed so
199
+ * Sprites boot a published image, the Sprites case here stops being true.
200
+ */
201
+ export type ImageSkipReason = "sprites-ignores-image" | "aws-microvm" | null;
202
+
203
+ export function imageSkipReason(shape: DeploymentShape | null): ImageSkipReason {
204
+ if (!shape) return null;
205
+ // Lambda MicroVMs have no install mechanism at all (qm#350).
206
+ if (shape.backend === "aws") return "aws-microvm";
207
+ // Sprites boot the stock base and ignore a published image (qm#272).
208
+ if (shape.backend === "sprites" || shape.target === "fly") {
209
+ return "sprites-ignores-image";
210
+ }
211
+ // An AWS target with no backend declared cannot run agents yet; skip rather
212
+ // than write a file whose fate depends on a choice not made.
213
+ if (shape.target === "aws") return "sprites-ignores-image";
214
+ return null;
215
+ }
216
+
217
+ /**
218
+ * Whether to scaffold a Dockerfile. Unknown shapes scaffold as before:
219
+ * guessing wrong in that direction removes a file someone needs.
220
+ */
221
+ export function customImageBoots(shape: DeploymentShape | null): boolean {
222
+ return imageSkipReason(shape) === null;
223
+ }
224
+
92
225
  export interface InitResult {
93
226
  written: string[];
94
227
  skipped: string[];
95
- dockerfileAction: "created" | "appended" | "manual";
228
+ dockerfileAction: "created" | "appended" | "manual" | "skipped-unusable";
229
+ shape: DeploymentShape | null;
230
+ skipReason: ImageSkipReason;
231
+ /** A Dockerfile an earlier version already wrote, on a deploy that cannot use it. */
232
+ staleDockerfile: boolean;
96
233
  version: string;
97
234
  configFragment: string;
98
235
  }
@@ -112,6 +249,7 @@ export async function initQm(
112
249
 
113
250
  const version = await packageVersion(root);
114
251
  const dest = resolve(targetDir);
252
+ const shape = await readDeploymentShape(dest);
115
253
  const written: string[] = [];
116
254
  const skipped: string[] = [];
117
255
 
@@ -133,7 +271,17 @@ export async function initQm(
133
271
  // has no PipesHub block yet, and otherwise leave it entirely alone.
134
272
  const dockerfile = join(dest, "sandbox", "Dockerfile");
135
273
  let dockerfileAction: InitResult["dockerfileAction"];
136
- if (!await exists(dockerfile)) {
274
+ const skipReason = imageSkipReason(shape);
275
+ // An earlier version wrote this unconditionally. Leaving it is not neutral:
276
+ // upcoming QM validation rejects it outright, so the operator needs telling.
277
+ // Not deleted here — it is their file and may carry their own build steps.
278
+ let staleDockerfile = false;
279
+ if (skipReason !== null) {
280
+ // Deliberately not written. The skill installs the CLI on first use, which
281
+ // is the path that actually runs on these deployments.
282
+ staleDockerfile = await exists(dockerfile);
283
+ dockerfileAction = "skipped-unusable";
284
+ } else if (!await exists(dockerfile)) {
137
285
  await copyFile(join(bundle, "sandbox", "Dockerfile"), dockerfile);
138
286
  // Restamp the pin so it matches the version actually installed.
139
287
  const body = await readFile(dockerfile, "utf8");
@@ -157,9 +305,8 @@ export async function initQm(
157
305
  }
158
306
  }
159
307
 
160
- // Do not swallow this. The fragment carries the `sandbox.env` block the
161
- // operator must merge in, so reporting a successful init without it leaves
162
- // them with a scaffold that cannot reach PipesHub and no sign of why.
308
+ // Do not swallow this. The fragment is part of the bundle contract; a
309
+ // successful init that cannot read it means the package was assembled wrong.
163
310
  const fragmentPath = join(bundle, "qm.config.fragment.jsonc");
164
311
  let configFragment: string;
165
312
  try {
@@ -171,7 +318,16 @@ export async function initQm(
171
318
  );
172
319
  }
173
320
 
174
- return { written, skipped, dockerfileAction, version, configFragment };
321
+ return {
322
+ written,
323
+ skipped,
324
+ dockerfileAction,
325
+ version,
326
+ configFragment,
327
+ shape,
328
+ skipReason,
329
+ staleDockerfile,
330
+ };
175
331
  }
176
332
 
177
333
  export function renderInitReport(dest: string, r: InitResult): string {
@@ -183,6 +339,37 @@ export function renderInitReport(dest: string, r: InitResult): string {
183
339
  for (const f of r.skipped) lines.push(` kept ${f} (already existed — use --force to replace)`);
184
340
  lines.push("");
185
341
 
342
+ if (r.dockerfileAction === "skipped-unusable") {
343
+ if (r.skipReason === "aws-microvm") {
344
+ lines.push("No sandbox/Dockerfile was written: AWS Lambda MicroVM sandboxes");
345
+ lines.push("have no way to install a binary, so the file could never run.");
346
+ } else {
347
+ lines.push("No sandbox/Dockerfile was written: Fly Sprites boot the stock");
348
+ lines.push("image and ignore a published one, so the file would look like the");
349
+ lines.push("install path while never running.");
350
+ }
351
+ lines.push("The skill installs the CLI on first use instead — that is the line");
352
+ lines.push("that actually executes, and it needs nothing from you.");
353
+ lines.push("");
354
+ }
355
+
356
+ if (r.staleDockerfile) {
357
+ lines.push("ACTION NEEDED: sandbox/Dockerfile already exists here, written by an");
358
+ lines.push("earlier version. This deployment cannot use it, and upcoming QM");
359
+ lines.push("validation rejects it rather than ignoring it — `qm check` will fail");
360
+ lines.push("with an error naming that file. Delete it, or remove the PipesHub");
361
+ lines.push("install block if the rest of it is yours.");
362
+ lines.push("");
363
+ }
364
+
365
+ if (r.skipReason === "aws-microvm") {
366
+ lines.push("Heads up on AWS: with Lambda MicroVM sandboxes the CLI cannot be");
367
+ lines.push("installed at all (yc-software/qm#350). The tool's guidance, network");
368
+ lines.push("allowlist, and approval rules still apply, but the binary will be");
369
+ lines.push("missing. The sprites backend is what this bundle is tested against.");
370
+ lines.push("");
371
+ }
372
+
186
373
  if (r.dockerfileAction === "appended") {
187
374
  lines.push("Appended the install block to your existing sandbox/Dockerfile.");
188
375
  } else if (r.dockerfileAction === "manual") {
@@ -195,20 +382,22 @@ export function renderInitReport(dest: string, r: InitResult): string {
195
382
  lines.push("");
196
383
  lines.push("Two things left to do:");
197
384
  lines.push("");
198
- lines.push("1. Set your PipesHub origin in qm.config.jsonc. It must be a PUBLIC");
199
- lines.push(" HTTPS address QM sandboxes do not run on your machine, so");
200
- lines.push(" localhost and LAN addresses are unreachable from them:");
385
+ lines.push("1. Set `egress` in sandbox/tools/pipeshub/tool.json to your PipesHub");
386
+ lines.push(" hostname (no scheme, no path). It must be reachable over public HTTPS");
387
+ lines.push(" QM sandboxes do not run on your machine, so localhost is unreachable.");
201
388
  lines.push("");
202
- lines.push(' "sandbox": {');
203
- lines.push(' "env": { "PIPESHUB_BASE_URL": "https://pipeshub.your-company.com" }');
204
- lines.push(" }");
389
+ lines.push("2. Each person adds two personal keychain entries (service: pipeshub):");
390
+ lines.push(" PIPESHUB_TOKEN → their PAT (never paste it into chat)");
391
+ lines.push(" PIPESHUB_BASE_URL → public HTTPS origin, no /mcp path");
392
+ lines.push(" Do NOT put a token in sandbox.secretEnv (org-wide). Do NOT rely on");
393
+ lines.push(" sandbox.env for the URL — it does not reach the sandbox.");
205
394
  lines.push("");
206
- lines.push(" Do NOT put anyone's token in sandbox.secretEnv — that is org-wide and");
207
- lines.push(" would hand one person's credential to everybody. Each person adds");
208
- lines.push(" their own to their own keychain (service: pipeshub, kind: env).");
395
+ lines.push("Then: qm check && qm up");
209
396
  lines.push("");
210
- lines.push("2. Set `egress` in sandbox/tools/pipeshub/tool.json to your hostname,");
211
- lines.push(" then run: qm check && qm sandbox publish && qm up");
397
+ if (r.dockerfileAction !== "skipped-unusable") {
398
+ lines.push("On Sprites, `qm sandbox publish` does not put pipeshub on PATH.");
399
+ lines.push("The skill installs the CLI on first use.");
400
+ }
212
401
  return lines.join("\n");
213
402
  }
214
403
 
@@ -244,13 +244,14 @@ async function run(argv: string[]): Promise<number> {
244
244
 
245
245
  if (origin === null) {
246
246
  // Report the missing credential too when both are absent. Otherwise a
247
- // person whose actual problem is "I never added my token" is told about an
248
- // admin-level setting, and goes looking in the wrong place.
247
+ // person whose actual problem is "I never added my token" is told about a
248
+ // missing URL, and goes looking in the wrong place.
249
249
  const alsoNoToken = token === null
250
250
  ? " Your PipesHub credential is also missing ($PIPESHUB_TOKEN is unset)."
251
251
  : "";
252
252
  throw new CliError(
253
- "PIPESHUB_BASE_URL is not set — an admin sets it once for the deployment."
253
+ "PIPESHUB_BASE_URL is not set — it must reach the sandbox as an env var "
254
+ + "(keychain or org service credential, not sandbox.env)."
254
255
  + alsoNoToken
255
256
  + " Run 'pipeshub auth connect-help' for the steps.",
256
257
  EXIT.USAGE,