diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 074db0b..0393d68 100755 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,7 +44,7 @@ jobs: path: | dist/*.exe dist/*.msi - dist/latest*.yml + dist/*.yml dist/*.blockmap retention-days: 5 @@ -112,7 +112,7 @@ jobs: path: | dist/*.AppImage dist/*.AppImage.zsync - dist/latest*.yml + dist/*.yml dist/*.blockmap retention-days: 5 @@ -151,7 +151,7 @@ jobs: path: | dist/*.dmg dist/*.zip - dist/latest*.yml + dist/*.yml dist/*.blockmap retention-days: 5 @@ -171,6 +171,17 @@ jobs: id: get_version run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + - name: Validate tag matches package version + run: | + TAG_VERSION="${{ steps.get_version.outputs.VERSION }}" + TAG_VERSION="${TAG_VERSION#v}" + PACKAGE_VERSION="$(node -p "require('./package.json').version")" + + if [[ "${TAG_VERSION}" != "${PACKAGE_VERSION}" ]]; then + echo "::error::Tag version (${TAG_VERSION}) does not match package.json version (${PACKAGE_VERSION})." + exit 1 + fi + - name: Get Changelog Entry id: changelog_reader uses: mindsers/changelog-reader-action@32aa5b4c155d76c94e4ec883a223c947b2f02656 @@ -203,7 +214,8 @@ jobs: with: name: Release ${{ steps.get_version.outputs.VERSION }} body: ${{ steps.changelog_reader.outputs.changes || 'No changelog provided' }} - draft: true + draft: ${{ !contains(steps.get_version.outputs.VERSION, '-alpha') }} + prerelease: ${{ contains(steps.get_version.outputs.VERSION, '-alpha') }} files: | artifacts/** env: diff --git a/.gitignore b/.gitignore index 1284783..34de8d7 100755 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,6 @@ src/renderer/bundle.js # Test artifacts /test-results + +# Local planning notes (never commit) +docs/plan/ diff --git a/package-lock.json b/package-lock.json index 4947f89..e224651 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,10 @@ "dependencies": { "@headlessui/react": "^2.2.9", "@heroicons/react": "^2.1.1", + "@openfeature/server-sdk": "^1.20.1", "clsx": "^2.1.0", "electron-store": "^8.1.0", + "electron-updater": "^6.7.3", "minimatch": "^9.0.3", "path-browserify": "^1.0.1", "process": "^0.11.10", @@ -3803,6 +3805,25 @@ "node": ">=10" } }, + "node_modules/@openfeature/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@openfeature/core/-/core-1.9.1.tgz", + "integrity": "sha512-YySPtH4s/rKKnHRU0xyFGrqMU8XA+OIPNWDrlEFxE6DCVWCIrxE5YpiB94YD2jMFn6SSdA0cwQ8vLkCkl8lm8A==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/@openfeature/server-sdk": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/@openfeature/server-sdk/-/server-sdk-1.20.1.tgz", + "integrity": "sha512-jzz++kblADniuc7hONZ4DlRsoektCMDX5PPHoltn0hYWWw/Zm6sh3f7z5mGUX2XOikWKNVCtUQ3gWsdmIdHHXg==", + "license": "Apache-2.0", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@openfeature/core": "^1.9.0" + } + }, "node_modules/@parcel/watcher": { "version": "2.5.6", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", @@ -6969,7 +6990,6 @@ "version": "9.5.1", "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz", "integrity": "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==", - "dev": true, "license": "MIT", "dependencies": { "debug": "^4.3.4", @@ -8099,7 +8119,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -8741,6 +8760,52 @@ "dev": true, "license": "ISC" }, + "node_modules/electron-updater": { + "version": "6.7.3", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.7.3.tgz", + "integrity": "sha512-EgkT8Z9noqXKbwc3u5FkJA+r48jwZ5DTUiOkJMOTEEH//n5Am6wfQGz7nvSFEA2oIAMv9jRzn5JKTyWeSKOPgg==", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.5.1", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "~7.7.3", + "tiny-typed-emitter": "^2.1.0" + } + }, + "node_modules/electron-updater/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/electron-updater/node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/electron-updater/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/electron-winstaller": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", @@ -10140,7 +10205,6 @@ "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -10600,7 +10664,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/graphemer": { @@ -13101,7 +13164,6 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, "license": "MIT", "dependencies": { "universalify": "^2.0.0" @@ -13261,7 +13323,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", - "dev": true, "license": "MIT" }, "node_modules/lcid": { @@ -14085,6 +14146,19 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -14714,7 +14788,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -17211,7 +17284,6 @@ "version": "1.4.4", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" @@ -18674,6 +18746,12 @@ "semver": "bin/semver" } }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" + }, "node_modules/tinycolor2": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", @@ -19108,7 +19186,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 10.0.0" diff --git a/package.json b/package.json index 28ac626..8d68ac0 100644 --- a/package.json +++ b/package.json @@ -115,8 +115,10 @@ "dependencies": { "@headlessui/react": "^2.2.9", "@heroicons/react": "^2.1.1", + "@openfeature/server-sdk": "^1.20.1", "clsx": "^2.1.0", "electron-store": "^8.1.0", + "electron-updater": "^6.7.3", "minimatch": "^9.0.3", "path-browserify": "^1.0.1", "process": "^0.11.10", @@ -134,6 +136,7 @@ "@babel/preset-typescript": "^7.26.0", "@electron/rebuild": "^4.0.3", "@jest/globals": "^29.7.0", + "@tailwindcss/cli": "^4.1.18", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^14.3.1", "@testing-library/user-event": "^14.6.1", @@ -143,7 +146,6 @@ "@types/react-dom": "^18.3.5", "@typescript-eslint/eslint-plugin": "^8.24.0", "@typescript-eslint/parser": "^8.24.0", - "@tailwindcss/cli": "^4.1.18", "autoprefixer": "^10.4.17", "babel-jest": "^29.7.0", "babel-loader": "^10.0.0", diff --git a/scripts/sonar-scan.js b/scripts/sonar-scan.js index 6a58fc9..856ee7b 100755 --- a/scripts/sonar-scan.js +++ b/scripts/sonar-scan.js @@ -1,5 +1,5 @@ #!/usr/bin/env node -const { execSync, spawnSync } = require('child_process'); +const { execFileSync, spawnSync } = require('child_process'); const fs = require('fs'); const path = require('path'); const sonarqubeScanner = require('sonarqube-scanner'); @@ -14,6 +14,24 @@ function redactUrlForLogs(rawUrl) { } } +function resolveNpmCliPath() { + const npmExecPath = process.env.npm_execpath; + if (npmExecPath && path.isAbsolute(npmExecPath) && fs.existsSync(npmExecPath)) { + return npmExecPath; + } + + try { + const resolvedNpmCliPath = require.resolve('npm/bin/npm-cli.js'); + if (path.isAbsolute(resolvedNpmCliPath) && fs.existsSync(resolvedNpmCliPath)) { + return resolvedNpmCliPath; + } + } catch (_error) { + // Keep fallback behavior below. + } + + return null; +} + function parseEnvValue(rawValue) { const value = rawValue.trim(); if ( @@ -275,17 +293,34 @@ if (!sonarToken) { console.log('SONAR_TOKEN not set; attempting unauthenticated scan'); } -// Run code coverage if it doesn't exist yet +// Generate a fresh coverage report so Sonar never uses stale lcov data. +const shouldGenerateCoverage = process.env.SONAR_GENERATE_COVERAGE !== 'false'; const coveragePath = path.join(__dirname, '..', 'coverage', 'lcov.info'); -if (!fs.existsSync(coveragePath)) { - console.log('No coverage data found. Running tests with coverage...'); +if (shouldGenerateCoverage) { + const coverageDir = path.join(__dirname, '..', 'coverage'); + fs.rmSync(coverageDir, { recursive: true, force: true }); + console.log('Generating fresh Jest coverage report for SonarQube...'); try { - execSync('npm test -- --coverage', { stdio: 'inherit' }); + const npmCliPath = resolveNpmCliPath(); + if (!npmCliPath) { + console.error('Error: Unable to resolve npm CLI path for coverage generation.'); + process.exit(1); + } + + execFileSync(process.execPath, [npmCliPath, 'test', '--', '--coverage', '--runInBand'], { + stdio: 'inherit', + }); } catch (error) { - console.warn('Warning: Test coverage generation had issues, but continuing with scan.'); + console.error('Error: Failed to generate coverage report for SonarQube.'); + process.exit(1); } } +if (!fs.existsSync(coveragePath)) { + console.error(`Error: Coverage report not found at ${coveragePath}`); + process.exit(1); +} + // Read properties from sonar-project.properties const propertiesPath = path.join(__dirname, '..', 'sonar-project.properties'); const propertiesContent = fs.readFileSync(propertiesPath, 'utf8'); diff --git a/sonar-project.properties b/sonar-project.properties old mode 100755 new mode 100644 index 2af8049..9520cb9 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,5 +1,5 @@ # Project identification -sonar.projectKey=ai-code-prep +sonar.projectKey=ai-code-fusion sonar.projectName=Repository Code Fusion sonar.projectVersion=0.1.0 @@ -7,12 +7,19 @@ sonar.projectVersion=0.1.0 sonar.sources=src sonar.sourceEncoding=UTF-8 -# Excluded directories and files -sonar.exclusions=node_modules/**,dist/**,**/*.test.js,**/*.test.jsx,**/*.spec.js,**/*.spec.jsx,coverage/** +# Excluded directories and files (avoid noise from tests, fixtures, generated assets) +sonar.exclusions=node_modules/**,dist/**,coverage/**,tests/**,scripts/**,**/__tests__/**,**/*.test.js,**/*.test.jsx,**/*.test.ts,**/*.test.tsx,**/*.spec.js,**/*.spec.jsx,**/*.spec.ts,**/*.spec.tsx,src/renderer/bundle.js,src/renderer/bundle.js.map,src/renderer/bundle.js.LICENSE.txt,src/renderer/output.css,src/renderer/styles.css -# Test directories -sonar.tests=src/__tests__ -sonar.test.inclusions=**/*.test.js,**/*.test.jsx,**/*.spec.js,**/*.spec.jsx +# Test directories (kept for coverage mapping, excluded from issue noise) +sonar.tests=tests,src/__tests__ +sonar.test.inclusions=tests/**/*.test.{js,jsx,ts,tsx},tests/**/*.spec.{js,jsx,ts,tsx},src/**/__tests__/**/*.{js,jsx,ts,tsx},src/**/*.test.{js,jsx,ts,tsx},src/**/*.spec.{js,jsx,ts,tsx} # JavaScript configuration sonar.javascript.lcov.reportPaths=coverage/lcov.info + +# Temporary rule suppression: +# Jest currently fails to resolve `node:` core-module imports in this repo's runtime/tooling setup. +# Keep stable `fs/path` imports and suppress only this stylistic Node import rule. +sonar.issue.ignore.multicriteria=nodeCoreImportStyle +sonar.issue.ignore.multicriteria.nodeCoreImportStyle.ruleKey=typescript:S7772 +sonar.issue.ignore.multicriteria.nodeCoreImportStyle.resourceKey=src/**/* diff --git a/src/main/errors.ts b/src/main/errors.ts new file mode 100644 index 0000000..fcc1f5c --- /dev/null +++ b/src/main/errors.ts @@ -0,0 +1,23 @@ +export const getErrorMessage = (error: unknown): string => { + if (error instanceof Error) { + return error.message; + } + + if (typeof error === 'object' && error !== null) { + try { + return JSON.stringify(error); + } catch { + return '[unserializable error object]'; + } + } + + if (typeof error === 'string') { + return error; + } + + if (typeof error === 'number' || typeof error === 'boolean' || typeof error === 'bigint') { + return String(error); + } + + return 'Unknown error'; +}; diff --git a/src/main/feature-flags.ts b/src/main/feature-flags.ts new file mode 100644 index 0000000..6ca7790 --- /dev/null +++ b/src/main/feature-flags.ts @@ -0,0 +1,250 @@ +import { InMemoryProvider, OpenFeature } from '@openfeature/server-sdk'; +import type { UpdaterFlagOverrides } from '../types/ipc'; +import { getErrorMessage } from './errors'; + +const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']); + +const DEFAULT_FETCH_TIMEOUT_MS = 3000; + +export const UPDATER_FLAG_KEYS = { + enabled: 'updater.enabled', + checkOnStart: 'updater.checkOnStart', + owner: 'updater.ghOwner', + repo: 'updater.ghRepo', +} as const; + +type UpdaterFlagKey = (typeof UPDATER_FLAG_KEYS)[keyof typeof UPDATER_FLAG_KEYS]; + +type FlagConfiguration = Record< + string, + { + variants: Record; + defaultVariant: string; + disabled: boolean; + } +>; + +type FetchLike = (input: string, init?: RequestInit) => Promise; + +type RemoteFlagRecord = Record; + +const logFlagFetchWarning = (message: string, error?: unknown) => { + let suffix = ''; + if (error !== undefined) { + suffix = `: ${getErrorMessage(error)}`; + } + console.warn(`[updater-flags] ${message}${suffix}`); +}; + +const parseBoolean = (value: unknown): boolean | undefined => { + if (typeof value === 'boolean') { + return value; + } + + if (typeof value === 'string') { + return TRUE_VALUES.has(value.trim().toLowerCase()); + } + + return undefined; +}; + +const parseNonEmptyString = (value: unknown): string | undefined => { + if (typeof value !== 'string') { + return undefined; + } + + const normalized = value.trim(); + return normalized.length > 0 ? normalized : undefined; +}; + +export const readUpdaterFlagOverridesFromEnv = ( + env: NodeJS.ProcessEnv = process.env +): UpdaterFlagOverrides => { + return { + enabled: parseBoolean(env.UPDATER_ENABLED), + checkOnStart: parseBoolean(env.UPDATER_CHECK_ON_START), + owner: parseNonEmptyString(env.UPDATER_GH_OWNER), + repo: parseNonEmptyString(env.UPDATER_GH_REPO), + }; +}; + +const mapFlatRemoteFlags = (payload: RemoteFlagRecord): UpdaterFlagOverrides => { + return { + enabled: parseBoolean(payload[UPDATER_FLAG_KEYS.enabled]), + checkOnStart: parseBoolean(payload[UPDATER_FLAG_KEYS.checkOnStart]), + owner: parseNonEmptyString(payload[UPDATER_FLAG_KEYS.owner]), + repo: parseNonEmptyString(payload[UPDATER_FLAG_KEYS.repo]), + }; +}; + +const mapNestedRemoteFlags = (payload: RemoteFlagRecord): UpdaterFlagOverrides => { + const updaterSection = payload.updater; + if (!updaterSection || typeof updaterSection !== 'object') { + return {}; + } + + const updaterObject = updaterSection as RemoteFlagRecord; + return { + enabled: parseBoolean(updaterObject.enabled), + checkOnStart: parseBoolean(updaterObject.checkOnStart), + owner: parseNonEmptyString(updaterObject.ghOwner), + repo: parseNonEmptyString(updaterObject.ghRepo), + }; +}; + +export const readUpdaterFlagOverridesFromRemotePayload = ( + payload: unknown +): UpdaterFlagOverrides => { + if (!payload || typeof payload !== 'object') { + return {}; + } + + const recordPayload = payload as RemoteFlagRecord; + const flat = mapFlatRemoteFlags(recordPayload); + const nested = mapNestedRemoteFlags(recordPayload); + + return { + enabled: flat.enabled ?? nested.enabled, + checkOnStart: flat.checkOnStart ?? nested.checkOnStart, + owner: flat.owner ?? nested.owner, + repo: flat.repo ?? nested.repo, + }; +}; + +export const mergeUpdaterFlagOverrides = ( + remoteFlags: UpdaterFlagOverrides, + envFlags: UpdaterFlagOverrides +): UpdaterFlagOverrides => { + return { + enabled: envFlags.enabled ?? remoteFlags.enabled, + checkOnStart: envFlags.checkOnStart ?? remoteFlags.checkOnStart, + owner: envFlags.owner ?? remoteFlags.owner, + repo: envFlags.repo ?? remoteFlags.repo, + }; +}; + +const isRemoteFlagsUrlAllowed = (url: string): boolean => { + try { + const parsed = new URL(url); + if (parsed.protocol === 'https:') { + return true; + } + return parsed.protocol === 'http:' && parsed.hostname === 'localhost'; + } catch { + return false; + } +}; + +export const fetchRemoteUpdaterFlagOverrides = async ({ + url, + fetchFn = globalThis.fetch as FetchLike | undefined, + timeoutMs = DEFAULT_FETCH_TIMEOUT_MS, +}: { + url: string; + fetchFn?: FetchLike; + timeoutMs?: number; +}): Promise => { + if (!url || !isRemoteFlagsUrlAllowed(url) || !fetchFn) { + return {}; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + controller.abort(); + }, timeoutMs); + + try { + const response = await fetchFn(url, { + method: 'GET', + headers: { Accept: 'application/json' }, + cache: 'no-store', + signal: controller.signal, + }); + + if (!response.ok) { + logFlagFetchWarning(`Remote flags request returned status ${response.status}`); + return {}; + } + + const payload = await response.json(); + return readUpdaterFlagOverridesFromRemotePayload(payload); + } catch (error) { + logFlagFetchWarning('Remote flags request failed', error); + return {}; + } finally { + clearTimeout(timeoutId); + } +}; + +const addBooleanFlag = ( + flags: FlagConfiguration, + key: UpdaterFlagKey, + value: boolean | undefined +) => { + if (typeof value !== 'boolean') { + return; + } + + flags[key] = { + variants: { + enabled: true, + disabled: false, + }, + defaultVariant: value ? 'enabled' : 'disabled', + disabled: false, + }; +}; + +const addStringFlag = (flags: FlagConfiguration, key: UpdaterFlagKey, value: string | undefined) => { + if (!value) { + return; + } + + flags[key] = { + variants: { + value, + }, + defaultVariant: 'value', + disabled: false, + }; +}; + +const getValueOrUndefined = (details: { value: T; reason?: string }): T | undefined => { + return details.reason === 'ERROR' ? undefined : details.value; +}; + +export const initializeUpdaterFeatureFlags = async ({ + env = process.env, + fetchFn = globalThis.fetch as FetchLike | undefined, +}: { + env?: NodeJS.ProcessEnv; + fetchFn?: FetchLike; +} = {}): Promise => { + const envFlags = readUpdaterFlagOverridesFromEnv(env); + const remoteUrl = parseNonEmptyString(env.FEATURE_FLAGS_URL); + const remoteFlags = remoteUrl + ? await fetchRemoteUpdaterFlagOverrides({ url: remoteUrl, fetchFn }) + : {}; + const mergedFlags = mergeUpdaterFlagOverrides(remoteFlags, envFlags); + + const configuration: FlagConfiguration = {}; + addBooleanFlag(configuration, UPDATER_FLAG_KEYS.enabled, mergedFlags.enabled); + addBooleanFlag(configuration, UPDATER_FLAG_KEYS.checkOnStart, mergedFlags.checkOnStart); + addStringFlag(configuration, UPDATER_FLAG_KEYS.owner, mergedFlags.owner); + addStringFlag(configuration, UPDATER_FLAG_KEYS.repo, mergedFlags.repo); + + await OpenFeature.setProviderAndWait(new InMemoryProvider(configuration)); + + const client = OpenFeature.getClient('desktop-main'); + const enabledDetails = await client.getBooleanDetails(UPDATER_FLAG_KEYS.enabled, false); + const checkOnStartDetails = await client.getBooleanDetails(UPDATER_FLAG_KEYS.checkOnStart, false); + const ownerDetails = await client.getStringDetails(UPDATER_FLAG_KEYS.owner, ''); + const repoDetails = await client.getStringDetails(UPDATER_FLAG_KEYS.repo, ''); + + return { + enabled: getValueOrUndefined(enabledDetails), + checkOnStart: getValueOrUndefined(checkOnStartDetails), + owner: getValueOrUndefined(ownerDetails), + repo: getValueOrUndefined(repoDetails), + }; +}; diff --git a/src/main/index.ts b/src/main/index.ts index c62fb15..3a9bd61 100755 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,7 +1,12 @@ -import { app, BrowserWindow, dialog, ipcMain, protocol } from 'electron'; +import { app, BrowserWindow, dialog, ipcMain, net, protocol } from 'electron'; +import { autoUpdater } from 'electron-updater'; import fs from 'fs'; import path from 'path'; +import { pathToFileURL } from 'node:url'; import yaml from 'yaml'; +import { getErrorMessage } from './errors'; +import { initializeUpdaterFeatureFlags } from './feature-flags'; +import { createUpdaterService, resolveUpdaterRuntimeOptions } from './updater'; import { loadDefaultConfig } from '../utils/config-manager'; import { ContentProcessor } from '../utils/content-processor'; import { FileAnalyzer, isBinaryFile } from '../utils/file-analyzer'; @@ -37,10 +42,20 @@ const tokenCounter = new TokenCounter(); let mainWindow: BrowserWindow | null = null; let authorizedRootPath: string | null = null; +let updaterService = createUpdaterService( + autoUpdater, + resolveUpdaterRuntimeOptions({ + currentVersion: app.getVersion(), + platform: process.platform, + env: process.env, + }) +); + const APP_ROOT = path.resolve(__dirname, '../../..'); const RENDERER_INDEX_PATH = path.join(APP_ROOT, 'src', 'renderer', 'index.html'); const ASSETS_DIR = path.join(APP_ROOT, 'src', 'assets'); const PUBLIC_ASSETS_DIR = path.join(APP_ROOT, 'public', 'assets'); +const createForbiddenAssetResponse = (): Response => new Response('Forbidden', { status: 403 }); // Set environment const isDevelopment = process.env.NODE_ENV === 'development'; @@ -82,21 +97,74 @@ if (process.platform === 'win32') { app.setAppUserModelId('com.ai.code.fusion'); } -// Create window when Electron is ready -app.whenReady().then(() => { +const runStartupUpdateCheck = () => { + if (!updaterService.shouldCheckOnStart) { + return; + } + + void updaterService.checkForUpdates().then((result) => { + if (result.state === 'error') { + console.warn(`Startup update check failed: ${result.errorMessage}`); + } else if (result.state === 'update-available') { + console.info(`Update available: ${result.latestVersion ?? 'unknown version'}`); + } + }); +}; + +const initializeUpdater = async () => { + try { + const flagOverrides = await initializeUpdaterFeatureFlags({ env: process.env }); + updaterService = createUpdaterService( + autoUpdater, + resolveUpdaterRuntimeOptions({ + currentVersion: app.getVersion(), + platform: process.platform, + env: process.env, + flagOverrides, + }) + ); + } catch (error) { + console.warn(`Failed to initialize OpenFeature updater flags: ${getErrorMessage(error)}`); + } finally { + runStartupUpdateCheck(); + } +}; + +const bootstrapApp = async () => { + await app.whenReady(); + // Register assets protocol - protocol.registerFileProtocol('assets', (request, callback) => { - const url = request.url.replace('assets://', ''); - const assetPath = path.normalize(path.join(PUBLIC_ASSETS_DIR, url)); - if (!isPathWithinRoot(PUBLIC_ASSETS_DIR, assetPath)) { - callback({ error: -6 }); - return; + protocol.handle('assets', async (request) => { + try { + const requestUrl = new URL(request.url); + const hostSegment = decodeURIComponent(requestUrl.hostname); + const pathSegment = requestUrl.pathname.replace(/^\/+/, ''); + const relativeAssetPath = decodeURIComponent( + [hostSegment, pathSegment].filter((segment) => segment.length > 0).join('/') + ); + const assetPath = path.normalize(path.join(PUBLIC_ASSETS_DIR, relativeAssetPath)); + + if (!isPathWithinRoot(PUBLIC_ASSETS_DIR, assetPath)) { + return createForbiddenAssetResponse(); + } + + try { + return await net.fetch(pathToFileURL(assetPath).toString()); + } catch (error) { + console.warn(`Failed to load asset from assets protocol: ${getErrorMessage(error)}`); + return new Response('Not Found', { status: 404 }); + } + } catch (error) { + console.warn(`Rejected malformed assets protocol request: ${getErrorMessage(error)}`); + return createForbiddenAssetResponse(); } - callback({ path: assetPath }); }); - void createWindow(); -}); + await createWindow(); + await initializeUpdater(); +}; + +void bootstrapApp(); // Quit when all windows are closed app.on('window-all-closed', () => { @@ -107,10 +175,18 @@ app.on('window-all-closed', () => { app.on('activate', () => { if (mainWindow === null) { - createWindow(); + void createWindow(); } }); +ipcMain.handle('updater:getStatus', () => { + return updaterService.getStatus(); +}); + +ipcMain.handle('updater:check', async () => { + return updaterService.checkForUpdates(); +}); + // IPC Event Handlers type FilterPatternBundle = string[] & { includePatterns?: string[]; includeExtensions?: string[] }; @@ -409,7 +485,7 @@ function generateTreeView(filesInfo: FileInfo[]): string { } const pathTree: PathTree = {}; sortedFiles.forEach((file) => { - if (!file || !file.path) return; + if (!file?.path) return; const parts = file.path.split('/'); let currentLevel: PathTree = pathTree; @@ -452,8 +528,106 @@ function generateTreeView(filesInfo: FileInfo[]): string { return printTree(pathTree); } -const getErrorMessage = (error: unknown): string => - error instanceof Error ? error.message : String(error); +type RepositoryProcessingOptions = { + showTokenCount: boolean; + includeTreeView: boolean; + exportFormat: ReturnType; +}; + +type ProcessedRepositoryFileResult = { + content: string; + tokenCount: number; +} | null; + +const resolveRepositoryProcessingOptions = ( + options: ProcessRepositoryOptions['options'] = {} +): RepositoryProcessingOptions => ({ + showTokenCount: options.showTokenCount !== false, + includeTreeView: options.includeTreeView === true, + exportFormat: normalizeExportFormat(options.exportFormat), +}); + +const buildRepositoryHeader = ( + processingOptions: RepositoryProcessingOptions, + treeView: string | undefined, + filesInfo: FileInfo[] +): string => { + let header = + processingOptions.exportFormat === 'xml' + ? '\n\n' + : '# Repository Content\n\n'; + + if (processingOptions.includeTreeView) { + const resolvedTreeView = treeView || generateTreeView(filesInfo); + if (processingOptions.exportFormat === 'xml') { + header += `${wrapXmlCdata(resolvedTreeView)}\n`; + } else { + header += '## File Structure\n\n'; + header += '```\n'; + header += resolvedTreeView; + header += '```\n\n'; + } + } + + if (processingOptions.exportFormat === 'markdown' && processingOptions.includeTreeView) { + header += '## File Contents\n\n'; + } + + if (processingOptions.exportFormat === 'xml') { + header += '\n'; + } + + return header; +}; + +const processRepositoryFile = ( + authorizedProcessRoot: string, + fileInfo: FileInfo, + contentProcessor: ContentProcessor, + processingOptions: RepositoryProcessingOptions +): ProcessedRepositoryFileResult => { + const filePath = fileInfo.path; + const tokenCount = normalizeTokenCount(fileInfo.tokens); + const fullPath = path.resolve(authorizedProcessRoot, filePath); + + if (!isPathWithinRoot(authorizedProcessRoot, fullPath)) { + console.warn(`Skipping file outside root directory: ${filePath}`); + return null; + } + + if (!fs.existsSync(fullPath)) { + console.warn(`File not found: ${filePath}`); + return null; + } + + const content = contentProcessor.processFile(fullPath, filePath, { + exportFormat: processingOptions.exportFormat, + showTokenCount: processingOptions.showTokenCount, + tokenCount, + }); + if (!content) { + return null; + } + + return { content, tokenCount }; +}; + +const buildRepositoryFooter = ( + processingOptions: RepositoryProcessingOptions, + summary: { totalTokens: number; processedFiles: number; skippedFiles: number } +): string => { + if (processingOptions.exportFormat === 'xml') { + return ( + '\n' + + `\n` + + '\n' + ); + } + + return '\n--END--\n'; +}; // Process repository ipcMain.handle( @@ -470,102 +644,49 @@ ipcMain.handle( const tokenCounter = new TokenCounter(); const contentProcessor = new ContentProcessor(tokenCounter); - - // Ensure options is an object with default values if missing - const processingOptions = { - showTokenCount: options.showTokenCount !== false, // Default to true if not explicitly false - includeTreeView: options.includeTreeView === true, - exportFormat: normalizeExportFormat(options.exportFormat), - }; + const processingOptions = resolveRepositoryProcessingOptions(options); console.log('Processing with options:', processingOptions); - let processedContent = ''; - - if (processingOptions.exportFormat === 'xml') { - processedContent += '\n'; - processedContent += '\n'; - } else { - processedContent += '# Repository Content\n\n'; - } - - // Add tree view if requested in options, whether provided or not - if (processingOptions.includeTreeView) { - const resolvedTreeView = treeView || generateTreeView(filesInfo); - if (processingOptions.exportFormat === 'xml') { - processedContent += `${wrapXmlCdata(resolvedTreeView)}\n`; - } else { - processedContent += '## File Structure\n\n'; - processedContent += '```\n'; - processedContent += resolvedTreeView; - processedContent += '```\n\n'; - } - } - - if (processingOptions.exportFormat === 'markdown' && processingOptions.includeTreeView) { - processedContent += '## File Contents\n\n'; - } - - if (processingOptions.exportFormat === 'xml') { - processedContent += '\n'; - } + const normalizedFilesInfo = filesInfo ?? []; + let processedContent = buildRepositoryHeader(processingOptions, treeView, normalizedFilesInfo); let totalTokens = 0; let processedFiles = 0; let skippedFiles = 0; - for (const fileInfo of filesInfo ?? []) { - try { - if (!fileInfo || !fileInfo.path) { - console.warn('Skipping invalid file info entry'); - skippedFiles++; - continue; - } - - const filePath = fileInfo.path; - const tokenCount = normalizeTokenCount(fileInfo.tokens); - - // Resolve and validate against root path to prevent traversal and prefix bypasses. - const fullPath = path.resolve(authorizedProcessRoot, filePath); + for (const fileInfo of normalizedFilesInfo) { + if (!fileInfo?.path) { + console.warn('Skipping invalid file info entry'); + skippedFiles++; + continue; + } - if (!isPathWithinRoot(authorizedProcessRoot, fullPath)) { - console.warn(`Skipping file outside root directory: ${filePath}`); + try { + const processedFile = processRepositoryFile( + authorizedProcessRoot, + fileInfo, + contentProcessor, + processingOptions + ); + if (!processedFile) { skippedFiles++; continue; } - if (fs.existsSync(fullPath)) { - const content = contentProcessor.processFile(fullPath, filePath, { - exportFormat: processingOptions.exportFormat, - showTokenCount: processingOptions.showTokenCount, - tokenCount, - }); - - if (content) { - processedContent += content; - totalTokens += tokenCount; - processedFiles++; - } - } else { - console.warn(`File not found: ${filePath}`); - skippedFiles++; - } + processedContent += processedFile.content; + totalTokens += processedFile.tokenCount; + processedFiles++; } catch (error) { console.warn(`Failed to process file: ${getErrorMessage(error)}`); skippedFiles++; } } - - if (processingOptions.exportFormat === 'xml') { - processedContent += '\n'; - processedContent += - `\n`; - processedContent += '\n'; - } else { - processedContent += '\n--END--\n'; - } + processedContent += buildRepositoryFooter(processingOptions, { + totalTokens, + processedFiles, + skippedFiles, + }); return { content: processedContent, @@ -573,7 +694,7 @@ ipcMain.handle( totalTokens, processedFiles, skippedFiles, - filesInfo: filesInfo, // Add filesInfo to the response + filesInfo: normalizedFilesInfo, }; } catch (error) { console.error('Error processing repository:', error); diff --git a/src/main/preload.ts b/src/main/preload.ts index 49a3d49..cee39fa 100755 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -9,6 +9,8 @@ import type { ProcessRepositoryOptions, ProcessRepositoryResult, SaveFileOptions, + UpdateCheckResult, + UpdaterStatus, } from '../types/ipc'; type DevUtils = { @@ -53,6 +55,8 @@ const electronAPI: ElectronApi = { ipcRenderer.invoke('assets:getPath', assetName) as Promise, countFilesTokens: (options: CountFilesTokensOptions) => ipcRenderer.invoke('tokens:countFiles', options) as Promise, + getUpdaterStatus: () => ipcRenderer.invoke('updater:getStatus') as Promise, + checkForUpdates: () => ipcRenderer.invoke('updater:check') as Promise, }; contextBridge.exposeInMainWorld('devUtils', devUtils); diff --git a/src/main/updater.ts b/src/main/updater.ts new file mode 100644 index 0000000..3f1a6a9 --- /dev/null +++ b/src/main/updater.ts @@ -0,0 +1,178 @@ +import type { AppUpdater } from 'electron-updater'; +import type { + UpdateCheckResult, + UpdaterChannel, + UpdaterFlagOverrides, + UpdaterStatus, +} from '../types/ipc'; +import { getErrorMessage } from './errors'; + +export interface UpdaterRuntimeOptions extends UpdaterStatus { + checkOnStart: boolean; +} + +const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']); + +export const parseBooleanEnv = (value: string | undefined): boolean | undefined => { + if (!value) { + return undefined; + } + return TRUE_VALUES.has(value.trim().toLowerCase()); +}; + +export const isAlphaVersion = (version: string): boolean => { + if (typeof version !== 'string') { + return false; + } + return version.toLowerCase().includes('-alpha'); +}; + +export const resolveUpdaterChannel = (version: string): UpdaterChannel => { + return isAlphaVersion(version) ? 'alpha' : 'stable'; +}; + +export const isUpdaterPlatformSupported = (platform: NodeJS.Platform): boolean => { + return platform === 'win32' || platform === 'darwin'; +}; + +export const resolveUpdaterRuntimeOptions = ({ + currentVersion, + platform, + env = process.env, + flagOverrides = {}, +}: { + currentVersion: string; + platform: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + flagOverrides?: UpdaterFlagOverrides; +}): UpdaterRuntimeOptions => { + const channel = resolveUpdaterChannel(currentVersion); + const allowPrerelease = channel === 'alpha'; + const platformSupported = isUpdaterPlatformSupported(platform); + + const enabledOverride = flagOverrides.enabled ?? parseBooleanEnv(env.UPDATER_ENABLED); + const enabledByDefault = env.NODE_ENV !== 'development'; + const enabled = platformSupported && (enabledOverride ?? enabledByDefault); + + const checkOnStart = + flagOverrides.checkOnStart ?? parseBooleanEnv(env.UPDATER_CHECK_ON_START) ?? false; + const owner = flagOverrides.owner || env.UPDATER_GH_OWNER || 'codingworkflow'; + const repo = flagOverrides.repo || env.UPDATER_GH_REPO || 'ai-code-fusion'; + + let reason: string | undefined; + if (!platformSupported) { + reason = `Updater is disabled on unsupported platform: ${platform}`; + } else if (!enabled) { + reason = 'Updater is disabled by configuration'; + } + + return { + enabled, + platformSupported, + channel, + allowPrerelease, + currentVersion, + owner, + repo, + checkOnStart, + reason, + }; +}; + +type UpdateInfoLike = { + version?: string; + releaseName?: string; +}; + +type UpdateCheckLike = { + updateInfo?: UpdateInfoLike; +}; + +type UpdaterClient = Pick< + AppUpdater, + 'checkForUpdates' | 'setFeedURL' | 'allowPrerelease' | 'autoDownload' | 'autoInstallOnAppQuit' +> & { + channel?: string; +}; + +export const createUpdaterService = ( + updaterClient: UpdaterClient, + runtimeOptions: UpdaterRuntimeOptions +) => { + let configured = false; + + const baseStatus: UpdaterStatus = { + enabled: runtimeOptions.enabled, + platformSupported: runtimeOptions.platformSupported, + channel: runtimeOptions.channel, + allowPrerelease: runtimeOptions.allowPrerelease, + currentVersion: runtimeOptions.currentVersion, + owner: runtimeOptions.owner, + repo: runtimeOptions.repo, + reason: runtimeOptions.reason, + }; + + const configure = () => { + if (configured || !runtimeOptions.enabled) { + return; + } + + updaterClient.autoDownload = false; + updaterClient.autoInstallOnAppQuit = true; + updaterClient.allowPrerelease = runtimeOptions.allowPrerelease; + updaterClient.channel = runtimeOptions.channel; + + updaterClient.setFeedURL({ + provider: 'github', + owner: runtimeOptions.owner, + repo: runtimeOptions.repo, + }); + + configured = true; + }; + + const getStatus = (): UpdaterStatus => ({ ...baseStatus }); + + const checkForUpdates = async (): Promise => { + if (!runtimeOptions.enabled) { + return { + ...baseStatus, + state: 'disabled', + updateAvailable: false, + }; + } + + configure(); + + try { + const checkResult = (await updaterClient.checkForUpdates()) as UpdateCheckLike | null; + const updateInfo = checkResult?.updateInfo || {}; + const latestVersion = updateInfo.version; + const updateAvailable = + typeof latestVersion === 'string' && + latestVersion.length > 0 && + latestVersion !== runtimeOptions.currentVersion; + + return { + ...baseStatus, + state: updateAvailable ? 'update-available' : 'up-to-date', + updateAvailable, + latestVersion, + releaseName: updateInfo.releaseName, + }; + } catch (error) { + return { + ...baseStatus, + state: 'error', + updateAvailable: false, + errorMessage: getErrorMessage(error), + }; + } + }; + + return { + getStatus, + checkForUpdates, + shouldCheckOnStart: runtimeOptions.enabled && runtimeOptions.checkOnStart, + }; +}; diff --git a/src/renderer/components/App.tsx b/src/renderer/components/App.tsx index 31712da..432254b 100755 --- a/src/renderer/components/App.tsx +++ b/src/renderer/components/App.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import TabBar from './TabBar'; import SourceTab from './SourceTab'; import ConfigTab from './ConfigTab'; @@ -18,8 +18,27 @@ import type { // Helper function to ensure consistent error handling const ensureError = (error: unknown): Error => { - if (error instanceof Error) return error; - return new Error(String(error)); + if (error instanceof Error) { + return error; + } + + if (typeof error === 'string') { + return new Error(error); + } + + if (typeof error === 'number' || typeof error === 'boolean' || typeof error === 'bigint') { + return new Error(String(error)); + } + + if (typeof error === 'object' && error !== null) { + try { + return new Error(JSON.stringify(error)); + } catch { + return new Error('Unknown error'); + } + } + + return new Error('Unknown error'); }; type ProcessingOptions = { @@ -34,7 +53,7 @@ const App = () => { const [directoryTree, setDirectoryTree] = useState([]); const [selectedFiles, setSelectedFiles] = useState([]); const [selectedFolders, setSelectedFolders] = useState([]); - const [, setAnalysisResult] = useState(null); + const analysisResultRef = useRef(null); const [processedResult, setProcessedResult] = useState(null); const [processingOptions, setProcessingOptions] = useState({ showTokenCount: true, @@ -43,6 +62,7 @@ const App = () => { }); // Load config from localStorage or via API, no fallbacks const [configContent, setConfigContent] = useState('# Loading configuration...'); + const appWindow = globalThis as Window & typeof globalThis; // Load config from localStorage or default config useEffect(() => { @@ -50,9 +70,9 @@ const App = () => { const savedConfig = localStorage.getItem('configContent'); if (savedConfig) { setConfigContent(savedConfig); - } else if (window.electronAPI?.getDefaultConfig) { + } else if (appWindow.electronAPI?.getDefaultConfig) { // Otherwise load from the main process - window.electronAPI + appWindow.electronAPI .getDefaultConfig?.() .then((defaultConfig) => { if (defaultConfig) { @@ -70,8 +90,8 @@ const App = () => { if (savedRootPath) { setRootPath(savedRootPath); // Load directory tree for the saved path - if (window.electronAPI?.getDirectoryTree) { - window.electronAPI + if (appWindow.electronAPI?.getDirectoryTree) { + appWindow.electronAPI .getDirectoryTree?.(savedRootPath, localStorage.getItem('configContent')) .then((tree) => { setDirectoryTree(tree ?? []); @@ -94,7 +114,7 @@ const App = () => { }; // Add event listener for localStorage changes - window.addEventListener('storage', handleStorageChange); + appWindow.addEventListener('storage', handleStorageChange); // Create an interval to check localStorage directly (for cross-component updates) const pathSyncInterval = setInterval(() => { @@ -106,10 +126,10 @@ const App = () => { // Cleanup return () => { - window.removeEventListener('storage', handleStorageChange); + appWindow.removeEventListener('storage', handleStorageChange); clearInterval(pathSyncInterval); }; - }, [rootPath]); + }, [rootPath, appWindow]); // Whenever configContent changes, save to localStorage useEffect(() => { @@ -149,14 +169,14 @@ const App = () => { // This allows the exclude patterns to be applied when the config is updated if (activeTab === 'config' && tab === 'source' && rootPath) { // Reset gitignore parser cache to ensure fresh parsing - window.electronAPI?.resetGitignoreCache?.(); + appWindow.electronAPI?.resetGitignoreCache?.(); // refreshDirectoryTree now resets selection states and gets a fresh tree refreshDirectoryTree(); } // Clear analysis results when switching to source tab if (tab === 'source') { - setAnalysisResult(null); + analysisResultRef.current = null; } if (tab === 'source') { @@ -165,7 +185,7 @@ const App = () => { }; // Expose the tab change function for other components to use - window.switchToTab = handleTabChange; + appWindow.switchToTab = handleTabChange; // Function to refresh the directory tree with current config const refreshDirectoryTree = async () => { @@ -175,29 +195,29 @@ const App = () => { setSelectedFolders([]); // Reset analysis results to prevent stale data - setAnalysisResult(null); + analysisResultRef.current = null; setProcessedResult(null); // Reset gitignore cache to ensure fresh parsing - await window.electronAPI?.resetGitignoreCache?.(); + await appWindow.electronAPI?.resetGitignoreCache?.(); // Get fresh directory tree - const tree = await window.electronAPI?.getDirectoryTree?.(rootPath, configContent); + const tree = await appWindow.electronAPI?.getDirectoryTree?.(rootPath, configContent); setDirectoryTree(tree ?? []); } }; - // Expose the refreshDirectoryTree function to the window object for SourceTab to use - window.refreshDirectoryTree = refreshDirectoryTree; + // Expose the refreshDirectoryTree function to the global window object for SourceTab to use + appWindow.refreshDirectoryTree = refreshDirectoryTree; const handleDirectorySelect = async () => { - const dirPath = await window.electronAPI?.selectDirectory?.(); + const dirPath = await appWindow.electronAPI?.selectDirectory?.(); if (dirPath) { // First reset selection states and analysis results setSelectedFiles([]); setSelectedFolders([]); - setAnalysisResult(null); + analysisResultRef.current = null; setProcessedResult(null); // Update rootPath and save to localStorage @@ -205,13 +225,13 @@ const App = () => { localStorage.setItem('rootPath', dirPath); // Dispatch a custom event to notify all components of the path change - window.dispatchEvent(new CustomEvent('rootPathChanged', { detail: dirPath })); + appWindow.dispatchEvent(new CustomEvent('rootPathChanged', { detail: dirPath })); // Reset gitignore cache to ensure fresh parsing - await window.electronAPI?.resetGitignoreCache?.(); + await appWindow.electronAPI?.resetGitignoreCache?.(); // Get fresh directory tree - const tree = await window.electronAPI?.getDirectoryTree?.(dirPath, configContent); + const tree = await appWindow.electronAPI?.getDirectoryTree?.(dirPath, configContent); setDirectoryTree(tree ?? []); } }; @@ -243,19 +263,19 @@ const App = () => { throw new Error('No valid files selected'); } - if (!window.electronAPI?.analyzeRepository || !window.electronAPI?.processRepository) { + if (!appWindow.electronAPI?.analyzeRepository || !appWindow.electronAPI?.processRepository) { throw new Error('Electron API is not available.'); } // Apply current config before analyzing - const currentAnalysisResult = await window.electronAPI.analyzeRepository({ + const currentAnalysisResult = await appWindow.electronAPI.analyzeRepository({ rootPath, configContent, selectedFiles: validFiles, // Use validated files only }); // Store analysis result - setAnalysisResult(currentAnalysisResult); + analysisResultRef.current = currentAnalysisResult; // Read options from config const options: ProcessingOptions = { @@ -274,7 +294,7 @@ const App = () => { setProcessingOptions(options); // Process directly without going to analyze tab - const result = await window.electronAPI.processRepository({ + const result = await appWindow.electronAPI.processRepository({ rootPath, filesInfo: currentAnalysisResult.filesInfo ?? [], treeView: null, // Let the main process handle tree generation @@ -301,8 +321,8 @@ const App = () => { }; const normalizePathForBoundaryCheck = (inputPath: string): string => { - const normalizedSlashes = inputPath.replace(/\\/g, '/'); - const driveMatch = normalizedSlashes.match(/^[A-Za-z]:/); + const normalizedSlashes = inputPath.replaceAll('\\', '/'); + const driveMatch = /^[A-Za-z]:/.exec(normalizedSlashes); const drivePrefix = driveMatch ? driveMatch[0].toLowerCase() : ''; const pathWithoutDrive = drivePrefix ? normalizedSlashes.slice(2) : normalizedSlashes; const hasLeadingSlash = pathWithoutDrive.startsWith('/'); @@ -312,7 +332,7 @@ const App = () => { for (const segment of segments) { if (segment === '..') { - if (resolvedSegments.length > 0 && resolvedSegments[resolvedSegments.length - 1] !== '..') { + if (resolvedSegments.length > 0 && resolvedSegments.at(-1) !== '..') { resolvedSegments.pop(); } else if (!hasLeadingSlash) { // Preserve relative parent traversals so boundary checks can reject them. @@ -352,21 +372,21 @@ const App = () => { return null; } - if (!window.electronAPI?.analyzeRepository || !window.electronAPI?.processRepository) { + if (!appWindow.electronAPI?.analyzeRepository || !appWindow.electronAPI?.processRepository) { throw new Error('Electron API is not available.'); } console.log('Reloading and processing files...'); // Run a fresh analysis to re-read all files from disk - const currentReanalysisResult = await window.electronAPI.analyzeRepository({ + const currentReanalysisResult = await appWindow.electronAPI.analyzeRepository({ rootPath, configContent, selectedFiles: selectedFiles, }); // Update our state with the fresh analysis - setAnalysisResult(currentReanalysisResult); + analysisResultRef.current = currentReanalysisResult; // Get the latest config options const options: ProcessingOptions = { ...processingOptions }; @@ -386,7 +406,7 @@ const App = () => { console.log('Processing with fresh analysis and options:', options); // Process with the fresh analysis - const result = await window.electronAPI.processRepository({ + const result = await appWindow.electronAPI.processRepository({ rootPath, filesInfo: currentReanalysisResult.filesInfo ?? [], treeView: null, // Let server generate @@ -418,7 +438,7 @@ const App = () => { try { const outputExtension = processedResult.exportFormat === 'xml' ? 'xml' : 'md'; - await window.electronAPI?.saveFile?.({ + await appWindow.electronAPI?.saveFile?.({ content: processedResult.content, defaultPath: `${rootPath}/output.${outputExtension}`, }); @@ -569,7 +589,7 @@ const App = () => { - {directoryTree.length > 0 ? ( -
-
- -
- -
- -
-
- ) : rootPath ? ( -
- - - -

Loading directory content...

-
- ) : null} + {fileSelectionContent} {isAnalyzing && (
diff --git a/src/renderer/context/DarkModeContext.tsx b/src/renderer/context/DarkModeContext.tsx index 12848d8..5d869b5 100644 --- a/src/renderer/context/DarkModeContext.tsx +++ b/src/renderer/context/DarkModeContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useEffect, useState } from 'react'; +import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; type DarkModeContextValue = { darkMode: boolean; @@ -10,6 +10,7 @@ type DarkModeProviderProps = { }; const DarkModeContext = createContext(undefined); +const appWindow = globalThis as Window & typeof globalThis; const getInitialDarkMode = (): boolean => { const savedMode = localStorage.getItem('darkMode'); @@ -21,8 +22,8 @@ const getInitialDarkMode = (): boolean => { } } - if (typeof window.matchMedia === 'function') { - return window.matchMedia('(prefers-color-scheme: dark)').matches; + if (typeof appWindow.matchMedia === 'function') { + return appWindow.matchMedia('(prefers-color-scheme: dark)').matches; } return false; @@ -42,11 +43,11 @@ export const DarkModeProvider = ({ children }: DarkModeProviderProps) => { }, [darkMode]); useEffect(() => { - if (typeof window.matchMedia !== 'function') { + if (typeof appWindow.matchMedia !== 'function') { return undefined; } - const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const mediaQuery = appWindow.matchMedia('(prefers-color-scheme: dark)'); const handleChange = (event: MediaQueryListEvent) => { if (localStorage.getItem('darkMode') === null) { setDarkMode(event.matches); @@ -58,17 +59,35 @@ export const DarkModeProvider = ({ children }: DarkModeProviderProps) => { return () => mediaQuery.removeEventListener('change', handleChange); } - const legacyHandler = (event: MediaQueryListEvent) => handleChange(event); - mediaQuery.addListener(legacyHandler); - return () => mediaQuery.removeListener(legacyHandler); + const legacyMediaQuery = mediaQuery as unknown as { + addListener?: unknown; + removeListener?: unknown; + }; + const addListener = legacyMediaQuery.addListener; + const removeListener = legacyMediaQuery.removeListener; + + if (typeof addListener === 'function' && typeof removeListener === 'function') { + addListener.call(mediaQuery, handleChange); + return () => removeListener.call(mediaQuery, handleChange); + } + + return undefined; }, []); - const toggleDarkMode = () => { + const toggleDarkMode = useCallback(() => { setDarkMode((prevMode) => !prevMode); - }; + }, []); + + const contextValue = useMemo( + () => ({ + darkMode, + toggleDarkMode, + }), + [darkMode, toggleDarkMode] + ); return ( - {children} + {children} ); }; diff --git a/src/renderer/index.html b/src/renderer/index.html index 5513002..68722ef 100755 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -7,22 +7,17 @@ diff --git a/src/types/ipc.ts b/src/types/ipc.ts index 5cd797d..6a4b067 100644 --- a/src/types/ipc.ts +++ b/src/types/ipc.ts @@ -1,5 +1,7 @@ export type TabId = 'config' | 'source' | 'processed'; export type ExportFormat = 'markdown' | 'xml'; +export type UpdaterChannel = 'alpha' | 'stable'; +export type UpdaterState = 'disabled' | 'up-to-date' | 'update-available' | 'error'; export type SelectionHandler = (path: string, isSelected: boolean) => void; @@ -80,6 +82,32 @@ export interface CountFilesTokensOptions { filePaths: string[]; } +export interface UpdaterStatus { + enabled: boolean; + platformSupported: boolean; + channel: UpdaterChannel; + allowPrerelease: boolean; + currentVersion: string; + owner: string; + repo: string; + reason?: string; +} + +export interface UpdateCheckResult extends UpdaterStatus { + state: UpdaterState; + updateAvailable: boolean; + latestVersion?: string; + releaseName?: string; + errorMessage?: string; +} + +export interface UpdaterFlagOverrides { + enabled?: boolean; + checkOnStart?: boolean; + owner?: string; + repo?: string; +} + export interface ElectronApi { selectDirectory: () => Promise; getDirectoryTree: ( @@ -93,4 +121,6 @@ export interface ElectronApi { getDefaultConfig: () => Promise; getAssetPath: (assetName: string) => Promise; countFilesTokens: (options: CountFilesTokensOptions) => Promise; + getUpdaterStatus: () => Promise; + checkForUpdates: () => Promise; } diff --git a/src/utils/content-processor.ts b/src/utils/content-processor.ts index 0cf3f9e..355b602 100755 --- a/src/utils/content-processor.ts +++ b/src/utils/content-processor.ts @@ -22,7 +22,7 @@ interface ProcessFileOptions { } export class ContentProcessor { - private tokenCounter: TokenCounter; + private readonly tokenCounter: TokenCounter; constructor(tokenCounter: TokenCounter) { this.tokenCounter = tokenCounter; @@ -47,7 +47,7 @@ export class ContentProcessor { const headerContent = `${relativePath} (binary file)`; if (exportFormat === 'xml') { - const fileType = path.extname(filePath).replace('.', '').toUpperCase(); + const fileType = path.extname(filePath).replaceAll('.', '').toUpperCase(); return ( ` format === 'xml' ? 'xml' : 'markdown'; export const sanitizeXmlContent = (value: string): string => - value.replace(INVALID_XML_CHARACTERS_REGEX, ''); + value.replaceAll(INVALID_XML_CHARACTERS_REGEX, ''); export const escapeXmlAttribute = (value: string): string => sanitizeXmlContent(value) - .replace(/&/g, '&') - .replace(/"/g, '"') - .replace(/'/g, ''') - .replace(//g, '>'); + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll("'", ''') + .replaceAll('<', '<') + .replaceAll('>', '>'); export const wrapXmlCdata = (value: string): string => - `/g, ']]]]>')}]]>`; + `', ']]]]>')}]]>`; export const normalizeTokenCount = (value: unknown): number => { const numericValue = typeof value === 'number' ? value : Number(value); diff --git a/src/utils/file-analyzer.ts b/src/utils/file-analyzer.ts index 5977f0f..2cf490f 100755 --- a/src/utils/file-analyzer.ts +++ b/src/utils/file-analyzer.ts @@ -49,10 +49,10 @@ export const isBinaryFile = (filePath: string): boolean => { }; class FileAnalyzer { - private config: ConfigObject; - private tokenCounter: TokenCounter; - private useGitignore: boolean; - private gitignorePatterns: GitignorePatterns; + private readonly config: ConfigObject; + private readonly tokenCounter: TokenCounter; + private readonly useGitignore: boolean; + private readonly gitignorePatterns: GitignorePatterns; constructor( config: ConfigObject, @@ -61,7 +61,7 @@ class FileAnalyzer { ) { this.config = config; this.tokenCounter = tokenCounter; - this.useGitignore = options.useGitignore || false; + this.useGitignore = options.useGitignore ?? false; this.gitignorePatterns = options.gitignorePatterns || { excludePatterns: [], includePatterns: [], @@ -70,7 +70,7 @@ class FileAnalyzer { shouldProcessFile(filePath: string): boolean { // Convert path to forward slashes for consistent pattern matching - const normalizedPath = filePath.replace(/\\/g, '/'); + const normalizedPath = filePath.replaceAll('\\', '/'); const ext = path.extname(filePath); // Explicit check for node_modules @@ -103,12 +103,12 @@ class FileAnalyzer { } // Add gitignore exclude patterns - if (this.useGitignore && this.gitignorePatterns && this.gitignorePatterns.excludePatterns) { + if (this.useGitignore) { patterns.push(...this.gitignorePatterns.excludePatterns); } // Add include patterns property for gitignore negated patterns - if (this.useGitignore && this.gitignorePatterns && this.gitignorePatterns.includePatterns) { + if (this.useGitignore) { patterns.includePatterns = this.gitignorePatterns.includePatterns; } diff --git a/src/utils/filter-utils.ts b/src/utils/filter-utils.ts index e0f1a1b..802ba16 100644 --- a/src/utils/filter-utils.ts +++ b/src/utils/filter-utils.ts @@ -5,7 +5,7 @@ import { shouldExcludeSensitiveFilePath } from './secret-scanner'; type ExcludePatterns = string[] & { includePatterns?: string[]; includeExtensions?: string[] }; -export const normalizePath = (inputPath: string): string => inputPath.replace(/\\/g, '/'); +export const normalizePath = (inputPath: string): string => inputPath.replaceAll('\\', '/'); export const getRelativePath = (filePath: string, rootPath: string): string => normalizePath(path.relative(rootPath, filePath)); @@ -55,13 +55,42 @@ const matchesExcludePatterns = ( excludePatterns: string[] ): boolean => Array.isArray(excludePatterns) && - excludePatterns.length > 0 && excludePatterns.some( (pattern) => fnmatch.fnmatch(normalizedPath, pattern) || (!pattern.includes('/') && fnmatch.fnmatch(itemName, pattern)) ); +const shouldExcludeByCustomPatterns = ( + normalizedPath: string, + itemName: string, + customExcludes: string[] +): boolean => + customExcludes.length > 0 && matchesExcludePatterns(normalizedPath, itemName, customExcludes); + +const shouldExcludeByGitignorePatterns = ( + normalizedPath: string, + itemName: string, + excludePatterns: ExcludePatterns | undefined, + customExcludes: string[], + config?: ConfigObject +): boolean => { + if (config?.use_gitignore === false) { + return false; + } + + const gitignoreIncludes = excludePatterns?.includePatterns || []; + if (gitignoreIncludes.length > 0 && matchesIncludePatterns(normalizedPath, itemName, gitignoreIncludes)) { + return false; + } + + const gitignoreExcludes = Array.isArray(excludePatterns) + ? excludePatterns.filter((pattern) => !customExcludes.includes(pattern)) + : []; + + return gitignoreExcludes.length > 0 && matchesExcludePatterns(normalizedPath, itemName, gitignoreExcludes); +}; + export const shouldExclude = ( itemPath: string, rootPath: string, @@ -83,32 +112,17 @@ export const shouldExclude = ( return true; } - if (customExcludes.length > 0 && matchesExcludePatterns(normalizedPath, itemName, customExcludes)) { + if (shouldExcludeByCustomPatterns(normalizedPath, itemName, customExcludes)) { return true; } - if (config?.use_gitignore !== false) { - const gitignoreIncludes = excludePatterns?.includePatterns || []; - if ( - gitignoreIncludes.length > 0 && - matchesIncludePatterns(normalizedPath, itemName, gitignoreIncludes) - ) { - return false; - } - - const gitignoreExcludes = Array.isArray(excludePatterns) - ? excludePatterns.filter((pattern) => !customExcludes.includes(pattern)) - : []; - - if ( - gitignoreExcludes.length > 0 && - matchesExcludePatterns(normalizedPath, itemName, gitignoreExcludes) - ) { - return true; - } - } - - return false; + return shouldExcludeByGitignorePatterns( + normalizedPath, + itemName, + excludePatterns, + customExcludes, + config + ); } catch (error) { console.error(`Error in shouldExclude for ${itemPath}:`, error); return false; diff --git a/src/utils/gitignore-parser.ts b/src/utils/gitignore-parser.ts index 6b1725d..0130a6c 100644 --- a/src/utils/gitignore-parser.ts +++ b/src/utils/gitignore-parser.ts @@ -10,7 +10,7 @@ export interface GitignorePatterns { * A utility class for parsing and applying gitignore rules. */ export class GitignoreParser { - private cache: Map; + private readonly cache: Map; constructor() { this.cache = new Map(); @@ -30,8 +30,9 @@ export class GitignoreParser { */ parseGitignore(rootPath: string): GitignorePatterns { // Check if we have a cached result for this root path - if (this.cache.has(rootPath)) { - return this.cache.get(rootPath); + const cachedResult = this.cache.get(rootPath); + if (cachedResult) { + return cachedResult; } const gitignorePath = path.join(rootPath, '.gitignore'); @@ -107,14 +108,7 @@ export class GitignoreParser { } this._addPattern(result, pattern, isNegated); - } else if (!pattern.includes('*')) { - // Pattern without leading slash and without wildcards - const rootPattern = pattern; - const subdirPattern = `**/${pattern}`; - - this._addPattern(result, rootPattern, isNegated); - this._addPattern(result, subdirPattern, isNegated); - } else { + } else if (pattern.includes('*')) { // Pattern with wildcards and path separators, but not starting with / this._addPattern(result, pattern, isNegated); @@ -122,6 +116,13 @@ export class GitignoreParser { if (pattern.includes('/')) { this._addPattern(result, `**/${pattern}`, isNegated); } + } else { + // Pattern without leading slash and without wildcards + const rootPattern = pattern; + const subdirPattern = `**/${pattern}`; + + this._addPattern(result, rootPattern, isNegated); + this._addPattern(result, subdirPattern, isNegated); } } diff --git a/src/utils/secret-scanner.ts b/src/utils/secret-scanner.ts index de43269..65f9873 100644 --- a/src/utils/secret-scanner.ts +++ b/src/utils/secret-scanner.ts @@ -5,7 +5,8 @@ import type { ConfigObject } from '../types/ipc'; type SecretRule = { id: string; description: string; - pattern: RegExp; + pattern?: RegExp; + matches?: (content: string) => boolean; }; export interface SecretMatch { @@ -19,6 +20,47 @@ export interface SecretScanResult { error?: string; } +const AWS_SECRET_KEY_ASSIGNMENT_PREFIX = + /aws(?:\s|_|-)?secret(?:\s|_|-)?access(?:\s|_|-)?key\s*[:=]\s*/gi; + +const AWS_SECRET_KEY_VALUE = /^[A-Za-z0-9+/=]{40}$/; + +const extractAssignedValue = (input: string): string => { + const trimmed = input.trimStart(); + if (!trimmed) { + return ''; + } + + const quoteCharacter = trimmed[0]; + if (quoteCharacter === '"' || quoteCharacter === "'") { + const endQuoteIndex = trimmed.indexOf(quoteCharacter, 1); + if (endQuoteIndex <= 0) { + return ''; + } + return trimmed.slice(1, endQuoteIndex); + } + + const stopCharacterIndex = trimmed.search(/[\s;,]/); + return stopCharacterIndex === -1 ? trimmed : trimmed.slice(0, stopCharacterIndex); +}; + +const hasAwsSecretAssignment = (content: string): boolean => { + AWS_SECRET_KEY_ASSIGNMENT_PREFIX.lastIndex = 0; + + let assignmentMatch: RegExpExecArray | null; + while ((assignmentMatch = AWS_SECRET_KEY_ASSIGNMENT_PREFIX.exec(content)) !== null) { + const startIndex = assignmentMatch.index + assignmentMatch[0].length; + const candidateValue = extractAssignedValue(content.slice(startIndex)); + if (AWS_SECRET_KEY_VALUE.test(candidateValue)) { + AWS_SECRET_KEY_ASSIGNMENT_PREFIX.lastIndex = 0; + return true; + } + } + + AWS_SECRET_KEY_ASSIGNMENT_PREFIX.lastIndex = 0; + return false; +}; + const SECRET_RULES: SecretRule[] = [ { id: 'private-key-block', @@ -38,8 +80,7 @@ const SECRET_RULES: SecretRule[] = [ { id: 'aws-secret-assignment', description: 'AWS secret key assignment detected', - pattern: - /aws(?:_|[\s-])?secret(?:_|[\s-])?access(?:_|[\s-])?key\s*[:=]\s*['"]?[A-Za-z0-9/+=]{40}['"]?/i, + matches: hasAwsSecretAssignment, }, { id: 'slack-token', @@ -57,10 +98,15 @@ const SECRET_RULES: SecretRule[] = [ pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/, }, { - id: 'generic-credential-assignment', - description: 'Credential assignment detected', + id: 'token-assignment', + description: 'Token assignment detected', pattern: - /(?:api[_-]?key|access[_-]?token|auth[_-]?token|secret|password|passwd|client[_-]?secret)\s*[:=]\s*['"][^'"\n]{8,}['"]/i, + /(?:api[_-]?key|access[_-]?token|auth[_-]?token)\s*[:=]\s*['"][^'"\n]{8,}['"]/i, + }, + { + id: 'credential-assignment', + description: 'Credential assignment detected', + pattern: /(?:secret|password|passwd|client[_-]?secret)\s*[:=]\s*['"][^'"\n]{8,}['"]/i, }, ]; @@ -75,7 +121,7 @@ const SENSITIVE_FILE_EXTENSION_PATTERN = const SENSITIVE_PATH_SEGMENTS = ['.aws/credentials', '.npmrc', '.pypirc', '.docker/config.json']; -const normalizeFilePath = (filePath: string): string => filePath.replace(/\\/g, '/').toLowerCase(); +const normalizeFilePath = (filePath: string): string => filePath.replaceAll('\\', '/').toLowerCase(); export const shouldExcludeSuspiciousFiles = (config?: ConfigObject): boolean => config?.enable_secret_scanning !== false && config?.exclude_suspicious_files !== false; @@ -114,7 +160,11 @@ export const scanContentForSecrets = (content: string): SecretScanResult => { const matches: SecretMatch[] = []; for (const rule of SECRET_RULES) { - if (rule.pattern.test(content)) { + const isMatch = + typeof rule.matches === 'function' + ? rule.matches(content) + : rule.pattern?.test(content) === true; + if (isMatch) { matches.push({ id: rule.id, description: rule.description }); } } diff --git a/src/utils/token-counter.ts b/src/utils/token-counter.ts index 905b4fc..7c04d99 100755 --- a/src/utils/token-counter.ts +++ b/src/utils/token-counter.ts @@ -5,7 +5,7 @@ type Encoder = { }; export class TokenCounter { - private encoder: Encoder | null; + private readonly encoder: Encoder | null; constructor(modelName = 'gpt-4') { try { @@ -22,7 +22,10 @@ export class TokenCounter { return 0; } - const textStr = String(text); + const textStr = this.normalizeTokenInput(text); + if (!textStr) { + return 0; + } if (this.encoder) { return this.encoder.encode(textStr).length; @@ -35,4 +38,29 @@ export class TokenCounter { return 0; } } + + private normalizeTokenInput(value: unknown): string { + if (typeof value === 'string') { + return value; + } + + if ( + typeof value === 'number' || + typeof value === 'boolean' || + typeof value === 'bigint' || + typeof value === 'symbol' + ) { + return String(value); + } + + if (typeof value === 'object') { + try { + return JSON.stringify(value) ?? ''; + } catch { + return ''; + } + } + + return ''; + } } diff --git a/tests/catalog.md b/tests/catalog.md index e513c6b..7a74160 100644 --- a/tests/catalog.md +++ b/tests/catalog.md @@ -28,6 +28,8 @@ Purpose: quick map of what is covered, why it exists, and which command to run. | `tests/unit/utils/config-manager.test.ts` | `src/utils/config-manager.ts` | Default config load, parse failures, graceful fallback behavior | | `tests/unit/utils/token-counter.test.ts` | `src/utils/token-counter.ts` | Token counting basics, empty/null input handling | | `tests/unit/scripts/security.test.js` | `scripts/lib/security.js` | Command safety validation, Windows path acceptance for approved executables | +| `tests/unit/main/updater.test.ts` | `src/main/updater.ts` | Alpha/stable channel selection, platform gating, update-check result handling | +| `tests/unit/main/feature-flags.test.ts` | `src/main/feature-flags.ts` | OpenFeature normalization, env/remote merge rules, secure remote fetch behavior | ## Integration Tests @@ -62,6 +64,8 @@ Purpose: quick map of what is covered, why it exists, and which command to run. - `tests/unit/components/config-tab.test.tsx` - Main process / IPC changes: - `tests/integration/main-process/handlers.test.ts` + - `tests/unit/main/updater.test.ts` + - `tests/unit/main/feature-flags.test.ts` - Content/token pipeline changes: - `tests/unit/file-analyzer.test.ts` - `tests/unit/utils/export-format.test.ts` diff --git a/tests/integration/main-process/handlers.test.ts b/tests/integration/main-process/handlers.test.ts index cddfa69..beb7b52 100644 --- a/tests/integration/main-process/handlers.test.ts +++ b/tests/integration/main-process/handlers.test.ts @@ -5,6 +5,15 @@ const FAKE_GITHUB_TOKEN = ['ghp', 'AAAAAAAAAAAAAAAAAAAAAAAA'].join('_'); // Mock electron ipcMain const mockIpcHandlers = {}; const mockProtocolHandlers = {}; +const mockNetFetch = jest.fn(); +const mockAutoUpdater = { + checkForUpdates: jest.fn(), + setFeedURL: jest.fn(), + allowPrerelease: false, + autoDownload: true, + autoInstallOnAppQuit: false, + channel: undefined, +}; const mockIpcMain = { handle: jest.fn((channel, handler) => { mockIpcHandlers[channel] = handler; @@ -18,6 +27,7 @@ jest.mock('electron', () => ({ on: jest.fn(), setAppUserModelId: jest.fn(), quit: jest.fn(), + getVersion: jest.fn().mockReturnValue('0.2.0'), }, BrowserWindow: jest.fn().mockImplementation(() => ({ loadFile: jest.fn().mockResolvedValue(null), @@ -33,10 +43,13 @@ jest.mock('electron', () => ({ showSaveDialog: jest.fn(), }, protocol: { - registerFileProtocol: jest.fn((scheme, handler) => { + handle: jest.fn((scheme, handler) => { mockProtocolHandlers[scheme] = handler; }), }, + net: { + fetch: mockNetFetch, + }, })); jest.mock('fs'); @@ -63,6 +76,9 @@ jest.mock('path', () => { }); jest.mock('yaml'); +jest.mock('electron-updater', () => ({ + autoUpdater: mockAutoUpdater, +})); // Mock core utils jest.mock('../../../src/utils/token-counter', () => ({ @@ -122,6 +138,14 @@ require('../../../src/main/index'); describe('Main Process IPC Handlers', () => { beforeEach(async () => { jest.clearAllMocks(); + mockNetFetch.mockResolvedValue({ ok: true, status: 200, url: 'file:///mock/icon.png' }); + mockAutoUpdater.allowPrerelease = false; + mockAutoUpdater.autoDownload = true; + mockAutoUpdater.autoInstallOnAppQuit = false; + mockAutoUpdater.channel = undefined; + mockAutoUpdater.checkForUpdates.mockResolvedValue({ + updateInfo: { version: '0.2.1', releaseName: 'Mock Update' }, + }); const { dialog } = require('electron'); dialog.showOpenDialog.mockResolvedValue({ canceled: false, @@ -199,10 +223,10 @@ describe('Main Process IPC Handlers', () => { const handler = mockProtocolHandlers['assets']; expect(handler).toBeDefined(); - const callback = jest.fn(); - handler({ url: 'assets://../../etc/passwd' }, callback); - - expect(callback).toHaveBeenCalledWith({ error: -6 }); + const response = await handler({ url: 'assets://../../etc/passwd' }); + expect(response).toBeDefined(); + expect(response.status).toBe(403); + expect(mockNetFetch).not.toHaveBeenCalled(); }); test('should resolve valid assets protocol requests', async () => { @@ -210,15 +234,60 @@ describe('Main Process IPC Handlers', () => { const handler = mockProtocolHandlers['assets']; expect(handler).toBeDefined(); - const callback = jest.fn(); - handler({ url: 'assets://icon.png' }, callback); + const basicResponse = await handler({ url: 'assets://icon.png' }); + expect(basicResponse).toBeDefined(); - expect(callback).toHaveBeenCalledWith( - expect.objectContaining({ - path: expect.stringContaining('icon.png'), - }) + const hostPathResponse = await handler({ url: 'assets://public/icon.png' }); + expect(hostPathResponse).toBeDefined(); + + const nestedResponse = await handler({ url: 'assets://host/dir/file.png' }); + expect(nestedResponse).toBeDefined(); + + const encodedResponse = await handler({ url: 'assets://host/dir/space%20file.png' }); + expect(encodedResponse).toBeDefined(); + + expect(mockNetFetch).toHaveBeenNthCalledWith(1, expect.stringContaining('icon.png')); + expect(mockNetFetch).toHaveBeenNthCalledWith(2, expect.stringContaining('public/icon.png')); + expect(mockNetFetch).toHaveBeenNthCalledWith(3, expect.stringContaining('host/dir/file.png')); + expect(mockNetFetch).toHaveBeenNthCalledWith( + 4, + expect.stringContaining('host/dir/space%20file.png') ); }); + + test('should reject encoded traversal in assets protocol requests', async () => { + await Promise.resolve(); + const handler = mockProtocolHandlers['assets']; + expect(handler).toBeDefined(); + + const response = await handler({ url: 'assets://%2e%2e%2f%2e%2e/etc/passwd' }); + expect(response).toBeDefined(); + expect(response.status).toBe(403); + expect(mockNetFetch).not.toHaveBeenCalled(); + }); + + test('should reject malformed assets protocol requests', async () => { + await Promise.resolve(); + const handler = mockProtocolHandlers['assets']; + expect(handler).toBeDefined(); + + const response = await handler({ url: 'assets://host/%E0%A4%A' }); + expect(response).toBeDefined(); + expect(response.status).toBe(403); + expect(mockNetFetch).not.toHaveBeenCalled(); + }); + + test('should return 404 when asset fetch fails', async () => { + await Promise.resolve(); + const handler = mockProtocolHandlers['assets']; + expect(handler).toBeDefined(); + + mockNetFetch.mockRejectedValueOnce(new Error('asset missing')); + + const response = await handler({ url: 'assets://icon.png' }); + expect(response).toBeDefined(); + expect(response.status).toBe(404); + }); }); describe('fs:getDirectoryTree', () => { @@ -292,6 +361,60 @@ describe('Main Process IPC Handlers', () => { }); }); + describe('updater handlers', () => { + test('should expose updater status from runtime', async () => { + const handler = mockIpcHandlers['updater:getStatus']; + expect(handler).toBeDefined(); + + const result = await handler(null); + const platformSupported = process.platform === 'win32' || process.platform === 'darwin'; + + expect(result).toEqual( + expect.objectContaining({ + currentVersion: '0.2.0', + channel: 'stable', + allowPrerelease: false, + platformSupported, + enabled: platformSupported, + }) + ); + }); + + test('should check updates with platform-aware behavior', async () => { + const handler = mockIpcHandlers['updater:check']; + expect(handler).toBeDefined(); + + const result = await handler(null); + const platformSupported = process.platform === 'win32' || process.platform === 'darwin'; + + if (platformSupported) { + expect(mockAutoUpdater.setFeedURL).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'github', + owner: 'codingworkflow', + repo: 'ai-code-fusion', + }) + ); + expect(mockAutoUpdater.checkForUpdates).toHaveBeenCalled(); + expect(result).toEqual( + expect.objectContaining({ + state: 'update-available', + updateAvailable: true, + latestVersion: '0.2.1', + }) + ); + } else { + expect(mockAutoUpdater.checkForUpdates).not.toHaveBeenCalled(); + expect(result).toEqual( + expect.objectContaining({ + state: 'disabled', + updateAvailable: false, + }) + ); + } + }); + }); + describe('repo:analyze', () => { test('should analyze selected files correctly', async () => { // Setup diff --git a/tests/integration/main-process/xml-export-e2e.test.ts b/tests/integration/main-process/xml-export-e2e.test.ts index e46aa41..b27f661 100644 --- a/tests/integration/main-process/xml-export-e2e.test.ts +++ b/tests/integration/main-process/xml-export-e2e.test.ts @@ -11,6 +11,7 @@ describe('XML export end-to-end', () => { let tempRoot = ''; let mockShowOpenDialog; let mockShowSaveDialog; + let mockNetFetch; beforeEach(() => { jest.resetModules(); @@ -20,6 +21,7 @@ describe('XML export end-to-end', () => { }); mockShowOpenDialog = jest.fn(); mockShowSaveDialog = jest.fn(); + mockNetFetch = jest.fn().mockResolvedValue({ ok: true, status: 200, url: 'file:///mock.png' }); jest.doMock('electron', () => ({ app: { @@ -27,6 +29,7 @@ describe('XML export end-to-end', () => { on: jest.fn(), setAppUserModelId: jest.fn(), quit: jest.fn(), + getVersion: jest.fn().mockReturnValue('0.2.0'), }, BrowserWindow: jest.fn().mockImplementation(() => ({ loadFile: jest.fn().mockResolvedValue(null), @@ -45,8 +48,24 @@ describe('XML export end-to-end', () => { showOpenDialog: mockShowOpenDialog, showSaveDialog: mockShowSaveDialog, }, + net: { + fetch: mockNetFetch, + }, protocol: { - registerFileProtocol: jest.fn(), + handle: jest.fn(), + }, + })); + + jest.doMock('electron-updater', () => ({ + autoUpdater: { + checkForUpdates: jest.fn().mockResolvedValue({ + updateInfo: { version: '0.2.1' }, + }), + setFeedURL: jest.fn(), + allowPrerelease: false, + autoDownload: true, + autoInstallOnAppQuit: false, + channel: undefined, }, })); diff --git a/tests/setup.ts b/tests/setup.ts index 1484871..f37bce5 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -41,6 +41,28 @@ window.electronAPI = { results: {}, stats: {}, }), + getUpdaterStatus: jest.fn().mockResolvedValue({ + enabled: false, + platformSupported: false, + channel: 'stable', + allowPrerelease: false, + currentVersion: '0.0.0', + owner: 'codingworkflow', + repo: 'ai-code-fusion', + reason: 'Updater is disabled in tests', + }), + checkForUpdates: jest.fn().mockResolvedValue({ + enabled: false, + platformSupported: false, + channel: 'stable', + allowPrerelease: false, + currentVersion: '0.0.0', + owner: 'codingworkflow', + repo: 'ai-code-fusion', + state: 'disabled', + updateAvailable: false, + reason: 'Updater is disabled in tests', + }), }; if (!window.matchMedia) { @@ -59,6 +81,27 @@ if (!window.matchMedia) { }); } +if (typeof globalThis.Response === 'undefined') { + class MockResponse { + readonly status: number; + readonly ok: boolean; + private readonly bodyValue: unknown; + + constructor(body?: unknown, init: { status?: number } = {}) { + this.status = init.status ?? 200; + this.ok = this.status >= 200 && this.status < 300; + this.bodyValue = body; + } + + async text(): Promise { + return typeof this.bodyValue === 'string' ? this.bodyValue : ''; + } + } + + (globalThis as unknown as { Response: typeof Response }).Response = + MockResponse as unknown as typeof Response; +} + // Mock fs module functions that we use in various tests jest.mock('fs', () => ({ existsSync: jest.fn().mockReturnValue(true), diff --git a/tests/unit/main/feature-flags.test.ts b/tests/unit/main/feature-flags.test.ts new file mode 100644 index 0000000..bd3d7eb --- /dev/null +++ b/tests/unit/main/feature-flags.test.ts @@ -0,0 +1,167 @@ +import { OpenFeature } from '@openfeature/server-sdk'; +import { + fetchRemoteUpdaterFlagOverrides, + initializeUpdaterFeatureFlags, + mergeUpdaterFlagOverrides, + readUpdaterFlagOverridesFromEnv, + readUpdaterFlagOverridesFromRemotePayload, +} from '../../../src/main/feature-flags'; + +describe('feature-flags updater normalization', () => { + afterEach(async () => { + await OpenFeature.close(); + }); + + test('reads updater overrides from environment', () => { + const result = readUpdaterFlagOverridesFromEnv({ + UPDATER_ENABLED: 'true', + UPDATER_CHECK_ON_START: '1', + UPDATER_GH_OWNER: 'codingworkflow', + UPDATER_GH_REPO: 'ai-code-fusion', + }); + + expect(result).toEqual({ + enabled: true, + checkOnStart: true, + owner: 'codingworkflow', + repo: 'ai-code-fusion', + }); + }); + + test('reads updater overrides from flat and nested remote payloads', () => { + const flatResult = readUpdaterFlagOverridesFromRemotePayload({ + 'updater.enabled': false, + 'updater.checkOnStart': true, + 'updater.ghOwner': 'remote-owner', + 'updater.ghRepo': 'remote-repo', + }); + + expect(flatResult).toEqual({ + enabled: false, + checkOnStart: true, + owner: 'remote-owner', + repo: 'remote-repo', + }); + + const nestedResult = readUpdaterFlagOverridesFromRemotePayload({ + updater: { + enabled: true, + checkOnStart: false, + ghOwner: 'nested-owner', + ghRepo: 'nested-repo', + }, + }); + + expect(nestedResult).toEqual({ + enabled: true, + checkOnStart: false, + owner: 'nested-owner', + repo: 'nested-repo', + }); + }); + + test('merges remote and env flags with env taking precedence', () => { + const result = mergeUpdaterFlagOverrides( + { + enabled: false, + checkOnStart: true, + owner: 'remote-owner', + repo: 'remote-repo', + }, + { + enabled: true, + checkOnStart: undefined, + owner: 'env-owner', + } + ); + + expect(result).toEqual({ + enabled: true, + checkOnStart: true, + owner: 'env-owner', + repo: 'remote-repo', + }); + }); + + test('fetches and parses remote flags only from allowed URLs', async () => { + const allowedResult = await fetchRemoteUpdaterFlagOverrides({ + url: 'https://example.com/flags.json', + fetchFn: jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + 'updater.enabled': true, + 'updater.checkOnStart': false, + }), + }), + }); + + expect(allowedResult).toEqual({ + enabled: true, + checkOnStart: false, + owner: undefined, + repo: undefined, + }); + + const blockedResult = await fetchRemoteUpdaterFlagOverrides({ + url: 'http://example.com/flags.json', + fetchFn: jest.fn(), + }); + expect(blockedResult).toEqual({}); + }); + + test('returns empty overrides and logs warning for non-ok remote responses', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const result = await fetchRemoteUpdaterFlagOverrides({ + url: 'https://example.com/flags.json', + fetchFn: jest.fn().mockResolvedValue({ + ok: false, + status: 503, + }), + }); + + expect(result).toEqual({}); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Remote flags request returned status 503') + ); + warnSpy.mockRestore(); + }); + + test('returns empty overrides and logs warning when remote fetch throws', async () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const result = await fetchRemoteUpdaterFlagOverrides({ + url: 'https://example.com/flags.json', + fetchFn: jest.fn().mockRejectedValue(new Error('network down')), + }); + + expect(result).toEqual({}); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Remote flags request failed: network down') + ); + warnSpy.mockRestore(); + }); + + test('initializes OpenFeature client and returns normalized updater overrides', async () => { + const result = await initializeUpdaterFeatureFlags({ + env: { + FEATURE_FLAGS_URL: 'https://example.com/flags.json', + UPDATER_ENABLED: 'true', + }, + fetchFn: jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + 'updater.enabled': false, + 'updater.checkOnStart': true, + 'updater.ghOwner': 'remote-owner', + 'updater.ghRepo': 'remote-repo', + }), + }), + }); + + expect(result).toEqual({ + enabled: true, + checkOnStart: true, + owner: 'remote-owner', + repo: 'remote-repo', + }); + }); +}); diff --git a/tests/unit/main/updater.test.ts b/tests/unit/main/updater.test.ts new file mode 100644 index 0000000..be88bbb --- /dev/null +++ b/tests/unit/main/updater.test.ts @@ -0,0 +1,208 @@ +import { + createUpdaterService, + isAlphaVersion, + isUpdaterPlatformSupported, + resolveUpdaterChannel, + resolveUpdaterRuntimeOptions, +} from '../../../src/main/updater'; + +describe('updater utilities', () => { + test('detects alpha version and channel', () => { + expect(isAlphaVersion('0.3.0-alpha.1')).toBe(true); + expect(resolveUpdaterChannel('0.3.0-alpha.1')).toBe('alpha'); + expect(resolveUpdaterChannel('0.3.0')).toBe('stable'); + }); + + test('detects platform support for updater', () => { + expect(isUpdaterPlatformSupported('win32')).toBe(true); + expect(isUpdaterPlatformSupported('darwin')).toBe(true); + expect(isUpdaterPlatformSupported('linux')).toBe(false); + }); + + test('builds runtime options from version and environment', () => { + const alphaOptions = resolveUpdaterRuntimeOptions({ + currentVersion: '0.3.0-alpha.2', + platform: 'win32', + env: { + NODE_ENV: 'production', + UPDATER_CHECK_ON_START: 'true', + UPDATER_GH_OWNER: 'acme', + UPDATER_GH_REPO: 'desktop-app', + }, + }); + + expect(alphaOptions.enabled).toBe(true); + expect(alphaOptions.channel).toBe('alpha'); + expect(alphaOptions.allowPrerelease).toBe(true); + expect(alphaOptions.checkOnStart).toBe(true); + expect(alphaOptions.owner).toBe('acme'); + expect(alphaOptions.repo).toBe('desktop-app'); + + const stableLinuxOptions = resolveUpdaterRuntimeOptions({ + currentVersion: '0.3.0', + platform: 'linux', + env: { + NODE_ENV: 'production', + }, + }); + + expect(stableLinuxOptions.channel).toBe('stable'); + expect(stableLinuxOptions.allowPrerelease).toBe(false); + expect(stableLinuxOptions.platformSupported).toBe(false); + expect(stableLinuxOptions.enabled).toBe(false); + expect(stableLinuxOptions.reason).toContain('unsupported platform'); + }); + + test('prioritizes normalized flag overrides over environment values', () => { + const options = resolveUpdaterRuntimeOptions({ + currentVersion: '0.3.0', + platform: 'win32', + env: { + NODE_ENV: 'production', + UPDATER_ENABLED: 'false', + UPDATER_CHECK_ON_START: 'false', + UPDATER_GH_OWNER: 'env-owner', + UPDATER_GH_REPO: 'env-repo', + }, + flagOverrides: { + enabled: true, + checkOnStart: true, + owner: 'flag-owner', + repo: 'flag-repo', + }, + }); + + expect(options.enabled).toBe(true); + expect(options.checkOnStart).toBe(true); + expect(options.owner).toBe('flag-owner'); + expect(options.repo).toBe('flag-repo'); + }); + + test('keeps updater disabled on unsupported platform even when override enables it', () => { + const options = resolveUpdaterRuntimeOptions({ + currentVersion: '0.3.0-alpha.1', + platform: 'linux', + env: { + NODE_ENV: 'production', + }, + flagOverrides: { + enabled: true, + checkOnStart: true, + }, + }); + + expect(options.platformSupported).toBe(false); + expect(options.enabled).toBe(false); + expect(options.reason).toContain('unsupported platform'); + }); +}); + +describe('createUpdaterService', () => { + const createMockUpdater = () => { + return { + checkForUpdates: jest.fn(), + setFeedURL: jest.fn(), + allowPrerelease: false, + autoDownload: true, + autoInstallOnAppQuit: false, + channel: undefined as string | undefined, + }; + }; + + test('configures alpha prerelease checks and returns update available result', async () => { + const updaterClient = createMockUpdater(); + updaterClient.checkForUpdates.mockResolvedValue({ + updateInfo: { version: '0.3.0-alpha.3', releaseName: 'Alpha 3' }, + }); + + const runtimeOptions = resolveUpdaterRuntimeOptions({ + currentVersion: '0.3.0-alpha.2', + platform: 'win32', + env: { + NODE_ENV: 'production', + }, + }); + + const service = createUpdaterService(updaterClient, runtimeOptions); + const result = await service.checkForUpdates(); + + expect(updaterClient.autoDownload).toBe(false); + expect(updaterClient.autoInstallOnAppQuit).toBe(true); + expect(updaterClient.allowPrerelease).toBe(true); + expect(updaterClient.channel).toBe('alpha'); + expect(updaterClient.setFeedURL).toHaveBeenCalledWith({ + provider: 'github', + owner: 'codingworkflow', + repo: 'ai-code-fusion', + }); + expect(result).toEqual( + expect.objectContaining({ + state: 'update-available', + updateAvailable: true, + latestVersion: '0.3.0-alpha.3', + channel: 'alpha', + }) + ); + }); + + test('returns up-to-date when latest equals current version', async () => { + const updaterClient = createMockUpdater(); + updaterClient.checkForUpdates.mockResolvedValue({ + updateInfo: { version: '0.3.0', releaseName: 'Stable' }, + }); + + const runtimeOptions = resolveUpdaterRuntimeOptions({ + currentVersion: '0.3.0', + platform: 'darwin', + env: { + NODE_ENV: 'production', + }, + }); + + const service = createUpdaterService(updaterClient, runtimeOptions); + const result = await service.checkForUpdates(); + + expect(updaterClient.allowPrerelease).toBe(false); + expect(updaterClient.channel).toBe('stable'); + expect(result.state).toBe('up-to-date'); + expect(result.updateAvailable).toBe(false); + }); + + test('returns disabled without calling updater client when disabled by env', async () => { + const updaterClient = createMockUpdater(); + const runtimeOptions = resolveUpdaterRuntimeOptions({ + currentVersion: '0.3.0-alpha.1', + platform: 'win32', + env: { + NODE_ENV: 'production', + UPDATER_ENABLED: 'false', + }, + }); + + const service = createUpdaterService(updaterClient, runtimeOptions); + const result = await service.checkForUpdates(); + + expect(updaterClient.checkForUpdates).not.toHaveBeenCalled(); + expect(result.state).toBe('disabled'); + expect(result.updateAvailable).toBe(false); + }); + + test('returns error details when updater check throws', async () => { + const updaterClient = createMockUpdater(); + updaterClient.checkForUpdates.mockRejectedValue(new Error('network failed')); + + const runtimeOptions = resolveUpdaterRuntimeOptions({ + currentVersion: '0.3.0', + platform: 'win32', + env: { + NODE_ENV: 'production', + }, + }); + + const service = createUpdaterService(updaterClient, runtimeOptions); + const result = await service.checkForUpdates(); + + expect(result.state).toBe('error'); + expect(result.errorMessage).toContain('network failed'); + }); +}); diff --git a/tests/unit/utils/secret-scanner.test.ts b/tests/unit/utils/secret-scanner.test.ts index dab9481..d5f5bab 100644 --- a/tests/unit/utils/secret-scanner.test.ts +++ b/tests/unit/utils/secret-scanner.test.ts @@ -87,6 +87,17 @@ describe('secret-scanner', () => { expect(result.matches.some((match) => match.id === 'aws-secret-assignment')).toBe(true); }); + test('should detect later valid AWS assignment when earlier assignment is invalid', () => { + const content = ` + AWS_SECRET_ACCESS_KEY="short" + AWS_SECRET_ACCESS_KEY="${FAKE_AWS_SECRET_ACCESS_KEY}" + `; + + const result = scanContentForSecrets(content); + expect(result.isSuspicious).toBe(true); + expect(result.matches.some((match) => match.id === 'aws-secret-assignment')).toBe(true); + }); + test('should detect Slack tokens', () => { const content = `const slackToken = "${FAKE_SLACK_TOKEN}";`; @@ -117,7 +128,7 @@ describe('secret-scanner', () => { const result = scanContentForSecrets(content); expect(result.isSuspicious).toBe(true); - expect(result.matches.some((match) => match.id === 'generic-credential-assignment')).toBe(true); + expect(result.matches.some((match) => match.id === 'credential-assignment')).toBe(true); }); test('should return clean result for normal content', () => { diff --git a/tsconfig.json b/tsconfig.json index 586b90b..e8ce97f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,9 +1,9 @@ { "compilerOptions": { - "target": "ES2020", + "target": "ES2021", "module": "commonjs", "moduleResolution": "node", - "lib": ["ES2020", "DOM"], + "lib": ["ES2021", "DOM"], "jsx": "react-jsx", "strict": false, "noEmit": true,