kasy-cli 1.31.6 → 1.31.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/kasy.js CHANGED
@@ -602,7 +602,22 @@ function buildProgram(language) {
602
602
  ],
603
603
  });
604
604
  if (sub === 'configure') await runCodemagicConfigure('.', { language });
605
- else if (sub === 'release') await runCodemagicRelease('.', { language });
605
+ else if (sub === 'release') {
606
+ const platform = await ui.select({
607
+ message: t('cli.command.codemagic.release.platformPick'),
608
+ options: [
609
+ { value: 'both', label: t('cli.command.codemagic.release.platform.both') },
610
+ { value: 'ios', label: t('cli.command.codemagic.release.platform.ios') },
611
+ { value: 'android', label: t('cli.command.codemagic.release.platform.android') },
612
+ ],
613
+ });
614
+ if (typeof platform === 'string') {
615
+ const relOpts = { language };
616
+ if (platform === 'ios') relOpts.ios = true;
617
+ else if (platform === 'android') relOpts.android = true;
618
+ await runCodemagicRelease('.', relOpts);
619
+ }
620
+ }
606
621
  else if (sub === 'status') {
607
622
  const buildId = await ui.text({
608
623
  message: 'Build ID',
@@ -625,9 +640,11 @@ function buildProgram(language) {
625
640
  codemagicCmd
626
641
  .command('release')
627
642
  .argument('[directory]', 'Project folder (default: current directory)', '.')
643
+ .option('--ios', t('cli.command.codemagic.release.iosOption'))
644
+ .option('--android', t('cli.command.codemagic.release.androidOption'))
628
645
  .description(t('cli.command.codemagic.release.description'))
629
- .action(async (directory) => {
630
- await runCodemagicRelease(directory, { language });
646
+ .action(async (directory, opts) => {
647
+ await runCodemagicRelease(directory, { language, ios: opts.ios, android: opts.android });
631
648
  }),
632
649
  t
633
650
  );
@@ -121,15 +121,17 @@ Atalho: `make release-ios`
121
121
 
122
122
  ---
123
123
 
124
- ### `kasy codemagic` — Release iOS (nuvem)
124
+ ### `kasy codemagic` — Release iOS/Android (nuvem)
125
125
 
126
- Dispara build iOS no Codemagic (ideal sem Mac).
126
+ Dispara build **iOS e Android** no Codemagic (ideal sem Mac), levando as chaves do `.env` no disparo.
127
127
 
128
128
  ```bash
129
- kasy add ci # cria codemagic.yaml (se necessário)
130
- kasy codemagic configure # token API + app/workflow ID
131
- kasy codemagic release # inicia build na nuvem
132
- kasy codemagic status <id> # status do build
129
+ kasy add ci # entrega o codemagic.yaml (se necessário)
130
+ kasy codemagic configure # token API valida e lista seus apps → branch
131
+ kasy codemagic release # iOS + Android na nuvem
132
+ kasy codemagic release --ios # iOS
133
+ kasy codemagic release --android # só Android
134
+ kasy codemagic status <id> # status do build
133
135
  ```
134
136
 
135
137
  Documentação: `docs/codemagic-release.md` (idioma da CLI)
@@ -11,18 +11,39 @@ const {
11
11
  codemagicReleaseDocPath,
12
12
  writeCodemagicEnv,
13
13
  validateCodemagicSetup,
14
+ loadProjectEnv,
15
+ listApps,
16
+ workflowIdFor,
17
+ resolveReleaseVars,
14
18
  triggerBuild,
15
19
  getBuildStatus,
16
20
  URL_CODEMAGIC_APPS,
17
21
  URL_CODEMAGIC_API,
18
22
  } = require('../utils/codemagic-release');
19
23
 
24
+ // Human label per platform (proper nouns — same across locales).
25
+ const PLATFORM_LABEL = { ios: 'iOS', android: 'Android' };
26
+
20
27
  async function assertProject(projectDir, t) {
21
28
  if (!(await isKasyFlutterProject(projectDir))) {
22
29
  throw new Error(t('codemagic.error.notProject'));
23
30
  }
24
31
  }
25
32
 
33
+ /**
34
+ * Decide which platforms to release. Explicit flags win; with no flag we build
35
+ * both. Returns an ordered list like ['ios', 'android'].
36
+ */
37
+ function platformsFromOptions(options) {
38
+ const wantIos = !!options.ios;
39
+ const wantAndroid = !!options.android;
40
+ if (!wantIos && !wantAndroid) return ['ios', 'android'];
41
+ const platforms = [];
42
+ if (wantIos) platforms.push('ios');
43
+ if (wantAndroid) platforms.push('android');
44
+ return platforms;
45
+ }
46
+
26
47
  async function runConfigure(directory, options = {}) {
27
48
  const lang = options.language || detectDefaultLanguage();
28
49
  const t = createTranslator(lang);
@@ -35,39 +56,62 @@ async function runConfigure(directory, options = {}) {
35
56
  ui.log.message(kleur.dim(`${t('codemagic.configure.doc')}: ${codemagicReleaseDocPath(lang)}`));
36
57
  ui.log.warn(t('codemagic.configure.checklist'));
37
58
 
38
- ui.log.info(t('codemagic.configure.openingLinks'));
39
- openUrl(URL_CODEMAGIC_API);
40
- openUrl(URL_CODEMAGIC_APPS);
41
-
42
59
  const cancel = () => { ui.cancel(t('codemagic.configure.cancelled')); process.exit(0); };
43
60
  const required = (v) => (v && String(v).trim().length > 0 ? undefined : t('codemagic.configure.q.required'));
44
61
 
45
- const token = await ui.password({
62
+ // 1. API token open the settings page so the user can copy it.
63
+ ui.log.info(t('codemagic.configure.openingToken'));
64
+ openUrl(URL_CODEMAGIC_API);
65
+ const tokenRaw = await ui.password({
46
66
  message: t('codemagic.configure.q.token'),
47
67
  validate: required,
48
68
  onCancel: cancel,
49
69
  });
50
- const appId = await ui.text({
51
- message: t('codemagic.configure.q.appId'),
52
- validate: required,
53
- onCancel: cancel,
54
- });
55
- const workflowId = await ui.text({
56
- message: t('codemagic.configure.q.workflowId'),
57
- initialValue: 'ios-workflow',
58
- onCancel: cancel,
59
- });
60
- const branch = await ui.text({
70
+ const token = String(tokenRaw).trim();
71
+
72
+ // 2. Validate the token by listing the user's apps.
73
+ const spinner = ui.spinner();
74
+ spinner.start(t('codemagic.configure.validating'));
75
+ let apps;
76
+ try {
77
+ apps = await listApps(token);
78
+ spinner.stop(t('codemagic.configure.validated'));
79
+ } catch (err) {
80
+ spinner.stop(`${t('codemagic.configure.tokenInvalid')} (${err.message})`, 2);
81
+ process.exitCode = 1;
82
+ return;
83
+ }
84
+
85
+ // 3. Pick the app (or guide the user to connect a repo first).
86
+ let appId;
87
+ if (!apps.length) {
88
+ ui.log.warn(t('codemagic.configure.noApps'));
89
+ openUrl(URL_CODEMAGIC_APPS);
90
+ const manual = await ui.text({
91
+ message: t('codemagic.configure.q.appId'),
92
+ validate: required,
93
+ onCancel: cancel,
94
+ });
95
+ appId = String(manual).trim();
96
+ } else {
97
+ appId = await ui.select({
98
+ message: t('codemagic.configure.pickApp'),
99
+ options: apps.map((a) => ({ value: a.id, label: a.name, hint: a.id })),
100
+ });
101
+ if (typeof appId === 'symbol') cancel();
102
+ }
103
+
104
+ // 4. Branch that triggers the build.
105
+ const branchRaw = await ui.text({
61
106
  message: t('codemagic.configure.q.branch'),
62
107
  initialValue: 'main',
63
108
  onCancel: cancel,
64
109
  });
65
110
 
66
111
  await writeCodemagicEnv(projectDir, {
67
- token: String(token).trim(),
68
- appId: String(appId).trim(),
69
- workflowId: String(workflowId).trim() || 'ios-workflow',
70
- branch: String(branch).trim() || 'main',
112
+ token,
113
+ appId,
114
+ branch: String(branchRaw).trim() || 'main',
71
115
  });
72
116
 
73
117
  ui.log.success(t('codemagic.configure.success'));
@@ -87,44 +131,64 @@ async function runRelease(directory, options = {}) {
87
131
  if (validation.issues.includes('missing_yaml')) {
88
132
  ui.log.message(kleur.dim(t('codemagic.error.addCi')));
89
133
  }
90
- if (validation.issues.includes('missing_env')) {
134
+ if (validation.issues.includes('missing_env') || validation.issues.includes('missing_token') || validation.issues.includes('missing_app_id')) {
91
135
  ui.log.message(kleur.dim(t('codemagic.error.runConfigure')));
92
136
  }
93
137
  process.exitCode = 1;
94
138
  return;
95
139
  }
96
140
 
97
- const googleScheme = await validateGoogleIosUrlScheme(projectDir);
98
- if (!googleScheme.ok) {
99
- printCompactHeader(t);
100
- ui.log.error(t('codemagic.error.googleUrlScheme'));
101
- ui.log.message(kleur.dim(googleScheme.error));
102
- ui.log.info(t('ios.error.googleUrlSchemeHint'));
103
- process.exitCode = 1;
104
- return;
141
+ const platforms = platformsFromOptions(options);
142
+
143
+ // iOS builds need the Google Sign-In URL scheme to be in sync.
144
+ if (platforms.includes('ios')) {
145
+ const googleScheme = await validateGoogleIosUrlScheme(projectDir);
146
+ if (!googleScheme.ok) {
147
+ printCompactHeader(t);
148
+ ui.log.error(t('codemagic.error.googleUrlScheme'));
149
+ ui.log.message(kleur.dim(googleScheme.error));
150
+ ui.log.info(t('ios.error.googleUrlSchemeHint'));
151
+ process.exitCode = 1;
152
+ return;
153
+ }
105
154
  }
106
155
 
107
156
  printCompactHeader(t);
108
157
  ui.intro(t('codemagic.release.title'));
109
158
 
110
- const spinner = ui.spinner();
111
- spinner.start(t('codemagic.release.spin'));
112
- try {
113
- const result = await triggerBuild(projectDir, validation.env);
114
- spinner.stop(t('codemagic.release.spinDone'));
115
- const buildId = result.buildId || result._id;
116
- if (buildId) {
117
- ui.log.success(t('codemagic.release.triggered'));
118
- ui.log.info(`Build ID: ${kleur.cyan(buildId)}`);
119
- ui.log.message(kleur.dim(`https://codemagic.io/builds/${buildId}`));
120
- ui.outro('');
121
- } else {
122
- ui.outro(t('codemagic.release.triggered'));
159
+ // Read the project's keys and carry them with the build.
160
+ const projectEnv = await loadProjectEnv(projectDir);
161
+ const variables = resolveReleaseVars(projectEnv);
162
+
163
+ if (platforms.includes('ios') && !variables.RC_IOS_PROD_KEY) {
164
+ ui.log.warn(t('codemagic.release.noIosKey'));
165
+ }
166
+ if (platforms.includes('android') && !variables.RC_ANDROID_PROD_KEY) {
167
+ ui.log.warn(t('codemagic.release.noAndroidKey'));
168
+ }
169
+
170
+ let anyFailed = false;
171
+ for (const platform of platforms) {
172
+ const label = PLATFORM_LABEL[platform];
173
+ const workflowId = workflowIdFor(validation.env, platform);
174
+ const spinner = ui.spinner();
175
+ spinner.start(t('codemagic.release.spinPlatform', { platform: label }));
176
+ try {
177
+ const result = await triggerBuild(validation.env, { workflowId, variables });
178
+ const buildId = result.buildId || result._id;
179
+ spinner.stop(t('codemagic.release.triggeredPlatform', { platform: label }));
180
+ if (buildId) {
181
+ ui.log.info(`Build ID: ${kleur.cyan(buildId)}`);
182
+ ui.log.message(kleur.dim(`https://codemagic.io/app/${validation.env.CODEMAGIC_APP_ID}/build/${buildId}`));
183
+ }
184
+ } catch (err) {
185
+ anyFailed = true;
186
+ spinner.stop(`${label}: ${err.message}`, 2);
123
187
  }
124
- } catch (err) {
125
- spinner.stop(err.message, 2);
126
- process.exitCode = 1;
127
188
  }
189
+
190
+ if (anyFailed) process.exitCode = 1;
191
+ ui.outro('');
128
192
  }
129
193
 
130
194
  async function runStatus(buildId, directory, options = {}) {
@@ -168,4 +232,5 @@ module.exports = {
168
232
  runConfigure,
169
233
  runRelease,
170
234
  runStatus,
235
+ platformsFromOptions,
171
236
  };
@@ -1733,6 +1733,11 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1733
1733
  // generation step once choices are made. The success card at the end shows
1734
1734
  // the full summary. Cancel any time with Ctrl+C.
1735
1735
 
1736
+ // Windows: disable native assets before the generator runs pub get / builds,
1737
+ // so a username with a space (C:\Users\John Silva) doesn't break the
1738
+ // objective_c hook compile. No-op elsewhere, idempotent, best-effort.
1739
+ require('../utils/env-tools').disableNativeAssetsWindows();
1740
+
1736
1741
  // ── Generate ────────────────────────────────────────────────────────────
1737
1742
  // Quick: single rolling line that mutates message. Advanced: stack each step.
1738
1743
  const stepper = ui.makeQuickStepper({ color: paintLime });
@@ -7,7 +7,7 @@ const { createTranslator, detectDefaultLanguage } = require('../utils/i18n');
7
7
  const { printCompactHeader } = require('../utils/brand');
8
8
  const { readBundleId, readPackageName } = require('../utils/mobile-identity');
9
9
  const { spawnFlutterWithSpinner } = require('../utils/flutter-run');
10
- const { spawnSyncFlutter } = require('../utils/env-tools');
10
+ const { spawnSyncFlutter, disableNativeAssetsWindows } = require('../utils/env-tools');
11
11
 
12
12
  function runCmd(cmd, args) {
13
13
  const res = spawnSync(cmd, args, { encoding: 'utf8' });
@@ -264,6 +264,10 @@ async function runReset(directory, options = {}) {
264
264
  throw new Error(t('reset.error.notFlutterProject'));
265
265
  }
266
266
 
267
+ // Windows: disable native assets before the reinstall rebuild so a username
268
+ // with a space doesn't break the objective_c hook compile. No-op elsewhere.
269
+ disableNativeAssetsWindows();
270
+
267
271
  printCompactHeader(t);
268
272
  ui.intro(t('reset.title'));
269
273
 
@@ -5,7 +5,7 @@ const ui = require('../utils/ui');
5
5
  const { createTranslator, detectDefaultLanguage } = require('../utils/i18n');
6
6
  const { printCompactHeader, paintLime } = require('../utils/brand');
7
7
  const { spawnFlutterWithSpinner } = require('../utils/flutter-run');
8
- const { spawnSyncFlutter } = require('../utils/env-tools');
8
+ const { spawnSyncFlutter, disableNativeAssetsWindows } = require('../utils/env-tools');
9
9
 
10
10
  function listFlutterDevices(projectDir) {
11
11
  // Shared flutter spawner: exposes a freshly-installed SDK on PATH (the terminal
@@ -249,6 +249,11 @@ async function runRun(directory, options = {}) {
249
249
  throw new Error(t('run.error.notFlutterProject'));
250
250
  }
251
251
 
252
+ // Windows: turn off native assets before building so a username with a space
253
+ // (C:\Users\John Silva) doesn't break the objective_c hook compile. No-op on
254
+ // macOS/Linux, idempotent, and best-effort — see disableNativeAssetsWindows.
255
+ disableNativeAssetsWindows();
256
+
252
257
  // Resolve device flag. If none of the platform shortcuts or -d is set,
253
258
  // ask the user when more than one device is available — `flutter run`
254
259
  // bails out with "More than one device connected" otherwise.
@@ -27,12 +27,14 @@ const ALWAYS_EXCLUDE = new Set([
27
27
  '.cursor',
28
28
  '.idea',
29
29
  '.claude', // AI assistant instructions — not for the client
30
- // CI/CD configs delivered only when the user selects the 'ci' module
31
- // (these files live in features/ci/ and are applied as a feature patch)
30
+ // GitHub/GitLab CI pipelines are not shipped to generated projects yet.
32
31
  '.github',
33
32
  '.gitlab',
34
33
  '.gitlab-ci.yml',
35
- 'codemagic.yaml',
34
+ // NOTE: codemagic.yaml is intentionally NOT excluded here. It ships in the
35
+ // base template and generate.js removes it unless the 'ci' module is
36
+ // selected (see the "Phase B" gating). This is what lets `kasy codemagic`
37
+ // work in a generated project.
36
38
  ]);
37
39
 
38
40
  // File basenames that should NEVER be copied, regardless of directory depth
@@ -236,6 +236,13 @@ async function generateProject(targetDir, backend, options, hooks = {}) {
236
236
  // Remove directories of modules that were NOT selected
237
237
  await removeModuleDirs(targetDir, modules);
238
238
 
239
+ // codemagic.yaml ships in the base template (so `kasy codemagic` works out
240
+ // of the box). It belongs to the 'ci' module — strip it when ci was not
241
+ // selected, mirroring how the other optional modules are gated above.
242
+ if (!modules.includes('ci')) {
243
+ await fs.remove(path.join(targetDir, 'codemagic.yaml'));
244
+ }
245
+
239
246
  // Write no-op analytics_api.dart if analytics not selected
240
247
  if (!modules.includes('analytics')) {
241
248
  await writeNoOpAnalyticsApi(targetDir, packageName);
@@ -8,18 +8,22 @@ const CODEMAGIC_ENV_REL = path.join('.kasy', 'codemagic.env');
8
8
  const CODEMAGIC_API = 'https://api.codemagic.io';
9
9
 
10
10
  const URL_CODEMAGIC_APPS = 'https://codemagic.io/apps';
11
- const URL_CODEMAGIC_API = 'https://codemagic.io/settings/integrations';
11
+ // Codemagic API token lives in the account settings → Integrations page.
12
+ const URL_CODEMAGIC_API = 'https://codemagic.io/settings';
13
+
14
+ // Default workflow ids defined in the codemagic.yaml shipped by the `ci` feature.
15
+ const DEFAULT_IOS_WORKFLOW = 'ios-workflow';
16
+ const DEFAULT_ANDROID_WORKFLOW = 'android-workflow';
12
17
 
13
18
  function codemagicReleaseDocPath() {
14
19
  return 'docs/codemagic-release.md';
15
20
  }
16
21
 
17
- async function loadCodemagicEnv(projectDir) {
18
- const envPath = path.join(projectDir, CODEMAGIC_ENV_REL);
19
- if (!(await fs.pathExists(envPath))) {
20
- return null;
21
- }
22
- const content = await fs.readFile(envPath, 'utf8');
22
+ /**
23
+ * Parse a simple KEY=VALUE env file into an object. Ignores blanks/comments.
24
+ * Values keep everything after the first '=' (so URLs with ':' survive).
25
+ */
26
+ function parseEnvContent(content) {
23
27
  const env = {};
24
28
  for (const line of content.split('\n')) {
25
29
  const trimmed = line.trim();
@@ -31,12 +35,32 @@ async function loadCodemagicEnv(projectDir) {
31
35
  return env;
32
36
  }
33
37
 
34
- async function writeCodemagicEnv(projectDir, { token, appId, workflowId, branch }) {
38
+ async function loadCodemagicEnv(projectDir) {
39
+ const envPath = path.join(projectDir, CODEMAGIC_ENV_REL);
40
+ if (!(await fs.pathExists(envPath))) {
41
+ return null;
42
+ }
43
+ return parseEnvContent(await fs.readFile(envPath, 'utf8'));
44
+ }
45
+
46
+ /**
47
+ * Read the project's runtime `.env` (the app's config). Returns {} when absent.
48
+ */
49
+ async function loadProjectEnv(projectDir) {
50
+ const envPath = path.join(projectDir, '.env');
51
+ if (!(await fs.pathExists(envPath))) {
52
+ return {};
53
+ }
54
+ return parseEnvContent(await fs.readFile(envPath, 'utf8'));
55
+ }
56
+
57
+ async function writeCodemagicEnv(projectDir, { token, appId, iosWorkflowId, androidWorkflowId, branch }) {
35
58
  const lines = [
36
59
  '# Generated by kasy codemagic configure — do not commit',
37
60
  `CODEMAGIC_API_TOKEN=${token}`,
38
61
  `CODEMAGIC_APP_ID=${appId}`,
39
- `CODEMAGIC_WORKFLOW_ID=${workflowId || 'ios-workflow'}`,
62
+ `CODEMAGIC_IOS_WORKFLOW_ID=${iosWorkflowId || DEFAULT_IOS_WORKFLOW}`,
63
+ `CODEMAGIC_ANDROID_WORKFLOW_ID=${androidWorkflowId || DEFAULT_ANDROID_WORKFLOW}`,
40
64
  `CODEMAGIC_BRANCH=${branch || 'main'}`,
41
65
  '',
42
66
  ];
@@ -83,12 +107,92 @@ function httpsJson(method, urlPath, token, body) {
83
107
  });
84
108
  }
85
109
 
86
- async function triggerBuild(projectDir, env) {
110
+ /**
111
+ * List the Codemagic applications the token can access.
112
+ * Returns [{ id, name }] so the configure wizard can let the user pick one
113
+ * instead of pasting a raw Mongo id.
114
+ */
115
+ async function listApps(token) {
116
+ const result = await httpsJson('GET', '/apps', token);
117
+ const apps = Array.isArray(result) ? result : (result.applications || []);
118
+ return apps
119
+ .map((a) => ({ id: a._id || a.id, name: a.appName || a.name || a.repository?.name || '(unnamed app)' }))
120
+ .filter((a) => a.id);
121
+ }
122
+
123
+ /**
124
+ * Resolve which workflow id to trigger for a platform, honoring custom ids
125
+ * saved at configure time and falling back to the shipped defaults. The legacy
126
+ * single CODEMAGIC_WORKFLOW_ID (older configs) is treated as the iOS workflow.
127
+ */
128
+ function workflowIdFor(env, platform) {
129
+ if (platform === 'android') {
130
+ return env.CODEMAGIC_ANDROID_WORKFLOW_ID || DEFAULT_ANDROID_WORKFLOW;
131
+ }
132
+ return env.CODEMAGIC_IOS_WORKFLOW_ID || env.CODEMAGIC_WORKFLOW_ID || DEFAULT_IOS_WORKFLOW;
133
+ }
134
+
135
+ /**
136
+ * Build the environment variables to inject at trigger time, read from the
137
+ * project's `.env`. These travel with the build so the cloud has the SAME
138
+ * production keys you use locally — the user does not have to fill the
139
+ * Codemagic variable groups by hand.
140
+ *
141
+ * RevenueCat SDK keys (appl_/goog_/test_) are public keys, safe to send this
142
+ * way. Real secrets (keystore, App Store key, service account) stay in the
143
+ * dashboard and are NOT sent here.
144
+ */
145
+ function resolveReleaseVars(projectEnv) {
146
+ const vars = { ENV: 'prod' };
147
+
148
+ const passthrough = [
149
+ 'BACKEND_URL',
150
+ 'RC_TEST_KEY',
151
+ 'RC_IOS_PROD_KEY',
152
+ 'RC_ANDROID_PROD_KEY',
153
+ 'MIXPANEL_TOKEN',
154
+ 'SENTRY_DSN',
155
+ ];
156
+ for (const key of passthrough) {
157
+ const value = projectEnv[key];
158
+ if (value && value.trim()) vars[key] = value.trim();
159
+ }
160
+
161
+ // LLM chat endpoint may be named either way across template versions; the app
162
+ // reads AI_CHAT_ENDPOINT.
163
+ const endpoint = projectEnv.AI_CHAT_ENDPOINT || projectEnv.LLM_CHAT_ENDPOINT;
164
+ if (endpoint && endpoint.trim()) vars.AI_CHAT_ENDPOINT = endpoint.trim();
165
+
166
+ // Legacy single-key projects (pre test/prod split): promote a non-test key
167
+ // into the prod slot so release builds still get a real key.
168
+ if (!vars.RC_IOS_PROD_KEY && projectEnv.RC_IOS_API_KEY && !projectEnv.RC_IOS_API_KEY.startsWith('test_')) {
169
+ vars.RC_IOS_PROD_KEY = projectEnv.RC_IOS_API_KEY.trim();
170
+ }
171
+ if (!vars.RC_ANDROID_PROD_KEY && projectEnv.RC_ANDROID_API_KEY && !projectEnv.RC_ANDROID_API_KEY.startsWith('test_')) {
172
+ vars.RC_ANDROID_PROD_KEY = projectEnv.RC_ANDROID_API_KEY.trim();
173
+ }
174
+
175
+ // Numeric App Store id → APP_ID, used by the iOS workflow for build numbering.
176
+ if (projectEnv.APP_STORE_ID && projectEnv.APP_STORE_ID.trim()) {
177
+ vars.APP_ID = projectEnv.APP_STORE_ID.trim();
178
+ }
179
+
180
+ return vars;
181
+ }
182
+
183
+ /**
184
+ * Trigger one build. `variables` is injected via the build's environment so the
185
+ * cloud build carries the project's production keys.
186
+ */
187
+ async function triggerBuild(env, { workflowId, branch, variables }) {
87
188
  const body = {
88
189
  appId: env.CODEMAGIC_APP_ID,
89
- workflowId: env.CODEMAGIC_WORKFLOW_ID,
90
- branch: env.CODEMAGIC_BRANCH || 'main',
190
+ workflowId,
191
+ branch: branch || env.CODEMAGIC_BRANCH || 'main',
91
192
  };
193
+ if (variables && Object.keys(variables).length > 0) {
194
+ body.environment = { variables };
195
+ }
92
196
  return httpsJson('POST', '/builds', env.CODEMAGIC_API_TOKEN, body);
93
197
  }
94
198
 
@@ -109,7 +213,6 @@ async function validateCodemagicSetup(projectDir) {
109
213
  }
110
214
  if (!env.CODEMAGIC_API_TOKEN) issues.push('missing_token');
111
215
  if (!env.CODEMAGIC_APP_ID) issues.push('missing_app_id');
112
- if (!env.CODEMAGIC_WORKFLOW_ID) issues.push('missing_workflow');
113
216
  return { ok: issues.length === 0, issues, env };
114
217
  }
115
218
 
@@ -118,9 +221,15 @@ module.exports = {
118
221
  CODEMAGIC_API,
119
222
  URL_CODEMAGIC_APPS,
120
223
  URL_CODEMAGIC_API,
224
+ DEFAULT_IOS_WORKFLOW,
225
+ DEFAULT_ANDROID_WORKFLOW,
121
226
  codemagicReleaseDocPath,
122
227
  loadCodemagicEnv,
228
+ loadProjectEnv,
123
229
  writeCodemagicEnv,
230
+ listApps,
231
+ workflowIdFor,
232
+ resolveReleaseVars,
124
233
  triggerBuild,
125
234
  getBuildStatus,
126
235
  validateCodemagicSetup,
@@ -201,6 +201,47 @@ function flutterSpawn(spawnFn, args, options = {}) {
201
201
  const spawnFlutter = (args, options) => flutterSpawn(spawn, args, options);
202
202
  const spawnSyncFlutter = (args, options) => flutterSpawn(spawnSync, args, options);
203
203
 
204
+ // Run the native-assets disable at most once per process — the setting is
205
+ // persisted in Flutter's global config, so re-running it every command would
206
+ // just add ~1s of cold flutter startup for nothing.
207
+ let nativeAssetsHandled = false;
208
+
209
+ /**
210
+ * Disable Flutter "native assets" compilation — Windows only.
211
+ *
212
+ * Why: `path_provider_foundation` (the Apple impl of `path_provider`, pulled in
213
+ * transitively by almost everything — google_fonts, home_widget,
214
+ * flutter_secure_storage…) ships a native-assets build hook for the
215
+ * `objective_c` package. On Windows, Dart's hook runner builds that compile
216
+ * command WITHOUT quoting the SDK/project path, so a username containing a
217
+ * SPACE (e.g. `C:\Users\John Silva`) breaks it — the shell reads only up to the
218
+ * space: `'C:\Users\John' is not recognized as a command`. It even fires for a
219
+ * Chrome/web run, where the Apple-only hook is pure dead weight.
220
+ *
221
+ * The hook is irrelevant on Windows (no iOS/macOS builds ever happen there), so
222
+ * turning native assets off removes the broken step at zero cost. `flutter
223
+ * config` is per-machine and global, so this never touches a Mac's iOS build.
224
+ *
225
+ * Idempotent and best-effort: it must never block `kasy run` / `kasy new`.
226
+ *
227
+ * @returns {{ ok: boolean, skipped?: boolean, error?: string }}
228
+ */
229
+ function disableNativeAssetsWindows() {
230
+ if (!isWindows) return { ok: false, skipped: true };
231
+ if (nativeAssetsHandled) return { ok: true, skipped: true };
232
+ nativeAssetsHandled = true;
233
+ try {
234
+ const r = spawnSyncFlutter(['config', '--no-enable-native-assets'], {
235
+ stdio: ['ignore', 'pipe', 'pipe'],
236
+ encoding: 'utf8',
237
+ timeout: 120_000,
238
+ });
239
+ return { ok: r.status === 0 };
240
+ } catch (e) {
241
+ return { ok: false, error: e.message };
242
+ }
243
+ }
244
+
204
245
  module.exports = {
205
246
  isWindows,
206
247
  homeDir,
@@ -210,4 +251,5 @@ module.exports = {
210
251
  augmentedEnv,
211
252
  spawnFlutter,
212
253
  spawnSyncFlutter,
254
+ disableNativeAssetsWindows,
213
255
  };
@@ -106,6 +106,14 @@ if ($LASTEXITCODE -ne 0) {
106
106
  throw 'Flutter was installed but its first run failed (Dart SDK bootstrap). Check your internet connection and run this again.'
107
107
  }
108
108
 
109
+ # 3b. Disable native assets. path_provider_foundation (Apple impl of
110
+ # path_provider) ships an objective_c native-assets build hook whose compile
111
+ # command isn't quoted, so a username with a SPACE (C:\\Users\\John Silva)
112
+ # breaks every build — even Chrome/web, where the Apple hook is dead weight.
113
+ # No iOS/macOS builds happen on Windows, so turning it off is free. Best
114
+ # effort: never fail the install over it.
115
+ & (Join-Path $bin 'flutter.bat') config --no-enable-native-assets 2>&1 | Out-Null
116
+
109
117
  # 4. Persist flutter\\bin on the User PATH so every future terminal finds it.
110
118
  $userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
111
119
  if (-not $userPath) { $userPath = '' }