Skip to content
Source
Sign inSign up

easProjectId.ts

237 lines10.0 KB

1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Option from "effect/Option";
4import * as Path from "effect/Path";
5import * as Schema from "effect/Schema";
6​
7const CONFIG_FILES = [
8 "app.config.ts",
9 "app.config.mts",
10 "app.config.js",
11 "app.config.mjs",
12 "app.config.cjs",
13];
14const JSON_CONFIG_FILES = ["app.config.json", "app.json"];
15const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
16// Only accept a quoted UUID as the whole value, followed by a separator or EOF.
17// Consume comments whole so separators inside them cannot end the entry;
18// an operator can follow a line break or comment.
19const QUOTED_PROJECT_ID =
20 /\bprojectId["']?\s*:\s*["'`]([^"'`\n]*)["'`](?=(?:\s|\/\/[^\n\r\u2028\u2029]*(?:[\n\r\u2028\u2029]|$)|\/\*(?:[^*]|\*(?!\/))*\*\/)*(?:[,;})\]]|$))/gi;
21const PROJECT_ID_MENTION = /\bprojectId\b/gi;
22// Reading a config is linear in its size per entry; one this large is not an
23// app config but a file made to keep the server busy.
24const MAX_CONFIG_LENGTH = 256 * 1024;
25// Each entry is read against the rest of the file, so a file with many is
26// quadratic; an app config mentions its project a few times at most.
27const MAX_MENTIONS = 32;
28​
29// A later spread at any depth can replace an entry through an enclosing object.
30// Assignments can replace the config from anywhere; Object.assign names its call
31// before either argument.
32const SPREAD_LATER = /\.\.\./;
33// Space, a comment, or an opening parenthesis: what may sit between `...`
34// and the thing spread. Sticky, so the scan moves through the file once.
35const SPREAD_FILLER = /(?:\s+|\/\/[^\n\r\u2028\u2029]*|\/\*(?:[^*]|\*(?!\/))*\*\/|\()/y;
36// `config`'s `extra` and `extra.eas` entries, the holders of the EAS id
37// (dotted, optional-chained or in brackets).
38const EXTRA_OF_CONFIG = String.raw`config(?:\s*\??\.\s*extra|\s*(?:\?\.)?\s*\[\s*["'\x60]extra["'\x60]\s*\])`;
39const EAS_OF_CONFIG =
40 EXTRA_OF_CONFIG + String.raw`(?:\s*\??\.\s*eas|\s*(?:\?\.)?\s*\[\s*["'\x60]eas["'\x60]\s*\])`;
41// The holder, and nothing more of it: no further property, call or operator
42// (`?? {}` keeps it, so it may follow).
43const HOLDER_END = String.raw`(?![\p{ID_Continue}$\u200c\u200d\\]|\s*(?:[.\[(]|\?(?!\?)))`;
44const HOLDER_AT = {
45 extra: new RegExp(EXTRA_OF_CONFIG + HOLDER_END, "uy"),
46 eas: new RegExp(EAS_OF_CONFIG + HOLDER_END, "uy"),
47};
48// `config` and no more of it: not an identifier continuing (`$`, Unicode
49// letters and a `\u` escape do, where `\b` would not), nor a property.
50const CONFIG_AT = new RegExp("config" + HOLDER_END, "uy");
51​
52/**
53 * Only Expo's `config` and its own `extra`/`extra.eas` spreads can preserve app.json's id.
54 * Other spreads may replace it; no spreads leaves inheritance unclear.
55 * Skip comments whole after spreads to keep scanning linear.
56 */
57const staticConfigUse = (source: string): "spreads" | "replaces" | "unclear" => {
58 let spreads = 0;
59 let at = 0;
60 for (;;) {
61 const next = source.indexOf("...", at);
62 if (next === -1) return spreads > 0 ? "spreads" : "unclear";
63 at = next + 3;
64 for (;;) {
65 SPREAD_FILLER.lastIndex = at;
66 const filler = SPREAD_FILLER.exec(source);
67 if (!filler) break;
68 at += filler[0].length;
69 }
70 CONFIG_AT.lastIndex = at;
71 if (CONFIG_AT.test(source)) {
72 spreads++;
73 continue;
74 }
75 HOLDER_AT.extra.lastIndex = at;
76 HOLDER_AT.eas.lastIndex = at;
77 if (!HOLDER_AT.extra.test(source) && !HOLDER_AT.eas.test(source)) return "replaces";
78 }
79};
80const ASSIGNS_OVER = /\bObject\s*\.\s*assign\b|\bassign\s*\(/;
81// A config may replace the object holding the entry, or the whole config,
82// from somewhere this scan cannot read: an assignment to `.extra` or `.eas`,
83// (as a property or in brackets), a `delete` of anything, or another file it
84// requires or imports.
85const REPLACES_HOLDER =
86 /(?:\.\s*(?:extra|eas)|\[\s*["'`](?:extra|eas)["'`]\s*\])\s*=(?!=)|\bdelete\b|\brequire\s*\(|\bimport\s*\(/;
87// An `extra`/`eas` object must start by spreading the matching `config` entry to keep app.json's id.
88// Spreading the whole config there puts the id elsewhere; shorthand entries replace it too.
89// Stop reading once entries exceed MAX_MENTIONS.
90const EXTRA_OR_EAS = /(?:\b(extra|eas)|["'`](extra|eas)["'`]\s*\]?)\s*:\s*/g;
91const SHORTHAND_EXTRA_OR_EAS = /(?<![.\p{ID_Continue}$])(?:extra|eas)\s*(?=[,}])/u;
92const keepsStaticExtra = (source: string): boolean => {
93 if (SHORTHAND_EXTRA_OR_EAS.test(source)) return false;
94 let entries = 0;
95 for (const entry of source.matchAll(EXTRA_OR_EAS)) {
96 if (++entries > MAX_MENTIONS) return false;
97 const holder = HOLDER_AT[(entry[1] ?? entry[2]) as "extra" | "eas"];
98 let at = entry.index + entry[0].length;
99 if (source[at] !== "{") return false;
100 at++;
101 for (;;) {
102 SPREAD_FILLER.lastIndex = at;
103 const filler = SPREAD_FILLER.exec(source);
104 if (!filler) break;
105 at += filler[0].length;
106 }
107 if (!source.startsWith("...", at)) return false;
108 at += 3;
109 for (;;) {
110 SPREAD_FILLER.lastIndex = at;
111 const filler = SPREAD_FILLER.exec(source);
112 if (!filler) break;
113 at += filler[0].length;
114 }
115 holder.lastIndex = at;
116 if (!holder.test(source)) return false;
117 }
118 return true;
119};
120​
121const Extra = Schema.optional(
122 Schema.Struct({
123 eas: Schema.optional(Schema.Struct({ projectId: Schema.optional(Schema.String) })),
124 }),
125);
126// Expo reads a static config wrapped in `expo` or not, and as JSON5; one
127// this cannot decode (comments, say) still names a project, unknown here.
128const decodeAppJson = Schema.decodeUnknownOption(
129 Schema.fromJsonString(
130 Schema.Struct({ expo: Schema.optional(Schema.Struct({ extra: Extra })), extra: Extra }),
131 ),
132);
133​
134/**
135 * Never execute dynamic configs. Keep ids in comments so stale ids make the answer ambiguous.
136 * Include JSON configs because dynamic configs may replace them only for some builds.
137 * Return null for unreadable configs or ids set or changed without a literal.
138 */
139const projectIdsOf = Effect.fn("easProjectId.projectIdsOf")(function* (dir: string) {
140 const fs = yield* FileSystem.FileSystem;
141 const path = yield* Path.Path;
142 const ids: Array<string> = [];
143 const configFile = yield* Effect.findFirst(CONFIG_FILES, (file) =>
144 fs.exists(path.join(dir, file)).pipe(Effect.orElseSucceed(() => false)),
145 );
146 if (Option.isSome(configFile)) {
147 const source = yield* fs
148 .readFileString(path.join(dir, configFile.value))
149 .pipe(Effect.orElseSucceed(() => "projectId"));
150 // Every mention of `projectId` must be a quoted entry: one set from a
151 // variable, or assigned later, could be any project, and makes the
152 // answer unknown.
153 if (source.length > MAX_CONFIG_LENGTH) return null;
154 const mentions = source.match(PROJECT_ID_MENTION)?.length ?? 0;
155 if (mentions > MAX_MENTIONS) return null;
156 const entries = [...source.matchAll(QUOTED_PROJECT_ID)];
157 const quoted = entries.map((match) => match[1]!);
158 // A template that interpolates could be any project too, and so could
159 // one a spread after the entry, or an assignment anywhere, replaces;
160 if (
161 quoted.length !== mentions ||
162 quoted.some((id) => id.includes("${")) ||
163 ASSIGNS_OVER.test(source) ||
164 REPLACES_HOLDER.test(source) ||
165 entries.some((match) => SPREAD_LATER.test(source.slice(match.index + match[0].length)))
166 ) {
167 return null;
168 }
169 // Only a config shown to spread `config` and nothing else carries app.json's
170 // entry through unchanged; any other shape could use or replace it.
171 if (
172 !quoted.some((id) => UUID.test(id)) &&
173 (staticConfigUse(source) !== "spreads" || !keepsStaticExtra(source))
174 ) {
175 return null;
176 }
177 ids.push(...quoted.filter((id) => UUID.test(id)).map((id) => id.toLowerCase()));
178 }
179 for (const file of JSON_CONFIG_FILES) {
180 const json = yield* fs.readFileString(path.join(dir, file)).pipe(
181 Effect.asSome,
182 Effect.catch((error) =>
183 Effect.succeed(error.reason._tag === "NotFound" ? Option.none<string>() : null),
184 ),
185 );
186 // A config that is there but cannot be read could name any project.
187 if (json === null) return null;
188 if (Option.isNone(json)) continue;
189 const decoded = decodeAppJson(json.value);
190 if (Option.isNone(decoded)) return null;
191 const id = (decoded.value.expo ?? decoded.value).extra?.eas?.projectId;
192 if (id !== undefined && UUID.test(id)) ids.push(id.toLowerCase());
193 // Expo reads only the first static config it finds.
194 break;
195 }
196 return ids;
197});
198​
199/**
200 * The EAS project of a workspace's Expo app: its root, or one app below
201 * `apps/` or `packages/`. None when there is none, a config is ambiguous, or
202 * the apps disagree, so the caller asks for an explicit choice instead of
203 * billing the wrong project.
204 */
205export const findEasProjectId = Effect.fn("easProjectId.findEasProjectId")(function* (
206 workspaceRoot: string,
207) {
208 const fs = yield* FileSystem.FileSystem;
209 const path = yield* Path.Path;
210 const isDirectory = (child: string) =>
211 fs.stat(child).pipe(
212 Effect.map((info) => info.type === "Directory"),
213 Effect.orElseSucceed(() => false),
214 );
215 // Only a directory can be an app; a README or .DS_Store beside them is not one.
216 const children = (parent: string) =>
217 fs.readDirectory(path.join(workspaceRoot, parent)).pipe(
218 Effect.map((names) => names.map((name) => path.join(workspaceRoot, parent, name))),
219 Effect.flatMap((paths) =>
220 Effect.forEach(paths, (child) =>
221 Effect.map(isDirectory(child), (directory) => (directory ? [child] : [])),
222 ),
223 ),
224 Effect.map((kept) => kept.flat()),
225 Effect.orElseSucceed(() => []),
226 );
227 const directories = [
228 workspaceRoot,
229 ...(yield* children("apps")),
230 ...(yield* children("packages")),
231 ];
232 const ids = yield* Effect.forEach(directories, projectIdsOf, { concurrency: 4 });
233 // An app whose project is unknown could be the one the thread works on.
234 if (ids.some((found) => found === null)) return Option.none<string>();
235 const distinct = new Set(ids.flatMap((found) => found ?? []));
236 return distinct.size === 1 ? Option.some([...distinct][0]!) : Option.none<string>();
237});