@comergehq/cli 0.1.1
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/LICENSE +22 -0
- package/README.md +15 -0
- package/dist/cli.js +1744 -0
- package/dist/cli.js.map +1 -0
- package/package.json +50 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1744 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import pc10 from "picocolors";
|
|
6
|
+
|
|
7
|
+
// src/commands/shellInit.ts
|
|
8
|
+
import path5 from "path";
|
|
9
|
+
import os from "os";
|
|
10
|
+
import pc from "picocolors";
|
|
11
|
+
import prompts from "prompts";
|
|
12
|
+
import fse4 from "fs-extra";
|
|
13
|
+
|
|
14
|
+
// src/lib/errors.ts
|
|
15
|
+
var CliError = class extends Error {
|
|
16
|
+
exitCode;
|
|
17
|
+
hint;
|
|
18
|
+
constructor(message, opts) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "CliError";
|
|
21
|
+
this.exitCode = opts?.exitCode ?? 1;
|
|
22
|
+
this.hint = opts?.hint ?? null;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// src/lib/packageJson.ts
|
|
27
|
+
import fs from "fs/promises";
|
|
28
|
+
async function readPackageJson(projectRoot) {
|
|
29
|
+
const raw = await fs.readFile(`${projectRoot}/package.json`, "utf8").catch(() => null);
|
|
30
|
+
if (!raw) {
|
|
31
|
+
throw new CliError("package.json not found", { exitCode: 2 });
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(raw);
|
|
35
|
+
} catch {
|
|
36
|
+
throw new CliError("Failed to parse package.json", { exitCode: 2 });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
var STUDIO_PEERS = [
|
|
40
|
+
"@callstack/liquid-glass",
|
|
41
|
+
"@supabase/supabase-js",
|
|
42
|
+
"@gorhom/bottom-sheet",
|
|
43
|
+
"expo-file-system",
|
|
44
|
+
"expo-haptics",
|
|
45
|
+
"expo-linear-gradient",
|
|
46
|
+
"lucide-react-native",
|
|
47
|
+
"react-native-gesture-handler",
|
|
48
|
+
"react-native-reanimated",
|
|
49
|
+
"react-native-safe-area-context",
|
|
50
|
+
"react-native-svg",
|
|
51
|
+
"react-native-view-shot"
|
|
52
|
+
];
|
|
53
|
+
function buildShellPackageJson(params) {
|
|
54
|
+
const orig = params.original;
|
|
55
|
+
const warnings = [];
|
|
56
|
+
const dependencies = { ...orig.dependencies ?? {} };
|
|
57
|
+
const devDependencies = { ...orig.devDependencies ?? {} };
|
|
58
|
+
const main2 = "expo-router/entry";
|
|
59
|
+
dependencies["@comergehq/studio"] = params.studioVersion || "latest";
|
|
60
|
+
dependencies["@comergehq/runtime"] = params.studioVersion || "latest";
|
|
61
|
+
if (!dependencies["expo-router"] && !devDependencies["expo-router"]) {
|
|
62
|
+
dependencies["expo-router"] = "latest";
|
|
63
|
+
warnings.push("Added missing dependency expo-router@latest");
|
|
64
|
+
}
|
|
65
|
+
for (const dep of STUDIO_PEERS) {
|
|
66
|
+
if (dependencies[dep] || devDependencies[dep]) continue;
|
|
67
|
+
dependencies[dep] = "latest";
|
|
68
|
+
warnings.push(`Added missing peer dependency ${dep}@latest`);
|
|
69
|
+
}
|
|
70
|
+
const pkg = {
|
|
71
|
+
...orig,
|
|
72
|
+
name: orig.name ? `${orig.name}-comerge-shell` : "comerge-shell",
|
|
73
|
+
private: true,
|
|
74
|
+
main: main2,
|
|
75
|
+
scripts: {
|
|
76
|
+
dev: "expo start -c",
|
|
77
|
+
ios: "expo start -c --ios",
|
|
78
|
+
android: "expo start -c --android",
|
|
79
|
+
web: "expo start -c --web",
|
|
80
|
+
...orig.scripts ?? {}
|
|
81
|
+
},
|
|
82
|
+
dependencies,
|
|
83
|
+
devDependencies
|
|
84
|
+
};
|
|
85
|
+
return { pkg, warnings };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// src/lib/fs.ts
|
|
89
|
+
import fs2 from "fs/promises";
|
|
90
|
+
import path from "path";
|
|
91
|
+
import fse from "fs-extra";
|
|
92
|
+
async function ensureEmptyDir(dir) {
|
|
93
|
+
const exists2 = await fse.pathExists(dir);
|
|
94
|
+
if (exists2) {
|
|
95
|
+
throw new CliError("Output directory already exists", {
|
|
96
|
+
hint: `Choose a new --out directory or delete: ${dir}`,
|
|
97
|
+
exitCode: 2
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
await fse.mkdirp(dir);
|
|
101
|
+
}
|
|
102
|
+
async function writeJsonAtomic(filePath, value) {
|
|
103
|
+
const dir = path.dirname(filePath);
|
|
104
|
+
await fse.mkdirp(dir);
|
|
105
|
+
const tmp = `${filePath}.tmp-${Date.now()}`;
|
|
106
|
+
await fs2.writeFile(tmp, JSON.stringify(value, null, 2) + "\n", "utf8");
|
|
107
|
+
await fs2.rename(tmp, filePath);
|
|
108
|
+
}
|
|
109
|
+
async function writeTextAtomic(filePath, contents) {
|
|
110
|
+
const dir = path.dirname(filePath);
|
|
111
|
+
await fse.mkdirp(dir);
|
|
112
|
+
const tmp = `${filePath}.tmp-${Date.now()}`;
|
|
113
|
+
await fs2.writeFile(tmp, contents, "utf8");
|
|
114
|
+
await fs2.rename(tmp, filePath);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// src/lib/projectDetect.ts
|
|
118
|
+
import fs3 from "fs/promises";
|
|
119
|
+
import path2 from "path";
|
|
120
|
+
async function fileExists(p) {
|
|
121
|
+
try {
|
|
122
|
+
const st = await fs3.stat(p);
|
|
123
|
+
return st.isFile();
|
|
124
|
+
} catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async function findExpoProjectRoot(startDir) {
|
|
129
|
+
let cur = path2.resolve(startDir);
|
|
130
|
+
while (true) {
|
|
131
|
+
const pkg = path2.join(cur, "package.json");
|
|
132
|
+
if (await fileExists(pkg)) {
|
|
133
|
+
const hasAppJson = await fileExists(path2.join(cur, "app.json"));
|
|
134
|
+
const hasAppConfig = await fileExists(path2.join(cur, "app.config.js")) || await fileExists(path2.join(cur, "app.config.ts"));
|
|
135
|
+
if (!hasAppJson && !hasAppConfig) {
|
|
136
|
+
} else {
|
|
137
|
+
return cur;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const parent = path2.dirname(cur);
|
|
141
|
+
if (parent === cur) break;
|
|
142
|
+
cur = parent;
|
|
143
|
+
}
|
|
144
|
+
throw new CliError("Not inside an Expo project", {
|
|
145
|
+
hint: "Run this command from the root of your Expo project repository.",
|
|
146
|
+
exitCode: 2
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// src/lib/templates.ts
|
|
151
|
+
function shellLayoutTsx() {
|
|
152
|
+
return `import { Stack } from 'expo-router';
|
|
153
|
+
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
|
154
|
+
|
|
155
|
+
export default function Layout() {
|
|
156
|
+
return (
|
|
157
|
+
<GestureHandlerRootView style={{ flex: 1 }}>
|
|
158
|
+
<Stack screenOptions={{ headerShown: false }} />
|
|
159
|
+
</GestureHandlerRootView>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
`;
|
|
163
|
+
}
|
|
164
|
+
function shellIndexTsx() {
|
|
165
|
+
return `import * as React from 'react';
|
|
166
|
+
import { View } from 'react-native';
|
|
167
|
+
import { Stack } from 'expo-router';
|
|
168
|
+
import { ComergeStudio } from '@comergehq/studio';
|
|
169
|
+
|
|
170
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
171
|
+
// @ts-ignore
|
|
172
|
+
import config from '../comerge.config.json';
|
|
173
|
+
|
|
174
|
+
export default function Index() {
|
|
175
|
+
const appId = String((config as any)?.appId || '');
|
|
176
|
+
const appKey = String((config as any)?.appKey || 'MicroMain');
|
|
177
|
+
const apiKey = String((config as any)?.apiKey || '');
|
|
178
|
+
return (
|
|
179
|
+
<>
|
|
180
|
+
<Stack.Screen options={{ headerShown: false }} />
|
|
181
|
+
<View style={{ flex: 1 }}>
|
|
182
|
+
{appId ? <ComergeStudio appId={appId} apiKey={apiKey} appKey={appKey} /> : null}
|
|
183
|
+
</View>
|
|
184
|
+
</>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
`;
|
|
188
|
+
}
|
|
189
|
+
function shellBabelConfigJs() {
|
|
190
|
+
return `module.exports = function (api) {
|
|
191
|
+
api.cache(true);
|
|
192
|
+
return {
|
|
193
|
+
presets: ['babel-preset-expo'],
|
|
194
|
+
plugins: ['react-native-reanimated/plugin'],
|
|
195
|
+
};
|
|
196
|
+
};
|
|
197
|
+
`;
|
|
198
|
+
}
|
|
199
|
+
function shellMetroConfigJs() {
|
|
200
|
+
return `const { getDefaultConfig } = require('expo/metro-config');
|
|
201
|
+
|
|
202
|
+
const config = getDefaultConfig(__dirname);
|
|
203
|
+
|
|
204
|
+
module.exports = config;
|
|
205
|
+
`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// src/lib/packageManager.ts
|
|
209
|
+
import fs4 from "fs/promises";
|
|
210
|
+
async function exists(p) {
|
|
211
|
+
try {
|
|
212
|
+
await fs4.stat(p);
|
|
213
|
+
return true;
|
|
214
|
+
} catch {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
async function detectPackageManager(projectRoot) {
|
|
219
|
+
if (await exists(`${projectRoot}/pnpm-lock.yaml`)) return "pnpm";
|
|
220
|
+
if (await exists(`${projectRoot}/yarn.lock`)) return "yarn";
|
|
221
|
+
if (await exists(`${projectRoot}/package-lock.json`)) return "npm";
|
|
222
|
+
if (await exists(`${projectRoot}/bun.lockb`)) return "bun";
|
|
223
|
+
return "npm";
|
|
224
|
+
}
|
|
225
|
+
function installCommand(pm) {
|
|
226
|
+
switch (pm) {
|
|
227
|
+
case "pnpm":
|
|
228
|
+
return { cmd: "pnpm", args: ["install"] };
|
|
229
|
+
case "yarn":
|
|
230
|
+
return { cmd: "yarn", args: ["install"] };
|
|
231
|
+
case "bun":
|
|
232
|
+
return { cmd: "bun", args: ["install"] };
|
|
233
|
+
case "npm":
|
|
234
|
+
default:
|
|
235
|
+
return { cmd: "npm", args: ["install"] };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// src/commands/shellInit.ts
|
|
240
|
+
import { execa } from "execa";
|
|
241
|
+
|
|
242
|
+
// src/lib/copyProject.ts
|
|
243
|
+
import path3 from "path";
|
|
244
|
+
import fse2 from "fs-extra";
|
|
245
|
+
var ALWAYS_EXCLUDE_DIRS = /* @__PURE__ */ new Set([
|
|
246
|
+
"node_modules",
|
|
247
|
+
".git",
|
|
248
|
+
".expo",
|
|
249
|
+
"dist",
|
|
250
|
+
"build"
|
|
251
|
+
]);
|
|
252
|
+
function normalizeRel(relPath) {
|
|
253
|
+
return relPath.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
254
|
+
}
|
|
255
|
+
function shouldExclude(relPath) {
|
|
256
|
+
const rel = normalizeRel(relPath);
|
|
257
|
+
const parts = rel.split("/").filter(Boolean);
|
|
258
|
+
const top = parts[0] ?? "";
|
|
259
|
+
if (!top) return false;
|
|
260
|
+
if (ALWAYS_EXCLUDE_DIRS.has(top)) return true;
|
|
261
|
+
if (rel.startsWith("ios/build/") || rel === "ios/build") return true;
|
|
262
|
+
if (rel.startsWith("android/build/") || rel === "android/build") return true;
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
async function copyProject(params) {
|
|
266
|
+
const src = path3.resolve(params.projectRoot);
|
|
267
|
+
const dest = path3.resolve(params.outRoot);
|
|
268
|
+
const srcExists = await fse2.pathExists(src);
|
|
269
|
+
if (!srcExists) {
|
|
270
|
+
throw new CliError("Project root does not exist", { exitCode: 2 });
|
|
271
|
+
}
|
|
272
|
+
await fse2.copy(src, dest, {
|
|
273
|
+
dereference: true,
|
|
274
|
+
preserveTimestamps: true,
|
|
275
|
+
filter: (srcPath) => {
|
|
276
|
+
const rel = path3.relative(src, srcPath);
|
|
277
|
+
if (!rel) return true;
|
|
278
|
+
return !shouldExclude(rel);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// src/lib/stripProject.ts
|
|
284
|
+
import path4 from "path";
|
|
285
|
+
import fse3 from "fs-extra";
|
|
286
|
+
var DEFAULT_STRIP_DIRS = ["app", "src", "components", "lib", "hooks", "providers"];
|
|
287
|
+
async function stripProject(params) {
|
|
288
|
+
const dirs = params.dirs ?? DEFAULT_STRIP_DIRS;
|
|
289
|
+
await Promise.all(
|
|
290
|
+
dirs.map(async (d) => {
|
|
291
|
+
const p = path4.join(params.outRoot, d);
|
|
292
|
+
const exists2 = await fse3.pathExists(p);
|
|
293
|
+
if (!exists2) return;
|
|
294
|
+
await fse3.remove(p);
|
|
295
|
+
})
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// src/commands/shellInit.ts
|
|
300
|
+
import fs7 from "fs/promises";
|
|
301
|
+
|
|
302
|
+
// src/lib/appJsonPatch.ts
|
|
303
|
+
import fs5 from "fs/promises";
|
|
304
|
+
function pluginName(entry) {
|
|
305
|
+
if (Array.isArray(entry)) return typeof entry[0] === "string" ? entry[0] : null;
|
|
306
|
+
return typeof entry === "string" ? entry : null;
|
|
307
|
+
}
|
|
308
|
+
async function ensureComergeShellPlugins(appJsonPath) {
|
|
309
|
+
let raw;
|
|
310
|
+
try {
|
|
311
|
+
raw = await fs5.readFile(appJsonPath, "utf8");
|
|
312
|
+
} catch {
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
let parsed;
|
|
316
|
+
try {
|
|
317
|
+
parsed = JSON.parse(raw);
|
|
318
|
+
} catch {
|
|
319
|
+
throw new CliError("Failed to parse app.json in generated shell", { exitCode: 2 });
|
|
320
|
+
}
|
|
321
|
+
const expo = parsed.expo;
|
|
322
|
+
if (!expo || typeof expo !== "object") return false;
|
|
323
|
+
const plugins = Array.isArray(expo.plugins) ? [...expo.plugins] : [];
|
|
324
|
+
const routerEntry = plugins.find((p) => pluginName(p) === "expo-router");
|
|
325
|
+
const runtimeEntry = plugins.find((p) => pluginName(p) === "@comergehq/runtime");
|
|
326
|
+
const needsRouter = !routerEntry;
|
|
327
|
+
const needsRuntime = !runtimeEntry;
|
|
328
|
+
if (!needsRouter && !needsRuntime) return false;
|
|
329
|
+
const rest = plugins.filter((p) => {
|
|
330
|
+
const name = pluginName(p);
|
|
331
|
+
return name !== "expo-router" && name !== "@comergehq/runtime";
|
|
332
|
+
});
|
|
333
|
+
expo.plugins = [routerEntry ?? "expo-router", runtimeEntry ?? "@comergehq/runtime", ...rest];
|
|
334
|
+
await fs5.writeFile(appJsonPath, JSON.stringify({ expo }, null, 2) + "\n", "utf8");
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// src/lib/reanimated.ts
|
|
339
|
+
import fs6 from "fs/promises";
|
|
340
|
+
async function ensureReanimatedBabelPlugin(babelConfigPath) {
|
|
341
|
+
const raw = await fs6.readFile(babelConfigPath, "utf8").catch(() => null);
|
|
342
|
+
if (!raw) return false;
|
|
343
|
+
if (raw.includes("react-native-reanimated/plugin")) return false;
|
|
344
|
+
const patched = patchBabelConfigAddReanimatedPlugin(raw);
|
|
345
|
+
if (patched === raw) return false;
|
|
346
|
+
await fs6.writeFile(babelConfigPath, patched, "utf8");
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
function patchBabelConfigAddReanimatedPlugin(raw) {
|
|
350
|
+
const pluginsMatch = /(^|\n)([ \t]*)plugins\s*:\s*\[/m.exec(raw);
|
|
351
|
+
if (pluginsMatch) {
|
|
352
|
+
const matchIdx = pluginsMatch.index + pluginsMatch[1].length;
|
|
353
|
+
const indent = pluginsMatch[2] ?? "";
|
|
354
|
+
const bracketIdx = raw.indexOf("[", matchIdx);
|
|
355
|
+
if (bracketIdx >= 0) {
|
|
356
|
+
const closeIdx = findMatchingBracket(raw, bracketIdx, "[", "]");
|
|
357
|
+
if (closeIdx >= 0) {
|
|
358
|
+
const inner = raw.slice(bracketIdx + 1, closeIdx);
|
|
359
|
+
const innerTrim = inner.trim();
|
|
360
|
+
const innerTrimEnd = inner.replace(/[ \t\r\n]+$/g, "");
|
|
361
|
+
const elementIndent = indent + " ";
|
|
362
|
+
let insert = "";
|
|
363
|
+
if (!innerTrim) {
|
|
364
|
+
insert = `
|
|
365
|
+
${elementIndent}'react-native-reanimated/plugin'
|
|
366
|
+
${indent}`;
|
|
367
|
+
} else if (innerTrimEnd.endsWith(",")) {
|
|
368
|
+
insert = `
|
|
369
|
+
${elementIndent}'react-native-reanimated/plugin'`;
|
|
370
|
+
} else {
|
|
371
|
+
insert = `,
|
|
372
|
+
${elementIndent}'react-native-reanimated/plugin'`;
|
|
373
|
+
}
|
|
374
|
+
return raw.slice(0, closeIdx) + insert + raw.slice(closeIdx);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
const presetsMatch = /(^|\n)([ \t]*)presets\s*:\s*\[[^\]]*\]\s*,?/m.exec(raw);
|
|
379
|
+
if (presetsMatch) {
|
|
380
|
+
const indent = presetsMatch[2] ?? "";
|
|
381
|
+
const insertAt = presetsMatch.index + presetsMatch[0].length;
|
|
382
|
+
const insertion = `
|
|
383
|
+
${indent}// Required by react-native-reanimated. Must be listed last.
|
|
384
|
+
${indent}plugins: ['react-native-reanimated/plugin'],`;
|
|
385
|
+
return raw.slice(0, insertAt) + insertion + raw.slice(insertAt);
|
|
386
|
+
}
|
|
387
|
+
return raw;
|
|
388
|
+
}
|
|
389
|
+
function findMatchingBracket(text, openIdx, open, close) {
|
|
390
|
+
let depth = 0;
|
|
391
|
+
let inSingle = false;
|
|
392
|
+
let inDouble = false;
|
|
393
|
+
let inTemplate = false;
|
|
394
|
+
let inLineComment = false;
|
|
395
|
+
let inBlockComment = false;
|
|
396
|
+
for (let i = openIdx; i < text.length; i++) {
|
|
397
|
+
const ch = text[i];
|
|
398
|
+
const next = text[i + 1];
|
|
399
|
+
if (inLineComment) {
|
|
400
|
+
if (ch === "\n") inLineComment = false;
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
if (inBlockComment) {
|
|
404
|
+
if (ch === "*" && next === "/") {
|
|
405
|
+
inBlockComment = false;
|
|
406
|
+
i++;
|
|
407
|
+
}
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
if (!inSingle && !inDouble && !inTemplate) {
|
|
411
|
+
if (ch === "/" && next === "/") {
|
|
412
|
+
inLineComment = true;
|
|
413
|
+
i++;
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
if (ch === "/" && next === "*") {
|
|
417
|
+
inBlockComment = true;
|
|
418
|
+
i++;
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (!inDouble && !inTemplate && ch === "'" && text[i - 1] !== "\\") {
|
|
423
|
+
inSingle = !inSingle;
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
if (!inSingle && !inTemplate && ch === `"` && text[i - 1] !== "\\") {
|
|
427
|
+
inDouble = !inDouble;
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
if (!inSingle && !inDouble && ch === "`" && text[i - 1] !== "\\") {
|
|
431
|
+
inTemplate = !inTemplate;
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (inSingle || inDouble || inTemplate) continue;
|
|
435
|
+
if (ch === open) depth++;
|
|
436
|
+
if (ch === close) {
|
|
437
|
+
depth--;
|
|
438
|
+
if (depth === 0) return i;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return -1;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// src/commands/shellInit.ts
|
|
445
|
+
async function shellInit(params) {
|
|
446
|
+
const projectRoot = await findExpoProjectRoot(process.cwd());
|
|
447
|
+
const outDirDefault = params.outDir || "comerge-shell";
|
|
448
|
+
let outDir = outDirDefault;
|
|
449
|
+
let appId = params.appId ?? "";
|
|
450
|
+
let apiKey = params.apiKey ?? "";
|
|
451
|
+
let appKey = params.appKey ?? "MicroMain";
|
|
452
|
+
if (!params.yes) {
|
|
453
|
+
const res = await prompts(
|
|
454
|
+
[
|
|
455
|
+
{
|
|
456
|
+
type: "text",
|
|
457
|
+
name: "outDir",
|
|
458
|
+
message: "Output directory",
|
|
459
|
+
initial: outDir
|
|
460
|
+
},
|
|
461
|
+
{
|
|
462
|
+
type: "text",
|
|
463
|
+
name: "appKey",
|
|
464
|
+
message: "App key",
|
|
465
|
+
initial: appKey
|
|
466
|
+
},
|
|
467
|
+
{
|
|
468
|
+
type: "text",
|
|
469
|
+
name: "appId",
|
|
470
|
+
message: "Comerge appId",
|
|
471
|
+
initial: appId,
|
|
472
|
+
validate: (value) => String(value || "").trim().length > 0 ? true : "appId is required"
|
|
473
|
+
},
|
|
474
|
+
{
|
|
475
|
+
type: "password",
|
|
476
|
+
name: "apiKey",
|
|
477
|
+
message: "Comerge apiKey",
|
|
478
|
+
initial: apiKey,
|
|
479
|
+
validate: (value) => String(value || "").trim().length > 0 ? true : "apiKey is required"
|
|
480
|
+
}
|
|
481
|
+
],
|
|
482
|
+
{
|
|
483
|
+
onCancel: () => {
|
|
484
|
+
throw new CliError("Cancelled", { exitCode: 130 });
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
);
|
|
488
|
+
outDir = String(res.outDir || outDir);
|
|
489
|
+
appKey = String(res.appKey || appKey);
|
|
490
|
+
appId = String(res.appId || appId);
|
|
491
|
+
apiKey = String(res.apiKey || apiKey);
|
|
492
|
+
}
|
|
493
|
+
appId = String(appId || "").trim();
|
|
494
|
+
apiKey = String(apiKey || "").trim();
|
|
495
|
+
if (!appId) {
|
|
496
|
+
throw new CliError("Missing required appId", {
|
|
497
|
+
hint: "Pass --app-id <uuid> (or omit --yes to be prompted).",
|
|
498
|
+
exitCode: 2
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
if (!apiKey) {
|
|
502
|
+
throw new CliError("Missing required apiKey", {
|
|
503
|
+
hint: "Pass --api-key <key> (or omit --yes to be prompted).",
|
|
504
|
+
exitCode: 2
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
const outputPath = path5.resolve(projectRoot, outDir);
|
|
508
|
+
console.log(pc.dim(`Project: ${projectRoot}`));
|
|
509
|
+
console.log(pc.dim(`Output: ${outputPath}`));
|
|
510
|
+
if (await fse4.pathExists(outputPath)) {
|
|
511
|
+
throw new CliError("Output directory already exists", {
|
|
512
|
+
hint: `Delete ${outputPath} or choose a different --out path.`,
|
|
513
|
+
exitCode: 2
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
const outputIsInsideProject = outputPath === projectRoot || outputPath.startsWith(projectRoot + path5.sep);
|
|
517
|
+
const stagingPath = outputIsInsideProject ? await fs7.mkdtemp(path5.join(os.tmpdir(), "comerge-shell-")) : outputPath;
|
|
518
|
+
let movedToFinal = false;
|
|
519
|
+
try {
|
|
520
|
+
if (!outputIsInsideProject) {
|
|
521
|
+
await ensureEmptyDir(stagingPath);
|
|
522
|
+
}
|
|
523
|
+
await copyProject({ projectRoot, outRoot: stagingPath });
|
|
524
|
+
await stripProject({ outRoot: stagingPath });
|
|
525
|
+
try {
|
|
526
|
+
const didPatch = await ensureComergeShellPlugins(path5.join(stagingPath, "app.json"));
|
|
527
|
+
if (didPatch) {
|
|
528
|
+
console.log(pc.dim("Patched app.json to include required Comerge shell plugins."));
|
|
529
|
+
}
|
|
530
|
+
} catch {
|
|
531
|
+
}
|
|
532
|
+
await writeJsonAtomic(path5.join(stagingPath, "comerge.config.json"), {
|
|
533
|
+
appId,
|
|
534
|
+
appKey: appKey || "MicroMain",
|
|
535
|
+
apiKey
|
|
536
|
+
});
|
|
537
|
+
await writeTextAtomic(path5.join(stagingPath, "app/_layout.tsx"), shellLayoutTsx());
|
|
538
|
+
await writeTextAtomic(path5.join(stagingPath, "app/index.tsx"), shellIndexTsx());
|
|
539
|
+
await ensureTextFile(path5.join(stagingPath, "babel.config.js"), shellBabelConfigJs());
|
|
540
|
+
await ensureTextFile(path5.join(stagingPath, "metro.config.js"), shellMetroConfigJs());
|
|
541
|
+
try {
|
|
542
|
+
const didPatch = await ensureReanimatedBabelPlugin(path5.join(stagingPath, "babel.config.js"));
|
|
543
|
+
if (didPatch) console.log(pc.dim("Patched babel.config.js to include react-native-reanimated/plugin."));
|
|
544
|
+
} catch {
|
|
545
|
+
}
|
|
546
|
+
await ensureTextFile(
|
|
547
|
+
path5.join(stagingPath, "tsconfig.json"),
|
|
548
|
+
JSON.stringify(
|
|
549
|
+
{
|
|
550
|
+
extends: "expo/tsconfig.base",
|
|
551
|
+
compilerOptions: { strict: true, resolveJsonModule: true }
|
|
552
|
+
},
|
|
553
|
+
null,
|
|
554
|
+
2
|
|
555
|
+
) + "\n"
|
|
556
|
+
);
|
|
557
|
+
const originalPkg = await readPackageJson(stagingPath);
|
|
558
|
+
const { pkg: shellPkg, warnings } = buildShellPackageJson({
|
|
559
|
+
original: originalPkg,
|
|
560
|
+
studioVersion: params.studioVersion
|
|
561
|
+
});
|
|
562
|
+
await writeJsonAtomic(path5.join(stagingPath, "package.json"), shellPkg);
|
|
563
|
+
for (const w of warnings) console.log(pc.yellow(`Warning: ${w}`));
|
|
564
|
+
if (outputIsInsideProject) {
|
|
565
|
+
await fse4.move(stagingPath, outputPath, { overwrite: false });
|
|
566
|
+
movedToFinal = true;
|
|
567
|
+
}
|
|
568
|
+
const finalPath = outputIsInsideProject ? outputPath : stagingPath;
|
|
569
|
+
if (params.install) {
|
|
570
|
+
const pm = params.packageManager ?? await detectPackageManager(projectRoot);
|
|
571
|
+
const { cmd, args } = installCommand(pm);
|
|
572
|
+
console.log(pc.dim(`Installing deps with ${cmd}...`));
|
|
573
|
+
const res = await execa(cmd, args, { cwd: finalPath, stdio: "inherit" });
|
|
574
|
+
if (res.exitCode !== 0) {
|
|
575
|
+
throw new CliError("Dependency install failed", { exitCode: res.exitCode ?? 1 });
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
console.log(pc.green("Shell app generated."));
|
|
579
|
+
console.log(pc.dim(`Next: cd ${path5.relative(process.cwd(), finalPath)} && (install deps) && expo prebuild`));
|
|
580
|
+
} finally {
|
|
581
|
+
if (outputIsInsideProject && !movedToFinal) {
|
|
582
|
+
try {
|
|
583
|
+
await fse4.remove(stagingPath);
|
|
584
|
+
} catch {
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
async function ensureTextFile(filePath, contents) {
|
|
590
|
+
try {
|
|
591
|
+
await fs7.access(filePath);
|
|
592
|
+
return;
|
|
593
|
+
} catch {
|
|
594
|
+
}
|
|
595
|
+
await writeTextAtomic(filePath, contents);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// src/commands/shell.ts
|
|
599
|
+
function registerShellCommands(program) {
|
|
600
|
+
const shell = program.command("shell").description("Shell app utilities");
|
|
601
|
+
shell.command("init").description("Generate an Expo Router shell app that renders Comerge Studio").option("--out <dir>", "Output directory", "comerge-shell").requiredOption("--app-id <uuid>", "Comerge appId (required)").requiredOption("--api-key <key>", "Comerge apiKey (required)").option("--app-key <key>", "Bundle app key (default MicroMain)", "MicroMain").option("--yes", "Non-interactive; use defaults", false).option("--install", "Install dependencies in the generated shell folder", false).option(
|
|
602
|
+
"--package-manager <pm>",
|
|
603
|
+
"Override package manager detection (pnpm|npm|yarn|bun)"
|
|
604
|
+
).option("--studio-version <ver>", "Version to use for @comergehq/studio", "latest").action(async (opts) => {
|
|
605
|
+
await shellInit({
|
|
606
|
+
outDir: String(opts.out),
|
|
607
|
+
appId: String(opts.appId ?? ""),
|
|
608
|
+
apiKey: String(opts.apiKey ?? ""),
|
|
609
|
+
appKey: String(opts.appKey ?? "MicroMain"),
|
|
610
|
+
yes: Boolean(opts.yes),
|
|
611
|
+
install: Boolean(opts.install),
|
|
612
|
+
packageManager: opts.packageManager ? String(opts.packageManager) : null,
|
|
613
|
+
studioVersion: String(opts.studioVersion ?? "latest")
|
|
614
|
+
});
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// src/commands/login.ts
|
|
619
|
+
import pc2 from "picocolors";
|
|
620
|
+
|
|
621
|
+
// src/lib/config.ts
|
|
622
|
+
import { z } from "zod";
|
|
623
|
+
var urlSchema = z.string().min(1).transform((s) => s.trim()).refine((s) => {
|
|
624
|
+
try {
|
|
625
|
+
new URL(s);
|
|
626
|
+
return true;
|
|
627
|
+
} catch {
|
|
628
|
+
return false;
|
|
629
|
+
}
|
|
630
|
+
}, "Invalid URL").transform((s) => s.replace(/\/+$/, ""));
|
|
631
|
+
var configSchema = z.object({
|
|
632
|
+
apiUrl: urlSchema,
|
|
633
|
+
supabaseUrl: urlSchema,
|
|
634
|
+
supabaseAnonKey: z.string().min(1)
|
|
635
|
+
});
|
|
636
|
+
function pickEnv(names) {
|
|
637
|
+
for (const n of names) {
|
|
638
|
+
const v = process.env[n];
|
|
639
|
+
if (typeof v === "string" && v.trim().length > 0) return v;
|
|
640
|
+
}
|
|
641
|
+
return void 0;
|
|
642
|
+
}
|
|
643
|
+
function resolveConfig(opts) {
|
|
644
|
+
void opts;
|
|
645
|
+
const apiUrl = pickEnv(["COMERGE_API_URL"]);
|
|
646
|
+
const supabaseUrl = pickEnv(["COMERGE_SUPABASE_URL"]);
|
|
647
|
+
const supabaseAnonKey = pickEnv(["COMERGE_SUPABASE_ANON_KEY"]);
|
|
648
|
+
const parsed = configSchema.safeParse({
|
|
649
|
+
apiUrl,
|
|
650
|
+
supabaseUrl,
|
|
651
|
+
supabaseAnonKey
|
|
652
|
+
});
|
|
653
|
+
if (!parsed.success) {
|
|
654
|
+
const missing = [];
|
|
655
|
+
if (!apiUrl) missing.push("COMERGE_API_URL");
|
|
656
|
+
if (!supabaseUrl) missing.push("COMERGE_SUPABASE_URL");
|
|
657
|
+
if (!supabaseAnonKey) missing.push("COMERGE_SUPABASE_ANON_KEY");
|
|
658
|
+
const hintLines = [];
|
|
659
|
+
hintLines.push("Set required environment variables:");
|
|
660
|
+
hintLines.push(` - COMERGE_API_URL`);
|
|
661
|
+
hintLines.push(` - COMERGE_SUPABASE_URL`);
|
|
662
|
+
hintLines.push(` - COMERGE_SUPABASE_ANON_KEY`);
|
|
663
|
+
throw new CliError("Missing or invalid CLI configuration", {
|
|
664
|
+
exitCode: 2,
|
|
665
|
+
hint: missing.length ? `${hintLines.join("\n")}
|
|
666
|
+
|
|
667
|
+
Missing: ${missing.join(", ")}` : `${hintLines.join("\n")}
|
|
668
|
+
|
|
669
|
+
Details: ${parsed.error.issues.map((i) => `${i.path.join(".") || "config"}: ${i.message}`).join("; ")}`
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
return parsed.data;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// src/lib/oauthCallbackServer.ts
|
|
676
|
+
import http from "http";
|
|
677
|
+
async function startOAuthCallbackServer(params) {
|
|
678
|
+
const timeoutMs = params?.timeoutMs ?? 3 * 6e4;
|
|
679
|
+
const requestedPort = params?.port ?? null;
|
|
680
|
+
let resolveCode = null;
|
|
681
|
+
let rejectCode = null;
|
|
682
|
+
const codePromise = new Promise((resolve, reject) => {
|
|
683
|
+
resolveCode = resolve;
|
|
684
|
+
rejectCode = reject;
|
|
685
|
+
});
|
|
686
|
+
const server = http.createServer((req, res) => {
|
|
687
|
+
try {
|
|
688
|
+
const base = `http://127.0.0.1`;
|
|
689
|
+
const u = new URL(req.url ?? "/", base);
|
|
690
|
+
const errorDescription = u.searchParams.get("error_description");
|
|
691
|
+
const error = u.searchParams.get("error");
|
|
692
|
+
const code = u.searchParams.get("code");
|
|
693
|
+
if (errorDescription || error) {
|
|
694
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
695
|
+
res.end(`<html><body><h3>Login failed</h3><p>${escapeHtml(errorDescription ?? error ?? "Unknown error")}</p></body></html>`);
|
|
696
|
+
rejectCode?.(new CliError("Login failed", { exitCode: 1, hint: errorDescription ?? error ?? void 0 }));
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
if (!code) {
|
|
700
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
701
|
+
res.end(`<html><body><h3>Missing code</h3><p>No authorization code was provided.</p></body></html>`);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
705
|
+
res.end(
|
|
706
|
+
`<html><body><h3>Login complete</h3><p>You can close this tab and return to the terminal.</p></body></html>`
|
|
707
|
+
);
|
|
708
|
+
resolveCode?.(code);
|
|
709
|
+
} catch (err) {
|
|
710
|
+
rejectCode?.(err);
|
|
711
|
+
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
|
712
|
+
res.end("Internal error");
|
|
713
|
+
} finally {
|
|
714
|
+
try {
|
|
715
|
+
server.close();
|
|
716
|
+
} catch {
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
const listenPort = typeof requestedPort === "number" ? requestedPort : 0;
|
|
721
|
+
await new Promise((resolve, reject) => {
|
|
722
|
+
server.once("error", reject);
|
|
723
|
+
server.listen(listenPort, "127.0.0.1", () => resolve());
|
|
724
|
+
});
|
|
725
|
+
const addr = server.address();
|
|
726
|
+
if (!addr || typeof addr === "string") {
|
|
727
|
+
server.close();
|
|
728
|
+
throw new CliError("Failed to start callback server", { exitCode: 1 });
|
|
729
|
+
}
|
|
730
|
+
const redirectTo = `http://127.0.0.1:${addr.port}/callback`;
|
|
731
|
+
const timeout = setTimeout(() => {
|
|
732
|
+
rejectCode?.(
|
|
733
|
+
new CliError("Login timed out", {
|
|
734
|
+
exitCode: 1,
|
|
735
|
+
hint: "Try running `comerge login` again."
|
|
736
|
+
})
|
|
737
|
+
);
|
|
738
|
+
try {
|
|
739
|
+
server.close();
|
|
740
|
+
} catch {
|
|
741
|
+
}
|
|
742
|
+
}, timeoutMs).unref();
|
|
743
|
+
const waitForCode = async () => {
|
|
744
|
+
try {
|
|
745
|
+
return await codePromise;
|
|
746
|
+
} finally {
|
|
747
|
+
clearTimeout(timeout);
|
|
748
|
+
}
|
|
749
|
+
};
|
|
750
|
+
const close = async () => {
|
|
751
|
+
clearTimeout(timeout);
|
|
752
|
+
await new Promise((resolve) => server.close(() => resolve())).catch(() => {
|
|
753
|
+
});
|
|
754
|
+
};
|
|
755
|
+
return { redirectTo, waitForCode, close };
|
|
756
|
+
}
|
|
757
|
+
function escapeHtml(input) {
|
|
758
|
+
return input.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/\"/g, """).replace(/'/g, "'");
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// src/lib/browser.ts
|
|
762
|
+
import { execa as execa2 } from "execa";
|
|
763
|
+
async function openBrowser(url) {
|
|
764
|
+
const platform = process.platform;
|
|
765
|
+
try {
|
|
766
|
+
if (platform === "darwin") {
|
|
767
|
+
await execa2("open", [url], { stdio: "ignore" });
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
if (platform === "win32") {
|
|
771
|
+
await execa2("cmd", ["/c", "start", "", url], { stdio: "ignore", windowsHide: true });
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
await execa2("xdg-open", [url], { stdio: "ignore" });
|
|
775
|
+
} catch (err) {
|
|
776
|
+
throw new CliError("Failed to open browser automatically", {
|
|
777
|
+
exitCode: 1,
|
|
778
|
+
hint: `Open this URL manually:
|
|
779
|
+
${url}
|
|
780
|
+
|
|
781
|
+
${err?.message ?? String(err)}`
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// src/lib/supabase.ts
|
|
787
|
+
import { createClient } from "@supabase/supabase-js";
|
|
788
|
+
function createInMemoryStorage() {
|
|
789
|
+
const map = /* @__PURE__ */ new Map();
|
|
790
|
+
return {
|
|
791
|
+
getItem: (k) => map.get(k) ?? null,
|
|
792
|
+
setItem: (k, v) => {
|
|
793
|
+
map.set(k, v);
|
|
794
|
+
},
|
|
795
|
+
removeItem: (k) => {
|
|
796
|
+
map.delete(k);
|
|
797
|
+
}
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
function createSupabaseClient(config, storage) {
|
|
801
|
+
return createClient(config.supabaseUrl, config.supabaseAnonKey, {
|
|
802
|
+
auth: {
|
|
803
|
+
flowType: "pkce",
|
|
804
|
+
persistSession: false,
|
|
805
|
+
autoRefreshToken: false,
|
|
806
|
+
detectSessionInUrl: false,
|
|
807
|
+
storage
|
|
808
|
+
},
|
|
809
|
+
global: {
|
|
810
|
+
headers: {
|
|
811
|
+
"X-Requested-By": "comerge-cli"
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
function toStoredSession(session) {
|
|
817
|
+
if (!session.access_token || !session.refresh_token || !session.expires_at) {
|
|
818
|
+
throw new CliError("Supabase session missing required fields", { exitCode: 1 });
|
|
819
|
+
}
|
|
820
|
+
return {
|
|
821
|
+
access_token: session.access_token,
|
|
822
|
+
refresh_token: session.refresh_token,
|
|
823
|
+
expires_at: session.expires_at,
|
|
824
|
+
token_type: session.token_type ?? void 0,
|
|
825
|
+
user: session.user ? { id: session.user.id, email: session.user.email ?? null } : void 0
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
function createSupabaseAuthHelpers(config) {
|
|
829
|
+
const storage = createInMemoryStorage();
|
|
830
|
+
const supabase = createSupabaseClient(config, storage);
|
|
831
|
+
return {
|
|
832
|
+
async startGoogleLogin(params) {
|
|
833
|
+
const { data, error } = await supabase.auth.signInWithOAuth({
|
|
834
|
+
provider: "google",
|
|
835
|
+
options: {
|
|
836
|
+
redirectTo: params.redirectTo,
|
|
837
|
+
skipBrowserRedirect: true,
|
|
838
|
+
queryParams: {
|
|
839
|
+
access_type: "offline",
|
|
840
|
+
prompt: "consent"
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
});
|
|
844
|
+
if (error) throw error;
|
|
845
|
+
if (!data?.url) throw new CliError("Supabase did not return an OAuth URL", { exitCode: 1 });
|
|
846
|
+
return { url: data.url };
|
|
847
|
+
},
|
|
848
|
+
async exchangeCode(params) {
|
|
849
|
+
const { data, error } = await supabase.auth.exchangeCodeForSession(params.code);
|
|
850
|
+
if (error) throw error;
|
|
851
|
+
if (!data?.session) throw new CliError("No session returned from Supabase", { exitCode: 1 });
|
|
852
|
+
return toStoredSession(data.session);
|
|
853
|
+
},
|
|
854
|
+
async refreshWithStoredSession(params) {
|
|
855
|
+
const { data, error } = await supabase.auth.setSession({
|
|
856
|
+
access_token: params.session.access_token,
|
|
857
|
+
refresh_token: params.session.refresh_token
|
|
858
|
+
});
|
|
859
|
+
if (error) throw error;
|
|
860
|
+
if (!data?.session) throw new CliError("No session returned after refresh", { exitCode: 1 });
|
|
861
|
+
return toStoredSession(data.session);
|
|
862
|
+
}
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// src/lib/sessionStore.ts
|
|
867
|
+
import fs8 from "fs/promises";
|
|
868
|
+
import os2 from "os";
|
|
869
|
+
import path6 from "path";
|
|
870
|
+
import { z as z2 } from "zod";
|
|
871
|
+
var storedSessionSchema = z2.object({
|
|
872
|
+
access_token: z2.string().min(1),
|
|
873
|
+
refresh_token: z2.string().min(1),
|
|
874
|
+
expires_at: z2.number().int().positive(),
|
|
875
|
+
token_type: z2.string().min(1).optional(),
|
|
876
|
+
user: z2.object({
|
|
877
|
+
id: z2.string().min(1),
|
|
878
|
+
email: z2.string().email().optional().nullable()
|
|
879
|
+
}).optional()
|
|
880
|
+
});
|
|
881
|
+
var KEYTAR_SERVICE = "comerge-cli";
|
|
882
|
+
var KEYTAR_ACCOUNT = "default";
|
|
883
|
+
function xdgConfigHome() {
|
|
884
|
+
const v = process.env.XDG_CONFIG_HOME;
|
|
885
|
+
if (typeof v === "string" && v.trim().length > 0) return v;
|
|
886
|
+
return path6.join(os2.homedir(), ".config");
|
|
887
|
+
}
|
|
888
|
+
function sessionFilePath() {
|
|
889
|
+
return path6.join(xdgConfigHome(), "comerge", "session.json");
|
|
890
|
+
}
|
|
891
|
+
async function maybeLoadKeytar() {
|
|
892
|
+
try {
|
|
893
|
+
const mod = await import("keytar");
|
|
894
|
+
const candidates = [mod?.default, mod].filter(Boolean);
|
|
895
|
+
for (const c of candidates) {
|
|
896
|
+
if (c && typeof c.getPassword === "function" && typeof c.setPassword === "function" && typeof c.deletePassword === "function") {
|
|
897
|
+
return c;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
return null;
|
|
901
|
+
} catch {
|
|
902
|
+
return null;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
async function ensurePathPermissions(filePath) {
|
|
906
|
+
const dir = path6.dirname(filePath);
|
|
907
|
+
await fs8.mkdir(dir, { recursive: true });
|
|
908
|
+
try {
|
|
909
|
+
await fs8.chmod(dir, 448);
|
|
910
|
+
} catch {
|
|
911
|
+
}
|
|
912
|
+
try {
|
|
913
|
+
await fs8.chmod(filePath, 384);
|
|
914
|
+
} catch {
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
async function getSession() {
|
|
918
|
+
const keytar = await maybeLoadKeytar();
|
|
919
|
+
if (keytar) {
|
|
920
|
+
const raw2 = await keytar.getPassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT);
|
|
921
|
+
if (!raw2) return null;
|
|
922
|
+
try {
|
|
923
|
+
const parsed = storedSessionSchema.safeParse(JSON.parse(raw2));
|
|
924
|
+
if (!parsed.success) return null;
|
|
925
|
+
return parsed.data;
|
|
926
|
+
} catch {
|
|
927
|
+
return null;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
const fp = sessionFilePath();
|
|
931
|
+
const raw = await fs8.readFile(fp, "utf8").catch(() => null);
|
|
932
|
+
if (!raw) return null;
|
|
933
|
+
try {
|
|
934
|
+
const parsed = storedSessionSchema.safeParse(JSON.parse(raw));
|
|
935
|
+
if (!parsed.success) return null;
|
|
936
|
+
await ensurePathPermissions(fp);
|
|
937
|
+
return parsed.data;
|
|
938
|
+
} catch {
|
|
939
|
+
return null;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
async function setSession(session) {
|
|
943
|
+
const parsed = storedSessionSchema.safeParse(session);
|
|
944
|
+
if (!parsed.success) {
|
|
945
|
+
throw new CliError("Refusing to store invalid session", { exitCode: 1 });
|
|
946
|
+
}
|
|
947
|
+
const keytar = await maybeLoadKeytar();
|
|
948
|
+
if (keytar) {
|
|
949
|
+
await keytar.setPassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT, JSON.stringify(parsed.data));
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
const fp = sessionFilePath();
|
|
953
|
+
await writeJsonAtomic(fp, parsed.data);
|
|
954
|
+
await ensurePathPermissions(fp);
|
|
955
|
+
}
|
|
956
|
+
async function clearSession() {
|
|
957
|
+
const keytar = await maybeLoadKeytar();
|
|
958
|
+
if (keytar) {
|
|
959
|
+
await keytar.deletePassword(KEYTAR_SERVICE, KEYTAR_ACCOUNT);
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
const fp = sessionFilePath();
|
|
963
|
+
await fs8.rm(fp, { force: true }).catch(() => {
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// src/lib/api.ts
|
|
968
|
+
function shouldRefreshSoon(session, skewSeconds = 60) {
|
|
969
|
+
const nowSec = Math.floor(Date.now() / 1e3);
|
|
970
|
+
return session.expires_at <= nowSec + skewSeconds;
|
|
971
|
+
}
|
|
972
|
+
async function readJsonSafe(res) {
|
|
973
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
974
|
+
if (!ct.toLowerCase().includes("application/json")) return null;
|
|
975
|
+
try {
|
|
976
|
+
return await res.json();
|
|
977
|
+
} catch {
|
|
978
|
+
return null;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
async function getAuthToken(config) {
|
|
982
|
+
const envToken = process.env.COMERGE_ACCESS_TOKEN;
|
|
983
|
+
if (typeof envToken === "string" && envToken.trim().length > 0) {
|
|
984
|
+
return { token: envToken.trim(), session: null, fromEnv: true };
|
|
985
|
+
}
|
|
986
|
+
let session = await getSession();
|
|
987
|
+
if (!session) {
|
|
988
|
+
throw new CliError("Not logged in", {
|
|
989
|
+
exitCode: 2,
|
|
990
|
+
hint: "Run `comerge login` first, or set COMERGE_ACCESS_TOKEN for CI."
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
if (shouldRefreshSoon(session)) {
|
|
994
|
+
try {
|
|
995
|
+
const supabase = createSupabaseAuthHelpers(config);
|
|
996
|
+
session = await supabase.refreshWithStoredSession({ session });
|
|
997
|
+
await setSession(session);
|
|
998
|
+
} catch (err) {
|
|
999
|
+
void err;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
return { token: session.access_token, session, fromEnv: false };
|
|
1003
|
+
}
|
|
1004
|
+
function createApiClient(config) {
|
|
1005
|
+
const cfg = config ?? resolveConfig();
|
|
1006
|
+
async function request(path10, init) {
|
|
1007
|
+
const { token, session, fromEnv } = await getAuthToken(cfg);
|
|
1008
|
+
const url = new URL(path10, cfg.apiUrl).toString();
|
|
1009
|
+
const doFetch = async (bearer) => {
|
|
1010
|
+
const res2 = await fetch(url, {
|
|
1011
|
+
...init,
|
|
1012
|
+
headers: {
|
|
1013
|
+
Accept: "application/json",
|
|
1014
|
+
"Content-Type": "application/json",
|
|
1015
|
+
...init?.headers ?? {},
|
|
1016
|
+
Authorization: `Bearer ${bearer}`
|
|
1017
|
+
}
|
|
1018
|
+
});
|
|
1019
|
+
return res2;
|
|
1020
|
+
};
|
|
1021
|
+
let res = await doFetch(token);
|
|
1022
|
+
if (res.status === 401 && !fromEnv && session?.refresh_token) {
|
|
1023
|
+
const supabase = createSupabaseAuthHelpers(cfg);
|
|
1024
|
+
const refreshed = await supabase.refreshWithStoredSession({ session });
|
|
1025
|
+
await setSession(refreshed);
|
|
1026
|
+
res = await doFetch(refreshed.access_token);
|
|
1027
|
+
}
|
|
1028
|
+
if (!res.ok) {
|
|
1029
|
+
const body = await readJsonSafe(res);
|
|
1030
|
+
const msg = (body && typeof body === "object" && body && "message" in body && typeof body.message === "string" ? body.message : null) ?? `Request failed (${res.status})`;
|
|
1031
|
+
throw new CliError(msg, { exitCode: 1, hint: body ? JSON.stringify(body, null, 2) : null });
|
|
1032
|
+
}
|
|
1033
|
+
const json = await readJsonSafe(res);
|
|
1034
|
+
return json ?? null;
|
|
1035
|
+
}
|
|
1036
|
+
return {
|
|
1037
|
+
getMe: () => request("/v1/me", { method: "GET" }),
|
|
1038
|
+
getApp: (appId) => request(`/v1/apps/${encodeURIComponent(appId)}`, { method: "GET" }),
|
|
1039
|
+
presignImportUpload: (payload) => request("/v1/apps/import/upload/presign", { method: "POST", body: JSON.stringify(payload) }),
|
|
1040
|
+
importFromUpload: (payload) => request("/v1/apps/import/upload", { method: "POST", body: JSON.stringify(payload) })
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
// src/commands/login.ts
|
|
1045
|
+
function registerLoginCommand(program) {
|
|
1046
|
+
program.command("login").description("Authenticate with Comerge").option("--no-browser", "Do not open browser automatically; print the URL").option("--port <port>", "Local callback port (default: random available port)").option("--json", "Output machine-readable JSON", false).action(async (opts) => {
|
|
1047
|
+
const config = resolveConfig();
|
|
1048
|
+
const portRaw = typeof opts.port === "string" ? opts.port.trim() : "";
|
|
1049
|
+
let port = null;
|
|
1050
|
+
if (portRaw) {
|
|
1051
|
+
const parsed = Number(portRaw);
|
|
1052
|
+
if (!Number.isFinite(parsed) || parsed <= 0 || !Number.isInteger(parsed)) {
|
|
1053
|
+
throw new CliError("Invalid --port", { exitCode: 2, hint: "Example: --port 8085" });
|
|
1054
|
+
}
|
|
1055
|
+
port = parsed;
|
|
1056
|
+
}
|
|
1057
|
+
const server = await startOAuthCallbackServer({ port });
|
|
1058
|
+
try {
|
|
1059
|
+
const supabase = createSupabaseAuthHelpers(config);
|
|
1060
|
+
const { url } = await supabase.startGoogleLogin({ redirectTo: server.redirectTo });
|
|
1061
|
+
const shouldOpenBrowser = Boolean(opts.browser ?? true);
|
|
1062
|
+
if (shouldOpenBrowser) {
|
|
1063
|
+
try {
|
|
1064
|
+
await openBrowser(url);
|
|
1065
|
+
console.log(pc2.dim("Opened browser for login..."));
|
|
1066
|
+
} catch (err) {
|
|
1067
|
+
console.error(pc2.yellow("Could not open the browser automatically."));
|
|
1068
|
+
console.error(pc2.dim(err?.message ?? String(err)));
|
|
1069
|
+
console.log(`Open this URL manually:
|
|
1070
|
+
${url}`);
|
|
1071
|
+
}
|
|
1072
|
+
} else {
|
|
1073
|
+
console.log(`Open this URL to login:
|
|
1074
|
+
${url}`);
|
|
1075
|
+
}
|
|
1076
|
+
const code = await server.waitForCode();
|
|
1077
|
+
const session = await supabase.exchangeCode({ code });
|
|
1078
|
+
await setSession(session);
|
|
1079
|
+
let me = null;
|
|
1080
|
+
try {
|
|
1081
|
+
const api = createApiClient(config);
|
|
1082
|
+
me = await api.getMe();
|
|
1083
|
+
} catch (err) {
|
|
1084
|
+
await clearSession().catch(() => {
|
|
1085
|
+
});
|
|
1086
|
+
throw err;
|
|
1087
|
+
}
|
|
1088
|
+
if (opts.json) {
|
|
1089
|
+
console.log(JSON.stringify({ success: true, me }, null, 2));
|
|
1090
|
+
} else {
|
|
1091
|
+
const profile = me?.responseObject;
|
|
1092
|
+
const label = profile?.email ? `${profile.email}` : profile?.id ? `user ${profile.id}` : "user";
|
|
1093
|
+
console.log(pc2.green(`Logged in as ${label}.`));
|
|
1094
|
+
}
|
|
1095
|
+
} finally {
|
|
1096
|
+
await server.close();
|
|
1097
|
+
}
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// src/commands/logout.ts
|
|
1102
|
+
import pc3 from "picocolors";
|
|
1103
|
+
function registerLogoutCommand(program) {
|
|
1104
|
+
program.command("logout").description("Clear local Comerge authentication session").option("--json", "Output machine-readable JSON", false).action(async (opts) => {
|
|
1105
|
+
await clearSession();
|
|
1106
|
+
const envToken = process.env.COMERGE_ACCESS_TOKEN;
|
|
1107
|
+
if (opts.json) {
|
|
1108
|
+
console.log(JSON.stringify({ success: true, cleared: true, envTokenPresent: Boolean(envToken) }, null, 2));
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
console.log(pc3.green("Logged out (local session cleared)."));
|
|
1112
|
+
if (envToken) {
|
|
1113
|
+
console.log(pc3.dim("Note: COMERGE_ACCESS_TOKEN is set in your environment; it will still authenticate requests."));
|
|
1114
|
+
}
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// src/commands/whoami.ts
|
|
1119
|
+
import pc4 from "picocolors";
|
|
1120
|
+
function registerWhoamiCommand(program) {
|
|
1121
|
+
program.command("whoami").description("Show the currently authenticated Comerge user").option("--json", "Output machine-readable JSON", false).action(async (opts) => {
|
|
1122
|
+
const config = resolveConfig();
|
|
1123
|
+
const api = createApiClient(config);
|
|
1124
|
+
const me = await api.getMe();
|
|
1125
|
+
if (opts.json) {
|
|
1126
|
+
console.log(JSON.stringify(me, null, 2));
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
const profile = me?.responseObject;
|
|
1130
|
+
const label = profile?.email ? `${profile.email}` : profile?.id ? `user ${profile.id}` : "unknown user";
|
|
1131
|
+
console.log(pc4.green(label));
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// src/commands/import.ts
|
|
1136
|
+
import pc6 from "picocolors";
|
|
1137
|
+
|
|
1138
|
+
// src/lib/importLocal.ts
|
|
1139
|
+
import path9 from "path";
|
|
1140
|
+
import pc5 from "picocolors";
|
|
1141
|
+
|
|
1142
|
+
// src/lib/gitFiles.ts
|
|
1143
|
+
import { execa as execa3 } from "execa";
|
|
1144
|
+
async function canUseGit(cwd) {
|
|
1145
|
+
try {
|
|
1146
|
+
const res = await execa3("git", ["rev-parse", "--is-inside-work-tree"], { cwd, stdio: "ignore" });
|
|
1147
|
+
return res.exitCode === 0;
|
|
1148
|
+
} catch {
|
|
1149
|
+
return false;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
function parseGitLsFilesZ(buf) {
|
|
1153
|
+
return buf.toString("utf8").split("\0").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
1154
|
+
}
|
|
1155
|
+
async function listFilesGit(params) {
|
|
1156
|
+
const args = ["ls-files", "-z", "--cached", "--others", "--exclude-standard"];
|
|
1157
|
+
if (params.pathspec) {
|
|
1158
|
+
args.push("--", params.pathspec);
|
|
1159
|
+
}
|
|
1160
|
+
const res = await execa3("git", args, { cwd: params.cwd, stdout: "pipe", stderr: "ignore" });
|
|
1161
|
+
return parseGitLsFilesZ(Buffer.from(res.stdout ?? "", "utf8"));
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// src/lib/fileSelection.ts
|
|
1165
|
+
import fs9 from "fs/promises";
|
|
1166
|
+
import path7 from "path";
|
|
1167
|
+
import fg from "fast-glob";
|
|
1168
|
+
import ignore from "ignore";
|
|
1169
|
+
var BUILTIN_IGNORES = [
|
|
1170
|
+
"**/node_modules/**",
|
|
1171
|
+
"**/.git/**",
|
|
1172
|
+
"**/.expo/**",
|
|
1173
|
+
"**/dist/**",
|
|
1174
|
+
"**/build/**",
|
|
1175
|
+
"**/coverage/**",
|
|
1176
|
+
"**/.turbo/**",
|
|
1177
|
+
"**/.next/**",
|
|
1178
|
+
"**/ios/build/**",
|
|
1179
|
+
"**/android/build/**",
|
|
1180
|
+
"**/.pnpm-store/**"
|
|
1181
|
+
];
|
|
1182
|
+
function normalizeRel2(p) {
|
|
1183
|
+
return p.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
1184
|
+
}
|
|
1185
|
+
async function readGitignore(root) {
|
|
1186
|
+
const fp = path7.join(root, ".gitignore");
|
|
1187
|
+
return await fs9.readFile(fp, "utf8").catch(() => "");
|
|
1188
|
+
}
|
|
1189
|
+
async function listFilesFs(params) {
|
|
1190
|
+
const ig = ignore();
|
|
1191
|
+
const raw = await readGitignore(params.root);
|
|
1192
|
+
if (raw) ig.add(raw);
|
|
1193
|
+
const entries = await fg(["**/*"], {
|
|
1194
|
+
cwd: params.root,
|
|
1195
|
+
onlyFiles: true,
|
|
1196
|
+
dot: true,
|
|
1197
|
+
followSymbolicLinks: false,
|
|
1198
|
+
unique: true,
|
|
1199
|
+
ignore: [...BUILTIN_IGNORES]
|
|
1200
|
+
});
|
|
1201
|
+
const filtered = entries.filter((p) => {
|
|
1202
|
+
const rel = normalizeRel2(p);
|
|
1203
|
+
if (!params.includeDotenv && path7.posix.basename(rel).startsWith(".env")) return false;
|
|
1204
|
+
return !ig.ignores(rel);
|
|
1205
|
+
});
|
|
1206
|
+
return filtered.map(normalizeRel2);
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// src/lib/zip.ts
|
|
1210
|
+
import crypto from "crypto";
|
|
1211
|
+
import fs10 from "fs";
|
|
1212
|
+
import fsp from "fs/promises";
|
|
1213
|
+
import os3 from "os";
|
|
1214
|
+
import path8 from "path";
|
|
1215
|
+
import archiver from "archiver";
|
|
1216
|
+
var MAX_IMPORT_ZIP_BYTES = 50 * 1024 * 1024;
|
|
1217
|
+
var MAX_IMPORT_FILE_COUNT = 25e3;
|
|
1218
|
+
function normalizeRel3(p) {
|
|
1219
|
+
return p.replace(/\\/g, "/").replace(/\/+/g, "/").replace(/^\/+/, "");
|
|
1220
|
+
}
|
|
1221
|
+
function validateRelPath(rel) {
|
|
1222
|
+
const p = normalizeRel3(rel);
|
|
1223
|
+
if (!p) throw new CliError("Invalid file path", { exitCode: 1 });
|
|
1224
|
+
if (p.startsWith("../") || p.includes("/../") || p === "..") {
|
|
1225
|
+
throw new CliError("Refusing to zip path traversal entry", { exitCode: 1, hint: p });
|
|
1226
|
+
}
|
|
1227
|
+
if (p.startsWith("/")) {
|
|
1228
|
+
throw new CliError("Refusing to zip absolute path", { exitCode: 1, hint: p });
|
|
1229
|
+
}
|
|
1230
|
+
return p;
|
|
1231
|
+
}
|
|
1232
|
+
async function computeStats(params) {
|
|
1233
|
+
const largestN = params.largestN ?? 10;
|
|
1234
|
+
if (params.files.length > MAX_IMPORT_FILE_COUNT) {
|
|
1235
|
+
throw new CliError("Too many files to import", {
|
|
1236
|
+
exitCode: 2,
|
|
1237
|
+
hint: `File count ${params.files.length} exceeds limit ${MAX_IMPORT_FILE_COUNT}. Use --path or add ignores.`
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
let totalBytes = 0;
|
|
1241
|
+
const largest = [];
|
|
1242
|
+
for (const rel0 of params.files) {
|
|
1243
|
+
const rel = validateRelPath(rel0);
|
|
1244
|
+
const abs = path8.join(params.root, rel);
|
|
1245
|
+
const st = await fsp.stat(abs);
|
|
1246
|
+
if (!st.isFile()) continue;
|
|
1247
|
+
const bytes = st.size;
|
|
1248
|
+
totalBytes += bytes;
|
|
1249
|
+
if (largestN > 0) {
|
|
1250
|
+
largest.push({ path: rel, bytes });
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
largest.sort((a, b) => b.bytes - a.bytes);
|
|
1254
|
+
return {
|
|
1255
|
+
fileCount: params.files.length,
|
|
1256
|
+
totalBytes,
|
|
1257
|
+
largestFiles: largest.slice(0, largestN)
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
async function createZip(params) {
|
|
1261
|
+
const zipName = (params.zipName ?? "import.zip").replace(/[^a-zA-Z0-9._-]+/g, "_");
|
|
1262
|
+
const tmpDir = await fsp.mkdtemp(path8.join(os3.tmpdir(), "comerge-import-"));
|
|
1263
|
+
const zipPath = path8.join(tmpDir, zipName);
|
|
1264
|
+
await new Promise((resolve, reject) => {
|
|
1265
|
+
const output = fs10.createWriteStream(zipPath);
|
|
1266
|
+
const archive = archiver("zip", { zlib: { level: 9 } });
|
|
1267
|
+
output.on("close", () => resolve());
|
|
1268
|
+
output.on("error", (err) => reject(err));
|
|
1269
|
+
archive.on("warning", (err) => {
|
|
1270
|
+
reject(err);
|
|
1271
|
+
});
|
|
1272
|
+
archive.on("error", (err) => reject(err));
|
|
1273
|
+
archive.pipe(output);
|
|
1274
|
+
for (const rel0 of params.files) {
|
|
1275
|
+
const rel = validateRelPath(rel0);
|
|
1276
|
+
const abs = path8.join(params.root, rel);
|
|
1277
|
+
archive.file(abs, { name: rel });
|
|
1278
|
+
}
|
|
1279
|
+
void archive.finalize();
|
|
1280
|
+
});
|
|
1281
|
+
const st = await fsp.stat(zipPath);
|
|
1282
|
+
if (st.size > MAX_IMPORT_ZIP_BYTES) {
|
|
1283
|
+
throw new CliError("Archive exceeds max upload size", {
|
|
1284
|
+
exitCode: 2,
|
|
1285
|
+
hint: `Zip size ${st.size} bytes exceeds limit ${MAX_IMPORT_ZIP_BYTES} bytes. Use --path or add ignores.`
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
return { zipPath };
|
|
1289
|
+
}
|
|
1290
|
+
async function sha256FileHex(filePath) {
|
|
1291
|
+
const hash = crypto.createHash("sha256");
|
|
1292
|
+
await new Promise((resolve, reject) => {
|
|
1293
|
+
const s = fs10.createReadStream(filePath);
|
|
1294
|
+
s.on("data", (chunk) => hash.update(chunk));
|
|
1295
|
+
s.on("error", reject);
|
|
1296
|
+
s.on("end", () => resolve());
|
|
1297
|
+
});
|
|
1298
|
+
return hash.digest("hex");
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
// src/lib/upload.ts
|
|
1302
|
+
import fs11 from "fs";
|
|
1303
|
+
import { PassThrough } from "stream";
|
|
1304
|
+
async function uploadPresigned(params) {
|
|
1305
|
+
const st = await fs11.promises.stat(params.filePath).catch(() => null);
|
|
1306
|
+
if (!st || !st.isFile()) throw new CliError("Upload file not found", { exitCode: 2 });
|
|
1307
|
+
const totalBytes = st.size;
|
|
1308
|
+
const fileStream = fs11.createReadStream(params.filePath);
|
|
1309
|
+
const pass = new PassThrough();
|
|
1310
|
+
let sent = 0;
|
|
1311
|
+
fileStream.on("data", (chunk) => {
|
|
1312
|
+
sent += chunk.length;
|
|
1313
|
+
params.onProgress?.({ sentBytes: sent, totalBytes });
|
|
1314
|
+
});
|
|
1315
|
+
fileStream.on("error", (err) => pass.destroy(err));
|
|
1316
|
+
fileStream.pipe(pass);
|
|
1317
|
+
const res = await fetch(params.uploadUrl, {
|
|
1318
|
+
method: "PUT",
|
|
1319
|
+
headers: params.headers,
|
|
1320
|
+
body: pass,
|
|
1321
|
+
duplex: "half"
|
|
1322
|
+
});
|
|
1323
|
+
if (!res.ok) {
|
|
1324
|
+
const text = await res.text().catch(() => "");
|
|
1325
|
+
throw new CliError("Upload failed", {
|
|
1326
|
+
exitCode: 1,
|
|
1327
|
+
hint: `Status: ${res.status}
|
|
1328
|
+
${text}`.trim() || null
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// src/lib/importLocal.ts
|
|
1334
|
+
var SAFE_PATH_RE = /^[A-Za-z0-9._/-]+$/;
|
|
1335
|
+
function sleep(ms) {
|
|
1336
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
1337
|
+
}
|
|
1338
|
+
function formatBytes(bytes) {
|
|
1339
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
1340
|
+
let v = bytes;
|
|
1341
|
+
let i = 0;
|
|
1342
|
+
while (v >= 1024 && i < units.length - 1) {
|
|
1343
|
+
v /= 1024;
|
|
1344
|
+
i++;
|
|
1345
|
+
}
|
|
1346
|
+
return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
|
1347
|
+
}
|
|
1348
|
+
function deriveAppName(projectRoot) {
|
|
1349
|
+
const base = path9.basename(projectRoot);
|
|
1350
|
+
return base || "Imported App";
|
|
1351
|
+
}
|
|
1352
|
+
function validateOptionalSubdir(subdir) {
|
|
1353
|
+
if (!subdir) return null;
|
|
1354
|
+
const s = subdir.trim().replace(/^\/+/, "").replace(/\\/g, "/");
|
|
1355
|
+
if (!s) return null;
|
|
1356
|
+
if (!SAFE_PATH_RE.test(s) || s.includes("..")) {
|
|
1357
|
+
throw new CliError("Invalid --path", {
|
|
1358
|
+
exitCode: 2,
|
|
1359
|
+
hint: "Use a safe subdirectory like packages/mobile-app (letters/numbers/._-/ only)."
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1362
|
+
return s;
|
|
1363
|
+
}
|
|
1364
|
+
function getAppStatus(appResp) {
|
|
1365
|
+
const obj = appResp?.responseObject;
|
|
1366
|
+
const status = typeof obj?.status === "string" ? obj.status : null;
|
|
1367
|
+
const statusError = typeof obj?.statusError === "string" ? obj.statusError : null;
|
|
1368
|
+
return { status, statusError };
|
|
1369
|
+
}
|
|
1370
|
+
async function importLocal(params) {
|
|
1371
|
+
const cfg = resolveConfig();
|
|
1372
|
+
const api = createApiClient(cfg);
|
|
1373
|
+
const projectRoot = await findExpoProjectRoot(process.cwd());
|
|
1374
|
+
const subdir = validateOptionalSubdir(params.subdir);
|
|
1375
|
+
const importRoot = subdir ? path9.join(projectRoot, subdir) : projectRoot;
|
|
1376
|
+
const appName = (params.appName ?? "").trim() || deriveAppName(projectRoot);
|
|
1377
|
+
const log = (msg) => {
|
|
1378
|
+
if (params.json) return;
|
|
1379
|
+
console.log(msg);
|
|
1380
|
+
};
|
|
1381
|
+
log(pc5.dim(`Project: ${projectRoot}`));
|
|
1382
|
+
if (subdir) log(pc5.dim(`Import path: ${subdir}`));
|
|
1383
|
+
let files = [];
|
|
1384
|
+
const useGit = await canUseGit(projectRoot);
|
|
1385
|
+
if (useGit) {
|
|
1386
|
+
const gitPaths = await listFilesGit({ cwd: projectRoot, pathspec: subdir ?? null });
|
|
1387
|
+
files = gitPaths;
|
|
1388
|
+
if (!params.includeDotenv) {
|
|
1389
|
+
files = files.filter((p) => !path9.posix.basename(p).startsWith(".env"));
|
|
1390
|
+
}
|
|
1391
|
+
} else {
|
|
1392
|
+
files = await listFilesFs({ root: importRoot, includeDotenv: params.includeDotenv });
|
|
1393
|
+
if (subdir) {
|
|
1394
|
+
files = files.map((p) => path9.posix.join(subdir, p));
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
if (files.length === 0) {
|
|
1398
|
+
throw new CliError("No files found to upload", { exitCode: 2, hint: "Check .gitignore rules or try without --path." });
|
|
1399
|
+
}
|
|
1400
|
+
const stats = await computeStats({ root: projectRoot, files, largestN: 10 });
|
|
1401
|
+
log(pc5.dim(`Files: ${stats.fileCount} Total: ${formatBytes(stats.totalBytes)}`));
|
|
1402
|
+
if (params.dryRun) {
|
|
1403
|
+
const top = stats.largestFiles.slice(0, 10);
|
|
1404
|
+
if (top.length > 0) {
|
|
1405
|
+
log(pc5.dim("Largest files:"));
|
|
1406
|
+
for (const f of top) {
|
|
1407
|
+
log(pc5.dim(` ${formatBytes(f.bytes)} ${f.path}`));
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
return { uploadId: "dry-run", appId: "dry-run", status: "dry-run" };
|
|
1411
|
+
}
|
|
1412
|
+
log(pc5.dim("Creating zip..."));
|
|
1413
|
+
const { zipPath } = await createZip({ root: projectRoot, files, zipName: "import.zip" });
|
|
1414
|
+
const zipSha = await sha256FileHex(zipPath);
|
|
1415
|
+
const zipSize = (await (await import("fs/promises")).stat(zipPath)).size;
|
|
1416
|
+
log(pc5.dim("Requesting upload URL..."));
|
|
1417
|
+
const presignResp = await api.presignImportUpload({
|
|
1418
|
+
file: {
|
|
1419
|
+
name: "import.zip",
|
|
1420
|
+
mimeType: "application/zip",
|
|
1421
|
+
size: zipSize,
|
|
1422
|
+
checksumSha256: zipSha
|
|
1423
|
+
}
|
|
1424
|
+
});
|
|
1425
|
+
const presign = presignResp?.responseObject;
|
|
1426
|
+
const uploadId = String(presign?.uploadId ?? "");
|
|
1427
|
+
const uploadUrl = String(presign?.uploadUrl ?? "");
|
|
1428
|
+
const headers = presign?.headers ?? {};
|
|
1429
|
+
if (!uploadId || !uploadUrl) {
|
|
1430
|
+
throw new CliError("Presign response missing upload fields", { exitCode: 1, hint: JSON.stringify(presignResp, null, 2) });
|
|
1431
|
+
}
|
|
1432
|
+
log(pc5.dim("Uploading zip..."));
|
|
1433
|
+
let lastPct = -1;
|
|
1434
|
+
await uploadPresigned({
|
|
1435
|
+
uploadUrl,
|
|
1436
|
+
headers,
|
|
1437
|
+
filePath: zipPath,
|
|
1438
|
+
onProgress: ({ sentBytes, totalBytes }) => {
|
|
1439
|
+
if (params.json) return;
|
|
1440
|
+
const pct = totalBytes > 0 ? Math.floor(sentBytes / totalBytes * 100) : 0;
|
|
1441
|
+
if (pct !== lastPct && (pct % 5 === 0 || pct === 100)) {
|
|
1442
|
+
lastPct = pct;
|
|
1443
|
+
process.stdout.write(pc5.dim(`Upload ${pct}%\r`));
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
});
|
|
1447
|
+
if (!params.json) process.stdout.write("\n");
|
|
1448
|
+
log(pc5.dim("Triggering import..."));
|
|
1449
|
+
const importResp = await api.importFromUpload({
|
|
1450
|
+
uploadId,
|
|
1451
|
+
appName,
|
|
1452
|
+
path: subdir ?? void 0
|
|
1453
|
+
});
|
|
1454
|
+
const importObj = importResp?.responseObject;
|
|
1455
|
+
const appId = String(importObj?.appId ?? "");
|
|
1456
|
+
const projectId = importObj?.projectId ? String(importObj.projectId) : void 0;
|
|
1457
|
+
const threadId = importObj?.threadId ? String(importObj.threadId) : void 0;
|
|
1458
|
+
if (!appId) {
|
|
1459
|
+
throw new CliError("Import response missing appId", { exitCode: 1, hint: JSON.stringify(importResp, null, 2) });
|
|
1460
|
+
}
|
|
1461
|
+
if (params.noWait) {
|
|
1462
|
+
return { uploadId, appId, projectId, threadId, status: "accepted" };
|
|
1463
|
+
}
|
|
1464
|
+
log(pc5.dim("Waiting for build..."));
|
|
1465
|
+
const startedAt = Date.now();
|
|
1466
|
+
let delay = 2e3;
|
|
1467
|
+
let lastStatus = null;
|
|
1468
|
+
while (Date.now() - startedAt < params.maxWaitMs) {
|
|
1469
|
+
const appResp = await api.getApp(appId);
|
|
1470
|
+
const { status, statusError } = getAppStatus(appResp);
|
|
1471
|
+
if (status && status !== lastStatus) {
|
|
1472
|
+
lastStatus = status;
|
|
1473
|
+
log(pc5.dim(`Status: ${status}`));
|
|
1474
|
+
}
|
|
1475
|
+
if (status === "ready") {
|
|
1476
|
+
return { uploadId, appId, projectId, threadId, status };
|
|
1477
|
+
}
|
|
1478
|
+
if (status === "error") {
|
|
1479
|
+
throw new CliError("Import failed", { exitCode: 1, hint: statusError ?? "App status is error" });
|
|
1480
|
+
}
|
|
1481
|
+
await sleep(delay);
|
|
1482
|
+
delay = Math.min(1e4, Math.floor(delay * 1.4));
|
|
1483
|
+
}
|
|
1484
|
+
throw new CliError("Timed out waiting for import", {
|
|
1485
|
+
exitCode: 1,
|
|
1486
|
+
hint: `Try again with --max-wait or check app status with: comerge import local --no-wait`
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
// src/commands/import.ts
|
|
1491
|
+
function registerImportCommands(program) {
|
|
1492
|
+
const imp = program.command("import").description("Import source code into Comerge");
|
|
1493
|
+
imp.command("local").description("Import the current local Expo project").option("--name <appName>", "Override app name (default: derived from Expo config / folder name)").option("--path <subdir>", "Optional subdirectory to import").option("--max-wait <sec>", "Max time to wait for import to finish (default: 1200)", "1200").option("--json", "Output machine-readable JSON", false).option("--no-wait", "Do not poll for completion; return immediately after enqueue", false).option("--no-include-dotenv", "Exclude .env* files from the uploaded archive", false).option("--dry-run", "Show what would be uploaded (no upload)", false).action(async (opts) => {
|
|
1494
|
+
const maxWaitSec = Number(String(opts.maxWait ?? "1200").trim());
|
|
1495
|
+
if (!Number.isFinite(maxWaitSec) || maxWaitSec <= 0) {
|
|
1496
|
+
throw new CliError("Invalid --max-wait", { exitCode: 2, hint: "Example: --max-wait 1200" });
|
|
1497
|
+
}
|
|
1498
|
+
const res = await importLocal({
|
|
1499
|
+
appName: opts.name ? String(opts.name) : null,
|
|
1500
|
+
subdir: opts.path ? String(opts.path) : null,
|
|
1501
|
+
maxWaitMs: Math.floor(maxWaitSec * 1e3),
|
|
1502
|
+
noWait: Boolean(opts.noWait),
|
|
1503
|
+
json: Boolean(opts.json),
|
|
1504
|
+
includeDotenv: Boolean(opts.includeDotenv),
|
|
1505
|
+
dryRun: Boolean(opts.dryRun)
|
|
1506
|
+
});
|
|
1507
|
+
if (Boolean(opts.json)) {
|
|
1508
|
+
console.log(JSON.stringify(res, null, 2));
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
console.log(pc6.green(`Import accepted. appId=${res.appId}`));
|
|
1512
|
+
if (res.status && !opts.noWait) {
|
|
1513
|
+
console.log(pc6.dim(`Final status: ${res.status}`));
|
|
1514
|
+
}
|
|
1515
|
+
});
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// src/commands/init.ts
|
|
1519
|
+
import pc9 from "picocolors";
|
|
1520
|
+
|
|
1521
|
+
// src/lib/initLocal.ts
|
|
1522
|
+
import pc8 from "picocolors";
|
|
1523
|
+
|
|
1524
|
+
// src/lib/ensureAuth.ts
|
|
1525
|
+
import pc7 from "picocolors";
|
|
1526
|
+
function isInteractive() {
|
|
1527
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
1528
|
+
}
|
|
1529
|
+
async function validateBackendSession() {
|
|
1530
|
+
const cfg = resolveConfig();
|
|
1531
|
+
const api = createApiClient(cfg);
|
|
1532
|
+
await api.getMe();
|
|
1533
|
+
}
|
|
1534
|
+
async function ensureAuth(params) {
|
|
1535
|
+
const envToken = process.env.COMERGE_ACCESS_TOKEN;
|
|
1536
|
+
if (typeof envToken === "string" && envToken.trim().length > 0) return;
|
|
1537
|
+
const existing = await getSession();
|
|
1538
|
+
if (existing) {
|
|
1539
|
+
try {
|
|
1540
|
+
await validateBackendSession();
|
|
1541
|
+
return;
|
|
1542
|
+
} catch {
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
const interactive = isInteractive();
|
|
1546
|
+
const yes = Boolean(params?.yes);
|
|
1547
|
+
if (!interactive || yes) {
|
|
1548
|
+
throw new CliError("Not logged in", {
|
|
1549
|
+
exitCode: 2,
|
|
1550
|
+
hint: "Run `comerge login` first, or set COMERGE_ACCESS_TOKEN for CI."
|
|
1551
|
+
});
|
|
1552
|
+
}
|
|
1553
|
+
const cfg = resolveConfig();
|
|
1554
|
+
const server = await startOAuthCallbackServer({ port: null });
|
|
1555
|
+
try {
|
|
1556
|
+
const supabase = createSupabaseAuthHelpers(cfg);
|
|
1557
|
+
const { url } = await supabase.startGoogleLogin({ redirectTo: server.redirectTo });
|
|
1558
|
+
try {
|
|
1559
|
+
await openBrowser(url);
|
|
1560
|
+
if (!params?.json) console.log(pc7.dim("Opened browser for login..."));
|
|
1561
|
+
} catch {
|
|
1562
|
+
if (!params?.json) {
|
|
1563
|
+
console.log(pc7.yellow("Could not open the browser automatically."));
|
|
1564
|
+
console.log(`Open this URL manually:
|
|
1565
|
+
${url}`);
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
const code = await server.waitForCode();
|
|
1569
|
+
const session = await supabase.exchangeCode({ code });
|
|
1570
|
+
await setSession(session);
|
|
1571
|
+
try {
|
|
1572
|
+
await validateBackendSession();
|
|
1573
|
+
} catch (err) {
|
|
1574
|
+
await clearSession().catch(() => {
|
|
1575
|
+
});
|
|
1576
|
+
throw err;
|
|
1577
|
+
}
|
|
1578
|
+
} finally {
|
|
1579
|
+
await server.close();
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
// src/lib/apiKey.ts
|
|
1584
|
+
import prompts2 from "prompts";
|
|
1585
|
+
function isInteractive2() {
|
|
1586
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
1587
|
+
}
|
|
1588
|
+
async function resolveStudioApiKey(params) {
|
|
1589
|
+
const fromFlag = (params.flagApiKey ?? "").trim();
|
|
1590
|
+
if (fromFlag) return fromFlag;
|
|
1591
|
+
const fromEnv = (process.env.COMERGE_STUDIO_API_KEY ?? "").trim();
|
|
1592
|
+
if (fromEnv) return fromEnv;
|
|
1593
|
+
if (params.yes || !isInteractive2()) {
|
|
1594
|
+
throw new CliError("Missing required Studio API key", {
|
|
1595
|
+
exitCode: 2,
|
|
1596
|
+
hint: "Pass --api-key <pk_...> or set COMERGE_STUDIO_API_KEY."
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
const res = await prompts2(
|
|
1600
|
+
[
|
|
1601
|
+
{
|
|
1602
|
+
type: "password",
|
|
1603
|
+
name: "apiKey",
|
|
1604
|
+
message: "Studio API key",
|
|
1605
|
+
validate: (v) => String(v || "").trim().length > 0 ? true : "apiKey is required"
|
|
1606
|
+
}
|
|
1607
|
+
],
|
|
1608
|
+
{
|
|
1609
|
+
onCancel: () => {
|
|
1610
|
+
throw new CliError("Cancelled", { exitCode: 130 });
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
);
|
|
1614
|
+
const apiKey = String(res.apiKey ?? "").trim();
|
|
1615
|
+
if (!apiKey) {
|
|
1616
|
+
throw new CliError("Missing required Studio API key", { exitCode: 2 });
|
|
1617
|
+
}
|
|
1618
|
+
return apiKey;
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
// src/lib/initLocal.ts
|
|
1622
|
+
async function initLocal(params) {
|
|
1623
|
+
if (params.dryRun) {
|
|
1624
|
+
const res = await importLocal({
|
|
1625
|
+
appName: params.appName,
|
|
1626
|
+
subdir: params.subdir,
|
|
1627
|
+
maxWaitMs: params.maxWaitMs,
|
|
1628
|
+
noWait: true,
|
|
1629
|
+
json: params.json,
|
|
1630
|
+
includeDotenv: params.includeDotenv,
|
|
1631
|
+
dryRun: true
|
|
1632
|
+
});
|
|
1633
|
+
return {
|
|
1634
|
+
appId: res.appId,
|
|
1635
|
+
uploadId: res.uploadId,
|
|
1636
|
+
projectId: res.projectId,
|
|
1637
|
+
threadId: res.threadId,
|
|
1638
|
+
status: res.status,
|
|
1639
|
+
shellOutDir: params.outDir,
|
|
1640
|
+
noWait: params.noWait
|
|
1641
|
+
};
|
|
1642
|
+
}
|
|
1643
|
+
await ensureAuth({ yes: params.yes, json: params.json });
|
|
1644
|
+
const apiKey = await resolveStudioApiKey({ flagApiKey: params.apiKey, yes: params.yes });
|
|
1645
|
+
const appKey = String(params.appKey || "MicroMain").trim() || "MicroMain";
|
|
1646
|
+
if (!params.json) console.log(pc8.dim("Importing project..."));
|
|
1647
|
+
const imported = await importLocal({
|
|
1648
|
+
appName: params.appName,
|
|
1649
|
+
subdir: params.subdir,
|
|
1650
|
+
maxWaitMs: params.maxWaitMs,
|
|
1651
|
+
noWait: params.noWait,
|
|
1652
|
+
json: params.json,
|
|
1653
|
+
includeDotenv: params.includeDotenv,
|
|
1654
|
+
dryRun: false
|
|
1655
|
+
});
|
|
1656
|
+
if (!params.json) console.log(pc8.dim("Generating shell..."));
|
|
1657
|
+
await shellInit({
|
|
1658
|
+
outDir: params.outDir,
|
|
1659
|
+
appId: imported.appId,
|
|
1660
|
+
apiKey,
|
|
1661
|
+
appKey,
|
|
1662
|
+
yes: true,
|
|
1663
|
+
install: params.install,
|
|
1664
|
+
packageManager: params.packageManager,
|
|
1665
|
+
studioVersion: params.studioVersion
|
|
1666
|
+
});
|
|
1667
|
+
return {
|
|
1668
|
+
appId: imported.appId,
|
|
1669
|
+
uploadId: imported.uploadId,
|
|
1670
|
+
projectId: imported.projectId,
|
|
1671
|
+
threadId: imported.threadId,
|
|
1672
|
+
status: imported.status,
|
|
1673
|
+
shellOutDir: params.outDir,
|
|
1674
|
+
noWait: params.noWait
|
|
1675
|
+
};
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
// src/commands/init.ts
|
|
1679
|
+
function registerInitCommands(program) {
|
|
1680
|
+
const init = program.command("init").description("Full loop: import + generate shell wrapper");
|
|
1681
|
+
init.command("local").description("Login if needed, import the local Expo project, then generate the shell wrapper").option("--api-key <pk>", "Studio API key (or set COMERGE_STUDIO_API_KEY)").option("--app-key <key>", "Bundle app key", "MicroMain").option("--out <dir>", "Output directory for shell wrapper", "comerge-shell").option("--install", "Install dependencies in generated shell", false).option("--package-manager <pm>", "Override package manager detection (pnpm|npm|yarn|bun)").option("--studio-version <ver>", "Version to use for @comergehq/studio", "latest").option("--name <appName>", "Override imported app name").option("--path <subdir>", "Optional subdirectory to import").option("--max-wait <sec>", "Max time to wait for import to finish (default: 1200)", "1200").option("--no-wait", "Do not poll for completion; generate shell immediately", false).option("--no-include-dotenv", "Exclude .env* files from the uploaded archive").option("--dry-run", "Show what would be uploaded (no login/import/shell)", false).option("--yes", "Non-interactive; do not prompt", false).option("--json", "Output machine-readable JSON", false).action(async (opts) => {
|
|
1682
|
+
const maxWaitSec = Number(String(opts.maxWait ?? "1200").trim());
|
|
1683
|
+
if (!Number.isFinite(maxWaitSec) || maxWaitSec <= 0) {
|
|
1684
|
+
throw new CliError("Invalid --max-wait", { exitCode: 2, hint: "Example: --max-wait 1200" });
|
|
1685
|
+
}
|
|
1686
|
+
const res = await initLocal({
|
|
1687
|
+
apiKey: opts.apiKey ? String(opts.apiKey) : null,
|
|
1688
|
+
appKey: opts.appKey ? String(opts.appKey) : "MicroMain",
|
|
1689
|
+
outDir: opts.out ? String(opts.out) : "comerge-shell",
|
|
1690
|
+
install: Boolean(opts.install),
|
|
1691
|
+
packageManager: opts.packageManager ? String(opts.packageManager) : null,
|
|
1692
|
+
studioVersion: opts.studioVersion ? String(opts.studioVersion) : "latest",
|
|
1693
|
+
appName: opts.name ? String(opts.name) : null,
|
|
1694
|
+
subdir: opts.path ? String(opts.path) : null,
|
|
1695
|
+
maxWaitMs: Math.floor(maxWaitSec * 1e3),
|
|
1696
|
+
noWait: Boolean(opts.noWait),
|
|
1697
|
+
includeDotenv: Boolean(opts.includeDotenv),
|
|
1698
|
+
dryRun: Boolean(opts.dryRun),
|
|
1699
|
+
yes: Boolean(opts.yes),
|
|
1700
|
+
json: Boolean(opts.json)
|
|
1701
|
+
});
|
|
1702
|
+
if (opts.json) {
|
|
1703
|
+
console.log(JSON.stringify(res, null, 2));
|
|
1704
|
+
return;
|
|
1705
|
+
}
|
|
1706
|
+
console.log(pc9.green(`Done. appId=${res.appId}`));
|
|
1707
|
+
console.log(pc9.dim(`Shell: ${res.shellOutDir}`));
|
|
1708
|
+
if (res.status) console.log(pc9.dim(`Status: ${res.status}`));
|
|
1709
|
+
if (res.noWait) console.log(pc9.yellow("Note: --no-wait was used; bundles may still be building."));
|
|
1710
|
+
});
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
// src/cli.ts
|
|
1714
|
+
async function main(argv) {
|
|
1715
|
+
const program = new Command();
|
|
1716
|
+
program.name("comerge").description("Comerge CLI").version("0.1.0");
|
|
1717
|
+
registerShellCommands(program);
|
|
1718
|
+
registerLoginCommand(program);
|
|
1719
|
+
registerWhoamiCommand(program);
|
|
1720
|
+
registerLogoutCommand(program);
|
|
1721
|
+
registerImportCommands(program);
|
|
1722
|
+
registerInitCommands(program);
|
|
1723
|
+
program.configureOutput({
|
|
1724
|
+
outputError: (str, write) => write(pc10.red(str))
|
|
1725
|
+
});
|
|
1726
|
+
try {
|
|
1727
|
+
await program.parseAsync(argv);
|
|
1728
|
+
} catch (err) {
|
|
1729
|
+
const e = err;
|
|
1730
|
+
if (e instanceof CliError) {
|
|
1731
|
+
console.error(pc10.red(`Error: ${e.message}`));
|
|
1732
|
+
if (e.hint) console.error(pc10.dim(e.hint));
|
|
1733
|
+
process.exitCode = e.exitCode;
|
|
1734
|
+
return;
|
|
1735
|
+
}
|
|
1736
|
+
console.error(pc10.red(`Unexpected error: ${e?.message ?? String(e)}`));
|
|
1737
|
+
process.exitCode = 1;
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
void main(process.argv);
|
|
1741
|
+
export {
|
|
1742
|
+
main
|
|
1743
|
+
};
|
|
1744
|
+
//# sourceMappingURL=cli.js.map
|