Skip to content

Commit 4bfb9d7

Browse files
ARHAEEMclaude
andcommitted
fix(tunnel): make named-tunnel hostname changes take effect; use pnpm exec in release
Addresses both Codex review findings on PR #18 (verified before fixing): 1. Entering a new hostname for an already-configured named tunnel was silently ignored: named-create short-circuited on the existing config and cf-named.start() reads the YAML from disk, so the old hostname kept serving. named-create now reconfigures in place when the requested hostname differs — routes DNS to the existing tunnel uuid (new exported routeTunnelDns helper) and rewrites the managed YAML; same-hostname requests stay idempotent. The extension's tunnel:enable handler runs named-create as a pre-step whenever a cf-named hostname is supplied, aborting on real failures (e.g. DNS route rejected) instead of starting the tunnel on the previous hostname; setup- incomplete errors still fall through to the full setup wizard. 2. npx --no-install is not an install-prevention flag on npm 7+ — verified locally on npm 11 that a missing binary still hits the registry, which defeats the supply-chain pin in steps that hold publish tokens. Replaced with pnpm exec (verified to resolve the workspace-root pinned binaries from package subdirs and to fail closed when absent). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 40eca03 commit 4bfb9d7

4 files changed

Lines changed: 99 additions & 16 deletions

File tree

.github/workflows/release.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ jobs:
7171
LOCAL=$(node -p "require('./$PKG').version")
7272
7373
# Query VS Code Marketplace for the latest published version
74-
PUBLISHED=$(npx --no-install vsce show Nskha.airtable-formula --json 2>/dev/null \
74+
PUBLISHED=$(pnpm exec vsce show Nskha.airtable-formula --json 2>/dev/null \
7575
| node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{
7676
try{console.log(JSON.parse(d).versions[0].version)}
7777
catch{console.log('0.0.0')}
@@ -247,7 +247,7 @@ jobs:
247247
248248
# Package
249249
cd packages/extension
250-
npx --no-install vsce package --no-dependencies
250+
pnpm exec vsce package --no-dependencies
251251
VSIX=$(ls *.vsix | head -1)
252252
echo "file=packages/extension/${VSIX}" >> $GITHUB_OUTPUT
253253
echo "Packaged: ${VSIX}"
@@ -257,7 +257,7 @@ jobs:
257257
if: |
258258
!inputs.dry_run &&
259259
(inputs.target == 'extension' || inputs.target == 'both')
260-
run: npx --no-install vsce publish --packagePath "${{ steps.vsix.outputs.file }}"
260+
run: pnpm exec vsce publish --packagePath "${{ steps.vsix.outputs.file }}"
261261
env:
262262
VSCE_PAT: ${{ secrets.VSCE_PAT }}
263263

@@ -266,7 +266,7 @@ jobs:
266266
!inputs.dry_run &&
267267
(inputs.target == 'extension' || inputs.target == 'both')
268268
run: |
269-
npx --no-install ovsx publish "${{ steps.vsix.outputs.file }}" --pat "$OVSX_PAT"
269+
pnpm exec ovsx publish "${{ steps.vsix.outputs.file }}" --pat "$OVSX_PAT"
270270
271271
# Verify the version actually landed (Open VSX indexes asynchronously)
272272
VERSION="${{ steps.ext_version.outputs.next }}"

packages/extension/src/webview/DashboardProvider.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,38 @@ export class DashboardProvider implements vscode.WebviewViewProvider {
455455
authtoken = await this.context.secrets.get('airtable-formula.ngrok.authtoken') ?? undefined;
456456
}
457457
}
458+
459+
// cf-named with an explicit hostname: run named-create FIRST. The
460+
// daemon's enable-tunnel starts from the on-disk YAML, so without
461+
// this pre-step a changed hostname would be silently ignored and the
462+
// old one kept serving. named-create is idempotent for the same
463+
// hostname and reconfigures (route dns + YAML rewrite) for a new one.
464+
if (msg.provider === 'cf-named' && msg.domain) {
465+
try {
466+
const createResp = await fetch(`http://127.0.0.1:${status.port}/daemon/tunnel/named-create`, {
467+
method: 'POST',
468+
headers: { Authorization: `Bearer ${status.bearerToken}`, 'Content-Type': 'application/json' },
469+
body: JSON.stringify({ hostname: msg.domain }),
470+
signal: AbortSignal.timeout(60_000),
471+
});
472+
if (!createResp.ok) {
473+
const b = await createResp.json().catch(() => ({})) as Record<string, unknown>;
474+
const createErr = typeof b.error === 'string' ? b.error : `HTTP ${createResp.status}`;
475+
if (/not installed|login required|cert/i.test(createErr)) {
476+
// First-time setup missing — fall through; enable-tunnel's
477+
// error path routes into the full setup wizard below.
478+
} else {
479+
// Real failure (e.g. DNS route rejected). Abort rather than
480+
// silently starting the tunnel on the previous hostname.
481+
void vscode.window.showErrorMessage(`Tunnel hostname configuration failed: ${createErr}`);
482+
this.postResult(msg.id, false, createErr);
483+
await this.pushState();
484+
return;
485+
}
486+
}
487+
} catch { /* daemon unreachable — the enable call below surfaces it */ }
488+
}
489+
458490
const enableResp = await fetch(`http://127.0.0.1:${status.port}/daemon/enable-tunnel`, {
459491
method: 'POST',
460492
headers: {

packages/mcp-server/src/daemon/server.js

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
createNamedTunnel,
2525
writeTunnelConfig,
2626
readNamedTunnelConfig,
27+
routeTunnelDns,
2728
} from './tunnel-providers/cloudflared-named-setup.js';
2829
import { getTunnelBinaryPath } from './install-tunnel.js';
2930
import { homedir } from 'node:os';
@@ -461,10 +462,26 @@ export async function startDaemonServer(options = {}) {
461462
return;
462463
}
463464

464-
// Idempotent: if already configured, just return existing config
465+
// Idempotent for the SAME hostname; a DIFFERENT hostname reconfigures
466+
// the existing tunnel in place (route dns + rewrite managed YAML) —
467+
// same uuid and credentials, no new tunnel. Without this branch a new
468+
// hostname was silently ignored and the old one kept serving.
465469
const existing = readNamedTunnelConfig(options.configDir);
466470
if (existing) {
467-
res.json({ ok: true, uuid: existing.uuid, hostname: existing.hostname, configPath: existing.configPath, alreadyConfigured: true });
471+
if (existing.hostname === hostname) {
472+
res.json({ ok: true, uuid: existing.uuid, hostname: existing.hostname, configPath: existing.configPath, alreadyConfigured: true });
473+
return;
474+
}
475+
const binaryPath = getTunnelBinaryPath(options.configDir);
476+
await routeTunnelDns({ configDir: options.configDir, uuid: existing.uuid, hostname, binaryPath });
477+
const rewritten = writeTunnelConfig({
478+
configDir: options.configDir,
479+
uuid: existing.uuid,
480+
hostname,
481+
port: getBoundPort(httpServer),
482+
credentialsPath: existing.credentialsPath,
483+
});
484+
res.json({ ok: true, uuid: existing.uuid, hostname, configPath: rewritten.configPath, reconfigured: true });
468485
return;
469486
}
470487

packages/mcp-server/src/daemon/tunnel-providers/cloudflared-named-setup.js

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -301,21 +301,55 @@ export async function createNamedTunnel(options) {
301301
return { uuid, name: parsedName || options.name, credentialsPath };
302302
})
303303
.then(async (tunnel) => {
304-
const { code, stdout, stderr } = await runCapture(
305-
spawnImpl,
304+
await routeTunnelDns({
305+
configDir: options.configDir,
306+
uuid: tunnel.uuid,
307+
hostname: options.hostname,
306308
binaryPath,
307-
['tunnel', 'route', 'dns', tunnel.uuid, options.hostname],
308-
options.signal,
309-
);
310-
if (code !== 0) {
311-
throw new Error(
312-
`cloudflared route dns exited with code ${code}: ${stderr.trim() || stdout.trim()}`,
313-
);
314-
}
309+
signal: options.signal,
310+
dependencies: options.dependencies,
311+
});
315312
return tunnel;
316313
});
317314
}
318315

316+
/**
317+
* Route an additional/replacement hostname to an EXISTING tunnel
318+
* (`cloudflared tunnel route dns <uuid> <hostname>`). Used to reconfigure a
319+
* named tunnel's hostname without creating a new tunnel — the uuid and
320+
* credentials stay the same; only the DNS route (and our managed YAML,
321+
* rewritten by the caller) change.
322+
*
323+
* @param {{
324+
* configDir?: string,
325+
* uuid: string,
326+
* hostname: string,
327+
* binaryPath?: string,
328+
* signal?: AbortSignal,
329+
* dependencies?: { spawn?: typeof nodeSpawn },
330+
* }} options
331+
* @returns {Promise<void>}
332+
*/
333+
export async function routeTunnelDns(options) {
334+
if (!options.uuid) throw new Error('routeTunnelDns: uuid is required.');
335+
if (!options.hostname) throw new Error('routeTunnelDns: hostname is required.');
336+
const binaryPath = options.binaryPath ?? getTunnelBinaryPath(options.configDir);
337+
assertBinaryExists(binaryPath);
338+
const spawnImpl = options.dependencies?.spawn ?? nodeSpawn;
339+
340+
const { code, stdout, stderr } = await runCapture(
341+
spawnImpl,
342+
binaryPath,
343+
['tunnel', 'route', 'dns', options.uuid, options.hostname],
344+
options.signal,
345+
);
346+
if (code !== 0) {
347+
throw new Error(
348+
`cloudflared route dns exited with code ${code}: ${stderr.trim() || stdout.trim()}`,
349+
);
350+
}
351+
}
352+
319353
// ─────────────────────────────────────────────────────────────────────
320354
// cloudflared tunnel delete
321355
// ─────────────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)