Skip to content

Commit 66234cc

Browse files
authored
feat(cli): generate upload signature and upload bundle.zip (#2484)
* feat(cli): generate upload signature and upload bundle.zip * feat: read from env variables * feat: use zod to validate response * refactor: parse * feat: read api host from env
1 parent 4b40859 commit 66234cc

8 files changed

Lines changed: 184 additions & 7 deletions

File tree

packages/cli/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@
2525
"chalk": "^5.3.0",
2626
"commander": "^14.0.0",
2727
"consola": "^3.4.2",
28-
"nypm": "^0.6.0"
28+
"nypm": "^0.6.0",
29+
"zod": "^4.0.5"
2930
},
3031
"devDependencies": {
3132
"@bigcommerce/eslint-config": "^2.11.0",
@@ -36,6 +37,7 @@
3637
"@vitest/coverage-v8": "^3.2.4",
3738
"@vitest/ui": "^3.2.4",
3839
"eslint": "^8.57.1",
40+
"msw": "^2.9.0",
3941
"prettier": "^3.6.2",
4042
"tsup": "^8.5.0",
4143
"typescript": "^5.8.3",

packages/cli/src/commands/deploy.ts

Lines changed: 115 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
11
import AdmZip from 'adm-zip';
2-
import { Command } from 'commander';
2+
import { Command, Option } from 'commander';
33
import { consola } from 'consola';
4-
import { access, readdir } from 'node:fs/promises';
4+
import { access, readdir, readFile } from 'node:fs/promises';
55
import { join } from 'node:path';
6+
import { z } from 'zod';
7+
8+
const UploadSignatureSchema = z.object({
9+
data: z.object({
10+
upload_url: z.url(),
11+
upload_uuid: z.string(),
12+
}),
13+
});
614

715
export const generateBundleZip = async (rootDir: string) => {
8-
consola.info('Generating bundle.zip');
16+
consola.info('Generating bundle...');
917

1018
const distDir = join(rootDir, '.bigcommerce/dist');
1119

@@ -33,19 +41,121 @@ export const generateBundleZip = async (rootDir: string) => {
3341
zip.addLocalFolder(distDir, 'output');
3442
zip.writeZip(outputZip);
3543

36-
consola.success(`Created ${outputZip}`);
44+
consola.success(`Bundle created at: ${outputZip}`);
45+
};
46+
47+
export const generateUploadSignature = async (
48+
storeHash: string,
49+
accessToken: string,
50+
apiHost: string,
51+
) => {
52+
consola.info('Generating upload signature...');
53+
54+
try {
55+
const response = await fetch(
56+
`https://${apiHost}/stores/${storeHash}/v3/headless/deployments/uploads`,
57+
{
58+
method: 'POST',
59+
headers: {
60+
'X-Auth-Token': accessToken,
61+
'Content-Type': 'application/json',
62+
Accept: 'application/json',
63+
},
64+
body: JSON.stringify({}),
65+
},
66+
);
67+
68+
if (!response.ok) {
69+
consola.error(`Failed to fetch upload signature: ${response.status} ${response.statusText}`);
70+
process.exit(1);
71+
}
72+
73+
const res: unknown = await response.json();
74+
const { data } = UploadSignatureSchema.parse(res);
75+
76+
consola.success('Upload signature generated.');
77+
78+
return data;
79+
} catch (error) {
80+
consola.error('Error in generateUploadSignature:', error);
81+
process.exit(1);
82+
}
83+
};
84+
85+
export const uploadBundleZip = async (uploadUrl: string, rootDir: string) => {
86+
consola.info('Uploading bundle...');
87+
88+
const zipPath = join(rootDir, '.bigcommerce/dist/bundle.zip');
89+
90+
// Read the zip file as a buffer
91+
const fileBuffer = await readFile(zipPath);
92+
93+
try {
94+
const response = await fetch(uploadUrl, {
95+
method: 'PUT',
96+
headers: {
97+
'Content-Type': 'application/zip',
98+
},
99+
body: fileBuffer,
100+
});
101+
102+
if (!response.ok) {
103+
consola.error(`Failed to upload bundle: ${response.status} ${response.statusText}`);
104+
process.exit(1);
105+
}
106+
107+
consola.success('Bundle uploaded successfully.');
108+
109+
return true;
110+
} catch (error) {
111+
consola.error('Error in uploadBundleZip:', error);
112+
process.exit(1);
113+
}
37114
};
38115

39116
interface DeployOptions {
117+
storeHash?: string;
118+
accessToken?: string;
40119
rootDir: string;
120+
apiHost: string;
41121
}
42122

43123
export const deploy = new Command('deploy')
44124
.description('Deploy the application to Cloudflare')
45-
.option('--root-dir <rootDir>', 'Root directory', process.cwd())
125+
.addOption(
126+
new Option(
127+
'--store-hash <hash>',
128+
'BigCommerce store hash. Can be found in the URL of your store Control Panel.',
129+
).env('BIGCOMMERCE_STORE_HASH'),
130+
)
131+
.addOption(
132+
new Option(
133+
'--access-token <token>',
134+
'BigCommerce access token. Can be found after creating a store-level API account.',
135+
).env('BIGCOMMERCE_ACCESS_TOKEN'),
136+
)
137+
.addOption(
138+
new Option('--api-host <host>', 'BigCommerce API host. The default is api.bigcommerce.com.')
139+
.env('BIGCOMMERCE_API_HOST')
140+
.default('api.bigcommerce.com'),
141+
)
142+
.option('--root-dir <rootDir>', 'Root directory to deploy from.', process.cwd())
46143
.action(async (opts: DeployOptions) => {
144+
if (!opts.storeHash || !opts.accessToken) {
145+
consola.error('Missing store hash and access token.');
146+
process.exit(1);
147+
}
148+
47149
await generateBundleZip(opts.rootDir);
48150

151+
const uploadSignature = await generateUploadSignature(
152+
opts.storeHash,
153+
opts.accessToken,
154+
opts.apiHost,
155+
);
156+
157+
await uploadBundleZip(uploadSignature.upload_url, opts.rootDir);
158+
49159
// @todo rest of upload flow
50160
process.exit(0);
51161
});

packages/cli/tests/commands/deploy.spec.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import { mkdir, stat, writeFile } from 'node:fs/promises';
33
import { dirname, join } from 'node:path';
44
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
55

6-
import { generateBundleZip } from '../../src/commands/deploy';
6+
import {
7+
generateBundleZip,
8+
generateUploadSignature,
9+
uploadBundleZip,
10+
} from '../../src/commands/deploy';
711
import { mkTempDir } from '../../src/lib/mk-temp-dir';
812

913
let tmpDir: string;
@@ -58,3 +62,24 @@ describe('bundle zip generation', () => {
5862
expect(entries).toContain('output/worker.js');
5963
});
6064
});
65+
66+
describe('bundle zip upload', () => {
67+
test('fetches upload signature and uploads bundle zip', async () => {
68+
const storeHash = 'test-store';
69+
const accessToken = 'test-token';
70+
const apiHost = 'api.bigcommerce.com';
71+
72+
// Test generateUploadSignature
73+
const signature = await generateUploadSignature(storeHash, accessToken, apiHost);
74+
75+
expect(signature.upload_url).toBe('https://mock-upload-url.com');
76+
expect(signature.upload_uuid).toBe('mock-uuid');
77+
78+
// Test uploadBundleZip
79+
await generateBundleZip(tmpDir); // Ensure zip exists
80+
81+
const uploadResult = await uploadBundleZip('https://mock-upload-url.com', tmpDir);
82+
83+
expect(uploadResult).toBe(true);
84+
});
85+
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { http, HttpResponse } from 'msw';
2+
3+
export const handlers = [
4+
// Handler for generateUploadSignature
5+
http.post('https://:apiHost/stores/:storeHash/v3/headless/deployments/uploads', () =>
6+
HttpResponse.json({
7+
data: {
8+
upload_url: 'https://mock-upload-url.com',
9+
upload_uuid: 'mock-uuid',
10+
},
11+
}),
12+
),
13+
14+
// Handler for uploadBundleZip
15+
http.put('https://mock-upload-url.com', () => new HttpResponse(null, { status: 200 })),
16+
];

packages/cli/tests/mocks/node.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { setupServer } from 'msw/node';
2+
3+
import { handlers } from './handlers';
4+
5+
export const server = setupServer(...handlers);

packages/cli/tests/vitest.setup.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { afterAll, afterEach, beforeAll } from 'vitest';
2+
3+
import { server } from './mocks/node';
4+
5+
beforeAll(() => server.listen());
6+
afterEach(() => server.resetHandlers());
7+
afterAll(() => server.close());

packages/cli/vitest.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { defineConfig } from 'vitest/config';
33
export default defineConfig({
44
test: {
55
environment: 'node',
6+
setupFiles: './tests/vitest.setup.ts',
67
include: ['tests/**/*.spec.ts'],
78
exclude: ['**/node_modules/**', '**/dist/**'],
89
coverage: {

pnpm-lock.yaml

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

0 commit comments

Comments
 (0)