@livx.cc/appwrap 0.19.0 → 0.20.0

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/README.md CHANGED
@@ -6,14 +6,21 @@ CLI that wraps any PWA into a native iOS/Android app with real native capabiliti
6
6
 
7
7
  ```bash
8
8
  bun add -d @livx.cc/appwrap
9
- bunx appwrap init # scaffold native/ from appwrap.json
9
+ bunx appwrap init # scaffold native/ from your config
10
10
  bunx appwrap sync # regenerate native/ after web or config changes
11
11
  bunx appwrap dev # live-load your dev server in the native shell
12
12
  ```
13
13
 
14
- Configuration lives in `appwrap.json` (app id, name, icon, modules, permissions, signing). The CLI generates and keeps `native/` in sync from a managed runtime template — you edit the template/config, never the generated output.
14
+ Configuration lives in a typed `appwrap.config.ts` (app id, name, icon, modules, permissions, signing) — author it with autocomplete + type-checking via `defineConfig`:
15
15
 
16
- **Full documentation, `appwrap.json` reference, and the managed-regeneration model:** see the [appwrap README](https://github.com/Livshitz/appwrap#readme).
16
+ ```ts
17
+ import { defineConfig } from '@livx.cc/appwrap/config';
18
+ export default defineConfig({ id: 'com.you.app', name: 'My App', version: '1.0.0', pwaDist: 'dist' });
19
+ ```
20
+
21
+ A plain `appwrap.json` is still supported as a fallback (the CLI resolves `appwrap.config.ts` → `appwrap.config.js` → `appwrap.json`). The CLI generates and keeps `native/` in sync from a managed runtime template — you edit the template/config, never the generated output.
22
+
23
+ **Full documentation, config reference, and the managed-regeneration model:** see the [appwrap README](https://github.com/Livshitz/appwrap#readme).
17
24
 
18
25
  ## License
19
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Wrap any PWA into a native app with native capabilities (appwrap runtime + @livx.cc/native-kit).",
5
5
  "license": "MIT",
6
6
  "author": "Elya Livshitz",
@@ -28,7 +28,9 @@
28
28
  "appwrap": "src/cli.ts"
29
29
  },
30
30
  "exports": {
31
- ".": "./src/cli.ts"
31
+ ".": "./src/config.ts",
32
+ "./config": "./src/config.ts",
33
+ "./cli": "./src/cli.ts"
32
34
  },
33
35
  "files": [
34
36
  "src",
package/src/cli.ts CHANGED
@@ -2,115 +2,24 @@
2
2
  /**
3
3
  * appwrap CLI v0 — scaffold a native wrapper around a built PWA.
4
4
  *
5
- * appwrap init [--config appwrap.json] [--out native] # from the PWA project dir
6
- * appwrap sync [--config appwrap.json] [--out native] # re-copy PWA dist into the wrapper
5
+ * appwrap init [--config <path>] [--out native] # from the PWA project dir
6
+ * appwrap sync [--config <path>] [--out native] # re-copy PWA dist into the wrapper
7
7
  *
8
- * appwrap.json: { id, name, version, entry?, backgroundColor?, statusBarStyle?, pwaDist }
8
+ * Config (TS preferred, JSON fallback) — probed in order: appwrap.config.ts → .js → appwrap.json.
9
+ * Shape: { id, name, version, entry?, backgroundColor?, statusBarStyle?, pwaDist }. See config.ts.
9
10
  */
10
11
  import { execFileSync } from 'child_process';
11
12
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
12
13
  import { networkInterfaces, tmpdir } from 'os';
13
14
  import { dirname, join, resolve } from 'path';
15
+ import { pathToFileURL } from 'url';
14
16
  // PURE-DATA capability manifest (no NativeScript globals) — type-only import (erased at runtime);
15
17
  // the VALUES are loaded dynamically below from the resolved runtime so the CLI works both in the
16
18
  // monorepo and from a published tarball (where runtime/ is bundled at the package root).
17
19
  import type * as CapManifest from '../../../runtime/app/shell/capabilities.manifest';
18
-
19
- interface AppwrapConfig {
20
- id: string;
21
- name: string;
22
- version: string;
23
- entry?: string;
24
- backgroundColor?: string;
25
- statusBarStyle?: 'light' | 'dark';
26
- /** Android only (experimental). When true, the WebView draws genuinely edge-to-edge UNDER the
27
- * transparent system bars (NS `androidOverflowEdge='dont-apply'`) and the real safe-area insets
28
- * are injected as `--saie-*` CSS vars + native `env(safe-area-inset-*)`, so a multi-theme PWA
29
- * paints the bar regions itself. Default false = bars show the page `backgroundColor` (works, but
30
- * can't match a multi-theme app). iOS is always genuinely edge-to-edge. */
31
- edgeToEdge?: boolean;
32
- pwaDist: string;
33
- /** Custom URL scheme for deep links (e.g. "hellowrap" → hellowrap://...). */
34
- urlScheme?: string;
35
- /** App icon source (≥512px square png). Defaults to the largest icon in the PWA manifest. */
36
- icon?: string;
37
- /** Loader: 'app' (default — app:// scheme, ES modules OK), 'file' (debug fallback), or 'server'
38
- * (load `serverUrl` live — dev HMR over LAN or a deployed URL). `appwrap dev` sets this. */
39
- loader?: 'app' | 'file' | 'server';
40
- /** Live URL loaded when loader === 'server'. Set via appwrap.json or `appwrap dev --url <url>`. */
41
- serverUrl?: string;
42
- /** Absolute backend origin for an offline (loader:'app') PWA whose API/WebSocket calls were
43
- * originally same-origin (e.g. "https://api.example.com"). Injected to the page as
44
- * `window.__APPWRAP_BACKEND_ORIGIN__`; a same-origin PWA reads it to make its calls absolute.
45
- * Empty/unset = same-origin (browser default), so the same web build is unaffected. */
46
- backendOrigin?: string;
47
- /** Backend-served STATIC assets the PWA loads via a *relative, hardcoded* URL (e.g. an SDK
48
- * `<script src="/_vendor/sdk.js">` that can't be made absolute without breaking script order).
49
- * Fetched from `backendOrigin` at build time and bundled into www/, so they resolve offline at
50
- * app://. The asset is pinned to the build (correct for a no-OTA native app). Needs `backendOrigin`. */
51
- vendorPaths?: string[];
52
- /** Debug/dev mode: keeps the screen awake (no auto-lock while foreground) + makes the WebView
53
- * inspectable (Safari Web Inspector / chrome://inspect) for continuous troubleshooting. `appwrap
54
- * deploy` forces this on; `appwrap build` leaves it off. NEVER ship a store build with debug on. */
55
- debug?: boolean;
56
- /** In debug mode, the value written to `localStorage.DEBUG` at startup so the PWA's logger goes
57
- * verbose (common convention — `'*'` = all, or comma-separated module names). Default `'*'`. */
58
- debugLog?: string;
59
- /** Apple Development Team ID for device builds (Xcode → Settings → Accounts). */
60
- teamId?: string;
61
- /** Path (relative to the PWA project) to a StoreKit configuration file for LOCAL IAP
62
- * testing — products resolve without App Store Connect. Only applies when launched from
63
- * Xcode (simulator or device-from-Xcode), not a standalone devicectl sideload. */
64
- storekitConfig?: string;
65
- /** Permission usage strings, keyed by domain. Only listed ones are stamped
66
- * (iOS: Info.plist usage string; Android: <uses-permission>). 'contacts' has no
67
- * iOS key (CNContactPicker needs none) — it only stamps Android READ_CONTACTS. */
68
- permissions?: Partial<
69
- Record<'location' | 'photos' | 'camera' | 'microphone' | 'faceid' | 'calendar' | 'contacts' | 'motion', string>
70
- >;
71
- /** Monotonic build identifier. Stores reject a re-upload unless this is HIGHER than the last:
72
- * iOS `CFBundleVersion`, Android `versionCode` (the marketing `version` stays the user-facing
73
- * string). Default: an integer derived from `version` (0.2.1 → 201). Set explicitly from a CI
74
- * run number for fleet builds of the same marketing version. */
75
- buildNumber?: string | number;
76
- /** iOS export-compliance. `ITSAppUsesNonExemptEncryption` — stamped `false` by default (skips the
77
- * per-upload prompt). Set `true` only if the app uses non-exempt encryption. */
78
- usesNonExemptEncryption?: boolean;
79
- /** Pure-native escape hatch: a directory (relative to the PWA project) whose contents are copied
80
- * OVER the generated wrapper after stamping — for legacy/custom native code the declarative config
81
- * can't express. Default `'appwrap.overrides'`; applied only if it exists. */
82
- overrides?: string;
83
- /** Reserved — appwrap plugins (npm packages contributing a kit module + native handlers + config).
84
- * Parsed today; full native composition lands with the plugin contract (see framework-extensibility). */
85
- plugins?: string[];
86
- /** Opt-in capability allow-list (built-in modules — see capabilities.manifest.ts). When PRESENT,
87
- * only the listed capabilities (plus always-on core) are advertised, permissioned, and — for
88
- * modules that own their handler file (e.g. health) — compiled into the shell. Their permissions,
89
- * background modes and native deps are collected from each module's self-contained manifest entry
90
- * (the per-app `permissions{}` map only OVERRIDES the default usage copy). When ABSENT, every
91
- * capability is active and permissions come solely from `permissions{}` (pre-modules behavior). */
92
- modules?: string[];
93
- /** Remote push (APNs/FCM). Off unless set — gating matters: an `aps-environment` entitlement on a
94
- * team that can't hold the Push capability (e.g. a personal team) BREAKS code signing, and the
95
- * handshake should honestly report `push: 'none'` on an un-provisioned build. The kit returns a raw
96
- * token; SENDING is your backend's job (provider-agnostic). */
97
- push?: {
98
- /** Master switch for the push lane. */
99
- enabled?: boolean;
100
- /** Per-platform gates (default true when `enabled`). Split because the two platforms have
101
- * independent prerequisites: iOS needs the `aps-environment` entitlement (a PAID Apple team —
102
- * a personal team can't hold it, and stamping it would break signing), Android needs FCM +
103
- * google-services.json. e.g. `{ enabled:true, ios:false, android:true }` ships Android push
104
- * while keeping a personal-team iOS build signable. */
105
- ios?: boolean;
106
- android?: boolean;
107
- /** iOS APNs environment in the entitlement: 'development' (debug/TestFlight builds) or
108
- * 'production' (App Store). Default 'development'. */
109
- apsEnvironment?: 'development' | 'production';
110
- /** Path (relative to the PWA project) to the Firebase `google-services.json` for Android FCM. */
111
- googleServicesJson?: string;
112
- };
113
- }
20
+ // Config shape lives in its own import-safe module so a `appwrap.config.ts` file can import the
21
+ // type + `defineConfig` helper without pulling in (and running) the CLI dispatch.
22
+ import type { AppwrapConfig } from './config';
114
23
 
115
24
  /** Marketing version → a monotonic integer build (0.2.1 → 201; 1.4.12 → 10412). Stable & increasing
116
25
  * across semver bumps so store re-uploads are always accepted without a manual bump. */
@@ -253,7 +162,7 @@ function generateModuleArtifacts(outDir: string, req: NativeReqs): void {
253
162
  const shell = join(outDir, 'app/shell');
254
163
  writeFileSync(
255
164
  join(shell, 'active-modules.generated.ts'),
256
- `/** Generated by \`appwrap\` from appwrap.json \`modules\`. Do not edit. */\n` +
165
+ `/** Generated by \`appwrap\` from the appwrap config \`modules\`. Do not edit. */\n` +
257
166
  `export const ACTIVE_MODULE_NAMES: string[] = ${JSON.stringify(req.activeOptIn)};\n`
258
167
  );
259
168
 
@@ -370,15 +279,39 @@ function loadManifest(cwd: string, cfg: AppwrapConfig): Record<string, any> | nu
370
279
  return null;
371
280
  }
372
281
 
373
- function loadConfig(cwd: string, flags: Record<string, string>): AppwrapConfig {
374
- const configPath = resolve(cwd, flags.config ?? 'appwrap.json');
282
+ /** Config filenames probed (in order) when `--config` is not passed. TS is preferred (typed,
283
+ * autocomplete via `defineConfig`); `.js` then `.json` are supported as fallbacks. */
284
+ const CONFIG_CANDIDATES = ['appwrap.config.ts', 'appwrap.config.js', 'appwrap.json'] as const;
285
+
286
+ /** Load a `.ts`/`.js`/`.json` config. TS/JS are imported (Bun runs them natively — no transpile
287
+ * step) and may `export default` (or a named `config`); JSON is parsed. Returns the raw object. */
288
+ async function readConfigFile(configPath: string): Promise<AppwrapConfig> {
289
+ if (configPath.endsWith('.json')) {
290
+ return JSON.parse(readFileSync(configPath, 'utf8')) as AppwrapConfig;
291
+ }
292
+ // .ts / .js — dynamic import (Bun runs it natively). Each CLI command is its own process, so the
293
+ // ESM module cache never outlives a single run.
294
+ const mod = await import(pathToFileURL(configPath).href);
295
+ const cfg = mod.default ?? mod.config;
296
+ if (!cfg || typeof cfg !== 'object') {
297
+ console.error(`✖ ${configPath} must \`export default\` (or export \`config\`) an appwrap config object.`);
298
+ process.exit(1);
299
+ }
300
+ return cfg as AppwrapConfig;
301
+ }
302
+
303
+ async function loadConfig(cwd: string, flags: Record<string, string>): Promise<AppwrapConfig> {
304
+ // Explicit --config wins; otherwise probe ts → js → json (TS preferred).
305
+ const configPath = flags.config
306
+ ? resolve(cwd, flags.config)
307
+ : (CONFIG_CANDIDATES.map((f) => resolve(cwd, f)).find(existsSync) ?? resolve(cwd, CONFIG_CANDIDATES[0]));
375
308
  if (!existsSync(configPath)) {
376
- console.error(`✖ Config not found: ${configPath}`);
309
+ console.error(`✖ Config not found — looked for ${CONFIG_CANDIDATES.join(' / ')} in ${cwd}`);
377
310
  process.exit(1);
378
311
  }
379
- const cfg = JSON.parse(readFileSync(configPath, 'utf8')) as AppwrapConfig;
312
+ const cfg = await readConfigFile(configPath);
380
313
 
381
- // Manifest as source: appwrap.json wins, the PWA manifest fills the gaps, template default last.
314
+ // Manifest as source: the appwrap config wins, the PWA manifest fills the gaps, template default last.
382
315
  // (DRY single-source — devs don't re-type identity already declared in the manifest.)
383
316
  if (cfg.pwaDist) {
384
317
  const mf = loadManifest(cwd, cfg);
@@ -390,7 +323,7 @@ function loadConfig(cwd: string, flags: Record<string, string>): AppwrapConfig {
390
323
 
391
324
  for (const key of ['id', 'name', 'version', 'pwaDist'] as const) {
392
325
  if (!cfg[key]) {
393
- console.error(`✖ appwrap.json missing required field: ${key}` + (key === 'name' ? ' (and no name/short_name in the PWA manifest)' : ''));
326
+ console.error(`✖ config missing required field: ${key}` + (key === 'name' ? ' (and no name/short_name in the PWA manifest)' : ''));
394
327
  process.exit(1);
395
328
  }
396
329
  }
@@ -399,7 +332,7 @@ function loadConfig(cwd: string, flags: Record<string, string>): AppwrapConfig {
399
332
 
400
333
  function stampShellConfig(outDir: string, cfg: AppwrapConfig): void {
401
334
  const content = `/**
402
- * Shell config — stamped by \`appwrap init\`/\`sync\` from appwrap.json. Do not edit.
335
+ * Shell config — stamped by \`appwrap init\`/\`sync\` from the appwrap config. Do not edit.
403
336
  */
404
337
  export const SHELL_CONFIG = {
405
338
  appId: ${JSON.stringify(cfg.id)},
@@ -499,7 +432,7 @@ function stampStoreKit(cwd: string, outDir: string, cfg: AppwrapConfig): void {
499
432
  if (!cfg.storekitConfig) return;
500
433
  const source = resolve(cwd, cfg.storekitConfig);
501
434
  if (!existsSync(source)) {
502
- console.warn(`⚠ appwrap.json.storekitConfig not found: ${source} — skipping StoreKit wiring`);
435
+ console.warn(`⚠ config \`storekitConfig\` not found: ${source} — skipping StoreKit wiring`);
503
436
  return;
504
437
  }
505
438
  const base = source.split('/').pop()!;
@@ -572,7 +505,7 @@ function stampPush(cwd: string, outDir: string, cfg: AppwrapConfig): void {
572
505
  if (fcmVals) console.log(` push ← Android FCM wired (firebase resources for ${fcmVals.project_id}, no plugin)`);
573
506
  else console.warn(`⚠ Could not parse ${src} — skipping Android FCM`);
574
507
  } else {
575
- console.warn(`⚠ appwrap.json.push.googleServicesJson not found: ${src} — skipping Android FCM`);
508
+ console.warn(`⚠ config \`push.googleServicesJson\` not found: ${src} — skipping Android FCM`);
576
509
  }
577
510
  }
578
511
  stampAndroidFcm(outDir, fcmVals);
@@ -694,7 +627,7 @@ function findIconSource(cwd: string, cfg: AppwrapConfig): string | null {
694
627
  if (cfg.icon) {
695
628
  const p = resolve(cwd, cfg.icon);
696
629
  if (existsSync(p)) return p;
697
- console.warn(`⚠ appwrap.json.icon not found: ${p}`);
630
+ console.warn(`⚠ config \`icon\` not found: ${p}`);
698
631
  return null;
699
632
  }
700
633
  const dist = resolve(cwd, cfg.pwaDist);
@@ -732,7 +665,7 @@ function findMaskableSource(cwd: string, cfg: AppwrapConfig): string | null {
732
665
  function generateIcons(cwd: string, outDir: string, cfg: AppwrapConfig): void {
733
666
  const source = findIconSource(cwd, cfg);
734
667
  if (!source) {
735
- console.warn('⚠ No app icon source found (manifest icons or appwrap.json.icon) — keeping template icons');
668
+ console.warn('⚠ No app icon source found (manifest icons or config `icon`) — keeping template icons');
736
669
  return;
737
670
  }
738
671
  const probe = (prop: string) =>
@@ -862,7 +795,7 @@ function copyPwa(cwd: string, outDir: string, cfg: AppwrapConfig): void {
862
795
  function vendorBackendAssets(www: string, cfg: AppwrapConfig): void {
863
796
  if (!cfg.vendorPaths?.length) return;
864
797
  if (!cfg.backendOrigin) {
865
- console.error('✖ vendorPaths requires backendOrigin in appwrap.json');
798
+ console.error('✖ vendorPaths requires backendOrigin in the appwrap config');
866
799
  process.exit(1);
867
800
  }
868
801
  const origin = cfg.backendOrigin.replace(/\/+$/, '');
@@ -912,7 +845,7 @@ function gitRoot(start: string): string {
912
845
  }
913
846
 
914
847
  /** Emit CI scaffolding (GH Actions → git repo root, fastlane → native/). Never overwrites. */
915
- function copyCiTemplates(cwd: string, outDir: string): void {
848
+ function copyCiTemplates(cwd: string, outDir: string, cfg: AppwrapConfig): void {
916
849
  if (!existsSync(CI_TEMPLATE_DIR)) return;
917
850
  // GitHub only reads `.github/workflows` at the REPO ROOT — in a monorepo, writing it under the
918
851
  // package cwd (e.g. packages/app/.github) is dead config and regenerates a stray workflow each init.
@@ -924,7 +857,18 @@ function copyCiTemplates(cwd: string, outDir: string): void {
924
857
  mkdirSync(to, { recursive: true });
925
858
  cpSync(from, to, { recursive: true, force: false, errorOnExist: false });
926
859
  }
927
- console.log(' ci ← GH Actions (.github/workflows) + fastlane (native/fastlane) — see secrets contract in workflow headers');
860
+ // Stamp the app id + team into the emitted fastlane (signing needs them; the templates ship
861
+ // `__APP_ID__`/`__TEAM_ID__` placeholders). Idempotent: re-init finds no placeholders → no-op.
862
+ const fastlaneDir = join(outDir, 'fastlane');
863
+ for (const file of ['Fastfile', 'Matchfile']) {
864
+ const p = join(fastlaneDir, file);
865
+ if (!existsSync(p)) continue;
866
+ const stamped = readFileSync(p, 'utf8')
867
+ .replaceAll('__APP_ID__', cfg.id)
868
+ .replaceAll('__TEAM_ID__', cfg.teamId ?? '');
869
+ writeFileSync(p, stamped);
870
+ }
871
+ console.log(' ci ← GH Actions (.github/workflows) + fastlane (native/fastlane, signing stamped) — see secrets contract in workflow headers');
928
872
  }
929
873
 
930
874
  /**
@@ -938,7 +882,7 @@ function copyCiTemplates(cwd: string, outDir: string): void {
938
882
  function regenerateCore(cwd: string, outDir: string, cfg: AppwrapConfig, opts: { firstRun?: boolean } = {}): void {
939
883
  const req = nativeReqs(cfg);
940
884
  if (opts.firstRun && !req.explicit) {
941
- console.log(' ℹ no `modules` in appwrap.json → all capabilities active. Declare `modules` to shrink the store build (strip unused handlers/perms).');
885
+ console.log(' ℹ no `modules` in the appwrap config → all capabilities active. Declare `modules` to shrink the store build (strip unused handlers/perms).');
942
886
  }
943
887
  cpSync(TEMPLATE_DIR, outDir, {
944
888
  recursive: true,
@@ -966,8 +910,8 @@ function regenerateCore(cwd: string, outDir: string, cfg: AppwrapConfig, opts: {
966
910
  copyPwa(cwd, outDir, cfg);
967
911
  }
968
912
 
969
- function init(cwd: string, flags: Record<string, string>): void {
970
- const cfg = loadConfig(cwd, flags);
913
+ async function init(cwd: string, flags: Record<string, string>): Promise<void> {
914
+ const cfg = await loadConfig(cwd, flags);
971
915
  const outDir = resolve(cwd, flags.out ?? 'native');
972
916
 
973
917
  if (!existsSync(TEMPLATE_DIR)) {
@@ -992,7 +936,7 @@ function init(cwd: string, flags: Record<string, string>): void {
992
936
  console.log(`🎁 appwrap init → ${outDir}`);
993
937
  mkdirSync(outDir, { recursive: true });
994
938
  regenerateCore(cwd, outDir, cfg, { firstRun: true });
995
- copyCiTemplates(cwd, outDir); // first-time scaffold (never overwrites)
939
+ copyCiTemplates(cwd, outDir, cfg); // first-time scaffold (never overwrites)
996
940
  writeFileSync(join(outDir, '.gitignore'), 'node_modules/\nplatforms/\nhooks/\n');
997
941
  applyOverrides(cwd, outDir, cfg); // escape hatch — last, so custom native code wins
998
942
  stampVersionManifest(outDir, cfg); // provenance — also marks the dir appwrap-managed
@@ -1002,8 +946,8 @@ function init(cwd: string, flags: Record<string, string>): void {
1002
946
  // `sync` = the same regenerate as `init`, minus the first-time guard/scaffold. It is a TRUE refresh from
1003
947
  // source (shell + config + PWA), so runtime/config edits never silently lag behind. `native/` is
1004
948
  // disposable; re-copying the shell costs ~ms (the real cost is the later `ns build`, which both share).
1005
- function sync(cwd: string, flags: Record<string, string>): void {
1006
- const cfg = loadConfig(cwd, flags);
949
+ async function sync(cwd: string, flags: Record<string, string>): Promise<void> {
950
+ const cfg = await loadConfig(cwd, flags);
1007
951
  const outDir = resolve(cwd, flags.out ?? 'native');
1008
952
  if (!existsSync(outDir)) {
1009
953
  console.error(`✖ Wrapper not found at ${outDir} — run \`appwrap init\` first`);
@@ -1028,8 +972,8 @@ function lanIp(): string | null {
1028
972
  /** `appwrap dev` — point the existing wrapper at a LIVE url (loader 'server') instead of bundled www.
1029
973
  * Dev runs their own web server (vite host:true) or a deployed URL; this just stamps the shell config.
1030
974
  * `--url <url>` explicit; else http://<lan-ip>:<port> (default 5173). Re-run `appwrap sync`/`init` to revert. */
1031
- function dev(cwd: string, flags: Record<string, string>): void {
1032
- const cfg = loadConfig(cwd, flags);
975
+ async function dev(cwd: string, flags: Record<string, string>): Promise<void> {
976
+ const cfg = await loadConfig(cwd, flags);
1033
977
  const outDir = resolve(cwd, flags.out ?? 'native');
1034
978
  if (!existsSync(outDir)) {
1035
979
  console.error(`✖ Wrapper not found at ${outDir} — run \`appwrap init\` first`);
@@ -1060,12 +1004,12 @@ function dev(cwd: string, flags: Record<string, string>): void {
1060
1004
  /** `appwrap build <ios|android> [--release] [--aab]` — store-readiness build path. Re-stamps config,
1061
1005
  * re-copies the PWA, then delegates the actual compile to NativeScript with the right flags. Release
1062
1006
  * Android signing comes from env (APPWRAP_ANDROID_KEYSTORE[_PASSWORD|_ALIAS|_ALIAS_PASSWORD]) — secrets
1063
- * never live in appwrap.json. iOS distribution signing/upload is the fastlane release lane's job (the
1007
+ * never live in the appwrap config. iOS distribution signing/upload is the fastlane release lane's job (the
1064
1008
  * cicd templates); `--release` here just builds the Release config for the device. */
1065
- function build(cwd: string, flags: Record<string, string>, positionals: string[]): void {
1009
+ async function build(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
1066
1010
  const platform = positionals[0];
1067
1011
  if (platform !== 'ios' && platform !== 'android') {
1068
- console.error('Usage: appwrap build <ios|android> [--release] [--aab] [--config appwrap.json] [--out native]');
1012
+ console.error('Usage: appwrap build <ios|android> [--release] [--aab] [--config <path>] [--out native]');
1069
1013
  process.exit(1);
1070
1014
  }
1071
1015
  const outDir = resolve(cwd, flags.out ?? 'native');
@@ -1073,8 +1017,8 @@ function build(cwd: string, flags: Record<string, string>, positionals: string[]
1073
1017
  console.error(`✖ Wrapper not found at ${outDir} — run \`appwrap init\` first`);
1074
1018
  process.exit(1);
1075
1019
  }
1076
- // Make sure the wrapper reflects the latest config + PWA before compiling (also validates appwrap.json).
1077
- sync(cwd, flags);
1020
+ // Make sure the wrapper reflects the latest config + PWA before compiling (also validates the config).
1021
+ await sync(cwd, flags);
1078
1022
 
1079
1023
  const release = 'release' in flags;
1080
1024
  const args = ['build', platform];
@@ -1160,13 +1104,13 @@ function pickDevice(devices: DeviceInfo[], explicitId?: string): DeviceInfo {
1160
1104
  /** `appwrap deploy ios [--device <id|name>] [--no-launch]` — build for device, auto-pick the
1161
1105
  * connected phone (USB or network; prompts if several), install + launch. Debug build (no
1162
1106
  * distribution signing) — for testing on your own device. Run the PWA build first (or via the script). */
1163
- function deploy(cwd: string, flags: Record<string, string>, positionals: string[]): void {
1107
+ async function deploy(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
1164
1108
  const platform = positionals[0];
1165
1109
  if (platform !== 'ios') {
1166
1110
  console.error('Usage: appwrap deploy ios [--device <id|name>] [--no-launch] (android: use `ns run android`)');
1167
1111
  process.exit(1);
1168
1112
  }
1169
- const cfg = loadConfig(cwd, flags);
1113
+ const cfg = await loadConfig(cwd, flags);
1170
1114
  const outDir = resolve(cwd, flags.out ?? 'native');
1171
1115
  if (!existsSync(outDir)) {
1172
1116
  console.error(`✖ Wrapper not found at ${outDir} — run \`appwrap init\` first`);
@@ -1175,9 +1119,9 @@ function deploy(cwd: string, flags: Record<string, string>, positionals: string[
1175
1119
  // Pick the device up front so we fail fast before a long build if nothing's connected.
1176
1120
  const device = pickDevice(listIosDevices(), flags.device || undefined);
1177
1121
 
1178
- sync(cwd, flags); // re-stamp config + copy latest PWA dist (+ vendor backend assets)
1122
+ await sync(cwd, flags); // re-stamp config + copy latest PWA dist (+ vendor backend assets)
1179
1123
  // Dev deploy → debug mode: keep-awake + WebView inspector for continuous troubleshooting.
1180
- stampShellConfig(outDir, { ...loadConfig(cwd, flags), debug: true });
1124
+ stampShellConfig(outDir, { ...cfg, debug: true });
1181
1125
  console.log('▶ ns build ios --for-device (debug: keep-awake + inspector on)');
1182
1126
  execFileSync('ns', ['build', 'ios', '--for-device'], { cwd: outDir, stdio: 'inherit' });
1183
1127
 
@@ -1243,13 +1187,13 @@ function libimobiledeviceUdid(): { udid: string; network: boolean } | null {
1243
1187
  * `devicectl device copy`. DEFAULT: watch (poll the file ~every 3s, print new lines). `--once`:
1244
1188
  * one snapshot. `--native`: the OS-level app syslog firehose via idevicesyslog (native crashes; USB).
1245
1189
  * Headless-friendly: redirect to a file and read it. */
1246
- function logs(cwd: string, flags: Record<string, string>, positionals: string[]): void {
1190
+ async function logs(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
1247
1191
  const platform = positionals[0] ?? 'ios';
1248
1192
  if (platform !== 'ios') {
1249
1193
  console.error('Usage: appwrap logs ios [--once] [--native] [--device <id|name>]');
1250
1194
  process.exit(1);
1251
1195
  }
1252
- const cfg = loadConfig(cwd, flags);
1196
+ const cfg = await loadConfig(cwd, flags);
1253
1197
 
1254
1198
  if ('native' in flags) {
1255
1199
  const li = libimobiledeviceUdid();
@@ -1298,31 +1242,40 @@ function logs(cwd: string, flags: Record<string, string>, positionals: string[])
1298
1242
  }
1299
1243
  }
1300
1244
 
1301
- const { command, flags, positionals } = parseArgs(process.argv.slice(2));
1302
- const cwd = process.cwd();
1303
-
1304
- switch (command) {
1305
- case 'init':
1306
- init(cwd, flags);
1307
- break;
1308
- case 'sync':
1309
- sync(cwd, flags);
1310
- break;
1311
- case 'dev':
1312
- dev(cwd, flags);
1313
- break;
1314
- case 'build':
1315
- build(cwd, flags, positionals);
1316
- break;
1317
- case 'deploy':
1318
- deploy(cwd, flags, positionals);
1319
- break;
1320
- case 'logs':
1321
- logs(cwd, flags, positionals);
1322
- break;
1323
- default:
1324
- console.log('Usage: appwrap <init|sync|dev|build|deploy|logs> [--config appwrap.json] [--out native]\n' +
1325
- ' build <ios|android> [--release] [--aab] deploy ios [--device <id|name>] [--no-launch]\n' +
1326
- ' logs ios [--once] [--native] dev [--url <url> | --port <p>]');
1327
- process.exit(command ? 1 : 0);
1245
+ /** CLI dispatch. Guarded by `import.meta.main` so importing this module (e.g. for the `AppwrapConfig`
1246
+ * type via the package entry) doesn't run a command. */
1247
+ async function main(): Promise<void> {
1248
+ const { command, flags, positionals } = parseArgs(process.argv.slice(2));
1249
+ const cwd = process.cwd();
1250
+
1251
+ switch (command) {
1252
+ case 'init':
1253
+ await init(cwd, flags);
1254
+ break;
1255
+ case 'sync':
1256
+ await sync(cwd, flags);
1257
+ break;
1258
+ case 'dev':
1259
+ await dev(cwd, flags);
1260
+ break;
1261
+ case 'build':
1262
+ await build(cwd, flags, positionals);
1263
+ break;
1264
+ case 'deploy':
1265
+ await deploy(cwd, flags, positionals);
1266
+ break;
1267
+ case 'logs':
1268
+ await logs(cwd, flags, positionals);
1269
+ break;
1270
+ default:
1271
+ console.log('Usage: appwrap <init|sync|dev|build|deploy|logs> [--config <path>] [--out native]\n' +
1272
+ ' config: appwrap.config.ts (preferred) → .js → appwrap.json\n' +
1273
+ ' build <ios|android> [--release] [--aab] deploy ios [--device <id|name>] [--no-launch]\n' +
1274
+ ' logs ios [--once] [--native] dev [--url <url> | --port <p>]');
1275
+ process.exit(command ? 1 : 0);
1276
+ }
1277
+ }
1278
+
1279
+ if (import.meta.main) {
1280
+ main();
1328
1281
  }
package/src/config.ts ADDED
@@ -0,0 +1,125 @@
1
+ /**
2
+ * appwrap config — the typed shape of an app's wrapper config + the `defineConfig` helper.
3
+ *
4
+ * This module is import-safe (no side effects, no CLI dispatch), so it can be imported from a
5
+ * TypeScript config file to get full autocomplete + type-checking:
6
+ *
7
+ * // appwrap.config.ts
8
+ * import { defineConfig } from '@livx.cc/appwrap/config';
9
+ * export default defineConfig({
10
+ * id: 'com.example.app',
11
+ * name: 'Example',
12
+ * version: '1.0.0',
13
+ * pwaDist: 'dist',
14
+ * });
15
+ *
16
+ * The CLI resolves `appwrap.config.ts` → `appwrap.config.js` → `appwrap.json` (TS preferred,
17
+ * JSON still supported as a fallback). See `loadConfig` in cli.ts.
18
+ */
19
+
20
+ export interface AppwrapConfig {
21
+ id: string;
22
+ name: string;
23
+ version: string;
24
+ entry?: string;
25
+ backgroundColor?: string;
26
+ statusBarStyle?: 'light' | 'dark';
27
+ /** Android only (experimental). When true, the WebView draws genuinely edge-to-edge UNDER the
28
+ * transparent system bars (NS `androidOverflowEdge='dont-apply'`) and the real safe-area insets
29
+ * are injected as `--saie-*` CSS vars + native `env(safe-area-inset-*)`, so a multi-theme PWA
30
+ * paints the bar regions itself. Default false = bars show the page `backgroundColor` (works, but
31
+ * can't match a multi-theme app). iOS is always genuinely edge-to-edge. */
32
+ edgeToEdge?: boolean;
33
+ pwaDist: string;
34
+ /** Custom URL scheme for deep links (e.g. "hellowrap" → hellowrap://...). */
35
+ urlScheme?: string;
36
+ /** App icon source (≥512px square png). Defaults to the largest icon in the PWA manifest. */
37
+ icon?: string;
38
+ /** Loader: 'app' (default — app:// scheme, ES modules OK), 'file' (debug fallback), or 'server'
39
+ * (load `serverUrl` live — dev HMR over LAN or a deployed URL). `appwrap dev` sets this. */
40
+ loader?: 'app' | 'file' | 'server';
41
+ /** Live URL loaded when loader === 'server'. Set via config or `appwrap dev --url <url>`. */
42
+ serverUrl?: string;
43
+ /** Absolute backend origin for an offline (loader:'app') PWA whose API/WebSocket calls were
44
+ * originally same-origin (e.g. "https://api.example.com"). Injected to the page as
45
+ * `window.__APPWRAP_BACKEND_ORIGIN__`; a same-origin PWA reads it to make its calls absolute.
46
+ * Empty/unset = same-origin (browser default), so the same web build is unaffected. */
47
+ backendOrigin?: string;
48
+ /** Backend-served STATIC assets the PWA loads via a *relative, hardcoded* URL (e.g. an SDK
49
+ * `<script src="/_vendor/sdk.js">` that can't be made absolute without breaking script order).
50
+ * Fetched from `backendOrigin` at build time and bundled into www/, so they resolve offline at
51
+ * app://. The asset is pinned to the build (correct for a no-OTA native app). Needs `backendOrigin`. */
52
+ vendorPaths?: string[];
53
+ /** Debug/dev mode: keeps the screen awake (no auto-lock while foreground) + makes the WebView
54
+ * inspectable (Safari Web Inspector / chrome://inspect) for continuous troubleshooting. `appwrap
55
+ * deploy` forces this on; `appwrap build` leaves it off. NEVER ship a store build with debug on. */
56
+ debug?: boolean;
57
+ /** In debug mode, the value written to `localStorage.DEBUG` at startup so the PWA's logger goes
58
+ * verbose (common convention — `'*'` = all, or comma-separated module names). Default `'*'`. */
59
+ debugLog?: string;
60
+ /** Apple Development Team ID for device builds (Xcode → Settings → Accounts). */
61
+ teamId?: string;
62
+ /** Path (relative to the PWA project) to a StoreKit configuration file for LOCAL IAP
63
+ * testing — products resolve without App Store Connect. Only applies when launched from
64
+ * Xcode (simulator or device-from-Xcode), not a standalone devicectl sideload. */
65
+ storekitConfig?: string;
66
+ /** Permission usage strings, keyed by domain. Only listed ones are stamped
67
+ * (iOS: Info.plist usage string; Android: <uses-permission>). 'contacts' has no
68
+ * iOS key (CNContactPicker needs none) — it only stamps Android READ_CONTACTS. */
69
+ permissions?: Partial<
70
+ Record<'location' | 'photos' | 'camera' | 'microphone' | 'faceid' | 'calendar' | 'contacts' | 'motion', string>
71
+ >;
72
+ /** Monotonic build identifier. Stores reject a re-upload unless this is HIGHER than the last:
73
+ * iOS `CFBundleVersion`, Android `versionCode` (the marketing `version` stays the user-facing
74
+ * string). Default: an integer derived from `version` (0.2.1 → 201). Set explicitly from a CI
75
+ * run number for fleet builds of the same marketing version. */
76
+ buildNumber?: string | number;
77
+ /** iOS export-compliance. `ITSAppUsesNonExemptEncryption` — stamped `false` by default (skips the
78
+ * per-upload prompt). Set `true` only if the app uses non-exempt encryption. */
79
+ usesNonExemptEncryption?: boolean;
80
+ /** Pure-native escape hatch: a directory (relative to the PWA project) whose contents are copied
81
+ * OVER the generated wrapper after stamping — for legacy/custom native code the declarative config
82
+ * can't express. Default `'appwrap.overrides'`; applied only if it exists. */
83
+ overrides?: string;
84
+ /** Reserved — appwrap plugins (npm packages contributing a kit module + native handlers + config).
85
+ * Parsed today; full native composition lands with the plugin contract (see framework-extensibility). */
86
+ plugins?: string[];
87
+ /** Opt-in capability allow-list (built-in modules — see capabilities.manifest.ts). When PRESENT,
88
+ * only the listed capabilities (plus always-on core) are advertised, permissioned, and — for
89
+ * modules that own their handler file (e.g. health) — compiled into the shell. Their permissions,
90
+ * background modes and native deps are collected from each module's self-contained manifest entry
91
+ * (the per-app `permissions{}` map only OVERRIDES the default usage copy). When ABSENT, every
92
+ * capability is active and permissions come solely from `permissions{}` (pre-modules behavior). */
93
+ modules?: string[];
94
+ /** Remote push (APNs/FCM). Off unless set — gating matters: an `aps-environment` entitlement on a
95
+ * team that can't hold the Push capability (e.g. a personal team) BREAKS code signing, and the
96
+ * handshake should honestly report `push: 'none'` on an un-provisioned build. The kit returns a raw
97
+ * token; SENDING is your backend's job (provider-agnostic). */
98
+ push?: {
99
+ /** Master switch for the push lane. */
100
+ enabled?: boolean;
101
+ /** Per-platform gates (default true when `enabled`). Split because the two platforms have
102
+ * independent prerequisites: iOS needs the `aps-environment` entitlement (a PAID Apple team —
103
+ * a personal team can't hold it, and stamping it would break signing), Android needs FCM +
104
+ * google-services.json. e.g. `{ enabled:true, ios:false, android:true }` ships Android push
105
+ * while keeping a personal-team iOS build signable. */
106
+ ios?: boolean;
107
+ android?: boolean;
108
+ /** iOS APNs environment in the entitlement: 'development' (debug/TestFlight builds) or
109
+ * 'production' (App Store). Default 'development'. */
110
+ apsEnvironment?: 'development' | 'production';
111
+ /** Path (relative to the PWA project) to the Firebase `google-services.json` for Android FCM. */
112
+ googleServicesJson?: string;
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Identity helper for a TypeScript config file. Does nothing at runtime — it exists purely so your
118
+ * editor type-checks the object and offers autocomplete against {@link AppwrapConfig}.
119
+ *
120
+ * import { defineConfig } from '@livx.cc/appwrap/config';
121
+ * export default defineConfig({ id: '…', name: '…', version: '1.0.0', pwaDist: 'dist' });
122
+ */
123
+ export function defineConfig(config: AppwrapConfig): AppwrapConfig {
124
+ return config;
125
+ }
@@ -1,11 +1,16 @@
1
- # appwrap fastlane — emitted by `appwrap init` into native/fastlane/.
2
- # Auth is App Store Connect API key only (headless-safe; no interactive Apple ID).
1
+ # appwrap fastlane — emitted by `appwrap init` into native/fastlane/. App id + team are stamped
2
+ # from the appwrap config. Auth is App Store Connect API key only (headless-safe; no interactive Apple ID).
3
3
 
4
4
  default_platform(:ios)
5
5
 
6
+ APP_ID = '__APP_ID__'.freeze
7
+ TEAM_ID = '__TEAM_ID__'.freeze
8
+
6
9
  platform :ios do
7
10
  desc 'Build the NativeScript wrapper and upload to TestFlight'
8
11
  lane :beta do
12
+ setup_ci # on CI: provisions a temporary unlocked keychain so codesign can use the match cert
13
+
9
14
  api_key = app_store_connect_api_key(
10
15
  key_id: ENV['ASC_KEY_ID'],
11
16
  issuer_id: ENV['ASC_ISSUER_ID'],
@@ -13,14 +18,30 @@ platform :ios do
13
18
  is_key_content_base64: true
14
19
  )
15
20
 
16
- match(type: 'appstore', readonly: true, git_url: ENV['MATCH_GIT_URL'], api_key: api_key)
21
+ # readonly: certs/profiles must be seeded once via `fastlane match appstore` (see workflow header).
22
+ match(type: 'appstore', readonly: true, app_identifier: APP_ID,
23
+ git_url: ENV['MATCH_GIT_URL'], api_key: api_key)
17
24
 
18
- sh('cd .. && ns prepare ios --release')
25
+ # NativeScript prepares the Xcode project inside the wrapper dir (native/ = parent of fastlane/).
26
+ sh("cd '#{File.expand_path('..', __dir__)}' && ns prepare ios --release")
27
+
28
+ # NS defaults the project to automatic signing → archive can't find a profile. Pin manual signing
29
+ # to the match-provided App Store profile (name exported by match into ENV).
30
+ profile = ENV["sigh_#{APP_ID}_appstore_profile-name"] || "match AppStore #{APP_ID}"
31
+ update_code_signing_settings(
32
+ use_automatic_signing: false,
33
+ path: 'platforms/ios/native.xcodeproj',
34
+ team_id: TEAM_ID,
35
+ bundle_identifier: APP_ID,
36
+ code_sign_identity: 'Apple Distribution',
37
+ profile_name: profile
38
+ )
19
39
 
20
40
  build_app(
21
41
  workspace: 'platforms/ios/native.xcworkspace',
22
42
  scheme: 'native',
23
- export_method: 'app-store'
43
+ export_method: 'app-store',
44
+ export_options: { provisioningProfiles: { APP_ID => profile } }
24
45
  )
25
46
 
26
47
  upload_to_testflight(api_key: api_key, skip_waiting_for_build_processing: true)
@@ -1,3 +1,4 @@
1
1
  git_url(ENV['MATCH_GIT_URL'])
2
2
  storage_mode('git')
3
3
  type('appstore')
4
+ app_identifier('__APP_ID__')
@@ -23,6 +23,6 @@ jobs:
23
23
  with: { node-version: 22 }
24
24
  - run: bun install && bun run build.ts
25
25
  - run: npm i -g nativescript
26
- - run: bunx appwrap init # native/ is generated (gitignored) — regenerate it fresh in CI
26
+ - run: bunx @livx.cc/appwrap init # native/ is generated (gitignored) — regenerate it fresh in CI
27
27
  - working-directory: native
28
28
  run: npm install && ns build ios --emulator
@@ -25,7 +25,7 @@ jobs:
25
25
  - uses: android-actions/setup-android@v3
26
26
  - run: bun install && bun run build.ts # → dist/ (the PWA bundle the wrapper ships)
27
27
  - run: npm i -g nativescript
28
- - run: bunx appwrap init # native/ is generated (gitignored) — regenerate fresh in CI
28
+ - run: bunx @livx.cc/appwrap init # native/ is generated (gitignored) — regenerate fresh in CI
29
29
  - working-directory: native
30
30
  run: npm install
31
31
  - name: Decode upload keystore
@@ -36,7 +36,7 @@ jobs:
36
36
  APPWRAP_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
37
37
  APPWRAP_ANDROID_KEYSTORE_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
38
38
  APPWRAP_ANDROID_KEYSTORE_ALIAS_PASSWORD: ${{ secrets.ANDROID_KEY_ALIAS_PASSWORD }}
39
- run: bunx appwrap build android --release --aab
39
+ run: bunx @livx.cc/appwrap build android --release --aab
40
40
  - uses: actions/upload-artifact@v4 # keep the AAB even if Play upload is skipped
41
41
  with:
42
42
  name: app-release-aab
@@ -1,13 +1,16 @@
1
- # appwrap iOS release — tag push (v*) → TestFlight via fastlane.
1
+ # appwrap iOS → TestFlight via fastlane. On-demand (workflow_dispatch) or tag push (v*).
2
2
  # Secrets contract (repo settings → Actions secrets):
3
- # MATCH_GIT_URL private repo holding signing certs (fastlane match)
3
+ # MATCH_GIT_URL private repo holding signing certs (fastlane match) — SSH form for the deploy key
4
+ # MATCH_DEPLOY_KEY read-only SSH deploy key on that certs repo (so CI can clone it)
4
5
  # MATCH_PASSWORD match encryption passphrase
5
6
  # ASC_KEY_ID App Store Connect API key id
6
7
  # ASC_ISSUER_ID App Store Connect issuer id
7
8
  # ASC_KEY_P8 App Store Connect .p8 key contents (base64)
8
- # No interactive Apple ID — API-key auth only (headless-safe).
9
+ # No interactive Apple ID — API-key auth only (headless-safe). Certs/profiles must be seeded once
10
+ # locally via `fastlane match appstore` before CI runs.
9
11
  name: Release iOS
10
12
  on:
13
+ workflow_dispatch:
11
14
  push:
12
15
  tags: ['v*']
13
16
 
@@ -16,17 +19,27 @@ jobs:
16
19
  runs-on: macos-15
17
20
  steps:
18
21
  - uses: actions/checkout@v4
22
+ # Apple requires the iOS 26 SDK (Xcode 26+) for uploads — runners may default to older Xcode.
23
+ - uses: maxim-lobanov/setup-xcode@v1
24
+ with: { xcode-version: latest-stable }
19
25
  - uses: oven-sh/setup-bun@v2
20
26
  - uses: actions/setup-node@v4
21
27
  with: { node-version: 22 }
22
28
  - run: bun install && bun run build.ts
23
29
  - run: npm i -g nativescript
24
- - run: bunx appwrap init # native/ is generated (gitignored) — regenerate it fresh in CI
30
+ - run: bunx @livx.cc/appwrap init # native/ is generated (gitignored) — regenerate it fresh in CI
25
31
  - working-directory: native
26
32
  run: npm install
27
33
  - uses: ruby/setup-ruby@v1
28
34
  with: { ruby-version: '3.3', bundler-cache: false }
29
35
  - run: gem install fastlane
36
+ - name: Set up match deploy key (read-only SSH on the certs repo)
37
+ run: |
38
+ mkdir -p ~/.ssh && chmod 700 ~/.ssh
39
+ printf '%s\n' "${{ secrets.MATCH_DEPLOY_KEY }}" > ~/.ssh/match_deploy_key
40
+ chmod 600 ~/.ssh/match_deploy_key
41
+ printf 'Host github.com\n IdentityFile ~/.ssh/match_deploy_key\n IdentitiesOnly yes\n' >> ~/.ssh/config
42
+ ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
30
43
  - name: Build & upload to TestFlight
31
44
  working-directory: native
32
45
  env:
@@ -37,5 +50,4 @@ jobs:
37
50
  ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }}
38
51
  run: fastlane ios beta
39
52
 
40
- # Production promotion is a manual gate: re-run with the `production`
41
- # environment approved, or promote the TestFlight build in App Store Connect.
53
+ # Production promotion is a manual gate: promote the TestFlight build in App Store Connect.