@shopify/oxygen-cli 1.0.2
Sign up to get free protection for your applications and to get access to all the features.
- package/README.md +91 -0
- package/dist/commands/oxygen/deploy.d.ts +26 -0
- package/dist/commands/oxygen/deploy.js +121 -0
- package/dist/deploy/build-cancel.d.ts +6 -0
- package/dist/deploy/build-cancel.js +36 -0
- package/dist/deploy/build-cancel.test.d.ts +2 -0
- package/dist/deploy/build-cancel.test.js +73 -0
- package/dist/deploy/build-initiate.d.ts +6 -0
- package/dist/deploy/build-initiate.js +38 -0
- package/dist/deploy/build-initiate.test.d.ts +2 -0
- package/dist/deploy/build-initiate.test.js +81 -0
- package/dist/deploy/build-project.d.ts +5 -0
- package/dist/deploy/build-project.js +32 -0
- package/dist/deploy/build-project.test.d.ts +2 -0
- package/dist/deploy/build-project.test.js +53 -0
- package/dist/deploy/deployment-cancel.d.ts +6 -0
- package/dist/deploy/deployment-cancel.js +36 -0
- package/dist/deploy/deployment-cancel.test.d.ts +2 -0
- package/dist/deploy/deployment-cancel.test.js +78 -0
- package/dist/deploy/deployment-complete.d.ts +6 -0
- package/dist/deploy/deployment-complete.js +33 -0
- package/dist/deploy/deployment-complete.test.d.ts +2 -0
- package/dist/deploy/deployment-complete.test.js +77 -0
- package/dist/deploy/deployment-initiate.d.ts +17 -0
- package/dist/deploy/deployment-initiate.js +40 -0
- package/dist/deploy/deployment-initiate.test.d.ts +2 -0
- package/dist/deploy/deployment-initiate.test.js +136 -0
- package/dist/deploy/get-upload-files.d.ts +5 -0
- package/dist/deploy/get-upload-files.js +65 -0
- package/dist/deploy/get-upload-files.test.d.ts +2 -0
- package/dist/deploy/get-upload-files.test.js +56 -0
- package/dist/deploy/graphql/build-cancel.d.ts +14 -0
- package/dist/deploy/graphql/build-cancel.js +14 -0
- package/dist/deploy/graphql/build-initiate.d.ts +15 -0
- package/dist/deploy/graphql/build-initiate.js +15 -0
- package/dist/deploy/graphql/deployment-cancel.d.ts +14 -0
- package/dist/deploy/graphql/deployment-cancel.js +14 -0
- package/dist/deploy/graphql/deployment-complete.d.ts +17 -0
- package/dist/deploy/graphql/deployment-complete.js +16 -0
- package/dist/deploy/graphql/deployment-initiate.d.ts +28 -0
- package/dist/deploy/graphql/deployment-initiate.js +25 -0
- package/dist/deploy/index.d.ts +5 -0
- package/dist/deploy/index.js +74 -0
- package/dist/deploy/metadata.d.ts +11 -0
- package/dist/deploy/metadata.js +65 -0
- package/dist/deploy/metadata.test.d.ts +2 -0
- package/dist/deploy/metadata.test.js +131 -0
- package/dist/deploy/types.d.ts +52 -0
- package/dist/deploy/types.js +7 -0
- package/dist/deploy/upload-files.d.ts +6 -0
- package/dist/deploy/upload-files.js +156 -0
- package/dist/deploy/upload-files.test.d.ts +2 -0
- package/dist/deploy/upload-files.test.js +194 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +11 -0
- package/dist/oxygen-cli.d.ts +1 -0
- package/dist/oxygen-cli.js +5 -0
- package/dist/utils/test-helper.d.ts +14 -0
- package/dist/utils/test-helper.js +27 -0
- package/dist/utils/utils.d.ts +20 -0
- package/dist/utils/utils.js +126 -0
- package/dist/utils/utils.test.d.ts +2 -0
- package/dist/utils/utils.test.js +154 -0
- package/oclif.manifest.json +108 -0
- package/package.json +68 -0
@@ -0,0 +1,28 @@
|
|
1
|
+
import { OxygenError } from '../types.js';
|
2
|
+
|
3
|
+
declare const DeploymentInitiateQuery = "\n mutation DeploymentInitiate($buildId: ID, $environment: EnvironmentSelectorInput, $labels: [String!], $files: [FileInput!]!) {\n deploymentInitiate(buildId: $buildId, environment: $environment, labels: $labels, files: $files) {\n deployment {\n id\n status\n }\n deploymentTargets {\n filePath\n fileSize\n uploadUrl\n fileType\n parameters {\n name\n value\n }\n }\n userErrors {\n message\n }\n }\n }\n";
|
4
|
+
interface DeploymentInitiateQueryData {
|
5
|
+
deploymentInitiate: DeploymentInitiateResponse;
|
6
|
+
}
|
7
|
+
interface DeploymentInitiateResponse {
|
8
|
+
deployment: Deployment;
|
9
|
+
deploymentTargets: DeploymentTargetResponse[];
|
10
|
+
userErrors: OxygenError[];
|
11
|
+
}
|
12
|
+
interface DeploymentTargetResponse {
|
13
|
+
filePath: string;
|
14
|
+
fileSize: number;
|
15
|
+
uploadUrl: string;
|
16
|
+
fileType: string;
|
17
|
+
parameters: DeploymentInitiateParameters[] | null;
|
18
|
+
}
|
19
|
+
interface Deployment {
|
20
|
+
id: string;
|
21
|
+
status: string;
|
22
|
+
}
|
23
|
+
interface DeploymentInitiateParameters {
|
24
|
+
name: string;
|
25
|
+
value: string;
|
26
|
+
}
|
27
|
+
|
28
|
+
export { DeploymentInitiateQuery, DeploymentInitiateQueryData, DeploymentInitiateResponse, DeploymentTargetResponse };
|
@@ -0,0 +1,25 @@
|
|
1
|
+
const DeploymentInitiateQuery = `
|
2
|
+
mutation DeploymentInitiate($buildId: ID, $environment: EnvironmentSelectorInput, $labels: [String!], $files: [FileInput!]!) {
|
3
|
+
deploymentInitiate(buildId: $buildId, environment: $environment, labels: $labels, files: $files) {
|
4
|
+
deployment {
|
5
|
+
id
|
6
|
+
status
|
7
|
+
}
|
8
|
+
deploymentTargets {
|
9
|
+
filePath
|
10
|
+
fileSize
|
11
|
+
uploadUrl
|
12
|
+
fileType
|
13
|
+
parameters {
|
14
|
+
name
|
15
|
+
value
|
16
|
+
}
|
17
|
+
}
|
18
|
+
userErrors {
|
19
|
+
message
|
20
|
+
}
|
21
|
+
}
|
22
|
+
}
|
23
|
+
`;
|
24
|
+
|
25
|
+
export { DeploymentInitiateQuery };
|
@@ -0,0 +1,74 @@
|
|
1
|
+
import { outputSuccess, outputWarn, consoleError } from '@shopify/cli-kit/node/output';
|
2
|
+
import { verifyConfig } from '../utils/utils.js';
|
3
|
+
import { buildInitiate } from './build-initiate.js';
|
4
|
+
import { buildCancel } from './build-cancel.js';
|
5
|
+
import { getUploadFiles } from './get-upload-files.js';
|
6
|
+
import { deploymentInitiate } from './deployment-initiate.js';
|
7
|
+
import { deploymentComplete } from './deployment-complete.js';
|
8
|
+
import { deploymentCancel } from './deployment-cancel.js';
|
9
|
+
import { uploadFiles } from './upload-files.js';
|
10
|
+
import { buildProject } from './build-project.js';
|
11
|
+
import { getMetadata, createLabels, getEnvironmentInput } from './metadata.js';
|
12
|
+
|
13
|
+
async function createDeploy(config) {
|
14
|
+
const build = {};
|
15
|
+
let buildCompleted;
|
16
|
+
let deployment;
|
17
|
+
try {
|
18
|
+
const metadata = await getMetadata(config);
|
19
|
+
const labels = createLabels(metadata);
|
20
|
+
const environment = getEnvironmentInput(config, metadata);
|
21
|
+
if (!config.workerOnly && !config.skipBuild) {
|
22
|
+
const buildInitiateResponse = await buildInitiate(
|
23
|
+
config,
|
24
|
+
environment,
|
25
|
+
labels
|
26
|
+
);
|
27
|
+
build.id = buildInitiateResponse.build.id;
|
28
|
+
build.assetPath = buildInitiateResponse.build.assetPath;
|
29
|
+
}
|
30
|
+
if (!config.skipBuild) {
|
31
|
+
await buildProject(config, build.assetPath);
|
32
|
+
verifyConfig({ config, performedBuild: true });
|
33
|
+
}
|
34
|
+
buildCompleted = true;
|
35
|
+
const manifest = await getUploadFiles(config);
|
36
|
+
const deploymentInitiateInput = build.id ? { buildId: build.id, manifest } : { environment, manifest, labels };
|
37
|
+
deployment = await deploymentInitiate(config, deploymentInitiateInput);
|
38
|
+
await uploadFiles(config, deployment.deploymentTargets);
|
39
|
+
const deploymentCompleteOp = await deploymentComplete(
|
40
|
+
config,
|
41
|
+
deployment.deployment.id
|
42
|
+
);
|
43
|
+
outputSuccess(
|
44
|
+
`Deployment complete: ${deploymentCompleteOp.deployment.url}`
|
45
|
+
);
|
46
|
+
} catch (error) {
|
47
|
+
if (!(error instanceof Error)) {
|
48
|
+
console.error("Unknown error", error);
|
49
|
+
return;
|
50
|
+
}
|
51
|
+
if (build.id && !buildCompleted) {
|
52
|
+
outputWarn(`Build failed with: ${error.message}, cancelling build.`);
|
53
|
+
await buildCancel(config, build.id, error.message).catch((err) => {
|
54
|
+
if (err instanceof Error) {
|
55
|
+
outputWarn(`Failed to cancel build: ${err.message}`);
|
56
|
+
}
|
57
|
+
});
|
58
|
+
} else if (deployment?.deployment.id) {
|
59
|
+
outputWarn(
|
60
|
+
`Deployment failed with: ${error.message}, cancelling deployment.`
|
61
|
+
);
|
62
|
+
await deploymentCancel(config, deployment.deployment.id, "failed").catch(
|
63
|
+
(err) => {
|
64
|
+
if (err instanceof Error) {
|
65
|
+
outputWarn(`Failed to cancel deployment: ${err.message}`);
|
66
|
+
}
|
67
|
+
}
|
68
|
+
);
|
69
|
+
}
|
70
|
+
consoleError(error.message);
|
71
|
+
}
|
72
|
+
}
|
73
|
+
|
74
|
+
export { createDeploy };
|
@@ -0,0 +1,11 @@
|
|
1
|
+
import { CIMetadata } from '@shopify/cli-kit/node/context/local';
|
2
|
+
import { DeployConfig, EnvironmentInput } from './types.js';
|
3
|
+
|
4
|
+
type Metadata = CIMetadata & {
|
5
|
+
name: string;
|
6
|
+
};
|
7
|
+
declare function getMetadata(config: DeployConfig): Promise<Metadata>;
|
8
|
+
declare function getEnvironmentInput(config: DeployConfig, metadata: CIMetadata): EnvironmentInput | undefined;
|
9
|
+
declare function createLabels(metadata: Metadata): string[];
|
10
|
+
|
11
|
+
export { createLabels, getEnvironmentInput, getMetadata };
|
@@ -0,0 +1,65 @@
|
|
1
|
+
import { ciPlatform } from '@shopify/cli-kit/node/context/local';
|
2
|
+
import { getLatestGitCommit } from '@shopify/cli-kit/node/git';
|
3
|
+
import { outputWarn } from '@shopify/cli-kit/node/output';
|
4
|
+
import { maxLabelLength } from '../utils/utils.js';
|
5
|
+
|
6
|
+
async function getMetadata(config) {
|
7
|
+
const ciInfo = ciPlatform();
|
8
|
+
let metadata = {};
|
9
|
+
if (ciInfo.isCI && ciInfo.name !== "unknown") {
|
10
|
+
metadata = ciInfo.metadata;
|
11
|
+
} else {
|
12
|
+
try {
|
13
|
+
const gitCommit = await getLatestGitCommit(config.rootPath);
|
14
|
+
const branch = gitCommit.refs.split(" ");
|
15
|
+
metadata = {
|
16
|
+
actor: gitCommit.author_name,
|
17
|
+
branch: branch[branch.length - 1],
|
18
|
+
commitSha: gitCommit.hash,
|
19
|
+
commitMessage: gitCommit.message
|
20
|
+
};
|
21
|
+
} catch (error) {
|
22
|
+
outputWarn("No CI metadata loaded from environment");
|
23
|
+
}
|
24
|
+
}
|
25
|
+
return {
|
26
|
+
name: ciInfo.isCI ? ciInfo.name : "unknown",
|
27
|
+
...metadata,
|
28
|
+
actor: config.metadata.user ?? metadata.actor,
|
29
|
+
commitSha: config.metadata.version ?? metadata.commitSha,
|
30
|
+
url: config.metadata.url ?? metadata.url
|
31
|
+
};
|
32
|
+
}
|
33
|
+
function getEnvironmentInput(config, metadata) {
|
34
|
+
const tag = config.environmentTag || metadata.branch;
|
35
|
+
return tag ? { tag } : void 0;
|
36
|
+
}
|
37
|
+
function createLabels(metadata) {
|
38
|
+
const labels = [];
|
39
|
+
const checkLabel = (labelKey, labelValue) => {
|
40
|
+
if (labelValue.length > maxLabelLength) {
|
41
|
+
throw new Error(
|
42
|
+
`Provided ${labelKey} metadata exceeds maximum length (max ${maxLabelLength} characters).`
|
43
|
+
);
|
44
|
+
}
|
45
|
+
const label = `${labelKey}=${JSON.stringify(labelValue)}`;
|
46
|
+
labels.push(label);
|
47
|
+
};
|
48
|
+
if (metadata.name !== "unknown" && metadata.run) {
|
49
|
+
labels.push(`${metadata.name}-runId=${JSON.stringify(metadata.run)}`);
|
50
|
+
}
|
51
|
+
const keys = ["url", "actor", "commitSha"];
|
52
|
+
const keyMapping = {
|
53
|
+
actor: "user",
|
54
|
+
commitSha: "version"
|
55
|
+
};
|
56
|
+
keys.forEach((key) => {
|
57
|
+
if (metadata[key]) {
|
58
|
+
const labelKey = keyMapping[key] || key;
|
59
|
+
checkLabel(labelKey, metadata[key]);
|
60
|
+
}
|
61
|
+
});
|
62
|
+
return labels;
|
63
|
+
}
|
64
|
+
|
65
|
+
export { createLabels, getEnvironmentInput, getMetadata };
|
@@ -0,0 +1,131 @@
|
|
1
|
+
import { getLatestGitCommit } from '@shopify/cli-kit/node/git';
|
2
|
+
import { ciPlatform } from '@shopify/cli-kit/node/context/local';
|
3
|
+
import { vi, describe, test, expect } from 'vitest';
|
4
|
+
import { maxLabelLength } from '../utils/utils.js';
|
5
|
+
import { createTestConfig } from '../utils/test-helper.js';
|
6
|
+
import { getMetadata, getEnvironmentInput, createLabels } from './metadata.js';
|
7
|
+
|
8
|
+
vi.mock("@shopify/cli-kit/node/context/local");
|
9
|
+
vi.mock("@shopify/cli-kit/node/git");
|
10
|
+
describe("getMetadata", () => {
|
11
|
+
test("should return custom metadata from environment", async () => {
|
12
|
+
vi.mocked(ciPlatform).mockReturnValueOnce({
|
13
|
+
isCI: true,
|
14
|
+
name: "circle",
|
15
|
+
metadata: {
|
16
|
+
actor: "circle_actor",
|
17
|
+
commitSha: "circle_sha",
|
18
|
+
url: "circle_url"
|
19
|
+
}
|
20
|
+
});
|
21
|
+
const testConfig = createTestConfig("/tmp/deploymentRoot/");
|
22
|
+
const metadataResult = await getMetadata(testConfig);
|
23
|
+
expect(metadataResult.actor).toBe("circle_actor");
|
24
|
+
expect(metadataResult.commitSha).toBe("circle_sha");
|
25
|
+
expect(metadataResult.name).toBe("circle");
|
26
|
+
expect(metadataResult.url).toBe("circle_url");
|
27
|
+
});
|
28
|
+
test("should return custom metadata when provided in config", async () => {
|
29
|
+
vi.mocked(ciPlatform).mockReturnValueOnce({
|
30
|
+
isCI: true,
|
31
|
+
name: "circle",
|
32
|
+
metadata: {
|
33
|
+
actor: "circle_actor",
|
34
|
+
commitSha: "circle_sha",
|
35
|
+
url: "circle_url"
|
36
|
+
}
|
37
|
+
});
|
38
|
+
const testConfig = createTestConfig("/tmp/deploymentRoot/");
|
39
|
+
testConfig.metadata = {
|
40
|
+
user: "custom_user",
|
41
|
+
version: "custom_version",
|
42
|
+
url: "custom_url"
|
43
|
+
};
|
44
|
+
const metadataResult = await getMetadata(testConfig);
|
45
|
+
expect(metadataResult.actor).toBe("custom_user");
|
46
|
+
expect(metadataResult.commitSha).toBe("custom_version");
|
47
|
+
expect(metadataResult.name).toBe("circle");
|
48
|
+
expect(metadataResult.url).toBe("custom_url");
|
49
|
+
});
|
50
|
+
test("should return commit data from latest commit through getLastestGitCommit", async () => {
|
51
|
+
vi.mocked(ciPlatform).mockReturnValueOnce({
|
52
|
+
isCI: false
|
53
|
+
});
|
54
|
+
vi.mocked(getLatestGitCommit).mockResolvedValueOnce({
|
55
|
+
author_name: "gh_author",
|
56
|
+
author_email: "test@github.com",
|
57
|
+
hash: "gh_hash",
|
58
|
+
message: "gh_message",
|
59
|
+
refs: "gh_refs",
|
60
|
+
date: "gh_date",
|
61
|
+
body: "gh_body"
|
62
|
+
});
|
63
|
+
const testConfig = createTestConfig("/tmp/deploymentRoot/");
|
64
|
+
const metadataResult = await getMetadata(testConfig);
|
65
|
+
expect(metadataResult.actor).toBe("gh_author");
|
66
|
+
expect(metadataResult.commitSha).toBe("gh_hash");
|
67
|
+
expect(metadataResult.name).toBe("unknown");
|
68
|
+
expect(metadataResult.url).toBe(void 0);
|
69
|
+
});
|
70
|
+
});
|
71
|
+
describe("getEnvironmentInput", () => {
|
72
|
+
test("should return environment tag from config", () => {
|
73
|
+
const testConfig = createTestConfig("/tmp/deploymentRoot/");
|
74
|
+
testConfig.environmentTag = "test_tag";
|
75
|
+
const metadata = {
|
76
|
+
branch: "test_branch"
|
77
|
+
};
|
78
|
+
const environmentInput = getEnvironmentInput(testConfig, metadata);
|
79
|
+
expect(environmentInput.tag).toBe("test_tag");
|
80
|
+
});
|
81
|
+
test("should return environment tag from metadata", () => {
|
82
|
+
const testConfig = createTestConfig("/tmp/deploymentRoot/");
|
83
|
+
delete testConfig.environmentTag;
|
84
|
+
const metadata = {
|
85
|
+
branch: "test_branch"
|
86
|
+
};
|
87
|
+
const environmentInput = getEnvironmentInput(testConfig, metadata);
|
88
|
+
expect(environmentInput.tag).toBe("test_branch");
|
89
|
+
});
|
90
|
+
test("should return undefined if no environment tag is provided", () => {
|
91
|
+
const testConfig = createTestConfig("/tmp/deploymentRoot/");
|
92
|
+
delete testConfig.environmentTag;
|
93
|
+
const metadata = {};
|
94
|
+
expect(getEnvironmentInput(testConfig, metadata)).toBe(void 0);
|
95
|
+
});
|
96
|
+
});
|
97
|
+
describe("createLabels", () => {
|
98
|
+
test("should return labels from metadata", () => {
|
99
|
+
const metadata = {
|
100
|
+
name: "circle",
|
101
|
+
run: "circle_1234",
|
102
|
+
url: "http://circleci.com/workflow/123",
|
103
|
+
actor: "circle_actor",
|
104
|
+
commitSha: "circle_sha"
|
105
|
+
};
|
106
|
+
const labels = [
|
107
|
+
'circle-runId="circle_1234"',
|
108
|
+
'url="http://circleci.com/workflow/123"',
|
109
|
+
'user="circle_actor"',
|
110
|
+
'version="circle_sha"'
|
111
|
+
];
|
112
|
+
expect(createLabels(metadata)).toEqual(labels);
|
113
|
+
});
|
114
|
+
test("should return labels from metadata only for populated fields", () => {
|
115
|
+
const metadata = {
|
116
|
+
name: "circle",
|
117
|
+
run: "circle_run"
|
118
|
+
};
|
119
|
+
const labels = ['circle-runId="circle_run"'];
|
120
|
+
expect(createLabels(metadata)).toEqual(labels);
|
121
|
+
});
|
122
|
+
test("should throw when user metadata exceeds maximum length", () => {
|
123
|
+
const metadata = {
|
124
|
+
actor: "a".repeat(101),
|
125
|
+
name: "unknown"
|
126
|
+
};
|
127
|
+
expect(() => createLabels(metadata)).toThrow(
|
128
|
+
`Provided user metadata exceeds maximum length (max ${maxLabelLength} characters).`
|
129
|
+
);
|
130
|
+
});
|
131
|
+
});
|
@@ -0,0 +1,52 @@
|
|
1
|
+
interface Build {
|
2
|
+
id: string;
|
3
|
+
assetPath: string;
|
4
|
+
}
|
5
|
+
interface ClientError extends Error {
|
6
|
+
statusCode: number;
|
7
|
+
}
|
8
|
+
interface DeployConfig {
|
9
|
+
assetsDir?: string;
|
10
|
+
buildCommand: string;
|
11
|
+
deploymentToken: DeploymentToken;
|
12
|
+
deploymentUrl: string;
|
13
|
+
environmentTag?: string;
|
14
|
+
metadata: {
|
15
|
+
user?: string;
|
16
|
+
version?: string;
|
17
|
+
url?: string;
|
18
|
+
};
|
19
|
+
rootPath?: string;
|
20
|
+
skipBuild: boolean;
|
21
|
+
workerDir?: string;
|
22
|
+
workerOnly: boolean;
|
23
|
+
}
|
24
|
+
interface DeploymentToken {
|
25
|
+
accessToken: string;
|
26
|
+
allowedResource: string;
|
27
|
+
appId: string;
|
28
|
+
client: string;
|
29
|
+
expiresAt: string;
|
30
|
+
namespace: string;
|
31
|
+
namespaceId: string;
|
32
|
+
}
|
33
|
+
interface DeploymentManifestFile {
|
34
|
+
filePath: string;
|
35
|
+
fileSize: number;
|
36
|
+
mimeType: string;
|
37
|
+
fileHash: string;
|
38
|
+
fileType: string;
|
39
|
+
}
|
40
|
+
interface EnvironmentInput {
|
41
|
+
handle?: string;
|
42
|
+
tag?: string;
|
43
|
+
}
|
44
|
+
declare enum FileType {
|
45
|
+
Worker = "WORKER",
|
46
|
+
Asset = "ASSET"
|
47
|
+
}
|
48
|
+
interface OxygenError {
|
49
|
+
message: string;
|
50
|
+
}
|
51
|
+
|
52
|
+
export { Build, ClientError, DeployConfig, DeploymentManifestFile, DeploymentToken, EnvironmentInput, FileType, OxygenError };
|
@@ -0,0 +1,156 @@
|
|
1
|
+
import { formData, fetch } from '@shopify/cli-kit/node/http';
|
2
|
+
import { createFileReadStream } from '@shopify/cli-kit/node/fs';
|
3
|
+
import { outputInfo, outputCompleted } from '@shopify/cli-kit/node/output';
|
4
|
+
import { joinPath } from '@shopify/cli-kit/node/path';
|
5
|
+
import { mapLimit } from 'async';
|
6
|
+
import { deployDefaults } from '../utils/utils.js';
|
7
|
+
|
8
|
+
async function uploadFiles(config, targets) {
|
9
|
+
outputInfo(`Uploading ${targets.length} files...`);
|
10
|
+
return mapLimit(targets, 6, async (target) => {
|
11
|
+
await uploadFile(config, target);
|
12
|
+
}).then(() => {
|
13
|
+
outputCompleted(`Files uploaded successfully`);
|
14
|
+
});
|
15
|
+
}
|
16
|
+
async function uploadFile(config, target) {
|
17
|
+
const localFolderPath = target.fileType === "WORKER" ? joinPath(config.rootPath, config.workerDir) : joinPath(config.rootPath, config.assetsDir);
|
18
|
+
if (target.parameters !== null && target.parameters.length > 0) {
|
19
|
+
const form = formData();
|
20
|
+
target.parameters.forEach((param) => {
|
21
|
+
form.append(param.name, param.value);
|
22
|
+
});
|
23
|
+
form.append(
|
24
|
+
"file",
|
25
|
+
createFileReadStream(joinPath(localFolderPath, target.filePath))
|
26
|
+
);
|
27
|
+
await formUpload(form, target);
|
28
|
+
} else {
|
29
|
+
const initData = await initiateResumableUpload(target);
|
30
|
+
await performResumableUpload(
|
31
|
+
joinPath(localFolderPath, target.filePath),
|
32
|
+
initData
|
33
|
+
);
|
34
|
+
}
|
35
|
+
}
|
36
|
+
async function formUpload(form, target, attemptNumber = 0) {
|
37
|
+
try {
|
38
|
+
const timeoutDuration = 6e4;
|
39
|
+
const controller = new AbortController();
|
40
|
+
const timeout = setTimeout(() => {
|
41
|
+
controller.abort();
|
42
|
+
}, timeoutDuration);
|
43
|
+
const response = await fetch(target.uploadUrl, {
|
44
|
+
method: "POST",
|
45
|
+
body: form,
|
46
|
+
signal: controller.signal,
|
47
|
+
headers: {
|
48
|
+
Connection: "keep-alive"
|
49
|
+
}
|
50
|
+
});
|
51
|
+
clearTimeout(timeout);
|
52
|
+
if (!response.ok) {
|
53
|
+
throw new Error(`${response.status}`);
|
54
|
+
}
|
55
|
+
} catch (err) {
|
56
|
+
if (isErrorCode(err, "ENOENT")) {
|
57
|
+
throw new Error(`File not found: ${target.filePath}`);
|
58
|
+
}
|
59
|
+
if (attemptNumber < Number(deployDefaults.maxUploadAttempts)) {
|
60
|
+
await formUpload(form, target, attemptNumber + 1);
|
61
|
+
} else {
|
62
|
+
if (err instanceof Error && err.name === "AbortError") {
|
63
|
+
throw new Error(`Request timeout whilst uploading ${target.filePath}`);
|
64
|
+
}
|
65
|
+
throw new Error(`Failed to upload file ${target.filePath}`);
|
66
|
+
}
|
67
|
+
}
|
68
|
+
}
|
69
|
+
async function initiateResumableUpload(target) {
|
70
|
+
return fetch(target.uploadUrl, {
|
71
|
+
method: "POST",
|
72
|
+
headers: {
|
73
|
+
"x-goog-resumable": "start",
|
74
|
+
"X-Goog-Content-Length-Range": `0,${target.fileSize}`,
|
75
|
+
"User-Agent": "oxygen-cli"
|
76
|
+
}
|
77
|
+
}).then((res) => {
|
78
|
+
return {
|
79
|
+
sessionUri: res.headers.get("x-guploader-uploadid"),
|
80
|
+
location: res.headers.get("location"),
|
81
|
+
target
|
82
|
+
};
|
83
|
+
}).catch((err) => {
|
84
|
+
throw new Error(
|
85
|
+
`Failed to initiate resumable upload for file ${target.filePath} (status code ${err.statusCode})`
|
86
|
+
);
|
87
|
+
});
|
88
|
+
}
|
89
|
+
async function performResumableUpload(localFilePath, initData, startByte = 0, attemptNumber = 0) {
|
90
|
+
await uploadResumable(initData.location, localFilePath, startByte).catch(
|
91
|
+
async (err) => {
|
92
|
+
if (isErrorCode(err, "ENOENT")) {
|
93
|
+
throw new Error(`File not found: ${initData.target.filePath}`);
|
94
|
+
}
|
95
|
+
if (err && attemptNumber >= Number(deployDefaults.maxResumabeUploadAttempts)) {
|
96
|
+
throw new Error(
|
97
|
+
`Failed to upload file ${initData.target.filePath} after ${deployDefaults.maxResumabeUploadAttempts} attempts`
|
98
|
+
);
|
99
|
+
}
|
100
|
+
const status = await resumableUploadStatus(
|
101
|
+
initData.location,
|
102
|
+
initData.target.fileSize
|
103
|
+
);
|
104
|
+
if (!status.complete) {
|
105
|
+
const nextAttemptStartByte = status.lastReceivedByte;
|
106
|
+
const attempt = attemptNumber + 1;
|
107
|
+
await performResumableUpload(
|
108
|
+
localFilePath,
|
109
|
+
initData,
|
110
|
+
nextAttemptStartByte,
|
111
|
+
attempt
|
112
|
+
);
|
113
|
+
}
|
114
|
+
}
|
115
|
+
);
|
116
|
+
}
|
117
|
+
async function uploadResumable(location, filePath, lastReceivedByte) {
|
118
|
+
const file = createFileReadStream(filePath, { start: lastReceivedByte });
|
119
|
+
return fetch(location, {
|
120
|
+
method: "PUT",
|
121
|
+
body: file
|
122
|
+
}).then((res) => {
|
123
|
+
return res.status;
|
124
|
+
});
|
125
|
+
}
|
126
|
+
async function resumableUploadStatus(location, fileSize) {
|
127
|
+
const getLastByte = (range) => {
|
128
|
+
if (!range || range.split("-").length !== 2)
|
129
|
+
return 0;
|
130
|
+
const rangeParts = range.split("-");
|
131
|
+
return parseInt(rangeParts[1], 10);
|
132
|
+
};
|
133
|
+
return fetch(location, {
|
134
|
+
method: "PUT",
|
135
|
+
headers: {
|
136
|
+
"Content-Length": "0",
|
137
|
+
"Content-Range": `bytes */${fileSize}`
|
138
|
+
}
|
139
|
+
}).then((res) => {
|
140
|
+
return {
|
141
|
+
complete: res.status === 200,
|
142
|
+
lastReceivedByte: getLastByte(res.headers.get("range"))
|
143
|
+
};
|
144
|
+
}).catch((err) => {
|
145
|
+
console.error(err);
|
146
|
+
return {
|
147
|
+
complete: false,
|
148
|
+
lastReceivedByte: 0
|
149
|
+
};
|
150
|
+
});
|
151
|
+
}
|
152
|
+
function isErrorCode(err, code) {
|
153
|
+
return err instanceof Error && "code" in err && err.code === code;
|
154
|
+
}
|
155
|
+
|
156
|
+
export { uploadFiles };
|