Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cli/src/capacitor-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -24,6 +26,9 @@ export interface CapacitorCliConfig {
}

export const requireTS: (typescript: unknown, filePath: string) => Record<string, unknown> = 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<CapacitorCliConfig> = loadConfigUntyped
export const writeConfig: (extConfig: CapacitorConfig, extConfigFilePath: string) => Promise<void> = writeConfigUntyped
export const findMonorepoRoot: (currentPath: string) => string = findMonorepoRootUntyped
Expand Down
37 changes: 31 additions & 6 deletions cli/src/config/index.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -59,11 +63,32 @@ async function loadConfigTarget(filePath: string): Promise<CapacitorConfig> {
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<string, unknown>)
: 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<void> {
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<ExtConfigPairs | undefined> {
const config = await loadConfigCap()
return {
Expand Down Expand Up @@ -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)
}
}

Expand Down
57 changes: 55 additions & 2 deletions cli/test/test-capacitor-config-target.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand All @@ -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')
Expand All @@ -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')
Expand Down Expand Up @@ -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'),
Expand Down
Loading