@hypequery/cli 1.7.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 CHANGED
@@ -182,6 +182,55 @@ are still accepted for metadata-only validation.
182
182
  npx hypequery deployment:validate analytics/hypequery-deployment
183
183
  ```
184
184
 
185
+ ### `hypequery deployment:release`
186
+
187
+ Prepares a deterministic release request from a verified deployment bundle and
188
+ an explicit project/environment target. This command does not upload, authorize,
189
+ or execute the release.
190
+
191
+ ```bash
192
+ npx hypequery deployment:release analytics/hypequery-deployment \
193
+ --project my-project \
194
+ --environment production
195
+ ```
196
+
197
+ The default output is `analytics/hypequery-deployment.release.json`. It is
198
+ written beside the bundle because adding it inside the closed bundle would
199
+ invalidate bundle verification.
200
+
201
+ Options:
202
+
203
+ - `--project <project>`: required target project identifier
204
+ - `--environment <environment>`: required target environment identifier
205
+ - `--output <path>`: release JSON path, default beside the bundle
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
+
185
234
  ## Non-interactive Setup
186
235
 
187
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;AAYpC,QAAA,MAAM,OAAO,SAAgB,CAAC;AAa9B,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;;EAKpE;AA2JD,OAAO,EAAE,OAAO,EAAE,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
@@ -6,7 +6,8 @@ import { devCommand } from './commands/dev.js';
6
6
  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
- import { buildDeploymentCommand, validateDeploymentCommand, } from './commands/deployment.js';
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 {
@@ -129,6 +130,23 @@ program
129
130
  .action(runCommand(async (artifact) => {
130
131
  await validateDeploymentCommand(artifact);
131
132
  }));
133
+ program
134
+ .command('deployment:release <bundle>')
135
+ .description('Prepare a target-bound release from a verified deployment bundle')
136
+ .requiredOption('--project <project>', 'Target project identifier')
137
+ .requiredOption('--environment <environment>', 'Target environment identifier')
138
+ .option('-o, --output <path>', 'Release JSON path (default: beside the bundle)')
139
+ .action(runCommand(async (bundle, options) => {
140
+ await prepareDeploymentReleaseCommand(bundle, options);
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
+ }));
132
150
  // Help command
133
151
  program
134
152
  .command('help [command]')
@@ -161,6 +179,8 @@ program.on('--help', () => {
161
179
  console.log(' hypequery generate:manifest analytics/api.ts --output analytics/hypequery-manifest.json');
162
180
  console.log(' hypequery deployment:build analytics/api.ts');
163
181
  console.log(' hypequery deployment:validate analytics/hypequery-deployment');
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');
164
184
  console.log('');
165
185
  console.log('Docs: https://hypequery.com/docs');
166
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
+ }
@@ -1,4 +1,4 @@
1
- import { type ProtocolDeploymentContract } from '@hypequery/protocol';
1
+ import { type ProtocolDeploymentContract, type ProtocolDeploymentReleaseEnvelope } from '@hypequery/protocol';
2
2
  export interface BuildDeploymentOptions {
3
3
  bundleOutput?: string;
4
4
  output?: string;
@@ -9,6 +9,12 @@ export interface BuildDeploymentOptions {
9
9
  entrypointPrefix?: string;
10
10
  hashOutput?: string;
11
11
  }
12
+ export interface PrepareDeploymentReleaseOptions {
13
+ project?: string;
14
+ environment?: string;
15
+ output?: string;
16
+ }
12
17
  export declare function buildDeploymentCommand(apiPath: string | undefined, options?: BuildDeploymentOptions): Promise<ProtocolDeploymentContract>;
13
18
  export declare function validateDeploymentCommand(artifactPath: string | undefined): Promise<ProtocolDeploymentContract>;
19
+ export declare function prepareDeploymentReleaseCommand(bundlePath: string | undefined, options?: PrepareDeploymentReleaseOptions): Promise<ProtocolDeploymentReleaseEnvelope>;
14
20
  //# sourceMappingURL=deployment.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"deployment.d.ts","sourceRoot":"","sources":["../../src/commands/deployment.ts"],"names":[],"mappings":"AAGA,OAAO,EAEL,KAAK,0BAA0B,EAChC,MAAM,qBAAqB,CAAC;AAiB7B,MAAM,WAAW,sBAAsB;IACrC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,GAAG,QAAQ,CAAC;IAC5B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAoDD,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,MAAM,GAAG,SAAS,EAC3B,OAAO,GAAE,sBAA2B,GACnC,OAAO,CAAC,0BAA0B,CAAC,CAwIrC;AAED,wBAAsB,yBAAyB,CAC7C,YAAY,EAAE,MAAM,GAAG,SAAS,GAC/B,OAAO,CAAC,0BAA0B,CAAC,CA2ErC"}
1
+ {"version":3,"file":"deployment.d.ts","sourceRoot":"","sources":["../../src/commands/deployment.ts"],"names":[],"mappings":"AAGA,OAAO,EAGL,KAAK,0BAA0B,EAC/B,KAAK,iCAAiC,EACvC,MAAM,qBAAqB,CAAC;AAiB7B,MAAM,WAAW,sBAAsB;IACrC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,GAAG,QAAQ,CAAC;IAC5B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,+BAA+B;IAC9C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAoDD,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,MAAM,GAAG,SAAS,EAC3B,OAAO,GAAE,sBAA2B,GACnC,OAAO,CAAC,0BAA0B,CAAC,CAwIrC;AAED,wBAAsB,yBAAyB,CAC7C,YAAY,EAAE,MAAM,GAAG,SAAS,GAC/B,OAAO,CAAC,0BAA0B,CAAC,CA2ErC;AA0DD,wBAAsB,+BAA+B,CACnD,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,OAAO,GAAE,+BAAoC,GAC5C,OAAO,CAAC,iCAAiC,CAAC,CA8C5C"}
@@ -1,7 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
2
+ import { lstat, mkdir, readFile, realpath, stat, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
- import { prepareProtocolDeploymentContract, } from '@hypequery/protocol';
4
+ import { prepareProtocolDeploymentReleaseEnvelope, prepareProtocolDeploymentContract, } from '@hypequery/protocol';
5
5
  import { writeDeploymentBundle, verifyDeploymentBundle, readDeploymentRuntimeFile, } from '../utils/deployment-bundle.js';
6
6
  import { buildNodeRuntimeArtifact, getDeploymentRuntimeEntrypoints, } from '../utils/deployment-runtime-artifact.js';
7
7
  import { loadApiModule } from '../utils/load-api.js';
@@ -217,3 +217,100 @@ export async function validateDeploymentCommand(artifactPath) {
217
217
  logger.info(`Identity: ${digest}`);
218
218
  return contract;
219
219
  }
220
+ function assertOutputOutsideBundle(bundlePath, outputPath) {
221
+ const bundle = path.resolve(bundlePath);
222
+ const output = path.resolve(outputPath);
223
+ const relative = path.relative(bundle, output);
224
+ const outside = relative === '..'
225
+ || relative.startsWith(`..${path.sep}`)
226
+ || path.isAbsolute(relative);
227
+ if (relative === '' || !outside) {
228
+ throw new Error('--output must be outside the closed deployment bundle directory.');
229
+ }
230
+ }
231
+ async function prospectiveRealPath(inputPath) {
232
+ let current = path.resolve(inputPath);
233
+ const missingSegments = [];
234
+ for (;;) {
235
+ try {
236
+ return path.join(await realpath(current), ...missingSegments);
237
+ }
238
+ catch (error) {
239
+ if (!(typeof error === 'object' && error !== null && 'code' in error
240
+ && error.code === 'ENOENT')) {
241
+ throw error;
242
+ }
243
+ try {
244
+ const stat = await lstat(current);
245
+ if (stat.isSymbolicLink()) {
246
+ throw new Error('--output must not traverse a dangling symbolic link.');
247
+ }
248
+ }
249
+ catch (lstatError) {
250
+ if (!(typeof lstatError === 'object' && lstatError !== null && 'code' in lstatError
251
+ && lstatError.code === 'ENOENT')) {
252
+ throw lstatError;
253
+ }
254
+ }
255
+ const parent = path.dirname(current);
256
+ if (parent === current)
257
+ throw error;
258
+ missingSegments.unshift(path.basename(current));
259
+ current = parent;
260
+ }
261
+ }
262
+ }
263
+ async function assertOutputIsNotSymbolicLink(outputPath) {
264
+ try {
265
+ const outputStat = await lstat(outputPath);
266
+ if (outputStat.isSymbolicLink()) {
267
+ throw new Error('--output must not be a symbolic link.');
268
+ }
269
+ }
270
+ catch (error) {
271
+ if (!(typeof error === 'object' && error !== null && 'code' in error
272
+ && error.code === 'ENOENT')) {
273
+ throw error;
274
+ }
275
+ }
276
+ }
277
+ export async function prepareDeploymentReleaseCommand(bundlePath, options = {}) {
278
+ if (!bundlePath) {
279
+ throw new Error('Missing deployment bundle path.\n\n'
280
+ + 'Usage: hypequery deployment:release analytics/hypequery-deployment '
281
+ + '--project <project> --environment <environment>');
282
+ }
283
+ if (options.project === undefined) {
284
+ throw new Error('Missing required --project <project>.');
285
+ }
286
+ if (options.environment === undefined) {
287
+ throw new Error('Missing required --environment <environment>.');
288
+ }
289
+ const outputPath = options.output ?? `${bundlePath.replace(/[\\/]+$/, '')}.release.json`;
290
+ assertOutputOutsideBundle(bundlePath, outputPath);
291
+ let bundle;
292
+ try {
293
+ bundle = await verifyDeploymentBundle(bundlePath);
294
+ }
295
+ catch (error) {
296
+ throw new Error(`Cannot prepare a release from an invalid deployment bundle: ${bundlePath}\n\n`
297
+ + (error instanceof Error ? error.message : String(error)));
298
+ }
299
+ assertOutputOutsideBundle(bundle.directory, await prospectiveRealPath(outputPath));
300
+ const prepared = prepareProtocolDeploymentReleaseEnvelope({
301
+ kind: 'hypequery-deployment-release',
302
+ version: 1,
303
+ bundleIdentity: bundle.identity,
304
+ target: {
305
+ project: options.project,
306
+ environment: options.environment,
307
+ },
308
+ });
309
+ await mkdir(path.dirname(outputPath), { recursive: true });
310
+ await assertOutputIsNotSymbolicLink(outputPath);
311
+ await writeFile(outputPath, `${prepared.canonical}\n`, 'utf8');
312
+ logger.success(`Deployment release written to ${outputPath}`);
313
+ logger.info(`Release identity: ${prepared.identity}`);
314
+ logger.info(`Bundle identity: ${bundle.identity}`);
315
+ return prepared.release;
316
+ }
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hypequery/cli",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "Command-line interface for hypequery",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -19,7 +19,7 @@
19
19
  "open": "^10.0.0",
20
20
  "ora": "^8.0.1",
21
21
  "prompts": "^2.4.2",
22
- "@hypequery/protocol": "0.4.0"
22
+ "@hypequery/protocol": "0.5.0"
23
23
  },
24
24
  "peerDependencies": {
25
25
  "@hypequery/clickhouse": "*",
@@ -37,7 +37,7 @@
37
37
  "typescript": "^5.7.3",
38
38
  "vitest": "^3.2.6",
39
39
  "@hypequery/clickhouse": "2.4.0",
40
- "@hypequery/serve": "0.13.1"
40
+ "@hypequery/serve": "0.13.2"
41
41
  },
42
42
  "repository": {
43
43
  "type": "git",