@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.
- package/README.md +152 -4
- package/bin/openiap.mjs +110 -0
- package/package.json +26 -5
- package/src/checks.mjs +610 -0
- package/src/doctor.mjs +183 -0
- package/src/findings.mjs +33 -0
- package/src/init.mjs +95 -0
- package/src/project.mjs +416 -0
package/src/project.mjs
ADDED
|
@@ -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
|
+
}
|