@uwayss/bareed 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,4 +19,21 @@ It runs `expo export`, zips the result, and asks which application and which
19
19
  runtime version to upload it to. The first upload asks for the address of your
20
20
  server and the admin password, and keeps them in `~/.config/bareed/config.json`.
21
21
 
22
+ It offers to install your dependencies when they are missing, and to create the
23
+ application on the server when your slug is not there yet.
24
+
22
25
  `bareed --login` saves them without an upload. `bareed --logout` deletes them.
26
+
27
+ ## In CI
28
+
29
+ `--yes` asks nothing. Set `BAREED_URL` and `BAREED_PASSWORD` in the environment,
30
+ and name the target with `--app` and `--runtime`:
31
+
32
+ ```bash
33
+ bareed --yes --app your-app-slug --runtime 1.0.0
34
+ ```
35
+
36
+ The password stays in the environment. Nothing is written to the config file.
37
+
38
+ Without `--app` or `--runtime`, it reads your Expo config. `--yes` creates a
39
+ runtime version that is not on the server yet, but never an application.
package/bin/bareed.js ADDED
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ // The real CLI is a Go binary. npm installs one prebuilt package per platform
3
+ // through optionalDependencies, and this file runs whichever one landed.
4
+ const { spawnSync } = require("node:child_process");
5
+ const { existsSync } = require("node:fs");
6
+ const path = require("node:path");
7
+
8
+ const target = `${process.platform}-${process.arch}`;
9
+ const pkg = `@uwayss/bareed-${target}`;
10
+ const binary = process.platform === "win32" ? "bareed.exe" : "bareed";
11
+
12
+ function findBinary() {
13
+ let manifest;
14
+ try {
15
+ // Resolving package.json rather than the binary itself, because Node adds
16
+ // .js and .node to an extensionless path and would miss the file.
17
+ manifest = require.resolve(`${pkg}/package.json`);
18
+ } catch {
19
+ return null;
20
+ }
21
+
22
+ const file = path.join(path.dirname(manifest), "bin", binary);
23
+ return existsSync(file) ? file : null;
24
+ }
25
+
26
+ const file = findBinary();
27
+ if (!file) {
28
+ console.error(`bareed has no prebuilt binary for ${target}.`);
29
+ console.error("");
30
+ console.error("Build it from source instead:");
31
+ console.error(" go install code.uwayss.com/uwayss/bareed/cmd/push@latest");
32
+ process.exit(1);
33
+ }
34
+
35
+ // stdio: inherit keeps the prompts and the expo export output on the terminal.
36
+ const result = spawnSync(file, process.argv.slice(2), { stdio: "inherit" });
37
+
38
+ if (result.error) {
39
+ console.error(`bareed could not start. ${result.error.message}`);
40
+ process.exit(1);
41
+ }
42
+ // A signal leaves status null. 128 + signal is what a shell reports.
43
+ if (result.signal) {
44
+ process.exit(128 + (require("node:os").constants.signals[result.signal] ?? 0));
45
+ }
46
+ process.exit(result.status ?? 1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uwayss/bareed",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
4
4
  "description": "Export an Expo app and upload it to your own Bareed update server",
5
5
  "license": "MIT",
6
6
  "author": "Muhammed Antar",
@@ -13,43 +13,23 @@
13
13
  "url": "git+https://code.uwayss.com/uwayss/bareed.git",
14
14
  "directory": "packages/cli"
15
15
  },
16
- "keywords": [
17
- "expo",
18
- "expo-updates",
19
- "ota",
20
- "self-hosted",
21
- "cli"
22
- ],
23
16
  "publishConfig": {
24
17
  "access": "public"
25
18
  },
26
- "type": "module",
27
- "main": "dist/index.js",
28
- "bin": {
29
- "bareed": "dist/index.js"
30
- },
31
- "files": [
32
- "dist",
33
- "README.md"
34
- ],
19
+ "keywords": ["expo", "expo-updates", "ota", "self-hosted", "cli"],
35
20
  "engines": {
36
- "node": ">=20.17.0"
21
+ "node": ">=18"
37
22
  },
38
- "scripts": {
39
- "dev": "tsx src/index.ts",
40
- "build": "tsc",
41
- "start": "node dist/index.js",
42
- "typecheck": "tsc --noEmit",
43
- "prepublishOnly": "pnpm run build"
44
- },
45
- "dependencies": {
46
- "@inquirer/prompts": "^8.6.0",
47
- "archiver": "^8.0.0"
23
+ "bin": {
24
+ "bareed": "bin/bareed.js"
48
25
  },
49
- "devDependencies": {
50
- "@types/archiver": "^8.0.0",
51
- "@types/node": "^26.2.0",
52
- "tsx": "^4.23.12",
53
- "typescript": "^7.0.2"
26
+ "files": ["bin", "README.md"],
27
+ "optionalDependencies": {
28
+ "@uwayss/bareed-darwin-arm64": "0.3.0",
29
+ "@uwayss/bareed-darwin-x64": "0.3.0",
30
+ "@uwayss/bareed-linux-x64": "0.3.0",
31
+ "@uwayss/bareed-linux-arm64": "0.3.0",
32
+ "@uwayss/bareed-win32-x64": "0.3.0",
33
+ "@uwayss/bareed-win32-arm64": "0.3.0"
54
34
  }
55
35
  }
package/dist/api.js DELETED
@@ -1,86 +0,0 @@
1
- import { openAsBlob } from 'node:fs';
2
- import path from 'node:path';
3
- import { writeCredentials } from './credentials.js';
4
- export class ApiError extends Error {
5
- status;
6
- constructor(message, status) {
7
- super(message);
8
- this.status = status;
9
- }
10
- }
11
- async function readError(res) {
12
- try {
13
- const body = (await res.json());
14
- if (body.error)
15
- return body.error;
16
- }
17
- catch {
18
- // A proxy in front of the server can answer with HTML.
19
- }
20
- return `The server answered ${res.status}.`;
21
- }
22
- export async function login(serverUrl, password) {
23
- let res;
24
- try {
25
- res = await fetch(`${serverUrl}/api/auth/login`, {
26
- method: 'POST',
27
- headers: { 'content-type': 'application/json' },
28
- body: JSON.stringify({ password }),
29
- });
30
- }
31
- catch (err) {
32
- throw new ApiError(`Cannot reach ${serverUrl}. ${err instanceof Error ? err.message : 'Unknown error'}`, 0);
33
- }
34
- if (res.status === 401)
35
- throw new ApiError('The server refused that password.', 401);
36
- if (!res.ok)
37
- throw new ApiError(await readError(res), res.status);
38
- const body = (await res.json());
39
- if (!body.token)
40
- throw new ApiError('The server sent no token.', res.status);
41
- return body.token;
42
- }
43
- export function createClient(credentials) {
44
- let token = credentials.token;
45
- async function send(pathname, init) {
46
- return fetch(`${credentials.serverUrl}${pathname}`, {
47
- ...init,
48
- headers: { ...init.headers, authorization: `Bearer ${token}` },
49
- });
50
- }
51
- async function call(pathname, init = {}) {
52
- if (!token) {
53
- token = await login(credentials.serverUrl, credentials.password);
54
- writeCredentials({ ...credentials, token });
55
- }
56
- let res = await send(pathname, init);
57
- // The token lasts seven days. The saved password buys a new one without
58
- // asking, so a run days after the last one still works.
59
- if (res.status === 401) {
60
- token = await login(credentials.serverUrl, credentials.password);
61
- writeCredentials({ ...credentials, token });
62
- res = await send(pathname, init);
63
- }
64
- if (!res.ok)
65
- throw new ApiError(await readError(res), res.status);
66
- if (res.status === 204)
67
- return undefined;
68
- return (await res.json());
69
- }
70
- return {
71
- serverUrl: credentials.serverUrl,
72
- listApps: () => call('/api/apps'),
73
- listRuntimes: (appId) => call(`/api/runtimes/${appId}`),
74
- createRuntime: (appId, version) => call(`/api/runtimes/${appId}`, {
75
- method: 'POST',
76
- headers: { 'content-type': 'application/json' },
77
- body: JSON.stringify({ version }),
78
- }),
79
- async uploadUpdate(runtimeId, zipPath) {
80
- const form = new FormData();
81
- // openAsBlob streams the archive off disk instead of holding it in memory.
82
- form.set('update', await openAsBlob(zipPath, { type: 'application/zip' }), path.basename(zipPath));
83
- await call(`/api/updates/${runtimeId}`, { method: 'POST', body: form });
84
- },
85
- };
86
- }
@@ -1,46 +0,0 @@
1
- import fs from 'node:fs';
2
- import os from 'node:os';
3
- import path from 'node:path';
4
- const configDir = path.join(os.homedir(), '.config', 'bareed');
5
- const configFile = path.join(configDir, 'config.json');
6
- export const credentialsPath = configFile;
7
- export function readCredentials() {
8
- let raw;
9
- try {
10
- raw = fs.readFileSync(configFile, 'utf8');
11
- }
12
- catch {
13
- return null;
14
- }
15
- try {
16
- const parsed = JSON.parse(raw);
17
- if (!parsed.serverUrl || !parsed.password)
18
- return null;
19
- return { serverUrl: parsed.serverUrl, password: parsed.password, token: parsed.token };
20
- }
21
- catch {
22
- return null;
23
- }
24
- }
25
- export function writeCredentials(credentials) {
26
- fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
27
- // The file holds the admin password in clear text. Owner-only, and the mode
28
- // is set again because an existing file keeps the mode it was made with.
29
- fs.writeFileSync(configFile, JSON.stringify(credentials, null, 2), { mode: 0o600 });
30
- fs.chmodSync(configFile, 0o600);
31
- }
32
- export function clearCredentials() {
33
- try {
34
- fs.unlinkSync(configFile);
35
- return true;
36
- }
37
- catch {
38
- return false;
39
- }
40
- }
41
- /** Accepts "localhost:4000" and "https://updates.example.com/" alike. */
42
- export function normalizeServerUrl(input) {
43
- const trimmed = input.trim();
44
- const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
45
- return withScheme.replace(/\/+$/, '');
46
- }
package/dist/expo.js DELETED
@@ -1,91 +0,0 @@
1
- import { execFile, spawn } from 'node:child_process';
2
- import fs from 'node:fs';
3
- import os from 'node:os';
4
- import path from 'node:path';
5
- import { promisify } from 'node:util';
6
- const execFileAsync = promisify(execFile);
7
- /** Turns "~/Code/app/", '"/Users/me/app"' and "./app" into an absolute path. */
8
- export function expandPath(input) {
9
- let value = input.trim();
10
- if ((value.startsWith('"') && value.endsWith('"')) ||
11
- (value.startsWith("'") && value.endsWith("'"))) {
12
- value = value.slice(1, -1);
13
- }
14
- // A path dragged into the terminal arrives with its spaces escaped.
15
- value = value.replace(/\\ /g, ' ').trim();
16
- if (value === '~')
17
- value = os.homedir();
18
- else if (value.startsWith('~/'))
19
- value = path.join(os.homedir(), value.slice(2));
20
- return path.resolve(value);
21
- }
22
- export function isExpoProject(dir) {
23
- let raw;
24
- try {
25
- raw = fs.readFileSync(path.join(dir, 'package.json'), 'utf8');
26
- }
27
- catch {
28
- return false;
29
- }
30
- try {
31
- const pkg = JSON.parse(raw);
32
- return Boolean(pkg.dependencies?.expo ?? pkg.devDependencies?.expo);
33
- }
34
- catch {
35
- return false;
36
- }
37
- }
38
- function shape(raw) {
39
- const slugFromUrl = raw.updates?.url?.split('?')[0].replace(/\/+$/, '').split('/').pop();
40
- return {
41
- name: raw.name ?? raw.slug ?? 'your app',
42
- // The dashboard matches on the slug in the update URL, which is the one the
43
- // client asks for. It does not have to equal the Expo slug.
44
- slug: slugFromUrl ?? raw.slug ?? '',
45
- // A runtime version policy resolves per build, so there is no one value to
46
- // preselect. Only a literal version is usable here.
47
- runtimeVersion: typeof raw.runtimeVersion === 'string' ? raw.runtimeVersion : undefined,
48
- };
49
- }
50
- /**
51
- * `app.config.js` and `app.config.ts` are code, so only Expo can resolve them.
52
- * `app.json` is the fallback for when the command is missing or fails.
53
- */
54
- export async function readAppConfig(dir) {
55
- try {
56
- const { stdout } = await execFileAsync('npx', ['expo', 'config', '--type', 'public', '--json'], {
57
- cwd: dir,
58
- maxBuffer: 32 * 1024 * 1024,
59
- });
60
- const parsed = JSON.parse(stdout);
61
- return shape(parsed.expo ?? parsed);
62
- }
63
- catch {
64
- try {
65
- const raw = JSON.parse(fs.readFileSync(path.join(dir, 'app.json'), 'utf8'));
66
- return shape(raw.expo ?? {});
67
- }
68
- catch {
69
- return { name: 'your app', slug: '' };
70
- }
71
- }
72
- }
73
- export function exportDir(appDir) {
74
- return path.join(appDir, 'dist');
75
- }
76
- export function runExport(appDir) {
77
- return new Promise((resolve, reject) => {
78
- const child = spawn('npx', ['expo', 'export', '--platform', 'ios', '--platform', 'android'], {
79
- cwd: appDir,
80
- stdio: 'inherit',
81
- shell: process.platform === 'win32',
82
- });
83
- child.on('error', (err) => reject(new Error(`Cannot run npx. ${err.message}`)));
84
- child.on('close', (code) => {
85
- if (code === 0)
86
- resolve();
87
- else
88
- reject(new Error(`The export failed. "expo export" ended with code ${code}.`));
89
- });
90
- });
91
- }
package/dist/index.js DELETED
@@ -1,152 +0,0 @@
1
- #!/usr/bin/env node
2
- import { confirm, input, password as passwordPrompt, select } from '@inquirer/prompts';
3
- import fs from 'node:fs';
4
- import path from 'node:path';
5
- import { createClient, login } from './api.js';
6
- import { clearCredentials, credentialsPath, normalizeServerUrl, readCredentials, writeCredentials, } from './credentials.js';
7
- import { exportDir, expandPath, isExpoProject, readAppConfig, runExport } from './expo.js';
8
- import { formatSize, zipDirectory } from './zip.js';
9
- const HELP = `Export an Expo app and upload it to your update server.
10
-
11
- Usage:
12
- bareed [option]
13
-
14
- Options:
15
- --login Save the server address and the admin password, then stop.
16
- --logout Delete the saved server address and password.
17
- --help Show this text.
18
-
19
- With no option, the tool exports the app, zips the export, and offers to
20
- upload it.`;
21
- async function askForLogin(current) {
22
- const serverUrl = normalizeServerUrl(await input({
23
- message: 'Server address',
24
- default: current?.serverUrl ?? 'http://localhost:4000',
25
- validate: (value) => (value.trim() ? true : 'Type the address of your update server.'),
26
- }));
27
- const password = await passwordPrompt({ message: 'Admin password', mask: true });
28
- const token = await login(serverUrl, password);
29
- const credentials = { serverUrl, password, token };
30
- writeCredentials(credentials);
31
- return credentials;
32
- }
33
- async function findExpoApp() {
34
- if (isExpoProject(process.cwd()))
35
- return process.cwd();
36
- const answer = await input({
37
- message: "Where's your expo app?",
38
- validate: (value) => {
39
- if (!value.trim())
40
- return 'Type the path to your Expo app.';
41
- const dir = expandPath(value);
42
- if (!fs.existsSync(dir))
43
- return `There is no directory at ${dir}.`;
44
- if (!isExpoProject(dir))
45
- return `${dir} has no package.json that depends on expo.`;
46
- return true;
47
- },
48
- });
49
- return expandPath(answer);
50
- }
51
- async function chooseApp(client, slug) {
52
- const apps = await client.listApps();
53
- if (apps.length === 0) {
54
- throw new Error(`${client.serverUrl} holds no applications. Create one in the dashboard.`);
55
- }
56
- const match = apps.find((app) => app.slug === slug);
57
- return select({
58
- message: 'Which application?',
59
- choices: apps.map((app) => ({ name: `${app.name} (${app.slug})`, value: app })),
60
- default: match,
61
- });
62
- }
63
- async function chooseRuntime(client, app, wanted) {
64
- const runtimes = await client.listRuntimes(app.id);
65
- const match = wanted ? runtimes.find((runtime) => runtime.version === wanted) : undefined;
66
- if (wanted && !match) {
67
- const create = await confirm({
68
- message: `${app.name} has no runtime version ${wanted} on the server. Create it?`,
69
- default: true,
70
- });
71
- if (create)
72
- return client.createRuntime(app.id, wanted);
73
- }
74
- if (runtimes.length === 0) {
75
- throw new Error(`${app.name} has no runtime versions. Create one in the dashboard.`);
76
- }
77
- return select({
78
- message: 'Which runtime version?',
79
- choices: runtimes.map((runtime) => ({
80
- name: runtime.version === wanted
81
- ? `${runtime.version} (your app asks for this one)`
82
- : runtime.version,
83
- value: runtime,
84
- })),
85
- default: match,
86
- });
87
- }
88
- async function run() {
89
- const args = process.argv.slice(2);
90
- if (args.includes('--help') || args.includes('-h')) {
91
- console.log(HELP);
92
- return;
93
- }
94
- if (args.includes('--logout')) {
95
- console.log(clearCredentials() ? `Deleted ${credentialsPath}.` : 'There is nothing saved.');
96
- return;
97
- }
98
- if (args.includes('--login')) {
99
- const credentials = await askForLogin(readCredentials());
100
- console.log(`Saved ${credentials.serverUrl} in ${credentialsPath}.`);
101
- return;
102
- }
103
- const unknown = args.filter((arg) => arg.startsWith('-'));
104
- if (unknown.length > 0) {
105
- throw new Error(`Unknown option ${unknown[0]}. Run with --help to see the options.`);
106
- }
107
- const appDir = await findExpoApp();
108
- const appConfig = await readAppConfig(appDir);
109
- console.log(`\nExporting ${appConfig.name} in ${appDir}\n`);
110
- await runExport(appDir);
111
- const dist = exportDir(appDir);
112
- if (!fs.existsSync(path.join(dist, 'metadata.json'))) {
113
- throw new Error(`The export wrote no metadata.json to ${dist}.`);
114
- }
115
- const zipPath = path.join(dist, 'update.zip');
116
- const size = await zipDirectory(dist, zipPath);
117
- console.log(`\nMade ${zipPath} (${formatSize(size)})\n`);
118
- const label = [
119
- appConfig.slug ? `${appConfig.name} (${appConfig.slug})` : appConfig.name,
120
- appConfig.runtimeVersion,
121
- ]
122
- .filter(Boolean)
123
- .join(' - ');
124
- if (!(await confirm({ message: 'Do you want me to upload it as well?', default: true }))) {
125
- console.log(`\nThe archive is at ${zipPath}`);
126
- console.log(`Upload it to "${label}" in the dashboard.`);
127
- return;
128
- }
129
- let credentials = readCredentials();
130
- if (!credentials) {
131
- console.log('\nYou are not logged in yet.');
132
- credentials = await askForLogin(null);
133
- }
134
- const client = createClient(credentials);
135
- const app = await chooseApp(client, appConfig.slug);
136
- const runtime = await chooseRuntime(client, app, appConfig.runtimeVersion);
137
- console.log('\nUploading...');
138
- await client.uploadUpdate(runtime.id, zipPath);
139
- console.log(`Uploaded to ${app.name} (${app.slug}) - ${runtime.version}.`);
140
- }
141
- try {
142
- await run();
143
- }
144
- catch (err) {
145
- // Ctrl+C inside a question is a stop, not a failure.
146
- if (err instanceof Error && err.name === 'ExitPromptError') {
147
- console.log('\nStopped.');
148
- process.exit(130);
149
- }
150
- console.error(`\n${err instanceof Error ? err.message : String(err)}`);
151
- process.exit(1);
152
- }
package/dist/zip.js DELETED
@@ -1,39 +0,0 @@
1
- import { ZipArchive } from 'archiver';
2
- import fs from 'node:fs';
3
- import path from 'node:path';
4
- /**
5
- * The archive lands inside the directory it packs, so it has to leave itself
6
- * out. The server reads `metadata.json` at the root of the archive, so the
7
- * entries are the contents of the directory, not the directory itself.
8
- */
9
- export function zipDirectory(sourceDir, outPath) {
10
- return new Promise((resolve, reject) => {
11
- const output = fs.createWriteStream(outPath);
12
- const archive = new ZipArchive({ zlib: { level: 9 } });
13
- output.on('close', () => resolve(archive.pointer()));
14
- output.on('error', reject);
15
- archive.on('error', reject);
16
- archive.on('warning', (err) => {
17
- if (err.code === 'ENOENT')
18
- return;
19
- reject(err);
20
- });
21
- archive.pipe(output);
22
- archive.glob('**/*', {
23
- cwd: sourceDir,
24
- dot: true,
25
- ignore: [path.basename(outPath)],
26
- });
27
- void archive.finalize();
28
- });
29
- }
30
- export function formatSize(bytes) {
31
- const units = ['B', 'KB', 'MB', 'GB'];
32
- let value = bytes;
33
- let unit = 0;
34
- while (value >= 1024 && unit < units.length - 1) {
35
- value /= 1024;
36
- unit += 1;
37
- }
38
- return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`;
39
- }