@hyodotdev/openiap 0.0.0-bootstrap.0 → 0.1.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.
@@ -0,0 +1,416 @@
1
+ import {
2
+ closeSync,
3
+ constants,
4
+ fstatSync,
5
+ lstatSync,
6
+ openSync,
7
+ readdirSync,
8
+ readFileSync,
9
+ realpathSync,
10
+ statSync,
11
+ } from "node:fs";
12
+ import path from "node:path";
13
+ import { parseDocument } from "yaml";
14
+
15
+ /** Paths this run could not read; `doctor` turns the set into findings. */
16
+ export const unreadable = new Set();
17
+
18
+ export function hasUnreadablePath(relative) {
19
+ return [...unreadable].some(
20
+ (file) =>
21
+ file === relative ||
22
+ file.startsWith(`${relative}/`) ||
23
+ file.startsWith(`${relative}${path.sep}`),
24
+ );
25
+ }
26
+
27
+ function isAbsent(file) {
28
+ try {
29
+ let ancestor = file;
30
+ while (!lstatSync(ancestor, { throwIfNoEntry: false })) {
31
+ const parent = path.dirname(ancestor);
32
+ if (parent === ancestor) return true;
33
+ ancestor = parent;
34
+ }
35
+ return ancestor !== file && statSync(ancestor).isDirectory();
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ /** Reading a file has three outcomes, not two. */
42
+ function readState(root, relative) {
43
+ const file = path.join(root, relative);
44
+ let descriptor;
45
+ try {
46
+ // Check the opened file; a pathname can be replaced by a FIFO before open.
47
+ descriptor = openSync(
48
+ file,
49
+ constants.O_RDONLY | (constants.O_NONBLOCK ?? 0),
50
+ );
51
+ if (!fstatSync(descriptor).isFile()) return { state: "unreadable" };
52
+ return { state: "read", text: readFileSync(descriptor, "utf8") };
53
+ } catch {
54
+ return { state: isAbsent(file) ? "absent" : "unreadable" };
55
+ } finally {
56
+ if (descriptor !== undefined) closeSync(descriptor);
57
+ }
58
+ }
59
+
60
+ /** Read a file, or null when it is absent or unreadable. */
61
+ export function read(root, relative) {
62
+ const result = readState(root, relative);
63
+ if (result.state === "unreadable") unreadable.add(relative);
64
+ return result.state === "read" ? result.text : null;
65
+ }
66
+
67
+ /** Read the first of these files that exists, with the path that supplied it. */
68
+ export function readFirst(root, relatives) {
69
+ for (const relative of relatives) {
70
+ const text = read(root, relative);
71
+ if (text !== null) return { file: relative, text };
72
+ }
73
+ return null;
74
+ }
75
+
76
+ /** Directory entry names, or an empty list when the directory is absent. */
77
+ export function listDir(root, relative) {
78
+ const dir = path.join(root, relative);
79
+ try {
80
+ return readdirSync(dir);
81
+ } catch {
82
+ // A directory that exists but cannot be listed is not an empty one.
83
+ if (!isAbsent(dir)) {
84
+ unreadable.add(relative === "." ? "." : relative);
85
+ }
86
+ return [];
87
+ }
88
+ }
89
+
90
+ export function isDirectory(root, relative) {
91
+ const file = path.join(root, relative);
92
+ try {
93
+ return statSync(file).isDirectory();
94
+ } catch {
95
+ if (!isAbsent(file)) unreadable.add(relative);
96
+ return false;
97
+ }
98
+ }
99
+
100
+ const GENERATED_DIRS = new Set([
101
+ "Pods",
102
+ "build",
103
+ "DerivedData",
104
+ "node_modules",
105
+ ".git",
106
+ ]);
107
+
108
+ /** A directory holding generated or vendored files rather than the app's own. */
109
+ export function isGeneratedDir(entry) {
110
+ return GENERATED_DIRS.has(entry) || entry.endsWith(".xcodeproj");
111
+ }
112
+
113
+ /** Every file under `relative` matching `pattern`, generated artifacts aside. */
114
+ export function walkFiles(root, relative, pattern) {
115
+ const found = [];
116
+ const pending = [relative];
117
+ const visited = new Set();
118
+ while (pending.length) {
119
+ const directory = pending.pop();
120
+ const entries = listDir(root, directory);
121
+ if (entries.length === 0) continue;
122
+ let canonical;
123
+ try {
124
+ canonical = realpathSync(path.join(root, directory));
125
+ } catch {
126
+ unreadable.add(directory);
127
+ continue;
128
+ }
129
+ if (visited.has(canonical)) continue;
130
+ visited.add(canonical);
131
+ for (const entry of entries) {
132
+ if (isGeneratedDir(entry)) continue;
133
+ const child = path.join(directory, entry);
134
+ if (isDirectory(root, child)) pending.push(child);
135
+ else if (pattern.test(entry)) found.push(child);
136
+ }
137
+ }
138
+ return found;
139
+ }
140
+
141
+ /**
142
+ * Assignments the way dotenv reads them. This is dotenv 16's own line pattern:
143
+ * `:` separates as well as `=`, names may hold `.` and `-`, and a value may be
144
+ * quoted with `'`, `"` or a backtick and span lines.
145
+ */
146
+ const DOTENV_LINE =
147
+ /(?:^|^)[^\S\r\n]*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/gm;
148
+
149
+ export function parseEnv(text) {
150
+ const source = text.replace(/^\uFEFF/, "").replace(/\r\n?/gm, "\n");
151
+ const entries = [];
152
+ for (const match of source.matchAll(DOTENV_LINE)) {
153
+ let value = (match[2] ?? "").trim();
154
+ const quote = value[0];
155
+ if (quote === '"' || quote === "'" || quote === "`") {
156
+ value = value.slice(1, -1);
157
+ if (quote === '"')
158
+ value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r");
159
+ }
160
+ entries.push({
161
+ name: match[1],
162
+ value,
163
+ line: source.slice(0, match.index).split("\n").length,
164
+ });
165
+ }
166
+ return entries;
167
+ }
168
+
169
+ /** The assignment dotenv applies for a name: the last one wins. */
170
+ export function envValue(entries, name) {
171
+ return entries.filter((one) => one.name === name).pop();
172
+ }
173
+
174
+ /**
175
+ * `java.util.Properties`: leading whitespace, `#` and `!` comments, `=`, `:`
176
+ * or plain whitespace as the separator, and a trailing `\\` continuing the
177
+ * value onto the next line.
178
+ */
179
+ export function parseProperties(text) {
180
+ const found = new Map();
181
+ const lines = text.split(/\r?\n/);
182
+ for (let index = 0; index < lines.length; index += 1) {
183
+ const number = index + 1;
184
+ let line = lines[index].trim();
185
+ if (!line || line.startsWith("#") || line.startsWith("!")) continue;
186
+ while (
187
+ (line.match(/\\+$/)?.[0].length ?? 0) % 2 === 1 &&
188
+ index + 1 < lines.length
189
+ ) {
190
+ index += 1;
191
+ line = line.slice(0, -1) + lines[index].trim();
192
+ }
193
+ const match = line.match(/^([^\s=:]+)(?:[ \t]*[=:][ \t]*|[ \t]+)(.*)$/);
194
+ if (match) found.set(match[1], { value: match[2].trim(), line: number });
195
+ }
196
+ return found;
197
+ }
198
+
199
+ /**
200
+ * A line that opens a comment in any of the files this reads. Deciding this
201
+ * per line keeps a mistake to one row: classifying a whole file needs a real
202
+ * lexer for JavaScript, Groovy and Swift, and every version of that erased the
203
+ * remainder of a file when it met a construct it did not know.
204
+ */
205
+ export const COMMENT_LINE = /^[ \t]*(?:\/\/|#|\*|<!--|--)/;
206
+
207
+ /**
208
+ * Whether `index` on `line` sits inside a quoted run. Counting each mark
209
+ * separately called an apostrophe inside a double-quoted string the start of a
210
+ * string, so one left-to-right pass tracks which mark actually opened.
211
+ */
212
+ export function quoted(line, index) {
213
+ let open = null;
214
+ for (let at = 0; at < index; at += 1) {
215
+ const char = line[at];
216
+ if (char === "\\") {
217
+ at += 1;
218
+ continue;
219
+ }
220
+ if (open === "`" && char === "$" && line[at + 1] === "{") {
221
+ // Interpolation is code, however deeply the braces nest.
222
+ let depth = 1;
223
+ at += 2;
224
+ while (at < index && depth > 0) {
225
+ if (line[at] === "{") depth += 1;
226
+ else if (line[at] === "}") depth -= 1;
227
+ at += 1;
228
+ }
229
+ // Still open means `index` is inside the interpolation, which is code.
230
+ if (depth > 0) return false;
231
+ at -= 1;
232
+ continue;
233
+ }
234
+ if (open) {
235
+ if (char === open) open = null;
236
+ continue;
237
+ }
238
+ if (char === "'" || char === '"' || char === "`") open = char;
239
+ }
240
+ return open !== null;
241
+ }
242
+
243
+ /**
244
+ * The code on each line of `text`, with its 1-based number. A whole-line
245
+ * comment is dropped and a trailing one is cut off; `//` after a colon is a
246
+ * URL, not a comment.
247
+ */
248
+ export function codeLines(text) {
249
+ const found = [];
250
+ let inBlock = false;
251
+ text.split(/\r?\n/).forEach((raw, index) => {
252
+ // Only a `/*` that opens its line starts a block: mid-line the same two
253
+ // characters are a glob, a regex or a path, and treating them as a comment
254
+ // erased the rest of the file.
255
+ const opens = !inBlock && /^[ \t]*\/\*/.test(raw);
256
+ if (opens) inBlock = true;
257
+ const closed = inBlock && raw.includes("*/");
258
+ if (inBlock) {
259
+ inBlock = !closed;
260
+ return;
261
+ }
262
+ const line = raw.split(/(?<!:)\/\//)[0];
263
+ if (line.trim() !== "" && !COMMENT_LINE.test(line)) {
264
+ found.push({ line, number: index + 1 });
265
+ }
266
+ });
267
+ return found;
268
+ }
269
+
270
+ function packageJson(root) {
271
+ const raw = read(root, "package.json");
272
+ if (raw === null) return { state: "absent" };
273
+ try {
274
+ const parsed = JSON.parse(raw.replace(/^\uFEFF/, ""));
275
+ return parsed && typeof parsed === "object"
276
+ ? { state: "read", value: parsed }
277
+ : { state: "malformed" };
278
+ } catch {
279
+ return { state: "malformed" };
280
+ }
281
+ }
282
+
283
+ /** Everything package.json declares, dependencies and devDependencies alike. */
284
+ export function dependencies(root) {
285
+ const pkg = packageJson(root);
286
+ if (pkg.state !== "read") return {};
287
+ const { dependencies: deps, devDependencies: dev } = pkg.value;
288
+ return {
289
+ ...(deps && typeof deps === "object" ? deps : {}),
290
+ ...(dev && typeof dev === "object" ? dev : {}),
291
+ };
292
+ }
293
+
294
+ /** Whether package.json exists but could not be parsed. */
295
+ export function manifestIsMalformed(root) {
296
+ return packageJson(root).state === "malformed";
297
+ }
298
+
299
+ /**
300
+ * Which framework builds the app -- not which OpenIAP package is declared. A
301
+ * monorepo or a lockfile alias can supply the library without naming it in
302
+ * package.json, and every framework-specific check here is about the build
303
+ * anyway: Expo is what inlines `EXPO_PUBLIC_` names, so it wins over bare
304
+ * React Native even when the project installs `react-native-iap`.
305
+ */
306
+ export function detectFramework(root) {
307
+ const deps = dependencies(root);
308
+ if (deps.expo || deps["expo-iap"]) return "expo";
309
+ if (deps["react-native"] || deps["react-native-iap"]) return "react-native";
310
+ const pubspec = read(root, "pubspec.yaml");
311
+ if (pubspec && /^\s*(flutter_inapp_purchase|flutter)\s*:/m.test(pubspec)) {
312
+ return "flutter";
313
+ }
314
+ if (declaresKmpIap(root)) return "kmp";
315
+ return "unknown";
316
+ }
317
+
318
+ /**
319
+ * A KMP app names the dependency in the version catalog or straight in the
320
+ * module that applies it, and that module is a directory the app names
321
+ * (`composeApp/`, `shared/`), so look one level down rather than guessing.
322
+ */
323
+ function declaresKmpIap(root) {
324
+ if (read(root, "gradle/libs.versions.toml")?.includes("kmp-iap")) return true;
325
+ for (const entry of listDir(root, ".")) {
326
+ if (entry.startsWith(".") || isGeneratedDir(entry)) continue;
327
+ if (!isDirectory(root, entry)) continue;
328
+ if (read(root, path.join(entry, "build.gradle.kts"))?.includes("kmp-iap")) {
329
+ return true;
330
+ }
331
+ }
332
+ return false;
333
+ }
334
+
335
+ /** An env file a build reads. Example files are templates, not configuration. */
336
+ const ENV_FILE = /^\.env(\..+)?$/;
337
+ const ENV_TEMPLATE = /\.(example|sample|template)$/;
338
+ /** What Flutter's `dotenv` is pointed at: `env`, `env.prod`, `.env.ci`. */
339
+ const FLUTTER_ENV = /(^|\.)env(\..+)?$/;
340
+
341
+ /** App configuration files inspected without evaluating executable exports. */
342
+ export const BUNDLED_FILES = [
343
+ "app.config.ts",
344
+ "app.config.mts",
345
+ "app.config.cts",
346
+ "app.config.js",
347
+ "app.config.mjs",
348
+ "app.config.cjs",
349
+ "app.config.json",
350
+ "app.json",
351
+ ];
352
+
353
+ /** The asset paths a Flutter project declares, which ship verbatim. */
354
+ export function pubspecAssets(root) {
355
+ const pubspec = read(root, "pubspec.yaml");
356
+ if (!pubspec) return [];
357
+ let assets;
358
+ try {
359
+ const document = parseDocument(pubspec, { prettyErrors: false });
360
+ if (document.errors.length || document.warnings.length) {
361
+ unreadable.add("pubspec.yaml");
362
+ return [];
363
+ }
364
+ assets = document.toJS({ maxAliasCount: 100 })?.flutter?.assets ?? [];
365
+ if (!Array.isArray(assets)) throw new Error("Invalid assets");
366
+ } catch {
367
+ unreadable.add("pubspec.yaml");
368
+ return [];
369
+ }
370
+ return assets
371
+ .map((asset) => (typeof asset === "string" ? asset : asset?.path))
372
+ .filter((asset) => typeof asset === "string")
373
+ .flatMap((asset) =>
374
+ asset.endsWith("/")
375
+ ? listDir(root, asset)
376
+ .map((entry) => path.join(asset, entry))
377
+ .filter((file) => !isDirectory(root, file))
378
+ : [asset],
379
+ )
380
+ .map((asset) => path.normalize(asset));
381
+ }
382
+
383
+ /**
384
+ * Client-visible files: anything shipped in the app bundle or read by the
385
+ * bundler. A Flutter project names its env file in pubspec, and this
386
+ * repository's own example calls it `env.example`, so the name shape alone
387
+ * cannot decide which files exist.
388
+ */
389
+ export function clientFiles(root) {
390
+ const names = listDir(root, ".");
391
+ const envFiles = names
392
+ .filter((one) => ENV_FILE.test(one) && !ENV_TEMPLATE.test(one))
393
+ .sort();
394
+ const declared = pubspecAssets(root).filter(
395
+ (one) =>
396
+ FLUTTER_ENV.test(path.basename(one)) &&
397
+ readState(root, one).state !== "absent",
398
+ );
399
+ // A listed name that will not resolve is unreadable; one pubspec declares
400
+ // but never shipped is a Flutter build problem, not this tool's.
401
+ for (const file of [...envFiles, ...declared]) {
402
+ if (readState(root, file).state === "absent") unreadable.add(file);
403
+ }
404
+ return [
405
+ ...new Set([
406
+ ...envFiles,
407
+ ...declared,
408
+ ...(read(root, "eas.json") === null ? [] : ["eas.json"]),
409
+ ...BUNDLED_FILES.filter((one) => read(root, one) !== null),
410
+ ]),
411
+ ];
412
+ }
413
+
414
+ export function isEnvFile(file) {
415
+ return FLUTTER_ENV.test(path.basename(file)) && !BUNDLED_FILES.includes(file);
416
+ }