react-native-store-version 1.4.2 → 2.0.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.
Files changed (149) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +514 -43
  3. package/dist/checkVersion.d.ts +73 -0
  4. package/dist/checkVersion.d.ts.map +1 -0
  5. package/dist/checkVersion.js +193 -0
  6. package/dist/cli/check.js +38 -0
  7. package/dist/cli/checks/ids.js +111 -0
  8. package/dist/cli/checks/installed.js +51 -0
  9. package/dist/cli/checks/peers.js +31 -0
  10. package/dist/cli/checks/rollout.js +141 -0
  11. package/dist/cli/checks/store.js +71 -0
  12. package/dist/cli/checks/versions.js +83 -0
  13. package/dist/cli/index.js +43 -0
  14. package/dist/cli/main.js +182 -0
  15. package/dist/cli/output.js +77 -0
  16. package/dist/cli/packages.js +40 -0
  17. package/dist/cli/project/android.js +60 -0
  18. package/dist/cli/project/expo.js +194 -0
  19. package/dist/cli/project/files.js +42 -0
  20. package/dist/cli/project/gradle.js +145 -0
  21. package/dist/cli/project/index.js +10 -0
  22. package/dist/cli/project/ios.js +190 -0
  23. package/dist/cli/project/jsx.js +327 -0
  24. package/dist/cli/project/plist.js +166 -0
  25. package/dist/cli/project/sources.js +86 -0
  26. package/dist/cli/result.js +12 -0
  27. package/dist/compare.d.ts +31 -0
  28. package/dist/compare.d.ts.map +1 -0
  29. package/dist/compare.js +68 -0
  30. package/dist/detect.d.ts +68 -0
  31. package/dist/detect.d.ts.map +1 -0
  32. package/dist/detect.js +103 -0
  33. package/dist/errors.d.ts +33 -0
  34. package/dist/errors.d.ts.map +1 -0
  35. package/dist/errors.js +23 -0
  36. package/dist/fetchers/android.d.ts +53 -0
  37. package/dist/fetchers/android.d.ts.map +1 -0
  38. package/dist/fetchers/android.js +91 -0
  39. package/dist/fetchers/custom.d.ts +29 -0
  40. package/dist/fetchers/custom.d.ts.map +1 -0
  41. package/dist/fetchers/custom.js +41 -0
  42. package/dist/fetchers/ios.d.ts +61 -0
  43. package/dist/fetchers/ios.d.ts.map +1 -0
  44. package/dist/fetchers/ios.js +79 -0
  45. package/dist/index.d.ts +15 -14
  46. package/dist/index.d.ts.map +1 -0
  47. package/dist/index.js +9 -58
  48. package/dist/openStore.d.ts +29 -0
  49. package/dist/openStore.d.ts.map +1 -0
  50. package/dist/openStore.js +46 -0
  51. package/dist/policy.d.ts +75 -0
  52. package/dist/policy.d.ts.map +1 -0
  53. package/dist/policy.js +128 -0
  54. package/dist/react/DefaultForceModal.d.ts +26 -0
  55. package/dist/react/DefaultForceModal.d.ts.map +1 -0
  56. package/dist/react/DefaultForceModal.js +58 -0
  57. package/dist/react/DefaultWarningSheet.d.ts +26 -0
  58. package/dist/react/DefaultWarningSheet.d.ts.map +1 -0
  59. package/dist/react/DefaultWarningSheet.js +66 -0
  60. package/dist/react/StoreVersionModal.d.ts +52 -0
  61. package/dist/react/StoreVersionModal.d.ts.map +1 -0
  62. package/dist/react/StoreVersionModal.js +250 -0
  63. package/dist/react/StoreVersionProvider.d.ts +51 -0
  64. package/dist/react/StoreVersionProvider.d.ts.map +1 -0
  65. package/dist/react/StoreVersionProvider.js +69 -0
  66. package/dist/react/defaultUi.d.ts +72 -0
  67. package/dist/react/defaultUi.d.ts.map +1 -0
  68. package/dist/react/defaultUi.js +118 -0
  69. package/dist/react/engine.d.ts +146 -0
  70. package/dist/react/engine.d.ts.map +1 -0
  71. package/dist/react/engine.js +541 -0
  72. package/dist/react/texts.d.ts +43 -0
  73. package/dist/react/texts.d.ts.map +1 -0
  74. package/dist/react/texts.js +57 -0
  75. package/dist/react/useIsomorphicLayoutEffect.d.ts +3 -0
  76. package/dist/react/useIsomorphicLayoutEffect.d.ts.map +1 -0
  77. package/dist/react/useIsomorphicLayoutEffect.js +6 -0
  78. package/dist/react/useStoreVersion.d.ts +121 -0
  79. package/dist/react/useStoreVersion.d.ts.map +1 -0
  80. package/dist/react/useStoreVersion.js +23 -0
  81. package/dist/request.d.ts +78 -0
  82. package/dist/request.d.ts.map +1 -0
  83. package/dist/request.js +227 -0
  84. package/dist/resolve.d.ts +98 -0
  85. package/dist/resolve.d.ts.map +1 -0
  86. package/dist/resolve.js +218 -0
  87. package/dist/texts/ja.d.ts +8 -0
  88. package/dist/texts/ja.d.ts.map +1 -0
  89. package/dist/texts/ja.js +18 -0
  90. package/dist/vendor/compare-versions.d.ts +52 -0
  91. package/dist/vendor/compare-versions.d.ts.map +1 -0
  92. package/dist/vendor/compare-versions.js +111 -0
  93. package/package.json +83 -44
  94. package/src/checkVersion.ts +273 -0
  95. package/src/cli/check.ts +59 -0
  96. package/src/cli/checks/ids.ts +163 -0
  97. package/src/cli/checks/installed.ts +75 -0
  98. package/src/cli/checks/peers.ts +34 -0
  99. package/src/cli/checks/rollout.ts +183 -0
  100. package/src/cli/checks/store.ts +105 -0
  101. package/src/cli/checks/versions.ts +108 -0
  102. package/src/cli/index.ts +51 -0
  103. package/src/cli/main.ts +229 -0
  104. package/src/cli/output.ts +122 -0
  105. package/src/cli/packages.ts +43 -0
  106. package/src/cli/project/android.ts +96 -0
  107. package/src/cli/project/expo.ts +232 -0
  108. package/src/cli/project/files.ts +56 -0
  109. package/src/cli/project/gradle.ts +181 -0
  110. package/src/cli/project/index.ts +18 -0
  111. package/src/cli/project/ios.ts +284 -0
  112. package/src/cli/project/jsx.ts +354 -0
  113. package/src/cli/project/plist.ts +179 -0
  114. package/src/cli/project/sources.ts +118 -0
  115. package/src/cli/result.ts +34 -0
  116. package/src/compare.ts +101 -0
  117. package/src/detect.ts +178 -0
  118. package/src/errors.ts +63 -0
  119. package/src/fetchers/android.ts +136 -0
  120. package/src/fetchers/custom.ts +84 -0
  121. package/src/fetchers/ios.ts +160 -0
  122. package/src/index.ts +37 -0
  123. package/src/openStore.ts +74 -0
  124. package/src/policy.ts +223 -0
  125. package/src/react/DefaultForceModal.tsx +100 -0
  126. package/src/react/DefaultWarningSheet.tsx +115 -0
  127. package/src/react/StoreVersionModal.tsx +365 -0
  128. package/src/react/StoreVersionProvider.tsx +149 -0
  129. package/src/react/defaultUi.tsx +205 -0
  130. package/src/react/engine.ts +768 -0
  131. package/src/react/texts.ts +119 -0
  132. package/src/react/useIsomorphicLayoutEffect.ts +8 -0
  133. package/src/react/useStoreVersion.ts +153 -0
  134. package/src/request.ts +328 -0
  135. package/src/resolve.ts +388 -0
  136. package/src/texts/ja.ts +26 -0
  137. package/src/vendor/compare-versions.ts +128 -0
  138. package/texts/ja.d.ts +2 -0
  139. package/texts/ja.js +3 -0
  140. package/.eslintrc.js +0 -51
  141. package/.github/FUNDING.yml +0 -3
  142. package/.github/workflows/pr.yml +0 -48
  143. package/.github/workflows/release.yml +0 -259
  144. package/.prettierrc.js +0 -6
  145. package/babel.config.js +0 -3
  146. package/dist/android.d.ts +0 -1
  147. package/dist/android.js +0 -17
  148. package/dist/ios.d.ts +0 -1
  149. package/dist/ios.js +0 -18
@@ -0,0 +1,183 @@
1
+ // C8: Android の段階公開の注意。Google Play の段階公開では、公開対象外のユーザーは新しい版を入手できない。
2
+ // ストアの版との差分で force にしていると、その人たちはアプリを使えなくなる。ソースの簡易なテキスト検索で、
3
+ // その設定になっていそうなときに注意を出す(誤検知はありうるので、--ignore C8 で抑止できることを書く)
4
+ import { displayPath } from '../project/files.ts';
5
+ import {
6
+ findOpeningTags,
7
+ maskSource,
8
+ readObjectLiteral,
9
+ } from '../project/jsx.ts';
10
+ import {
11
+ SCAN_LIMITS,
12
+ type ScanLimits,
13
+ scanSources,
14
+ } from '../project/sources.ts';
15
+ import type { CheckEntry } from '../result.ts';
16
+
17
+ const PROVIDER = 'StoreVersionProvider';
18
+
19
+ /** ストアの差分によらず版を渡す関数。どこかで使っていれば、force をストアの差分だけで決めていない */
20
+ const VERSION_GETTERS = /\b(getLatestVersion|getMinimumVersion)\b/;
21
+
22
+ /** 一覧に出すファイルの数(多いと 1 行が長くなりすぎる) */
23
+ const MAX_LISTED = 5;
24
+
25
+ const files = (n: number) => `${n} file${n === 1 ? '' : 's'}`;
26
+
27
+ const listFiles = (files: readonly string[]): string =>
28
+ files.length > MAX_LISTED
29
+ ? `${files.slice(0, MAX_LISTED).join(', ')} and ${files.length - MAX_LISTED} more`
30
+ : files.join(', ');
31
+
32
+ const ROLLOUT_NOTE =
33
+ "If you use a staged rollout on Google Play, users outside the rollout cannot get the new version, so a force update from the store version can leave them unable to use the app. When Platform.OS === 'android', set major: 'warning' in the policy, or control force with getMinimumVersion. This is a simple text search and may be a false positive (a policy that already uses major: 'warning' only on Android still has 'force' in it); if so, suppress it with --ignore C8";
34
+
35
+ /** 理由の文言(`<StoreVersionProvider> …` に続く) */
36
+ const REASONS = {
37
+ noPolicy: "has no policy prop (the default policy has major: 'force')",
38
+ spread: 'has no policy prop (it may come from a spread)',
39
+ force: "has 'force' in its policy",
40
+ noMajor:
41
+ "does not set major in its policy, so major updates use the default 'force'",
42
+ defaultPolicy: (expression: string) =>
43
+ `has policy={${expression}}, so the default policy (major: 'force') is used`,
44
+ } as const;
45
+
46
+ /** null / undefined は省略と同じで既定値になる */
47
+ const isDefault = (expression: string): boolean =>
48
+ expression === 'undefined' || expression === 'null';
49
+
50
+ /**
51
+ * policy 属性の値(`{…}` / `"…"` の書かれたまま)から、ストアの差分で force になりそうな理由。
52
+ * `'force'` / `"force"` があるか、`{undefined}` / `{null}`(既定の policy)か、オブジェクトリテラルで `major` が無い・
53
+ * null / undefined(既定の major は force)。変数や関数の policy は中身が分からないので、`'force'` の有無だけを見る。
54
+ * `...base` を含むオブジェクトは major がそこから来うるので、major の有無は見ない
55
+ */
56
+ const policyReasons = (value: string | null): string[] => {
57
+ if (value === null) {
58
+ // 値の無い `policy`(true)。型の誤りで、判定の材料にならない
59
+ return [];
60
+ }
61
+ const reasons: string[] = [];
62
+ if (/'force'|"force"/.test(value)) {
63
+ reasons.push(REASONS.force);
64
+ }
65
+ if (!value.startsWith('{') || !value.endsWith('}')) {
66
+ return reasons;
67
+ }
68
+ const expression = maskSource(value.slice(1, -1), { strings: false }).trim();
69
+ if (isDefault(expression)) {
70
+ return [REASONS.defaultPolicy(expression)];
71
+ }
72
+ const object = readObjectLiteral(expression);
73
+ if (object !== null && !object.spread) {
74
+ const major = object.properties.get('major');
75
+ if (major === undefined || (major !== null && isDefault(major))) {
76
+ reasons.push(REASONS.noMajor);
77
+ }
78
+ }
79
+ return reasons;
80
+ };
81
+
82
+ /**
83
+ * 1 つのファイルの `<StoreVersionProvider …>` の開始タグのうち、ストアの差分で force になりそうなものの理由
84
+ * (policy 属性が無い = デフォルトの major が force。{@link policyReasons})。重複は 1 つにする
85
+ */
86
+ const forceReasons = (source: string): string[] => {
87
+ const reasons = new Set<string>();
88
+ for (const tag of findOpeningTags(source, PROVIDER)) {
89
+ const policy = tag.attributes.find(({ name }) => name === 'policy');
90
+ if (policy === undefined) {
91
+ reasons.add(tag.spreads > 0 ? REASONS.spread : REASONS.noPolicy);
92
+ continue;
93
+ }
94
+ for (const reason of policyReasons(policy.value)) {
95
+ reasons.add(reason);
96
+ }
97
+ }
98
+ return [...reasons];
99
+ };
100
+
101
+ /**
102
+ * C8。Android の package が特定できたときだけ(無ければ skipped)。`<cwd>` の下のソースを走査し、
103
+ * `<StoreVersionProvider>` の開始タグが force になりそうなファイルごとに warning。ただし `getLatestVersion` /
104
+ * `getMinimumVersion` が走査したどこかのファイルにあれば、どれも出さない。ファイル数の上限を超えたら判定せずに info
105
+ */
106
+ export const checkRollout = (
107
+ cwd: string,
108
+ androidPackage: string | null,
109
+ limits: ScanLimits = SCAN_LIMITS
110
+ ): CheckEntry[] => {
111
+ if (androidPackage === null) {
112
+ return [
113
+ {
114
+ id: 'C8',
115
+ level: 'skipped',
116
+ message:
117
+ 'no Android package was determined, so the staged rollout note does not apply',
118
+ },
119
+ ];
120
+ }
121
+
122
+ const flagged: { file: string; reasons: string[] }[] = [];
123
+ let getterFile: string | null = null;
124
+ const scan = scanSources(
125
+ cwd,
126
+ (path, source) => {
127
+ // コメントの中の名前(`// TODO: getMinimumVersion を使う`)は使っていることにならない
128
+ if (
129
+ getterFile === null &&
130
+ VERSION_GETTERS.test(maskSource(source, { strings: false }))
131
+ ) {
132
+ getterFile = displayPath(cwd, path);
133
+ }
134
+ if (source.includes(PROVIDER)) {
135
+ const reasons = forceReasons(source);
136
+ if (reasons.length > 0) {
137
+ flagged.push({ file: displayPath(cwd, path), reasons });
138
+ }
139
+ }
140
+ },
141
+ limits
142
+ );
143
+
144
+ if (scan.truncated) {
145
+ return [
146
+ {
147
+ id: 'C8',
148
+ level: 'info',
149
+ message: `stopped scanning the sources at ${files(limits.maxFiles)} (.ts / .tsx / .js / .jsx outside node_modules, ios, android, and build output), so the staged rollout note was not checked. Check --cwd, or suppress this with --ignore C8`,
150
+ },
151
+ ];
152
+ }
153
+
154
+ const entries: CheckEntry[] = [];
155
+ if (scan.tooLarge.length > 0) {
156
+ entries.push({
157
+ id: 'C8',
158
+ level: 'info',
159
+ message: `did not scan ${scan.tooLarge.length} source ${scan.tooLarge.length === 1 ? 'file' : 'files'} larger than ${limits.maxFileBytes} bytes: ${listFiles(scan.tooLarge.map((path) => displayPath(cwd, path)))}`,
160
+ });
161
+ }
162
+
163
+ if (getterFile !== null || flagged.length === 0) {
164
+ entries.push({
165
+ id: 'C8',
166
+ level: 'ok',
167
+ message:
168
+ getterFile !== null
169
+ ? `getLatestVersion / getMinimumVersion is used (${getterFile}), so force updates do not depend only on the store version`
170
+ : `no <${PROVIDER}> that forces updates from the store version was found (${files(scan.scanned)} scanned)`,
171
+ });
172
+ return entries;
173
+ }
174
+
175
+ for (const { file, reasons } of flagged) {
176
+ entries.push({
177
+ id: 'C8',
178
+ level: 'warning',
179
+ message: `${file}: <${PROVIDER}> ${reasons.join(' / ')}, and getLatestVersion / getMinimumVersion is not used in the sources. ${ROLLOUT_NOTE}`,
180
+ });
181
+ }
182
+ return entries;
183
+ };
@@ -0,0 +1,105 @@
1
+ // C3: その ID でストアから引けるか。ストアの取得はライブラリ本体の fetcher をそのまま使う
2
+ // (アプリが起動時に使うのと同じ取得先・同じ抽出)
3
+ import { StoreVersionError } from '../../errors.ts';
4
+ import { fetchAndroidVersion } from '../../fetchers/android.ts';
5
+ import { lookupIOS } from '../../fetchers/ios.ts';
6
+ import type { CheckEntry } from '../result.ts';
7
+
8
+ /** C3 の 1 回の取得のタイムアウト(ms) */
9
+ export const STORE_TIMEOUT_MS = 10_000;
10
+
11
+ /** C3 で引く ID。無い(null / undefined / 空文字)プラットフォームは飛ばす */
12
+ export type StoreIds = {
13
+ /** iOS の bundleId */
14
+ ios?: string | null;
15
+ /** Android の package name */
16
+ android?: string | null;
17
+ };
18
+
19
+ const describeError = (error: unknown): string =>
20
+ error instanceof StoreVersionError
21
+ ? `${error.code}: ${error.message}`
22
+ : error instanceof Error
23
+ ? error.message
24
+ : String(error);
25
+
26
+ const androidFailure = (packageName: string, error: unknown): string => {
27
+ const code = error instanceof StoreVersionError ? error.code : undefined;
28
+ if (code === 'NOT_FOUND') {
29
+ return `package "${packageName}" is not found on Google Play: not published yet, a wrong ID, or not available in the country of this network`;
30
+ }
31
+ // ページは取れたが版を比較できない(Varies with device / `50616-r-2026-11-03` のような版)。アプリの起動時の判定でも
32
+ // ストアの版は使えない(INVALID_VERSION で onError になり UI を出さない)ので、取り直しても直らない失敗として分ける
33
+ if (code === 'INVALID_VERSION') {
34
+ return `package "${packageName}" is on Google Play, but its version cannot be compared (${(error as StoreVersionError).message}). The store version cannot drive force / warning updates, so use getLatestVersion / getMinimumVersion`;
35
+ }
36
+ return `could not get package "${packageName}" from Google Play (access from CI can be unstable): ${describeError(error)}`;
37
+ };
38
+
39
+ /**
40
+ * C3。ID ごとにストアを引き、見つかれば ok(ストアの版を添える)、失敗はすべて warning
41
+ * (`NOT_FOUND` は「ストアに見つからない」。ほかの失敗も、CI からのストア、特に Play Store へのアクセスは不安定なので error にしない)。
42
+ * Android の `INVALID_VERSION`(Google Play にはあるが版を比較できない)は、getLatestVersion / getMinimumVersion を勧める文面にする
43
+ * (iOS の lookupIOS は版を検証しないので INVALID_VERSION にならない)。ID が 1 つも無ければ skipped
44
+ */
45
+ export const checkStore = async (
46
+ ids: StoreIds,
47
+ { country }: { country: string }
48
+ ): Promise<CheckEntry[]> => {
49
+ const tasks: Promise<CheckEntry>[] = [];
50
+
51
+ if (ids.ios) {
52
+ const bundleId = ids.ios;
53
+ const where = `the App Store (${country})`;
54
+ tasks.push(
55
+ lookupIOS({ bundleId }, country, { timeoutMs: STORE_TIMEOUT_MS }).then(
56
+ ({ version }): CheckEntry => ({
57
+ id: 'C3',
58
+ level: 'ok',
59
+ message: `bundleId "${bundleId}" is on ${where} (version ${version})`,
60
+ }),
61
+ (error: unknown): CheckEntry => ({
62
+ id: 'C3',
63
+ level: 'warning',
64
+ message:
65
+ error instanceof StoreVersionError && error.code === 'NOT_FOUND'
66
+ ? `bundleId "${bundleId}" is not found on ${where}: not published yet, a wrong ID, or not available in that country`
67
+ : `could not look up bundleId "${bundleId}" on ${where}: ${describeError(error)}`,
68
+ })
69
+ )
70
+ );
71
+ }
72
+
73
+ if (ids.android) {
74
+ const packageName = ids.android;
75
+ tasks.push(
76
+ fetchAndroidVersion(
77
+ { packageName },
78
+ { timeoutMs: STORE_TIMEOUT_MS }
79
+ ).then(
80
+ ({ version }): CheckEntry => ({
81
+ id: 'C3',
82
+ level: 'ok',
83
+ message: `package "${packageName}" is on Google Play (version ${version})`,
84
+ }),
85
+ (error: unknown): CheckEntry => ({
86
+ id: 'C3',
87
+ level: 'warning',
88
+ message: androidFailure(packageName, error),
89
+ })
90
+ )
91
+ );
92
+ }
93
+
94
+ if (tasks.length === 0) {
95
+ return [
96
+ {
97
+ id: 'C3',
98
+ level: 'skipped',
99
+ message: 'no iOS bundleId or Android package to look up',
100
+ },
101
+ ];
102
+ }
103
+ // 各 task は reject しない(失敗は then の第 2 引数で warning にしている)
104
+ return Promise.all(tasks);
105
+ };
@@ -0,0 +1,108 @@
1
+ // C4: ローカルの版(Expo の設定の version・iOS・Android)が一致しているか。
2
+ // ストアの版と比べる「ローカルの版」はプラットフォームごとにアプリへ入る値なので、ずれていると片方だけ判定が変わる
3
+ import type { Found } from '../project/files.ts';
4
+ import type { ProjectInfo } from '../project/index.ts';
5
+ import type { CheckEntry } from '../result.ts';
6
+
7
+ type Source = {
8
+ /** `iOS` / `Android` / `Expo` */
9
+ name: string;
10
+ /** 見つかった値。見つからなければ理由 */
11
+ found: Found | null;
12
+ reason: string;
13
+ };
14
+
15
+ const describe = (name: string, { label, value, file }: Found) =>
16
+ `${name} ${label} ${value} (${file})`;
17
+
18
+ /**
19
+ * C4。ネイティブのディレクトリ(`ios/` / `android/`)がどちらも無い(CNG)なら skipped(指摘ではないので注釈にしない)。
20
+ * iOS と Android(ネイティブ同士)、Expo の設定の version と各ネイティブを比べ、ずれている組ごとに warning。
21
+ * 特定できないネイティブの値は、その値を含む比較を飛ばして info(Expo の version が書かれていなければ黙って比べない)。比べる組が無ければ skipped。比較は文字列の一致
22
+ * (`1.0` と `1.0.0` もずれとして出す。ストアに出る値の書き方を揃えるため)
23
+ */
24
+ export const checkVersions = (project: ProjectInfo): CheckEntry[] => {
25
+ if (project.ios === null && project.android === null) {
26
+ return [
27
+ {
28
+ id: 'C4',
29
+ level: 'skipped',
30
+ message:
31
+ 'there is no ios/ or android/ directory (Continuous Native Generation), so there are no native versions to compare',
32
+ },
33
+ ];
34
+ }
35
+
36
+ const sources: Source[] = [];
37
+ for (const [name, lookup] of [
38
+ ['iOS', project.ios?.version],
39
+ ['Android', project.android?.version],
40
+ ] as const) {
41
+ if (lookup !== undefined) {
42
+ sources.push(
43
+ lookup.value === null
44
+ ? { name, found: null, reason: lookup.reason }
45
+ : { name, found: lookup, reason: '' }
46
+ );
47
+ }
48
+ }
49
+ // Expo の設定の version は任意(書かなければ Expo が package.json の値で補う。expo.ts の writtenVersion)なので、
50
+ // 書かれていないときは比べず、info も出さない(bare RN に expo を入れただけのアプリで毎回出るのを避ける)
51
+ const config = project.expo?.config;
52
+ if (config && config.version !== null) {
53
+ sources.push({
54
+ name: 'Expo',
55
+ found: { label: 'version', value: config.version, file: config.file },
56
+ reason: '',
57
+ });
58
+ }
59
+
60
+ const entries: CheckEntry[] = [];
61
+ for (const { name, found, reason } of sources) {
62
+ if (found === null) {
63
+ entries.push({
64
+ id: 'C4',
65
+ level: 'info',
66
+ message: `could not determine the ${name} version (${reason}), so it is not compared`,
67
+ });
68
+ }
69
+ }
70
+
71
+ const known = sources.filter(
72
+ (source): source is Source & { found: Found } => source.found !== null
73
+ );
74
+ if (known.length < 2) {
75
+ if (entries.length === 0) {
76
+ entries.push({
77
+ id: 'C4',
78
+ level: 'skipped',
79
+ message: `only one version was found (${known.map(({ name, found }) => describe(name, found)).join('')}), so there is nothing to compare`,
80
+ });
81
+ }
82
+ return entries;
83
+ }
84
+
85
+ let differs = false;
86
+ for (let i = 0; i < known.length; i += 1) {
87
+ for (let j = i + 1; j < known.length; j += 1) {
88
+ const a = known[i];
89
+ const b = known[j];
90
+ if (a.found.value !== b.found.value) {
91
+ differs = true;
92
+ entries.push({
93
+ id: 'C4',
94
+ level: 'warning',
95
+ message: `${describe(a.name, a.found)} differs from ${describe(b.name, b.found)}`,
96
+ });
97
+ }
98
+ }
99
+ }
100
+ if (!differs) {
101
+ entries.push({
102
+ id: 'C4',
103
+ level: 'ok',
104
+ message: `the versions match: ${known.map(({ name, found }) => describe(name, found)).join(', ')}`,
105
+ });
106
+ }
107
+ return entries;
108
+ };
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+ // CLI の入口(package.json の bin。Node 専用で、アプリのバンドルには入らない。exports からは辿れない)。
3
+ // ここでは Node の版だけを確かめ、満たすときだけ本体(main.ts)を読み込む。ESM の static import は巻き上げられて
4
+ // この判定より先に評価される(古い Node では本体が使う API の読み込みで先に落ちうる)ので、ほかのモジュールを
5
+ // static に import しない(__tests__/cli/index.test.ts でソースを読んで確かめている)
6
+
7
+ /** CLI が動く Node の最低の版(package.json の engines には書かない) */
8
+ export const MIN_NODE_VERSION = '22.14.0';
9
+
10
+ /**
11
+ * `process.versions.node`(`22.14.0` など)が {@link MIN_NODE_VERSION} 以上か。major / minor だけを見る。
12
+ * 比較のモジュールも import しないため、手で分ける。読めない値は満たさない扱い
13
+ */
14
+ export const isSupportedNode = (version: string): boolean => {
15
+ const [major, minor] = version.split('.').map((part) => Number(part));
16
+ if (!Number.isInteger(major) || !Number.isInteger(minor)) {
17
+ return false;
18
+ }
19
+ return major > 22 || (major === 22 && minor >= 14);
20
+ };
21
+
22
+ const start = async (): Promise<void> => {
23
+ const current = process.versions.node;
24
+ if (!isSupportedNode(current)) {
25
+ const message = `the CLI requires Node.js ${MIN_NODE_VERSION} or later (current: ${current})`;
26
+ process.stderr.write(`react-native-store-version: ${message}\n`);
27
+ // GitHub Actions 上では、ログの stderr だけでは理由が PR の画面に出ないので注釈も出す(main.ts の失敗と同じ title)。
28
+ // output.ts の escapeData は import できない(上のとおり)ので、同じ最小限のエスケープ(`%` を最初に)をここで行う
29
+ if (process.env.GITHUB_ACTIONS === 'true') {
30
+ const escaped = message
31
+ .replace(/%/g, '%25')
32
+ .replace(/\r/g, '%0D')
33
+ .replace(/\n/g, '%0A');
34
+ process.stderr.write(
35
+ `::error title=react-native-store-version check::${escaped}\n`
36
+ );
37
+ }
38
+ process.exitCode = 1;
39
+ return;
40
+ }
41
+ const { main } = await import('./main.ts');
42
+ await main();
43
+ };
44
+
45
+ // main() は自分で失敗を受けて終了コードにするので、ここに来るのは本体の読み込みの失敗だけ
46
+ start().catch((error: unknown) => {
47
+ process.stderr.write(
48
+ `react-native-store-version: ${error instanceof Error ? error.message : String(error)}\n`
49
+ );
50
+ process.exitCode = 1;
51
+ });
@@ -0,0 +1,229 @@
1
+ // CLI の本体(index.ts が Node の版を確かめてから読み込む)。引数の解析・チェックの実行・出力・終了コード。
2
+ // Node の標準モジュールと、react-native を読まないライブラリ本体のファイルだけを使う(CLI のための依存は足さない)
3
+ import { appendFileSync, statSync } from 'node:fs';
4
+ import { resolve } from 'node:path';
5
+ import { parseArgs } from 'node:util';
6
+
7
+ import { runChecks } from './check.ts';
8
+ import {
9
+ formatGithub,
10
+ formatGithubFailure,
11
+ formatJson,
12
+ formatSummary,
13
+ formatText,
14
+ oneLine,
15
+ type Report,
16
+ } from './output.ts';
17
+ import { countEntries, exitCodeOf } from './result.ts';
18
+
19
+ /** C3 の App Store の国の既定 */
20
+ export const DEFAULT_COUNTRY = 'us';
21
+
22
+ /** `--help` の出力 */
23
+ export const USAGE = `Usage: react-native-store-version check [options]
24
+
25
+ Checks the setup of react-native-store-version in an app.
26
+
27
+ Options:
28
+ --cwd <dir> the app directory (default: the current directory)
29
+ --json print the result as JSON
30
+ --strict exit with 1 on warnings too
31
+ --ignore <ids> comma-separated checks not to run (e.g. C4,C8)
32
+ --country <xx> the App Store country for C3 (default: ${DEFAULT_COUNTRY})
33
+ -h, --help show this help
34
+ `;
35
+
36
+ /** {@link run} が使う外界(テストから差し替える) */
37
+ export type RunContext = {
38
+ /** `--cwd` の相対パスの起点と既定 */
39
+ cwd: string;
40
+ env: Readonly<Record<string, string | undefined>>;
41
+ stdout: (text: string) => void;
42
+ stderr: (text: string) => void;
43
+ };
44
+
45
+ const errorMessage = (error: unknown) =>
46
+ error instanceof Error ? error.message : String(error);
47
+
48
+ /** 引数の誤り。使い方と一緒に stderr に出し、終了コード 1 にする */
49
+ class UsageError extends Error {}
50
+
51
+ type CheckArgs = {
52
+ cwd: string;
53
+ json: boolean;
54
+ strict: boolean;
55
+ ignore: Set<string>;
56
+ country: string;
57
+ };
58
+
59
+ const parseIgnore = (values: readonly string[]): Set<string> => {
60
+ const ids = new Set<string>();
61
+ for (const value of values) {
62
+ for (const part of value.split(',')) {
63
+ const id = part.trim().toUpperCase();
64
+ if (id === '') {
65
+ continue;
66
+ }
67
+ // ID の形だけを見る(無い ID は何も止めないだけなので、誤りにはしない)
68
+ if (!/^C\d+$/.test(id)) {
69
+ throw new UsageError(
70
+ `--ignore: ${JSON.stringify(part.trim())} is not a check ID (e.g. C1)`
71
+ );
72
+ }
73
+ ids.add(id);
74
+ }
75
+ }
76
+ return ids;
77
+ };
78
+
79
+ const parse = (argv: readonly string[]) =>
80
+ parseArgs({
81
+ args: [...argv],
82
+ allowPositionals: true,
83
+ strict: true,
84
+ options: {
85
+ cwd: { type: 'string' },
86
+ json: { type: 'boolean' },
87
+ strict: { type: 'boolean' },
88
+ ignore: { type: 'string', multiple: true },
89
+ country: { type: 'string' },
90
+ help: { type: 'boolean', short: 'h' },
91
+ },
92
+ });
93
+
94
+ /** 引数を解析する。`--help` なら null */
95
+ const parseCheckArgs = (
96
+ argv: readonly string[],
97
+ context: RunContext
98
+ ): CheckArgs | null => {
99
+ let parsed: ReturnType<typeof parse>;
100
+ try {
101
+ parsed = parse(argv);
102
+ } catch (error) {
103
+ // parseArgs の誤り(不明なオプション・値の無い --cwd など)
104
+ throw new UsageError(errorMessage(error));
105
+ }
106
+ const { values, positionals } = parsed;
107
+ if (values.help) {
108
+ return null;
109
+ }
110
+ const [command, ...rest] = positionals;
111
+ if (command === undefined) {
112
+ throw new UsageError('no command given');
113
+ }
114
+ if (command !== 'check') {
115
+ throw new UsageError(`unknown command: ${command}`);
116
+ }
117
+ if (rest.length > 0) {
118
+ throw new UsageError(`unexpected argument: ${rest[0]}`);
119
+ }
120
+
121
+ const cwd = resolve(context.cwd, values.cwd ?? '.');
122
+ let isDirectory = false;
123
+ try {
124
+ isDirectory = statSync(cwd).isDirectory();
125
+ } catch {
126
+ // 無いパスも、下でディレクトリでない扱いにする
127
+ }
128
+ if (!isDirectory) {
129
+ throw new UsageError(`--cwd: ${cwd} is not a directory`);
130
+ }
131
+
132
+ const country = values.country ?? DEFAULT_COUNTRY;
133
+ // iTunes Lookup の country は 2 文字の国コード
134
+ if (!/^[A-Za-z]{2}$/.test(country)) {
135
+ throw new UsageError(
136
+ `--country: ${JSON.stringify(country)} is not a 2-letter country code`
137
+ );
138
+ }
139
+
140
+ return {
141
+ cwd,
142
+ json: values.json ?? false,
143
+ strict: values.strict ?? false,
144
+ ignore: parseIgnore(values.ignore ?? []),
145
+ country: country.toLowerCase(),
146
+ };
147
+ };
148
+
149
+ /**
150
+ * CLI を引数 `argv`(`process.argv.slice(2)`)で動かし、終了コードを返す。例外は投げない
151
+ * (引数の誤り・想定外の失敗は stderr に出して 1。GitHub Actions 上では stderr に `::error::` の注釈も出し、
152
+ * stdout には何も出さない(`--json` の stdout を JSON 以外で汚さないため。runner は stderr のコマンドも処理する))
153
+ */
154
+ export const run = async (
155
+ argv: readonly string[],
156
+ context: RunContext
157
+ ): Promise<number> => {
158
+ const onGithub = context.env.GITHUB_ACTIONS === 'true';
159
+ const fail = (message: string, usage: boolean): 1 => {
160
+ // GitHub Actions 上では、message の改行で次の行がワークフローコマンドにならないよう 1 行にする
161
+ const line = onGithub ? oneLine(message) : message;
162
+ context.stderr(`error: ${line}\n${usage ? `\n${USAGE}` : ''}`);
163
+ if (onGithub) {
164
+ context.stderr(formatGithubFailure(message));
165
+ }
166
+ return 1;
167
+ };
168
+
169
+ let args: CheckArgs | null;
170
+ try {
171
+ args = parseCheckArgs(argv, context);
172
+ } catch (error) {
173
+ return fail(errorMessage(error), true);
174
+ }
175
+ if (args === null) {
176
+ context.stdout(USAGE);
177
+ return 0;
178
+ }
179
+
180
+ let report: Report;
181
+ try {
182
+ const entries = await runChecks({
183
+ cwd: args.cwd,
184
+ country: args.country,
185
+ ignore: args.ignore,
186
+ });
187
+ const counts = countEntries(entries);
188
+ report = {
189
+ cwd: args.cwd,
190
+ entries,
191
+ counts,
192
+ exitCode: exitCodeOf(counts, args.strict),
193
+ };
194
+ } catch (error) {
195
+ return fail(errorMessage(error), false);
196
+ }
197
+
198
+ if (args.json) {
199
+ // stdout は JSON だけにする(GitHub Actions 上でも注釈を混ぜない)
200
+ context.stdout(formatJson(report));
201
+ } else if (onGithub) {
202
+ context.stdout(formatGithub(report));
203
+ } else {
204
+ context.stdout(formatText(report));
205
+ }
206
+ // Job Summary はファイルへの追記で stdout を汚さないので、--json でも書く
207
+ const summary = context.env.GITHUB_STEP_SUMMARY;
208
+ if (onGithub && summary) {
209
+ try {
210
+ appendFileSync(summary, formatSummary(report));
211
+ } catch (error) {
212
+ // Job Summary は付け足しの表示なので、書けなくてもチェックの結果(終了コード)は変えない
213
+ context.stderr(
214
+ `warning: could not write the job summary: ${errorMessage(error)}\n`
215
+ );
216
+ }
217
+ }
218
+ return report.exitCode;
219
+ };
220
+
221
+ /** `process` につないで {@link run} を動かす(index.ts から呼ぶ) */
222
+ export const main = async (): Promise<void> => {
223
+ process.exitCode = await run(process.argv.slice(2), {
224
+ cwd: process.cwd(),
225
+ env: process.env,
226
+ stdout: (text) => process.stdout.write(text),
227
+ stderr: (text) => process.stderr.write(text),
228
+ });
229
+ };