@granite-js/forge-cli 0.0.0-dev-20250725013859
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 +3 -0
- package/bin/index.js +3 -0
- package/dist/index.js +255 -0
- package/package.json +38 -0
package/README.md
ADDED
package/bin/index.js
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { Command } from "@commander-js/extra-typings";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { fromNodeProviderChain } from "@aws-sdk/credential-providers";
|
|
4
|
+
import { loadSharedConfigFiles } from "@aws-sdk/shared-ini-file-loader";
|
|
5
|
+
import * as p$3 from "@clack/prompts";
|
|
6
|
+
import * as p$2 from "@clack/prompts";
|
|
7
|
+
import * as p$1 from "@clack/prompts";
|
|
8
|
+
import * as p from "@clack/prompts";
|
|
9
|
+
import { loadConfig } from "@granite-js/cli";
|
|
10
|
+
import { DeployManager, NoSuchKey, S3Client } from "@granite-js/deployment-manager";
|
|
11
|
+
import * as v from "valibot";
|
|
12
|
+
import { v7 } from "uuid";
|
|
13
|
+
import { createReadStream, createWriteStream } from "fs";
|
|
14
|
+
import { pipeline } from "stream/promises";
|
|
15
|
+
import { createGzip } from "zlib";
|
|
16
|
+
|
|
17
|
+
//#region src/utils/generateDeploymentId.ts
|
|
18
|
+
function generateDeploymentId() {
|
|
19
|
+
return v7();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
//#endregion
|
|
23
|
+
//#region src/utils/gzip.ts
|
|
24
|
+
/**
|
|
25
|
+
* Compresses inputFile using gzip and saves it to outputFile.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} inputFile - Path to the source file to compress
|
|
28
|
+
* @param {string} outputFile - Path to save the gzipped file (recommended to use .gz extension)
|
|
29
|
+
* @throws Throws an error if compression fails
|
|
30
|
+
*/
|
|
31
|
+
async function gzipFile({ inputFile, outputFile }) {
|
|
32
|
+
await pipeline(createReadStream(inputFile), createGzip(), createWriteStream(outputFile));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
//#endregion
|
|
36
|
+
//#region src/utils/handlePrompts.ts
|
|
37
|
+
function handlePrompts(title, task) {
|
|
38
|
+
return (...args) => {
|
|
39
|
+
p$3.intro(title);
|
|
40
|
+
try {
|
|
41
|
+
const result = task.call(null, ...args);
|
|
42
|
+
if (result instanceof Promise) result.catch(promptErrorHandler);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
promptErrorHandler(error);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function promptErrorHandler(error) {
|
|
49
|
+
p$3.log.error(error instanceof Error ? error.message : String(error));
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
//#endregion
|
|
54
|
+
//#region src/operations/deploy.ts
|
|
55
|
+
const deploy$1 = handlePrompts("Start deployment", deployImpl);
|
|
56
|
+
async function deployImpl({ androidBundle, iosBundle, appName, tag }, context) {
|
|
57
|
+
const { s3Client } = context;
|
|
58
|
+
const gzippedAndroidBundle = `${androidBundle}.gz`;
|
|
59
|
+
const gzippedIosBundle = `${iosBundle}.gz`;
|
|
60
|
+
await Promise.all([gzipFile({
|
|
61
|
+
inputFile: androidBundle,
|
|
62
|
+
outputFile: gzippedAndroidBundle
|
|
63
|
+
}), gzipFile({
|
|
64
|
+
inputFile: iosBundle,
|
|
65
|
+
outputFile: gzippedIosBundle
|
|
66
|
+
})]);
|
|
67
|
+
const deploymentId = generateDeploymentId();
|
|
68
|
+
const deployedAt = new Date();
|
|
69
|
+
let spinner = p$2.spinner();
|
|
70
|
+
spinner.start("Fetching current deployment state...");
|
|
71
|
+
const currentDeploymentState = await DeployManager.readDeploymentState(appName, { s3Client }).catch(handleReadDeploymentStateError);
|
|
72
|
+
spinner.stop("Successfully fetched current deployment state");
|
|
73
|
+
let newDeploymentState;
|
|
74
|
+
if (currentDeploymentState != null) {
|
|
75
|
+
p$2.log.info("Planning rollout...");
|
|
76
|
+
newDeploymentState = await DeployManager.planRollout(currentDeploymentState, {
|
|
77
|
+
progress: 100,
|
|
78
|
+
targetDeploymentId: deploymentId
|
|
79
|
+
});
|
|
80
|
+
switch (newDeploymentState.type) {
|
|
81
|
+
case "STABLE":
|
|
82
|
+
p$2.log.info(`Stable deployment planned (target: ${newDeploymentState.deploymentId})`);
|
|
83
|
+
break;
|
|
84
|
+
case "CANARY":
|
|
85
|
+
case "PENDING":
|
|
86
|
+
console.error("unimplemented yet");
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
p$2.log.warn("No deployment state found");
|
|
91
|
+
newDeploymentState = {
|
|
92
|
+
type: "STABLE",
|
|
93
|
+
deploymentId
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (process.stdin.isTTY) {
|
|
97
|
+
const confirmed = await p$2.confirm({ message: `Are you sure you want to deploy ${appName}?` });
|
|
98
|
+
if (!confirmed || p$2.isCancel(confirmed)) {
|
|
99
|
+
p$2.outro("Deployment cancelled");
|
|
100
|
+
process.exit(0);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
await p$2.tasks([{
|
|
104
|
+
bundlePath: gzippedAndroidBundle,
|
|
105
|
+
platform: "android"
|
|
106
|
+
}, {
|
|
107
|
+
bundlePath: gzippedIosBundle,
|
|
108
|
+
platform: "ios"
|
|
109
|
+
}].map(({ bundlePath, platform }) => ({
|
|
110
|
+
title: `Uploading bundle... (${platform})`,
|
|
111
|
+
task: async () => {
|
|
112
|
+
DeployManager.uploadBundle({
|
|
113
|
+
bundlePath,
|
|
114
|
+
appName,
|
|
115
|
+
platform,
|
|
116
|
+
tag,
|
|
117
|
+
deploymentId,
|
|
118
|
+
deployedAt
|
|
119
|
+
}, { s3Client });
|
|
120
|
+
return "Bundle uploaded";
|
|
121
|
+
}
|
|
122
|
+
})));
|
|
123
|
+
spinner = p$2.spinner();
|
|
124
|
+
spinner.start("Updating bundle list...");
|
|
125
|
+
await DeployManager.updateBundleList({
|
|
126
|
+
appName,
|
|
127
|
+
newDeploymentInfo: {
|
|
128
|
+
deployedAt: deployedAt.getTime(),
|
|
129
|
+
deploymentId
|
|
130
|
+
}
|
|
131
|
+
}, { s3Client });
|
|
132
|
+
spinner.stop("Bundle list updated");
|
|
133
|
+
spinner = p$2.spinner();
|
|
134
|
+
spinner.start(`Deploying ${appName}@${deploymentId}...`);
|
|
135
|
+
await DeployManager.rollout({
|
|
136
|
+
state: newDeploymentState,
|
|
137
|
+
appName
|
|
138
|
+
}, { s3Client });
|
|
139
|
+
spinner.stop(`Deployed successfully! (Deployment ID: ${deploymentId})`);
|
|
140
|
+
p$2.outro("Done");
|
|
141
|
+
}
|
|
142
|
+
function handleReadDeploymentStateError(error) {
|
|
143
|
+
if (error instanceof NoSuchKey) return null;
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
//#endregion
|
|
148
|
+
//#region src/commands/deploy.ts
|
|
149
|
+
const envSchema = v.object({
|
|
150
|
+
AWS_REGION: v.string(),
|
|
151
|
+
AWS_ACCESS_KEY_ID: v.string(),
|
|
152
|
+
AWS_SECRET_ACCESS_KEY: v.string(),
|
|
153
|
+
AWS_SESSION_TOKEN: v.optional(v.string())
|
|
154
|
+
});
|
|
155
|
+
const awsCredentialsProvider = fromNodeProviderChain();
|
|
156
|
+
function deploy() {
|
|
157
|
+
return new Command("deploy").description("Deploy a Granite application").requiredOption("--bucket <BUCKET>", "AWS bucket").action(async (options) => {
|
|
158
|
+
const [config, awsCredentials, region] = await Promise.all([
|
|
159
|
+
loadConfig(),
|
|
160
|
+
awsCredentialsProvider(),
|
|
161
|
+
getRegion()
|
|
162
|
+
]);
|
|
163
|
+
const envResult = v.safeParse(envSchema, {
|
|
164
|
+
AWS_REGION: region,
|
|
165
|
+
AWS_ACCESS_KEY_ID: awsCredentials.accessKeyId,
|
|
166
|
+
AWS_SECRET_ACCESS_KEY: awsCredentials.secretAccessKey,
|
|
167
|
+
AWS_SESSION_TOKEN: awsCredentials.sessionToken
|
|
168
|
+
});
|
|
169
|
+
if (!envResult.success) {
|
|
170
|
+
const missingVars = envResult.issues.map((issue) => {
|
|
171
|
+
const path$1 = issue.path?.[0];
|
|
172
|
+
return path$1 ? `process.env.${path$1.key}` : "unknown variable";
|
|
173
|
+
});
|
|
174
|
+
p$1.log.error(`Required environment variables are not set:`);
|
|
175
|
+
for (const missingVar of missingVars) p$1.log.error(`${missingVar}`);
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
await deploy$1({
|
|
179
|
+
appName: config.appName,
|
|
180
|
+
iosBundle: path.join(config.outdir, `bundle.ios.hbc`),
|
|
181
|
+
androidBundle: path.join(config.outdir, `bundle.android.hbc`)
|
|
182
|
+
}, { s3Client: new S3Client({
|
|
183
|
+
region,
|
|
184
|
+
bucket: options.bucket,
|
|
185
|
+
credentials: {
|
|
186
|
+
accessKeyId: envResult.output.AWS_ACCESS_KEY_ID,
|
|
187
|
+
secretAccessKey: envResult.output.AWS_SECRET_ACCESS_KEY,
|
|
188
|
+
sessionToken: envResult.output.AWS_SESSION_TOKEN
|
|
189
|
+
}
|
|
190
|
+
}) });
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
async function getRegion() {
|
|
194
|
+
const { configFile } = await loadSharedConfigFiles();
|
|
195
|
+
const envRegion = process.env.AWS_REGION;
|
|
196
|
+
if (envRegion != null) return envRegion;
|
|
197
|
+
const currentProfile = process.env.AWS_PROFILE;
|
|
198
|
+
if (currentProfile != null) {
|
|
199
|
+
const region = configFile?.[currentProfile]?.region;
|
|
200
|
+
if (region != null) return region;
|
|
201
|
+
}
|
|
202
|
+
const defaultRegion = configFile.default?.region;
|
|
203
|
+
return defaultRegion;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
//#endregion
|
|
207
|
+
//#region src/helpers/command.ts
|
|
208
|
+
function withS3Client(command) {
|
|
209
|
+
return command.requiredOption("--region <REGION>", "AWS region").requiredOption("--bucket <BUCKET>", "AWS bucket").requiredOption("--access-key-id <ACCESS_KEY_ID>", "AWS access key ID").requiredOption("--secret-access-key <SECRET_ACCESS_KEY>", "AWS secret access key").option("--session-token <SESSION_TOKEN>", "AWS session token");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
//#endregion
|
|
213
|
+
//#region src/operations/deployList.ts
|
|
214
|
+
const deployList$1 = handlePrompts("Deployment list", deployListImpl);
|
|
215
|
+
async function deployListImpl({ appName }, context) {
|
|
216
|
+
const { s3Client } = context;
|
|
217
|
+
const spinner = p.spinner();
|
|
218
|
+
spinner.start("Fetching deployment list...");
|
|
219
|
+
const deployments = await DeployManager.readBundleList(appName, { s3Client }).catch(handleReadBundleListError);
|
|
220
|
+
spinner.stop("Successfully fetched deployment list");
|
|
221
|
+
deployments.slice(0, 20).forEach((deployment, index) => {
|
|
222
|
+
p.log.info(`${index + 1}. ${deployment.deploymentId} (Deployed at: ${new Date(deployment.deployedAt).toLocaleString()})`);
|
|
223
|
+
});
|
|
224
|
+
p.outro(deployments.length ? `${deployments.length} deployment(s) found` : "No deployments found");
|
|
225
|
+
}
|
|
226
|
+
function handleReadBundleListError(error) {
|
|
227
|
+
if (error instanceof NoSuchKey) return [];
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
//#endregion
|
|
232
|
+
//#region src/commands/deployList.ts
|
|
233
|
+
function deployList() {
|
|
234
|
+
return withS3Client(new Command("deploy-list")).description("Show the deployment list of a Granite application").requiredOption("-n, --app-name <APP_NAME>", "Granite application name").action(async (options) => {
|
|
235
|
+
await deployList$1(options, { s3Client: new S3Client({
|
|
236
|
+
region: options.region,
|
|
237
|
+
bucket: options.bucket,
|
|
238
|
+
credentials: {
|
|
239
|
+
accessKeyId: options.accessKeyId,
|
|
240
|
+
secretAccessKey: options.secretAccessKey,
|
|
241
|
+
sessionToken: options.sessionToken
|
|
242
|
+
}
|
|
243
|
+
}) });
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
//#endregion
|
|
248
|
+
//#region src/index.ts
|
|
249
|
+
const program = new Command("granite-forge");
|
|
250
|
+
program.description("A CLI tool for managing Granite applications");
|
|
251
|
+
program.addCommand(deploy());
|
|
252
|
+
program.addCommand(deployList());
|
|
253
|
+
program.parse();
|
|
254
|
+
|
|
255
|
+
//#endregion
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@granite-js/forge-cli",
|
|
3
|
+
"description": "A CLI tool for managing Granite applications",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"version": "0.0.0-dev-20250725013859",
|
|
6
|
+
"bin": {
|
|
7
|
+
"granite-forge": "./bin/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"bin",
|
|
12
|
+
"package.json"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"prepack": "yarn build",
|
|
16
|
+
"typecheck": "tsc --noEmit",
|
|
17
|
+
"test": "vitest --run --coverage --passWithNoTests",
|
|
18
|
+
"build": "tsdown"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/node": "^22.10.2",
|
|
22
|
+
"@vitest/coverage-v8": "^3.1.4",
|
|
23
|
+
"tsdown": "^0.11.13",
|
|
24
|
+
"typescript": "^5.8.3",
|
|
25
|
+
"vitest": "^3.1.4"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@aws-sdk/credential-providers": "^3.817.0",
|
|
29
|
+
"@aws-sdk/shared-ini-file-loader": "^3.374.0",
|
|
30
|
+
"@clack/prompts": "^0.10.1",
|
|
31
|
+
"@commander-js/extra-typings": "^14.0.0",
|
|
32
|
+
"@granite-js/cli": "workspace:*",
|
|
33
|
+
"@granite-js/deployment-manager": "workspace:*",
|
|
34
|
+
"commander": "^14.0.0",
|
|
35
|
+
"uuid": "^11.1.0",
|
|
36
|
+
"valibot": "^1.1.0"
|
|
37
|
+
}
|
|
38
|
+
}
|