Skip to content

Commit f04ec24

Browse files
author
figma-bot
committed
Code Connect v1.4.6
1 parent 9c3c523 commit f04ec24

18 files changed

Lines changed: 690 additions & 242 deletions

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,18 @@
1+
# Code Connect v1.4.6 (22 May 2026)
2+
3+
## Features
4+
5+
### General
6+
7+
- The `--file` (`-f`) option on `figma connect publish`, `unpublish`, and `parse` now accepts multiple files, so you can target several Code Connect files in one command (e.g. `figma connect publish --file a.figma.ts b.figma.ts`). Previously only a single file path was accepted.
8+
- Shared flags (`--verbose`, `--token`, `--config`, `--dir`, `--file`, `--out-file`, `--out-dir`, `--api-url`, `--skip-update-check`, `--exit-on-unreadable-files`, `--dry-run`) now work whether you write them before or after the subcommand name. Previously, only `figma connect publish -v` toggled verbose mode; `figma connect -v publish` was silently ignored.
9+
10+
### Compose
11+
12+
- Fixed a crash that prevented the Code Connect Gradle plugin from being used with projects on Kotlin 2.3 or newer. The plugin now runs Kotlin source parsing in an isolated worker process, so it no longer ships an embedded Kotlin compiler in the consumer's build environment and cannot conflict with the host project's Kotlin Gradle Plugin across version upgrades.
13+
- Fixed `parseCodeConnect` / `createCodeConnect` failing in multi-module Gradle projects when the plugin is applied to more than one subproject. The task now resolves the `-PfilePath`/`-PoutputDir` arguments against the root project so every subproject finds the input the CLI wrote at the build root.
14+
- The Code Connect Gradle tasks now work cleanly with Gradle's configuration cache enabled. Previously, enabling `--configuration-cache` caused an opaque serialization error; the tasks now store and reuse the configuration cache entry as expected.
15+
116
# Code Connect v1.4.5 (13 May 2026)
217

318
## Fixed

cli/npm_catalog.toml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -183,15 +183,18 @@
183183
"@storybook/test" = "8.5.1"
184184
"@storybook/theming" = "8.5.1"
185185
"storybook" = "8.5.1"
186-
"@stylexjs/stylex" = "0.17.4"
187-
"@stylexjs/babel-plugin" = "0.17.4"
188-
"@stylexjs/eslint-plugin" = "0.17.4"
186+
"@stylexjs/stylex" = "0.18.3"
187+
"@stylexjs/babel-plugin" = "0.18.3"
188+
"@stylexjs/eslint-plugin" = "0.18.3"
189189
"@tailwindcss/oxide" = "4.1.3"
190190
"@tailwindcss/postcss" = "4.1.3"
191191
"@tailwindcss/vite" = "4.1.3"
192192
"tailwindcss" = "4.1.3"
193193
"@tanstack/react-router" = "1.130.0"
194194
"@tanstack/react-router-devtools" = "1.130.0"
195+
"@terrazzo/cli" = "2.1.0"
196+
"@terrazzo/plugin-css" = "2.1.0"
197+
"@terrazzo/plugin-tailwind" = "2.1.0"
195198
"webpack" = "5.91.0"
196199
"webpack-cli" = "5.1.4"
197200
"modal" = "0.7.2"

cli/package.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@figma/code-connect",
3-
"version": "1.4.5",
3+
"version": "1.4.6",
44
"description": "A tool for connecting your design system components in code with your design system in Figma",
55
"keywords": [],
66
"author": "Figma",
@@ -46,7 +46,7 @@
4646
"dev": "tsx src/cli.ts",
4747
"build": "rm -rf dist && tsc --noCheck",
4848
"build:web": "pnpm build",
49-
"build:webpack": "node scripts/resolve-dependencies.js && cross-env NODE_OPTIONS=\"--max-old-space-size=8196\" webpack --mode production",
49+
"build:webpack": "cross-env NODE_OPTIONS=\"--max-old-space-size=8196\" webpack --mode production",
5050
"test": "npm run test:no-coverage -- --coverage",
5151
"test:no-coverage": "cross-env NODE_OPTIONS=\"--experimental-vm-modules --no-deprecation --max-old-space-size=10240\" npx jest --logHeapUsage --workerIdleMemoryLimit=1.5G",
5252
"test:fast": "npm run test -- --testPathIgnorePatterns=template_rendering.test.ts --testPathIgnorePatterns=e2e_parse_command_swift.test.ts --testPathIgnorePatterns=e2e_wizard_swift.test.ts",
@@ -57,7 +57,7 @@
5757
"test:swift": "npm run test -- --runInBand --testPathPattern=e2e_parse_command_swift.test.ts --testPathPattern=e2e_wizard_swift.test.ts --testPathPattern=e2e_parse_command_swift_xcodeproj.test.ts",
5858
"test:non-mac": "npm run test -- --testPathIgnorePatterns=e2e_parse_command_swift.test.ts --testPathIgnorePatterns=e2e_wizard_swift.test.ts --testPathIgnorePatterns=e2e_parse_command_swift_xcodeproj.test.ts",
5959
"bundle": "npm run build && npm pack && mkdir -p bundle && mv figma-code-connect*.tgz bundle",
60-
"bundle:local": "cp package.json package.json.bak && node scripts/resolve-dependencies.js && npm run build && npm pack && mkdir -p bundle-local && mv figma-code-connect*.tgz bundle-local; mv package.json.bak package.json",
60+
"bundle:local": "npm run build && mkdir -p bundle-local && pnpm pack --pack-destination bundle-local",
6161
"bundle:npm-readme:prepare": "mv README.md ../cli-README.md.bak && cp ../README.md . && npx tsx ../scripts/make_readme_links_absolute.ts",
6262
"bundle:npm-readme:restore": "mv ../cli-README.md.bak README.md",
6363
"bundle:npm": "npm run build && npm run bundle:npm-readme:prepare && npm pack && mkdir -p bundle-npm && mv figma-code-connect*.tgz bundle-npm; npm run bundle:npm-readme:restore",
@@ -76,6 +76,8 @@
7676
"@babel/parser": "7.28.5",
7777
"@babel/types": "7.28.5",
7878

79+
80+
7981
"@storybook/csf-tools": "8.5.1",
8082
"@types/cross-spawn": "^6.0.6",
8183
"@types/jest": "^29.5.13",
@@ -96,8 +98,6 @@
9698
"webpack-cli": "5.1.4"
9799
},
98100
"dependencies": {
99-
100-
101101
"boxen": "5.1.1",
102102

103103

cli/src/cli.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ async function run() {
1010
maybePrefillWizardQuestionsForTesting()
1111

1212
const program = new commander.Command().version(require('./../package.json').version)
13-
program.enablePositionalOptions()
1413

1514
addConnectCommandToProgram(program)
1615

cli/src/commands/__test__/filterProjectInfoByFile.test.ts

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,13 @@ describe('filterProjectInfoByFile', () => {
3030
expect(filterProjectInfoByFile(info, '')).toBe(info)
3131
})
3232

33-
it('filters files to only the specified file', () => {
33+
it('returns projectInfo unchanged when file list is empty', () => {
34+
const info = makeProjectInfo(['/a.ts'])
35+
expect(filterProjectInfoByFile(info, [])).toBe(info)
36+
expect(existsSync).not.toHaveBeenCalled()
37+
})
38+
39+
it('filters files to only the specified file (string input)', () => {
3440
const absFile = path.resolve('/project/a.ts')
3541
const info = makeProjectInfo([absFile, '/project/b.ts'])
3642
existsSync.mockReturnValue(true)
@@ -43,18 +49,42 @@ describe('filterProjectInfoByFile', () => {
4349
expect(result).not.toBe(info)
4450
})
4551

46-
it('exits with error when the file does not exist on disk', () => {
47-
existsSync.mockReturnValue(false)
48-
filterProjectInfoByFile(makeProjectInfo([]), '/project/missing.ts')
52+
it('filters files to the specified files (array input)', () => {
53+
const absA = path.resolve('/project/a.ts')
54+
const absB = path.resolve('/project/b.ts')
55+
const info = makeProjectInfo([absA, absB, '/project/c.ts'])
56+
existsSync.mockReturnValue(true)
57+
58+
const result = filterProjectInfoByFile(info, ['/project/a.ts', '/project/b.ts'])
59+
60+
expect(result.files).toEqual([absA, absB])
61+
})
62+
63+
it('preserves projectInfo.files ordering regardless of input order', () => {
64+
const absA = path.resolve('/project/a.ts')
65+
const absB = path.resolve('/project/b.ts')
66+
const info = makeProjectInfo([absA, absB])
67+
existsSync.mockReturnValue(true)
68+
69+
const result = filterProjectInfoByFile(info, ['/project/b.ts', '/project/a.ts'])
70+
71+
expect(result.files).toEqual([absA, absB])
72+
})
73+
74+
it('exits with error when any file does not exist on disk', () => {
75+
existsSync.mockImplementation((p: string) => !p.endsWith('missing.ts'))
76+
filterProjectInfoByFile(makeProjectInfo([]), ['/project/a.ts', '/project/missing.ts'])
4977
expect(exitWithError).toHaveBeenCalledWith(expect.stringContaining('File not found:'))
5078
})
5179

52-
it('exits with error when the file is not in the project file list', () => {
80+
it('exits with error when any file is not in the project file list', () => {
5381
existsSync.mockReturnValue(true)
54-
const absFile = path.resolve('/project/unlisted.ts')
55-
filterProjectInfoByFile(makeProjectInfo(['/other.ts']), '/project/unlisted.ts')
56-
expect(exitWithError).toHaveBeenCalledWith(
57-
expect.stringContaining(`File ${absFile} was not found in the project's file list`),
58-
)
82+
const absUnlisted = path.resolve('/project/unlisted.ts')
83+
const absListed = path.resolve('/project/listed.ts')
84+
filterProjectInfoByFile(makeProjectInfo([absListed]), [
85+
'/project/listed.ts',
86+
'/project/unlisted.ts',
87+
])
88+
expect(exitWithError).toHaveBeenCalledWith(expect.stringContaining(` - ${absUnlisted}`))
5989
})
6090
})
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import * as commander from 'commander'
2+
import { addConnectCommandToProgram } from '../connect'
3+
4+
type Opts = Record<string, unknown>
5+
6+
// Build the real production command tree via addConnectCommandToProgram,
7+
// then replace the named subcommand's action with a spy so we can assert on
8+
// the options the handler would have received. This exercises the actual
9+
// preAction hook wired up in connect.ts — if that hook is removed or broken,
10+
// these tests fail.
11+
function exerciseSubcommand(subcommandName: string) {
12+
const program = new commander.Command()
13+
addConnectCommandToProgram(program)
14+
const connectCmd = program.commands.find((c) => c.name() === 'connect')!
15+
const sub = connectCmd.commands.find((c) => c.name() === subcommandName)!
16+
let captured: Opts = {}
17+
// commander's action callback receives any positional arguments first, then
18+
// the options object, then the Command instance — so options is always the
19+
// second-to-last argument regardless of the subcommand's positional shape.
20+
sub.action((...allArgs: unknown[]) => {
21+
captured = allArgs[allArgs.length - 2] as Opts
22+
})
23+
return (args: string[]): Opts => {
24+
program.parse(['node', 'cli', ...args])
25+
return captured
26+
}
27+
}
28+
29+
describe('addConnectCommandToProgram: shared option propagation', () => {
30+
it('forwards leading flags through to the subcommand handler', () => {
31+
const parse = exerciseSubcommand('parse')
32+
expect(parse(['connect', '-v', '-c', 'cfg', 'parse'])).toMatchObject({
33+
verbose: true,
34+
config: 'cfg',
35+
})
36+
})
37+
38+
it('still honours trailing flags', () => {
39+
const parse = exerciseSubcommand('parse')
40+
expect(parse(['connect', 'parse', '-v', '-c', 'cfg'])).toMatchObject({
41+
verbose: true,
42+
config: 'cfg',
43+
})
44+
})
45+
46+
it.each([
47+
['leading', ['connect', '-v', 'parse']],
48+
['trailing', ['connect', 'parse', '-v']],
49+
])('--verbose works in %s position', (_label, args) => {
50+
expect(exerciseSubcommand('parse')(args).verbose).toBe(true)
51+
})
52+
53+
it.each([
54+
['leading', ['connect', '-t', 'TKN', 'publish']],
55+
['trailing', ['connect', 'publish', '-t', 'TKN']],
56+
])('--token works in %s position', (_label, args) => {
57+
expect(exerciseSubcommand('publish')(args).token).toBe('TKN')
58+
})
59+
60+
it.each([
61+
['leading', ['connect', '-c', 'cfg.json', 'publish']],
62+
['trailing', ['connect', 'publish', '-c', 'cfg.json']],
63+
])('--config works in %s position', (_label, args) => {
64+
expect(exerciseSubcommand('publish')(args).config).toBe('cfg.json')
65+
})
66+
67+
it.each([
68+
['leading', ['connect', '--api-url', 'https://api.test', 'preview']],
69+
['trailing', ['connect', 'preview', '--api-url', 'https://api.test']],
70+
])('--api-url works in %s position', (_label, args) => {
71+
expect(exerciseSubcommand('preview')(args).apiUrl).toBe('https://api.test')
72+
})
73+
74+
it.each([
75+
['leading', ['connect', '--skip-update-check', 'parse']],
76+
['trailing', ['connect', 'parse', '--skip-update-check']],
77+
])('--skip-update-check works in %s position', (_label, args) => {
78+
expect(exerciseSubcommand('parse')(args).skipUpdateCheck).toBe(true)
79+
})
80+
81+
it.each([
82+
['leading', ['connect', '--dry-run', 'publish']],
83+
['trailing', ['connect', 'publish', '--dry-run']],
84+
])('--dry-run works in %s position', (_label, args) => {
85+
expect(exerciseSubcommand('publish')(args).dryRun).toBe(true)
86+
})
87+
88+
it('the same flag at both levels resolves rightmost-wins', () => {
89+
const parse = exerciseSubcommand('publish')
90+
expect(parse(['connect', '-t', 'PARENT', 'publish', '-t', 'CHILD']).token).toBe('CHILD')
91+
})
92+
93+
it('subcommand-only flags (e.g. --force on publish) still reach the handler', () => {
94+
const parse = exerciseSubcommand('publish')
95+
expect(parse(['connect', '-v', 'publish', '--force'])).toMatchObject({
96+
verbose: true,
97+
force: true,
98+
})
99+
})
100+
101+
it('with no shared flag supplied, options stays at subcommand defaults', () => {
102+
const parse = exerciseSubcommand('parse')
103+
const opts = parse(['connect', 'parse'])
104+
expect(opts.verbose).toBeUndefined()
105+
expect(opts.token).toBeUndefined()
106+
expect(opts.config).toBeUndefined()
107+
})
108+
109+
it('multiple shared flags written in mixed positions all reach the handler', () => {
110+
const parse = exerciseSubcommand('publish')
111+
expect(parse(['connect', '-v', 'publish', '-t', 'TKN'])).toMatchObject({
112+
verbose: true,
113+
token: 'TKN',
114+
})
115+
})
116+
})

cli/src/commands/connect.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export type BaseCommand = commander.Command & {
5757
config: string
5858
dryRun: boolean
5959
dir: string
60-
file: string
60+
file: string[]
6161
jsonFile: string
6262
skipUpdateCheck: boolean
6363
exitOnUnreadableFiles: boolean
@@ -71,7 +71,10 @@ function addBaseCommand(command: commander.Command, name: string, description: s
7171
.description(description)
7272
.usage('[options]')
7373
.option('-r --dir <dir>', 'directory to parse')
74-
.option('-f --file <file>', 'path to a single Code Connect file to process')
74+
.option(
75+
'-f --file <file...>',
76+
'paths to one or more Code Connect files to process (space-separated)',
77+
)
7578
.option('-t --token <token>', 'figma access token')
7679
.option('-v --verbose', 'enable verbose logging for debugging')
7780
.option('-o --outFile <file>', 'specify a file to output generated Code Connect')
@@ -96,6 +99,12 @@ export function addConnectCommandToProgram(program: commander.Command) {
9699
withUpdateCheck(runWizard),
97100
)
98101

102+
// Make shared base flags work on either side of the subcommand name —
103+
// `figma connect -v publish` and `figma connect publish -v` both honour -v.
104+
connectCommand.hook('preAction', (_thisCommand, actionCommand) => {
105+
Object.assign(actionCommand.opts(), actionCommand.optsWithGlobals())
106+
})
107+
99108
// Sub-commands, invoked with e.g. `figma connect publish`
100109
addBaseCommand(
101110
connectCommand,

cli/src/commands/connect_template.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export type BaseCommand = commander.Command & {
2424
config: string
2525
dryRun: boolean
2626
dir: string
27-
file: string
27+
file: string[]
2828
jsonFile: string
2929
skipUpdateCheck: boolean
3030
exitOnUnreadableFiles: boolean
@@ -321,7 +321,7 @@ interface ProgramOptions {
321321
*
322322
* Note: does NOT use addBaseCommand internally because that function registers
323323
* both `-o --outFile` and `-o --outDir` with the same `-o` short flag.
324-
* commander@12+ (used by litmus-cli) throws a conflict error on duplicate flags.
324+
* commander@12+ (used by @figma/cli) throws a conflict error on duplicate flags.
325325
*/
326326
export function addCodeConnectCommandsToProgram(
327327
program: commander.Command,
@@ -333,7 +333,10 @@ export function addCodeConnectCommandsToProgram(
333333
.description(description)
334334
.usage('[options]')
335335
.option('-r --dir <dir>', 'directory to parse')
336-
.option('-f --file <file>', 'path to a single Code Connect file to process')
336+
.option(
337+
'-f --file <file...>',
338+
'paths to one or more Code Connect files to process (space-separated)',
339+
)
337340
.option('--outFile <file>', 'specify a file to output generated Code Connect')
338341
.option('--outDir <dir>', 'specify a directory to output generated Code Connect')
339342
.option('-c --config <path>', 'path to a figma config file')

cli/src/commands/filter_project_info.ts

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,34 +4,40 @@ import { ProjectInfo } from '../connect/project'
44
import { exitWithError } from '../common/logging'
55

66
/**
7-
* If --file is specified, filter projectInfo.files to only include that file.
8-
* Exits with an error if the file doesn't exist or isn't in the project's file list.
7+
* If --file is specified, filter projectInfo.files to only include those files.
8+
* Exits with an error if any file doesn't exist or isn't in the project's file list.
99
*/
1010
export function filterProjectInfoByFile(
1111
projectInfo: ProjectInfo,
12-
file: string | undefined,
12+
files: string[] | string | undefined,
1313
): ProjectInfo {
14-
if (!file) {
14+
if (!files || (Array.isArray(files) && files.length === 0)) {
1515
return projectInfo
1616
}
1717

18-
const absFile = path.resolve(file)
18+
const inputs = Array.isArray(files) ? files : [files]
19+
const absFiles = inputs.map((f) => path.resolve(f))
1920

20-
if (!fs.existsSync(absFile)) {
21-
exitWithError(`File not found: ${absFile}`)
21+
for (const absFile of absFiles) {
22+
if (!fs.existsSync(absFile)) {
23+
exitWithError(`File not found: ${absFile}`)
24+
}
2225
}
2326

24-
const matched = projectInfo.files.filter((f) => f === absFile)
27+
const projectFileSet = new Set(projectInfo.files)
28+
const missing = absFiles.filter((f) => !projectFileSet.has(f))
2529

26-
if (matched.length === 0) {
30+
if (missing.length > 0) {
2731
exitWithError(
28-
`File ${absFile} was not found in the project's file list. ` +
29-
`Make sure it matches the include/exclude globs in your config.`,
32+
`The following file(s) were not found in the project's file list:\n` +
33+
missing.map((f) => ` - ${f}`).join('\n') +
34+
`\nMake sure they match the include/exclude globs in your config.`,
3035
)
3136
}
3237

38+
const requestedSet = new Set(absFiles)
3339
return {
3440
...projectInfo,
35-
files: matched,
41+
files: projectInfo.files.filter((f) => requestedSet.has(f)),
3642
}
3743
}

0 commit comments

Comments
 (0)