From a97a128adf670b5eb3030eca3f65b91a06bbcf0d Mon Sep 17 00:00:00 2001 From: "posthog-eu[bot]" <226701856+posthog-eu[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:20:31 +0000 Subject: [PATCH] fix(cli): accept capacitor.config.js as a --capacitor-config target The `--capacitor-config` validator only matched `.ts`/`.json`, so monorepo apps using `capacitor.config.js` were rejected outright with a misleading error that read like a wrong path rather than an unsupported extension. - Widen the accepted-extension regex to include `.js` and name all three extensions in the error message. - Add a `.js` load branch to `loadConfigTarget`, mirroring Capacitor's own `require()`-based `capacitor.config.js` loader. - Add JS write support: Capacitor's `writeConfig` silently no-ops on `.js` (it only formats `.ts`/`.json`), so `writeConfigTarget` now emits CommonJS itself using Capacitor's `formatJSObject`, matching how it renders `.ts`. - Cover load + write of a `capacitor.config.js` target in the existing test. Generated-By: PostHog Code Task-Id: 98e54bc7-9597-4b83-a37e-266ef13e9f92 --- cli/src/capacitor-cli.ts | 5 ++ cli/src/config/index.ts | 37 ++++++++++++--- cli/test/test-capacitor-config-target.mjs | 57 ++++++++++++++++++++++- 3 files changed, 91 insertions(+), 8 deletions(-) diff --git a/cli/src/capacitor-cli.ts b/cli/src/capacitor-cli.ts index bf3a537ef3..e45c940aec 100644 --- a/cli/src/capacitor-cli.ts +++ b/cli/src/capacitor-cli.ts @@ -13,6 +13,8 @@ import type { CapacitorConfig } from './schemas/config' import { loadConfig as loadConfigUntyped, writeConfig as writeConfigUntyped } from '@capacitor/cli/dist/config' // @ts-expect-error `@capacitor/cli/dist/util/monorepotools` ships no type declarations import { findMonorepoRoot as findMonorepoRootUntyped, findNXMonorepoRoot as findNXMonorepoRootUntyped, isMonorepo as isMonorepoUntyped, isNXMonorepo as isNXMonorepoUntyped } from '@capacitor/cli/dist/util/monorepotools' +// @ts-expect-error `@capacitor/cli/dist/util/js` ships no type declarations +import { formatJSObject as formatJSObjectUntyped } from '@capacitor/cli/dist/util/js' // @ts-expect-error `@capacitor/cli/dist/util/node` ships no type declarations import { requireTS as requireTSUntyped } from '@capacitor/cli/dist/util/node' @@ -24,6 +26,9 @@ export interface CapacitorCliConfig { } export const requireTS: (typescript: unknown, filePath: string) => Record = requireTSUntyped +// Serializes a config object the way Capacitor formats its own `.ts` writes, so +// the `.js` config we emit reads identically to the `.ts` one Capacitor produces. +export const formatJSObject: (value: unknown) => string = formatJSObjectUntyped export const loadConfig: () => Promise = loadConfigUntyped export const writeConfig: (extConfig: CapacitorConfig, extConfigFilePath: string) => Promise = writeConfigUntyped export const findMonorepoRoot: (currentPath: string) => string = findMonorepoRootUntyped diff --git a/cli/src/config/index.ts b/cli/src/config/index.ts index 8fd2de1a96..934dd1f2b7 100644 --- a/cli/src/config/index.ts +++ b/cli/src/config/index.ts @@ -1,17 +1,21 @@ import { AsyncLocalStorage } from 'node:async_hooks' import { existsSync, realpathSync, statSync } from 'node:fs' -import { readFile } from 'node:fs/promises' +import { readFile, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { basename, extname, isAbsolute, relative, resolve, sep } from 'node:path' import { cwd } from 'node:process' import type { CapacitorConfig, ExtConfigPairs } from '../schemas/config' -import { loadConfig as loadConfigCap, requireTS, writeConfig as writeConfigCap } from '../capacitor-cli' +import { formatJSObject, loadConfig as loadConfigCap, requireTS, writeConfig as writeConfigCap } from '../capacitor-cli' export type { CapacitorConfig, ExtConfigPairs } from '../schemas/config' let configWriteTarget: string | undefined const configWriteTargetStore = new AsyncLocalStorage<{ filePath: string | undefined }>() -const capacitorConfigFilePattern = /^capacitor\.config(?:\.[^.]+)*\.(?:ts|json)$/ +// `.js` is accepted alongside `.ts`/`.json` — these are the three config names Capacitor's +// own loader recognizes (see `capacitorConfigFiles` in init/command.ts). `.mjs`/`.cjs` are +// intentionally excluded: Capacitor never loads them as configs, and the `.js` writer below +// emits CommonJS, so allowing them would let reads pass but writes silently no-op. +const capacitorConfigFilePattern = /^capacitor\.config(?:\.[^.]+)*\.(?:ts|js|json)$/ /** * Overrides the config file Capacitor writes after loading the active root config. @@ -45,7 +49,7 @@ export function resolveCapacitorConfigTargetPath(value: string | undefined, init if (!existsSync(resolved) || !statSync(resolved).isFile()) throw new Error(`Capacitor config path does not exist: ${resolved}`) if (!capacitorConfigFilePattern.test(basename(resolved))) - throw new Error(`Capacitor config path must point to a capacitor.config.*.ts or capacitor.config.*.json file: ${resolved}`) + throw new Error(`Capacitor config path must point to a capacitor.config.*.ts, capacitor.config.*.js, or capacitor.config.*.json file: ${resolved}`) const workspaceRoot = realpathSync(initialCwd) const target = realpathSync(resolved) @@ -59,11 +63,32 @@ async function loadConfigTarget(filePath: string): Promise { if (extname(filePath) === '.json') return JSON.parse(await readFile(filePath, 'utf8')) as CapacitorConfig - const configModule = requireTS(createRequire(filePath)('typescript'), filePath) + // Mirror Capacitor's own `capacitor.config.js` loader, which simply `require()`s the file. + const configModule = extname(filePath) === '.js' + ? (createRequire(filePath)(filePath) as Record) + : requireTS(createRequire(filePath)('typescript'), filePath) const exportedConfig = configModule.default ?? configModule return (typeof exportedConfig === 'function' ? await exportedConfig() : await exportedConfig) as CapacitorConfig } +/** + * Persists a config update to the target file. Capacitor's own `writeConfig` + * silently no-ops on `.js` (it only formats `.ts`/`.json`), so we format and + * write `capacitor.config.js` ourselves — mirroring how Capacitor emits `.ts`. + */ +async function writeConfigTarget(extConfig: CapacitorConfig, filePath: string): Promise { + if (extname(filePath) === '.js') { + const source = `/** @type {import('@capacitor/cli').CapacitorConfig} */ +const config = ${formatJSObject(extConfig)} + +module.exports = config +` + await writeFile(filePath, source) + return + } + await writeConfigCap(extConfig, filePath) +} + export async function loadConfig(): Promise { const config = await loadConfigCap() return { @@ -107,7 +132,7 @@ export async function writeConfig(key: string, config: ExtConfigPairs, raw = fal extConfig.plugins[key] = config.config.plugins?.[key] else extConfig = config.config - await writeConfigCap(extConfig, oldConfig.path) + await writeConfigTarget(extConfig, oldConfig.path) } } diff --git a/cli/test/test-capacitor-config-target.mjs b/cli/test/test-capacitor-config-target.mjs index 46d0f81619..9abee88f11 100644 --- a/cli/test/test-capacitor-config-target.mjs +++ b/cli/test/test-capacitor-config-target.mjs @@ -75,6 +75,24 @@ try { appName: 'Factory app', webDir: 'factory-www', }) +` + const javascriptConfigSource = `/** @type {import('@capacitor/cli').CapacitorConfig} */ +const config = { + appId: 'com.example.javascript', + appName: 'JavaScript app', + webDir: 'javascript-www', + plugins: { + JsOnlyPlugin: { + enabled: true, + }, + CapacitorUpdater: { + appId: 'com.example.javascript', + jsOnly: true, + }, + }, +} + +module.exports = config ` const appDir = join(root, 'apps', 'qr-code-reader') mkdirSync(configDir, { recursive: true }) @@ -86,14 +104,16 @@ try { writeFileSync(multiPartConfigTarget, 'export default {}\n') writeFileSync(configTarget, configTargetSource) writeFileSync(jsonConfigTarget, JSON.stringify({ appId: 'com.example.json', appName: 'JSON app', webDir: 'json-www' })) - writeFileSync(javascriptConfigTarget, 'module.exports = {}\n') + writeFileSync(javascriptConfigTarget, javascriptConfigSource) writeFileSync(factoryConfigTarget, factoryConfigSource) writeFileSync(join(configDir, 'not-a-capacitor-config.ts'), 'export default {}\n') + writeFileSync(join(configDir, 'capacitor.config.esm.mjs'), 'export default {}\n') writeFileSync(outsideConfigTarget, 'export default {}\n') assert.equal(resolveCapacitorConfigTargetPath('./env-configs/capacitor.config.qr-code-reader.production.ts', root), multiPartConfigTarget) assert.equal(resolveCapacitorConfigTargetPath('./env-configs/capacitor.config.qr-code-reader.ts', root), configTarget) assert.equal(resolveCapacitorConfigTargetPath('./env-configs/capacitor.config.json-target.json', root), jsonConfigTarget) - assert.throws(() => resolveCapacitorConfigTargetPath('./env-configs/capacitor.config.javascript.js', root), /\.ts or capacitor\.config\.\*\.json/) + assert.equal(resolveCapacitorConfigTargetPath('./env-configs/capacitor.config.javascript.js', root), javascriptConfigTarget) + assert.throws(() => resolveCapacitorConfigTargetPath('./env-configs/capacitor.config.esm.mjs', root), /\.ts, capacitor\.config\.\*\.js, or capacitor\.config\.\*\.json/) assert.throws(() => resolveCapacitorConfigTargetPath(relative(root, outsideConfigTarget), root), /must stay within the current working directory/) assert.throws(() => resolveCapacitorConfigTargetPath(outsideConfigTarget, root), /must stay within the current working directory/) const outsideLink = join(root, 'outside-link') @@ -110,6 +130,11 @@ try { const factoryConfigWriteSnapshot = await loadConfigForWrite() assert.equal(factoryConfigWriteSnapshot.config.appId, 'com.example.factory') assert.equal(factoryConfigWriteSnapshot.config.webDir, 'factory-www') + setConfigWriteTarget(javascriptConfigTarget) + const javascriptConfigWriteSnapshot = await loadConfigForWrite() + assert.equal(javascriptConfigWriteSnapshot.config.appId, 'com.example.javascript') + assert.equal(javascriptConfigWriteSnapshot.config.webDir, 'javascript-www') + assert.equal(javascriptConfigWriteSnapshot.config.plugins.CapacitorUpdater.jsOnly, true) setConfigWriteTarget(jsonConfigTarget) const jsonConfigSnapshot = await getConfig() assert.equal(jsonConfigSnapshot.config.appId, 'com.example.root') @@ -191,6 +216,34 @@ try { assert.doesNotMatch(writtenTargetConfig, /RootOnlyPlugin/) assert.equal(readFileSync(rootConfig, 'utf8'), rootConfigSource) + // Capacitor's own writeConfig silently no-ops on `.js`, so exercise a real write + // through the CLI and confirm the `capacitor.config.js` target is actually updated. + const javascriptCommand = spawnSync('node', [ + join(cliRoot, 'dist/index.js'), + 'app', + 'setting', + 'plugins.CapacitorUpdater.autoUpdate', + '--bool', + 'false', + '--capacitor-config', + javascriptConfigTarget, + ], { + cwd: root, + encoding: 'utf8', + env: { ...process.env, CAPGO_DISABLE_TELEMETRY: 'true' }, + }) + + assert.equal(javascriptCommand.status, 0, `${javascriptCommand.stdout}\n${javascriptCommand.stderr}`) + const writtenJavascriptConfig = readFileSync(javascriptConfigTarget, 'utf8') + assert.match(writtenJavascriptConfig, /module\.exports = config/) + assert.match(writtenJavascriptConfig, /appId:\s*'com\.example\.javascript'/) + assert.match(writtenJavascriptConfig, /webDir:\s*'javascript-www'/) + assert.match(writtenJavascriptConfig, /JsOnlyPlugin/) + assert.match(writtenJavascriptConfig, /jsOnly:\s*true/) + assert.match(writtenJavascriptConfig, /autoUpdate:\s*false/) + assert.doesNotMatch(writtenJavascriptConfig, /RootOnlyPlugin/) + assert.equal(readFileSync(rootConfig, 'utf8'), rootConfigSource) + const notificationHelper = join(root, 'src', 'capgo-notifications.ts') const notificationsCommand = spawnSync('node', [ join(cliRoot, 'dist/index.js'),