@appspacer/cli 1.0.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/LICENSE +21 -0
- package/README.md +271 -0
- package/dist/__tests__/api.test.d.ts +1 -0
- package/dist/__tests__/api.test.js +142 -0
- package/dist/__tests__/config.test.d.ts +1 -0
- package/dist/__tests__/config.test.js +109 -0
- package/dist/__tests__/hash.test.d.ts +1 -0
- package/dist/__tests__/hash.test.js +47 -0
- package/dist/__tests__/setup-injections.test.d.ts +1 -0
- package/dist/__tests__/setup-injections.test.js +238 -0
- package/dist/__tests__/zip.test.d.ts +1 -0
- package/dist/__tests__/zip.test.js +62 -0
- package/dist/api.d.ts +6 -0
- package/dist/api.js +52 -0
- package/dist/commands/deployments.d.ts +2 -0
- package/dist/commands/deployments.js +39 -0
- package/dist/commands/envsync.d.ts +2 -0
- package/dist/commands/envsync.js +230 -0
- package/dist/commands/login.d.ts +2 -0
- package/dist/commands/login.js +41 -0
- package/dist/commands/release-flutter.d.ts +2 -0
- package/dist/commands/release-flutter.js +176 -0
- package/dist/commands/release-react-native.d.ts +2 -0
- package/dist/commands/release-react-native.js +143 -0
- package/dist/commands/release.d.ts +2 -0
- package/dist/commands/release.js +106 -0
- package/dist/commands/rollback.d.ts +2 -0
- package/dist/commands/rollback.js +43 -0
- package/dist/commands/setup.d.ts +22 -0
- package/dist/commands/setup.js +575 -0
- package/dist/commands/vault.d.ts +2 -0
- package/dist/commands/vault.js +292 -0
- package/dist/commands/whoami.d.ts +2 -0
- package/dist/commands/whoami.js +16 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.js +45 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +25 -0
- package/dist/utils/bundle.d.ts +8 -0
- package/dist/utils/bundle.js +59 -0
- package/dist/utils/hash.d.ts +4 -0
- package/dist/utils/hash.js +9 -0
- package/dist/utils/ui.d.ts +19 -0
- package/dist/utils/ui.js +43 -0
- package/dist/utils/validators.d.ts +25 -0
- package/dist/utils/validators.js +65 -0
- package/dist/utils/zip.d.ts +5 -0
- package/dist/utils/zip.js +17 -0
- package/package.json +66 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import readline from "node:readline";
|
|
4
|
+
import { saveConfig, getApiUrl } from "../config.js";
|
|
5
|
+
import { apiRequest } from "../api.js";
|
|
6
|
+
export const loginCommand = new Command("login")
|
|
7
|
+
.description("Authenticate with AppSpacer using a Personal Access Token")
|
|
8
|
+
.option("-t, --token <token>", "Personal Access Token")
|
|
9
|
+
.option("--api-url <url>", "API base URL")
|
|
10
|
+
.action(async (opts) => {
|
|
11
|
+
try {
|
|
12
|
+
const apiUrl = opts.apiUrl || getApiUrl();
|
|
13
|
+
if (opts.apiUrl)
|
|
14
|
+
saveConfig({ apiUrl: opts.apiUrl });
|
|
15
|
+
let accessToken = opts.token;
|
|
16
|
+
if (!accessToken) {
|
|
17
|
+
// Interactive prompt for Token
|
|
18
|
+
// console.log(chalk.cyan(`\nTo authenticate, please generate a token at:`));
|
|
19
|
+
// console.log(chalk.bold.underline(` ${apiUrl.replace("/api", "")}/settings/tokens\n`));
|
|
20
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
21
|
+
const ask = (q) => new Promise((r) => rl.question(q, r));
|
|
22
|
+
accessToken = await ask(chalk.green("? ") + chalk.bold("Enter your Personal Access Token: "));
|
|
23
|
+
rl.close();
|
|
24
|
+
if (!accessToken || !accessToken.startsWith("pat_")) {
|
|
25
|
+
console.error(chalk.red("✗ Invalid token format. Support tokens start with 'pat_'"));
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// 1. Save the token
|
|
30
|
+
saveConfig({ accessToken });
|
|
31
|
+
// 2. Verify and show whoami info
|
|
32
|
+
console.log(chalk.dim("Verifying session..."));
|
|
33
|
+
const user = await apiRequest("/profile");
|
|
34
|
+
const displayName = `${user.first_name || ""} ${user.last_name || ""}`.trim() || user.email;
|
|
35
|
+
console.log(chalk.green(`\n✓ Logged in as ${chalk.bold(displayName)} (${user.email})`));
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
console.error(chalk.red(`✗ ${err.message}`));
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import ora from "ora";
|
|
6
|
+
import { apiRequest } from "../api.js";
|
|
7
|
+
import { stepLabel, printSummary } from "../utils/ui.js";
|
|
8
|
+
import { requireValidPlatform, requireValidDeployment, requireNonEmpty } from "../utils/validators.js";
|
|
9
|
+
import { computeFileHash } from "../utils/hash.js";
|
|
10
|
+
import { createZip } from "../utils/zip.js";
|
|
11
|
+
const TEMP_ZIP = ".ota-flutter-assets.zip";
|
|
12
|
+
function getFlutterVersionFromPubspec() {
|
|
13
|
+
try {
|
|
14
|
+
const pubspecPath = path.join(process.cwd(), "pubspec.yaml");
|
|
15
|
+
if (!fs.existsSync(pubspecPath))
|
|
16
|
+
return null;
|
|
17
|
+
const content = fs.readFileSync(pubspecPath, "utf-8");
|
|
18
|
+
const match = content.match(/^version:\s*([^\s+]+)/m);
|
|
19
|
+
if (match && match[1])
|
|
20
|
+
return match[1].trim();
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// ignore
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
export const releaseFlutterCommand = new Command("release-flutter")
|
|
28
|
+
.description("Upload an AppSpacer OTA update for Flutter apps")
|
|
29
|
+
.requiredOption("-a, --app <appIdOrName>", "App ID or Name")
|
|
30
|
+
.requiredOption("-d, --deployment <name>", "Deployment name (e.g. Staging, Production)")
|
|
31
|
+
.requiredOption("-p, --platform <os>", "Target platform (android | ios)")
|
|
32
|
+
.option("-f, --file <path>", "Path to zipped assets OR assets directory (auto-detects 'assets/' if omitted)")
|
|
33
|
+
.option("-t, --target-version <version>", "Target app binary version (auto-detected from pubspec.yaml if omitted)")
|
|
34
|
+
.option("--description <text>", "Release description")
|
|
35
|
+
.option("--mandatory", "Mark update as mandatory", false)
|
|
36
|
+
.option("--scheduled-at <datetime>", "Schedule the release (ISO 8601)")
|
|
37
|
+
.option("--rollout <percentage>", "Rollout percentage (0-100)", "100")
|
|
38
|
+
.addHelpText("after", `
|
|
39
|
+
Examples:
|
|
40
|
+
$ appspacer release-flutter -p android -a my-app -d Staging
|
|
41
|
+
$ appspacer release-flutter -p ios -a my-app -d Production --mandatory -t 1.2.0
|
|
42
|
+
$ appspacer release-flutter -p android -a my-app -d Staging -f ./custom-assets.zip`)
|
|
43
|
+
.action(async (opts) => {
|
|
44
|
+
// ── Validate inputs ─────────────────────────────────────
|
|
45
|
+
requireValidPlatform(opts.platform);
|
|
46
|
+
requireNonEmpty(opts.app, "App ID");
|
|
47
|
+
requireValidDeployment(opts.deployment);
|
|
48
|
+
let filePath = opts.file ? path.resolve(opts.file) : null;
|
|
49
|
+
let isTemporaryZip = false;
|
|
50
|
+
try {
|
|
51
|
+
// ── Step 1: Resolve / package assets ───────────────────
|
|
52
|
+
if (!filePath) {
|
|
53
|
+
const defaultAssetsDir = path.resolve("assets");
|
|
54
|
+
if (fs.existsSync(defaultAssetsDir) && fs.statSync(defaultAssetsDir).isDirectory()) {
|
|
55
|
+
console.log(chalk.blue(" ℹ No file provided — auto-detecting assets/ directory"));
|
|
56
|
+
filePath = path.resolve(TEMP_ZIP);
|
|
57
|
+
isTemporaryZip = true;
|
|
58
|
+
const zipSpinner = ora(stepLabel(1, 3, "Packaging assets/ directory...")).start();
|
|
59
|
+
try {
|
|
60
|
+
await createZip(defaultAssetsDir, filePath);
|
|
61
|
+
zipSpinner.succeed(stepLabel(1, 3, "Assets packaged"));
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
zipSpinner.fail(stepLabel(1, 3, "Packaging failed"));
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
console.error(chalk.red(" ✗ No file provided and assets/ directory not found."));
|
|
70
|
+
console.log(chalk.dim(" Provide a file with -f or run from a Flutter project root."));
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
else if (fs.statSync(filePath).isDirectory()) {
|
|
75
|
+
const sourceDir = filePath;
|
|
76
|
+
filePath = path.resolve(TEMP_ZIP);
|
|
77
|
+
isTemporaryZip = true;
|
|
78
|
+
const zipSpinner = ora(stepLabel(1, 3, `Packaging ${path.basename(sourceDir)}/...`)).start();
|
|
79
|
+
try {
|
|
80
|
+
await createZip(sourceDir, filePath);
|
|
81
|
+
zipSpinner.succeed(stepLabel(1, 3, "Directory packaged"));
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
zipSpinner.fail(stepLabel(1, 3, "Packaging failed"));
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (!fs.existsSync(filePath)) {
|
|
89
|
+
console.error(chalk.red(` ✗ File not found: ${filePath}`));
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
const filename = isTemporaryZip ? "assets.zip" : path.basename(filePath);
|
|
93
|
+
const fileSize = fs.statSync(filePath).size;
|
|
94
|
+
const bundleHash = computeFileHash(filePath);
|
|
95
|
+
// Resolve target version
|
|
96
|
+
let targetVersion = opts.targetVersion;
|
|
97
|
+
if (!targetVersion) {
|
|
98
|
+
targetVersion = getFlutterVersionFromPubspec();
|
|
99
|
+
if (targetVersion) {
|
|
100
|
+
console.log(chalk.dim(` ℹ Auto-detected version ${chalk.white(targetVersion)} from pubspec.yaml`));
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
console.error(chalk.red(" ✗ Could not auto-detect version. Provide -t explicitly."));
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// ── Step 2: Upload ──────────────────────────────────────
|
|
108
|
+
const uploadSpinner = ora(stepLabel(2, 3, `Uploading ${filename} (${(fileSize / 1024).toFixed(1)} KB)...`)).start();
|
|
109
|
+
let uploadData;
|
|
110
|
+
try {
|
|
111
|
+
uploadData = await apiRequest("/codepush/upload-url", {
|
|
112
|
+
method: "POST",
|
|
113
|
+
body: JSON.stringify({ app_id: opts.app, deployment: opts.deployment, filename }),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
uploadSpinner.fail(stepLabel(2, 3, "Failed to get upload URL"));
|
|
118
|
+
throw err;
|
|
119
|
+
}
|
|
120
|
+
// Read file into buffer — reliable across all Node 18+ environments,
|
|
121
|
+
// avoids the ReadableStream + duplex:"half" workaround.
|
|
122
|
+
const fileBuffer = fs.readFileSync(filePath);
|
|
123
|
+
const uploadRes = await fetch(uploadData.signedUrl, {
|
|
124
|
+
method: "PUT",
|
|
125
|
+
headers: {
|
|
126
|
+
"Content-Type": "application/zip",
|
|
127
|
+
"Content-Length": String(fileSize),
|
|
128
|
+
},
|
|
129
|
+
body: fileBuffer,
|
|
130
|
+
});
|
|
131
|
+
if (!uploadRes.ok) {
|
|
132
|
+
uploadSpinner.fail(stepLabel(2, 3, "Upload failed"));
|
|
133
|
+
const errText = await uploadRes.text();
|
|
134
|
+
throw new Error(`Upload failed: ${errText}`);
|
|
135
|
+
}
|
|
136
|
+
uploadSpinner.succeed(stepLabel(2, 3, "Uploaded"));
|
|
137
|
+
// ── Step 3: Create release ──────────────────────────────
|
|
138
|
+
const releaseSpinner = ora(stepLabel(3, 3, "Creating release...")).start();
|
|
139
|
+
const release = await apiRequest("/codepush/release", {
|
|
140
|
+
method: "POST",
|
|
141
|
+
body: JSON.stringify({
|
|
142
|
+
app_id: opts.app,
|
|
143
|
+
deployment: opts.deployment,
|
|
144
|
+
platform: opts.platform,
|
|
145
|
+
target_binary_range: targetVersion,
|
|
146
|
+
description: opts.description || "",
|
|
147
|
+
is_mandatory: opts.mandatory,
|
|
148
|
+
bundle_url: uploadData.publicUrl,
|
|
149
|
+
bundle_hash: bundleHash,
|
|
150
|
+
package_size: fileSize,
|
|
151
|
+
scheduled_at: opts.scheduledAt,
|
|
152
|
+
rollout_pct: opts.rollout ? parseInt(opts.rollout) : 100,
|
|
153
|
+
}),
|
|
154
|
+
});
|
|
155
|
+
releaseSpinner.succeed(stepLabel(3, 3, "Release created"));
|
|
156
|
+
printSummary("Flutter release published", [
|
|
157
|
+
["Platform", opts.platform],
|
|
158
|
+
["Deployment", opts.deployment],
|
|
159
|
+
["Target", targetVersion],
|
|
160
|
+
["Mandatory", opts.mandatory ? chalk.yellow("yes") : "no"],
|
|
161
|
+
["Rollout", `${opts.rollout ?? 100}%`],
|
|
162
|
+
["Size", `${(fileSize / 1024).toFixed(1)} KB`],
|
|
163
|
+
["Hash", bundleHash.slice(0, 16) + "…"],
|
|
164
|
+
["Release ID", String(release.id)],
|
|
165
|
+
]);
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
console.error(chalk.red(`\n ✗ ${err.message}`));
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
if (isTemporaryZip && filePath && fs.existsSync(filePath)) {
|
|
173
|
+
fs.unlinkSync(filePath);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import { runReactNativeBundle } from "../utils/bundle.js";
|
|
7
|
+
import { createZip } from "../utils/zip.js";
|
|
8
|
+
import { computeFileHash } from "../utils/hash.js";
|
|
9
|
+
import { apiRequest } from "../api.js";
|
|
10
|
+
import { stepLabel, printSummary } from "../utils/ui.js";
|
|
11
|
+
import { requireValidPlatform, requireValidDeployment, requireValidVersion, requireNonEmpty, requireValidRollout } from "../utils/validators.js";
|
|
12
|
+
const BUILD_DIR = ".ota-build";
|
|
13
|
+
const ZIP_FILENAME = "update.zip";
|
|
14
|
+
export const releaseReactNativeCommand = new Command("release-react-native")
|
|
15
|
+
.description("Bundle, package, and upload a React Native OTA update")
|
|
16
|
+
.argument("<platform>", "Target platform (android | ios)")
|
|
17
|
+
.requiredOption("-a, --app <appIdOrName>", "App ID or Name")
|
|
18
|
+
.requiredOption("-d, --deployment <name>", "Deployment name (e.g. Staging, Production)")
|
|
19
|
+
.requiredOption("-t, --target-version <version>", "Target app version (e.g. 1.0.0)")
|
|
20
|
+
.option("--description <text>", "Release description")
|
|
21
|
+
.option("--mandatory", "Mark update as mandatory", false)
|
|
22
|
+
.option("--include-assets", "Include assets in the bundle (increases size significantly)", false)
|
|
23
|
+
.option("--scheduled-at <datetime>", "Schedule the release (ISO 8601)")
|
|
24
|
+
.option("--rollout <percentage>", "Rollout percentage (0-100)", "100")
|
|
25
|
+
.addHelpText("after", `
|
|
26
|
+
Examples:
|
|
27
|
+
$ appspacer release-react-native android -a my-app -d Staging -t 1.0.0
|
|
28
|
+
$ appspacer release-react-native ios -a my-app -d Production -t 2.1.0 --mandatory
|
|
29
|
+
$ appspacer release-react-native android -a my-app -d Staging -t 1.0.0 --rollout 20`)
|
|
30
|
+
.action(async (platform, opts) => {
|
|
31
|
+
// ── Validate inputs ─────────────────────────────────────
|
|
32
|
+
requireValidPlatform(platform);
|
|
33
|
+
requireNonEmpty(opts.app, "App ID");
|
|
34
|
+
requireValidDeployment(opts.deployment);
|
|
35
|
+
requireValidVersion(opts.targetVersion);
|
|
36
|
+
const rolloutPct = requireValidRollout(opts.rollout);
|
|
37
|
+
const buildDir = path.resolve(BUILD_DIR);
|
|
38
|
+
const zipPath = path.resolve(`${BUILD_DIR}.zip`);
|
|
39
|
+
try {
|
|
40
|
+
// ── Step 1: Bundle ──────────────────────────────────────
|
|
41
|
+
const bundleSpinner = ora(stepLabel(1, 4, "Bundling React Native app...")).start();
|
|
42
|
+
try {
|
|
43
|
+
runReactNativeBundle(platform, buildDir, opts.includeAssets);
|
|
44
|
+
bundleSpinner.succeed(stepLabel(1, 4, "Bundle created"));
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
bundleSpinner.fail(stepLabel(1, 4, "Bundling failed"));
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
// ── Step 2: Zip ─────────────────────────────────────────
|
|
51
|
+
const zipSpinner = ora(stepLabel(2, 4, "Packaging update...")).start();
|
|
52
|
+
let zipSize = 0;
|
|
53
|
+
try {
|
|
54
|
+
await createZip(buildDir, zipPath);
|
|
55
|
+
zipSize = fs.statSync(zipPath).size;
|
|
56
|
+
zipSpinner.succeed(stepLabel(2, 4, `Packaged ${chalk.dim(`(${(zipSize / 1024).toFixed(1)} KB)`)}`));
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
zipSpinner.fail(stepLabel(2, 4, "Packaging failed"));
|
|
60
|
+
throw err;
|
|
61
|
+
}
|
|
62
|
+
// ── Step 3: Hash ────────────────────────────────────────
|
|
63
|
+
const hashSpinner = ora(stepLabel(3, 5, "Calculating hash...")).start();
|
|
64
|
+
const bundleHash = computeFileHash(zipPath);
|
|
65
|
+
hashSpinner.succeed(stepLabel(3, 5, `Hash ${chalk.dim(bundleHash.slice(0, 16) + "…")}`));
|
|
66
|
+
// ── Step 4: Upload ──────────────────────────────────────
|
|
67
|
+
const uploadSpinner = ora(stepLabel(4, 5, `Uploading (${(zipSize / 1024).toFixed(1)} KB)...`)).start();
|
|
68
|
+
let uploadData;
|
|
69
|
+
try {
|
|
70
|
+
uploadData = await apiRequest("/codepush/upload-url", {
|
|
71
|
+
method: "POST",
|
|
72
|
+
body: JSON.stringify({
|
|
73
|
+
app_id: opts.app,
|
|
74
|
+
deployment: opts.deployment,
|
|
75
|
+
filename: ZIP_FILENAME,
|
|
76
|
+
}),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
uploadSpinner.fail(stepLabel(4, 5, "Failed to get upload URL"));
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
// Read file into buffer — reliable across all Node 18+ environments,
|
|
84
|
+
// avoids the ReadableStream + duplex:"half" workaround which silently
|
|
85
|
+
// fails on some storage backends.
|
|
86
|
+
const fileBuffer = fs.readFileSync(zipPath);
|
|
87
|
+
const uploadRes = await fetch(uploadData.signedUrl, {
|
|
88
|
+
method: "PUT",
|
|
89
|
+
headers: {
|
|
90
|
+
"Content-Type": "application/zip",
|
|
91
|
+
"Content-Length": String(zipSize),
|
|
92
|
+
},
|
|
93
|
+
body: fileBuffer,
|
|
94
|
+
});
|
|
95
|
+
if (!uploadRes.ok) {
|
|
96
|
+
uploadSpinner.fail(stepLabel(4, 5, "Upload failed"));
|
|
97
|
+
const errText = await uploadRes.text();
|
|
98
|
+
throw new Error(`Storage upload failed: ${errText}`);
|
|
99
|
+
}
|
|
100
|
+
uploadSpinner.succeed(stepLabel(4, 5, "Uploaded"));
|
|
101
|
+
// ── Step 5: Finalize ────────────────────────────────────
|
|
102
|
+
const finalizeSpinner = ora(stepLabel(5, 5, "Finalizing release...")).start();
|
|
103
|
+
const release = await apiRequest("/codepush/release", {
|
|
104
|
+
method: "POST",
|
|
105
|
+
body: JSON.stringify({
|
|
106
|
+
app_id: opts.app,
|
|
107
|
+
deployment: opts.deployment,
|
|
108
|
+
platform,
|
|
109
|
+
target_binary_range: opts.targetVersion,
|
|
110
|
+
description: opts.description || "",
|
|
111
|
+
is_mandatory: opts.mandatory,
|
|
112
|
+
bundle_url: uploadData.publicUrl,
|
|
113
|
+
bundle_hash: bundleHash,
|
|
114
|
+
package_size: zipSize,
|
|
115
|
+
scheduled_at: opts.scheduledAt,
|
|
116
|
+
rollout_pct: rolloutPct,
|
|
117
|
+
}),
|
|
118
|
+
});
|
|
119
|
+
finalizeSpinner.succeed(stepLabel(5, 5, "Release created"));
|
|
120
|
+
printSummary("React Native release published", [
|
|
121
|
+
["Platform", platform],
|
|
122
|
+
["Deployment", opts.deployment],
|
|
123
|
+
["Target", opts.targetVersion],
|
|
124
|
+
["Mandatory", opts.mandatory ? chalk.yellow("yes") : "no"],
|
|
125
|
+
["Rollout", `${rolloutPct}%`],
|
|
126
|
+
["Size", `${(zipSize / 1024).toFixed(1)} KB`],
|
|
127
|
+
["Hash", bundleHash.slice(0, 16) + "…"],
|
|
128
|
+
["Release ID", String(release.id)],
|
|
129
|
+
]);
|
|
130
|
+
// ── Cleanup ─────────────────────────────────────────────
|
|
131
|
+
fs.rmSync(buildDir, { recursive: true, force: true });
|
|
132
|
+
if (fs.existsSync(zipPath))
|
|
133
|
+
fs.unlinkSync(zipPath);
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
if (fs.existsSync(buildDir))
|
|
137
|
+
fs.rmSync(buildDir, { recursive: true, force: true });
|
|
138
|
+
if (fs.existsSync(zipPath))
|
|
139
|
+
fs.unlinkSync(zipPath);
|
|
140
|
+
console.error(chalk.red(`\n ✗ ${err.message}`));
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import { apiRequest } from "../api.js";
|
|
7
|
+
import { stepLabel, printSummary } from "../utils/ui.js";
|
|
8
|
+
import { requireValidPlatform, requireValidDeployment, requireValidVersion, requireNonEmpty, requireValidRollout } from "../utils/validators.js";
|
|
9
|
+
export const releaseCommand = new Command("release")
|
|
10
|
+
.description("Upload a JS bundle and create an OTA release")
|
|
11
|
+
.requiredOption("-a, --app <appIdOrName>", "App ID or Name")
|
|
12
|
+
.requiredOption("-d, --deployment <name>", "Deployment name (e.g. Staging, Production)")
|
|
13
|
+
.requiredOption("-p, --platform <os>", "Target platform (android | ios)")
|
|
14
|
+
.requiredOption("-f, --file <path>", "Path to the bundle file (.bundle or .zip)")
|
|
15
|
+
.requiredOption("-t, --target-version <version>", "Target app binary version or semver range (e.g. ^1.0.0)")
|
|
16
|
+
.option("--description <text>", "Release description")
|
|
17
|
+
.option("--mandatory", "Mark update as mandatory", false)
|
|
18
|
+
.option("--scheduled-at <datetime>", "Schedule the release (ISO 8601)")
|
|
19
|
+
.option("--rollout <percentage>", "Rollout percentage (0-100)", "100")
|
|
20
|
+
.addHelpText("after", `
|
|
21
|
+
Examples:
|
|
22
|
+
$ appspacer release -a my-app -d Staging -p android -f ./bundle.zip -t 1.0.0
|
|
23
|
+
$ appspacer release -a my-app -d Production -p ios -f ./main.jsbundle -t ^2.0.0 --mandatory`)
|
|
24
|
+
.action(async (opts) => {
|
|
25
|
+
// ── Validate inputs ─────────────────────────────────────
|
|
26
|
+
requireValidPlatform(opts.platform);
|
|
27
|
+
requireNonEmpty(opts.app, "App ID");
|
|
28
|
+
requireValidDeployment(opts.deployment);
|
|
29
|
+
requireValidVersion(opts.targetVersion);
|
|
30
|
+
const rolloutPct = requireValidRollout(opts.rollout);
|
|
31
|
+
try {
|
|
32
|
+
const filePath = path.resolve(opts.file);
|
|
33
|
+
if (!fs.existsSync(filePath)) {
|
|
34
|
+
console.error(chalk.red(` ✗ File not found: ${filePath}`));
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
const filename = path.basename(filePath);
|
|
38
|
+
const fileSize = fs.statSync(filePath).size;
|
|
39
|
+
// ── Step 1: Get upload URL ──────────────────────────────
|
|
40
|
+
const urlSpinner = ora(stepLabel(1, 3, "Requesting upload URL...")).start();
|
|
41
|
+
let uploadData;
|
|
42
|
+
try {
|
|
43
|
+
uploadData = await apiRequest("/codepush/upload-url", {
|
|
44
|
+
method: "POST",
|
|
45
|
+
body: JSON.stringify({ app_id: opts.app, deployment: opts.deployment, filename }),
|
|
46
|
+
});
|
|
47
|
+
urlSpinner.succeed(stepLabel(1, 3, "Upload URL ready"));
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
urlSpinner.fail(stepLabel(1, 3, "Failed to get upload URL"));
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
// ── Step 2: Upload ──────────────────────────────────────
|
|
54
|
+
const uploadSpinner = ora(stepLabel(2, 3, `Uploading ${filename} (${(fileSize / 1024).toFixed(1)} KB)...`)).start();
|
|
55
|
+
// Read file into buffer — reliable across all Node 18+ environments,
|
|
56
|
+
// avoids the ReadableStream + duplex:"half" workaround.
|
|
57
|
+
const fileBuffer = fs.readFileSync(filePath);
|
|
58
|
+
const contentType = filename.endsWith(".zip") ? "application/zip" : "application/octet-stream";
|
|
59
|
+
const uploadRes = await fetch(uploadData.signedUrl, {
|
|
60
|
+
method: "PUT",
|
|
61
|
+
headers: {
|
|
62
|
+
"Content-Type": contentType,
|
|
63
|
+
"Content-Length": String(fileSize),
|
|
64
|
+
},
|
|
65
|
+
body: fileBuffer,
|
|
66
|
+
});
|
|
67
|
+
if (!uploadRes.ok) {
|
|
68
|
+
uploadSpinner.fail(stepLabel(2, 3, "Upload failed"));
|
|
69
|
+
const errText = await uploadRes.text();
|
|
70
|
+
throw new Error(`Upload failed: ${errText}`);
|
|
71
|
+
}
|
|
72
|
+
uploadSpinner.succeed(stepLabel(2, 3, "Uploaded"));
|
|
73
|
+
// ── Step 3: Create release ──────────────────────────────
|
|
74
|
+
const releaseSpinner = ora(stepLabel(3, 3, "Creating release...")).start();
|
|
75
|
+
const release = await apiRequest("/codepush/release", {
|
|
76
|
+
method: "POST",
|
|
77
|
+
body: JSON.stringify({
|
|
78
|
+
app_id: opts.app,
|
|
79
|
+
deployment: opts.deployment,
|
|
80
|
+
platform: opts.platform,
|
|
81
|
+
target_binary_range: opts.targetVersion,
|
|
82
|
+
description: opts.description || "",
|
|
83
|
+
is_mandatory: opts.mandatory,
|
|
84
|
+
bundle_url: uploadData.publicUrl,
|
|
85
|
+
package_size: fileSize,
|
|
86
|
+
scheduled_at: opts.scheduledAt,
|
|
87
|
+
rollout_pct: rolloutPct,
|
|
88
|
+
}),
|
|
89
|
+
});
|
|
90
|
+
releaseSpinner.succeed(stepLabel(3, 3, "Release created"));
|
|
91
|
+
printSummary("Release published", [
|
|
92
|
+
["Platform", opts.platform],
|
|
93
|
+
["Deployment", opts.deployment],
|
|
94
|
+
["Target", opts.targetVersion],
|
|
95
|
+
["Mandatory", opts.mandatory ? chalk.yellow("yes") : "no"],
|
|
96
|
+
["Rollout", `${rolloutPct}%`],
|
|
97
|
+
["File", filename],
|
|
98
|
+
["Size", `${(fileSize / 1024).toFixed(1)} KB`],
|
|
99
|
+
["Release ID", String(release.id)],
|
|
100
|
+
]);
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
console.error(chalk.red(`\n ✗ ${err.message}`));
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { apiRequest } from "../api.js";
|
|
4
|
+
import { requireValidPlatform, requireValidDeployment, requireNonEmpty } from "../utils/validators.js";
|
|
5
|
+
export const rollbackCommand = new Command("rollback")
|
|
6
|
+
.description("Roll back the latest release for a deployment")
|
|
7
|
+
.requiredOption("-a, --app <appIdOrName>", "App ID or Name")
|
|
8
|
+
.requiredOption("-d, --deployment <name>", "Deployment name (e.g. Staging, Production)")
|
|
9
|
+
.requiredOption("-p, --platform <os>", "Target platform (android | ios)")
|
|
10
|
+
.addHelpText("after", `
|
|
11
|
+
Examples:
|
|
12
|
+
$ appspacer rollback -a my-app -d Staging -p android
|
|
13
|
+
$ appspacer rollback -a my-app -d Production -p ios`)
|
|
14
|
+
.action(async (opts) => {
|
|
15
|
+
// ── Validate inputs ─────────────────────────────────────
|
|
16
|
+
requireNonEmpty(opts.app, "App ID");
|
|
17
|
+
requireValidDeployment(opts.deployment);
|
|
18
|
+
requireValidPlatform(opts.platform);
|
|
19
|
+
try {
|
|
20
|
+
console.log(chalk.dim(` → Rolling back ${opts.platform} release on ${opts.deployment}...`));
|
|
21
|
+
const response = await apiRequest("/codepush/rollback", {
|
|
22
|
+
method: "POST",
|
|
23
|
+
body: JSON.stringify({
|
|
24
|
+
app_id: opts.app,
|
|
25
|
+
deployment: opts.deployment,
|
|
26
|
+
platform: opts.platform,
|
|
27
|
+
}),
|
|
28
|
+
});
|
|
29
|
+
console.log(chalk.green(`\n ✓ Rollback successful!`));
|
|
30
|
+
console.log(chalk.dim(` Disabled: ${response.disabled_release.label} (v${response.disabled_release.version})`));
|
|
31
|
+
if (response.now_active_release) {
|
|
32
|
+
console.log(chalk.dim(` Now active: ${response.now_active_release.label} (v${response.now_active_release.version})`));
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
console.log(chalk.yellow(` Warning: no active releases remaining for this deployment.`));
|
|
36
|
+
}
|
|
37
|
+
console.log("");
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
console.error(chalk.red(` ✗ ${err.message}`));
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
type Architecture = "new" | "traditional";
|
|
3
|
+
export declare function detectArchitecture(content: string): Architecture;
|
|
4
|
+
export declare function hasExistingInjection(content: string): boolean;
|
|
5
|
+
export declare function removeExistingInjection(content: string): string;
|
|
6
|
+
export declare function injectTraditionalAndroid(content: string): {
|
|
7
|
+
result: string;
|
|
8
|
+
method: string;
|
|
9
|
+
warn?: string;
|
|
10
|
+
};
|
|
11
|
+
export declare function injectNewArchAndroid(content: string): {
|
|
12
|
+
result: string;
|
|
13
|
+
method: string;
|
|
14
|
+
warn?: string;
|
|
15
|
+
};
|
|
16
|
+
export declare function injectIosSetup(content: string, filePath: string): {
|
|
17
|
+
result: string;
|
|
18
|
+
warn?: string;
|
|
19
|
+
};
|
|
20
|
+
export declare const undoCommand: Command;
|
|
21
|
+
export declare const setupCommand: Command;
|
|
22
|
+
export {};
|