Skip to content

Commit 036a661

Browse files
tagboolatonybaroneee
authored andcommitted
Add app distribute command (#1612)
Note: App Distribution is still a closed alpha, so you need to be whitelisted for this command to work.
1 parent 2066c22 commit 036a661

File tree

8 files changed

+590
-0
lines changed

8 files changed

+590
-0
lines changed

‎package-lock.json

Lines changed: 38 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@
9090
"marked": "^0.7.0",
9191
"marked-terminal": "^3.3.0",
9292
"minimatch": "^3.0.4",
93+
"plist": "^3.0.1",
9394
"open": "^6.3.0",
9495
"ora": "^3.4.0",
9596
"portfinder": "^1.0.23",
@@ -121,6 +122,7 @@
121122
"@types/mocha": "^5.2.5",
122123
"@types/nock": "^9.3.0",
123124
"@types/node": "^10.12.0",
125+
"@types/plist": "^3.0.1",
124126
"@types/progress": "^2.0.3",
125127
"@types/request": "^2.48.1",
126128
"@types/semver": "^6.0.0",

‎src/api.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,14 @@ var api = {
9999
"https://logging.googleapis.com"
100100
),
101101
adminOrigin: utils.envOverride("FIREBASE_ADMIN_URL", "https://admin.firebase.com"),
102+
appDistributionOrigin: utils.envOverride(
103+
"FIREBASE_APP_DISTRIBUTION_URL",
104+
"https://firebaseappdistribution.googleapis.com"
105+
),
106+
appDistributionUploadOrigin: utils.envOverride(
107+
"FIREBASE_APP_DISTRIBUTION_UPLOAD_URL",
108+
"https://appdistribution-uploads.crashlytics.com"
109+
),
102110
appengineOrigin: utils.envOverride("FIREBASE_APPENGINE_URL", "https://appengine.googleapis.com"),
103111
authOrigin: utils.envOverride("FIREBASE_AUTH_URL", "https://accounts.google.com"),
104112
consoleOrigin: utils.envOverride("FIREBASE_CONSOLE_URL", "https://console.firebase.google.com"),

‎src/appdistribution/client.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import * as _ from "lodash";
2+
import * as api from "../api";
3+
import * as utils from "../utils";
4+
import { Distribution } from "./distribution";
5+
import { FirebaseError } from "../error";
6+
7+
// tslint:disable-next-line:no-var-requires
8+
const pkg = require("../../package.json");
9+
10+
/**
11+
* Proxies HTTPS requests to the App Distribution server backend.
12+
*/
13+
export class AppDistributionClient {
14+
static MAX_POLLING_RETRIES = 30;
15+
static POLLING_INTERVAL_MS = 1000;
16+
17+
constructor(private readonly appId: string) {}
18+
19+
async provisionApp(): Promise<void> {
20+
await api.request("POST", `/v1alpha/apps/${this.appId}`, {
21+
origin: api.appDistributionOrigin,
22+
auth: true,
23+
});
24+
25+
utils.logSuccess("provisioned for app distribution");
26+
}
27+
28+
async getJwtToken(): Promise<string> {
29+
const apiResponse = await api.request("GET", `/v1alpha/apps/${this.appId}/jwt`, {
30+
auth: true,
31+
origin: api.appDistributionOrigin,
32+
});
33+
34+
return _.get(apiResponse, "body.token");
35+
}
36+
37+
async uploadDistribution(token: string, distribution: Distribution): Promise<string> {
38+
const apiResponse = await api.request("POST", "/spi/v1/jwt_distributions", {
39+
origin: api.appDistributionUploadOrigin,
40+
headers: {
41+
Authorization: `Bearer ${token}`,
42+
"X-APP-DISTRO-API-CLIENT-ID": pkg.name,
43+
"X-APP-DISTRO-API-CLIENT-TYPE": distribution.platform(),
44+
"X-APP-DISTRO-API-CLIENT-VERSION": pkg.version,
45+
},
46+
files: {
47+
file: {
48+
stream: distribution.readStream(),
49+
size: distribution.fileSize(),
50+
contentType: "multipart/form-data",
51+
},
52+
},
53+
});
54+
55+
return _.get(apiResponse, "response.headers.etag");
56+
}
57+
58+
async pollReleaseIdByHash(hash: string, retryCount = 0): Promise<string> {
59+
try {
60+
return await this.getReleaseIdByHash(hash);
61+
} catch (err) {
62+
if (retryCount >= AppDistributionClient.MAX_POLLING_RETRIES) {
63+
throw new FirebaseError(`failed to find the uploaded release: ${err.message}`, { exit: 1 });
64+
}
65+
66+
await new Promise((resolve) =>
67+
setTimeout(resolve, AppDistributionClient.POLLING_INTERVAL_MS)
68+
);
69+
70+
return this.pollReleaseIdByHash(hash, retryCount + 1);
71+
}
72+
}
73+
74+
async getReleaseIdByHash(hash: string): Promise<string> {
75+
const apiResponse = await api.request(
76+
"GET",
77+
`/v1alpha/apps/${this.appId}/release_by_hash/${hash}`,
78+
{
79+
origin: api.appDistributionOrigin,
80+
auth: true,
81+
}
82+
);
83+
84+
return _.get(apiResponse, "body.release.id");
85+
}
86+
87+
async addReleaseNotes(releaseId: string, releaseNotes: string): Promise<void> {
88+
if (!releaseNotes) {
89+
utils.logWarning("no release notes specified, skipping");
90+
return;
91+
}
92+
93+
utils.logBullet("adding release notes...");
94+
95+
const data = {
96+
releaseNotes: {
97+
releaseNotes,
98+
},
99+
};
100+
101+
try {
102+
await api.request("POST", `/v1alpha/apps/${this.appId}/releases/${releaseId}/notes`, {
103+
origin: api.appDistributionOrigin,
104+
auth: true,
105+
data,
106+
});
107+
} catch (err) {
108+
throw new FirebaseError(`failed to add release notes with ${err.message}`, { exit: 1 });
109+
}
110+
111+
utils.logSuccess("added release notes successfully");
112+
}
113+
114+
async enableAccess(
115+
releaseId: string,
116+
emails: string[] = [],
117+
groupIds: string[] = []
118+
): Promise<void> {
119+
if (emails.length === 0 && groupIds.length === 0) {
120+
utils.logWarning("no testers or groups specified, skipping");
121+
return;
122+
}
123+
124+
utils.logBullet("adding testers/groups...");
125+
126+
const data = {
127+
emails,
128+
groupIds,
129+
};
130+
131+
try {
132+
await api.request("POST", `/v1alpha/apps/${this.appId}/releases/${releaseId}/enable_access`, {
133+
origin: api.appDistributionOrigin,
134+
auth: true,
135+
data,
136+
});
137+
} catch (err) {
138+
let errorMessage = err.message;
139+
if (_.has(err, "context.body.error")) {
140+
const errorStatus = _.get(err, "context.body.error.status");
141+
if (errorStatus === "FAILED_PRECONDITION") {
142+
errorMessage = "invalid testers";
143+
} else if (errorStatus === "INVALID_ARGUMENT") {
144+
errorMessage = "invalid groups";
145+
}
146+
}
147+
throw new FirebaseError(`failed to add testers/groups: ${errorMessage}`, { exit: 1 });
148+
}
149+
150+
utils.logSuccess("added testers/groups successfully");
151+
}
152+
}

‎src/appdistribution/distribution.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import * as fs from "fs-extra";
2+
import { FirebaseError } from "../error";
3+
import * as crypto from "crypto";
4+
5+
export enum DistributionFileType {
6+
IPA = "ipa",
7+
APK = "apk",
8+
}
9+
10+
/**
11+
* Object representing an APK or IPa file. Used for uploading app distributions.
12+
*/
13+
export class Distribution {
14+
private readonly fileType: DistributionFileType;
15+
16+
constructor(private readonly path: string) {
17+
if (!path) {
18+
throw new FirebaseError("must specify a distribution file");
19+
}
20+
21+
const distributionType = path.split(".").pop();
22+
if (
23+
distributionType !== DistributionFileType.IPA &&
24+
distributionType !== DistributionFileType.APK
25+
) {
26+
throw new FirebaseError("unsupported distribution file format, should be .ipa or .apk");
27+
}
28+
29+
if (!fs.existsSync(path)) {
30+
throw new FirebaseError(
31+
`File ${path} does not exist: verify that file points to a distribution`
32+
);
33+
}
34+
35+
this.path = path;
36+
this.fileType = distributionType;
37+
}
38+
39+
fileSize(): number {
40+
return fs.statSync(this.path).size;
41+
}
42+
43+
readStream(): fs.ReadStream {
44+
return fs.createReadStream(this.path);
45+
}
46+
47+
platform(): string {
48+
switch (this.fileType) {
49+
case DistributionFileType.IPA:
50+
return "ios";
51+
case DistributionFileType.APK:
52+
return "android";
53+
default:
54+
throw new FirebaseError("Unsupported distribution file format, should be .ipa or .apk");
55+
}
56+
}
57+
58+
async releaseHash(): Promise<string> {
59+
return new Promise<string>((resolve) => {
60+
const hash = crypto.createHash("sha1");
61+
const stream = this.readStream();
62+
stream.on("data", (data) => hash.update(data));
63+
stream.on("end", () => {
64+
return resolve(hash.digest("hex"));
65+
});
66+
});
67+
}
68+
}

0 commit comments

Comments
 (0)