@expo-up/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@expo-up/cli",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "bin": {
6
+ "expo-up": "./dist/index.js"
7
+ },
8
+ "scripts": {
9
+ "build": "bun build ./src/index.tsx --outdir ./dist --target node --external react --external ink --external ink-spinner --external figlet",
10
+ "dev": "bun build ./src/index.tsx --outdir ./dist --target node --watch --external figlet",
11
+ "check-types": "tsc --noEmit",
12
+ "lint": "prettier --check \"src/**/*.{ts,tsx}\"",
13
+ "test": "bun test",
14
+ "quality": "bun run lint && bun run check-types && bun run test"
15
+ },
16
+ "dependencies": {
17
+ "@expo-up/core": "workspace:*",
18
+ "@expo/config": "^55.0.8",
19
+ "@octokit/core": "^7.0.6",
20
+ "@octokit/rest": "^22.0.1",
21
+ "commander": "^13.1.0",
22
+ "figlet": "^1.10.0",
23
+ "ink": "^5.1.0",
24
+ "ink-spinner": "^5.0.0",
25
+ "open": "^11.0.0",
26
+ "picocolors": "^1.1.1",
27
+ "react": "^18.3.1",
28
+ "zod": "^4.3.6"
29
+ },
30
+ "devDependencies": {
31
+ "@types/react": "^18.3.12",
32
+ "@types/node": "^22.0.0",
33
+ "typescript": "5.9.2"
34
+ }
35
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,135 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import open from "open";
4
+ import pc from "picocolors";
5
+ import http from "http";
6
+ import { getConfig } from "@expo/config";
7
+ import { z } from "zod";
8
+ import { DEFAULT_CHANNEL, parseExpoUpUpdatesUrl } from "../../core/src/index";
9
+
10
+ const EXPO_UP_CONFIG_RELATIVE_PATH = path.join(".expo", "expo-up.json");
11
+
12
+ const StoredConfigSchema = z.object({
13
+ github_token: z.string().min(1).optional(),
14
+ channel: z.string().min(1).optional(),
15
+ });
16
+
17
+ type StoredConfig = z.infer<typeof StoredConfigSchema>;
18
+
19
+ interface AutoConfig {
20
+ serverUrl: string;
21
+ projectId: string;
22
+ runtimeVersion: string;
23
+ }
24
+
25
+ function getConfigPath() {
26
+ const dotExpoDir = path.join(process.cwd(), ".expo");
27
+ if (!fs.existsSync(dotExpoDir)) fs.mkdirSync(dotExpoDir, { recursive: true });
28
+ return path.join(dotExpoDir, "expo-up.json");
29
+ }
30
+
31
+ function readConfig(): StoredConfig {
32
+ const configPath = getConfigPath();
33
+ if (!fs.existsSync(configPath)) return {};
34
+ try {
35
+ const raw = JSON.parse(fs.readFileSync(configPath, "utf-8"));
36
+ const parsed = StoredConfigSchema.safeParse(raw);
37
+ return parsed.success ? parsed.data : {};
38
+ } catch {
39
+ return {};
40
+ }
41
+ }
42
+
43
+ function ensureConfigIgnored(configPathRelativeToCwd: string): void {
44
+ const gitignorePath = path.join(process.cwd(), ".gitignore");
45
+ if (!fs.existsSync(gitignorePath)) return;
46
+
47
+ const content = fs.readFileSync(gitignorePath, "utf-8");
48
+ if (content.includes(configPathRelativeToCwd)) return;
49
+
50
+ const needsTrailingNewline = content.length > 0 && !content.endsWith("\n");
51
+ const prefix = needsTrailingNewline ? "\n" : "";
52
+ const block = `${prefix}# expo-up secrets\n${configPathRelativeToCwd}\n`;
53
+ fs.appendFileSync(gitignorePath, block);
54
+ }
55
+
56
+ /**
57
+ * Get values automatically using @expo/config
58
+ */
59
+ export function getAutoConfig(): AutoConfig {
60
+ try {
61
+ const { exp } = getConfig(process.cwd());
62
+ const { serverUrl, projectId } = parseExpoUpUpdatesUrl(
63
+ exp.updates?.url ?? "",
64
+ );
65
+
66
+ return {
67
+ serverUrl,
68
+ projectId,
69
+ runtimeVersion: exp.version ?? "",
70
+ };
71
+ } catch {
72
+ return { serverUrl: "", projectId: "", runtimeVersion: "" };
73
+ }
74
+ }
75
+
76
+ export function writeConfig(data: Partial<StoredConfig>): void {
77
+ const configPath = getConfigPath();
78
+ const current = readConfig();
79
+ const merged = { ...current, ...data };
80
+ fs.writeFileSync(configPath, JSON.stringify(merged, null, 2));
81
+ ensureConfigIgnored(EXPO_UP_CONFIG_RELATIVE_PATH);
82
+ }
83
+
84
+ export async function login() {
85
+ const { serverUrl } = getAutoConfig();
86
+ if (!serverUrl) throw new Error(`Server URL not found in expo config.`);
87
+
88
+ return new Promise((resolve, reject) => {
89
+ const localServer = http.createServer((req, res) => {
90
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
91
+ const token = url.searchParams.get("token");
92
+ if (token) {
93
+ writeConfig({ github_token: token });
94
+ res.writeHead(200, { "Content-Type": "text/html" });
95
+ res.end(
96
+ `<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body style="font-family: sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; background: #0f172a; color: white;"><h1 style="color: #22d3ee;">🚀 expo-up</h1><p>Login successful! You can close this tab and return to your terminal.</p></body></html>`,
97
+ );
98
+ console.log(
99
+ `\n${pc.green("✔")} Successfully authenticated! Token saved.`,
100
+ );
101
+ localServer.close();
102
+ resolve(token);
103
+ } else {
104
+ res.writeHead(400);
105
+ res.end("Token missing");
106
+ }
107
+ });
108
+
109
+ localServer.listen(4321, async () => {
110
+ const callbackUrl = encodeURIComponent("http://localhost:4321");
111
+ const authUrl = `${serverUrl}/auth/github?callback=${callbackUrl}`;
112
+ console.log(
113
+ `\n${pc.cyan("🚀")} Opening browser for GitHub Authentication...`,
114
+ );
115
+ await open(authUrl);
116
+ });
117
+ localServer.on("error", (err) =>
118
+ reject(new Error(`Local server failed: ${err.message}`)),
119
+ );
120
+ });
121
+ }
122
+
123
+ export function getStoredToken() {
124
+ return readConfig().github_token;
125
+ }
126
+ export function getStoredChannel() {
127
+ return readConfig().channel || DEFAULT_CHANNEL;
128
+ }
129
+
130
+ export function logout() {
131
+ const config = readConfig();
132
+ delete config.github_token;
133
+ fs.writeFileSync(getConfigPath(), JSON.stringify(config, null, 2));
134
+ console.log(`${pc.green("✔")} Logged out.`);
135
+ }
@@ -0,0 +1,123 @@
1
+ import * as React from "react";
2
+ import { Text, Box } from "ink";
3
+ import Spinner from "ink-spinner";
4
+ import { Octokit } from "@octokit/rest";
5
+ import { getStoredToken, getAutoConfig, getStoredChannel } from "./auth";
6
+ import { INIT_CHANNEL, parseProjectDescriptor } from "../../core/src/index";
7
+ import { Badge, BrandHeader, CliCard, KV } from "./ui";
8
+
9
+ interface ListChannelsProps {
10
+ debug?: boolean;
11
+ }
12
+
13
+ export const ListChannels: React.FC<ListChannelsProps> = ({
14
+ debug = false,
15
+ }) => {
16
+ const [channels, setChannels] = React.useState<string[]>([]);
17
+ const [loading, setLoading] = React.useState(true);
18
+ const [error, setError] = React.useState<string | null>(null);
19
+ const [debugLogs, setDebugLogs] = React.useState<string[]>([]);
20
+
21
+ React.useEffect(() => {
22
+ const run = async () => {
23
+ try {
24
+ const appendDebug = (message: string): void =>
25
+ setDebugLogs((prev) => [...prev, message]);
26
+ const token = getStoredToken();
27
+ const { serverUrl, projectId } = getAutoConfig();
28
+
29
+ if (!token || !serverUrl || !projectId)
30
+ throw new Error("Missing configuration. Are you logged in?");
31
+ if (debug)
32
+ appendDebug(
33
+ `Resolved config: server=${serverUrl}, project=${projectId}`,
34
+ );
35
+
36
+ const projRes = await fetch(`${serverUrl}/projects/${projectId}`);
37
+ if (!projRes.ok) throw new Error(`Project "${projectId}" not found.`);
38
+
39
+ const { owner, repo } = parseProjectDescriptor(await projRes.json());
40
+
41
+ const octokit = new Octokit({ auth: token });
42
+ const { data: branches } = await octokit.repos.listBranches({
43
+ owner,
44
+ repo,
45
+ });
46
+
47
+ const availableChannels = branches
48
+ .map((branch) => branch.name)
49
+ .filter((name) => name !== INIT_CHANNEL);
50
+ setChannels(availableChannels);
51
+ if (debug)
52
+ appendDebug(
53
+ `Fetched channels: ${availableChannels.join(", ") || "(none)"}`,
54
+ );
55
+ setLoading(false);
56
+ } catch (e: any) {
57
+ setError(e.message);
58
+ if (debug) setDebugLogs((prev) => [...prev, `Failure: ${e.message}`]);
59
+ setLoading(false);
60
+ }
61
+ };
62
+ run();
63
+ }, [debug]);
64
+
65
+ const currentChannel = getStoredChannel();
66
+
67
+ return (
68
+ <Box flexDirection="column" padding={1}>
69
+ <BrandHeader subtitle="Over-the-air updates" />
70
+ <CliCard
71
+ title="expo-up channels"
72
+ subtitle="Available release channels from GitHub"
73
+ >
74
+ <KV keyName="Active" value={currentChannel} valueColor="green" />
75
+ </CliCard>
76
+
77
+ <CliCard title="Channels">
78
+ {loading && (
79
+ <Box>
80
+ <Badge label="LOADING" tone="yellow" />
81
+ <Text>
82
+ <Spinner /> Fetching channels...
83
+ </Text>
84
+ </Box>
85
+ )}
86
+ {!loading &&
87
+ !error &&
88
+ channels.map((name) => (
89
+ <Box key={name}>
90
+ <Text color={name === currentChannel ? "green" : "white"}>
91
+ {name === currentChannel ? "●" : "○"} {name}
92
+ </Text>
93
+ {name === currentChannel && (
94
+ <Text color="green" dimColor>
95
+ {" "}
96
+ (active)
97
+ </Text>
98
+ )}
99
+ </Box>
100
+ ))}
101
+ {error && (
102
+ <Box>
103
+ <Badge label="FAILED" tone="red" />
104
+ <Text color="red">{error}</Text>
105
+ </Box>
106
+ )}
107
+ </CliCard>
108
+
109
+ {debug && (
110
+ <CliCard title="Debug Logs" subtitle="Verbose diagnostics">
111
+ {debugLogs.length === 0 ? (
112
+ <Text color="gray">No debug logs yet.</Text>
113
+ ) : null}
114
+ {debugLogs.map((line, i) => (
115
+ <Text key={i} color="gray">
116
+ {line}
117
+ </Text>
118
+ ))}
119
+ </CliCard>
120
+ )}
121
+ </Box>
122
+ );
123
+ };
@@ -0,0 +1,25 @@
1
+ /// <reference path="../../typescript-config/bun-test-shim.d.ts" />
2
+ import { describe, expect, it } from "bun:test";
3
+ import { maskToken, parsePlatform } from "./cli-utils";
4
+
5
+ describe("parsePlatform", () => {
6
+ it("accepts supported platforms", () => {
7
+ expect(parsePlatform("ios")).toBe("ios");
8
+ expect(parsePlatform("android")).toBe("android");
9
+ expect(parsePlatform("all")).toBe("all");
10
+ });
11
+
12
+ it("throws on unsupported values", () => {
13
+ expect(() => parsePlatform("web")).toThrow('Invalid platform "web"');
14
+ });
15
+ });
16
+
17
+ describe("maskToken", () => {
18
+ it("masks long tokens", () => {
19
+ expect(maskToken("abcd1234wxyz7890")).toBe("abcd...7890");
20
+ });
21
+
22
+ it("keeps short tokens unchanged", () => {
23
+ expect(maskToken("short")).toBe("short");
24
+ });
25
+ });
@@ -0,0 +1,19 @@
1
+ export type PlatformOption = "ios" | "android" | "all";
2
+
3
+ export function parsePlatform(platform: string): PlatformOption {
4
+ if (platform === "ios" || platform === "android" || platform === "all") {
5
+ return platform;
6
+ }
7
+
8
+ throw new Error(
9
+ `Invalid platform "${platform}". Use "ios", "android", or "all".`,
10
+ );
11
+ }
12
+
13
+ export function maskToken(token: string): string {
14
+ if (token.length <= 8) {
15
+ return token;
16
+ }
17
+
18
+ return `${token.slice(0, 4)}...${token.slice(-4)}`;
19
+ }
@@ -0,0 +1,165 @@
1
+ /// <reference path="../../typescript-config/bun-test-shim.d.ts" />
2
+ import { afterEach, describe, expect, it } from "bun:test";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import {
7
+ configureCodesigning,
8
+ generateCodesigning,
9
+ resolveProjectRoot,
10
+ } from "./codesigning";
11
+
12
+ const tempDirs: string[] = [];
13
+
14
+ function makeTempDir(): string {
15
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "expo-up-codesigning-"));
16
+ tempDirs.push(dir);
17
+ return dir;
18
+ }
19
+
20
+ function writeJson(filePath: string, value: unknown): void {
21
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
22
+ fs.writeFileSync(filePath, JSON.stringify(value, null, 2));
23
+ }
24
+
25
+ function writeExpoProject(projectRoot: string): void {
26
+ writeJson(path.join(projectRoot, "app.json"), { expo: { name: "app" } });
27
+ writeJson(path.join(projectRoot, "package.json"), {
28
+ name: "test-app",
29
+ private: true,
30
+ });
31
+ }
32
+
33
+ afterEach(() => {
34
+ for (const dir of tempDirs.splice(0)) {
35
+ fs.rmSync(dir, { recursive: true, force: true });
36
+ }
37
+ });
38
+
39
+ describe("resolveProjectRoot", () => {
40
+ it("returns cwd when app.json exists in cwd", () => {
41
+ const root = makeTempDir();
42
+ writeExpoProject(root);
43
+
44
+ expect(resolveProjectRoot(root)).toBe(root);
45
+ });
46
+
47
+ it("throws when app.json does not exist in cwd", () => {
48
+ const root = makeTempDir();
49
+
50
+ expect(() => resolveProjectRoot(root)).toThrow(
51
+ "Could not find Expo app config (app.json/app.config.*). Run this command inside your Expo app or pass --project-root.",
52
+ );
53
+ });
54
+
55
+ it("accepts app.config.ts as project config", () => {
56
+ const root = makeTempDir();
57
+ fs.writeFileSync(
58
+ path.join(root, "app.config.ts"),
59
+ "export default { expo: { name: 'app' } };\n",
60
+ );
61
+
62
+ expect(resolveProjectRoot(root)).toBe(root);
63
+ });
64
+ });
65
+
66
+ describe("generateCodesigning", () => {
67
+ it("generates keys/cert into separate dirs and updates gitignore", () => {
68
+ const projectRoot = makeTempDir();
69
+ writeExpoProject(projectRoot);
70
+
71
+ const result = generateCodesigning({
72
+ projectRoot,
73
+ organization: "Acme Inc",
74
+ validityYears: 10,
75
+ expoUpdatesRunner: () => {
76
+ fs.mkdirSync(path.join(projectRoot, "codesigning-keys"), {
77
+ recursive: true,
78
+ });
79
+ fs.mkdirSync(path.join(projectRoot, "certs"), { recursive: true });
80
+ fs.writeFileSync(
81
+ path.join(projectRoot, "codesigning-keys/private-key.pem"),
82
+ "private",
83
+ );
84
+ fs.writeFileSync(
85
+ path.join(projectRoot, "codesigning-keys/public-key.pem"),
86
+ "public",
87
+ );
88
+ fs.writeFileSync(
89
+ path.join(projectRoot, "certs/certificate.pem"),
90
+ "cert",
91
+ );
92
+ },
93
+ });
94
+
95
+ expect(result.projectRoot).toBe(projectRoot);
96
+ expect(fs.existsSync(result.privateKeyPath)).toBe(true);
97
+ expect(fs.existsSync(result.publicKeyPath)).toBe(true);
98
+ expect(fs.existsSync(result.certificatePath)).toBe(true);
99
+
100
+ const gitignore = fs.readFileSync(
101
+ path.join(projectRoot, ".gitignore"),
102
+ "utf-8",
103
+ );
104
+ expect(gitignore).toContain("codesigning-keys/");
105
+ });
106
+
107
+ it("throws when directories exist and force is not enabled", () => {
108
+ const projectRoot = makeTempDir();
109
+ writeExpoProject(projectRoot);
110
+ fs.mkdirSync(path.join(projectRoot, "codesigning-keys"), {
111
+ recursive: true,
112
+ });
113
+
114
+ expect(() =>
115
+ generateCodesigning({
116
+ projectRoot,
117
+ organization: "Acme Inc",
118
+ validityYears: 10,
119
+ }),
120
+ ).toThrow("already exists");
121
+ });
122
+ });
123
+
124
+ describe("configureCodesigning", () => {
125
+ it("writes codeSigningCertificate and codeSigningMetadata", () => {
126
+ const projectRoot = makeTempDir();
127
+ writeExpoProject(projectRoot);
128
+
129
+ const certDir = path.join(projectRoot, "certs");
130
+ const keyDir = path.join(projectRoot, "codesigning-keys");
131
+ fs.mkdirSync(certDir, { recursive: true });
132
+ fs.mkdirSync(keyDir, { recursive: true });
133
+ fs.writeFileSync(path.join(certDir, "certificate.pem"), "cert");
134
+ fs.writeFileSync(path.join(keyDir, "private-key.pem"), "priv");
135
+ fs.writeFileSync(path.join(keyDir, "public-key.pem"), "pub");
136
+
137
+ const result = configureCodesigning({
138
+ projectRoot,
139
+ certificateInputDirectory: "certs",
140
+ keyInputDirectory: "codesigning-keys",
141
+ keyId: "main",
142
+ });
143
+
144
+ expect(result.keyId).toBe("main");
145
+
146
+ const appJson = JSON.parse(
147
+ fs.readFileSync(path.join(projectRoot, "app.json"), "utf-8"),
148
+ ) as {
149
+ expo: {
150
+ updates: {
151
+ codeSigningCertificate: string;
152
+ codeSigningMetadata: { keyid: string; alg: string };
153
+ };
154
+ };
155
+ };
156
+
157
+ expect(appJson.expo.updates.codeSigningCertificate).toBe(
158
+ "./certs/certificate.pem",
159
+ );
160
+ expect(appJson.expo.updates.codeSigningMetadata).toEqual({
161
+ keyid: "main",
162
+ alg: "rsa-v1_5-sha256",
163
+ });
164
+ });
165
+ });