@livx.cc/appwrap 0.36.1 → 0.37.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.36.1",
3
+ "version": "0.37.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",
package/src/cli.ts CHANGED
@@ -87,6 +87,12 @@ function resolveAssetRoot(rel: string): string {
87
87
  const TEMPLATE_DIR = resolveAssetRoot('runtime');
88
88
  const CI_TEMPLATE_DIR = resolveAssetRoot('templates/ci');
89
89
 
90
+ /** This CLI's own published version — used to pin the `bunx @livx.cc/appwrap@^x.y.z` invocations the
91
+ * emitted workflow runs. Pinning to THIS version's floor means CI fails LOUDLY ("version not found")
92
+ * until that version is published, instead of bunx silently resolving an older published build that
93
+ * lacks `release`/`init` flags. package.json sits one level above src/ (published) or the monorepo dir. */
94
+ const CLI_VERSION: string = (await import(resolve(import.meta.dir, '..', 'package.json'), { with: { type: 'json' } })).default.version;
95
+
90
96
  // Load the capability manifest VALUES from the resolved runtime (pure data — safe outside NativeScript).
91
97
  // Top-level await resolves before any command dispatches at the bottom of this file.
92
98
  const { MODULES, OPTIONAL_GROUPS } = (await import(
@@ -959,23 +965,34 @@ export function isFrameworkRepo(root: string): boolean {
959
965
  return existsSync(join(root, 'packages/appwrap-cli/src/cli.ts'));
960
966
  }
961
967
 
962
- /** Emit CI scaffolding (GH Actions → git repo root, fastlane → native/). Never overwrites. */
963
- function copyCiTemplates(cwd: string, outDir: string, cfg: AppwrapConfig): void {
968
+ /** Emit CI scaffolding (GH Actions → git repo root, fastlane → native/).
969
+ * GH workflows are never overwritten (users may customize them). The fastlane lane IS appwrap-managed
970
+ * (the release recipe, not for hand-editing — see AGENTS.md), so it is RE-EMITTED on `--force` to keep
971
+ * the recipe current after a framework upgrade; without --force it's still first-time-only. */
972
+ function copyCiTemplates(cwd: string, outDir: string, cfg: AppwrapConfig, force = false): void {
964
973
  if (!existsSync(CI_TEMPLATE_DIR)) return;
965
974
  const repoRoot = gitRoot(cwd);
966
975
  // GitHub only reads `.github/workflows` at the REPO ROOT — in a monorepo, writing it under the
967
976
  // package cwd (e.g. packages/app/.github) is dead config and regenerates a stray workflow each init.
968
- const targets: Array<[string, string]> = [[join(CI_TEMPLATE_DIR, 'fastlane'), join(outDir, 'fastlane')]];
977
+ // [from, to, overwritable]
978
+ const targets: Array<[string, string, boolean]> = [[join(CI_TEMPLATE_DIR, 'fastlane'), join(outDir, 'fastlane'), force]];
969
979
  // …but if the repo root IS the appwrap framework itself (in-repo example), DON'T scaffold consumer
970
980
  // workflows into the framework's .github — that's the stray-workflow-each-init bug.
971
981
  if (isFrameworkRepo(repoRoot)) {
972
982
  console.log(' ci ← GH Actions scaffold skipped (inside the appwrap framework repo — manages its own CI)');
973
983
  } else {
974
- targets.unshift([join(CI_TEMPLATE_DIR, 'github/workflows'), join(repoRoot, '.github/workflows')]);
984
+ targets.unshift([join(CI_TEMPLATE_DIR, 'github/workflows'), join(repoRoot, '.github/workflows'), false]);
975
985
  }
976
- for (const [from, to] of targets) {
986
+ for (const [from, to, overwrite] of targets) {
977
987
  mkdirSync(to, { recursive: true });
978
- cpSync(from, to, { recursive: true, force: false, errorOnExist: false });
988
+ cpSync(from, to, { recursive: true, force: overwrite, errorOnExist: false });
989
+ }
990
+ // Pin the emitted workflow's `bunx @livx.cc/appwrap@^x.y.z` to THIS CLI's version floor, so a CI run
991
+ // using a freshly-emitted workflow can't silently resolve an older published build that lacks the
992
+ // `init`/`release` commands (it would 404 loudly instead). Idempotent: re-init finds no placeholder.
993
+ if (!isFrameworkRepo(repoRoot)) {
994
+ const wf = join(repoRoot, '.github/workflows/appwrap-release-ios.yml');
995
+ if (existsSync(wf)) writeFileSync(wf, readFileSync(wf, 'utf8').replaceAll('__APPWRAP_VERSION__', CLI_VERSION));
979
996
  }
980
997
  // Stamp the app id + team into the emitted fastlane (signing needs them; the templates ship
981
998
  // `__APP_ID__`/`__TEAM_ID__` placeholders). Idempotent: re-init finds no placeholders → no-op.
@@ -1060,7 +1077,7 @@ async function init(cwd: string, flags: Record<string, string>): Promise<void> {
1060
1077
  console.log(`🎁 appwrap init → ${outDir}`);
1061
1078
  mkdirSync(outDir, { recursive: true });
1062
1079
  regenerateCore(cwd, outDir, cfg, { firstRun: true, flags });
1063
- copyCiTemplates(cwd, outDir, cfg); // first-time scaffold (never overwrites)
1080
+ copyCiTemplates(cwd, outDir, cfg, 'force' in flags); // GH workflows: first-time only; fastlane lane: re-emit on --force
1064
1081
  writeFileSync(join(outDir, '.gitignore'), 'node_modules/\nplatforms/\nhooks/\n');
1065
1082
  applyOverrides(cwd, outDir, cfg); // escape hatch — last, so custom native code wins
1066
1083
  stampVersionManifest(outDir, cfg); // provenance — also marks the dir appwrap-managed
@@ -1176,6 +1193,84 @@ async function build(cwd: string, flags: Record<string, string>, positionals: st
1176
1193
  }
1177
1194
  }
1178
1195
 
1196
+ /** `appwrap release ios` — the ONE build+sign+upload-to-TestFlight command, identical locally and in CI.
1197
+ *
1198
+ * It re-stamps the config + PWA (`sync`) and then delegates the full archive/sign/upload to the emitted
1199
+ * fastlane lane (`native/fastlane` `:beta`) — the SINGLE source of truth for the iOS release recipe (the
1200
+ * lane runs `ns prepare ios --release` → match signing → build_app → upload_to_testflight). CI is a thin
1201
+ * wrapper that just calls this. Keeping the recipe in fastlane (not duplicated in TS) means local and CI
1202
+ * run byte-identical steps.
1203
+ *
1204
+ * Knobs (all optional; mirror the workflow):
1205
+ * --server-url <url> override loader:'server' serverUrl for this release (lab vs prod backend)
1206
+ * --env <name> convenience: resolve serverUrl from cfg.envs[name] when present (see config)
1207
+ * --build-number <n> set the store CFBundleVersion (sets APPWRAP_BUILD_NUMBER for the lane)
1208
+ *
1209
+ * Signing/ASC config is read from env by the lane (ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_P8 /
1210
+ * MATCH_GIT_URL / MATCH_PASSWORD) — secrets never live in appwrap.config. */
1211
+ async function release(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
1212
+ const platform = positionals[0];
1213
+ if (platform !== 'ios') {
1214
+ console.error('Usage: appwrap release ios [--server-url <url>] [--env <name>] [--build-number <n>] [--config <path>] [--out native]\n' +
1215
+ ' (Android: `appwrap build android --release --aab` then `fastlane android beta`.)');
1216
+ process.exit(1);
1217
+ }
1218
+ const cfg = await loadConfig(cwd, flags);
1219
+ const outDir = resolve(cwd, flags.out ?? 'native');
1220
+ if (!existsSync(outDir)) {
1221
+ console.error(`✖ Wrapper not found at ${outDir} — run \`appwrap init\` first (CI must \`init\`, native/ is gitignored).`);
1222
+ process.exit(1);
1223
+ }
1224
+ const fastfile = join(outDir, 'fastlane', 'Fastfile');
1225
+ if (!existsSync(fastfile)) {
1226
+ console.error(`✖ No fastlane lane at ${fastfile} — run \`appwrap init\` to emit it (it carries the release recipe).`);
1227
+ process.exit(1);
1228
+ }
1229
+
1230
+ // Optional backend-url override for loader:'server' apps (lab vs prod). --server-url wins; else
1231
+ // --env resolves from cfg.envs[name] when the consumer config declares it.
1232
+ let serverUrl = flags['server-url'] || undefined;
1233
+ if (!serverUrl && flags.env) {
1234
+ serverUrl = (cfg as { envs?: Record<string, string> }).envs?.[flags.env];
1235
+ if (!serverUrl) {
1236
+ console.error(`✖ --env ${flags.env} given but cfg.envs[${flags.env}] is not set in the config.`);
1237
+ process.exit(1);
1238
+ }
1239
+ }
1240
+ const stampCfg = serverUrl ? { ...cfg, loader: 'server' as const, serverUrl } : cfg;
1241
+
1242
+ // Build number: explicit flag → APPWRAP_BUILD_NUMBER (which the CLI's stamping already honors and
1243
+ // wins over the derived default). In CI the workflow sets APPWRAP_BUILD_NUMBER itself.
1244
+ const env = { ...process.env };
1245
+ if (flags['build-number']) {
1246
+ if (!/^\d+$/.test(flags['build-number'])) {
1247
+ console.error(`✖ --build-number must be a positive integer (got "${flags['build-number']}").`);
1248
+ process.exit(1);
1249
+ }
1250
+ env.APPWRAP_BUILD_NUMBER = flags['build-number'];
1251
+ }
1252
+
1253
+ // Re-stamp config + copy the latest PWA into native/ so the lane archives current sources. (The lane
1254
+ // also runs `ns prepare ios --release`; sync here makes the wrapper config/PWA authoritative first.)
1255
+ await sync(cwd, flags);
1256
+ if (serverUrl) {
1257
+ stampShellConfig(outDir, stampCfg);
1258
+ console.log(`✓ Release loader → ${serverUrl}${flags.env ? ` (env: ${flags.env})` : ''}`);
1259
+ }
1260
+
1261
+ console.log(`▶ fastlane ios beta (cwd: ${outDir}/fastlane → native/)${env.APPWRAP_BUILD_NUMBER ? ` build #${env.APPWRAP_BUILD_NUMBER}` : ''}`);
1262
+ try {
1263
+ execFileSync('fastlane', ['ios', 'beta'], { cwd: outDir, stdio: 'inherit', env });
1264
+ } catch {
1265
+ console.error('\n✖ TestFlight release failed. Common causes:\n' +
1266
+ ' • Missing ASC/match env: ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_P8 (base64), MATCH_GIT_URL, MATCH_PASSWORD.\n' +
1267
+ ' • Certs/profiles not seeded — run `fastlane match appstore` once against MATCH_GIT_URL.\n' +
1268
+ ' • CFBundleVersion already used for this marketing version → pass a higher --build-number.');
1269
+ process.exit(1);
1270
+ }
1271
+ console.log('✓ Uploaded to TestFlight (App Store Connect processing — check the build list / wait for the email).');
1272
+ }
1273
+
1179
1274
  interface AppleTeam { teamId: string; name: string; email?: string; paid: boolean }
1180
1275
  interface DeviceInfo { id: string; name: string; model: string; transport: string }
1181
1276
 
@@ -1712,13 +1807,17 @@ async function main(): Promise<void> {
1712
1807
  case 'deploy':
1713
1808
  await deploy(cwd, flags, positionals);
1714
1809
  break;
1810
+ case 'release':
1811
+ await release(cwd, flags, positionals);
1812
+ break;
1715
1813
  case 'logs':
1716
1814
  await logs(cwd, flags, positionals);
1717
1815
  break;
1718
1816
  default:
1719
- console.log('Usage: appwrap <init|sync|dev|build|deploy|logs> [--config <path>] [--out native]\n' +
1817
+ console.log('Usage: appwrap <init|sync|dev|build|deploy|release|logs> [--config <path>] [--out native]\n' +
1720
1818
  ' config: appwrap.config.ts (preferred) → .js → appwrap.json\n' +
1721
1819
  ' build <ios|android> [--release] [--aab] deploy ios [--device <id|name>] [--no-launch]\n' +
1820
+ ' release ios [--server-url <url>] [--env <name>] [--build-number <n>] (build+sign+upload to TestFlight)\n' +
1722
1821
  ' logs ios [--once] [--native] dev [--url <url> | --port <p>]');
1723
1822
  process.exit(command ? 1 : 0);
1724
1823
  }
@@ -6,6 +6,36 @@ default_platform(:ios)
6
6
  APP_ID = '__APP_ID__'.freeze
7
7
  TEAM_ID = '__TEAM_ID__'.freeze
8
8
 
9
+ # Run `ns prepare` (which shells out to `pod install`) with a SANITIZED gem env.
10
+ #
11
+ # Why: fastlane runs under its own Ruby and exports GEM_HOME/GEM_PATH into every child process. On a
12
+ # Mac, NativeScript's `pod` is the system one (`/usr/local/bin/pod`, shebang-pinned to Ruby 2.6, whose
13
+ # CocoaPods lives in /Library/Ruby/Gems/2.6.0). The inherited GEM_HOME redirects its gem lookup to
14
+ # fastlane's Ruby dir → `can't find gem cocoapods (Gem::GemNotFoundException)` → NS prints
15
+ # `'arch install' command failed` (exit 127). Interactively `pod` works because no GEM_HOME is set.
16
+ #
17
+ # Fix: only when the active `pod` is that system-Ruby-2.6 binary, drop the leaked GEM_* vars so it
18
+ # falls back to its own default gem dir. CI runners use a self-managed Ruby (setup-ruby) whose `pod`
19
+ # is NOT the system 2.6 one, so the env is left untouched there — no behavior change on CI.
20
+ def appwrap_prepare_ios
21
+ prepare = "cd '#{File.expand_path('..', __dir__)}' && ns prepare ios --release"
22
+ pod = `command -v pod`.strip
23
+ system_pod = !pod.empty? && File.exist?(pod) &&
24
+ File.read(pod, 256).include?('Ruby.framework/Versions/2.6')
25
+ unless system_pod
26
+ sh(prepare)
27
+ return
28
+ end
29
+ leaked = %w[GEM_HOME GEM_PATH RUBYLIB RUBYOPT BUNDLE_GEMFILE BUNDLE_BIN_PATH]
30
+ saved = {}
31
+ leaked.each { |k| saved[k] = ENV[k]; ENV.delete(k) }
32
+ begin
33
+ sh(prepare)
34
+ ensure
35
+ saved.each { |k, v| ENV[k] = v unless v.nil? }
36
+ end
37
+ end
38
+
9
39
  platform :ios do
10
40
  desc 'Build the NativeScript wrapper and upload to TestFlight'
11
41
  lane :beta do
@@ -23,7 +53,8 @@ platform :ios do
23
53
  git_url: ENV['MATCH_GIT_URL'], api_key: api_key)
24
54
 
25
55
  # NativeScript prepares the Xcode project inside the wrapper dir (native/ = parent of fastlane/).
26
- sh("cd '#{File.expand_path('..', __dir__)}' && ns prepare ios --release")
56
+ # Wrapped to sanitize the gem env so the system `pod` can find CocoaPods (see appwrap_prepare_ios).
57
+ appwrap_prepare_ios
27
58
 
28
59
  # NS defaults the project to automatic signing → archive can't find a profile. Pin manual signing
29
60
  # to the match-provided App Store profile (name exported by match into ENV).
@@ -34,7 +34,13 @@ jobs:
34
34
  - run: bun install && bun run build
35
35
  # Pin global tools — unpinned installs can grab a breaking release mid-flight. Bump deliberately.
36
36
  - run: npm i -g nativescript@9.0.6
37
- - run: bunx @livx.cc/appwrap init # native/ is generated (gitignored) — regenerate it fresh in CI
37
+ # bunx is PINNED to the appwrap version that emitted this workflow (stamped at `init`). Pinning is
38
+ # load-bearing: an UNPINNED `bunx @livx.cc/appwrap` resolves to whatever is currently published,
39
+ # which may pre-date the `init`/`release` commands and would silently run old code + exit 1 with a
40
+ # confusing usage dump. With the pin, CI fails LOUDLY ("No matching version") until that version is
41
+ # published to the @livx.cc registry. PUBLISH-ORDERING: publish the appwrap CLI BEFORE the first CI
42
+ # run that uses a freshly-emitted workflow.
43
+ - run: bunx @livx.cc/appwrap@^__APPWRAP_VERSION__ init # native/ is generated (gitignored) — regenerate it fresh in CI
38
44
  - working-directory: native
39
45
  run: npm install
40
46
  - uses: ruby/setup-ruby@v1
@@ -47,14 +53,15 @@ jobs:
47
53
  chmod 600 ~/.ssh/match_deploy_key
48
54
  printf 'Host github.com\n IdentityFile ~/.ssh/match_deploy_key\n IdentitiesOnly yes\n' >> ~/.ssh/config
49
55
  ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
56
+ # Single source of truth: the SAME command a developer runs locally. It re-stamps native/ and
57
+ # delegates the full archive/sign/upload to the emitted fastlane lane — no fastlane logic here.
50
58
  - name: Build & upload to TestFlight
51
- working-directory: native
52
59
  env:
53
60
  MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
54
61
  MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
55
62
  ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
56
63
  ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
57
64
  ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }}
58
- run: fastlane ios beta
65
+ run: bunx @livx.cc/appwrap@^__APPWRAP_VERSION__ release ios
59
66
 
60
67
  # Production promotion is a manual gate: promote the TestFlight build in App Store Connect.