qwen-code/packages/vscode-ide-companion/esbuild.js
易良 e49867a762
Some checks are pending
Qwen Code CI / Lint (push) Waiting to run
Qwen Code CI / Test (push) Blocked by required conditions
Qwen Code CI / Test-1 (push) Blocked by required conditions
Qwen Code CI / Test-2 (push) Blocked by required conditions
Qwen Code CI / Test-3 (push) Blocked by required conditions
Qwen Code CI / Test-4 (push) Blocked by required conditions
Qwen Code CI / Test-5 (push) Blocked by required conditions
Qwen Code CI / Test-6 (push) Blocked by required conditions
Qwen Code CI / Test-7 (push) Blocked by required conditions
Qwen Code CI / Test-8 (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
feat(vscode): replace OAuth with Coding Plan / API Key provider setup (#3398)
* refactor(core): move codingPlan constants from cli to core package

Extract Coding Plan region configs, model templates, and utility
functions into packages/core/src/constants/ so both CLI and VSCode
extension can import from a shared source of truth.

* refactor(cli): import codingPlan constants from core instead of local path

Update all CLI files to import CodingPlanRegion, CODING_PLAN_ENV_KEY,
and related utilities from @qwen-code/qwen-code-core, replacing the
local ../../constants/codingPlan.js imports.

* feat(vscode-ide-companion): replace login flow with provider setup via VSCode Settings

Replace the OAuth-based login command with a settings-driven provider
configuration flow. Users now configure Coding Plan or API Key providers
through VSCode Settings (qwen-code.*), which auto-syncs to
~/.qwen/settings.json.

- Rename login command to auth, opening VSCode Settings panel
- Add /auth2 interactive flow (QuickPick + InputBox)
- Add ProviderSetupForm onboarding component with inline config
- Add bidirectional sync between VSCode settings and ~/.qwen/settings.json
- Add settingsWriter service for direct settings.json read/write
- Add VSCode configuration schema (provider, apiKey, region, model, etc.)
- Update all login/session messages to use auth terminology

* refactor(vscode-ide-companion): rename auth2→auth, remove dead code, fix sync guard

- Rename auth2 to auth for all message types, handlers, and slash command
- Remove unused InfoBanner.tsx (128 lines, no references)
- Remove dead openProviderSettings handler (no callers)
- Remove redundant qwen-code.baseUrl VSCode setting (already in modelProviders)
- Replace unreliable setTimeout(500) sync guard with await Promise.all + finally
- Clean up old authHandler/setAuthHandler in favor of authInteractiveHandler

* refactor(vscode-ide-companion): remove dead VSCode Settings plumbing, simplify sync

- Remove qwen-code.modelProviders and qwen-code.model from package.json
  (model switching handled by chat UI's /model command, not VSCode Settings)
- Remove connectWithSettings message handler and plumbing
  (no webview component sends this message type)
- Remove handleConnectWithSettings method from WebViewProvider
- Simplify syncVSCodeSettingsToQwenConfig: only sync provider/apiKey/region
- Simplify syncQwenConfigToVSCodeSettings: only populate provider/apiKey/region
- Simplify QwenSettingsForVSCode interface: remove modelProviders and model
- Improve Onboarding UI: logo above card, better hierarchy, arrow icon on button

* fix(vscode-ide-companion): add missing vscode.workspace mock in test

Add onDidChangeConfiguration and getConfiguration to the vscode.workspace
mock in WebViewProvider.test.ts to fix CI test failures.

* fix(vscode-ide-companion): clean up stale coding plan state, add auth cancel handling, add tests

- Clear CODING_PLAN_ENV_KEY and codingPlan metadata when switching to api-key mode
- Add authCancelled notification when QuickPick/InputBox is dismissed
- ProviderSetupForm resets button state on authCancelled
- syncVSCodeSettingsToQwenConfig returns false for api-key mode (no-op)
- Fix Onboarding vertical centering (flex-1 min-h-0)
- Import from @qwen-code/qwen-code-core top-level instead of deep paths
- Add tests: settingsWriter, ProviderSetupForm cancel, AuthMessageHandler cancel, WebViewProvider sync
- Fix redundant ternary in pick() helper

* fix(vscode-ide-companion): force center Onboarding against parent override

Parent container uses [&>*]:items-start and [&>*]:text-left which overrides
Tailwind classes. Use inline style for alignItems/justifyContent/textAlign
to ensure Onboarding is always centered both horizontally and vertically.

* fix(vscode-ide-companion): bundle onboarding logo

* test(vscode-ide-companion): add png loader to bundle test

* fix(vscode-ide-companion/webview): avoid redundant auth sync reconnects

* fix(vscode-ide-companion/webview): fix auth sync typecheck

* docs(vscode-ide-companion): clarify auth restoration flow

* fix(webui): use bracket access for permission drawer plan content

* fix(vscode-ide-companion): guard authSuccess emission on actual auth state

After reconnecting in handleAuthInteractive, doInitializeAgentConnection
may return without throwing even when credentials are rejected (it sends
authState:false internally and returns early). Previously we unconditionally
emitted authSuccess, which contradicted the failed auth state and could
briefly show a success toast before re-opening the auth flow.

Now we check this.authState after reconnection: only emit authSuccess when
authentication actually succeeded, otherwise emit authError with a clear
credentials message.

Addresses review feedback from PR #3398.

* fix(vscode): address auth setup review feedback

* fix(vscode-ide-companion): guard concurrent auth flows, merge model providers

- Add authFlowActive mutex and autoAuthTimer to WebViewProvider so
  startInteractiveAuth() cancels the deferred auto-auth timeout,
  preventing two overlapping QuickPick flows from a single command.
- Change writeModelProvidersConfig() to merge new entries with existing
  non-target models (different envKey) instead of replacing the entire
  array, preserving unrelated providers like Coding Plan.

* fix(vscode-ide-companion): handle apiKey clearing as de-auth signal, fix auto-auth race, clean imports

- Add clearPersistedAuth() to settingsWriter.ts: removes selectedType,
  API keys, and coding plan metadata from ~/.qwen/settings.json
- Config change handler now detects empty apiKey with active agent and
  triggers de-auth: clear credentials, disconnect, update authState
- Auto-auth timer callback now properly sets authFlowActive mutex to
  prevent concurrent auth flows with startInteractiveAuth()
- Add test covering the de-auth path (clearPersistedAuth + disconnect)
- Fix import formatting in 7 CLI files (spacing, trailing commas)
- Remove duplicate comment in attemptAuthStateRestoration()

* fix(vscode-ide-companion): scope de-auth to apiKey changes only

The previous de-auth logic triggered on any auth-related setting change
where syncVSCodeSettingsToQwenConfig() returned false. For api-key
providers this is the normal path (interactive auth owns config), so
changing codingPlanRegion or provider would incorrectly wipe OPENAI_API_KEY.

Now the de-auth branch only fires when e.affectsConfiguration('qwen-code.apiKey')
is true AND the value is empty, preventing false-positive credential clearing.

Add regression test: non-apiKey setting changes on an api-key provider
must not trigger clearPersistedAuth or disconnect.

* fix(vscode-ide-companion): add disconnect to mock type to fix CI typecheck

The hoisted mockQwenAgentManagerInstances type was missing the
disconnect property, causing TS2339 in the de-auth test assertions.
2026-04-21 22:20:58 +08:00

240 lines
7 KiB
JavaScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import esbuild from 'esbuild';
import { createRequire } from 'node:module';
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { wasmLoader } from 'esbuild-plugin-wasm';
const production = process.argv.includes('--production');
const watch = process.argv.includes('--watch');
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, '..', '..');
const rootRequire = createRequire(resolve(repoRoot, 'package.json'));
/**
* @type {import('esbuild').Plugin}
*/
const esbuildProblemMatcherPlugin = {
name: 'esbuild-problem-matcher',
setup(build) {
const isWatchMode = build.initialOptions.watch;
build.onStart(() => {
if (isWatchMode) {
console.log('[watch] build started');
}
});
build.onEnd((result) => {
result.errors.forEach(({ text, location }) => {
console.error(`✘ [ERROR] ${text}`);
console.error(
` ${location.file}:${location.line}:${location.column}:`,
);
});
if (isWatchMode) {
console.log('[watch] build finished');
}
});
},
};
/**
* Ensure a single React copy in the webview bundle by resolving from repo root.
* Prevents mixing React 18/19 element types when nested node_modules exist.
* @type {import('esbuild').Plugin}
*/
const resolveFromRoot = (moduleId) => {
try {
return rootRequire.resolve(moduleId);
} catch {
return null;
}
};
const reactDedupPlugin = {
name: 'react-dedup',
setup(build) {
const aliases = [
'react',
'react-dom',
'react-dom/client',
'react/jsx-runtime',
'react/jsx-dev-runtime',
];
for (const alias of aliases) {
build.onResolve({ filter: new RegExp(`^${alias}$`) }, () => {
const resolved = resolveFromRoot(alias);
if (!resolved) {
return undefined;
}
return { path: resolved };
});
}
},
};
/**
* Resolve `*.wasm?binary` imports to embedded Uint8Array content.
* This keeps the companion bundle compatible with core's inline-WASM loader.
* @type {import('esbuild').Plugin}
*/
const wasmBinaryPlugin = {
name: 'wasm-binary',
setup(build) {
build.onResolve({ filter: /\.wasm\?binary$/ }, (args) => {
const specifier = args.path.replace(/\?binary$/, '');
const localRequire = createRequire(
resolve(args.resolveDir || repoRoot, '_dummy_.js'),
);
return {
path: localRequire.resolve(specifier),
namespace: 'wasm-binary',
};
});
build.onLoad({ filter: /.*/, namespace: 'wasm-binary' }, (args) => ({
contents: readFileSync(args.path),
loader: 'binary',
}));
},
};
/**
* @type {import('esbuild').Plugin}
*/
const cssInjectPlugin = {
name: 'css-inject',
setup(build) {
// Handle CSS files
build.onLoad({ filter: /\.css$/ }, async (args) => {
const fs = await import('fs');
const postcss = (await import('postcss')).default;
const tailwindcss = (await import('tailwindcss')).default;
const autoprefixer = (await import('autoprefixer')).default;
let css = await fs.promises.readFile(args.path, 'utf8');
// For styles.css, we need to resolve @import statements
if (args.path.endsWith('styles.css')) {
// Read all imported CSS files and inline them
const importRegex = /@import\s+'([^']+)';/g;
let match;
const basePath = args.path.substring(0, args.path.lastIndexOf('/'));
while ((match = importRegex.exec(css)) !== null) {
const importPath = match[1];
// Resolve relative paths correctly
let fullPath;
if (importPath.startsWith('./')) {
fullPath = basePath + importPath.substring(1);
} else if (importPath.startsWith('../')) {
fullPath = basePath + '/' + importPath;
} else {
fullPath = basePath + '/' + importPath;
}
try {
const importedCss = await fs.promises.readFile(fullPath, 'utf8');
css = css.replace(match[0], importedCss);
} catch (err) {
console.warn(`Could not import ${fullPath}: ${err.message}`);
}
}
}
// Process with PostCSS (Tailwind + Autoprefixer)
const result = await postcss([tailwindcss, autoprefixer]).process(css, {
from: args.path,
to: args.path,
});
return {
contents: `
const style = document.createElement('style');
style.textContent = ${JSON.stringify(result.css)};
document.head.appendChild(style);
`,
loader: 'js',
};
});
},
};
async function main() {
// Build extension
const extensionCtx = await esbuild.context({
entryPoints: ['src/extension.ts'],
bundle: true,
format: 'cjs',
minify: production,
sourcemap: !production,
sourcesContent: false,
platform: 'node',
outfile: 'dist/extension.cjs',
external: ['vscode'],
logLevel: 'silent',
banner: {
js: `const import_meta = { url: require('url').pathToFileURL(__filename).href };`,
},
define: {
'import.meta.url': 'import_meta.url',
},
plugins: [
wasmBinaryPlugin,
wasmLoader({ mode: 'embedded' }),
/* add to the end of plugins array */
esbuildProblemMatcherPlugin,
],
loader: { '.node': 'file' },
});
// Build webview
const webviewCtx = await esbuild.context({
entryPoints: ['src/webview/index.tsx'],
bundle: true,
format: 'iife',
minify: production,
sourcemap: !production,
sourcesContent: false,
platform: 'browser',
outfile: 'dist/webview.js',
// @qwen-code/qwen-code-core is a peer dependency of @qwen-code/webui.
// Since @qwen-code/webui marks it as external in its own Vite build, the
// browser bundle must also mark it external to avoid bundling Node.js-only
// modules (undici, @grpc/grpc-js, fs, stream, etc.) into the webview.
// The wildcard ensures deep sub-path imports (e.g.
// '@qwen-code/qwen-code-core/src/core/tokenLimits.js') are also excluded;
// without it esbuild only matches the bare package name and attempts to
// bundle the sub-path, which triggers "Dynamic require is not supported"
// at runtime in the browser.
external: ['@qwen-code/qwen-code-core', '@qwen-code/qwen-code-core/*'],
logLevel: 'silent',
plugins: [reactDedupPlugin, cssInjectPlugin, esbuildProblemMatcherPlugin],
jsx: 'automatic', // Use new JSX transform (React 17+)
loader: {
'.png': 'dataurl',
},
define: {
'process.env.NODE_ENV': production ? '"production"' : '"development"',
},
});
if (watch) {
await Promise.all([extensionCtx.watch(), webviewCtx.watch()]);
} else {
await Promise.all([extensionCtx.rebuild(), webviewCtx.rebuild()]);
await Promise.all([extensionCtx.dispose(), webviewCtx.dispose()]);
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});