@hypequery/cli 1.8.0 → 1.9.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/README.md +27 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +10 -0
- package/dist/commands/deploy.d.ts +11 -0
- package/dist/commands/deploy.d.ts.map +1 -0
- package/dist/commands/deploy.js +102 -0
- package/dist/utils/deployment-upload.d.ts +42 -0
- package/dist/utils/deployment-upload.d.ts.map +1 -0
- package/dist/utils/deployment-upload.js +359 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -204,6 +204,33 @@ Options:
|
|
|
204
204
|
- `--environment <environment>`: required target environment identifier
|
|
205
205
|
- `--output <path>`: release JSON path, default beside the bundle
|
|
206
206
|
|
|
207
|
+
### `hypequery deploy`
|
|
208
|
+
|
|
209
|
+
Submits a verified deployment bundle with an already-prepared target-bound
|
|
210
|
+
release. The command verifies both inputs again, requires their bundle
|
|
211
|
+
identities to match, and streams only the files declared by the bundle.
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
HYPEQUERY_API_TOKEN=<token> \
|
|
215
|
+
npx hypequery deploy analytics/hypequery-deployment \
|
|
216
|
+
--release analytics/hypequery-deployment.release.json \
|
|
217
|
+
--endpoint https://deploy.example.com/v1/releases
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
The token is accepted only through `HYPEQUERY_API_TOKEN`, keeping it out of
|
|
221
|
+
shell history. The submission endpoint must use HTTPS, may also be supplied by
|
|
222
|
+
`HYPEQUERY_DEPLOYMENT_ENDPOINT`, and must not contain credentials or a URL
|
|
223
|
+
fragment. The release identity is sent as the idempotency key, so an unchanged
|
|
224
|
+
release can be submitted safely again.
|
|
225
|
+
|
|
226
|
+
This command submits immutable deployment inputs. Activation, status changes,
|
|
227
|
+
promotion, and rollback remain control-plane operations.
|
|
228
|
+
|
|
229
|
+
Options:
|
|
230
|
+
|
|
231
|
+
- `--release <path>`: required target-bound release JSON
|
|
232
|
+
- `--endpoint <url>`: HTTPS submission endpoint; defaults to `HYPEQUERY_DEPLOYMENT_ENDPOINT`
|
|
233
|
+
|
|
207
234
|
## Non-interactive Setup
|
|
208
235
|
|
|
209
236
|
For ClickHouse, `hypequery init --no-interactive` reads:
|
package/dist/cli.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAkBpC,QAAA,MAAM,OAAO,SAAgB,CAAC;AAa9B,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;;EAKpE;AAgLD,OAAO,EAAE,OAAO,EAAE,CAAC"}
|
package/dist/cli.js
CHANGED
|
@@ -7,6 +7,7 @@ import { generateCommand } from './commands/generate.js';
|
|
|
7
7
|
import { generateDatasetsCommand } from './commands/generate-datasets.js';
|
|
8
8
|
import { generateManifestCommand } from './commands/generate-manifest.js';
|
|
9
9
|
import { buildDeploymentCommand, prepareDeploymentReleaseCommand, validateDeploymentCommand, } from './commands/deployment.js';
|
|
10
|
+
import { deployCommand, } from './commands/deploy.js';
|
|
10
11
|
const program = new Command();
|
|
11
12
|
function getCliVersion() {
|
|
12
13
|
try {
|
|
@@ -138,6 +139,14 @@ program
|
|
|
138
139
|
.action(runCommand(async (bundle, options) => {
|
|
139
140
|
await prepareDeploymentReleaseCommand(bundle, options);
|
|
140
141
|
}));
|
|
142
|
+
program
|
|
143
|
+
.command('deploy <bundle>')
|
|
144
|
+
.description('Submit a verified deployment bundle and release')
|
|
145
|
+
.requiredOption('--release <path>', 'Target-bound release JSON path')
|
|
146
|
+
.option('--endpoint <url>', 'HTTPS submission endpoint (or HYPEQUERY_DEPLOYMENT_ENDPOINT)')
|
|
147
|
+
.action(runCommand(async (bundle, options) => {
|
|
148
|
+
await deployCommand(bundle, options);
|
|
149
|
+
}));
|
|
141
150
|
// Help command
|
|
142
151
|
program
|
|
143
152
|
.command('help [command]')
|
|
@@ -171,6 +180,7 @@ program.on('--help', () => {
|
|
|
171
180
|
console.log(' hypequery deployment:build analytics/api.ts');
|
|
172
181
|
console.log(' hypequery deployment:validate analytics/hypequery-deployment');
|
|
173
182
|
console.log(' hypequery deployment:release analytics/hypequery-deployment --project my-project --environment production');
|
|
183
|
+
console.log(' hypequery deploy analytics/hypequery-deployment --release analytics/hypequery-deployment.release.json');
|
|
174
184
|
console.log('');
|
|
175
185
|
console.log('Docs: https://hypequery.com/docs');
|
|
176
186
|
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createHttpDeploymentUploadTransport, type DeploymentSubmissionResponse } from '../utils/deployment-upload.js';
|
|
2
|
+
export interface DeployOptions {
|
|
3
|
+
release?: string;
|
|
4
|
+
endpoint?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface DeployDependencies {
|
|
7
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
8
|
+
readonly createTransport?: typeof createHttpDeploymentUploadTransport;
|
|
9
|
+
}
|
|
10
|
+
export declare function deployCommand(bundlePath: string | undefined, options?: DeployOptions, dependencies?: DeployDependencies): Promise<DeploymentSubmissionResponse>;
|
|
11
|
+
//# sourceMappingURL=deploy.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deploy.d.ts","sourceRoot":"","sources":["../../src/commands/deploy.ts"],"names":[],"mappings":"AAKA,OAAO,EACL,mCAAmC,EAEnC,KAAK,4BAA4B,EAElC,MAAM,+BAA+B,CAAC;AAMvC,MAAM,WAAW,aAAa;IAC5B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,GAAG,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC;IAC5D,QAAQ,CAAC,eAAe,CAAC,EAAE,OAAO,mCAAmC,CAAC;CACvE;AA4DD,wBAAsB,aAAa,CACjC,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,OAAO,GAAE,aAAkB,EAC3B,YAAY,GAAE,kBAAuB,GACpC,OAAO,CAAC,4BAA4B,CAAC,CA0DvC"}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { constants, open } from 'node:fs/promises';
|
|
2
|
+
import { prepareProtocolDeploymentReleaseEnvelope, } from '@hypequery/protocol';
|
|
3
|
+
import { verifyDeploymentBundle } from '../utils/deployment-bundle.js';
|
|
4
|
+
import { createHttpDeploymentUploadTransport, DeploymentUploadError, } from '../utils/deployment-upload.js';
|
|
5
|
+
import { logger } from '../utils/logger.js';
|
|
6
|
+
const MAX_RELEASE_FILE_BYTES = 16 * 1024;
|
|
7
|
+
const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
|
|
8
|
+
async function readReleaseFile(releasePath) {
|
|
9
|
+
let handle;
|
|
10
|
+
let bytes;
|
|
11
|
+
let fileFailure;
|
|
12
|
+
try {
|
|
13
|
+
handle = await open(releasePath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
14
|
+
const stat = await handle.stat();
|
|
15
|
+
if (!stat.isFile())
|
|
16
|
+
throw new Error('Release JSON must be a regular file.');
|
|
17
|
+
if (stat.size > MAX_RELEASE_FILE_BYTES) {
|
|
18
|
+
throw new Error('Release JSON exceeds 16384 bytes.');
|
|
19
|
+
}
|
|
20
|
+
const buffer = new Uint8Array(MAX_RELEASE_FILE_BYTES + 1);
|
|
21
|
+
let total = 0;
|
|
22
|
+
while (total < buffer.byteLength) {
|
|
23
|
+
const { bytesRead } = await handle.read(buffer, total, buffer.byteLength - total, total);
|
|
24
|
+
if (bytesRead === 0)
|
|
25
|
+
break;
|
|
26
|
+
total += bytesRead;
|
|
27
|
+
}
|
|
28
|
+
if (total > MAX_RELEASE_FILE_BYTES) {
|
|
29
|
+
throw new Error('Release JSON exceeds 16384 bytes.');
|
|
30
|
+
}
|
|
31
|
+
bytes = buffer.subarray(0, total);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
fileFailure = { error };
|
|
35
|
+
}
|
|
36
|
+
if (handle) {
|
|
37
|
+
try {
|
|
38
|
+
await handle.close();
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
fileFailure ??= { error };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (fileFailure) {
|
|
45
|
+
throw new Error(`Cannot read deployment release file: ${releasePath}\n\n`
|
|
46
|
+
+ (fileFailure.error instanceof Error
|
|
47
|
+
? fileFailure.error.message
|
|
48
|
+
: String(fileFailure.error)));
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(utf8Decoder.decode(bytes));
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
throw new Error(`Invalid deployment release JSON: ${releasePath}\n\n`
|
|
55
|
+
+ (error instanceof Error ? error.message : String(error)));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function requiredConfiguration(value, message) {
|
|
59
|
+
if (value === undefined || value.length === 0)
|
|
60
|
+
throw new Error(message);
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
export async function deployCommand(bundlePath, options = {}, dependencies = {}) {
|
|
64
|
+
if (!bundlePath) {
|
|
65
|
+
throw new Error('Missing deployment bundle path.\n\n'
|
|
66
|
+
+ 'Usage: hypequery deploy analytics/hypequery-deployment '
|
|
67
|
+
+ '--release analytics/hypequery-deployment.release.json');
|
|
68
|
+
}
|
|
69
|
+
const releasePath = requiredConfiguration(options.release, 'Missing required --release <path>.');
|
|
70
|
+
const env = dependencies.env ?? process.env;
|
|
71
|
+
const endpoint = requiredConfiguration(options.endpoint ?? env.HYPEQUERY_DEPLOYMENT_ENDPOINT, 'Missing deployment endpoint. Pass --endpoint or set HYPEQUERY_DEPLOYMENT_ENDPOINT.');
|
|
72
|
+
const token = requiredConfiguration(env.HYPEQUERY_API_TOKEN, 'Missing HYPEQUERY_API_TOKEN.');
|
|
73
|
+
let bundle;
|
|
74
|
+
try {
|
|
75
|
+
bundle = await verifyDeploymentBundle(bundlePath);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
throw new Error(`Cannot push an invalid deployment bundle: ${bundlePath}\n\n`
|
|
79
|
+
+ (error instanceof Error ? error.message : String(error)));
|
|
80
|
+
}
|
|
81
|
+
const releaseInput = await readReleaseFile(releasePath);
|
|
82
|
+
let release;
|
|
83
|
+
try {
|
|
84
|
+
release = prepareProtocolDeploymentReleaseEnvelope(releaseInput);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
throw new Error(`Invalid deployment release: ${releasePath}\n\n`
|
|
88
|
+
+ (error instanceof Error ? error.message : String(error)));
|
|
89
|
+
}
|
|
90
|
+
if (release.release.bundleIdentity !== bundle.identity) {
|
|
91
|
+
throw new DeploymentUploadError('HQ_UPLOAD_IDENTITY_MISMATCH', 'Release bundle identity does not match the verified deployment bundle.');
|
|
92
|
+
}
|
|
93
|
+
const createTransport = dependencies.createTransport ?? createHttpDeploymentUploadTransport;
|
|
94
|
+
const transportOptions = { endpoint, token };
|
|
95
|
+
const result = await createTransport(transportOptions).submit(bundle, release);
|
|
96
|
+
logger.success(result.status === 'accepted'
|
|
97
|
+
? 'Deployment release accepted'
|
|
98
|
+
: 'Deployment release already exists');
|
|
99
|
+
logger.info(`Release identity: ${result.releaseIdentity}`);
|
|
100
|
+
logger.info(`Bundle identity: ${result.bundleIdentity}`);
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ReadableStream } from 'node:stream/web';
|
|
2
|
+
import { type PreparedProtocolDeploymentReleaseEnvelope } from '@hypequery/protocol';
|
|
3
|
+
import { type VerifiedDeploymentBundle } from './deployment-bundle.js';
|
|
4
|
+
export type DeploymentUploadErrorCode = 'HQ_UPLOAD_CONFIGURATION' | 'HQ_UPLOAD_IDENTITY_MISMATCH' | 'HQ_UPLOAD_BUNDLE_CHANGED' | 'HQ_UPLOAD_NETWORK' | 'HQ_UPLOAD_REJECTED' | 'HQ_UPLOAD_INVALID_RESPONSE';
|
|
5
|
+
export declare class DeploymentUploadError extends Error {
|
|
6
|
+
readonly code: DeploymentUploadErrorCode;
|
|
7
|
+
readonly status?: number;
|
|
8
|
+
constructor(code: DeploymentUploadErrorCode, message: string, status?: number);
|
|
9
|
+
}
|
|
10
|
+
export interface DeploymentSubmissionResponse {
|
|
11
|
+
readonly kind: 'hypequery-deployment-submission';
|
|
12
|
+
readonly version: 1;
|
|
13
|
+
readonly status: 'accepted' | 'already-exists';
|
|
14
|
+
readonly releaseIdentity: string;
|
|
15
|
+
readonly bundleIdentity: string;
|
|
16
|
+
}
|
|
17
|
+
export interface DeploymentHttpResponse {
|
|
18
|
+
readonly ok: boolean;
|
|
19
|
+
readonly status: number;
|
|
20
|
+
readonly statusText: string;
|
|
21
|
+
readonly body: ReadableStream<Uint8Array> | null;
|
|
22
|
+
}
|
|
23
|
+
export interface DeploymentFetchInit {
|
|
24
|
+
readonly method: 'POST';
|
|
25
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
26
|
+
readonly body: AsyncIterable<Uint8Array>;
|
|
27
|
+
readonly duplex: 'half';
|
|
28
|
+
readonly redirect: 'error';
|
|
29
|
+
readonly signal: AbortSignal;
|
|
30
|
+
}
|
|
31
|
+
export type DeploymentFetch = (input: string, init: DeploymentFetchInit) => Promise<DeploymentHttpResponse>;
|
|
32
|
+
export interface HttpDeploymentUploadTransportOptions {
|
|
33
|
+
readonly endpoint: string;
|
|
34
|
+
readonly token: string;
|
|
35
|
+
readonly timeoutMs?: number;
|
|
36
|
+
readonly fetch?: DeploymentFetch;
|
|
37
|
+
}
|
|
38
|
+
export interface DeploymentUploadTransport {
|
|
39
|
+
submit(bundle: VerifiedDeploymentBundle, release: PreparedProtocolDeploymentReleaseEnvelope): Promise<DeploymentSubmissionResponse>;
|
|
40
|
+
}
|
|
41
|
+
export declare function createHttpDeploymentUploadTransport(options: HttpDeploymentUploadTransportOptions): DeploymentUploadTransport;
|
|
42
|
+
//# sourceMappingURL=deployment-upload.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deployment-upload.d.ts","sourceRoot":"","sources":["../../src/utils/deployment-upload.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACtD,OAAO,EAIL,KAAK,yCAAyC,EAC/C,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAEL,KAAK,wBAAwB,EAC9B,MAAM,wBAAwB,CAAC;AAQhC,MAAM,MAAM,yBAAyB,GACjC,yBAAyB,GACzB,6BAA6B,GAC7B,0BAA0B,GAC1B,mBAAmB,GACnB,oBAAoB,GACpB,4BAA4B,CAAC;AAEjC,qBAAa,qBAAsB,SAAQ,KAAK;IAC9C,QAAQ,CAAC,IAAI,EAAE,yBAAyB,CAAC;IACzC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;gBAEb,IAAI,EAAE,yBAAyB,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM;CAM9E;AAED,MAAM,WAAW,4BAA4B;IAC3C,QAAQ,CAAC,IAAI,EAAE,iCAAiC,CAAC;IACjD,QAAQ,CAAC,OAAO,EAAE,CAAC,CAAC;IACpB,QAAQ,CAAC,MAAM,EAAE,UAAU,GAAG,gBAAgB,CAAC;IAC/C,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;CACjC;AAED,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAC;IACrB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAC,UAAU,CAAC,GAAG,IAAI,CAAC;CAClD;AAED,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACnD,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC,UAAU,CAAC,CAAC;IACzC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC;CAC9B;AAED,MAAM,MAAM,eAAe,GAAG,CAC5B,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,mBAAmB,KACtB,OAAO,CAAC,sBAAsB,CAAC,CAAC;AAErC,MAAM,WAAW,oCAAoC;IACnD,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,KAAK,CAAC,EAAE,eAAe,CAAC;CAClC;AAED,MAAM,WAAW,yBAAyB;IACxC,MAAM,CACJ,MAAM,EAAE,wBAAwB,EAChC,OAAO,EAAE,yCAAyC,GACjD,OAAO,CAAC,4BAA4B,CAAC,CAAC;CAC1C;AAmVD,wBAAgB,mCAAmC,CACjD,OAAO,EAAE,oCAAoC,GAC5C,yBAAyB,CAsF3B"}
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants, open } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { prepareProtocolDeploymentBundleManifest, prepareProtocolDeploymentReleaseEnvelope, } from '@hypequery/protocol';
|
|
5
|
+
import { DEPLOYMENT_BUNDLE_MANIFEST, } from './deployment-bundle.js';
|
|
6
|
+
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
|
|
7
|
+
const MAX_RESPONSE_BYTES = 64 * 1024;
|
|
8
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
9
|
+
const textEncoder = new TextEncoder();
|
|
10
|
+
const textDecoder = new TextDecoder('utf-8', { fatal: true });
|
|
11
|
+
export class DeploymentUploadError extends Error {
|
|
12
|
+
code;
|
|
13
|
+
status;
|
|
14
|
+
constructor(code, message, status) {
|
|
15
|
+
super(`${code}: ${message}`);
|
|
16
|
+
this.name = 'DeploymentUploadError';
|
|
17
|
+
this.code = code;
|
|
18
|
+
if (status !== undefined)
|
|
19
|
+
this.status = status;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function configurationError(message) {
|
|
23
|
+
throw new DeploymentUploadError('HQ_UPLOAD_CONFIGURATION', message);
|
|
24
|
+
}
|
|
25
|
+
function endpointUrl(input) {
|
|
26
|
+
let url;
|
|
27
|
+
try {
|
|
28
|
+
url = new URL(input);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
configurationError('Deployment endpoint must be an absolute HTTPS URL.');
|
|
32
|
+
}
|
|
33
|
+
if (url.protocol !== 'https:') {
|
|
34
|
+
configurationError('Deployment endpoint must use HTTPS.');
|
|
35
|
+
}
|
|
36
|
+
if (url.username || url.password) {
|
|
37
|
+
configurationError('Deployment endpoint must not contain credentials.');
|
|
38
|
+
}
|
|
39
|
+
if (url.hash)
|
|
40
|
+
configurationError('Deployment endpoint must not contain a URL fragment.');
|
|
41
|
+
return url.toString();
|
|
42
|
+
}
|
|
43
|
+
function bearerToken(input) {
|
|
44
|
+
if (input.length < 1 || input.length > 4096 || input.trim() !== input
|
|
45
|
+
|| [...input].some(character => {
|
|
46
|
+
const code = character.charCodeAt(0);
|
|
47
|
+
return code < 0x21 || code > 0x7e;
|
|
48
|
+
})) {
|
|
49
|
+
configurationError('HYPEQUERY_API_TOKEN contains invalid characters or length.');
|
|
50
|
+
}
|
|
51
|
+
return input;
|
|
52
|
+
}
|
|
53
|
+
function timeout(input) {
|
|
54
|
+
const value = input ?? DEFAULT_TIMEOUT_MS;
|
|
55
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > 10 * 60_000) {
|
|
56
|
+
configurationError('Deployment upload timeout must be between 1 and 600000 milliseconds.');
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
function multipartBundlePath(input) {
|
|
61
|
+
if (!/^[\x21-\x7e]+$/.test(input) || input.includes('"') || input.includes('\\')) {
|
|
62
|
+
throw new DeploymentUploadError('HQ_UPLOAD_BUNDLE_CHANGED', 'A bundle path cannot be safely encoded as a multipart header.');
|
|
63
|
+
}
|
|
64
|
+
return input;
|
|
65
|
+
}
|
|
66
|
+
function validateMultipartBundlePaths(bundle) {
|
|
67
|
+
multipartBundlePath(bundle.manifest.deployment.path);
|
|
68
|
+
for (const artifact of bundle.manifest.artifacts)
|
|
69
|
+
multipartBundlePath(artifact.path);
|
|
70
|
+
}
|
|
71
|
+
function partHeader(boundary, part) {
|
|
72
|
+
const headers = [
|
|
73
|
+
`--${boundary}`,
|
|
74
|
+
`Content-Disposition: form-data; name="${part.name}"; filename="${part.filename}"`,
|
|
75
|
+
`Content-Type: ${part.contentType}`,
|
|
76
|
+
...(part.bundlePath
|
|
77
|
+
? [`X-HypeQuery-Bundle-Path: ${multipartBundlePath(part.bundlePath)}`]
|
|
78
|
+
: []),
|
|
79
|
+
'',
|
|
80
|
+
'',
|
|
81
|
+
].join('\r\n');
|
|
82
|
+
return textEncoder.encode(headers);
|
|
83
|
+
}
|
|
84
|
+
function uploadParts(bundle, release, manifest) {
|
|
85
|
+
const root = bundle.directory;
|
|
86
|
+
return Object.freeze([
|
|
87
|
+
{
|
|
88
|
+
kind: 'bytes',
|
|
89
|
+
name: 'release',
|
|
90
|
+
filename: 'release.json',
|
|
91
|
+
contentType: 'application/json',
|
|
92
|
+
bytes: release.bytes,
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
kind: 'bytes',
|
|
96
|
+
name: 'bundle',
|
|
97
|
+
filename: DEPLOYMENT_BUNDLE_MANIFEST,
|
|
98
|
+
contentType: 'application/json',
|
|
99
|
+
bundlePath: DEPLOYMENT_BUNDLE_MANIFEST,
|
|
100
|
+
bytes: textEncoder.encode(`${manifest.canonical}\n`),
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
kind: 'file',
|
|
104
|
+
name: 'bundle',
|
|
105
|
+
filename: path.basename(manifest.manifest.deployment.path),
|
|
106
|
+
contentType: 'application/json',
|
|
107
|
+
absolutePath: path.join(root, ...manifest.manifest.deployment.path.split('/')),
|
|
108
|
+
bundlePath: manifest.manifest.deployment.path,
|
|
109
|
+
byteLength: manifest.manifest.deployment.byteLength,
|
|
110
|
+
sha256: manifest.manifest.deployment.sha256,
|
|
111
|
+
},
|
|
112
|
+
...manifest.manifest.artifacts.map(artifact => ({
|
|
113
|
+
kind: 'file',
|
|
114
|
+
name: 'bundle',
|
|
115
|
+
filename: path.basename(artifact.path),
|
|
116
|
+
contentType: 'application/octet-stream',
|
|
117
|
+
absolutePath: path.join(root, ...artifact.path.split('/')),
|
|
118
|
+
bundlePath: artifact.path,
|
|
119
|
+
byteLength: artifact.byteLength,
|
|
120
|
+
sha256: artifact.sha256,
|
|
121
|
+
})),
|
|
122
|
+
]);
|
|
123
|
+
}
|
|
124
|
+
async function* streamVerifiedFile(part) {
|
|
125
|
+
let handle;
|
|
126
|
+
try {
|
|
127
|
+
handle = await open(part.absolutePath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
128
|
+
const initial = await handle.stat();
|
|
129
|
+
if (!initial.isFile() || initial.size !== part.byteLength) {
|
|
130
|
+
throw new DeploymentUploadError('HQ_UPLOAD_BUNDLE_CHANGED', `Bundle entry changed after verification: ${part.bundlePath}`);
|
|
131
|
+
}
|
|
132
|
+
const digest = createHash('sha256');
|
|
133
|
+
let bytesRead = 0;
|
|
134
|
+
for await (const value of handle.createReadStream({ autoClose: false })) {
|
|
135
|
+
const chunk = value;
|
|
136
|
+
bytesRead += chunk.byteLength;
|
|
137
|
+
if (bytesRead > part.byteLength) {
|
|
138
|
+
throw new DeploymentUploadError('HQ_UPLOAD_BUNDLE_CHANGED', `Bundle entry changed after verification: ${part.bundlePath}`);
|
|
139
|
+
}
|
|
140
|
+
digest.update(chunk);
|
|
141
|
+
yield chunk;
|
|
142
|
+
}
|
|
143
|
+
if (bytesRead !== part.byteLength || digest.digest('hex') !== part.sha256) {
|
|
144
|
+
throw new DeploymentUploadError('HQ_UPLOAD_BUNDLE_CHANGED', `Bundle entry changed after verification: ${part.bundlePath}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
if (error instanceof DeploymentUploadError)
|
|
149
|
+
throw error;
|
|
150
|
+
throw new DeploymentUploadError('HQ_UPLOAD_BUNDLE_CHANGED', `Cannot safely read verified bundle entry ${part.bundlePath}: `
|
|
151
|
+
+ (error instanceof Error ? error.message : String(error)));
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
await handle?.close();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function multipartLength(boundary, parts) {
|
|
158
|
+
const separatorBytes = 2;
|
|
159
|
+
const ending = textEncoder.encode(`--${boundary}--\r\n`).byteLength;
|
|
160
|
+
return parts.reduce((total, part) => (total + partHeader(boundary, part).byteLength
|
|
161
|
+
+ (part.kind === 'bytes' ? part.bytes.byteLength : part.byteLength)
|
|
162
|
+
+ separatorBytes), ending);
|
|
163
|
+
}
|
|
164
|
+
async function* multipartBody(boundary, parts) {
|
|
165
|
+
const separator = textEncoder.encode('\r\n');
|
|
166
|
+
for (const part of parts) {
|
|
167
|
+
yield partHeader(boundary, part);
|
|
168
|
+
if (part.kind === 'bytes') {
|
|
169
|
+
yield part.bytes;
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
yield* streamVerifiedFile(part);
|
|
173
|
+
}
|
|
174
|
+
yield separator;
|
|
175
|
+
}
|
|
176
|
+
yield textEncoder.encode(`--${boundary}--\r\n`);
|
|
177
|
+
}
|
|
178
|
+
async function readBoundedResponse(response) {
|
|
179
|
+
if (!response.body)
|
|
180
|
+
return '';
|
|
181
|
+
const reader = response.body.getReader();
|
|
182
|
+
const chunks = [];
|
|
183
|
+
let total = 0;
|
|
184
|
+
try {
|
|
185
|
+
for (;;) {
|
|
186
|
+
const { done, value } = await reader.read();
|
|
187
|
+
if (done)
|
|
188
|
+
break;
|
|
189
|
+
total += value.byteLength;
|
|
190
|
+
if (total > MAX_RESPONSE_BYTES) {
|
|
191
|
+
await reader.cancel().catch(() => undefined);
|
|
192
|
+
throw new DeploymentUploadError('HQ_UPLOAD_INVALID_RESPONSE', 'Deployment service response exceeds 65536 bytes.', response.status);
|
|
193
|
+
}
|
|
194
|
+
chunks.push(value);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
reader.releaseLock();
|
|
199
|
+
}
|
|
200
|
+
const bytes = new Uint8Array(total);
|
|
201
|
+
let offset = 0;
|
|
202
|
+
for (const chunk of chunks) {
|
|
203
|
+
bytes.set(chunk, offset);
|
|
204
|
+
offset += chunk.byteLength;
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
return textDecoder.decode(bytes);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
throw new DeploymentUploadError('HQ_UPLOAD_INVALID_RESPONSE', 'Deployment service response is not valid UTF-8.', response.status);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function exactRecord(value, fields) {
|
|
214
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)
|
|
215
|
+
|| Object.getPrototypeOf(value) !== Object.prototype)
|
|
216
|
+
return undefined;
|
|
217
|
+
const record = value;
|
|
218
|
+
const keys = Object.keys(record).sort();
|
|
219
|
+
const expected = [...fields].sort();
|
|
220
|
+
if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) {
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
return record;
|
|
224
|
+
}
|
|
225
|
+
function validateSubmissionResponse(input, bundleIdentity, releaseIdentity, status) {
|
|
226
|
+
const value = exactRecord(input, [
|
|
227
|
+
'kind',
|
|
228
|
+
'version',
|
|
229
|
+
'status',
|
|
230
|
+
'releaseIdentity',
|
|
231
|
+
'bundleIdentity',
|
|
232
|
+
]);
|
|
233
|
+
if (!value
|
|
234
|
+
|| value.kind !== 'hypequery-deployment-submission'
|
|
235
|
+
|| value.version !== 1
|
|
236
|
+
|| (value.status !== 'accepted' && value.status !== 'already-exists')
|
|
237
|
+
|| typeof value.releaseIdentity !== 'string'
|
|
238
|
+
|| typeof value.bundleIdentity !== 'string'
|
|
239
|
+
|| !SHA256_PATTERN.test(value.releaseIdentity)
|
|
240
|
+
|| !SHA256_PATTERN.test(value.bundleIdentity)
|
|
241
|
+
|| value.releaseIdentity !== releaseIdentity
|
|
242
|
+
|| value.bundleIdentity !== bundleIdentity) {
|
|
243
|
+
throw new DeploymentUploadError('HQ_UPLOAD_INVALID_RESPONSE', 'Deployment service returned an invalid or mismatched submission response.', status);
|
|
244
|
+
}
|
|
245
|
+
return Object.freeze({
|
|
246
|
+
kind: value.kind,
|
|
247
|
+
version: value.version,
|
|
248
|
+
status: value.status,
|
|
249
|
+
releaseIdentity: value.releaseIdentity,
|
|
250
|
+
bundleIdentity: value.bundleIdentity,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
function rejectedMessage(response, body) {
|
|
254
|
+
let detail = '';
|
|
255
|
+
try {
|
|
256
|
+
const parsed = JSON.parse(body);
|
|
257
|
+
const error = typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
|
|
258
|
+
? parsed.error
|
|
259
|
+
: undefined;
|
|
260
|
+
if (typeof error === 'object' && error !== null && !Array.isArray(error)) {
|
|
261
|
+
const code = error.code;
|
|
262
|
+
const message = error.message;
|
|
263
|
+
if (typeof code === 'string' && /^[A-Z0-9_:-]{1,64}$/.test(code))
|
|
264
|
+
detail += ` ${code}`;
|
|
265
|
+
if (typeof message === 'string') {
|
|
266
|
+
const safe = [...message].map(character => {
|
|
267
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
268
|
+
return codePoint <= 0x1f || codePoint === 0x7f ? ' ' : character;
|
|
269
|
+
}).join('').slice(0, 512).trim();
|
|
270
|
+
if (safe)
|
|
271
|
+
detail += `: ${safe}`;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
// Rejection bodies are optional; status remains authoritative.
|
|
277
|
+
}
|
|
278
|
+
return `Deployment service rejected the submission (${response.status} ${response.statusText})${detail}.`;
|
|
279
|
+
}
|
|
280
|
+
function nestedUploadError(input) {
|
|
281
|
+
const seen = new Set();
|
|
282
|
+
let current = input;
|
|
283
|
+
while (typeof current === 'object' && current !== null && !seen.has(current)) {
|
|
284
|
+
if (current instanceof DeploymentUploadError)
|
|
285
|
+
return current;
|
|
286
|
+
seen.add(current);
|
|
287
|
+
if (!('cause' in current))
|
|
288
|
+
return undefined;
|
|
289
|
+
current = current.cause;
|
|
290
|
+
}
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
const defaultFetch = async (input, init) => fetch(input, init);
|
|
294
|
+
export function createHttpDeploymentUploadTransport(options) {
|
|
295
|
+
const endpoint = endpointUrl(options.endpoint);
|
|
296
|
+
const token = bearerToken(options.token);
|
|
297
|
+
const timeoutMs = timeout(options.timeoutMs);
|
|
298
|
+
const fetchImplementation = options.fetch ?? defaultFetch;
|
|
299
|
+
const transport = {
|
|
300
|
+
async submit(bundle, release) {
|
|
301
|
+
validateMultipartBundlePaths(bundle);
|
|
302
|
+
const manifest = prepareProtocolDeploymentBundleManifest(bundle.manifest);
|
|
303
|
+
const preparedRelease = prepareProtocolDeploymentReleaseEnvelope(release.release);
|
|
304
|
+
if (manifest.identity !== bundle.identity
|
|
305
|
+
|| preparedRelease.identity !== release.identity
|
|
306
|
+
|| preparedRelease.release.bundleIdentity !== manifest.identity) {
|
|
307
|
+
throw new DeploymentUploadError('HQ_UPLOAD_IDENTITY_MISMATCH', 'Release and bundle identities do not match their verified content.');
|
|
308
|
+
}
|
|
309
|
+
const boundary = `hypequery-${randomUUID()}`;
|
|
310
|
+
const parts = uploadParts(bundle, preparedRelease, manifest);
|
|
311
|
+
let response;
|
|
312
|
+
try {
|
|
313
|
+
response = await fetchImplementation(endpoint, {
|
|
314
|
+
method: 'POST',
|
|
315
|
+
headers: {
|
|
316
|
+
Accept: 'application/json',
|
|
317
|
+
Authorization: `Bearer ${token}`,
|
|
318
|
+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
319
|
+
'Content-Length': String(multipartLength(boundary, parts)),
|
|
320
|
+
'Idempotency-Key': preparedRelease.identity,
|
|
321
|
+
'X-HypeQuery-Bundle-Identity': manifest.identity,
|
|
322
|
+
'X-HypeQuery-Release-Identity': preparedRelease.identity,
|
|
323
|
+
},
|
|
324
|
+
body: multipartBody(boundary, parts),
|
|
325
|
+
duplex: 'half',
|
|
326
|
+
redirect: 'error',
|
|
327
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
const uploadError = nestedUploadError(error);
|
|
332
|
+
if (uploadError)
|
|
333
|
+
throw uploadError;
|
|
334
|
+
throw new DeploymentUploadError('HQ_UPLOAD_NETWORK', `Deployment service request failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
335
|
+
}
|
|
336
|
+
let body;
|
|
337
|
+
try {
|
|
338
|
+
body = await readBoundedResponse(response);
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
if (error instanceof DeploymentUploadError)
|
|
342
|
+
throw error;
|
|
343
|
+
throw new DeploymentUploadError('HQ_UPLOAD_NETWORK', `Deployment service response failed: ${error instanceof Error ? error.message : String(error)}`, response.status);
|
|
344
|
+
}
|
|
345
|
+
if (!response.ok) {
|
|
346
|
+
throw new DeploymentUploadError('HQ_UPLOAD_REJECTED', rejectedMessage(response, body), response.status);
|
|
347
|
+
}
|
|
348
|
+
let input;
|
|
349
|
+
try {
|
|
350
|
+
input = JSON.parse(body);
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
throw new DeploymentUploadError('HQ_UPLOAD_INVALID_RESPONSE', 'Deployment service returned invalid JSON.', response.status);
|
|
354
|
+
}
|
|
355
|
+
return validateSubmissionResponse(input, manifest.identity, preparedRelease.identity, response.status);
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
return Object.freeze(transport);
|
|
359
|
+
}
|