mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-24 08:24:07 +00:00
* feat(cli): serve the Web Shell UI from `qwen serve` `qwen serve` now serves the built Web Shell SPA at its root on the same origin as the API, so a released binary exposes the browser terminal without the dev-only Vite server (the `npm run dev:daemon` two-process setup is unchanged for development). - New `webShellStatic.ts` mounts `/`, `/assets/*` and an SPA deep-link fallback. The fallback uses the same document-navigation discriminator as the Vite dev proxy so it never shadows API JSON 404s. - The static shell is registered BEFORE bearerAuth (a browser can't attach a token to a `<script>` subresource or an address-bar navigation; the shell carries no secrets and every API route stays token-gated). HTML responses set CSP + X-Frame-Options + Referrer-Policy + no-cache. - `--open` launches the browser at the daemon URL (with `?token=` when set) once the listener is up, guarded by `shouldLaunchBrowser()`. - `--no-web` opts out for an API-only daemon. - Bundle / npm publish / standalone packaging now ship `dist/web-shell/`. Missing assets degrade to API-only with a breadcrumb, never a hard fail. Tests: +6 cases in server.test.ts (root shell, assets, SPA fallback, non-navigation 404 passthrough, security headers, --no-web off). * fix(cli): address review on Web Shell serving Review fixes for #5392 (qwen-code-ci-bot): - [Critical] SPA fallback no longer shadows /health or /demo on non-loopback binds — those paths fall through to their own routes / bearerAuth instead of receiving index.html. - [Critical] --open trims the bearer token before putting it in the browser URL, matching runQwenServe's own trimming, so a trailing newline from `$(cat token.txt)` no longer makes every API call 401. - --open is wrapped in its own try/catch so a failed browser launch can't take down the already-listening daemon; it normalizes wildcard binds (0.0.0.0 / ::) to loopback, and only fires when the UI is actually mounted (new RunHandle.webShellMounted). - resolveWebShellDir() now requires BOTH index.html and assets/, so a partial build degrades to API-only instead of serving a shell whose chunks 404. - runQwenServe logs a positive "Web Shell UI served from <dir>" breadcrumb, and warns that on a non-loopback bind without --allow-origin the shell is read-only (same-origin POSTs are blocked by the CORS wall). - Document the --open token-in-process-list exposure in help text + a stderr note when a token is forwarded. - Tests: POST method guard, sec-fetch navigation signal, /health not shadowed, sendFile 500 path, plus isDocumentNavigation and resolveWebShellDir units. * fix(cli): harden Web Shell asset resolution and send-error logging Second-round review (claude /qreview on the initial commit): - resolveWebShellDir() now walks up from this module to find a sibling packages/web-shell/dist, covering the transpiled layouts the previous fixed `..` depth missed — per-package `tsc` output and the integration daemon harness (packages/cli/dist/index.js), which would otherwise resolve to nonexistent paths and silently run API-only. - sendFile failures are no longer silent: log the error (matching the /demo handler — previously the only 5xx path that emitted nothing) and res.end() a half-streamed response instead of leaving the client on a 200 with a partial body. The remaining comment (open-browser inside the boot try) was already fixed in2487c90, where the --open block gained its own try/catch. * fix(cli): pass --open token via URL fragment + add auth-contract tests Third-round review (qwen3.7-max /review): - --open now puts the token in the URL fragment (#token=) instead of a query param, and the Web Shell reads it from the fragment first (falling back to ?token= for the dev launcher / hand-built URLs). A fragment is never sent to the server, so the token stays out of access logs and Referer headers. It is still visible in the browser-launcher's argv, so the stderr note stays and a one-time-code exchange remains the real fix for multi-user hosts (follow-up). - Add a server test pinning the "shell served before bearerAuth, API still token-gated" contract (GET / → 200 without auth, /capabilities → 401 with a token set), plus front-end getDaemonToken fragment/query precedence tests. The token-trim comment in this pass was already addressed in2487c90. * fix(cli): read --open token from RunHandle.resolvedToken; doc + test polish Fourth-round review (qwen3.7-max /review), all suggestions: - --open now reads the server's resolved (trimmed) token from RunHandle.resolvedToken instead of re-deriving it from argv/env. Removes the duplicated QWEN_SERVER_TOKEN literal + trim logic and any drift risk; the browser token is by construction what the daemon authenticates against. - Simplify webShellMounted to !!webShellDir (serveWebShell===false already forces webShellDir to undefined, so the extra conjunct was dead). - Docs: the --open row now documents the #token= fragment transport (was ?token=) and why a fragment is used. - Tests: add removeDaemonTokenFromUrl coverage (strip from fragment / query / both, preserve non-token hash params, no-op when absent) and the missing afterEach import. * fix(cli): register Web Shell SPA fallback after API routes Fifth-round review (claude /qreview): - The SPA fallback no longer sits before bearerAuth. It now runs after every API route (just before the error handler), so authed routes — and their 401s — always win, and only genuine 404 misses fall through to the shell. A navigation with an attacker-controlled `Accept: text/html` to /capabilities (or /health on a non-loopback bind) no longer coaxes the 200 shell out of a gated endpoint, and the fragile exact-match /health,/demo denylist (which trailing-slash variants slipped past) is gone. registerWebShell is split into mountWebShellAssets (/, /assets — still pre-auth so a browser can load the shell + subresources without a header) and mountWebShellSpaFallback (post-auth). The contract test now sends Accept: text/html to /capabilities and asserts 401 — it would have been 200 before this change (the test was passing only because it omitted Accept). - verifyBundleArtifacts (the publish gate) now requires dist/web-shell, so a build that skipped the web-shell workspace (e.g. npm ci --ignore-scripts bypassing the root prepare) fails packaging loudly instead of silently shipping an API-only CLI whose GET / 404s. * fix(cli): return a clean 404 for missing Web Shell assets Sixth-round review (qwen3.7-max /review): A missing /assets/* (e.g. a stale hashed chunk after a redeploy renamed it) now returns 404 instead of falling through to the SPA fallback and answering a browser navigation with a 200 index.html. Implemented with an explicit /assets 404 handler after express.static rather than serve-static's `fallthrough: false` — the latter forwards a 404 error to the catch-all error handler, which would turn it into a 500. Test added. * test(cli): cover --open + Web Shell signals; add shell security headers Seventh-round review (qwen-code-ci-bot): - [Critical] Extract the --open browser-launch logic into the exported maybeOpenWebShellBrowser() and unit-test it: --open / webShellMounted / shouldLaunchBrowser gating, wildcard-host -> loopback rewrite, token in the URL fragment (not query), and the never-throws error catch. - [Critical] Assert RunHandle.webShellMounted (false under --no-web) and resolvedToken (trimmed / undefined) in runQwenServe.test.ts; also cover --web/--no-web and --open arg parsing. - Drop dead code: target.hostname === '::' is unreachable (Node's URL returns the IPv6 wildcard as '[::]', which is already handled). - Add defense-in-depth headers to the shell response: base-uri 'none' in the CSP (does not fall back to default-src), X-Content-Type-Options: nosniff, and a restrictive Permissions-Policy. - Add serve-debug-gated logging for /assets 404s and SPA-fallback hits so a white-screen shell / routing misconfig has a diagnostic trail. * fix(test): satisfy the Web Shell release gate in package-assets fixture Eighth-round review (claude /qreview) — this is the actual CI failure. The verifyBundleArtifacts Web Shell gate (requiring dist/web-shell, added in this PR) broke scripts/tests/package-assets.test.js, which merge-main pulled in: its createBundleArtifacts fixture only created cli.js / vendor / bundled, so preparePackage exited 1 at the new gate before the test's assertions ran — red on all three Test jobs. Add the web-shell artifacts (index.html + assets/) to the fixture. The gate itself is intentional (it stops an API-only package from shipping).
253 lines
7.6 KiB
JavaScript
253 lines
7.6 KiB
JavaScript
/**
|
|
* @license
|
|
* Copyright 2025 Qwen
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
/**
|
|
* Prepares the bundled CLI package for npm publishing
|
|
* This script adds publishing metadata (package.json, README, LICENSE) to dist/
|
|
* All runtime assets (cli.js, vendor/, *.sb) are already in dist/ from the bundle step
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
const defaultRootDir = path.resolve(__dirname, '..');
|
|
|
|
export function preparePackage({ rootDir = defaultRootDir } = {}) {
|
|
const distDir = path.join(rootDir, 'dist');
|
|
|
|
verifyBundleArtifacts(rootDir, distDir);
|
|
copyDocumentationFiles(rootDir, distDir);
|
|
copyLocales(rootDir, distDir);
|
|
copyExtensionExamples(rootDir, distDir);
|
|
writeDistPackageJson(rootDir, distDir);
|
|
printPackageStructure(distDir);
|
|
}
|
|
|
|
if (isDirectRun()) {
|
|
preparePackage();
|
|
}
|
|
|
|
function isDirectRun() {
|
|
return process.argv[1]
|
|
? fileURLToPath(import.meta.url) === path.resolve(process.argv[1])
|
|
: false;
|
|
}
|
|
|
|
function verifyBundleArtifacts(rootDir, distDir) {
|
|
const requiredPaths = [
|
|
path.join(distDir, 'cli.js'),
|
|
path.join(distDir, 'vendor'),
|
|
path.join(distDir, 'bundled', 'qc-helper', 'docs'),
|
|
// The Web Shell ships with the published package ("Web Shell out of the
|
|
// box"). Gate on it here so a build that skipped the web-shell workspace
|
|
// (e.g. `npm ci --ignore-scripts` bypassing the root `prepare`) fails
|
|
// loudly during packaging instead of silently publishing an API-only CLI
|
|
// whose `GET /` 404s. copy_bundle_assets.js stays warn-and-skip for
|
|
// --cli-only dev bundles; this is the release gate.
|
|
path.join(distDir, 'web-shell', 'index.html'),
|
|
path.join(distDir, 'web-shell', 'assets'),
|
|
];
|
|
|
|
if (!fs.existsSync(distDir)) {
|
|
console.error('Error: dist/ directory not found');
|
|
console.error('Please run "npm run bundle" first');
|
|
process.exit(1);
|
|
}
|
|
|
|
for (const requiredPath of requiredPaths) {
|
|
if (!fs.existsSync(requiredPath)) {
|
|
console.error(
|
|
`Error: Required package artifact not found: ${requiredPath}`,
|
|
);
|
|
console.error('Please run "npm run bundle" first');
|
|
process.exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
function copyDocumentationFiles(rootDir, distDir) {
|
|
console.log('Copying documentation files...');
|
|
const filesToCopy = ['README.md', 'LICENSE'];
|
|
for (const file of filesToCopy) {
|
|
const sourcePath = path.join(rootDir, file);
|
|
const destPath = path.join(distDir, file);
|
|
if (fs.existsSync(sourcePath)) {
|
|
fs.copyFileSync(sourcePath, destPath);
|
|
console.log(`Copied ${file}`);
|
|
} else {
|
|
console.warn(`Warning: ${file} not found at ${sourcePath}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function copyLocales(rootDir, distDir) {
|
|
console.log('Copying locales folder...');
|
|
const localesSourceDir = path.join(
|
|
rootDir,
|
|
'packages',
|
|
'cli',
|
|
'src',
|
|
'i18n',
|
|
'locales',
|
|
);
|
|
const localesDestDir = path.join(distDir, 'locales');
|
|
|
|
if (fs.existsSync(localesSourceDir)) {
|
|
copyRecursiveSync(localesSourceDir, localesDestDir);
|
|
console.log('Copied locales folder');
|
|
} else {
|
|
console.warn(`Warning: locales folder not found at ${localesSourceDir}`);
|
|
}
|
|
}
|
|
|
|
function copyExtensionExamples(rootDir, distDir) {
|
|
console.log('Copying extension examples folder...');
|
|
const extensionExamplesDir = path.join(
|
|
rootDir,
|
|
'packages',
|
|
'cli',
|
|
'src',
|
|
'commands',
|
|
'extensions',
|
|
'examples',
|
|
);
|
|
const extensionExamplesDestDir = path.join(distDir, 'examples');
|
|
|
|
if (fs.existsSync(extensionExamplesDir)) {
|
|
copyRecursiveSync(extensionExamplesDir, extensionExamplesDestDir);
|
|
console.log('Copied extension examples folder');
|
|
} else {
|
|
console.warn(
|
|
`Warning: extension examples folder not found at ${extensionExamplesDir}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function writeDistPackageJson(rootDir, distDir) {
|
|
console.log('Creating package.json for distribution...');
|
|
|
|
const cliEntryContent = `#!/usr/bin/env node
|
|
import { spawnSync } from 'node:child_process';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, join } from 'node:path';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const cliPath = join(__dirname, 'cli.js');
|
|
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
['--expose-gc', cliPath, ...process.argv.slice(2)],
|
|
{ stdio: 'inherit' },
|
|
);
|
|
|
|
if (result.signal) {
|
|
process.kill(process.pid, result.signal);
|
|
} else {
|
|
process.exit(result.status ?? 1);
|
|
}
|
|
`;
|
|
|
|
const cliEntryPath = path.join(distDir, 'cli-entry.js');
|
|
fs.writeFileSync(cliEntryPath, cliEntryContent, { mode: 0o755 });
|
|
console.log('Created dist cli-entry.js wrapper');
|
|
|
|
const rootPackageJson = JSON.parse(
|
|
fs.readFileSync(path.join(rootDir, 'package.json'), 'utf-8'),
|
|
);
|
|
|
|
const distPackageJson = {
|
|
name: rootPackageJson.name,
|
|
version: rootPackageJson.version,
|
|
description:
|
|
rootPackageJson.description || 'Qwen Code - AI-powered coding assistant',
|
|
repository: rootPackageJson.repository,
|
|
type: 'module',
|
|
main: 'cli.js',
|
|
bin: {
|
|
qwen: 'cli-entry.js',
|
|
},
|
|
files: [
|
|
'cli-entry.js',
|
|
'cli.js',
|
|
// Worker thread entry loaded by FzfWorkerHandle at runtime via
|
|
// `resolveBundleDir(import.meta.url)` + `path.join(dir, 'fzfWorker.js')`.
|
|
// Must ship in the tarball or the @-picker silently falls back to the
|
|
// in-thread AsyncFzf path on big workspaces in npm-installed CLIs.
|
|
'fzfWorker.js',
|
|
'chunks',
|
|
'vendor',
|
|
'*.sb',
|
|
'README.md',
|
|
'LICENSE',
|
|
'locales',
|
|
'examples',
|
|
'bundled',
|
|
'web-shell',
|
|
],
|
|
config: rootPackageJson.config,
|
|
dependencies: {},
|
|
optionalDependencies: {
|
|
'@lydell/node-pty': '1.2.0-beta.10',
|
|
'@lydell/node-pty-darwin-arm64': '1.2.0-beta.10',
|
|
'@lydell/node-pty-darwin-x64': '1.2.0-beta.10',
|
|
'@lydell/node-pty-linux-x64': '1.2.0-beta.10',
|
|
'@lydell/node-pty-win32-arm64': '1.2.0-beta.10',
|
|
'@lydell/node-pty-win32-x64': '1.2.0-beta.10',
|
|
'@teddyzhu/clipboard': '0.0.5',
|
|
'@teddyzhu/clipboard-darwin-arm64': '0.0.5',
|
|
'@teddyzhu/clipboard-darwin-x64': '0.0.5',
|
|
'@teddyzhu/clipboard-linux-x64-gnu': '0.0.5',
|
|
'@teddyzhu/clipboard-linux-arm64-gnu': '0.0.5',
|
|
'@teddyzhu/clipboard-win32-x64-msvc': '0.0.5',
|
|
'@teddyzhu/clipboard-win32-arm64-msvc': '0.0.5',
|
|
},
|
|
engines: rootPackageJson.engines,
|
|
};
|
|
|
|
fs.writeFileSync(
|
|
path.join(distDir, 'package.json'),
|
|
JSON.stringify(distPackageJson, null, 2) + '\n',
|
|
);
|
|
}
|
|
|
|
function printPackageStructure(distDir) {
|
|
console.log('\n✅ Package prepared for publishing at dist/');
|
|
console.log('\nPackage structure:');
|
|
// Use Node.js to list directory contents (cross-platform)
|
|
const distFiles = fs.readdirSync(distDir);
|
|
for (const file of distFiles) {
|
|
const filePath = path.join(distDir, file);
|
|
const stats = fs.statSync(filePath);
|
|
const size = stats.isDirectory() ? '<DIR>' : formatBytes(stats.size);
|
|
console.log(` ${size.padEnd(12)} ${file}`);
|
|
}
|
|
}
|
|
|
|
function copyRecursiveSync(src, dest) {
|
|
const stats = fs.statSync(src);
|
|
if (stats.isDirectory()) {
|
|
if (!fs.existsSync(dest)) {
|
|
fs.mkdirSync(dest, { recursive: true });
|
|
}
|
|
const entries = fs.readdirSync(src);
|
|
for (const entry of entries) {
|
|
const srcPath = path.join(src, entry);
|
|
const destPath = path.join(dest, entry);
|
|
copyRecursiveSync(srcPath, destPath);
|
|
}
|
|
} else {
|
|
fs.copyFileSync(src, dest);
|
|
}
|
|
}
|
|
|
|
function formatBytes(bytes) {
|
|
if (bytes < 1024) return `${bytes}B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
}
|