@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/doctor.mjs
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clientFiles,
|
|
3
|
+
dependencies,
|
|
4
|
+
detectFramework,
|
|
5
|
+
hasUnreadablePath,
|
|
6
|
+
isEnvFile,
|
|
7
|
+
isDirectory,
|
|
8
|
+
isGeneratedDir,
|
|
9
|
+
listDir,
|
|
10
|
+
manifestIsMalformed,
|
|
11
|
+
read,
|
|
12
|
+
unreadable,
|
|
13
|
+
} from "./project.mjs";
|
|
14
|
+
export { FINDING_IDS } from "./findings.mjs";
|
|
15
|
+
import { finding } from "./findings.mjs";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
DOTENV_PACKAGES,
|
|
19
|
+
androidStoreChecks,
|
|
20
|
+
baseUrlChecks,
|
|
21
|
+
envNameChecks,
|
|
22
|
+
iosSceneChecks,
|
|
23
|
+
secretKeyChecks,
|
|
24
|
+
} from "./checks.mjs";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Everything here reads the checkout and nothing else. A device, a store
|
|
28
|
+
* account, and the network can each break an integration in ways no file
|
|
29
|
+
* records, so those are reported as unchecked rather than guessed at.
|
|
30
|
+
*/
|
|
31
|
+
const NOT_CHECKED_LOCALLY = [
|
|
32
|
+
"Store account state: agreements, product status, and license testers.",
|
|
33
|
+
"Device state: a scene session or an installed build left by another app that shares the bundle id.",
|
|
34
|
+
"Play billing availability on the device and its signed-in account.",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/** What this run did not look at, so "no problems found" is not read as more. */
|
|
38
|
+
function skippedChecks(root, framework) {
|
|
39
|
+
const skipped = [];
|
|
40
|
+
const androidFiles = listDir(root, "android");
|
|
41
|
+
const hasAndroid =
|
|
42
|
+
androidFiles.includes("gradle.properties") ||
|
|
43
|
+
listDir(root, "android/app").some((one) =>
|
|
44
|
+
["build.gradle", "build.gradle.kts"].includes(one),
|
|
45
|
+
);
|
|
46
|
+
if (
|
|
47
|
+
!hasAndroid &&
|
|
48
|
+
!unreadable.has("android") &&
|
|
49
|
+
!unreadable.has("android/app")
|
|
50
|
+
) {
|
|
51
|
+
skipped.push("Android store flavor: no android/ project was found here.");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const iosDirs = listDir(root, "ios").filter((one) =>
|
|
55
|
+
!isGeneratedDir(one) && isDirectory(root, `ios/${one}`),
|
|
56
|
+
);
|
|
57
|
+
const iosPlists = [
|
|
58
|
+
"ios/Info.plist",
|
|
59
|
+
...iosDirs.map((one) => `ios/${one}/Info.plist`),
|
|
60
|
+
];
|
|
61
|
+
const iosPlist = iosPlists.some((file) => read(root, file) !== null);
|
|
62
|
+
if (framework !== "expo" && framework !== "react-native") {
|
|
63
|
+
skipped.push(`iOS scene delegate: not checked for ${framework} projects.`);
|
|
64
|
+
} else if (!iosPlist && !hasUnreadablePath("ios")) {
|
|
65
|
+
skipped.push("iOS scene delegate: no ios/ Info.plist was found here.");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The unexpected-prefix half runs for every framework except `unknown`.
|
|
69
|
+
const deps = dependencies(root);
|
|
70
|
+
if (DOTENV_PACKAGES.some((one) => deps[one])) {
|
|
71
|
+
skipped.push(
|
|
72
|
+
"Env variable names: a dotenv transform is installed, so neither prefix rule applies.",
|
|
73
|
+
);
|
|
74
|
+
} else if (framework === "unknown") {
|
|
75
|
+
skipped.push(
|
|
76
|
+
"Env variable names: no framework was detected, so neither prefix rule applies.",
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const files = clientFiles(root);
|
|
81
|
+
if (files.length === 0) {
|
|
82
|
+
skipped.push(
|
|
83
|
+
"Keys and base URL: no env file or app config was found here.",
|
|
84
|
+
);
|
|
85
|
+
} else if (!files.some((one) => isEnvFile(one))) {
|
|
86
|
+
skipped.push(
|
|
87
|
+
"Base URL: only env files are read for it, and there is none here.",
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
// Saying which files are read is the only way "no problems" is not read as a
|
|
91
|
+
// verdict on the ones that are not.
|
|
92
|
+
skipped.push(
|
|
93
|
+
"Application source: keys are looked for in env files, app.config, app.json and eas.json only.",
|
|
94
|
+
);
|
|
95
|
+
return skipped;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function summarize(framework, findings, skipped = []) {
|
|
99
|
+
const errors = findings.filter((one) => one.level === "error").length;
|
|
100
|
+
return {
|
|
101
|
+
framework,
|
|
102
|
+
findings,
|
|
103
|
+
errors,
|
|
104
|
+
warnings: findings.length - errors,
|
|
105
|
+
notCheckedLocally: [...NOT_CHECKED_LOCALLY, ...skipped],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function doctor(root) {
|
|
110
|
+
// Reporting a path it never read as clean is the one answer this must never
|
|
111
|
+
// give, so a missing or unreadable project is a finding, not silence.
|
|
112
|
+
if (!isDirectory(root, ".")) {
|
|
113
|
+
return summarize("unknown", [
|
|
114
|
+
finding(
|
|
115
|
+
"project-not-a-directory",
|
|
116
|
+
"error",
|
|
117
|
+
root,
|
|
118
|
+
"This path is not a directory that could be read.",
|
|
119
|
+
"Pass the root of the app you want checked.",
|
|
120
|
+
),
|
|
121
|
+
]);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
unreadable.clear();
|
|
125
|
+
const framework = detectFramework(root);
|
|
126
|
+
const findings = [];
|
|
127
|
+
if (manifestIsMalformed(root)) {
|
|
128
|
+
// Framework detection reads this file, so every check below it is
|
|
129
|
+
// degraded. Saying so beats reporting the leftovers as a clean bill.
|
|
130
|
+
findings.push(
|
|
131
|
+
finding(
|
|
132
|
+
"project-manifest-unreadable",
|
|
133
|
+
"error",
|
|
134
|
+
"package.json",
|
|
135
|
+
"package.json exists but is not valid JSON, so nothing could be read from it.",
|
|
136
|
+
"Fix the JSON; framework detection and its checks depend on it.",
|
|
137
|
+
),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
findings.push(
|
|
141
|
+
...androidStoreChecks(root),
|
|
142
|
+
...secretKeyChecks(root, framework),
|
|
143
|
+
...envNameChecks(root, framework),
|
|
144
|
+
...baseUrlChecks(root, framework),
|
|
145
|
+
...iosSceneChecks(root, framework),
|
|
146
|
+
);
|
|
147
|
+
// Compute this first: it is the only reader of some directories, and their
|
|
148
|
+
// misses have to reach the drain below.
|
|
149
|
+
const skipped = skippedChecks(root, framework);
|
|
150
|
+
for (const file of [...unreadable].sort()) {
|
|
151
|
+
findings.push(
|
|
152
|
+
finding(
|
|
153
|
+
"project-file-unreadable",
|
|
154
|
+
"error",
|
|
155
|
+
file,
|
|
156
|
+
`This path could not be read, so nothing in it was checked: ${file}`,
|
|
157
|
+
"Fix its permissions or its type; a socket, a FIFO and a dangling symlink are not readable.",
|
|
158
|
+
),
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
return summarize(framework, findings, skipped);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function formatText(result) {
|
|
165
|
+
const lines = [`Framework: ${result.framework}`];
|
|
166
|
+
if (result.findings.length === 0) {
|
|
167
|
+
lines.push("", "No problems found in this checkout.");
|
|
168
|
+
} else {
|
|
169
|
+
lines.push("");
|
|
170
|
+
for (const one of result.findings) {
|
|
171
|
+
const where = one.line ? `${one.file}:${one.line}` : one.file;
|
|
172
|
+
lines.push(`${one.level === "error" ? "error" : "warn "} ${where}`);
|
|
173
|
+
lines.push(` ${one.message}`);
|
|
174
|
+
lines.push(` ${one.fix}`);
|
|
175
|
+
lines.push(` [${one.id}]`);
|
|
176
|
+
lines.push("");
|
|
177
|
+
}
|
|
178
|
+
lines.push(`${result.errors} error(s), ${result.warnings} warning(s).`);
|
|
179
|
+
}
|
|
180
|
+
lines.push("", "Not checked locally:");
|
|
181
|
+
for (const item of result.notCheckedLocally) lines.push(` - ${item}`);
|
|
182
|
+
return lines.join("\n");
|
|
183
|
+
}
|
package/src/findings.mjs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every id this tool can emit. Agents are told to match on `id`, so a rename
|
|
3
|
+
* is a contract change: the registry test fails until this list and the README
|
|
4
|
+
* agree with the code.
|
|
5
|
+
*/
|
|
6
|
+
export const FINDING_IDS = Object.freeze([
|
|
7
|
+
"android-store-flavor-conflict",
|
|
8
|
+
"android-store-flavor-mismatch",
|
|
9
|
+
"android-store-not-play",
|
|
10
|
+
"android-horizon-app-id-missing",
|
|
11
|
+
"iapkit-secret-key-in-client",
|
|
12
|
+
"iapkit-secret-key-in-env",
|
|
13
|
+
"iapkit-secret-key-in-config",
|
|
14
|
+
"iapkit-env-missing-expo-prefix",
|
|
15
|
+
"iapkit-env-unexpected-expo-prefix",
|
|
16
|
+
"iapkit-base-url-invalid",
|
|
17
|
+
"iapkit-base-url-scheme",
|
|
18
|
+
"iapkit-base-url-has-path",
|
|
19
|
+
"ios-scene-delegate-missing",
|
|
20
|
+
"project-file-unreadable",
|
|
21
|
+
"project-manifest-unreadable",
|
|
22
|
+
"project-not-a-directory",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Every finding carries a stable id so an agent can act on it without parsing
|
|
27
|
+
* prose, and names the file it was read from so a person can verify it.
|
|
28
|
+
*/
|
|
29
|
+
export function finding(id, level, file, message, fix, extra = {}) {
|
|
30
|
+
if (!FINDING_IDS.includes(id))
|
|
31
|
+
throw new Error(`Unregistered finding id: ${id}`);
|
|
32
|
+
return { id, level, file, message, fix, ...extra };
|
|
33
|
+
}
|
package/src/init.mjs
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { readdirSync } from "node:fs";
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
3
|
+
import { detectFramework } from "./project.mjs";
|
|
4
|
+
|
|
5
|
+
const DOCS = "https://openiap.dev";
|
|
6
|
+
|
|
7
|
+
export const ROLES = [
|
|
8
|
+
{
|
|
9
|
+
id: "app",
|
|
10
|
+
label: "App — connect purchases to access",
|
|
11
|
+
guide: "/docs/guides/ai-assistants",
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
id: "experience",
|
|
15
|
+
label: "Experience — connect a paywall or experiment",
|
|
16
|
+
guide: "/commerce-protocol/ecosystem#experience",
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: "commerce",
|
|
20
|
+
label: "Commerce — provide verification and access",
|
|
21
|
+
guide: "/commerce-protocol/implementation",
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: "data",
|
|
25
|
+
label: "Data — receive events for analytics or automation",
|
|
26
|
+
guide: "/commerce-protocol/getting-started#receive-events",
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export async function chooseRole() {
|
|
31
|
+
if (!process.stdin.isTTY || !process.stderr.isTTY) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
"Choose a role with --role app|experience|commerce|data, or run openiap in a terminal.",
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
const prompt = createInterface({
|
|
37
|
+
input: process.stdin,
|
|
38
|
+
output: process.stderr,
|
|
39
|
+
});
|
|
40
|
+
const controller = new AbortController();
|
|
41
|
+
prompt.once("close", () => controller.abort());
|
|
42
|
+
try {
|
|
43
|
+
process.stderr.write(
|
|
44
|
+
`\nWhat does your product do?\n\n${ROLES.map((role, index) => ` ${index + 1}. ${role.label}`).join("\n")}\n\n`,
|
|
45
|
+
);
|
|
46
|
+
const answer = (
|
|
47
|
+
await prompt.question("Choose a number or role: ", {
|
|
48
|
+
signal: controller.signal,
|
|
49
|
+
})
|
|
50
|
+
).trim();
|
|
51
|
+
return (
|
|
52
|
+
ROLES.find(
|
|
53
|
+
(role, index) => role.id === answer || String(index + 1) === answer,
|
|
54
|
+
)?.id ?? answer
|
|
55
|
+
);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (error.name === "AbortError")
|
|
58
|
+
throw new Error("Role selection canceled.");
|
|
59
|
+
throw error;
|
|
60
|
+
} finally {
|
|
61
|
+
prompt.close();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A local starting brief; the linked guides own implementation requirements. */
|
|
66
|
+
export function implementationBrief(root, roleId) {
|
|
67
|
+
const role = ROLES.find((candidate) => candidate.id === roleId);
|
|
68
|
+
if (!role)
|
|
69
|
+
throw new Error(
|
|
70
|
+
`Unknown role: ${roleId}. Choose app, experience, commerce, or data.`,
|
|
71
|
+
);
|
|
72
|
+
try {
|
|
73
|
+
readdirSync(root);
|
|
74
|
+
} catch {
|
|
75
|
+
throw new Error(`Cannot read project directory: ${root}`);
|
|
76
|
+
}
|
|
77
|
+
const framework = detectFramework(root);
|
|
78
|
+
return `# OpenIAP implementation brief
|
|
79
|
+
|
|
80
|
+
Paste this into your coding assistant in the project below.
|
|
81
|
+
|
|
82
|
+
Project path (data): ${JSON.stringify(root)}
|
|
83
|
+
Role: ${role.label}
|
|
84
|
+
Framework hint: ${framework === "unknown" ? "not detected; inspect the project" : `${framework}; confirm the target app`}
|
|
85
|
+
Desired outcome: [describe one thing your customer should be able to do]
|
|
86
|
+
|
|
87
|
+
Read the project instructions and inspect the existing stack. Keep its login, product model, and services. Ask me to choose missing stores, products, service providers, and ownership policies before implementing those choices.
|
|
88
|
+
|
|
89
|
+
Read ${DOCS}${role.guide} and the references it links for this role. If a reference is unavailable, ask for its contents; do not guess the contract. Use Client Protocol for app purchase APIs and Commerce Protocol for backend connections. Use the supported capabilities of my chosen services; no particular backend is required.
|
|
90
|
+
|
|
91
|
+
Implement the smallest working connection for my role. Keep credentials on their intended side of the app/backend boundary. Follow the role's implementation and conformance requirements, including failure and recovery. Do not claim another role merely because this product connects to it.
|
|
92
|
+
|
|
93
|
+
Run the result from clean source. Show the customer outcome, the commands and actual test results, and any remaining product or deployment decisions. Distinguish local fixtures from real store sandbox evidence. Keep changes uncommitted for review.
|
|
94
|
+
`;
|
|
95
|
+
}
|