@comergehq/cli 0.1.4 → 0.1.6
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/dist/cli.js +1898 -1523
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -5,14 +5,10 @@ import { Command } from "commander";
|
|
|
5
5
|
import pc10 from "picocolors";
|
|
6
6
|
import { createRequire } from "module";
|
|
7
7
|
|
|
8
|
-
// src/commands/
|
|
9
|
-
import path5 from "path";
|
|
10
|
-
import os from "os";
|
|
8
|
+
// src/commands/login.ts
|
|
11
9
|
import pc from "picocolors";
|
|
12
|
-
import prompts from "prompts";
|
|
13
|
-
import fse4 from "fs-extra";
|
|
14
10
|
|
|
15
|
-
// src/
|
|
11
|
+
// src/errors/cliError.ts
|
|
16
12
|
var CliError = class extends Error {
|
|
17
13
|
exitCode;
|
|
18
14
|
hint;
|
|
@@ -24,71 +20,245 @@ var CliError = class extends Error {
|
|
|
24
20
|
}
|
|
25
21
|
};
|
|
26
22
|
|
|
27
|
-
// src/
|
|
28
|
-
import
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
23
|
+
// src/config/config.ts
|
|
24
|
+
import { z } from "zod";
|
|
25
|
+
var API_URL = "https://comerge.ai";
|
|
26
|
+
var SUPABASE_URL = "https://xtfxwbckjpfmqubnsusu.supabase.co";
|
|
27
|
+
var SUPABASE_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inh0Znh3YmNranBmbXF1Ym5zdXN1Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjA2MDEyMzAsImV4cCI6MjA3NjE3NzIzMH0.dzWGAWrK4CvrmHVHzf8w7JlUZohdap0ZPnLZnABMV8s";
|
|
28
|
+
function isValidUrl(value) {
|
|
34
29
|
try {
|
|
35
|
-
|
|
30
|
+
new URL(value);
|
|
31
|
+
return true;
|
|
36
32
|
} catch {
|
|
37
|
-
|
|
33
|
+
return false;
|
|
38
34
|
}
|
|
39
35
|
}
|
|
40
|
-
var
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const main2 = "expo-router/entry";
|
|
61
|
-
dependencies["@comergehq/studio"] = params.studioVersion || "latest";
|
|
62
|
-
dependencies["@comergehq/runtime"] = params.studioVersion || "latest";
|
|
63
|
-
if (!dependencies["expo-router"] && !devDependencies["expo-router"]) {
|
|
64
|
-
dependencies["expo-router"] = "latest";
|
|
65
|
-
warnings.push("Added missing dependency expo-router@latest");
|
|
36
|
+
var urlSchema = z.string().min(1).transform((s) => s.trim()).refine((s) => isValidUrl(s), "Invalid URL.").transform((s) => s.replace(/\/+$/, ""));
|
|
37
|
+
var configSchema = z.object({
|
|
38
|
+
apiUrl: urlSchema,
|
|
39
|
+
supabaseUrl: urlSchema,
|
|
40
|
+
supabaseAnonKey: z.string().min(1)
|
|
41
|
+
});
|
|
42
|
+
var cachedConfig = null;
|
|
43
|
+
async function resolveConfig(_opts) {
|
|
44
|
+
if (cachedConfig) return cachedConfig;
|
|
45
|
+
const cfgRaw = {
|
|
46
|
+
apiUrl: API_URL,
|
|
47
|
+
supabaseUrl: SUPABASE_URL,
|
|
48
|
+
supabaseAnonKey: SUPABASE_ANON_KEY
|
|
49
|
+
};
|
|
50
|
+
const parsedCfg = configSchema.safeParse(cfgRaw);
|
|
51
|
+
if (!parsedCfg.success) {
|
|
52
|
+
throw new CliError("Invalid CLI configuration.", {
|
|
53
|
+
exitCode: 1,
|
|
54
|
+
hint: parsedCfg.error.issues.map((i) => `${i.path.join(".") || "config"}: ${i.message}`).join("; ")
|
|
55
|
+
});
|
|
66
56
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
57
|
+
cachedConfig = parsedCfg.data;
|
|
58
|
+
return parsedCfg.data;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/services/oauthCallbackServer.ts
|
|
62
|
+
import http from "http";
|
|
63
|
+
async function startOAuthCallbackServer(params) {
|
|
64
|
+
const timeoutMs = params?.timeoutMs ?? 3 * 6e4;
|
|
65
|
+
const requestedPort = params?.port ?? null;
|
|
66
|
+
let resolveCode = null;
|
|
67
|
+
let rejectCode = null;
|
|
68
|
+
const codePromise = new Promise((resolve, reject) => {
|
|
69
|
+
resolveCode = resolve;
|
|
70
|
+
rejectCode = reject;
|
|
71
|
+
});
|
|
72
|
+
const server = http.createServer((req, res) => {
|
|
73
|
+
try {
|
|
74
|
+
const base = `http://127.0.0.1`;
|
|
75
|
+
const u = new URL(req.url ?? "/", base);
|
|
76
|
+
const errorDescription = u.searchParams.get("error_description");
|
|
77
|
+
const error = u.searchParams.get("error");
|
|
78
|
+
const code = u.searchParams.get("code");
|
|
79
|
+
if (errorDescription || error) {
|
|
80
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
81
|
+
res.end(
|
|
82
|
+
`<html><body><h3>Sign-in failed</h3><p>${escapeHtml(errorDescription ?? error ?? "Unknown error")}</p></body></html>`
|
|
83
|
+
);
|
|
84
|
+
rejectCode?.(new CliError("Sign-in failed.", { exitCode: 1, hint: errorDescription ?? error ?? void 0 }));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (!code) {
|
|
88
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
89
|
+
res.end(`<html><body><h3>Missing code</h3><p>No authorization code was provided.</p></body></html>`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
93
|
+
res.end(
|
|
94
|
+
`<html><body><h3>Sign-in complete</h3><p>You can close this tab and return to the terminal.</p></body></html>`
|
|
95
|
+
);
|
|
96
|
+
resolveCode?.(code);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
rejectCode?.(err);
|
|
99
|
+
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
|
100
|
+
res.end("Internal error.");
|
|
101
|
+
} finally {
|
|
102
|
+
try {
|
|
103
|
+
server.close();
|
|
104
|
+
} catch {
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
const listenPort = typeof requestedPort === "number" ? requestedPort : 0;
|
|
109
|
+
await new Promise((resolve, reject) => {
|
|
110
|
+
server.once("error", reject);
|
|
111
|
+
server.listen(listenPort, "127.0.0.1", () => resolve());
|
|
112
|
+
});
|
|
113
|
+
const addr = server.address();
|
|
114
|
+
if (!addr || typeof addr === "string") {
|
|
115
|
+
server.close();
|
|
116
|
+
throw new CliError("Failed to start the local callback server.", { exitCode: 1 });
|
|
71
117
|
}
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
118
|
+
const redirectTo = `http://127.0.0.1:${addr.port}/callback`;
|
|
119
|
+
const timeout = setTimeout(() => {
|
|
120
|
+
rejectCode?.(
|
|
121
|
+
new CliError("Sign-in timed out.", {
|
|
122
|
+
exitCode: 1,
|
|
123
|
+
hint: "Try running `comerge login` again."
|
|
124
|
+
})
|
|
125
|
+
);
|
|
126
|
+
try {
|
|
127
|
+
server.close();
|
|
128
|
+
} catch {
|
|
129
|
+
}
|
|
130
|
+
}, timeoutMs).unref();
|
|
131
|
+
const waitForCode = async () => {
|
|
132
|
+
try {
|
|
133
|
+
return await codePromise;
|
|
134
|
+
} finally {
|
|
135
|
+
clearTimeout(timeout);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
const close = async () => {
|
|
139
|
+
clearTimeout(timeout);
|
|
140
|
+
await new Promise((resolve) => server.close(() => resolve())).catch(() => {
|
|
141
|
+
});
|
|
142
|
+
};
|
|
143
|
+
return { redirectTo, waitForCode, close };
|
|
144
|
+
}
|
|
145
|
+
function escapeHtml(input) {
|
|
146
|
+
return input.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/\"/g, """).replace(/'/g, "'");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// src/utils/browser.ts
|
|
150
|
+
import { execa } from "execa";
|
|
151
|
+
async function openBrowser(url) {
|
|
152
|
+
const platform = process.platform;
|
|
153
|
+
try {
|
|
154
|
+
if (platform === "darwin") {
|
|
155
|
+
await execa("open", [url], { stdio: "ignore" });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (platform === "win32") {
|
|
159
|
+
await execa("cmd", ["/c", "start", "", url], { stdio: "ignore", windowsHide: true });
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
await execa("xdg-open", [url], { stdio: "ignore" });
|
|
163
|
+
} catch (err) {
|
|
164
|
+
throw new CliError("Could not open a browser automatically.", {
|
|
165
|
+
exitCode: 1,
|
|
166
|
+
hint: `Open this URL in a browser:
|
|
167
|
+
${url}
|
|
168
|
+
|
|
169
|
+
${err?.message ?? String(err)}`
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// src/clients/supabase.ts
|
|
175
|
+
import { createClient } from "@supabase/supabase-js";
|
|
176
|
+
function createInMemoryStorage() {
|
|
177
|
+
const map = /* @__PURE__ */ new Map();
|
|
178
|
+
return {
|
|
179
|
+
getItem: (k) => map.get(k) ?? null,
|
|
180
|
+
setItem: (k, v) => {
|
|
181
|
+
map.set(k, v);
|
|
83
182
|
},
|
|
84
|
-
|
|
85
|
-
|
|
183
|
+
removeItem: (k) => {
|
|
184
|
+
map.delete(k);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
function createSupabaseClient(config, storage) {
|
|
189
|
+
return createClient(config.supabaseUrl, config.supabaseAnonKey, {
|
|
190
|
+
auth: {
|
|
191
|
+
flowType: "pkce",
|
|
192
|
+
persistSession: false,
|
|
193
|
+
autoRefreshToken: false,
|
|
194
|
+
detectSessionInUrl: false,
|
|
195
|
+
storage
|
|
196
|
+
},
|
|
197
|
+
global: {
|
|
198
|
+
headers: {
|
|
199
|
+
"X-Requested-By": "comerge-cli"
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
function toStoredSession(session) {
|
|
205
|
+
if (!session.access_token || !session.refresh_token || !session.expires_at) {
|
|
206
|
+
throw new CliError("Supabase session is missing required fields.", { exitCode: 1 });
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
access_token: session.access_token,
|
|
210
|
+
refresh_token: session.refresh_token,
|
|
211
|
+
expires_at: session.expires_at,
|
|
212
|
+
token_type: session.token_type ?? void 0,
|
|
213
|
+
user: session.user ? { id: session.user.id, email: session.user.email ?? null } : void 0
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
function createSupabaseAuthHelpers(config) {
|
|
217
|
+
const storage = createInMemoryStorage();
|
|
218
|
+
const supabase = createSupabaseClient(config, storage);
|
|
219
|
+
return {
|
|
220
|
+
async startGoogleLogin(params) {
|
|
221
|
+
const { data, error } = await supabase.auth.signInWithOAuth({
|
|
222
|
+
provider: "google",
|
|
223
|
+
options: {
|
|
224
|
+
redirectTo: params.redirectTo,
|
|
225
|
+
skipBrowserRedirect: true,
|
|
226
|
+
queryParams: {
|
|
227
|
+
access_type: "offline",
|
|
228
|
+
prompt: "consent"
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
if (error) throw error;
|
|
233
|
+
if (!data?.url) throw new CliError("Supabase did not return an OAuth URL.", { exitCode: 1 });
|
|
234
|
+
return { url: data.url };
|
|
235
|
+
},
|
|
236
|
+
async exchangeCode(params) {
|
|
237
|
+
const { data, error } = await supabase.auth.exchangeCodeForSession(params.code);
|
|
238
|
+
if (error) throw error;
|
|
239
|
+
if (!data?.session) throw new CliError("Supabase did not return a session.", { exitCode: 1 });
|
|
240
|
+
return toStoredSession(data.session);
|
|
241
|
+
},
|
|
242
|
+
async refreshWithStoredSession(params) {
|
|
243
|
+
const { data, error } = await supabase.auth.setSession({
|
|
244
|
+
access_token: params.session.access_token,
|
|
245
|
+
refresh_token: params.session.refresh_token
|
|
246
|
+
});
|
|
247
|
+
if (error) throw error;
|
|
248
|
+
if (!data?.session) throw new CliError("No session returned after refresh.", { exitCode: 1 });
|
|
249
|
+
return toStoredSession(data.session);
|
|
250
|
+
}
|
|
86
251
|
};
|
|
87
|
-
return { pkg, warnings, info };
|
|
88
252
|
}
|
|
89
253
|
|
|
90
|
-
// src/
|
|
254
|
+
// src/services/sessionStore.ts
|
|
91
255
|
import fs2 from "fs/promises";
|
|
256
|
+
import os from "os";
|
|
257
|
+
import path2 from "path";
|
|
258
|
+
import { z as z2 } from "zod";
|
|
259
|
+
|
|
260
|
+
// src/utils/fs.ts
|
|
261
|
+
import fs from "fs/promises";
|
|
92
262
|
import path from "path";
|
|
93
263
|
import fse from "fs-extra";
|
|
94
264
|
async function findAvailableDirPath(preferredDir) {
|
|
@@ -100,7 +270,7 @@ async function findAvailableDirPath(preferredDir) {
|
|
|
100
270
|
const candidate = path.join(parent, `${base}-${i}`);
|
|
101
271
|
if (!await fse.pathExists(candidate)) return candidate;
|
|
102
272
|
}
|
|
103
|
-
throw new CliError("
|
|
273
|
+
throw new CliError("No available output directory name.", {
|
|
104
274
|
exitCode: 2,
|
|
105
275
|
hint: `Tried ${base}-2 through ${base}-1000 under ${parent}.`
|
|
106
276
|
});
|
|
@@ -108,7 +278,7 @@ async function findAvailableDirPath(preferredDir) {
|
|
|
108
278
|
async function ensureEmptyDir(dir) {
|
|
109
279
|
const exists2 = await fse.pathExists(dir);
|
|
110
280
|
if (exists2) {
|
|
111
|
-
throw new CliError("Output directory already exists", {
|
|
281
|
+
throw new CliError("Output directory already exists.", {
|
|
112
282
|
hint: `Choose a new --out directory or delete: ${dir}`,
|
|
113
283
|
exitCode: 2
|
|
114
284
|
});
|
|
@@ -119,1640 +289,1860 @@ async function writeJsonAtomic(filePath, value) {
|
|
|
119
289
|
const dir = path.dirname(filePath);
|
|
120
290
|
await fse.mkdirp(dir);
|
|
121
291
|
const tmp = `${filePath}.tmp-${Date.now()}`;
|
|
122
|
-
await
|
|
123
|
-
await
|
|
292
|
+
await fs.writeFile(tmp, JSON.stringify(value, null, 2) + "\n", "utf8");
|
|
293
|
+
await fs.rename(tmp, filePath);
|
|
124
294
|
}
|
|
125
295
|
async function writeTextAtomic(filePath, contents) {
|
|
126
296
|
const dir = path.dirname(filePath);
|
|
127
297
|
await fse.mkdirp(dir);
|
|
128
298
|
const tmp = `${filePath}.tmp-${Date.now()}`;
|
|
129
|
-
await
|
|
130
|
-
await
|
|
299
|
+
await fs.writeFile(tmp, contents, "utf8");
|
|
300
|
+
await fs.rename(tmp, filePath);
|
|
131
301
|
}
|
|
132
302
|
|
|
133
|
-
// src/
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
const hasAppConfig = await fileExists(path2.join(cur, "app.config.js")) || await fileExists(path2.join(cur, "app.config.ts"));
|
|
151
|
-
if (!hasAppJson && !hasAppConfig) {
|
|
152
|
-
} else {
|
|
153
|
-
return cur;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
const parent = path2.dirname(cur);
|
|
157
|
-
if (parent === cur) break;
|
|
158
|
-
cur = parent;
|
|
159
|
-
}
|
|
160
|
-
throw new CliError("Not inside an Expo project", {
|
|
161
|
-
hint: "Run this command from the root of your Expo project repository.",
|
|
162
|
-
exitCode: 2
|
|
163
|
-
});
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// src/lib/templates.ts
|
|
167
|
-
function shellLayoutTsx() {
|
|
168
|
-
return `import { Stack } from 'expo-router';
|
|
169
|
-
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
|
170
|
-
|
|
171
|
-
export default function Layout() {
|
|
172
|
-
return (
|
|
173
|
-
<GestureHandlerRootView style={{ flex: 1 }}>
|
|
174
|
-
<Stack screenOptions={{ headerShown: false }} />
|
|
175
|
-
</GestureHandlerRootView>
|
|
176
|
-
);
|
|
177
|
-
}
|
|
178
|
-
`;
|
|
179
|
-
}
|
|
180
|
-
function shellIndexTsx() {
|
|
181
|
-
return `import * as React from 'react';
|
|
182
|
-
import { View } from 'react-native';
|
|
183
|
-
import { Stack } from 'expo-router';
|
|
184
|
-
import { ComergeStudio } from '@comergehq/studio';
|
|
185
|
-
|
|
186
|
-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
187
|
-
// @ts-ignore
|
|
188
|
-
import config from '../comerge.config.json';
|
|
189
|
-
|
|
190
|
-
export default function Index() {
|
|
191
|
-
const appId = String((config as any)?.appId || '');
|
|
192
|
-
const appKey = String((config as any)?.appKey || 'MicroMain');
|
|
193
|
-
const apiKey = String((config as any)?.apiKey || '');
|
|
194
|
-
return (
|
|
195
|
-
<>
|
|
196
|
-
<Stack.Screen options={{ headerShown: false }} />
|
|
197
|
-
<View style={{ flex: 1 }}>
|
|
198
|
-
{appId ? <ComergeStudio appId={appId} apiKey={apiKey} appKey={appKey} /> : null}
|
|
199
|
-
</View>
|
|
200
|
-
</>
|
|
201
|
-
);
|
|
202
|
-
}
|
|
203
|
-
`;
|
|
204
|
-
}
|
|
205
|
-
function shellBabelConfigJs() {
|
|
206
|
-
return `module.exports = function (api) {
|
|
207
|
-
api.cache(true);
|
|
208
|
-
return {
|
|
209
|
-
presets: ['babel-preset-expo'],
|
|
210
|
-
plugins: ['react-native-reanimated/plugin'],
|
|
211
|
-
};
|
|
212
|
-
};
|
|
213
|
-
`;
|
|
303
|
+
// src/services/sessionStore.ts
|
|
304
|
+
var storedSessionSchema = z2.object({
|
|
305
|
+
access_token: z2.string().min(1),
|
|
306
|
+
refresh_token: z2.string().min(1),
|
|
307
|
+
expires_at: z2.number().int().positive(),
|
|
308
|
+
token_type: z2.string().min(1).optional(),
|
|
309
|
+
user: z2.object({
|
|
310
|
+
id: z2.string().min(1),
|
|
311
|
+
email: z2.string().email().optional().nullable()
|
|
312
|
+
}).optional()
|
|
313
|
+
});
|
|
314
|
+
var KEYTAR_SERVICE = "comerge-cli";
|
|
315
|
+
var KEYTAR_ACCOUNT = "default";
|
|
316
|
+
function xdgConfigHome() {
|
|
317
|
+
const v = process.env.XDG_CONFIG_HOME;
|
|
318
|
+
if (typeof v === "string" && v.trim().length > 0) return v;
|
|
319
|
+
return path2.join(os.homedir(), ".config");
|
|
214
320
|
}
|
|
215
|
-
function
|
|
216
|
-
return
|
|
217
|
-
|
|
218
|
-
const config = getDefaultConfig(__dirname);
|
|
219
|
-
|
|
220
|
-
module.exports = config;
|
|
221
|
-
`;
|
|
321
|
+
function sessionFilePath() {
|
|
322
|
+
return path2.join(xdgConfigHome(), "comerge", "session.json");
|
|
222
323
|
}
|
|
223
|
-
|
|
224
|
-
// src/lib/packageManager.ts
|
|
225
|
-
import fs4 from "fs/promises";
|
|
226
|
-
async function exists(p) {
|
|
324
|
+
async function maybeLoadKeytar() {
|
|
227
325
|
try {
|
|
228
|
-
await
|
|
229
|
-
|
|
326
|
+
const mod = await import("keytar");
|
|
327
|
+
const candidates = [mod?.default, mod].filter(Boolean);
|
|
328
|
+
for (const c of candidates) {
|
|
329
|
+
if (c && typeof c.getPassword === "function" && typeof c.setPassword === "function" && typeof c.deletePassword === "function") {
|
|
330
|
+
return c;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return null;
|
|
230
334
|
} catch {
|
|
231
|
-
return
|
|
335
|
+
return null;
|
|
232
336
|
}
|
|
233
337
|
}
|
|
234
|
-
async function
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
return { cmd: "pnpm", args: ["install"] };
|
|
245
|
-
case "yarn":
|
|
246
|
-
return { cmd: "yarn", args: ["install"] };
|
|
247
|
-
case "bun":
|
|
248
|
-
return { cmd: "bun", args: ["install"] };
|
|
249
|
-
case "npm":
|
|
250
|
-
default:
|
|
251
|
-
return { cmd: "npm", args: ["install"] };
|
|
338
|
+
async function ensurePathPermissions(filePath) {
|
|
339
|
+
const dir = path2.dirname(filePath);
|
|
340
|
+
await fs2.mkdir(dir, { recursive: true });
|
|
341
|
+
try {
|
|
342
|
+
await fs2.chmod(dir, 448);
|
|
343
|
+
} catch {
|
|
344
|
+
}
|
|
345
|
+
try {
|
|
346
|
+
await fs2.chmod(filePath, 384);
|
|
347
|
+
} catch {
|
|
252
348
|
}
|
|
253
349
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
350
|
+
async function getSession() {
|
|
351
|
+
const keytar = await maybeLoadKeytar();
|
|
352
|
+
if (keytar) {
|
|
353
|
+
const raw2 = await keytar.getPassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT);
|
|
354
|
+
if (!raw2) return null;
|
|
355
|
+
try {
|
|
356
|
+
const parsed = storedSessionSchema.safeParse(JSON.parse(raw2));
|
|
357
|
+
if (!parsed.success) return null;
|
|
358
|
+
return parsed.data;
|
|
359
|
+
} catch {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
const fp = sessionFilePath();
|
|
364
|
+
const raw = await fs2.readFile(fp, "utf8").catch(() => null);
|
|
365
|
+
if (!raw) return null;
|
|
366
|
+
try {
|
|
367
|
+
const parsed = storedSessionSchema.safeParse(JSON.parse(raw));
|
|
368
|
+
if (!parsed.success) return null;
|
|
369
|
+
await ensurePathPermissions(fp);
|
|
370
|
+
return parsed.data;
|
|
371
|
+
} catch {
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
270
374
|
}
|
|
271
|
-
function
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
if (
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
375
|
+
async function setSession(session) {
|
|
376
|
+
const parsed = storedSessionSchema.safeParse(session);
|
|
377
|
+
if (!parsed.success) {
|
|
378
|
+
throw new CliError("Session data is invalid and was not stored.", { exitCode: 1 });
|
|
379
|
+
}
|
|
380
|
+
const keytar = await maybeLoadKeytar();
|
|
381
|
+
if (keytar) {
|
|
382
|
+
await keytar.setPassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT, JSON.stringify(parsed.data));
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const fp = sessionFilePath();
|
|
386
|
+
await writeJsonAtomic(fp, parsed.data);
|
|
387
|
+
await ensurePathPermissions(fp);
|
|
281
388
|
}
|
|
282
|
-
async function
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
throw new CliError("Project root does not exist", { exitCode: 2 });
|
|
389
|
+
async function clearSession() {
|
|
390
|
+
const keytar = await maybeLoadKeytar();
|
|
391
|
+
if (keytar) {
|
|
392
|
+
await keytar.deletePassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT);
|
|
393
|
+
return;
|
|
288
394
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
preserveTimestamps: true,
|
|
292
|
-
filter: (srcPath) => {
|
|
293
|
-
const rel = path3.relative(src, srcPath);
|
|
294
|
-
if (!rel) return true;
|
|
295
|
-
return !shouldExclude(rel);
|
|
296
|
-
}
|
|
395
|
+
const fp = sessionFilePath();
|
|
396
|
+
await fs2.rm(fp, { force: true }).catch(() => {
|
|
297
397
|
});
|
|
298
398
|
}
|
|
299
399
|
|
|
300
|
-
// src/
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
async function stripProject(params) {
|
|
305
|
-
const dirs = params.dirs ?? DEFAULT_STRIP_DIRS;
|
|
306
|
-
await Promise.all(
|
|
307
|
-
dirs.map(async (d) => {
|
|
308
|
-
const p = path4.join(params.outRoot, d);
|
|
309
|
-
const exists2 = await fse3.pathExists(p);
|
|
310
|
-
if (!exists2) return;
|
|
311
|
-
await fse3.remove(p);
|
|
312
|
-
})
|
|
313
|
-
);
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
// src/commands/shellInit.ts
|
|
317
|
-
import fs7 from "fs/promises";
|
|
318
|
-
|
|
319
|
-
// src/lib/appJsonPatch.ts
|
|
320
|
-
import fs5 from "fs/promises";
|
|
321
|
-
function pluginName(entry) {
|
|
322
|
-
if (Array.isArray(entry)) return typeof entry[0] === "string" ? entry[0] : null;
|
|
323
|
-
return typeof entry === "string" ? entry : null;
|
|
400
|
+
// src/clients/api.ts
|
|
401
|
+
function shouldRefreshSoon(session, skewSeconds = 60) {
|
|
402
|
+
const nowSec = Math.floor(Date.now() / 1e3);
|
|
403
|
+
return session.expires_at <= nowSec + skewSeconds;
|
|
324
404
|
}
|
|
325
|
-
async function
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
raw = await fs5.readFile(appJsonPath, "utf8");
|
|
329
|
-
} catch {
|
|
330
|
-
return false;
|
|
331
|
-
}
|
|
332
|
-
let parsed;
|
|
405
|
+
async function readJsonSafe(res) {
|
|
406
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
407
|
+
if (!ct.toLowerCase().includes("application/json")) return null;
|
|
333
408
|
try {
|
|
334
|
-
|
|
409
|
+
return await res.json();
|
|
335
410
|
} catch {
|
|
336
|
-
|
|
411
|
+
return null;
|
|
337
412
|
}
|
|
338
|
-
const expo = parsed.expo;
|
|
339
|
-
if (!expo || typeof expo !== "object") return false;
|
|
340
|
-
const plugins = Array.isArray(expo.plugins) ? [...expo.plugins] : [];
|
|
341
|
-
const routerEntry = plugins.find((p) => pluginName(p) === "expo-router");
|
|
342
|
-
const runtimeEntry = plugins.find((p) => pluginName(p) === "@comergehq/runtime");
|
|
343
|
-
const needsRouter = !routerEntry;
|
|
344
|
-
const needsRuntime = !runtimeEntry;
|
|
345
|
-
if (!needsRouter && !needsRuntime) return false;
|
|
346
|
-
const rest = plugins.filter((p) => {
|
|
347
|
-
const name = pluginName(p);
|
|
348
|
-
return name !== "expo-router" && name !== "@comergehq/runtime";
|
|
349
|
-
});
|
|
350
|
-
expo.plugins = [routerEntry ?? "expo-router", runtimeEntry ?? "@comergehq/runtime", ...rest];
|
|
351
|
-
await fs5.writeFile(appJsonPath, JSON.stringify({ expo }, null, 2) + "\n", "utf8");
|
|
352
|
-
return true;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
// src/lib/reanimated.ts
|
|
356
|
-
import fs6 from "fs/promises";
|
|
357
|
-
async function ensureReanimatedBabelPlugin(babelConfigPath) {
|
|
358
|
-
const raw = await fs6.readFile(babelConfigPath, "utf8").catch(() => null);
|
|
359
|
-
if (!raw) return false;
|
|
360
|
-
if (raw.includes("react-native-reanimated/plugin")) return false;
|
|
361
|
-
const patched = patchBabelConfigAddReanimatedPlugin(raw);
|
|
362
|
-
if (patched === raw) return false;
|
|
363
|
-
await fs6.writeFile(babelConfigPath, patched, "utf8");
|
|
364
|
-
return true;
|
|
365
413
|
}
|
|
366
|
-
function
|
|
367
|
-
const
|
|
368
|
-
if (
|
|
369
|
-
|
|
370
|
-
const indent = pluginsMatch[2] ?? "";
|
|
371
|
-
const bracketIdx = raw.indexOf("[", matchIdx);
|
|
372
|
-
if (bracketIdx >= 0) {
|
|
373
|
-
const closeIdx = findMatchingBracket(raw, bracketIdx, "[", "]");
|
|
374
|
-
if (closeIdx >= 0) {
|
|
375
|
-
const inner = raw.slice(bracketIdx + 1, closeIdx);
|
|
376
|
-
const innerTrim = inner.trim();
|
|
377
|
-
const innerTrimEnd = inner.replace(/[ \t\r\n]+$/g, "");
|
|
378
|
-
const elementIndent = indent + " ";
|
|
379
|
-
let insert = "";
|
|
380
|
-
if (!innerTrim) {
|
|
381
|
-
insert = `
|
|
382
|
-
${elementIndent}'react-native-reanimated/plugin'
|
|
383
|
-
${indent}`;
|
|
384
|
-
} else if (innerTrimEnd.endsWith(",")) {
|
|
385
|
-
insert = `
|
|
386
|
-
${elementIndent}'react-native-reanimated/plugin'`;
|
|
387
|
-
} else {
|
|
388
|
-
insert = `,
|
|
389
|
-
${elementIndent}'react-native-reanimated/plugin'`;
|
|
390
|
-
}
|
|
391
|
-
return raw.slice(0, closeIdx) + insert + raw.slice(closeIdx);
|
|
392
|
-
}
|
|
393
|
-
}
|
|
414
|
+
async function getAuthToken(config) {
|
|
415
|
+
const envToken = process.env.COMERGE_ACCESS_TOKEN;
|
|
416
|
+
if (typeof envToken === "string" && envToken.trim().length > 0) {
|
|
417
|
+
return { token: envToken.trim(), session: null, fromEnv: true };
|
|
394
418
|
}
|
|
395
|
-
|
|
396
|
-
if (
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
${indent}plugins: ['react-native-reanimated/plugin'],`;
|
|
402
|
-
return raw.slice(0, insertAt) + insertion + raw.slice(insertAt);
|
|
419
|
+
let session = await getSession();
|
|
420
|
+
if (!session) {
|
|
421
|
+
throw new CliError("Not signed in.", {
|
|
422
|
+
exitCode: 2,
|
|
423
|
+
hint: "Run `comerge login`, or set COMERGE_ACCESS_TOKEN for CI."
|
|
424
|
+
});
|
|
403
425
|
}
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
let inLineComment = false;
|
|
412
|
-
let inBlockComment = false;
|
|
413
|
-
for (let i = openIdx; i < text.length; i++) {
|
|
414
|
-
const ch = text[i];
|
|
415
|
-
const next = text[i + 1];
|
|
416
|
-
if (inLineComment) {
|
|
417
|
-
if (ch === "\n") inLineComment = false;
|
|
418
|
-
continue;
|
|
419
|
-
}
|
|
420
|
-
if (inBlockComment) {
|
|
421
|
-
if (ch === "*" && next === "/") {
|
|
422
|
-
inBlockComment = false;
|
|
423
|
-
i++;
|
|
424
|
-
}
|
|
425
|
-
continue;
|
|
426
|
-
}
|
|
427
|
-
if (!inSingle && !inDouble && !inTemplate) {
|
|
428
|
-
if (ch === "/" && next === "/") {
|
|
429
|
-
inLineComment = true;
|
|
430
|
-
i++;
|
|
431
|
-
continue;
|
|
432
|
-
}
|
|
433
|
-
if (ch === "/" && next === "*") {
|
|
434
|
-
inBlockComment = true;
|
|
435
|
-
i++;
|
|
436
|
-
continue;
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
if (!inDouble && !inTemplate && ch === "'" && text[i - 1] !== "\\") {
|
|
440
|
-
inSingle = !inSingle;
|
|
441
|
-
continue;
|
|
442
|
-
}
|
|
443
|
-
if (!inSingle && !inTemplate && ch === `"` && text[i - 1] !== "\\") {
|
|
444
|
-
inDouble = !inDouble;
|
|
445
|
-
continue;
|
|
426
|
+
if (shouldRefreshSoon(session)) {
|
|
427
|
+
try {
|
|
428
|
+
const supabase = createSupabaseAuthHelpers(config);
|
|
429
|
+
session = await supabase.refreshWithStoredSession({ session });
|
|
430
|
+
await setSession(session);
|
|
431
|
+
} catch (err) {
|
|
432
|
+
void err;
|
|
446
433
|
}
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
434
|
+
}
|
|
435
|
+
return { token: session.access_token, session, fromEnv: false };
|
|
436
|
+
}
|
|
437
|
+
function createApiClient(config, opts) {
|
|
438
|
+
const cfg = config;
|
|
439
|
+
const apiKey = (opts?.apiKey ?? "").trim();
|
|
440
|
+
const CLIENT_KEY_HEADER = "x-comerge-api-key";
|
|
441
|
+
async function request(path13, init) {
|
|
442
|
+
const { token, session, fromEnv } = await getAuthToken(cfg);
|
|
443
|
+
const url = new URL(path13, cfg.apiUrl).toString();
|
|
444
|
+
const doFetch = async (bearer) => {
|
|
445
|
+
const res2 = await fetch(url, {
|
|
446
|
+
...init,
|
|
447
|
+
headers: {
|
|
448
|
+
Accept: "application/json",
|
|
449
|
+
"Content-Type": "application/json",
|
|
450
|
+
...init?.headers ?? {},
|
|
451
|
+
Authorization: `Bearer ${bearer}`,
|
|
452
|
+
...apiKey ? { [CLIENT_KEY_HEADER]: apiKey } : {}
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
return res2;
|
|
456
|
+
};
|
|
457
|
+
let res = await doFetch(token);
|
|
458
|
+
if (res.status === 401 && !fromEnv && session?.refresh_token) {
|
|
459
|
+
const supabase = createSupabaseAuthHelpers(cfg);
|
|
460
|
+
const refreshed = await supabase.refreshWithStoredSession({ session });
|
|
461
|
+
await setSession(refreshed);
|
|
462
|
+
res = await doFetch(refreshed.access_token);
|
|
450
463
|
}
|
|
451
|
-
if (
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
if (depth === 0) return i;
|
|
464
|
+
if (!res.ok) {
|
|
465
|
+
const body = await readJsonSafe(res);
|
|
466
|
+
const msg = (body && typeof body === "object" && body && "message" in body && typeof body.message === "string" ? body.message : null) ?? `Request failed (status ${res.status})`;
|
|
467
|
+
throw new CliError(msg, { exitCode: 1, hint: body ? JSON.stringify(body, null, 2) : null });
|
|
456
468
|
}
|
|
469
|
+
const json = await readJsonSafe(res);
|
|
470
|
+
return json ?? null;
|
|
457
471
|
}
|
|
458
|
-
return
|
|
472
|
+
return {
|
|
473
|
+
getMe: () => request("/v1/me", { method: "GET" }),
|
|
474
|
+
listOrganizations: () => request("/v1/organizations", { method: "GET" }),
|
|
475
|
+
autoEnableDeveloper: () => request("/v1/developer/auto-enable", { method: "POST" }),
|
|
476
|
+
listClientApps: (params) => {
|
|
477
|
+
const qs = params?.orgId ? `?orgId=${encodeURIComponent(params.orgId)}` : "";
|
|
478
|
+
return request(`/v1/developer/client-apps${qs}`, { method: "GET" });
|
|
479
|
+
},
|
|
480
|
+
createClientApp: (payload) => request("/v1/developer/client-apps", { method: "POST", body: JSON.stringify(payload) }),
|
|
481
|
+
createClientAppKey: (clientAppId, payload) => request(`/v1/developer/client-apps/${encodeURIComponent(clientAppId)}/keys`, {
|
|
482
|
+
method: "POST",
|
|
483
|
+
body: JSON.stringify(payload ?? {})
|
|
484
|
+
}),
|
|
485
|
+
getApp: (appId) => request(`/v1/apps/${encodeURIComponent(appId)}`, { method: "GET" }),
|
|
486
|
+
presignImportUpload: (payload) => request("/v1/apps/import/upload/presign", { method: "POST", body: JSON.stringify(payload) }),
|
|
487
|
+
importFromUpload: (payload) => request("/v1/apps/import/upload", { method: "POST", body: JSON.stringify(payload) }),
|
|
488
|
+
initiateBundle: (appId, payload) => request(`/v1/apps/${encodeURIComponent(appId)}/bundles`, { method: "POST", body: JSON.stringify(payload) }),
|
|
489
|
+
getBundle: (appId, bundleId) => request(`/v1/apps/${encodeURIComponent(appId)}/bundles/${encodeURIComponent(bundleId)}`, { method: "GET" }),
|
|
490
|
+
getBundleDownloadUrl: (appId, bundleId, options) => request(
|
|
491
|
+
`/v1/apps/${encodeURIComponent(appId)}/bundles/${encodeURIComponent(bundleId)}/download?redirect=${options?.redirect ?? false}`,
|
|
492
|
+
{ method: "GET" }
|
|
493
|
+
)
|
|
494
|
+
};
|
|
459
495
|
}
|
|
460
496
|
|
|
461
|
-
// src/commands/
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
let appId = params.appId ?? "";
|
|
467
|
-
let apiKey = params.apiKey ?? "";
|
|
468
|
-
let appKey = params.appKey ?? "MicroMain";
|
|
469
|
-
if (!params.yes) {
|
|
470
|
-
const res = await prompts(
|
|
471
|
-
[
|
|
472
|
-
{
|
|
473
|
-
type: "text",
|
|
474
|
-
name: "outDir",
|
|
475
|
-
message: "Output directory",
|
|
476
|
-
initial: outDir
|
|
477
|
-
},
|
|
478
|
-
{
|
|
479
|
-
type: "text",
|
|
480
|
-
name: "appKey",
|
|
481
|
-
message: "App key",
|
|
482
|
-
initial: appKey
|
|
483
|
-
},
|
|
484
|
-
{
|
|
485
|
-
type: "text",
|
|
486
|
-
name: "appId",
|
|
487
|
-
message: "Comerge appId",
|
|
488
|
-
initial: appId,
|
|
489
|
-
validate: (value) => String(value || "").trim().length > 0 ? true : "appId is required"
|
|
490
|
-
},
|
|
491
|
-
{
|
|
492
|
-
type: "password",
|
|
493
|
-
name: "apiKey",
|
|
494
|
-
message: "Comerge apiKey",
|
|
495
|
-
initial: apiKey,
|
|
496
|
-
validate: (value) => String(value || "").trim().length > 0 ? true : "apiKey is required"
|
|
497
|
-
}
|
|
498
|
-
],
|
|
499
|
-
{
|
|
500
|
-
onCancel: () => {
|
|
501
|
-
throw new CliError("Cancelled", { exitCode: 130 });
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
);
|
|
505
|
-
outDir = String(res.outDir || outDir);
|
|
506
|
-
appKey = String(res.appKey || appKey);
|
|
507
|
-
appId = String(res.appId || appId);
|
|
508
|
-
apiKey = String(res.apiKey || apiKey);
|
|
509
|
-
}
|
|
510
|
-
appId = String(appId || "").trim();
|
|
511
|
-
apiKey = String(apiKey || "").trim();
|
|
512
|
-
if (!appId) {
|
|
513
|
-
throw new CliError("Missing required appId", {
|
|
514
|
-
hint: "Pass --app-id <uuid> (or omit --yes to be prompted).",
|
|
515
|
-
exitCode: 2
|
|
516
|
-
});
|
|
517
|
-
}
|
|
518
|
-
if (!apiKey) {
|
|
519
|
-
throw new CliError("Missing required apiKey", {
|
|
520
|
-
hint: "Pass --api-key <key> (or omit --yes to be prompted).",
|
|
521
|
-
exitCode: 2
|
|
497
|
+
// src/commands/login.ts
|
|
498
|
+
function registerLoginCommand(program) {
|
|
499
|
+
program.command("login").description("Sign in to Comerge").option("--no-browser", "Do not open a browser; print the URL").option("--port <port>", "Local callback port (default: random available port)").option("--yes", "Run non-interactively", false).option("--json", "Output JSON", false).action(async (opts) => {
|
|
500
|
+
const config = await resolveConfig({
|
|
501
|
+
yes: Boolean(opts.yes)
|
|
522
502
|
});
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
console.log(pc.dim(`Using: ${outputPath}`));
|
|
530
|
-
} else {
|
|
531
|
-
console.log(pc.dim(`Output: ${outputPath}`));
|
|
532
|
-
}
|
|
533
|
-
const outputIsInsideProject = outputPath === projectRoot || outputPath.startsWith(projectRoot + path5.sep);
|
|
534
|
-
const stagingPath = outputIsInsideProject ? await fs7.mkdtemp(path5.join(os.tmpdir(), "comerge-shell-")) : outputPath;
|
|
535
|
-
let movedToFinal = false;
|
|
536
|
-
try {
|
|
537
|
-
if (!outputIsInsideProject) {
|
|
538
|
-
await ensureEmptyDir(stagingPath);
|
|
539
|
-
}
|
|
540
|
-
await copyProject({ projectRoot, outRoot: stagingPath });
|
|
541
|
-
await stripProject({ outRoot: stagingPath });
|
|
542
|
-
try {
|
|
543
|
-
const didPatch = await ensureComergeShellPlugins(path5.join(stagingPath, "app.json"));
|
|
544
|
-
if (didPatch) {
|
|
545
|
-
console.log(pc.dim("Patched app.json to include required Comerge shell plugins."));
|
|
503
|
+
const portRaw = typeof opts.port === "string" ? opts.port.trim() : "";
|
|
504
|
+
let port = null;
|
|
505
|
+
if (portRaw) {
|
|
506
|
+
const parsed = Number(portRaw);
|
|
507
|
+
if (!Number.isFinite(parsed) || parsed <= 0 || !Number.isInteger(parsed)) {
|
|
508
|
+
throw new CliError("Invalid --port value", { exitCode: 2, hint: "Example: --port 8085" });
|
|
546
509
|
}
|
|
547
|
-
|
|
510
|
+
port = parsed;
|
|
548
511
|
}
|
|
549
|
-
await
|
|
550
|
-
appId,
|
|
551
|
-
appKey: appKey || "MicroMain",
|
|
552
|
-
apiKey
|
|
553
|
-
});
|
|
554
|
-
await writeTextAtomic(path5.join(stagingPath, "app/_layout.tsx"), shellLayoutTsx());
|
|
555
|
-
await writeTextAtomic(path5.join(stagingPath, "app/index.tsx"), shellIndexTsx());
|
|
556
|
-
await ensureTextFile(path5.join(stagingPath, "babel.config.js"), shellBabelConfigJs());
|
|
557
|
-
await ensureTextFile(path5.join(stagingPath, "metro.config.js"), shellMetroConfigJs());
|
|
512
|
+
const server = await startOAuthCallbackServer({ port });
|
|
558
513
|
try {
|
|
559
|
-
const
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
{
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
const { pkg: shellPkg, warnings, info } = buildShellPackageJson({
|
|
576
|
-
original: originalPkg,
|
|
577
|
-
studioVersion: params.studioVersion
|
|
578
|
-
});
|
|
579
|
-
await writeJsonAtomic(path5.join(stagingPath, "package.json"), shellPkg);
|
|
580
|
-
for (const w of warnings) console.log(pc.yellow(`Warning: ${w}`));
|
|
581
|
-
for (const i of info) console.log(pc.dim(`Info: ${i}`));
|
|
582
|
-
if (outputIsInsideProject) {
|
|
583
|
-
await fse4.move(stagingPath, outputPath, { overwrite: false });
|
|
584
|
-
movedToFinal = true;
|
|
585
|
-
}
|
|
586
|
-
const finalPath = outputIsInsideProject ? outputPath : stagingPath;
|
|
587
|
-
if (params.install) {
|
|
588
|
-
const pm = params.packageManager ?? await detectPackageManager(projectRoot);
|
|
589
|
-
const { cmd, args } = installCommand(pm);
|
|
590
|
-
console.log(pc.dim(`Installing deps with ${cmd}...`));
|
|
591
|
-
const res = await execa(cmd, args, { cwd: finalPath, stdio: "inherit" });
|
|
592
|
-
if (res.exitCode !== 0) {
|
|
593
|
-
throw new CliError("Dependency install failed", { exitCode: res.exitCode ?? 1 });
|
|
594
|
-
}
|
|
595
|
-
console.log(pc.dim("Running expo prebuild..."));
|
|
596
|
-
const prebuild = await execa("npx", ["expo", "prebuild"], { cwd: finalPath, stdio: "inherit" });
|
|
597
|
-
if (prebuild.exitCode !== 0) {
|
|
598
|
-
throw new CliError("expo prebuild failed", { exitCode: prebuild.exitCode ?? 1 });
|
|
514
|
+
const supabase = createSupabaseAuthHelpers(config);
|
|
515
|
+
const { url } = await supabase.startGoogleLogin({ redirectTo: server.redirectTo });
|
|
516
|
+
const shouldOpenBrowser = Boolean(opts.browser ?? true);
|
|
517
|
+
if (shouldOpenBrowser) {
|
|
518
|
+
try {
|
|
519
|
+
await openBrowser(url);
|
|
520
|
+
console.log(pc.dim("Opened a browser for sign-in."));
|
|
521
|
+
} catch (err) {
|
|
522
|
+
console.error(pc.yellow("Could not open a browser automatically."));
|
|
523
|
+
console.error(pc.dim(err?.message ?? String(err)));
|
|
524
|
+
console.log(`Open this URL in a browser:
|
|
525
|
+
${url}`);
|
|
526
|
+
}
|
|
527
|
+
} else {
|
|
528
|
+
console.log(`Open this URL to sign in:
|
|
529
|
+
${url}`);
|
|
599
530
|
}
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
console.log(pc.dim("Next steps:"));
|
|
605
|
-
console.log(pc.dim(` cd ${rel}`));
|
|
606
|
-
console.log(pc.dim(` npx expo run:ios`));
|
|
607
|
-
console.log(pc.dim(` npx expo run:android`));
|
|
608
|
-
} finally {
|
|
609
|
-
if (outputIsInsideProject && !movedToFinal) {
|
|
531
|
+
const code = await server.waitForCode();
|
|
532
|
+
const session = await supabase.exchangeCode({ code });
|
|
533
|
+
await setSession(session);
|
|
534
|
+
let me = null;
|
|
610
535
|
try {
|
|
611
|
-
|
|
612
|
-
|
|
536
|
+
const api = createApiClient(config);
|
|
537
|
+
me = await api.getMe();
|
|
538
|
+
} catch (err) {
|
|
539
|
+
await clearSession().catch(() => {
|
|
540
|
+
});
|
|
541
|
+
throw err;
|
|
542
|
+
}
|
|
543
|
+
if (opts.json) {
|
|
544
|
+
console.log(JSON.stringify({ success: true, me }, null, 2));
|
|
545
|
+
} else {
|
|
546
|
+
const profile = me?.responseObject;
|
|
547
|
+
const label = profile?.email ? `${profile.email}` : profile?.id ? `user ${profile.id}` : "user";
|
|
548
|
+
console.log(pc.green(`Signed in as ${label}.`));
|
|
613
549
|
}
|
|
550
|
+
} finally {
|
|
551
|
+
await server.close();
|
|
614
552
|
}
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
async function ensureTextFile(filePath, contents) {
|
|
618
|
-
try {
|
|
619
|
-
await fs7.access(filePath);
|
|
620
|
-
return;
|
|
621
|
-
} catch {
|
|
622
|
-
}
|
|
623
|
-
await writeTextAtomic(filePath, contents);
|
|
553
|
+
});
|
|
624
554
|
}
|
|
625
555
|
|
|
626
|
-
// src/commands/
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
556
|
+
// src/commands/logout.ts
|
|
557
|
+
import pc2 from "picocolors";
|
|
558
|
+
function registerLogoutCommand(program) {
|
|
559
|
+
program.command("logout").description("Clear the local Comerge session").option("--json", "Output JSON", false).action(async (opts) => {
|
|
560
|
+
await clearSession();
|
|
561
|
+
const envToken = process.env.COMERGE_ACCESS_TOKEN;
|
|
562
|
+
if (opts.json) {
|
|
563
|
+
console.log(JSON.stringify({ success: true, cleared: true, envTokenPresent: Boolean(envToken) }, null, 2));
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
console.log(pc2.green("Signed out."));
|
|
567
|
+
if (envToken) {
|
|
568
|
+
console.log(pc2.dim("Note: COMERGE_ACCESS_TOKEN is set and will still authenticate requests."));
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// src/commands/whoami.ts
|
|
574
|
+
import pc3 from "picocolors";
|
|
575
|
+
function registerWhoamiCommand(program) {
|
|
576
|
+
program.command("whoami").description("Show the current Comerge user").option("--yes", "Run non-interactively", false).option("--json", "Output JSON", false).action(async (opts) => {
|
|
577
|
+
const config = await resolveConfig({
|
|
578
|
+
yes: Boolean(opts.yes)
|
|
642
579
|
});
|
|
580
|
+
const api = createApiClient(config);
|
|
581
|
+
const me = await api.getMe();
|
|
582
|
+
if (opts.json) {
|
|
583
|
+
console.log(JSON.stringify(me, null, 2));
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
const profile = me?.responseObject;
|
|
587
|
+
const label = profile?.email ? `${profile.email}` : profile?.id ? `user ${profile.id}` : "unknown";
|
|
588
|
+
console.log(pc3.green(`Signed in as ${label}.`));
|
|
643
589
|
});
|
|
644
590
|
}
|
|
645
591
|
|
|
646
|
-
// src/commands/
|
|
647
|
-
import
|
|
592
|
+
// src/commands/init.ts
|
|
593
|
+
import pc9 from "picocolors";
|
|
648
594
|
|
|
649
|
-
// src/
|
|
650
|
-
import
|
|
595
|
+
// src/services/initLocal.ts
|
|
596
|
+
import os5 from "os";
|
|
597
|
+
import pc8 from "picocolors";
|
|
598
|
+
import prompts from "prompts";
|
|
599
|
+
|
|
600
|
+
// src/services/ensureAuth.ts
|
|
601
|
+
import pc4 from "picocolors";
|
|
651
602
|
|
|
652
|
-
// src/
|
|
653
|
-
import prompts2 from "prompts";
|
|
603
|
+
// src/utils/tty.ts
|
|
654
604
|
function isInteractive() {
|
|
655
605
|
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
656
606
|
}
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
const
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
const
|
|
666
|
-
if (
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
607
|
+
|
|
608
|
+
// src/services/ensureAuth.ts
|
|
609
|
+
async function validateBackendSession(params) {
|
|
610
|
+
const cfg = await resolveConfig({ yes: Boolean(params?.yes) });
|
|
611
|
+
const api = createApiClient(cfg);
|
|
612
|
+
await api.getMe();
|
|
613
|
+
}
|
|
614
|
+
async function ensureAuth(params) {
|
|
615
|
+
const envToken = process.env.COMERGE_ACCESS_TOKEN;
|
|
616
|
+
if (typeof envToken === "string" && envToken.trim().length > 0) return;
|
|
617
|
+
const existing = await getSession();
|
|
618
|
+
if (existing) {
|
|
619
|
+
try {
|
|
620
|
+
await validateBackendSession({ yes: params?.yes });
|
|
621
|
+
return;
|
|
622
|
+
} catch {
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
const interactive = isInteractive();
|
|
626
|
+
const yes = Boolean(params?.yes);
|
|
627
|
+
if (!interactive || yes) {
|
|
628
|
+
throw new CliError("Not signed in.", {
|
|
672
629
|
exitCode: 2,
|
|
673
|
-
hint: "
|
|
630
|
+
hint: "Run `comerge login`, or set COMERGE_ACCESS_TOKEN for CI."
|
|
674
631
|
});
|
|
675
632
|
}
|
|
676
|
-
const
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
633
|
+
const cfg = await resolveConfig({ yes: Boolean(params?.yes) });
|
|
634
|
+
const server = await startOAuthCallbackServer({ port: null });
|
|
635
|
+
try {
|
|
636
|
+
const supabase = createSupabaseAuthHelpers(cfg);
|
|
637
|
+
const { url } = await supabase.startGoogleLogin({ redirectTo: server.redirectTo });
|
|
638
|
+
try {
|
|
639
|
+
await openBrowser(url);
|
|
640
|
+
if (!params?.json) console.log(pc4.dim("Opened a browser for sign-in."));
|
|
641
|
+
} catch {
|
|
642
|
+
if (!params?.json) {
|
|
643
|
+
console.log(pc4.yellow("Could not open a browser automatically."));
|
|
644
|
+
console.log(`Open this URL in a browser:
|
|
645
|
+
${url}`);
|
|
688
646
|
}
|
|
689
647
|
}
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
648
|
+
const code = await server.waitForCode();
|
|
649
|
+
const session = await supabase.exchangeCode({ code });
|
|
650
|
+
await setSession(session);
|
|
651
|
+
try {
|
|
652
|
+
await validateBackendSession({ yes: params?.yes });
|
|
653
|
+
} catch (err) {
|
|
654
|
+
await clearSession().catch(() => {
|
|
655
|
+
});
|
|
656
|
+
throw err;
|
|
657
|
+
}
|
|
658
|
+
} finally {
|
|
659
|
+
await server.close();
|
|
694
660
|
}
|
|
695
|
-
cachedApiKey = apiKey;
|
|
696
|
-
return apiKey;
|
|
697
661
|
}
|
|
698
662
|
|
|
699
|
-
// src/
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
663
|
+
// src/services/importLocal.ts
|
|
664
|
+
import path6 from "path";
|
|
665
|
+
import pc5 from "picocolors";
|
|
666
|
+
|
|
667
|
+
// src/utils/projectDetect.ts
|
|
668
|
+
import fs3 from "fs/promises";
|
|
669
|
+
import path3 from "path";
|
|
670
|
+
async function fileExists(p) {
|
|
703
671
|
try {
|
|
704
|
-
|
|
705
|
-
return
|
|
672
|
+
const st = await fs3.stat(p);
|
|
673
|
+
return st.isFile();
|
|
706
674
|
} catch {
|
|
707
675
|
return false;
|
|
708
676
|
}
|
|
709
|
-
}, "Invalid URL").transform((s) => s.replace(/\/+$/, ""));
|
|
710
|
-
var configSchema = z.object({
|
|
711
|
-
apiUrl: urlSchema,
|
|
712
|
-
supabaseUrl: urlSchema,
|
|
713
|
-
supabaseAnonKey: z.string().min(1)
|
|
714
|
-
});
|
|
715
|
-
var studioConfigResponseSchema = z.object({
|
|
716
|
-
success: z.boolean(),
|
|
717
|
-
message: z.string(),
|
|
718
|
-
responseObject: z.object({
|
|
719
|
-
url: urlSchema,
|
|
720
|
-
anonKey: z.string().min(1)
|
|
721
|
-
}).optional(),
|
|
722
|
-
statusCode: z.number()
|
|
723
|
-
});
|
|
724
|
-
var cachedByApiKey = null;
|
|
725
|
-
var inFlight = null;
|
|
726
|
-
async function readJsonSafe(res) {
|
|
727
|
-
const ct = res.headers.get("content-type") ?? "";
|
|
728
|
-
if (!ct.toLowerCase().includes("application/json")) return null;
|
|
729
|
-
try {
|
|
730
|
-
return await res.json();
|
|
731
|
-
} catch {
|
|
732
|
-
return null;
|
|
733
|
-
}
|
|
734
677
|
}
|
|
735
|
-
async function
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
headers: {
|
|
746
|
-
Accept: "application/json",
|
|
747
|
-
[CLIENT_KEY_HEADER]: apiKey
|
|
678
|
+
async function findExpoProjectRoot(startDir) {
|
|
679
|
+
let cur = path3.resolve(startDir);
|
|
680
|
+
while (true) {
|
|
681
|
+
const pkg = path3.join(cur, "package.json");
|
|
682
|
+
if (await fileExists(pkg)) {
|
|
683
|
+
const hasAppJson = await fileExists(path3.join(cur, "app.json"));
|
|
684
|
+
const hasAppConfig = await fileExists(path3.join(cur, "app.config.js")) || await fileExists(path3.join(cur, "app.config.ts"));
|
|
685
|
+
if (!hasAppJson && !hasAppConfig) {
|
|
686
|
+
} else {
|
|
687
|
+
return cur;
|
|
748
688
|
}
|
|
749
|
-
});
|
|
750
|
-
const body = await readJsonSafe(res);
|
|
751
|
-
if (!res.ok) {
|
|
752
|
-
const msg = (body && typeof body === "object" && body && "message" in body && typeof body.message === "string" ? body.message : null) ?? `Failed to fetch studio config (${res.status})`;
|
|
753
|
-
throw new CliError(msg, {
|
|
754
|
-
exitCode: 1,
|
|
755
|
-
hint: body ? JSON.stringify(body, null, 2) : "Check your API Key and backend connectivity."
|
|
756
|
-
});
|
|
757
|
-
}
|
|
758
|
-
const parsedResp = studioConfigResponseSchema.safeParse(body);
|
|
759
|
-
if (!parsedResp.success || !parsedResp.data.success || !parsedResp.data.responseObject) {
|
|
760
|
-
throw new CliError("Invalid studio-config response", {
|
|
761
|
-
exitCode: 1,
|
|
762
|
-
hint: body ? JSON.stringify(body, null, 2) : null
|
|
763
|
-
});
|
|
764
|
-
}
|
|
765
|
-
const cfgRaw = {
|
|
766
|
-
apiUrl,
|
|
767
|
-
supabaseUrl: parsedResp.data.responseObject.url,
|
|
768
|
-
supabaseAnonKey: parsedResp.data.responseObject.anonKey
|
|
769
|
-
};
|
|
770
|
-
const parsedCfg = configSchema.safeParse(cfgRaw);
|
|
771
|
-
if (!parsedCfg.success) {
|
|
772
|
-
throw new CliError("Invalid CLI configuration", {
|
|
773
|
-
exitCode: 1,
|
|
774
|
-
hint: parsedCfg.error.issues.map((i) => `${i.path.join(".") || "config"}: ${i.message}`).join("; ")
|
|
775
|
-
});
|
|
776
689
|
}
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
690
|
+
const parent = path3.dirname(cur);
|
|
691
|
+
if (parent === cur) break;
|
|
692
|
+
cur = parent;
|
|
693
|
+
}
|
|
694
|
+
throw new CliError("Not inside an Expo project.", {
|
|
695
|
+
hint: "Run this command from the root of your Expo project.",
|
|
696
|
+
exitCode: 2
|
|
781
697
|
});
|
|
782
|
-
return inFlight;
|
|
783
698
|
}
|
|
784
699
|
|
|
785
|
-
// src/
|
|
786
|
-
import
|
|
787
|
-
async function
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
resolveCode = resolve;
|
|
794
|
-
rejectCode = reject;
|
|
795
|
-
});
|
|
796
|
-
const server = http.createServer((req, res) => {
|
|
797
|
-
try {
|
|
798
|
-
const base = `http://127.0.0.1`;
|
|
799
|
-
const u = new URL(req.url ?? "/", base);
|
|
800
|
-
const errorDescription = u.searchParams.get("error_description");
|
|
801
|
-
const error = u.searchParams.get("error");
|
|
802
|
-
const code = u.searchParams.get("code");
|
|
803
|
-
if (errorDescription || error) {
|
|
804
|
-
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
805
|
-
res.end(`<html><body><h3>Login failed</h3><p>${escapeHtml(errorDescription ?? error ?? "Unknown error")}</p></body></html>`);
|
|
806
|
-
rejectCode?.(new CliError("Login failed", { exitCode: 1, hint: errorDescription ?? error ?? void 0 }));
|
|
807
|
-
return;
|
|
808
|
-
}
|
|
809
|
-
if (!code) {
|
|
810
|
-
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
811
|
-
res.end(`<html><body><h3>Missing code</h3><p>No authorization code was provided.</p></body></html>`);
|
|
812
|
-
return;
|
|
813
|
-
}
|
|
814
|
-
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
815
|
-
res.end(
|
|
816
|
-
`<html><body><h3>Login complete</h3><p>You can close this tab and return to the terminal.</p></body></html>`
|
|
817
|
-
);
|
|
818
|
-
resolveCode?.(code);
|
|
819
|
-
} catch (err) {
|
|
820
|
-
rejectCode?.(err);
|
|
821
|
-
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
|
822
|
-
res.end("Internal error");
|
|
823
|
-
} finally {
|
|
824
|
-
try {
|
|
825
|
-
server.close();
|
|
826
|
-
} catch {
|
|
827
|
-
}
|
|
828
|
-
}
|
|
829
|
-
});
|
|
830
|
-
const listenPort = typeof requestedPort === "number" ? requestedPort : 0;
|
|
831
|
-
await new Promise((resolve, reject) => {
|
|
832
|
-
server.once("error", reject);
|
|
833
|
-
server.listen(listenPort, "127.0.0.1", () => resolve());
|
|
834
|
-
});
|
|
835
|
-
const addr = server.address();
|
|
836
|
-
if (!addr || typeof addr === "string") {
|
|
837
|
-
server.close();
|
|
838
|
-
throw new CliError("Failed to start callback server", { exitCode: 1 });
|
|
700
|
+
// src/utils/gitFiles.ts
|
|
701
|
+
import { execa as execa2 } from "execa";
|
|
702
|
+
async function canUseGit(cwd) {
|
|
703
|
+
try {
|
|
704
|
+
const res = await execa2("git", ["rev-parse", "--is-inside-work-tree"], { cwd, stdio: "ignore" });
|
|
705
|
+
return res.exitCode === 0;
|
|
706
|
+
} catch {
|
|
707
|
+
return false;
|
|
839
708
|
}
|
|
840
|
-
const redirectTo = `http://127.0.0.1:${addr.port}/callback`;
|
|
841
|
-
const timeout = setTimeout(() => {
|
|
842
|
-
rejectCode?.(
|
|
843
|
-
new CliError("Login timed out", {
|
|
844
|
-
exitCode: 1,
|
|
845
|
-
hint: "Try running `comerge login` again."
|
|
846
|
-
})
|
|
847
|
-
);
|
|
848
|
-
try {
|
|
849
|
-
server.close();
|
|
850
|
-
} catch {
|
|
851
|
-
}
|
|
852
|
-
}, timeoutMs).unref();
|
|
853
|
-
const waitForCode = async () => {
|
|
854
|
-
try {
|
|
855
|
-
return await codePromise;
|
|
856
|
-
} finally {
|
|
857
|
-
clearTimeout(timeout);
|
|
858
|
-
}
|
|
859
|
-
};
|
|
860
|
-
const close = async () => {
|
|
861
|
-
clearTimeout(timeout);
|
|
862
|
-
await new Promise((resolve) => server.close(() => resolve())).catch(() => {
|
|
863
|
-
});
|
|
864
|
-
};
|
|
865
|
-
return { redirectTo, waitForCode, close };
|
|
866
709
|
}
|
|
867
|
-
function
|
|
868
|
-
return
|
|
710
|
+
function parseGitLsFilesZ(buf) {
|
|
711
|
+
return buf.toString("utf8").split("\0").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
869
712
|
}
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
const platform = process.platform;
|
|
875
|
-
try {
|
|
876
|
-
if (platform === "darwin") {
|
|
877
|
-
await execa2("open", [url], { stdio: "ignore" });
|
|
878
|
-
return;
|
|
879
|
-
}
|
|
880
|
-
if (platform === "win32") {
|
|
881
|
-
await execa2("cmd", ["/c", "start", "", url], { stdio: "ignore", windowsHide: true });
|
|
882
|
-
return;
|
|
883
|
-
}
|
|
884
|
-
await execa2("xdg-open", [url], { stdio: "ignore" });
|
|
885
|
-
} catch (err) {
|
|
886
|
-
throw new CliError("Failed to open browser automatically", {
|
|
887
|
-
exitCode: 1,
|
|
888
|
-
hint: `Open this URL manually:
|
|
889
|
-
${url}
|
|
890
|
-
|
|
891
|
-
${err?.message ?? String(err)}`
|
|
892
|
-
});
|
|
713
|
+
async function listFilesGit(params) {
|
|
714
|
+
const args = ["ls-files", "-z", "--cached", "--others", "--exclude-standard"];
|
|
715
|
+
if (params.pathspec) {
|
|
716
|
+
args.push("--", params.pathspec);
|
|
893
717
|
}
|
|
718
|
+
const res = await execa2("git", args, { cwd: params.cwd, stdout: "pipe", stderr: "ignore" });
|
|
719
|
+
return parseGitLsFilesZ(Buffer.from(res.stdout ?? "", "utf8"));
|
|
894
720
|
}
|
|
895
721
|
|
|
896
|
-
// src/
|
|
897
|
-
import
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
722
|
+
// src/utils/fileSelection.ts
|
|
723
|
+
import fs4 from "fs/promises";
|
|
724
|
+
import path4 from "path";
|
|
725
|
+
import fg from "fast-glob";
|
|
726
|
+
import ignore from "ignore";
|
|
727
|
+
var BUILTIN_IGNORES = [
|
|
728
|
+
"**/node_modules/**",
|
|
729
|
+
"**/.git/**",
|
|
730
|
+
"**/.expo/**",
|
|
731
|
+
"**/dist/**",
|
|
732
|
+
"**/build/**",
|
|
733
|
+
"**/coverage/**",
|
|
734
|
+
"**/.turbo/**",
|
|
735
|
+
"**/.next/**",
|
|
736
|
+
"**/ios/build/**",
|
|
737
|
+
"**/android/build/**",
|
|
738
|
+
"**/.pnpm-store/**",
|
|
739
|
+
"**/comerge-shell*/**"
|
|
740
|
+
];
|
|
741
|
+
function normalizeRel(p) {
|
|
742
|
+
return p.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
743
|
+
}
|
|
744
|
+
async function readGitignore(root) {
|
|
745
|
+
const fp = path4.join(root, ".gitignore");
|
|
746
|
+
return await fs4.readFile(fp, "utf8").catch(() => "");
|
|
747
|
+
}
|
|
748
|
+
async function listFilesFs(params) {
|
|
749
|
+
const ig = ignore();
|
|
750
|
+
const raw = await readGitignore(params.root);
|
|
751
|
+
if (raw) ig.add(raw);
|
|
752
|
+
const entries = await fg(["**/*"], {
|
|
753
|
+
cwd: params.root,
|
|
754
|
+
onlyFiles: true,
|
|
755
|
+
dot: true,
|
|
756
|
+
followSymbolicLinks: false,
|
|
757
|
+
unique: true,
|
|
758
|
+
ignore: [...BUILTIN_IGNORES]
|
|
759
|
+
});
|
|
760
|
+
const filtered = entries.filter((p) => {
|
|
761
|
+
const rel = normalizeRel(p);
|
|
762
|
+
if (!params.includeDotenv && path4.posix.basename(rel).startsWith(".env")) return false;
|
|
763
|
+
return !ig.ignores(rel);
|
|
924
764
|
});
|
|
765
|
+
return filtered.map(normalizeRel);
|
|
925
766
|
}
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
767
|
+
|
|
768
|
+
// src/utils/zip.ts
|
|
769
|
+
import crypto from "crypto";
|
|
770
|
+
import fs5 from "fs";
|
|
771
|
+
import fsp from "fs/promises";
|
|
772
|
+
import os2 from "os";
|
|
773
|
+
import path5 from "path";
|
|
774
|
+
import archiver from "archiver";
|
|
775
|
+
var MAX_IMPORT_ZIP_BYTES = 50 * 1024 * 1024;
|
|
776
|
+
var MAX_IMPORT_FILE_COUNT = 25e3;
|
|
777
|
+
function normalizeRel2(p) {
|
|
778
|
+
return p.replace(/\\/g, "/").replace(/\/+/g, "/").replace(/^\/+/, "");
|
|
779
|
+
}
|
|
780
|
+
function validateRelPath(rel) {
|
|
781
|
+
const p = normalizeRel2(rel);
|
|
782
|
+
if (!p) throw new CliError("Invalid file path.", { exitCode: 1 });
|
|
783
|
+
if (p.startsWith("../") || p.includes("/../") || p === "..") {
|
|
784
|
+
throw new CliError("Refusing to add a path traversal entry.", { exitCode: 1, hint: p });
|
|
785
|
+
}
|
|
786
|
+
if (p.startsWith("/")) {
|
|
787
|
+
throw new CliError("Refusing to add an absolute path.", { exitCode: 1, hint: p });
|
|
788
|
+
}
|
|
789
|
+
return p;
|
|
790
|
+
}
|
|
791
|
+
async function computeStats(params) {
|
|
792
|
+
const largestN = params.largestN ?? 10;
|
|
793
|
+
if (params.files.length > MAX_IMPORT_FILE_COUNT) {
|
|
794
|
+
throw new CliError("Too many files to import.", {
|
|
795
|
+
exitCode: 2,
|
|
796
|
+
hint: `File count ${params.files.length} exceeds limit ${MAX_IMPORT_FILE_COUNT}. Use --path or add ignores.`
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
let totalBytes = 0;
|
|
800
|
+
const largest = [];
|
|
801
|
+
for (const rel0 of params.files) {
|
|
802
|
+
const rel = validateRelPath(rel0);
|
|
803
|
+
const abs = path5.join(params.root, rel);
|
|
804
|
+
const st = await fsp.stat(abs);
|
|
805
|
+
if (!st.isFile()) continue;
|
|
806
|
+
const bytes = st.size;
|
|
807
|
+
totalBytes += bytes;
|
|
808
|
+
if (largestN > 0) {
|
|
809
|
+
largest.push({ path: rel, bytes });
|
|
810
|
+
}
|
|
929
811
|
}
|
|
812
|
+
largest.sort((a, b) => b.bytes - a.bytes);
|
|
930
813
|
return {
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
token_type: session.token_type ?? void 0,
|
|
935
|
-
user: session.user ? { id: session.user.id, email: session.user.email ?? null } : void 0
|
|
814
|
+
fileCount: params.files.length,
|
|
815
|
+
totalBytes,
|
|
816
|
+
largestFiles: largest.slice(0, largestN)
|
|
936
817
|
};
|
|
937
818
|
}
|
|
938
|
-
function
|
|
939
|
-
const
|
|
940
|
-
const
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
return { url: data.url };
|
|
957
|
-
},
|
|
958
|
-
async exchangeCode(params) {
|
|
959
|
-
const { data, error } = await supabase.auth.exchangeCodeForSession(params.code);
|
|
960
|
-
if (error) throw error;
|
|
961
|
-
if (!data?.session) throw new CliError("No session returned from Supabase", { exitCode: 1 });
|
|
962
|
-
return toStoredSession(data.session);
|
|
963
|
-
},
|
|
964
|
-
async refreshWithStoredSession(params) {
|
|
965
|
-
const { data, error } = await supabase.auth.setSession({
|
|
966
|
-
access_token: params.session.access_token,
|
|
967
|
-
refresh_token: params.session.refresh_token
|
|
968
|
-
});
|
|
969
|
-
if (error) throw error;
|
|
970
|
-
if (!data?.session) throw new CliError("No session returned after refresh", { exitCode: 1 });
|
|
971
|
-
return toStoredSession(data.session);
|
|
819
|
+
async function createZip(params) {
|
|
820
|
+
const zipName = (params.zipName ?? "import.zip").replace(/[^a-zA-Z0-9._-]+/g, "_");
|
|
821
|
+
const tmpDir = await fsp.mkdtemp(path5.join(os2.tmpdir(), "comerge-import-"));
|
|
822
|
+
const zipPath = path5.join(tmpDir, zipName);
|
|
823
|
+
await new Promise((resolve, reject) => {
|
|
824
|
+
const output = fs5.createWriteStream(zipPath);
|
|
825
|
+
const archive = archiver("zip", { zlib: { level: 9 } });
|
|
826
|
+
output.on("close", () => resolve());
|
|
827
|
+
output.on("error", (err) => reject(err));
|
|
828
|
+
archive.on("warning", (err) => {
|
|
829
|
+
reject(err);
|
|
830
|
+
});
|
|
831
|
+
archive.on("error", (err) => reject(err));
|
|
832
|
+
archive.pipe(output);
|
|
833
|
+
for (const rel0 of params.files) {
|
|
834
|
+
const rel = validateRelPath(rel0);
|
|
835
|
+
const abs = path5.join(params.root, rel);
|
|
836
|
+
archive.file(abs, { name: rel });
|
|
972
837
|
}
|
|
973
|
-
|
|
838
|
+
void archive.finalize();
|
|
839
|
+
});
|
|
840
|
+
const st = await fsp.stat(zipPath);
|
|
841
|
+
if (st.size > MAX_IMPORT_ZIP_BYTES) {
|
|
842
|
+
throw new CliError("Archive exceeds the upload size limit.", {
|
|
843
|
+
exitCode: 2,
|
|
844
|
+
hint: `Zip size ${st.size} bytes exceeds limit ${MAX_IMPORT_ZIP_BYTES} bytes. Use --path or add ignores.`
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
return { zipPath };
|
|
848
|
+
}
|
|
849
|
+
async function sha256FileHex(filePath) {
|
|
850
|
+
const hash = crypto.createHash("sha256");
|
|
851
|
+
await new Promise((resolve, reject) => {
|
|
852
|
+
const s = fs5.createReadStream(filePath);
|
|
853
|
+
s.on("data", (chunk) => hash.update(chunk));
|
|
854
|
+
s.on("error", reject);
|
|
855
|
+
s.on("end", () => resolve());
|
|
856
|
+
});
|
|
857
|
+
return hash.digest("hex");
|
|
974
858
|
}
|
|
975
859
|
|
|
976
|
-
// src/
|
|
977
|
-
import
|
|
978
|
-
import
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
})
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
860
|
+
// src/clients/upload.ts
|
|
861
|
+
import fs6 from "fs";
|
|
862
|
+
import { PassThrough } from "stream";
|
|
863
|
+
async function uploadPresigned(params) {
|
|
864
|
+
const st = await fs6.promises.stat(params.filePath).catch(() => null);
|
|
865
|
+
if (!st || !st.isFile()) throw new CliError("Upload file not found.", { exitCode: 2 });
|
|
866
|
+
const totalBytes = st.size;
|
|
867
|
+
const fileStream = fs6.createReadStream(params.filePath);
|
|
868
|
+
const pass = new PassThrough();
|
|
869
|
+
let sent = 0;
|
|
870
|
+
fileStream.on("data", (chunk) => {
|
|
871
|
+
sent += chunk.length;
|
|
872
|
+
params.onProgress?.({ sentBytes: sent, totalBytes });
|
|
873
|
+
});
|
|
874
|
+
fileStream.on("error", (err) => pass.destroy(err));
|
|
875
|
+
fileStream.pipe(pass);
|
|
876
|
+
const res = await fetch(params.uploadUrl, {
|
|
877
|
+
method: "PUT",
|
|
878
|
+
headers: params.headers,
|
|
879
|
+
body: pass,
|
|
880
|
+
duplex: "half"
|
|
881
|
+
});
|
|
882
|
+
if (!res.ok) {
|
|
883
|
+
const text = await res.text().catch(() => "");
|
|
884
|
+
throw new CliError("Upload failed.", {
|
|
885
|
+
exitCode: 1,
|
|
886
|
+
hint: `Status: ${res.status}
|
|
887
|
+
${text}`.trim() || null
|
|
888
|
+
});
|
|
889
|
+
}
|
|
997
890
|
}
|
|
998
|
-
|
|
999
|
-
|
|
891
|
+
|
|
892
|
+
// src/services/importLocal.ts
|
|
893
|
+
var SAFE_PATH_RE = /^[A-Za-z0-9._/-]+$/;
|
|
894
|
+
var COMERGE_SHELL_SEGMENT_RE = /(^|\/)comerge-shell[^/]*(\/|$)/;
|
|
895
|
+
function sleep(ms) {
|
|
896
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
1000
897
|
}
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
898
|
+
function formatBytes(bytes) {
|
|
899
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
900
|
+
let v = bytes;
|
|
901
|
+
let i = 0;
|
|
902
|
+
while (v >= 1024 && i < units.length - 1) {
|
|
903
|
+
v /= 1024;
|
|
904
|
+
i++;
|
|
905
|
+
}
|
|
906
|
+
return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
|
907
|
+
}
|
|
908
|
+
function deriveAppName(projectRoot) {
|
|
909
|
+
const base = path6.basename(projectRoot);
|
|
910
|
+
return base || "Imported App";
|
|
911
|
+
}
|
|
912
|
+
function validateOptionalSubdir(subdir) {
|
|
913
|
+
if (!subdir) return null;
|
|
914
|
+
const s = subdir.trim().replace(/^\/+/, "").replace(/\\/g, "/");
|
|
915
|
+
if (!s) return null;
|
|
916
|
+
if (!SAFE_PATH_RE.test(s) || s.includes("..")) {
|
|
917
|
+
throw new CliError("Invalid --path value.", {
|
|
918
|
+
exitCode: 2,
|
|
919
|
+
hint: "Use a safe subdirectory such as packages/mobile-app (letters, numbers, ., _, -, / only)."
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
return s;
|
|
923
|
+
}
|
|
924
|
+
function getAppStatus(appResp) {
|
|
925
|
+
const obj = appResp?.responseObject;
|
|
926
|
+
const status = typeof obj?.status === "string" ? obj.status : null;
|
|
927
|
+
const statusError = typeof obj?.statusError === "string" ? obj.statusError : null;
|
|
928
|
+
return { status, statusError };
|
|
929
|
+
}
|
|
930
|
+
async function importLocal(params) {
|
|
931
|
+
const noWait = Boolean(params.noWait);
|
|
932
|
+
const maxWaitMs = 1200 * 1e3;
|
|
933
|
+
const apiKey = String(params.apiKey || "").trim();
|
|
934
|
+
if (!apiKey && !params.dryRun) {
|
|
935
|
+
throw new CliError("Missing client key.", { exitCode: 2, hint: "Sign in and rerun `comerge init`." });
|
|
936
|
+
}
|
|
937
|
+
const cfg = await resolveConfig({ yes: Boolean(params.yes) });
|
|
938
|
+
const api = createApiClient(cfg, { apiKey });
|
|
939
|
+
const projectRoot = await findExpoProjectRoot(process.cwd());
|
|
940
|
+
const subdir = validateOptionalSubdir(params.subdir);
|
|
941
|
+
const importRoot = subdir ? path6.join(projectRoot, subdir) : projectRoot;
|
|
942
|
+
const appName = (params.appName ?? "").trim() || deriveAppName(projectRoot);
|
|
943
|
+
const log = (msg) => {
|
|
944
|
+
if (params.json) return;
|
|
945
|
+
console.log(msg);
|
|
946
|
+
};
|
|
947
|
+
log(pc5.dim(`Project: ${projectRoot}`));
|
|
948
|
+
if (subdir) log(pc5.dim(`Import path: ${subdir}`));
|
|
949
|
+
let files = [];
|
|
950
|
+
const useGit = await canUseGit(projectRoot);
|
|
951
|
+
if (useGit) {
|
|
952
|
+
const gitPaths = await listFilesGit({ cwd: projectRoot, pathspec: subdir ?? null });
|
|
953
|
+
files = gitPaths.filter((p) => !COMERGE_SHELL_SEGMENT_RE.test(p.replace(/\\/g, "/")));
|
|
954
|
+
if (!params.includeDotenv) {
|
|
955
|
+
files = files.filter((p) => !path6.posix.basename(p).startsWith(".env"));
|
|
956
|
+
}
|
|
957
|
+
} else {
|
|
958
|
+
files = await listFilesFs({ root: importRoot, includeDotenv: params.includeDotenv });
|
|
959
|
+
if (subdir) {
|
|
960
|
+
files = files.map((p) => path6.posix.join(subdir, p));
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
if (files.length === 0) {
|
|
964
|
+
throw new CliError("No files found to upload.", {
|
|
965
|
+
exitCode: 2,
|
|
966
|
+
hint: "Check .gitignore or try without --path."
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
const stats = await computeStats({ root: projectRoot, files, largestN: 10 });
|
|
970
|
+
log(pc5.dim(`Files: ${stats.fileCount} Total: ${formatBytes(stats.totalBytes)}`));
|
|
971
|
+
if (params.dryRun) {
|
|
972
|
+
const top = stats.largestFiles.slice(0, 10);
|
|
973
|
+
if (top.length > 0) {
|
|
974
|
+
log(pc5.dim("Largest files:"));
|
|
975
|
+
for (const f of top) {
|
|
976
|
+
log(pc5.dim(` ${formatBytes(f.bytes)} ${f.path}`));
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
return { uploadId: "dry-run", appId: "dry-run", status: "dry-run" };
|
|
980
|
+
}
|
|
981
|
+
log(pc5.dim("Creating archive..."));
|
|
982
|
+
const { zipPath } = await createZip({ root: projectRoot, files, zipName: "import.zip" });
|
|
983
|
+
const zipSha = await sha256FileHex(zipPath);
|
|
984
|
+
const zipSize = (await (await import("fs/promises")).stat(zipPath)).size;
|
|
985
|
+
log(pc5.dim("Requesting upload URL..."));
|
|
986
|
+
const presignResp = await api.presignImportUpload({
|
|
987
|
+
file: {
|
|
988
|
+
name: "import.zip",
|
|
989
|
+
mimeType: "application/zip",
|
|
990
|
+
size: zipSize,
|
|
991
|
+
checksumSha256: zipSha
|
|
992
|
+
}
|
|
993
|
+
});
|
|
994
|
+
const presign = presignResp?.responseObject;
|
|
995
|
+
const uploadId = String(presign?.uploadId ?? "");
|
|
996
|
+
const uploadUrl = String(presign?.uploadUrl ?? "");
|
|
997
|
+
const headers = presign?.headers ?? {};
|
|
998
|
+
if (!uploadId || !uploadUrl) {
|
|
999
|
+
throw new CliError("Upload URL response is missing required fields.", {
|
|
1000
|
+
exitCode: 1,
|
|
1001
|
+
hint: JSON.stringify(presignResp, null, 2)
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
log(pc5.dim("Uploading archive..."));
|
|
1005
|
+
let lastPct = -1;
|
|
1006
|
+
await uploadPresigned({
|
|
1007
|
+
uploadUrl,
|
|
1008
|
+
headers,
|
|
1009
|
+
filePath: zipPath,
|
|
1010
|
+
onProgress: ({ sentBytes, totalBytes }) => {
|
|
1011
|
+
if (params.json) return;
|
|
1012
|
+
const pct = totalBytes > 0 ? Math.floor(sentBytes / totalBytes * 100) : 0;
|
|
1013
|
+
if (pct !== lastPct && (pct % 5 === 0 || pct === 100)) {
|
|
1014
|
+
lastPct = pct;
|
|
1015
|
+
process.stdout.write(pc5.dim(`Upload ${pct}%\r`));
|
|
1008
1016
|
}
|
|
1009
1017
|
}
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1018
|
+
});
|
|
1019
|
+
if (!params.json) process.stdout.write("\n");
|
|
1020
|
+
log(pc5.dim("Starting import..."));
|
|
1021
|
+
const importResp = await api.importFromUpload({
|
|
1022
|
+
uploadId,
|
|
1023
|
+
appName,
|
|
1024
|
+
path: subdir ?? void 0
|
|
1025
|
+
});
|
|
1026
|
+
const importObj = importResp?.responseObject;
|
|
1027
|
+
const appId = String(importObj?.appId ?? "");
|
|
1028
|
+
const projectId = importObj?.projectId ? String(importObj.projectId) : void 0;
|
|
1029
|
+
const threadId = importObj?.threadId ? String(importObj.threadId) : void 0;
|
|
1030
|
+
if (!appId) {
|
|
1031
|
+
throw new CliError("Import response is missing the app ID.", {
|
|
1032
|
+
exitCode: 1,
|
|
1033
|
+
hint: JSON.stringify(importResp, null, 2)
|
|
1034
|
+
});
|
|
1021
1035
|
}
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
} catch {
|
|
1036
|
+
if (noWait) {
|
|
1037
|
+
return { uploadId, appId, projectId, threadId, status: "accepted" };
|
|
1025
1038
|
}
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
} catch {
|
|
1037
|
-
return null;
|
|
1039
|
+
log(pc5.dim("Waiting for import to finish..."));
|
|
1040
|
+
const startedAt = Date.now();
|
|
1041
|
+
let delay = 2e3;
|
|
1042
|
+
let lastStatus = null;
|
|
1043
|
+
while (Date.now() - startedAt < maxWaitMs) {
|
|
1044
|
+
const appResp = await api.getApp(appId);
|
|
1045
|
+
const { status, statusError } = getAppStatus(appResp);
|
|
1046
|
+
if (status && status !== lastStatus) {
|
|
1047
|
+
lastStatus = status;
|
|
1048
|
+
log(pc5.dim(`Status: ${status}`));
|
|
1038
1049
|
}
|
|
1050
|
+
if (status === "ready") {
|
|
1051
|
+
return { uploadId, appId, projectId, threadId, status };
|
|
1052
|
+
}
|
|
1053
|
+
if (status === "error") {
|
|
1054
|
+
throw new CliError("Import failed.", { exitCode: 1, hint: statusError ?? "App status is error." });
|
|
1055
|
+
}
|
|
1056
|
+
await sleep(delay);
|
|
1057
|
+
delay = Math.min(1e4, Math.floor(delay * 1.4));
|
|
1039
1058
|
}
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
try {
|
|
1044
|
-
const parsed = storedSessionSchema.safeParse(JSON.parse(raw));
|
|
1045
|
-
if (!parsed.success) return null;
|
|
1046
|
-
await ensurePathPermissions(fp);
|
|
1047
|
-
return parsed.data;
|
|
1048
|
-
} catch {
|
|
1049
|
-
return null;
|
|
1050
|
-
}
|
|
1051
|
-
}
|
|
1052
|
-
async function setSession(session) {
|
|
1053
|
-
const parsed = storedSessionSchema.safeParse(session);
|
|
1054
|
-
if (!parsed.success) {
|
|
1055
|
-
throw new CliError("Refusing to store invalid session", { exitCode: 1 });
|
|
1056
|
-
}
|
|
1057
|
-
const keytar = await maybeLoadKeytar();
|
|
1058
|
-
if (keytar) {
|
|
1059
|
-
await keytar.setPassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT, JSON.stringify(parsed.data));
|
|
1060
|
-
return;
|
|
1061
|
-
}
|
|
1062
|
-
const fp = sessionFilePath();
|
|
1063
|
-
await writeJsonAtomic(fp, parsed.data);
|
|
1064
|
-
await ensurePathPermissions(fp);
|
|
1065
|
-
}
|
|
1066
|
-
async function clearSession() {
|
|
1067
|
-
const keytar = await maybeLoadKeytar();
|
|
1068
|
-
if (keytar) {
|
|
1069
|
-
await keytar.deletePassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT);
|
|
1070
|
-
return;
|
|
1071
|
-
}
|
|
1072
|
-
const fp = sessionFilePath();
|
|
1073
|
-
await fs8.rm(fp, { force: true }).catch(() => {
|
|
1059
|
+
throw new CliError("Timed out waiting for import.", {
|
|
1060
|
+
exitCode: 1,
|
|
1061
|
+
hint: "Check app status with: comerge import local --no-wait"
|
|
1074
1062
|
});
|
|
1075
1063
|
}
|
|
1076
1064
|
|
|
1077
|
-
// src/
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1065
|
+
// src/commands/shellInit.ts
|
|
1066
|
+
import path10 from "path";
|
|
1067
|
+
import os3 from "os";
|
|
1068
|
+
import pc7 from "picocolors";
|
|
1069
|
+
import fse4 from "fs-extra";
|
|
1070
|
+
|
|
1071
|
+
// src/utils/packageJson.ts
|
|
1072
|
+
import fs7 from "fs/promises";
|
|
1073
|
+
async function readPackageJson(projectRoot) {
|
|
1074
|
+
const raw = await fs7.readFile(`${projectRoot}/package.json`, "utf8").catch(() => null);
|
|
1075
|
+
if (!raw) {
|
|
1076
|
+
throw new CliError("package.json not found.", { exitCode: 2 });
|
|
1077
|
+
}
|
|
1085
1078
|
try {
|
|
1086
|
-
return
|
|
1079
|
+
return JSON.parse(raw);
|
|
1087
1080
|
} catch {
|
|
1088
|
-
|
|
1081
|
+
throw new CliError("Failed to parse package.json.", { exitCode: 2 });
|
|
1089
1082
|
}
|
|
1090
1083
|
}
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1084
|
+
var STUDIO_PEERS = [
|
|
1085
|
+
"@callstack/liquid-glass",
|
|
1086
|
+
"@supabase/supabase-js",
|
|
1087
|
+
"@gorhom/bottom-sheet",
|
|
1088
|
+
"expo-file-system",
|
|
1089
|
+
"expo-haptics",
|
|
1090
|
+
"expo-linear-gradient",
|
|
1091
|
+
"lucide-react-native",
|
|
1092
|
+
"react-native-gesture-handler",
|
|
1093
|
+
"react-native-reanimated",
|
|
1094
|
+
"react-native-safe-area-context",
|
|
1095
|
+
"react-native-svg",
|
|
1096
|
+
"react-native-view-shot"
|
|
1097
|
+
];
|
|
1098
|
+
function buildShellPackageJson(params) {
|
|
1099
|
+
const orig = params.original;
|
|
1100
|
+
const warnings = [];
|
|
1101
|
+
const info = [];
|
|
1102
|
+
const dependencies = { ...orig.dependencies ?? {} };
|
|
1103
|
+
const devDependencies = { ...orig.devDependencies ?? {} };
|
|
1104
|
+
const main2 = "expo-router/entry";
|
|
1105
|
+
dependencies["@comergehq/studio"] = "latest";
|
|
1106
|
+
dependencies["@comergehq/runtime"] = "latest";
|
|
1107
|
+
if (!dependencies["expo-router"] && !devDependencies["expo-router"]) {
|
|
1108
|
+
dependencies["expo-router"] = "latest";
|
|
1109
|
+
warnings.push("Added missing dependency expo-router@latest.");
|
|
1111
1110
|
}
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
const apiKey = (opts?.apiKey ?? "").trim();
|
|
1117
|
-
const CLIENT_KEY_HEADER2 = "x-comerge-api-key";
|
|
1118
|
-
async function request(path10, init) {
|
|
1119
|
-
const { token, session, fromEnv } = await getAuthToken(cfg);
|
|
1120
|
-
const url = new URL(path10, cfg.apiUrl).toString();
|
|
1121
|
-
const doFetch = async (bearer) => {
|
|
1122
|
-
const res2 = await fetch(url, {
|
|
1123
|
-
...init,
|
|
1124
|
-
headers: {
|
|
1125
|
-
Accept: "application/json",
|
|
1126
|
-
"Content-Type": "application/json",
|
|
1127
|
-
...init?.headers ?? {},
|
|
1128
|
-
Authorization: `Bearer ${bearer}`,
|
|
1129
|
-
...apiKey ? { [CLIENT_KEY_HEADER2]: apiKey } : {}
|
|
1130
|
-
}
|
|
1131
|
-
});
|
|
1132
|
-
return res2;
|
|
1133
|
-
};
|
|
1134
|
-
let res = await doFetch(token);
|
|
1135
|
-
if (res.status === 401 && !fromEnv && session?.refresh_token) {
|
|
1136
|
-
const supabase = createSupabaseAuthHelpers(cfg);
|
|
1137
|
-
const refreshed = await supabase.refreshWithStoredSession({ session });
|
|
1138
|
-
await setSession(refreshed);
|
|
1139
|
-
res = await doFetch(refreshed.access_token);
|
|
1140
|
-
}
|
|
1141
|
-
if (!res.ok) {
|
|
1142
|
-
const body = await readJsonSafe2(res);
|
|
1143
|
-
const msg = (body && typeof body === "object" && body && "message" in body && typeof body.message === "string" ? body.message : null) ?? `Request failed (${res.status})`;
|
|
1144
|
-
throw new CliError(msg, { exitCode: 1, hint: body ? JSON.stringify(body, null, 2) : null });
|
|
1145
|
-
}
|
|
1146
|
-
const json = await readJsonSafe2(res);
|
|
1147
|
-
return json ?? null;
|
|
1111
|
+
for (const dep of STUDIO_PEERS) {
|
|
1112
|
+
if (dependencies[dep] || devDependencies[dep]) continue;
|
|
1113
|
+
dependencies[dep] = "latest";
|
|
1114
|
+
info.push(`Added missing peer dependency ${dep}@latest.`);
|
|
1148
1115
|
}
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1116
|
+
const pkg = {
|
|
1117
|
+
...orig,
|
|
1118
|
+
name: orig.name ? `${orig.name}-comerge-shell` : "comerge-shell",
|
|
1119
|
+
private: true,
|
|
1120
|
+
main: main2,
|
|
1121
|
+
scripts: {
|
|
1122
|
+
dev: "expo start -c",
|
|
1123
|
+
ios: "expo start -c --ios",
|
|
1124
|
+
android: "expo start -c --android",
|
|
1125
|
+
web: "expo start -c --web",
|
|
1126
|
+
...orig.scripts ?? {}
|
|
1127
|
+
},
|
|
1128
|
+
dependencies,
|
|
1129
|
+
devDependencies
|
|
1154
1130
|
};
|
|
1131
|
+
return { pkg, warnings, info };
|
|
1155
1132
|
}
|
|
1156
1133
|
|
|
1157
|
-
// src/
|
|
1158
|
-
function
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
if (!Number.isFinite(parsed) || parsed <= 0 || !Number.isInteger(parsed)) {
|
|
1169
|
-
throw new CliError("Invalid --port", { exitCode: 2, hint: "Example: --port 8085" });
|
|
1170
|
-
}
|
|
1171
|
-
port = parsed;
|
|
1172
|
-
}
|
|
1173
|
-
const server = await startOAuthCallbackServer({ port });
|
|
1174
|
-
try {
|
|
1175
|
-
const supabase = createSupabaseAuthHelpers(config);
|
|
1176
|
-
const { url } = await supabase.startGoogleLogin({ redirectTo: server.redirectTo });
|
|
1177
|
-
const shouldOpenBrowser = Boolean(opts.browser ?? true);
|
|
1178
|
-
if (shouldOpenBrowser) {
|
|
1179
|
-
try {
|
|
1180
|
-
await openBrowser(url);
|
|
1181
|
-
console.log(pc2.dim("Opened browser for login..."));
|
|
1182
|
-
} catch (err) {
|
|
1183
|
-
console.error(pc2.yellow("Could not open the browser automatically."));
|
|
1184
|
-
console.error(pc2.dim(err?.message ?? String(err)));
|
|
1185
|
-
console.log(`Open this URL manually:
|
|
1186
|
-
${url}`);
|
|
1187
|
-
}
|
|
1188
|
-
} else {
|
|
1189
|
-
console.log(`Open this URL to login:
|
|
1190
|
-
${url}`);
|
|
1191
|
-
}
|
|
1192
|
-
const code = await server.waitForCode();
|
|
1193
|
-
const session = await supabase.exchangeCode({ code });
|
|
1194
|
-
await setSession(session);
|
|
1195
|
-
let me = null;
|
|
1196
|
-
try {
|
|
1197
|
-
const api = createApiClient(config);
|
|
1198
|
-
me = await api.getMe();
|
|
1199
|
-
} catch (err) {
|
|
1200
|
-
await clearSession().catch(() => {
|
|
1201
|
-
});
|
|
1202
|
-
throw err;
|
|
1203
|
-
}
|
|
1204
|
-
if (opts.json) {
|
|
1205
|
-
console.log(JSON.stringify({ success: true, me }, null, 2));
|
|
1206
|
-
} else {
|
|
1207
|
-
const profile = me?.responseObject;
|
|
1208
|
-
const label = profile?.email ? `${profile.email}` : profile?.id ? `user ${profile.id}` : "user";
|
|
1209
|
-
console.log(pc2.green(`Logged in as ${label}.`));
|
|
1210
|
-
}
|
|
1211
|
-
} finally {
|
|
1212
|
-
await server.close();
|
|
1213
|
-
}
|
|
1214
|
-
});
|
|
1134
|
+
// src/utils/templates.ts
|
|
1135
|
+
function shellLayoutTsx() {
|
|
1136
|
+
return `import { Stack } from 'expo-router';
|
|
1137
|
+
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
|
1138
|
+
|
|
1139
|
+
export default function Layout() {
|
|
1140
|
+
return (
|
|
1141
|
+
<GestureHandlerRootView style={{ flex: 1 }}>
|
|
1142
|
+
<Stack screenOptions={{ headerShown: false }} />
|
|
1143
|
+
</GestureHandlerRootView>
|
|
1144
|
+
);
|
|
1215
1145
|
}
|
|
1216
|
-
|
|
1217
|
-
// src/commands/logout.ts
|
|
1218
|
-
import pc3 from "picocolors";
|
|
1219
|
-
function registerLogoutCommand(program) {
|
|
1220
|
-
program.command("logout").description("Clear local Comerge authentication session").option("--json", "Output machine-readable JSON", false).action(async (opts) => {
|
|
1221
|
-
await clearSession();
|
|
1222
|
-
const envToken = process.env.COMERGE_ACCESS_TOKEN;
|
|
1223
|
-
if (opts.json) {
|
|
1224
|
-
console.log(JSON.stringify({ success: true, cleared: true, envTokenPresent: Boolean(envToken) }, null, 2));
|
|
1225
|
-
return;
|
|
1226
|
-
}
|
|
1227
|
-
console.log(pc3.green("Logged out (local session cleared)."));
|
|
1228
|
-
if (envToken) {
|
|
1229
|
-
console.log(pc3.dim("Note: COMERGE_ACCESS_TOKEN is set in your environment; it will still authenticate requests."));
|
|
1230
|
-
}
|
|
1231
|
-
});
|
|
1146
|
+
`;
|
|
1232
1147
|
}
|
|
1148
|
+
function shellIndexTsx() {
|
|
1149
|
+
return `import * as React from 'react';
|
|
1150
|
+
import { View } from 'react-native';
|
|
1151
|
+
import { Stack } from 'expo-router';
|
|
1152
|
+
import { ComergeStudio } from '@comergehq/studio';
|
|
1233
1153
|
|
|
1234
|
-
//
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1154
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
1155
|
+
// @ts-ignore
|
|
1156
|
+
import config from '../comerge.config.json';
|
|
1157
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
1158
|
+
// @ts-ignore
|
|
1159
|
+
import embeddedMeta from '../assets/comerge/base.meta.json';
|
|
1160
|
+
|
|
1161
|
+
export default function Index() {
|
|
1162
|
+
const appId = String((config as any)?.appId || '');
|
|
1163
|
+
const appKey = String((config as any)?.appKey || 'MicroMain');
|
|
1164
|
+
const apiKey = String((config as any)?.apiKey || '');
|
|
1165
|
+
const embeddedBaseBundles = {
|
|
1166
|
+
ios: { module: require('../assets/comerge/base.ios.jsbundle'), meta: (embeddedMeta as any)?.ios },
|
|
1167
|
+
android: { module: require('../assets/comerge/base.android.jsbundle'), meta: (embeddedMeta as any)?.android },
|
|
1168
|
+
};
|
|
1169
|
+
return (
|
|
1170
|
+
<>
|
|
1171
|
+
<Stack.Screen options={{ headerShown: false }} />
|
|
1172
|
+
<View style={{ flex: 1 }}>
|
|
1173
|
+
{appId ? (
|
|
1174
|
+
<ComergeStudio appId={appId} apiKey={apiKey} appKey={appKey} embeddedBaseBundles={embeddedBaseBundles} />
|
|
1175
|
+
) : null}
|
|
1176
|
+
</View>
|
|
1177
|
+
</>
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
`;
|
|
1181
|
+
}
|
|
1182
|
+
function shellBabelConfigJs() {
|
|
1183
|
+
return `module.exports = function (api) {
|
|
1184
|
+
api.cache(true);
|
|
1185
|
+
return {
|
|
1186
|
+
presets: ['babel-preset-expo'],
|
|
1187
|
+
plugins: ['react-native-reanimated/plugin'],
|
|
1188
|
+
};
|
|
1189
|
+
};
|
|
1190
|
+
`;
|
|
1252
1191
|
}
|
|
1192
|
+
function shellMetroConfigJs() {
|
|
1193
|
+
return `const { getDefaultConfig } = require('expo/metro-config');
|
|
1253
1194
|
|
|
1254
|
-
|
|
1255
|
-
|
|
1195
|
+
const config = getDefaultConfig(__dirname);
|
|
1196
|
+
const resolver = config.resolver || {};
|
|
1197
|
+
const assetExts = resolver.assetExts || [];
|
|
1198
|
+
if (!assetExts.includes('jsbundle')) {
|
|
1199
|
+
resolver.assetExts = [...assetExts, 'jsbundle'];
|
|
1200
|
+
}
|
|
1201
|
+
config.resolver = resolver;
|
|
1256
1202
|
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1203
|
+
module.exports = config;
|
|
1204
|
+
`;
|
|
1205
|
+
}
|
|
1260
1206
|
|
|
1261
|
-
// src/
|
|
1262
|
-
import
|
|
1263
|
-
async function
|
|
1207
|
+
// src/utils/packageManager.ts
|
|
1208
|
+
import fs8 from "fs/promises";
|
|
1209
|
+
async function exists(p) {
|
|
1264
1210
|
try {
|
|
1265
|
-
|
|
1266
|
-
return
|
|
1211
|
+
await fs8.stat(p);
|
|
1212
|
+
return true;
|
|
1267
1213
|
} catch {
|
|
1268
1214
|
return false;
|
|
1269
1215
|
}
|
|
1270
1216
|
}
|
|
1271
|
-
function
|
|
1272
|
-
|
|
1217
|
+
async function detectPackageManager(projectRoot) {
|
|
1218
|
+
if (await exists(`${projectRoot}/pnpm-lock.yaml`)) return "pnpm";
|
|
1219
|
+
if (await exists(`${projectRoot}/yarn.lock`)) return "yarn";
|
|
1220
|
+
if (await exists(`${projectRoot}/package-lock.json`)) return "npm";
|
|
1221
|
+
if (await exists(`${projectRoot}/bun.lockb`)) return "bun";
|
|
1222
|
+
return "npm";
|
|
1273
1223
|
}
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1224
|
+
function installCommand(pm) {
|
|
1225
|
+
switch (pm) {
|
|
1226
|
+
case "pnpm":
|
|
1227
|
+
return { cmd: "pnpm", args: ["install"] };
|
|
1228
|
+
case "yarn":
|
|
1229
|
+
return { cmd: "yarn", args: ["install"] };
|
|
1230
|
+
case "bun":
|
|
1231
|
+
return { cmd: "bun", args: ["install"] };
|
|
1232
|
+
case "npm":
|
|
1233
|
+
default:
|
|
1234
|
+
return { cmd: "npm", args: ["install"] };
|
|
1278
1235
|
}
|
|
1279
|
-
const res = await execa3("git", args, { cwd: params.cwd, stdout: "pipe", stderr: "ignore" });
|
|
1280
|
-
return parseGitLsFilesZ(Buffer.from(res.stdout ?? "", "utf8"));
|
|
1281
1236
|
}
|
|
1282
1237
|
|
|
1283
|
-
// src/
|
|
1284
|
-
import
|
|
1238
|
+
// src/commands/shellInit.ts
|
|
1239
|
+
import { execa as execa3 } from "execa";
|
|
1240
|
+
|
|
1241
|
+
// src/utils/copyProject.ts
|
|
1285
1242
|
import path7 from "path";
|
|
1286
|
-
import
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
"
|
|
1290
|
-
"
|
|
1291
|
-
"
|
|
1292
|
-
"
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
"
|
|
1296
|
-
"**/.next/**",
|
|
1297
|
-
"**/ios/build/**",
|
|
1298
|
-
"**/android/build/**",
|
|
1299
|
-
"**/.pnpm-store/**",
|
|
1300
|
-
"**/comerge-shell*/**"
|
|
1301
|
-
];
|
|
1302
|
-
function normalizeRel2(p) {
|
|
1303
|
-
return p.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
1243
|
+
import fse2 from "fs-extra";
|
|
1244
|
+
var ALWAYS_EXCLUDE_DIRS = /* @__PURE__ */ new Set([
|
|
1245
|
+
"node_modules",
|
|
1246
|
+
".git",
|
|
1247
|
+
".expo",
|
|
1248
|
+
"dist",
|
|
1249
|
+
"build"
|
|
1250
|
+
]);
|
|
1251
|
+
function normalizeRel3(relPath) {
|
|
1252
|
+
return relPath.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
1304
1253
|
}
|
|
1305
|
-
|
|
1306
|
-
const
|
|
1307
|
-
|
|
1254
|
+
function shouldExclude(relPath) {
|
|
1255
|
+
const rel = normalizeRel3(relPath);
|
|
1256
|
+
const parts = rel.split("/").filter(Boolean);
|
|
1257
|
+
const top = parts[0] ?? "";
|
|
1258
|
+
if (!top) return false;
|
|
1259
|
+
if (ALWAYS_EXCLUDE_DIRS.has(top)) return true;
|
|
1260
|
+
if (top.startsWith("comerge-shell")) return true;
|
|
1261
|
+
if (rel.startsWith("ios/build/") || rel === "ios/build") return true;
|
|
1262
|
+
if (rel.startsWith("android/build/") || rel === "android/build") return true;
|
|
1263
|
+
return false;
|
|
1308
1264
|
}
|
|
1309
|
-
async function
|
|
1310
|
-
const
|
|
1311
|
-
const
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
return !ig.ignores(rel);
|
|
1265
|
+
async function copyProject(params) {
|
|
1266
|
+
const src = path7.resolve(params.projectRoot);
|
|
1267
|
+
const dest = path7.resolve(params.outRoot);
|
|
1268
|
+
const srcExists = await fse2.pathExists(src);
|
|
1269
|
+
if (!srcExists) {
|
|
1270
|
+
throw new CliError("Project root not found.", { exitCode: 2 });
|
|
1271
|
+
}
|
|
1272
|
+
await fse2.copy(src, dest, {
|
|
1273
|
+
dereference: true,
|
|
1274
|
+
preserveTimestamps: true,
|
|
1275
|
+
filter: (srcPath) => {
|
|
1276
|
+
const rel = path7.relative(src, srcPath);
|
|
1277
|
+
if (!rel) return true;
|
|
1278
|
+
return !shouldExclude(rel);
|
|
1279
|
+
}
|
|
1325
1280
|
});
|
|
1326
|
-
return filtered.map(normalizeRel2);
|
|
1327
1281
|
}
|
|
1328
1282
|
|
|
1329
|
-
// src/
|
|
1330
|
-
import crypto from "crypto";
|
|
1331
|
-
import fs10 from "fs";
|
|
1332
|
-
import fsp from "fs/promises";
|
|
1333
|
-
import os3 from "os";
|
|
1283
|
+
// src/utils/stripProject.ts
|
|
1334
1284
|
import path8 from "path";
|
|
1335
|
-
import
|
|
1336
|
-
var
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1285
|
+
import fse3 from "fs-extra";
|
|
1286
|
+
var DEFAULT_STRIP_DIRS = ["app", "src", "components", "lib", "hooks", "providers"];
|
|
1287
|
+
async function stripProject(params) {
|
|
1288
|
+
const dirs = params.dirs ?? DEFAULT_STRIP_DIRS;
|
|
1289
|
+
await Promise.all(
|
|
1290
|
+
dirs.map(async (d) => {
|
|
1291
|
+
const p = path8.join(params.outRoot, d);
|
|
1292
|
+
const exists2 = await fse3.pathExists(p);
|
|
1293
|
+
if (!exists2) return;
|
|
1294
|
+
await fse3.remove(p);
|
|
1295
|
+
})
|
|
1296
|
+
);
|
|
1340
1297
|
}
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1298
|
+
|
|
1299
|
+
// src/commands/shellInit.ts
|
|
1300
|
+
import fs12 from "fs/promises";
|
|
1301
|
+
|
|
1302
|
+
// src/utils/appJsonPatch.ts
|
|
1303
|
+
import fs9 from "fs/promises";
|
|
1304
|
+
function pluginName(entry) {
|
|
1305
|
+
if (Array.isArray(entry)) return typeof entry[0] === "string" ? entry[0] : null;
|
|
1306
|
+
return typeof entry === "string" ? entry : null;
|
|
1307
|
+
}
|
|
1308
|
+
async function ensureComergeShellPlugins(appJsonPath) {
|
|
1309
|
+
let raw;
|
|
1310
|
+
try {
|
|
1311
|
+
raw = await fs9.readFile(appJsonPath, "utf8");
|
|
1312
|
+
} catch {
|
|
1313
|
+
return false;
|
|
1314
|
+
}
|
|
1315
|
+
let parsed;
|
|
1316
|
+
try {
|
|
1317
|
+
parsed = JSON.parse(raw);
|
|
1318
|
+
} catch {
|
|
1319
|
+
throw new CliError("Failed to parse app.json in the generated shell.", { exitCode: 2 });
|
|
1320
|
+
}
|
|
1321
|
+
const expo = parsed.expo;
|
|
1322
|
+
if (!expo || typeof expo !== "object") return false;
|
|
1323
|
+
const plugins = Array.isArray(expo.plugins) ? [...expo.plugins] : [];
|
|
1324
|
+
const routerEntry = plugins.find((p) => pluginName(p) === "expo-router");
|
|
1325
|
+
const runtimeEntry = plugins.find((p) => pluginName(p) === "@comergehq/runtime");
|
|
1326
|
+
const needsRouter = !routerEntry;
|
|
1327
|
+
const needsRuntime = !runtimeEntry;
|
|
1328
|
+
if (!needsRouter && !needsRuntime) return false;
|
|
1329
|
+
const rest = plugins.filter((p) => {
|
|
1330
|
+
const name = pluginName(p);
|
|
1331
|
+
return name !== "expo-router" && name !== "@comergehq/runtime";
|
|
1332
|
+
});
|
|
1333
|
+
expo.plugins = [routerEntry ?? "expo-router", runtimeEntry ?? "@comergehq/runtime", ...rest];
|
|
1334
|
+
await fs9.writeFile(appJsonPath, JSON.stringify({ expo }, null, 2) + "\n", "utf8");
|
|
1335
|
+
return true;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// src/utils/reanimated.ts
|
|
1339
|
+
import fs10 from "fs/promises";
|
|
1340
|
+
async function ensureReanimatedBabelPlugin(babelConfigPath) {
|
|
1341
|
+
const raw = await fs10.readFile(babelConfigPath, "utf8").catch(() => null);
|
|
1342
|
+
if (!raw) return false;
|
|
1343
|
+
if (raw.includes("react-native-reanimated/plugin")) return false;
|
|
1344
|
+
const patched = patchBabelConfigAddReanimatedPlugin(raw);
|
|
1345
|
+
if (patched === raw) return false;
|
|
1346
|
+
await fs10.writeFile(babelConfigPath, patched, "utf8");
|
|
1347
|
+
return true;
|
|
1348
|
+
}
|
|
1349
|
+
function patchBabelConfigAddReanimatedPlugin(raw) {
|
|
1350
|
+
const pluginsMatch = /(^|\n)([ \t]*)plugins\s*:\s*\[/m.exec(raw);
|
|
1351
|
+
if (pluginsMatch) {
|
|
1352
|
+
const matchIdx = pluginsMatch.index + pluginsMatch[1].length;
|
|
1353
|
+
const indent = pluginsMatch[2] ?? "";
|
|
1354
|
+
const bracketIdx = raw.indexOf("[", matchIdx);
|
|
1355
|
+
if (bracketIdx >= 0) {
|
|
1356
|
+
const closeIdx = findMatchingBracket(raw, bracketIdx, "[", "]");
|
|
1357
|
+
if (closeIdx >= 0) {
|
|
1358
|
+
const inner = raw.slice(bracketIdx + 1, closeIdx);
|
|
1359
|
+
const innerTrim = inner.trim();
|
|
1360
|
+
const innerTrimEnd = inner.replace(/[ \t\r\n]+$/g, "");
|
|
1361
|
+
const elementIndent = indent + " ";
|
|
1362
|
+
let insert = "";
|
|
1363
|
+
if (!innerTrim) {
|
|
1364
|
+
insert = `
|
|
1365
|
+
${elementIndent}'react-native-reanimated/plugin'
|
|
1366
|
+
${indent}`;
|
|
1367
|
+
} else if (innerTrimEnd.endsWith(",")) {
|
|
1368
|
+
insert = `
|
|
1369
|
+
${elementIndent}'react-native-reanimated/plugin'`;
|
|
1370
|
+
} else {
|
|
1371
|
+
insert = `,
|
|
1372
|
+
${elementIndent}'react-native-reanimated/plugin'`;
|
|
1373
|
+
}
|
|
1374
|
+
return raw.slice(0, closeIdx) + insert + raw.slice(closeIdx);
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1346
1377
|
}
|
|
1347
|
-
|
|
1348
|
-
|
|
1378
|
+
const presetsMatch = /(^|\n)([ \t]*)presets\s*:\s*\[[^\]]*\]\s*,?/m.exec(raw);
|
|
1379
|
+
if (presetsMatch) {
|
|
1380
|
+
const indent = presetsMatch[2] ?? "";
|
|
1381
|
+
const insertAt = presetsMatch.index + presetsMatch[0].length;
|
|
1382
|
+
const insertion = `
|
|
1383
|
+
${indent}// Required by react-native-reanimated. Must be listed last.
|
|
1384
|
+
${indent}plugins: ['react-native-reanimated/plugin'],`;
|
|
1385
|
+
return raw.slice(0, insertAt) + insertion + raw.slice(insertAt);
|
|
1349
1386
|
}
|
|
1350
|
-
return
|
|
1387
|
+
return raw;
|
|
1351
1388
|
}
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
const st = await fsp.stat(abs);
|
|
1366
|
-
if (!st.isFile()) continue;
|
|
1367
|
-
const bytes = st.size;
|
|
1368
|
-
totalBytes += bytes;
|
|
1369
|
-
if (largestN > 0) {
|
|
1370
|
-
largest.push({ path: rel, bytes });
|
|
1389
|
+
function findMatchingBracket(text, openIdx, open, close) {
|
|
1390
|
+
let depth = 0;
|
|
1391
|
+
let inSingle = false;
|
|
1392
|
+
let inDouble = false;
|
|
1393
|
+
let inTemplate = false;
|
|
1394
|
+
let inLineComment = false;
|
|
1395
|
+
let inBlockComment = false;
|
|
1396
|
+
for (let i = openIdx; i < text.length; i++) {
|
|
1397
|
+
const ch = text[i];
|
|
1398
|
+
const next = text[i + 1];
|
|
1399
|
+
if (inLineComment) {
|
|
1400
|
+
if (ch === "\n") inLineComment = false;
|
|
1401
|
+
continue;
|
|
1371
1402
|
}
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1403
|
+
if (inBlockComment) {
|
|
1404
|
+
if (ch === "*" && next === "/") {
|
|
1405
|
+
inBlockComment = false;
|
|
1406
|
+
i++;
|
|
1407
|
+
}
|
|
1408
|
+
continue;
|
|
1409
|
+
}
|
|
1410
|
+
if (!inSingle && !inDouble && !inTemplate) {
|
|
1411
|
+
if (ch === "/" && next === "/") {
|
|
1412
|
+
inLineComment = true;
|
|
1413
|
+
i++;
|
|
1414
|
+
continue;
|
|
1415
|
+
}
|
|
1416
|
+
if (ch === "/" && next === "*") {
|
|
1417
|
+
inBlockComment = true;
|
|
1418
|
+
i++;
|
|
1419
|
+
continue;
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
if (!inDouble && !inTemplate && ch === "'" && text[i - 1] !== "\\") {
|
|
1423
|
+
inSingle = !inSingle;
|
|
1424
|
+
continue;
|
|
1425
|
+
}
|
|
1426
|
+
if (!inSingle && !inTemplate && ch === `"` && text[i - 1] !== "\\") {
|
|
1427
|
+
inDouble = !inDouble;
|
|
1428
|
+
continue;
|
|
1429
|
+
}
|
|
1430
|
+
if (!inSingle && !inDouble && ch === "`" && text[i - 1] !== "\\") {
|
|
1431
|
+
inTemplate = !inTemplate;
|
|
1432
|
+
continue;
|
|
1433
|
+
}
|
|
1434
|
+
if (inSingle || inDouble || inTemplate) continue;
|
|
1435
|
+
if (ch === open) depth++;
|
|
1436
|
+
if (ch === close) {
|
|
1437
|
+
depth--;
|
|
1438
|
+
if (depth === 0) return i;
|
|
1398
1439
|
}
|
|
1399
|
-
void archive.finalize();
|
|
1400
|
-
});
|
|
1401
|
-
const st = await fsp.stat(zipPath);
|
|
1402
|
-
if (st.size > MAX_IMPORT_ZIP_BYTES) {
|
|
1403
|
-
throw new CliError("Archive exceeds max upload size", {
|
|
1404
|
-
exitCode: 2,
|
|
1405
|
-
hint: `Zip size ${st.size} bytes exceeds limit ${MAX_IMPORT_ZIP_BYTES} bytes. Use --path or add ignores.`
|
|
1406
|
-
});
|
|
1407
1440
|
}
|
|
1408
|
-
return
|
|
1409
|
-
}
|
|
1410
|
-
async function sha256FileHex(filePath) {
|
|
1411
|
-
const hash = crypto.createHash("sha256");
|
|
1412
|
-
await new Promise((resolve, reject) => {
|
|
1413
|
-
const s = fs10.createReadStream(filePath);
|
|
1414
|
-
s.on("data", (chunk) => hash.update(chunk));
|
|
1415
|
-
s.on("error", reject);
|
|
1416
|
-
s.on("end", () => resolve());
|
|
1417
|
-
});
|
|
1418
|
-
return hash.digest("hex");
|
|
1441
|
+
return -1;
|
|
1419
1442
|
}
|
|
1420
1443
|
|
|
1421
|
-
// src/
|
|
1444
|
+
// src/services/bundles.ts
|
|
1422
1445
|
import fs11 from "fs";
|
|
1423
|
-
import
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
const pass = new PassThrough();
|
|
1430
|
-
let sent = 0;
|
|
1431
|
-
fileStream.on("data", (chunk) => {
|
|
1432
|
-
sent += chunk.length;
|
|
1433
|
-
params.onProgress?.({ sentBytes: sent, totalBytes });
|
|
1434
|
-
});
|
|
1435
|
-
fileStream.on("error", (err) => pass.destroy(err));
|
|
1436
|
-
fileStream.pipe(pass);
|
|
1437
|
-
const res = await fetch(params.uploadUrl, {
|
|
1438
|
-
method: "PUT",
|
|
1439
|
-
headers: params.headers,
|
|
1440
|
-
body: pass,
|
|
1441
|
-
duplex: "half"
|
|
1442
|
-
});
|
|
1443
|
-
if (!res.ok) {
|
|
1444
|
-
const text = await res.text().catch(() => "");
|
|
1445
|
-
throw new CliError("Upload failed", {
|
|
1446
|
-
exitCode: 1,
|
|
1447
|
-
hint: `Status: ${res.status}
|
|
1448
|
-
${text}`.trim() || null
|
|
1449
|
-
});
|
|
1450
|
-
}
|
|
1451
|
-
}
|
|
1452
|
-
|
|
1453
|
-
// src/lib/importLocal.ts
|
|
1454
|
-
var SAFE_PATH_RE = /^[A-Za-z0-9._/-]+$/;
|
|
1455
|
-
var COMERGE_SHELL_SEGMENT_RE = /(^|\/)comerge-shell[^/]*(\/|$)/;
|
|
1456
|
-
function sleep(ms) {
|
|
1446
|
+
import fsPromises from "fs/promises";
|
|
1447
|
+
import path9 from "path";
|
|
1448
|
+
import { Readable } from "stream";
|
|
1449
|
+
import { pipeline } from "stream/promises";
|
|
1450
|
+
import pc6 from "picocolors";
|
|
1451
|
+
function sleep2(ms) {
|
|
1457
1452
|
return new Promise((r) => setTimeout(r, ms));
|
|
1458
1453
|
}
|
|
1459
|
-
function
|
|
1460
|
-
const
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
|
1468
|
-
}
|
|
1469
|
-
function deriveAppName(projectRoot) {
|
|
1470
|
-
const base = path9.basename(projectRoot);
|
|
1471
|
-
return base || "Imported App";
|
|
1472
|
-
}
|
|
1473
|
-
function validateOptionalSubdir(subdir) {
|
|
1474
|
-
if (!subdir) return null;
|
|
1475
|
-
const s = subdir.trim().replace(/^\/+/, "").replace(/\\/g, "/");
|
|
1476
|
-
if (!s) return null;
|
|
1477
|
-
if (!SAFE_PATH_RE.test(s) || s.includes("..")) {
|
|
1478
|
-
throw new CliError("Invalid --path", {
|
|
1479
|
-
exitCode: 2,
|
|
1480
|
-
hint: "Use a safe subdirectory like packages/mobile-app (letters/numbers/._-/ only)."
|
|
1481
|
-
});
|
|
1482
|
-
}
|
|
1483
|
-
return s;
|
|
1484
|
-
}
|
|
1485
|
-
function getAppStatus(appResp) {
|
|
1486
|
-
const obj = appResp?.responseObject;
|
|
1487
|
-
const status = typeof obj?.status === "string" ? obj.status : null;
|
|
1488
|
-
const statusError = typeof obj?.statusError === "string" ? obj.statusError : null;
|
|
1489
|
-
return { status, statusError };
|
|
1454
|
+
function isRetryableNetworkError(e) {
|
|
1455
|
+
const err = e;
|
|
1456
|
+
const code = typeof err?.code === "string" ? err.code : "";
|
|
1457
|
+
const message = typeof err?.message === "string" ? err.message : "";
|
|
1458
|
+
if (code === "ERR_NETWORK" || code === "ECONNABORTED" || code === "ETIMEDOUT") return true;
|
|
1459
|
+
if (message.toLowerCase().includes("network error")) return true;
|
|
1460
|
+
if (message.toLowerCase().includes("timeout")) return true;
|
|
1461
|
+
return false;
|
|
1490
1462
|
}
|
|
1491
|
-
async function
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
let files = [];
|
|
1506
|
-
const useGit = await canUseGit(projectRoot);
|
|
1507
|
-
if (useGit) {
|
|
1508
|
-
const gitPaths = await listFilesGit({ cwd: projectRoot, pathspec: subdir ?? null });
|
|
1509
|
-
files = gitPaths.filter((p) => !COMERGE_SHELL_SEGMENT_RE.test(p.replace(/\\/g, "/")));
|
|
1510
|
-
if (!params.includeDotenv) {
|
|
1511
|
-
files = files.filter((p) => !path9.posix.basename(p).startsWith(".env"));
|
|
1512
|
-
}
|
|
1513
|
-
} else {
|
|
1514
|
-
files = await listFilesFs({ root: importRoot, includeDotenv: params.includeDotenv });
|
|
1515
|
-
if (subdir) {
|
|
1516
|
-
files = files.map((p) => path9.posix.join(subdir, p));
|
|
1463
|
+
async function withRetry(fn, opts) {
|
|
1464
|
+
let lastErr = null;
|
|
1465
|
+
for (let attempt = 1; attempt <= opts.attempts; attempt += 1) {
|
|
1466
|
+
try {
|
|
1467
|
+
return await fn();
|
|
1468
|
+
} catch (e) {
|
|
1469
|
+
lastErr = e;
|
|
1470
|
+
const retryable = isRetryableNetworkError(e);
|
|
1471
|
+
if (!retryable || attempt >= opts.attempts) {
|
|
1472
|
+
throw e;
|
|
1473
|
+
}
|
|
1474
|
+
const exp = Math.min(opts.maxDelayMs, opts.baseDelayMs * Math.pow(2, attempt - 1));
|
|
1475
|
+
const jitter = Math.floor(Math.random() * 250);
|
|
1476
|
+
await sleep2(exp + jitter);
|
|
1517
1477
|
}
|
|
1518
1478
|
}
|
|
1519
|
-
|
|
1520
|
-
|
|
1479
|
+
throw lastErr;
|
|
1480
|
+
}
|
|
1481
|
+
function unwrapResponseObject(resp, label) {
|
|
1482
|
+
const obj = resp?.responseObject;
|
|
1483
|
+
if (obj === void 0 || obj === null) {
|
|
1484
|
+
const message = typeof resp?.message === "string" && resp.message.trim().length > 0 ? resp.message : `Missing ${label} response`;
|
|
1485
|
+
throw new CliError(message, { exitCode: 1, hint: resp ? JSON.stringify(resp, null, 2) : null });
|
|
1521
1486
|
}
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1487
|
+
return obj;
|
|
1488
|
+
}
|
|
1489
|
+
async function pollBundle(api, appId, bundleId, opts) {
|
|
1490
|
+
const start = Date.now();
|
|
1491
|
+
while (true) {
|
|
1492
|
+
try {
|
|
1493
|
+
const bundleResp = await api.getBundle(appId, bundleId);
|
|
1494
|
+
const bundle = unwrapResponseObject(bundleResp, "bundle");
|
|
1495
|
+
if (bundle.status === "succeeded" || bundle.status === "failed") return bundle;
|
|
1496
|
+
} catch (e) {
|
|
1497
|
+
if (!isRetryableNetworkError(e)) {
|
|
1498
|
+
throw e;
|
|
1530
1499
|
}
|
|
1531
1500
|
}
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
log(pc5.dim("Creating zip..."));
|
|
1535
|
-
const { zipPath } = await createZip({ root: projectRoot, files, zipName: "import.zip" });
|
|
1536
|
-
const zipSha = await sha256FileHex(zipPath);
|
|
1537
|
-
const zipSize = (await (await import("fs/promises")).stat(zipPath)).size;
|
|
1538
|
-
log(pc5.dim("Requesting upload URL..."));
|
|
1539
|
-
const presignResp = await api.presignImportUpload({
|
|
1540
|
-
file: {
|
|
1541
|
-
name: "import.zip",
|
|
1542
|
-
mimeType: "application/zip",
|
|
1543
|
-
size: zipSize,
|
|
1544
|
-
checksumSha256: zipSha
|
|
1501
|
+
if (Date.now() - start > opts.timeoutMs) {
|
|
1502
|
+
throw new Error("Bundle build timed out.");
|
|
1545
1503
|
}
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
const
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
headers,
|
|
1559
|
-
filePath: zipPath,
|
|
1560
|
-
onProgress: ({ sentBytes, totalBytes }) => {
|
|
1561
|
-
if (params.json) return;
|
|
1562
|
-
const pct = totalBytes > 0 ? Math.floor(sentBytes / totalBytes * 100) : 0;
|
|
1563
|
-
if (pct !== lastPct && (pct % 5 === 0 || pct === 100)) {
|
|
1564
|
-
lastPct = pct;
|
|
1565
|
-
process.stdout.write(pc5.dim(`Upload ${pct}%\r`));
|
|
1504
|
+
await sleep2(opts.intervalMs);
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
async function downloadToFile(url, targetPath) {
|
|
1508
|
+
const dir = path9.dirname(targetPath);
|
|
1509
|
+
await fsPromises.mkdir(dir, { recursive: true });
|
|
1510
|
+
const tmpPath = `${targetPath}.tmp-${Date.now()}`;
|
|
1511
|
+
await withRetry(
|
|
1512
|
+
async () => {
|
|
1513
|
+
const res = await fetch(url);
|
|
1514
|
+
if (!res.ok) {
|
|
1515
|
+
throw new Error(`Download failed (status ${res.status})`);
|
|
1566
1516
|
}
|
|
1567
|
-
|
|
1517
|
+
if (!res.body) {
|
|
1518
|
+
throw new Error("Download response has no body.");
|
|
1519
|
+
}
|
|
1520
|
+
const body = Readable.fromWeb(res.body);
|
|
1521
|
+
const out = fs11.createWriteStream(tmpPath);
|
|
1522
|
+
await pipeline(body, out);
|
|
1523
|
+
},
|
|
1524
|
+
{ attempts: 3, baseDelayMs: 500, maxDelayMs: 4e3 }
|
|
1525
|
+
);
|
|
1526
|
+
const stat = await fsPromises.stat(tmpPath);
|
|
1527
|
+
if (!stat.size || stat.size <= 0) {
|
|
1528
|
+
await fsPromises.unlink(tmpPath).catch(() => {
|
|
1529
|
+
});
|
|
1530
|
+
throw new Error("Downloaded bundle is empty.");
|
|
1531
|
+
}
|
|
1532
|
+
await fsPromises.rename(tmpPath, targetPath);
|
|
1533
|
+
}
|
|
1534
|
+
async function downloadBundle(api, appId, platform, targetPath) {
|
|
1535
|
+
const initiateResp = await api.initiateBundle(appId, {
|
|
1536
|
+
platform,
|
|
1537
|
+
idempotencyKey: `${appId}:head:${platform}`
|
|
1568
1538
|
});
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1539
|
+
const initiate = unwrapResponseObject(initiateResp, "bundle initiate");
|
|
1540
|
+
const finalBundle = initiate.status === "succeeded" || initiate.status === "failed" ? initiate : await pollBundle(api, appId, initiate.id, { timeoutMs: 3 * 60 * 1e3, intervalMs: 1200 });
|
|
1541
|
+
if (finalBundle.status === "failed") {
|
|
1542
|
+
throw new Error("Bundle build failed.");
|
|
1543
|
+
}
|
|
1544
|
+
const signedResp = await api.getBundleDownloadUrl(appId, finalBundle.id, { redirect: false });
|
|
1545
|
+
const signed = unwrapResponseObject(signedResp, "bundle download url");
|
|
1546
|
+
const url = String(signed.url || "").trim();
|
|
1547
|
+
if (!url) throw new Error("Download URL is missing.");
|
|
1548
|
+
await downloadToFile(url, targetPath);
|
|
1549
|
+
const fingerprint = finalBundle.checksumSha256 ?? `id:${finalBundle.id}`;
|
|
1550
|
+
const meta = {
|
|
1551
|
+
fingerprint,
|
|
1552
|
+
bundleId: finalBundle.id,
|
|
1553
|
+
checksumSha256: finalBundle.checksumSha256 ?? null,
|
|
1554
|
+
size: finalBundle.size ?? null,
|
|
1555
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1556
|
+
};
|
|
1557
|
+
return { platform, bundlePath: targetPath, meta };
|
|
1558
|
+
}
|
|
1559
|
+
async function downloadEmbeddedBundles(params) {
|
|
1560
|
+
const log = params.log ?? (() => {
|
|
1575
1561
|
});
|
|
1576
|
-
const
|
|
1577
|
-
const
|
|
1578
|
-
const
|
|
1579
|
-
|
|
1562
|
+
const baseDir = path9.join(params.outRoot, "assets", "comerge");
|
|
1563
|
+
const iosPath = path9.join(baseDir, "base.ios.jsbundle");
|
|
1564
|
+
const androidPath = path9.join(baseDir, "base.android.jsbundle");
|
|
1565
|
+
log(pc6.dim("Downloading base bundles for shell..."));
|
|
1566
|
+
const results = await Promise.allSettled([
|
|
1567
|
+
downloadBundle(params.api, params.appId, "ios", iosPath),
|
|
1568
|
+
downloadBundle(params.api, params.appId, "android", androidPath)
|
|
1569
|
+
]);
|
|
1570
|
+
const meta = {};
|
|
1571
|
+
for (const res of results) {
|
|
1572
|
+
if (res.status === "fulfilled") {
|
|
1573
|
+
meta[res.value.platform] = res.value.meta;
|
|
1574
|
+
} else {
|
|
1575
|
+
log(pc6.yellow(`Warning: failed to download ${res.reason?.message ?? res.reason}`));
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
await fsPromises.mkdir(baseDir, { recursive: true });
|
|
1579
|
+
await fsPromises.writeFile(path9.join(baseDir, "base.meta.json"), JSON.stringify(meta, null, 2) + "\n", "utf8");
|
|
1580
|
+
return meta;
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
// src/commands/shellInit.ts
|
|
1584
|
+
async function shellInit(params) {
|
|
1585
|
+
const projectRoot = await findExpoProjectRoot(process.cwd());
|
|
1586
|
+
const outDirDefault = params.outDir || "comerge-shell";
|
|
1587
|
+
let outDir = outDirDefault;
|
|
1588
|
+
let appId = params.appId ?? "";
|
|
1589
|
+
let apiKey = params.apiKey ?? "";
|
|
1590
|
+
let appKey = params.appKey ?? "MicroMain";
|
|
1591
|
+
appId = String(appId || "").trim();
|
|
1592
|
+
apiKey = String(apiKey || "").trim();
|
|
1580
1593
|
if (!appId) {
|
|
1581
|
-
throw new CliError("
|
|
1594
|
+
throw new CliError("Missing app ID.", {
|
|
1595
|
+
hint: "Pass --app-id <uuid>.",
|
|
1596
|
+
exitCode: 2
|
|
1597
|
+
});
|
|
1582
1598
|
}
|
|
1583
|
-
if (
|
|
1584
|
-
|
|
1599
|
+
if (!apiKey) {
|
|
1600
|
+
throw new CliError("Missing client key.", {
|
|
1601
|
+
hint: "Pass --api-key <key>.",
|
|
1602
|
+
exitCode: 2
|
|
1603
|
+
});
|
|
1585
1604
|
}
|
|
1586
|
-
|
|
1587
|
-
const
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1605
|
+
const requestedOutputPath = path10.resolve(projectRoot, outDir);
|
|
1606
|
+
const outputPath = await findAvailableDirPath(requestedOutputPath);
|
|
1607
|
+
console.log(pc7.dim(`Project: ${projectRoot}`));
|
|
1608
|
+
if (outputPath !== requestedOutputPath) {
|
|
1609
|
+
console.log(pc7.dim(`Output: ${requestedOutputPath} (already exists)`));
|
|
1610
|
+
console.log(pc7.dim(`Using: ${outputPath}`));
|
|
1611
|
+
} else {
|
|
1612
|
+
console.log(pc7.dim(`Output: ${outputPath}`));
|
|
1613
|
+
}
|
|
1614
|
+
const outputIsInsideProject = outputPath === projectRoot || outputPath.startsWith(projectRoot + path10.sep);
|
|
1615
|
+
const stagingPath = outputIsInsideProject ? await fs12.mkdtemp(path10.join(os3.tmpdir(), "comerge-shell-")) : outputPath;
|
|
1616
|
+
let movedToFinal = false;
|
|
1617
|
+
try {
|
|
1618
|
+
if (!outputIsInsideProject) {
|
|
1619
|
+
await ensureEmptyDir(stagingPath);
|
|
1596
1620
|
}
|
|
1597
|
-
|
|
1598
|
-
|
|
1621
|
+
await copyProject({ projectRoot, outRoot: stagingPath });
|
|
1622
|
+
await stripProject({ outRoot: stagingPath });
|
|
1623
|
+
try {
|
|
1624
|
+
const didPatch = await ensureComergeShellPlugins(path10.join(stagingPath, "app.json"));
|
|
1625
|
+
if (didPatch) {
|
|
1626
|
+
console.log(pc7.dim("Updated app.json with required Comerge shell plugins."));
|
|
1627
|
+
}
|
|
1628
|
+
} catch {
|
|
1599
1629
|
}
|
|
1600
|
-
|
|
1601
|
-
|
|
1630
|
+
await writeJsonAtomic(path10.join(stagingPath, "comerge.config.json"), {
|
|
1631
|
+
appId,
|
|
1632
|
+
appKey: appKey || "MicroMain",
|
|
1633
|
+
apiKey
|
|
1634
|
+
});
|
|
1635
|
+
try {
|
|
1636
|
+
const cfg = await resolveConfig({ yes: true });
|
|
1637
|
+
const api = createApiClient(cfg, { apiKey });
|
|
1638
|
+
await downloadEmbeddedBundles({
|
|
1639
|
+
api,
|
|
1640
|
+
appId,
|
|
1641
|
+
outRoot: stagingPath,
|
|
1642
|
+
log: (msg) => console.log(msg)
|
|
1643
|
+
});
|
|
1644
|
+
} catch (e) {
|
|
1645
|
+
console.log(pc7.yellow(`Warning: failed to embed base bundles: ${e instanceof Error ? e.message : String(e)}`));
|
|
1646
|
+
}
|
|
1647
|
+
await writeTextAtomic(path10.join(stagingPath, "app/_layout.tsx"), shellLayoutTsx());
|
|
1648
|
+
await writeTextAtomic(path10.join(stagingPath, "app/index.tsx"), shellIndexTsx());
|
|
1649
|
+
await ensureTextFile(path10.join(stagingPath, "babel.config.js"), shellBabelConfigJs());
|
|
1650
|
+
await ensureTextFile(path10.join(stagingPath, "metro.config.js"), shellMetroConfigJs());
|
|
1651
|
+
try {
|
|
1652
|
+
const didPatch = await ensureReanimatedBabelPlugin(path10.join(stagingPath, "babel.config.js"));
|
|
1653
|
+
if (didPatch) console.log(pc7.dim("Updated babel.config.js to include react-native-reanimated/plugin."));
|
|
1654
|
+
} catch {
|
|
1655
|
+
}
|
|
1656
|
+
await ensureTextFile(
|
|
1657
|
+
path10.join(stagingPath, "tsconfig.json"),
|
|
1658
|
+
JSON.stringify(
|
|
1659
|
+
{
|
|
1660
|
+
extends: "expo/tsconfig.base",
|
|
1661
|
+
compilerOptions: { strict: true, resolveJsonModule: true }
|
|
1662
|
+
},
|
|
1663
|
+
null,
|
|
1664
|
+
2
|
|
1665
|
+
) + "\n"
|
|
1666
|
+
);
|
|
1667
|
+
const originalPkg = await readPackageJson(stagingPath);
|
|
1668
|
+
const { pkg: shellPkg, warnings, info } = buildShellPackageJson({
|
|
1669
|
+
original: originalPkg
|
|
1670
|
+
});
|
|
1671
|
+
await writeJsonAtomic(path10.join(stagingPath, "package.json"), shellPkg);
|
|
1672
|
+
for (const w of warnings) console.log(pc7.yellow(`Warning: ${w}`));
|
|
1673
|
+
for (const i of info) console.log(pc7.dim(`Info: ${i}`));
|
|
1674
|
+
if (outputIsInsideProject) {
|
|
1675
|
+
await fse4.move(stagingPath, outputPath, { overwrite: false });
|
|
1676
|
+
movedToFinal = true;
|
|
1677
|
+
}
|
|
1678
|
+
const finalPath = outputIsInsideProject ? outputPath : stagingPath;
|
|
1679
|
+
const pm = params.packageManager ?? await detectPackageManager(projectRoot);
|
|
1680
|
+
const { cmd, args } = installCommand(pm);
|
|
1681
|
+
console.log(pc7.dim(`Installing dependencies with ${cmd}...`));
|
|
1682
|
+
const res = await execa3(cmd, args, { cwd: finalPath, stdio: "inherit" });
|
|
1683
|
+
if (res.exitCode !== 0) {
|
|
1684
|
+
throw new CliError("Dependency installation failed.", { exitCode: res.exitCode ?? 1 });
|
|
1685
|
+
}
|
|
1686
|
+
console.log(pc7.dim("Running Expo prebuild..."));
|
|
1687
|
+
const prebuild = await execa3("npx", ["expo", "prebuild"], { cwd: finalPath, stdio: "inherit" });
|
|
1688
|
+
if (prebuild.exitCode !== 0) {
|
|
1689
|
+
throw new CliError("Expo prebuild failed.", { exitCode: prebuild.exitCode ?? 1 });
|
|
1690
|
+
}
|
|
1691
|
+
console.log(pc7.green("Comerge has been installed successfully."));
|
|
1692
|
+
console.log(pc7.dim("You can now open the app from the My apps page in the Comerge app."));
|
|
1693
|
+
} finally {
|
|
1694
|
+
if (outputIsInsideProject && !movedToFinal) {
|
|
1695
|
+
try {
|
|
1696
|
+
await fse4.remove(stagingPath);
|
|
1697
|
+
} catch {
|
|
1698
|
+
}
|
|
1602
1699
|
}
|
|
1603
|
-
await sleep(delay);
|
|
1604
|
-
delay = Math.min(1e4, Math.floor(delay * 1.4));
|
|
1605
1700
|
}
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1701
|
+
}
|
|
1702
|
+
async function ensureTextFile(filePath, contents) {
|
|
1703
|
+
try {
|
|
1704
|
+
await fs12.access(filePath);
|
|
1705
|
+
return;
|
|
1706
|
+
} catch {
|
|
1707
|
+
}
|
|
1708
|
+
await writeTextAtomic(filePath, contents);
|
|
1610
1709
|
}
|
|
1611
1710
|
|
|
1612
|
-
// src/
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1711
|
+
// src/utils/appDefaults.ts
|
|
1712
|
+
import fs13 from "fs/promises";
|
|
1713
|
+
import path11 from "path";
|
|
1714
|
+
import fg2 from "fast-glob";
|
|
1715
|
+
import { execa as execa4 } from "execa";
|
|
1716
|
+
function normalizeString(value) {
|
|
1717
|
+
if (typeof value !== "string") return null;
|
|
1718
|
+
const trimmed = value.trim();
|
|
1719
|
+
return trimmed ? trimmed : null;
|
|
1720
|
+
}
|
|
1721
|
+
function normalizeSlug(value) {
|
|
1722
|
+
if (!value) return null;
|
|
1723
|
+
const cleaned = value.trim().toLowerCase().replace(/[^a-z0-9.-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
1724
|
+
return cleaned || null;
|
|
1725
|
+
}
|
|
1726
|
+
function suggestBundleId(slug) {
|
|
1727
|
+
if (!slug) return null;
|
|
1728
|
+
return `com.example.${slug}`;
|
|
1729
|
+
}
|
|
1730
|
+
function derivePlatforms(config, iosBundleId, androidPackageName) {
|
|
1731
|
+
const platforms = Array.isArray(config.platforms) ? config.platforms.map(String) : null;
|
|
1732
|
+
const iosEnabled = platforms ? platforms.includes("ios") : iosBundleId ? true : null;
|
|
1733
|
+
const androidEnabled = platforms ? platforms.includes("android") : androidPackageName ? true : null;
|
|
1734
|
+
return { iosEnabled, androidEnabled };
|
|
1735
|
+
}
|
|
1736
|
+
async function loadExpoConfig(projectRoot) {
|
|
1737
|
+
try {
|
|
1738
|
+
const res = await execa4("npx", ["expo", "config", "--json"], { cwd: projectRoot });
|
|
1739
|
+
const parsed = JSON.parse(res.stdout);
|
|
1740
|
+
if (parsed && typeof parsed === "object" && "expo" in parsed) {
|
|
1741
|
+
return parsed.expo ?? {};
|
|
1634
1742
|
}
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1743
|
+
return parsed;
|
|
1744
|
+
} catch {
|
|
1745
|
+
}
|
|
1746
|
+
const appJsonPath = path11.join(projectRoot, "app.json");
|
|
1747
|
+
const rawAppJson = await fs13.readFile(appJsonPath, "utf8").catch(() => null);
|
|
1748
|
+
if (!rawAppJson) return {};
|
|
1749
|
+
try {
|
|
1750
|
+
const parsed = JSON.parse(rawAppJson);
|
|
1751
|
+
if (parsed && typeof parsed === "object" && "expo" in parsed) {
|
|
1752
|
+
return parsed.expo ?? {};
|
|
1638
1753
|
}
|
|
1754
|
+
} catch {
|
|
1755
|
+
}
|
|
1756
|
+
return {};
|
|
1757
|
+
}
|
|
1758
|
+
async function findIosBundleId(projectRoot) {
|
|
1759
|
+
const pbxprojMatches = await fg2("ios/**/*.xcodeproj/project.pbxproj", {
|
|
1760
|
+
cwd: projectRoot,
|
|
1761
|
+
absolute: true,
|
|
1762
|
+
suppressErrors: true
|
|
1639
1763
|
});
|
|
1764
|
+
for (const file of pbxprojMatches) {
|
|
1765
|
+
const raw = await fs13.readFile(file, "utf8").catch(() => null);
|
|
1766
|
+
if (!raw) continue;
|
|
1767
|
+
const match = raw.match(/PRODUCT_BUNDLE_IDENTIFIER\s*=\s*([A-Za-z0-9.\-]+)\s*;/);
|
|
1768
|
+
if (match && match[1]) return match[1];
|
|
1769
|
+
}
|
|
1770
|
+
const plistMatches = await fg2("ios/**/Info.plist", {
|
|
1771
|
+
cwd: projectRoot,
|
|
1772
|
+
absolute: true,
|
|
1773
|
+
suppressErrors: true
|
|
1774
|
+
});
|
|
1775
|
+
for (const file of plistMatches) {
|
|
1776
|
+
const raw = await fs13.readFile(file, "utf8").catch(() => null);
|
|
1777
|
+
if (!raw) continue;
|
|
1778
|
+
const match = raw.match(/<key>CFBundleIdentifier<\/key>\s*<string>([^<]+)<\/string>/);
|
|
1779
|
+
if (match && match[1] && !match[1].includes("$")) return match[1];
|
|
1780
|
+
}
|
|
1781
|
+
return null;
|
|
1782
|
+
}
|
|
1783
|
+
async function findAndroidPackageName(projectRoot) {
|
|
1784
|
+
const gradlePaths = ["android/app/build.gradle", "android/app/build.gradle.kts"];
|
|
1785
|
+
for (const rel of gradlePaths) {
|
|
1786
|
+
const file = path11.join(projectRoot, rel);
|
|
1787
|
+
const raw = await fs13.readFile(file, "utf8").catch(() => null);
|
|
1788
|
+
if (!raw) continue;
|
|
1789
|
+
const match = raw.match(/applicationId\s+["']([^"']+)["']/) || raw.match(/applicationId\s*=\s*["']([^"']+)["']/);
|
|
1790
|
+
if (match && match[1]) return match[1];
|
|
1791
|
+
}
|
|
1792
|
+
const manifestMatches = await fg2("android/**/AndroidManifest.xml", {
|
|
1793
|
+
cwd: projectRoot,
|
|
1794
|
+
absolute: true,
|
|
1795
|
+
suppressErrors: true
|
|
1796
|
+
});
|
|
1797
|
+
for (const file of manifestMatches) {
|
|
1798
|
+
const raw = await fs13.readFile(file, "utf8").catch(() => null);
|
|
1799
|
+
if (!raw) continue;
|
|
1800
|
+
const match = raw.match(/<manifest[^>]*\spackage="([^"]+)"/);
|
|
1801
|
+
if (match && match[1] && !match[1].includes("$")) return match[1];
|
|
1802
|
+
}
|
|
1803
|
+
return null;
|
|
1804
|
+
}
|
|
1805
|
+
async function inferExpoAppDefaults(projectRoot) {
|
|
1806
|
+
const expoConfig = await loadExpoConfig(projectRoot);
|
|
1807
|
+
const name = normalizeString(expoConfig.name);
|
|
1808
|
+
const slug = normalizeSlug(normalizeString(expoConfig.slug));
|
|
1809
|
+
const iosBundleId = normalizeString(expoConfig.ios?.bundleIdentifier);
|
|
1810
|
+
const androidPackageName = normalizeString(expoConfig.android?.package);
|
|
1811
|
+
const platforms = derivePlatforms(expoConfig, iosBundleId, androidPackageName);
|
|
1812
|
+
let fallbackName = name;
|
|
1813
|
+
if (!fallbackName) {
|
|
1814
|
+
const pkg = await readPackageJson(projectRoot).catch(() => null);
|
|
1815
|
+
fallbackName = normalizeString(pkg?.name ?? null);
|
|
1816
|
+
}
|
|
1817
|
+
if (!fallbackName) {
|
|
1818
|
+
fallbackName = normalizeString(path11.basename(projectRoot));
|
|
1819
|
+
}
|
|
1820
|
+
const resolvedSlug = slug ?? normalizeSlug(fallbackName ?? null) ?? normalizeSlug(path11.basename(projectRoot));
|
|
1821
|
+
const suggestedBundleId = suggestBundleId(resolvedSlug);
|
|
1822
|
+
const nativeIosBundleId = iosBundleId ?? await findIosBundleId(projectRoot);
|
|
1823
|
+
const nativeAndroidPackage = androidPackageName ?? await findAndroidPackageName(projectRoot);
|
|
1824
|
+
return {
|
|
1825
|
+
name: fallbackName,
|
|
1826
|
+
slug: resolvedSlug,
|
|
1827
|
+
iosBundleId: nativeIosBundleId,
|
|
1828
|
+
androidPackageName: nativeAndroidPackage,
|
|
1829
|
+
iosEnabled: platforms.iosEnabled,
|
|
1830
|
+
androidEnabled: platforms.androidEnabled,
|
|
1831
|
+
suggestedBundleId
|
|
1832
|
+
};
|
|
1640
1833
|
}
|
|
1641
1834
|
|
|
1642
|
-
// src/
|
|
1643
|
-
import
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
1835
|
+
// src/services/apiKeyStore.ts
|
|
1836
|
+
import fs14 from "fs/promises";
|
|
1837
|
+
import os4 from "os";
|
|
1838
|
+
import path12 from "path";
|
|
1839
|
+
var KEYTAR_SERVICE2 = "comerge-cli-api-keys";
|
|
1840
|
+
function xdgConfigHome2() {
|
|
1841
|
+
const v = process.env.XDG_CONFIG_HOME;
|
|
1842
|
+
if (typeof v === "string" && v.trim().length > 0) return v;
|
|
1843
|
+
return path12.join(os4.homedir(), ".config");
|
|
1652
1844
|
}
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
const api = createApiClient(cfg);
|
|
1656
|
-
await api.getMe();
|
|
1845
|
+
function apiKeyFilePath() {
|
|
1846
|
+
return path12.join(xdgConfigHome2(), "comerge", "api-keys.json");
|
|
1657
1847
|
}
|
|
1658
|
-
async function
|
|
1659
|
-
const
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
await validateBackendSession({ apiKey: params?.apiKey ?? null, yes: params?.yes });
|
|
1665
|
-
return;
|
|
1666
|
-
} catch {
|
|
1667
|
-
}
|
|
1848
|
+
async function ensurePathPermissions2(filePath) {
|
|
1849
|
+
const dir = path12.dirname(filePath);
|
|
1850
|
+
await fs14.mkdir(dir, { recursive: true });
|
|
1851
|
+
try {
|
|
1852
|
+
await fs14.chmod(dir, 448);
|
|
1853
|
+
} catch {
|
|
1668
1854
|
}
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
throw new CliError("Not logged in", {
|
|
1673
|
-
exitCode: 2,
|
|
1674
|
-
hint: "Run `comerge login` first, or set COMERGE_ACCESS_TOKEN for CI."
|
|
1675
|
-
});
|
|
1855
|
+
try {
|
|
1856
|
+
await fs14.chmod(filePath, 384);
|
|
1857
|
+
} catch {
|
|
1676
1858
|
}
|
|
1677
|
-
|
|
1678
|
-
|
|
1859
|
+
}
|
|
1860
|
+
async function maybeLoadKeytar2() {
|
|
1679
1861
|
try {
|
|
1680
|
-
const
|
|
1681
|
-
const
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
} catch {
|
|
1686
|
-
if (!params?.json) {
|
|
1687
|
-
console.log(pc7.yellow("Could not open the browser automatically."));
|
|
1688
|
-
console.log(`Open this URL manually:
|
|
1689
|
-
${url}`);
|
|
1862
|
+
const mod = await import("keytar");
|
|
1863
|
+
const candidates = [mod?.default, mod].filter(Boolean);
|
|
1864
|
+
for (const c of candidates) {
|
|
1865
|
+
if (c && typeof c.getPassword === "function" && typeof c.setPassword === "function") {
|
|
1866
|
+
return c;
|
|
1690
1867
|
}
|
|
1691
1868
|
}
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
try {
|
|
1696
|
-
await validateBackendSession({ apiKey: params?.apiKey ?? null, yes: params?.yes });
|
|
1697
|
-
} catch (err) {
|
|
1698
|
-
await clearSession().catch(() => {
|
|
1699
|
-
});
|
|
1700
|
-
throw err;
|
|
1701
|
-
}
|
|
1702
|
-
} finally {
|
|
1703
|
-
await server.close();
|
|
1869
|
+
return null;
|
|
1870
|
+
} catch {
|
|
1871
|
+
return null;
|
|
1704
1872
|
}
|
|
1705
1873
|
}
|
|
1874
|
+
function keyId(orgId, clientAppId) {
|
|
1875
|
+
return `${orgId}:${clientAppId}`;
|
|
1876
|
+
}
|
|
1877
|
+
async function readKeyFile() {
|
|
1878
|
+
const fp = apiKeyFilePath();
|
|
1879
|
+
const raw = await fs14.readFile(fp, "utf8").catch(() => null);
|
|
1880
|
+
if (!raw) return {};
|
|
1881
|
+
try {
|
|
1882
|
+
const parsed = JSON.parse(raw);
|
|
1883
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
1884
|
+
return parsed;
|
|
1885
|
+
} catch {
|
|
1886
|
+
return {};
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
async function getStoredApiKey(orgId, clientAppId) {
|
|
1890
|
+
const account = keyId(orgId, clientAppId);
|
|
1891
|
+
const keytar = await maybeLoadKeytar2();
|
|
1892
|
+
if (keytar) {
|
|
1893
|
+
const raw = await keytar.getPassword(KEYTAR_SERVICE2, account);
|
|
1894
|
+
return raw && raw.trim() ? raw.trim() : null;
|
|
1895
|
+
}
|
|
1896
|
+
const map = await readKeyFile();
|
|
1897
|
+
const key = map[account];
|
|
1898
|
+
return key && key.trim() ? key.trim() : null;
|
|
1899
|
+
}
|
|
1900
|
+
async function setStoredApiKey(orgId, clientAppId, apiKey) {
|
|
1901
|
+
const account = keyId(orgId, clientAppId);
|
|
1902
|
+
const trimmed = String(apiKey || "").trim();
|
|
1903
|
+
if (!trimmed) {
|
|
1904
|
+
throw new CliError("API key is empty and was not stored.", { exitCode: 1 });
|
|
1905
|
+
}
|
|
1906
|
+
const keytar = await maybeLoadKeytar2();
|
|
1907
|
+
if (keytar) {
|
|
1908
|
+
await keytar.setPassword(KEYTAR_SERVICE2, account, trimmed);
|
|
1909
|
+
return;
|
|
1910
|
+
}
|
|
1911
|
+
const fp = apiKeyFilePath();
|
|
1912
|
+
const map = await readKeyFile();
|
|
1913
|
+
map[account] = trimmed;
|
|
1914
|
+
await writeJsonAtomic(fp, map);
|
|
1915
|
+
await ensurePathPermissions2(fp);
|
|
1916
|
+
}
|
|
1706
1917
|
|
|
1707
|
-
// src/
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1918
|
+
// src/services/initLocal.ts
|
|
1919
|
+
function unwrapResponseObject2(resp, label) {
|
|
1920
|
+
const obj = resp?.responseObject;
|
|
1921
|
+
if (obj === void 0 || obj === null) {
|
|
1922
|
+
const message = typeof resp?.message === "string" && resp.message.trim().length > 0 ? resp.message : `Missing ${label} response`;
|
|
1923
|
+
throw new CliError(message, { exitCode: 1, hint: resp ? JSON.stringify(resp, null, 2) : null });
|
|
1924
|
+
}
|
|
1925
|
+
return obj;
|
|
1926
|
+
}
|
|
1927
|
+
async function selectOrganization(orgs, yes) {
|
|
1928
|
+
if (orgs.length === 0) {
|
|
1929
|
+
throw new CliError("No organizations found for your account.", { exitCode: 1 });
|
|
1930
|
+
}
|
|
1931
|
+
if (orgs.length === 1) return orgs[0];
|
|
1932
|
+
if (yes || !isInteractive()) {
|
|
1933
|
+
throw new CliError("Multiple organizations found. Re-run without --yes to select one.", { exitCode: 2 });
|
|
1934
|
+
}
|
|
1935
|
+
const res = await prompts(
|
|
1936
|
+
[
|
|
1937
|
+
{
|
|
1938
|
+
type: "select",
|
|
1939
|
+
name: "orgId",
|
|
1940
|
+
message: "Select an organization",
|
|
1941
|
+
choices: orgs.map((org) => ({
|
|
1942
|
+
title: org.name ? `${org.name} (${org.id})` : org.id,
|
|
1943
|
+
value: org.id
|
|
1944
|
+
}))
|
|
1945
|
+
}
|
|
1946
|
+
],
|
|
1947
|
+
{
|
|
1948
|
+
onCancel: () => {
|
|
1949
|
+
throw new CliError("Cancelled by user.", { exitCode: 130 });
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
);
|
|
1953
|
+
const selected = orgs.find((org) => org.id === res.orgId);
|
|
1954
|
+
if (!selected) throw new CliError("Invalid organization selection.", { exitCode: 1 });
|
|
1955
|
+
return selected;
|
|
1956
|
+
}
|
|
1957
|
+
async function selectClientApp(apps, yes) {
|
|
1958
|
+
if (apps.length === 0) {
|
|
1959
|
+
if (yes || !isInteractive()) {
|
|
1960
|
+
return { app: null };
|
|
1961
|
+
}
|
|
1962
|
+
console.log(
|
|
1963
|
+
pc8.dim(
|
|
1964
|
+
"No apps found. Create one here or in the Comerge dashboard at dashboard.comerge.ai."
|
|
1965
|
+
)
|
|
1966
|
+
);
|
|
1967
|
+
const res2 = await prompts(
|
|
1968
|
+
[
|
|
1969
|
+
{
|
|
1970
|
+
type: "select",
|
|
1971
|
+
name: "appId",
|
|
1972
|
+
message: "Select an app or create a new one:",
|
|
1973
|
+
choices: [{ title: "Create a new app", value: "__create_new__" }]
|
|
1974
|
+
}
|
|
1975
|
+
],
|
|
1976
|
+
{
|
|
1977
|
+
onCancel: () => {
|
|
1978
|
+
throw new CliError("Cancelled by user.", { exitCode: 130 });
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
);
|
|
1982
|
+
if (res2.appId === "__create_new__") return { app: null };
|
|
1983
|
+
throw new CliError("Invalid app selection.", { exitCode: 1 });
|
|
1984
|
+
}
|
|
1985
|
+
if (yes || !isInteractive()) {
|
|
1986
|
+
throw new CliError("Multiple apps found. Re-run without --yes to select one.", { exitCode: 2 });
|
|
1987
|
+
}
|
|
1988
|
+
const res = await prompts(
|
|
1989
|
+
[
|
|
1990
|
+
{
|
|
1991
|
+
type: "select",
|
|
1992
|
+
name: "appId",
|
|
1993
|
+
message: "Select an app or create a new one:",
|
|
1994
|
+
choices: [
|
|
1995
|
+
...apps.map((app) => ({
|
|
1996
|
+
title: app.name ? `${app.name} (${app.id})` : app.id,
|
|
1997
|
+
value: app.id
|
|
1998
|
+
})),
|
|
1999
|
+
{ title: "Create a new app", value: "__create_new__" }
|
|
2000
|
+
]
|
|
2001
|
+
}
|
|
2002
|
+
],
|
|
2003
|
+
{
|
|
2004
|
+
onCancel: () => {
|
|
2005
|
+
throw new CliError("Cancelled by user.", { exitCode: 130 });
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
);
|
|
2009
|
+
if (res.appId === "__create_new__") return { app: null };
|
|
2010
|
+
const selected = apps.find((app) => app.id === res.appId);
|
|
2011
|
+
if (!selected) throw new CliError("Invalid app selection.", { exitCode: 1 });
|
|
2012
|
+
return { app: selected };
|
|
2013
|
+
}
|
|
2014
|
+
async function promptNewAppDetails(projectRoot, yes) {
|
|
2015
|
+
const defaults = await inferExpoAppDefaults(projectRoot);
|
|
2016
|
+
const defaultName = defaults.name || "My App";
|
|
2017
|
+
const iosEnabled = true;
|
|
2018
|
+
const androidEnabled = true;
|
|
2019
|
+
if (yes || !isInteractive()) {
|
|
1721
2020
|
return {
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
shellOutDir: params.outDir,
|
|
1728
|
-
noWait: params.noWait
|
|
2021
|
+
name: defaultName,
|
|
2022
|
+
iosEnabled,
|
|
2023
|
+
androidEnabled,
|
|
2024
|
+
iosBundleId: defaults.iosBundleId || defaults.suggestedBundleId || "com.example.app",
|
|
2025
|
+
androidPackageName: defaults.androidPackageName || defaults.suggestedBundleId || "com.example.app"
|
|
1729
2026
|
};
|
|
1730
2027
|
}
|
|
1731
|
-
const
|
|
1732
|
-
|
|
2028
|
+
const answers = await prompts(
|
|
2029
|
+
[
|
|
2030
|
+
{
|
|
2031
|
+
type: "text",
|
|
2032
|
+
name: "name",
|
|
2033
|
+
message: "App name",
|
|
2034
|
+
initial: defaultName,
|
|
2035
|
+
validate: (v) => String(v || "").trim() ? true : "App name is required."
|
|
2036
|
+
}
|
|
2037
|
+
],
|
|
2038
|
+
{
|
|
2039
|
+
onCancel: () => {
|
|
2040
|
+
throw new CliError("Cancelled by user.", { exitCode: 130 });
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
);
|
|
2044
|
+
const bundlePrompts = [
|
|
2045
|
+
iosEnabled ? {
|
|
2046
|
+
type: "text",
|
|
2047
|
+
name: "iosBundleId",
|
|
2048
|
+
message: "iOS bundle ID",
|
|
2049
|
+
initial: defaults.iosBundleId || defaults.suggestedBundleId || "com.example.app",
|
|
2050
|
+
validate: (v) => String(v || "").trim() ? true : "iOS bundle ID is required."
|
|
2051
|
+
} : null,
|
|
2052
|
+
androidEnabled ? {
|
|
2053
|
+
type: "text",
|
|
2054
|
+
name: "androidPackageName",
|
|
2055
|
+
message: "Android package name",
|
|
2056
|
+
initial: defaults.androidPackageName || defaults.suggestedBundleId || "com.example.app",
|
|
2057
|
+
validate: (v) => String(v || "").trim() ? true : "Android package name is required."
|
|
2058
|
+
} : null
|
|
2059
|
+
].filter(Boolean);
|
|
2060
|
+
const bundleAnswers = await prompts(bundlePrompts, {
|
|
2061
|
+
onCancel: () => {
|
|
2062
|
+
throw new CliError("Cancelled by user.", { exitCode: 130 });
|
|
2063
|
+
}
|
|
2064
|
+
});
|
|
2065
|
+
return {
|
|
2066
|
+
name: String(answers.name || defaultName).trim(),
|
|
2067
|
+
iosEnabled,
|
|
2068
|
+
androidEnabled,
|
|
2069
|
+
iosBundleId: iosEnabled ? String(bundleAnswers.iosBundleId || "").trim() : null,
|
|
2070
|
+
androidPackageName: androidEnabled ? String(bundleAnswers.androidPackageName || "").trim() : null
|
|
2071
|
+
};
|
|
2072
|
+
}
|
|
2073
|
+
function buildKeyName(params) {
|
|
2074
|
+
const rawUser = params.email && params.email.includes("@") ? params.email.split("@")[0] : params.id || "user";
|
|
2075
|
+
const user = rawUser.trim().replace(/\s+/g, "-");
|
|
2076
|
+
const hostname = os5.hostname().trim().replace(/\s+/g, "-");
|
|
2077
|
+
const ts = Math.floor(Date.now() / 1e3);
|
|
2078
|
+
return `cli_${user}_${hostname}_${ts}`;
|
|
2079
|
+
}
|
|
2080
|
+
async function initLocal(params) {
|
|
2081
|
+
await ensureAuth({ yes: params.yes, json: params.json });
|
|
2082
|
+
const cfg = await resolveConfig({ yes: params.yes });
|
|
2083
|
+
const api = createApiClient(cfg);
|
|
1733
2084
|
const appKey = String(params.appKey || "MicroMain").trim() || "MicroMain";
|
|
2085
|
+
try {
|
|
2086
|
+
await api.autoEnableDeveloper();
|
|
2087
|
+
} catch {
|
|
2088
|
+
}
|
|
2089
|
+
const orgs = unwrapResponseObject2(await api.listOrganizations(), "organizations");
|
|
2090
|
+
const selectedOrg = await selectOrganization(orgs, params.yes);
|
|
2091
|
+
const apps = unwrapResponseObject2(
|
|
2092
|
+
await api.listClientApps({ orgId: selectedOrg.id }),
|
|
2093
|
+
"client apps"
|
|
2094
|
+
);
|
|
2095
|
+
const selectedAppResult = await selectClientApp(apps, params.yes);
|
|
2096
|
+
let clientApp = selectedAppResult.app;
|
|
2097
|
+
let importAppName = clientApp?.name ?? null;
|
|
2098
|
+
if (!clientApp) {
|
|
2099
|
+
const projectRoot = await findExpoProjectRoot(process.cwd());
|
|
2100
|
+
const details = await promptNewAppDetails(projectRoot, params.yes);
|
|
2101
|
+
const createdResp = await api.createClientApp({
|
|
2102
|
+
orgId: selectedOrg.id,
|
|
2103
|
+
name: details.name,
|
|
2104
|
+
type: "wrapper",
|
|
2105
|
+
environment: "production",
|
|
2106
|
+
platform: "expo",
|
|
2107
|
+
iosBundleId: details.iosEnabled ? details.iosBundleId ?? void 0 : void 0,
|
|
2108
|
+
androidPackageName: details.androidEnabled ? details.androidPackageName ?? void 0 : void 0
|
|
2109
|
+
});
|
|
2110
|
+
clientApp = unwrapResponseObject2(createdResp, "client app");
|
|
2111
|
+
importAppName = details.name;
|
|
2112
|
+
}
|
|
2113
|
+
const clientAppId = String(clientApp.id || "").trim();
|
|
2114
|
+
if (!clientAppId) {
|
|
2115
|
+
throw new CliError("Missing client app ID.", { exitCode: 1 });
|
|
2116
|
+
}
|
|
2117
|
+
let apiKey = await getStoredApiKey(selectedOrg.id, clientAppId);
|
|
2118
|
+
if (!apiKey) {
|
|
2119
|
+
const me = unwrapResponseObject2(await api.getMe(), "user");
|
|
2120
|
+
const keyName = buildKeyName({ email: me.email ?? null, id: me.id ?? null });
|
|
2121
|
+
const createdKeyResp = await api.createClientAppKey(clientAppId, { name: keyName });
|
|
2122
|
+
const keyPayload = unwrapResponseObject2(createdKeyResp, "client key");
|
|
2123
|
+
apiKey = String(keyPayload.key || "").trim();
|
|
2124
|
+
if (!apiKey) {
|
|
2125
|
+
throw new CliError("Server did not return a client key.", { exitCode: 1 });
|
|
2126
|
+
}
|
|
2127
|
+
await setStoredApiKey(selectedOrg.id, clientAppId, apiKey);
|
|
2128
|
+
}
|
|
1734
2129
|
if (!params.json) console.log(pc8.dim("Importing project..."));
|
|
1735
2130
|
const imported = await importLocal({
|
|
1736
2131
|
apiKey,
|
|
1737
2132
|
yes: params.yes,
|
|
1738
|
-
appName:
|
|
2133
|
+
appName: importAppName,
|
|
1739
2134
|
subdir: params.subdir,
|
|
1740
|
-
maxWaitMs: params.maxWaitMs,
|
|
1741
|
-
noWait: params.noWait,
|
|
1742
2135
|
json: params.json,
|
|
1743
2136
|
includeDotenv: params.includeDotenv,
|
|
1744
2137
|
dryRun: false
|
|
1745
2138
|
});
|
|
1746
|
-
if (!params.json) console.log(pc8.dim("Generating shell..."));
|
|
2139
|
+
if (!params.json) console.log(pc8.dim("Generating shell app..."));
|
|
1747
2140
|
await shellInit({
|
|
1748
2141
|
outDir: params.outDir,
|
|
1749
2142
|
appId: imported.appId,
|
|
1750
2143
|
apiKey,
|
|
1751
2144
|
appKey,
|
|
1752
|
-
|
|
1753
|
-
install: params.install,
|
|
1754
|
-
packageManager: params.packageManager,
|
|
1755
|
-
studioVersion: params.studioVersion
|
|
2145
|
+
packageManager: params.packageManager
|
|
1756
2146
|
});
|
|
1757
2147
|
return {
|
|
1758
2148
|
appId: imported.appId,
|
|
@@ -1760,32 +2150,20 @@ async function initLocal(params) {
|
|
|
1760
2150
|
projectId: imported.projectId,
|
|
1761
2151
|
threadId: imported.threadId,
|
|
1762
2152
|
status: imported.status,
|
|
1763
|
-
shellOutDir: params.outDir
|
|
1764
|
-
noWait: params.noWait
|
|
2153
|
+
shellOutDir: params.outDir
|
|
1765
2154
|
};
|
|
1766
2155
|
}
|
|
1767
2156
|
|
|
1768
2157
|
// src/commands/init.ts
|
|
1769
2158
|
function registerInitCommands(program) {
|
|
1770
|
-
const init = program.command("init").description("Import
|
|
1771
|
-
init.
|
|
1772
|
-
const maxWaitSec = Number(String(opts.maxWait ?? "1200").trim());
|
|
1773
|
-
if (!Number.isFinite(maxWaitSec) || maxWaitSec <= 0) {
|
|
1774
|
-
throw new CliError("Invalid --max-wait", { exitCode: 2, hint: "Example: --max-wait 1200" });
|
|
1775
|
-
}
|
|
2159
|
+
const init = program.command("init").description("Import into Comerge and generate the shell wrapper");
|
|
2160
|
+
init.description("Sign in if needed, import the local Expo project, then generate the shell wrapper").option("--out <dir>", "Output directory for the shell wrapper", "comerge-shell").option("--package-manager <pm>", "Override package manager detection (pnpm|npm|yarn|bun)").option("--path <subdir>", "Optional subdirectory to import").option("--no-include-dotenv", "Exclude .env* files from the upload").option("--yes", "Run non-interactively", false).option("--json", "Output JSON", false).action(async (opts) => {
|
|
1776
2161
|
const res = await initLocal({
|
|
1777
|
-
|
|
1778
|
-
appKey: opts.appKey ? String(opts.appKey) : "MicroMain",
|
|
2162
|
+
appKey: "MicroMain",
|
|
1779
2163
|
outDir: opts.out ? String(opts.out) : "comerge-shell",
|
|
1780
|
-
install: Boolean(opts.install),
|
|
1781
2164
|
packageManager: opts.packageManager ? String(opts.packageManager) : null,
|
|
1782
|
-
studioVersion: opts.studioVersion ? String(opts.studioVersion) : "latest",
|
|
1783
|
-
appName: opts.name ? String(opts.name) : null,
|
|
1784
2165
|
subdir: opts.path ? String(opts.path) : null,
|
|
1785
|
-
maxWaitMs: Math.floor(maxWaitSec * 1e3),
|
|
1786
|
-
noWait: Boolean(opts.noWait),
|
|
1787
2166
|
includeDotenv: Boolean(opts.includeDotenv),
|
|
1788
|
-
dryRun: Boolean(opts.dryRun),
|
|
1789
2167
|
yes: Boolean(opts.yes),
|
|
1790
2168
|
json: Boolean(opts.json)
|
|
1791
2169
|
});
|
|
@@ -1793,9 +2171,8 @@ function registerInitCommands(program) {
|
|
|
1793
2171
|
console.log(JSON.stringify(res, null, 2));
|
|
1794
2172
|
return;
|
|
1795
2173
|
}
|
|
1796
|
-
console.log(pc9.green(`
|
|
2174
|
+
console.log(pc9.green(`Completed. appId=${res.appId}`));
|
|
1797
2175
|
if (res.status) console.log(pc9.dim(`Status: ${res.status}`));
|
|
1798
|
-
if (res.noWait) console.log(pc9.yellow("Note: --no-wait was used; bundles may still be building."));
|
|
1799
2176
|
});
|
|
1800
2177
|
}
|
|
1801
2178
|
|
|
@@ -1805,11 +2182,9 @@ var { version } = require2("../package.json");
|
|
|
1805
2182
|
async function main(argv) {
|
|
1806
2183
|
const program = new Command();
|
|
1807
2184
|
program.name("comerge").description("Comerge CLI").version(version);
|
|
1808
|
-
registerShellCommands(program);
|
|
1809
2185
|
registerLoginCommand(program);
|
|
1810
2186
|
registerWhoamiCommand(program);
|
|
1811
2187
|
registerLogoutCommand(program);
|
|
1812
|
-
registerImportCommands(program);
|
|
1813
2188
|
registerInitCommands(program);
|
|
1814
2189
|
program.configureOutput({
|
|
1815
2190
|
outputError: (str, write) => write(pc10.red(str))
|