@hypequery/cli 1.6.0 → 1.7.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
@@ -138,36 +138,48 @@ semantic keys such as `dataset:orders`.
138
138
 
139
139
  ### `hypequery deployment:build`
140
140
 
141
- Builds deployment metadata for an exported HypeQuery API and writes the
142
- artifact with its identity sidecar.
141
+ Builds a closed deployment bundle for an exported HypeQuery API. The bundle
142
+ contains canonical deployment metadata, every referenced runtime artifact, and
143
+ a manifest that binds their exact bytes and identities.
143
144
 
144
145
  ```bash
145
146
  npx hypequery deployment:build analytics/api.ts
146
147
  ```
147
148
 
148
- Named Serve handlers are bundled into a Node runtime artifact automatically.
149
- Dataset-only APIs do not produce a runtime artifact. Use `--runtime-artifact`
150
- to reference a separately built Node or Python artifact instead.
149
+ The default output is `analytics/hypequery-deployment/`. Named Serve handlers
150
+ are bundled into a Node runtime artifact automatically. Dataset-only APIs do
151
+ not produce a runtime artifact. For a separately built Node or Python runtime,
152
+ provide both its expected digest and file path:
153
+
154
+ ```bash
155
+ npx hypequery deployment:build analytics/api.ts \
156
+ --runtime python \
157
+ --runtime-artifact <sha256> \
158
+ --runtime-file dist/runtime.pyz
159
+ ```
151
160
 
152
161
  Options:
153
162
 
154
- - `--output <path>`: default `analytics/hypequery-deployment.json`
163
+ - `--bundle-output <directory>`: default `analytics/hypequery-deployment`
155
164
  - `--runtime <runtime>`: `node` (default) or `python`
156
165
  - `--runtime-artifact <sha256>`: lowercase SHA-256 of a prebuilt runtime artifact
157
- - `--runtime-output <path>`: default beside the deployment JSON as `hypequery-runtime.mjs`
166
+ - `--runtime-file <path>`: bytes for the prebuilt runtime artifact
158
167
  - `--entrypoint-prefix <prefix>`: default `queries`
159
- - `--hash-output <path>`: default `<output>.sha256`
160
168
 
161
- The identity sidecar is not compatible with `sha256sum -c`. Use
162
- `deployment:validate` to validate the artifact and report its identity.
169
+ The compatibility options `--output`, `--runtime-output`, and `--hash-output`
170
+ still emit the earlier metadata files instead of a complete bundle. They cannot
171
+ be combined with `--bundle-output`.
163
172
 
164
173
  ### `hypequery deployment:validate`
165
174
 
166
- Validates an existing deployment artifact and reports its datasets, queries,
167
- runtime artifacts, and identity.
175
+ Verifies a complete deployment bundle before returning any contained metadata.
176
+ Verification rejects missing or undeclared files, symbolic links, path
177
+ traversal, byte-length or hash mismatches, deployment identity mismatches, and
178
+ runtime files not referenced by the deployment. Legacy deployment JSON files
179
+ are still accepted for metadata-only validation.
168
180
 
169
181
  ```bash
170
- npx hypequery deployment:validate analytics/hypequery-deployment.json
182
+ npx hypequery deployment:validate analytics/hypequery-deployment
171
183
  ```
172
184
 
173
185
  ## Non-interactive Setup
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;AAyJD,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;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"}
package/dist/cli.js CHANGED
@@ -111,10 +111,12 @@ program
111
111
  }));
112
112
  program
113
113
  .command('deployment:build <api>')
114
- .description('Build a validated, canonical deployment contract and identity')
114
+ .description('Build a verified deployment bundle')
115
+ .option('--bundle-output <directory>', 'Bundle directory (default: analytics/hypequery-deployment)')
115
116
  .option('-o, --output <path>', 'Output JSON file (default: analytics/hypequery-deployment.json)')
116
117
  .option('--runtime <runtime>', 'Runtime for non-portable handlers: node or python (default: node)')
117
118
  .option('--runtime-artifact <sha256>', 'Use a prebuilt runtime artifact with this SHA-256 identity')
119
+ .option('--runtime-file <path>', 'Include the bytes for a prebuilt runtime artifact')
118
120
  .option('--runtime-output <path>', 'Bundled Node runtime path (default: beside deployment JSON)')
119
121
  .option('--entrypoint-prefix <prefix>', 'Runtime entrypoint prefix (default: queries)')
120
122
  .option('--hash-output <path>', 'Deployment identity sidecar path (default: <output>.sha256)')
@@ -123,7 +125,7 @@ program
123
125
  }));
124
126
  program
125
127
  .command('deployment:validate <artifact>')
126
- .description('Validate a deployment contract and report its identity')
128
+ .description('Verify a deployment bundle or validate a legacy deployment JSON file')
127
129
  .action(runCommand(async (artifact) => {
128
130
  await validateDeploymentCommand(artifact);
129
131
  }));
@@ -157,8 +159,8 @@ program.on('--help', () => {
157
159
  console.log(' hypequery generate:types --output analytics/schema.ts');
158
160
  console.log(' hypequery generate:datasets');
159
161
  console.log(' hypequery generate:manifest analytics/api.ts --output analytics/hypequery-manifest.json');
160
- console.log(' hypequery deployment:build analytics/api.ts --runtime-artifact <sha256>');
161
- console.log(' hypequery deployment:validate analytics/hypequery-deployment.json');
162
+ console.log(' hypequery deployment:build analytics/api.ts');
163
+ console.log(' hypequery deployment:validate analytics/hypequery-deployment');
162
164
  console.log('');
163
165
  console.log('Docs: https://hypequery.com/docs');
164
166
  });
@@ -1,8 +1,10 @@
1
1
  import { type ProtocolDeploymentContract } from '@hypequery/protocol';
2
2
  export interface BuildDeploymentOptions {
3
+ bundleOutput?: string;
3
4
  output?: string;
4
5
  runtime?: 'node' | 'python';
5
6
  runtimeArtifact?: string;
7
+ runtimeFile?: string;
6
8
  runtimeOutput?: string;
7
9
  entrypointPrefix?: string;
8
10
  hashOutput?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"deployment.d.ts","sourceRoot":"","sources":["../../src/commands/deployment.ts"],"names":[],"mappings":"AAEA,OAAO,EAEL,KAAK,0BAA0B,EAChC,MAAM,qBAAqB,CAAC;AAW7B,MAAM,WAAW,sBAAsB;IACrC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,GAAG,QAAQ,CAAC;IAC5B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAkDD,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,MAAM,GAAG,SAAS,EAC3B,OAAO,GAAE,sBAA2B,GACnC,OAAO,CAAC,0BAA0B,CAAC,CA0FrC;AAED,wBAAsB,yBAAyB,CAC7C,YAAY,EAAE,MAAM,GAAG,SAAS,GAC/B,OAAO,CAAC,0BAA0B,CAAC,CAoCrC"}
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,10 +1,13 @@
1
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
2
3
  import path from 'node:path';
3
4
  import { prepareProtocolDeploymentContract, } from '@hypequery/protocol';
5
+ import { writeDeploymentBundle, verifyDeploymentBundle, readDeploymentRuntimeFile, } from '../utils/deployment-bundle.js';
4
6
  import { buildNodeRuntimeArtifact, getDeploymentRuntimeEntrypoints, } from '../utils/deployment-runtime-artifact.js';
5
7
  import { loadApiModule } from '../utils/load-api.js';
6
8
  import { logger } from '../utils/logger.js';
7
9
  const SHA256_PATTERN = /^[0-9a-f]{64}$/;
10
+ const DEFAULT_BUNDLE_OUTPUT = 'analytics/hypequery-deployment';
8
11
  function runtimeName(options) {
9
12
  const runtime = options.runtime ?? 'node';
10
13
  if (runtime !== 'node' && runtime !== 'python') {
@@ -46,6 +49,23 @@ export async function buildDeploymentCommand(apiPath, options = {}) {
46
49
  }
47
50
  const runtime = runtimeName(options);
48
51
  const configuredArtifact = configuredRuntimeArtifact(options, runtime);
52
+ const legacyOutputRequested = options.output !== undefined
53
+ || options.hashOutput !== undefined
54
+ || options.runtimeOutput !== undefined;
55
+ if (options.bundleOutput !== undefined && legacyOutputRequested) {
56
+ throw new Error('--bundle-output cannot be combined with --output, --hash-output, or --runtime-output.');
57
+ }
58
+ const bundleOutput = options.bundleOutput
59
+ ?? (legacyOutputRequested ? undefined : DEFAULT_BUNDLE_OUTPUT);
60
+ if (options.runtimeFile !== undefined && configuredArtifact === undefined) {
61
+ throw new Error('--runtime-file requires --runtime-artifact.');
62
+ }
63
+ if (options.runtimeFile !== undefined && bundleOutput === undefined) {
64
+ throw new Error('--runtime-file is only supported when building a deployment bundle.');
65
+ }
66
+ if (configuredArtifact && bundleOutput !== undefined && options.runtimeFile === undefined) {
67
+ throw new Error('A complete deployment bundle requires --runtime-file with a prebuilt --runtime-artifact.');
68
+ }
49
69
  if (configuredArtifact && options.runtimeOutput !== undefined) {
50
70
  throw new Error('--runtime-output cannot be used with a prebuilt --runtime-artifact.');
51
71
  }
@@ -84,6 +104,30 @@ export async function buildDeploymentCommand(apiPath, options = {}) {
84
104
  const contract = api.deploymentContract(artifact ? { runtimeArtifact: artifact } : {});
85
105
  const prepared = prepareProtocolDeploymentContract(contract);
86
106
  const { canonical, contract: validated, identity: digest } = prepared;
107
+ if (bundleOutput !== undefined) {
108
+ const runtimeFiles = [];
109
+ if (builtArtifact) {
110
+ runtimeFiles.push({
111
+ runtime: 'node',
112
+ sha256: builtArtifact.artifactSha256,
113
+ bytes: builtArtifact.bytes,
114
+ });
115
+ }
116
+ else if (configuredArtifact && options.runtimeFile) {
117
+ const bytes = await readDeploymentRuntimeFile(options.runtimeFile);
118
+ const actualSha256 = createHash('sha256').update(bytes).digest('hex');
119
+ if (actualSha256 !== configuredArtifact.artifactSha256) {
120
+ throw new Error(`Prebuilt runtime artifact SHA-256 mismatch: ${options.runtimeFile}\n\n`
121
+ + `Expected: ${configuredArtifact.artifactSha256}\nActual: ${actualSha256}`);
122
+ }
123
+ runtimeFiles.push({ runtime, sha256: actualSha256, bytes });
124
+ }
125
+ const bundle = await writeDeploymentBundle(bundleOutput, prepared, runtimeFiles);
126
+ logger.success(`Deployment bundle written to ${bundle.directory}`);
127
+ logger.info(`Bundle identity: ${bundle.identity}`);
128
+ logger.info(`Deployment identity: ${digest}`);
129
+ return validated;
130
+ }
87
131
  const runtimeOutputPath = builtArtifact
88
132
  ? options.runtimeOutput ?? path.join(path.dirname(outputPath), 'hypequery-runtime.mjs')
89
133
  : undefined;
@@ -118,7 +162,37 @@ export async function buildDeploymentCommand(apiPath, options = {}) {
118
162
  export async function validateDeploymentCommand(artifactPath) {
119
163
  if (!artifactPath) {
120
164
  throw new Error('Missing deployment artifact path.\n\n'
121
- + 'Usage: hypequery deployment:validate analytics/hypequery-deployment.json');
165
+ + 'Usage: hypequery deployment:validate analytics/hypequery-deployment');
166
+ }
167
+ let artifactStat;
168
+ try {
169
+ artifactStat = await stat(artifactPath);
170
+ }
171
+ catch (error) {
172
+ if (!(typeof error === 'object' && error !== null && 'code' in error
173
+ && error.code === 'ENOENT')) {
174
+ throw new Error(`Cannot inspect deployment input: ${artifactPath}\n\n`
175
+ + (error instanceof Error ? error.message : String(error)));
176
+ }
177
+ }
178
+ if (artifactStat?.isDirectory()) {
179
+ try {
180
+ const bundle = await verifyDeploymentBundle(artifactPath);
181
+ const contract = bundle.contract;
182
+ logger.success(`Valid deployment bundle: ${artifactPath}`);
183
+ logger.info(`${contract.datasets.length} datasets, ${contract.queries.length} queries, `
184
+ + `${contract.artifacts.length} runtime artifacts`);
185
+ logger.info(`Bundle identity: ${bundle.identity}`);
186
+ logger.info(`Deployment identity: ${bundle.manifest.deployment.identity}`);
187
+ return contract;
188
+ }
189
+ catch (error) {
190
+ throw new Error(`Invalid deployment bundle: ${artifactPath}\n\n`
191
+ + (error instanceof Error ? error.message : String(error)));
192
+ }
193
+ }
194
+ if (artifactStat && !artifactStat.isFile()) {
195
+ throw new Error(`Deployment input must be a regular JSON file or bundle directory: ${artifactPath}`);
122
196
  }
123
197
  let input;
124
198
  try {
@@ -0,0 +1,19 @@
1
+ import { type PreparedProtocolDeploymentContract, type ProtocolDeploymentBundleManifest, type ProtocolDeploymentContract } from '@hypequery/protocol';
2
+ export declare const DEPLOYMENT_BUNDLE_MANIFEST = "bundle.json";
3
+ export declare const DEPLOYMENT_BUNDLE_CONTRACT = "deployment.json";
4
+ export interface DeploymentBundleRuntimeFile {
5
+ readonly runtime: 'node' | 'python';
6
+ readonly sha256: string;
7
+ readonly bytes: Uint8Array;
8
+ }
9
+ export interface WrittenDeploymentBundle {
10
+ readonly directory: string;
11
+ readonly manifest: ProtocolDeploymentBundleManifest;
12
+ readonly identity: string;
13
+ readonly contract: ProtocolDeploymentContract;
14
+ }
15
+ export type VerifiedDeploymentBundle = WrittenDeploymentBundle;
16
+ export declare function writeDeploymentBundle(outputDirectory: string, prepared: PreparedProtocolDeploymentContract, runtimeFiles: readonly DeploymentBundleRuntimeFile[]): Promise<WrittenDeploymentBundle>;
17
+ export declare function readDeploymentRuntimeFile(filePath: string): Promise<Uint8Array>;
18
+ export declare function verifyDeploymentBundle(bundleDirectory: string): Promise<VerifiedDeploymentBundle>;
19
+ //# sourceMappingURL=deployment-bundle.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deployment-bundle.d.ts","sourceRoot":"","sources":["../../src/utils/deployment-bundle.ts"],"names":[],"mappings":"AAaA,OAAO,EAIL,KAAK,kCAAkC,EAEvC,KAAK,gCAAgC,EACrC,KAAK,0BAA0B,EAChC,MAAM,qBAAqB,CAAC;AAG7B,eAAO,MAAM,0BAA0B,gBAAgB,CAAC;AACxD,eAAO,MAAM,0BAA0B,oBAAoB,CAAC;AAE5D,MAAM,WAAW,2BAA2B;IAC1C,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,QAAQ,CAAC;IACpC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAC;CAC5B;AAED,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,QAAQ,EAAE,gCAAgC,CAAC;IACpD,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,QAAQ,EAAE,0BAA0B,CAAC;CAC/C;AAED,MAAM,MAAM,wBAAwB,GAAG,uBAAuB,CAAC;AA+F/D,wBAAsB,qBAAqB,CACzC,eAAe,EAAE,MAAM,EACvB,QAAQ,EAAE,kCAAkC,EAC5C,YAAY,EAAE,SAAS,2BAA2B,EAAE,GACnD,OAAO,CAAC,uBAAuB,CAAC,CA6ElC;AAgDD,wBAAsB,yBAAyB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAMrF;AA+FD,wBAAsB,sBAAsB,CAC1C,eAAe,EAAE,MAAM,GACtB,OAAO,CAAC,wBAAwB,CAAC,CA+DnC"}
@@ -0,0 +1,322 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { constants, lstat, mkdir, mkdtemp, open, readdir, rename, rm, writeFile, } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { DEFAULT_PROTOCOL_DEPLOYMENT_BUNDLE_LIMITS, prepareProtocolDeploymentBundleManifest, prepareProtocolDeploymentContract, } from '@hypequery/protocol';
5
+ import { logger } from './logger.js';
6
+ export const DEPLOYMENT_BUNDLE_MANIFEST = 'bundle.json';
7
+ export const DEPLOYMENT_BUNDLE_CONTRACT = 'deployment.json';
8
+ const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
9
+ const utf8Encoder = new TextEncoder();
10
+ function sha256(bytes) {
11
+ return createHash('sha256').update(bytes).digest('hex');
12
+ }
13
+ function runtimeArtifactPath(runtime, digest) {
14
+ return `artifacts/${digest}.${runtime === 'node' ? 'mjs' : 'pyz'}`;
15
+ }
16
+ function errorCode(error) {
17
+ return typeof error === 'object' && error !== null && 'code' in error
18
+ ? String(error.code)
19
+ : undefined;
20
+ }
21
+ async function existingVerifiedBundle(outputDirectory) {
22
+ try {
23
+ await lstat(outputDirectory);
24
+ }
25
+ catch (error) {
26
+ if (errorCode(error) === 'ENOENT')
27
+ return false;
28
+ throw error;
29
+ }
30
+ try {
31
+ await verifyDeploymentBundle(outputDirectory);
32
+ }
33
+ catch (error) {
34
+ throw new Error('Refusing to replace an existing path that is not a valid deployment bundle: '
35
+ + `${outputDirectory}\n\n`
36
+ + (error instanceof Error ? error.message : String(error)));
37
+ }
38
+ return true;
39
+ }
40
+ function validateRuntimeFiles(prepared, files) {
41
+ requireClosedContractArtifactSet(prepared.contract);
42
+ const declared = new Map(prepared.contract.artifacts.map(artifact => [artifact.artifactSha256, artifact.runtime]));
43
+ const supplied = new Map();
44
+ for (const file of files) {
45
+ if (sha256(file.bytes) !== file.sha256) {
46
+ throw new Error(`Runtime artifact bytes do not match SHA-256 ${file.sha256}.`);
47
+ }
48
+ if (declared.get(file.sha256) !== file.runtime) {
49
+ throw new Error(`Runtime artifact ${file.sha256} is not declared by the deployment contract as ${file.runtime}.`);
50
+ }
51
+ if (supplied.has(file.sha256)) {
52
+ throw new Error(`Duplicate runtime artifact bytes supplied for ${file.sha256}.`);
53
+ }
54
+ if (file.bytes.byteLength < 1
55
+ || file.bytes.byteLength > DEFAULT_PROTOCOL_DEPLOYMENT_BUNDLE_LIMITS.maxArtifactBytes) {
56
+ throw new Error(`Runtime artifact ${file.sha256} exceeds the bundle byte limits.`);
57
+ }
58
+ supplied.set(file.sha256, file.runtime);
59
+ }
60
+ for (const [digest, runtime] of declared) {
61
+ if (supplied.get(digest) !== runtime) {
62
+ throw new Error(`Deployment bundle is missing ${runtime} runtime artifact ${digest}.`);
63
+ }
64
+ }
65
+ return Object.freeze([...files].sort((left, right) => {
66
+ const leftPath = runtimeArtifactPath(left.runtime, left.sha256);
67
+ const rightPath = runtimeArtifactPath(right.runtime, right.sha256);
68
+ return leftPath < rightPath ? -1 : leftPath > rightPath ? 1 : 0;
69
+ }));
70
+ }
71
+ function requireClosedContractArtifactSet(contract) {
72
+ const referenced = new Map();
73
+ for (const query of contract.queries) {
74
+ if (query.implementation.kind === 'runtime-reference') {
75
+ referenced.set(query.implementation.artifactSha256, query.implementation.runtime);
76
+ }
77
+ }
78
+ if (referenced.size !== contract.artifacts.length) {
79
+ throw new Error('Deployment contract contains missing or unreferenced runtime artifacts.');
80
+ }
81
+ for (const artifact of contract.artifacts) {
82
+ if (referenced.get(artifact.artifactSha256) !== artifact.runtime) {
83
+ throw new Error(`Deployment runtime artifact ${artifact.artifactSha256} is not referenced by a named query.`);
84
+ }
85
+ }
86
+ }
87
+ export async function writeDeploymentBundle(outputDirectory, prepared, runtimeFiles) {
88
+ const destination = path.resolve(outputDirectory);
89
+ if (destination === path.parse(destination).root) {
90
+ throw new Error('The deployment bundle output cannot be a filesystem root.');
91
+ }
92
+ const files = validateRuntimeFiles(prepared, runtimeFiles);
93
+ const deploymentBytes = utf8Encoder.encode(`${prepared.canonical}\n`);
94
+ const manifestInput = {
95
+ kind: 'hypequery-deployment-bundle',
96
+ version: 1,
97
+ deployment: {
98
+ path: DEPLOYMENT_BUNDLE_CONTRACT,
99
+ identity: prepared.identity,
100
+ sha256: sha256(deploymentBytes),
101
+ byteLength: deploymentBytes.byteLength,
102
+ },
103
+ artifacts: files.map(file => ({
104
+ runtime: file.runtime,
105
+ path: runtimeArtifactPath(file.runtime, file.sha256),
106
+ sha256: file.sha256,
107
+ byteLength: file.bytes.byteLength,
108
+ })),
109
+ };
110
+ const preparedManifest = prepareProtocolDeploymentBundleManifest(manifestInput);
111
+ const replaceExisting = await existingVerifiedBundle(destination);
112
+ const parent = path.dirname(destination);
113
+ await mkdir(parent, { recursive: true });
114
+ const staging = await mkdtemp(path.join(parent, `.${path.basename(destination)}.tmp-`));
115
+ try {
116
+ if (files.length > 0)
117
+ await mkdir(path.join(staging, 'artifacts'));
118
+ await writeFile(path.join(staging, DEPLOYMENT_BUNDLE_CONTRACT), deploymentBytes, { flag: 'wx' });
119
+ for (const file of files) {
120
+ await writeFile(path.join(staging, runtimeArtifactPath(file.runtime, file.sha256)), file.bytes, { flag: 'wx' });
121
+ }
122
+ await writeFile(path.join(staging, DEPLOYMENT_BUNDLE_MANIFEST), `${preparedManifest.canonical}\n`, { encoding: 'utf8', flag: 'wx' });
123
+ if (!replaceExisting) {
124
+ await rename(staging, destination);
125
+ }
126
+ else {
127
+ const backup = path.join(parent, `.${path.basename(destination)}.previous-${randomUUID()}`);
128
+ await rename(destination, backup);
129
+ try {
130
+ await rename(staging, destination);
131
+ }
132
+ catch (error) {
133
+ await rename(backup, destination);
134
+ throw error;
135
+ }
136
+ try {
137
+ await rm(backup, { recursive: true });
138
+ }
139
+ catch (error) {
140
+ logger.warn(`Deployment bundle was replaced, but its previous backup could not be removed: ${backup}`
141
+ + ` (${error instanceof Error ? error.message : String(error)})`);
142
+ }
143
+ }
144
+ }
145
+ catch (error) {
146
+ await rm(staging, { force: true, recursive: true });
147
+ throw error;
148
+ }
149
+ return Object.freeze({
150
+ directory: destination,
151
+ manifest: preparedManifest.manifest,
152
+ identity: preparedManifest.identity,
153
+ contract: prepared.contract,
154
+ });
155
+ }
156
+ async function readBoundedAbsoluteRegularFile(absolutePath, displayPath, maximum) {
157
+ let handle;
158
+ try {
159
+ const initialStat = await lstat(absolutePath);
160
+ if (initialStat.isSymbolicLink()) {
161
+ throw new Error(`Bundle entry must not be a symbolic link: ${displayPath}`);
162
+ }
163
+ if (!initialStat.isFile()) {
164
+ throw new Error(`Bundle entry is not a regular file: ${displayPath}`);
165
+ }
166
+ if (initialStat.size < 1 || initialStat.size > maximum) {
167
+ throw new Error(`Bundle entry exceeds its byte limit: ${displayPath}`);
168
+ }
169
+ handle = await open(absolutePath, constants.O_RDONLY | constants.O_NOFOLLOW);
170
+ const stat = await handle.stat();
171
+ if (!stat.isFile())
172
+ throw new Error(`Bundle entry is not a regular file: ${displayPath}`);
173
+ if (stat.size < 1 || stat.size > maximum) {
174
+ throw new Error(`Bundle entry exceeds its byte limit: ${displayPath}`);
175
+ }
176
+ return await handle.readFile();
177
+ }
178
+ catch (error) {
179
+ if (errorCode(error) === 'ELOOP') {
180
+ throw new Error(`Bundle entry must not be a symbolic link: ${displayPath}`);
181
+ }
182
+ throw error;
183
+ }
184
+ finally {
185
+ await handle?.close();
186
+ }
187
+ }
188
+ async function readBoundedRegularFile(root, relativePath, maximum) {
189
+ return readBoundedAbsoluteRegularFile(path.join(root, ...relativePath.split('/')), relativePath, maximum);
190
+ }
191
+ export async function readDeploymentRuntimeFile(filePath) {
192
+ return readBoundedAbsoluteRegularFile(path.resolve(filePath), filePath, DEFAULT_PROTOCOL_DEPLOYMENT_BUNDLE_LIMITS.maxArtifactBytes);
193
+ }
194
+ function expectedDirectories(files) {
195
+ const result = new Set();
196
+ for (const file of files) {
197
+ const segments = file.split('/');
198
+ for (let index = 1; index < segments.length; index += 1) {
199
+ result.add(segments.slice(0, index).join('/'));
200
+ }
201
+ }
202
+ return result;
203
+ }
204
+ async function verifyExactEntries(root, manifest) {
205
+ const expectedFiles = new Set([
206
+ DEPLOYMENT_BUNDLE_MANIFEST,
207
+ manifest.deployment.path,
208
+ ...manifest.artifacts.map(artifact => artifact.path),
209
+ ]);
210
+ const expectedDirectoryPaths = expectedDirectories([...expectedFiles]);
211
+ const seenFiles = new Set();
212
+ const seenDirectories = new Set();
213
+ async function visit(relativeDirectory) {
214
+ const absoluteDirectory = relativeDirectory
215
+ ? path.join(root, ...relativeDirectory.split('/'))
216
+ : root;
217
+ const names = await readdir(absoluteDirectory);
218
+ names.sort();
219
+ for (const name of names) {
220
+ const relativePath = relativeDirectory ? `${relativeDirectory}/${name}` : name;
221
+ const stat = await lstat(path.join(absoluteDirectory, name));
222
+ if (stat.isSymbolicLink()) {
223
+ throw new Error(`Bundle entries must not be symbolic links: ${relativePath}`);
224
+ }
225
+ if (stat.isDirectory()) {
226
+ if (!expectedDirectoryPaths.has(relativePath)) {
227
+ throw new Error(`Deployment bundle contains an undeclared directory: ${relativePath}`);
228
+ }
229
+ seenDirectories.add(relativePath);
230
+ await visit(relativePath);
231
+ }
232
+ else if (stat.isFile()) {
233
+ if (!expectedFiles.has(relativePath)) {
234
+ throw new Error(`Deployment bundle contains an undeclared file: ${relativePath}`);
235
+ }
236
+ seenFiles.add(relativePath);
237
+ }
238
+ else {
239
+ throw new Error(`Bundle entry is not a regular file or directory: ${relativePath}`);
240
+ }
241
+ }
242
+ }
243
+ await visit('');
244
+ const missingFiles = [...expectedFiles].filter(file => !seenFiles.has(file)).sort();
245
+ if (missingFiles.length > 0) {
246
+ throw new Error(`Deployment bundle is missing files: ${missingFiles.join(', ')}`);
247
+ }
248
+ const missingDirectories = [...expectedDirectoryPaths]
249
+ .filter(directory => !seenDirectories.has(directory))
250
+ .sort();
251
+ if (missingDirectories.length > 0) {
252
+ throw new Error(`Deployment bundle is missing directories: ${missingDirectories.join(', ')}`);
253
+ }
254
+ }
255
+ function verifyFile(bytes, file) {
256
+ if (bytes.byteLength !== file.byteLength) {
257
+ throw new Error(`Bundle entry byte length does not match the manifest: ${file.path}`);
258
+ }
259
+ if (sha256(bytes) !== file.sha256) {
260
+ throw new Error(`Bundle entry SHA-256 does not match the manifest: ${file.path}`);
261
+ }
262
+ }
263
+ function verifyRuntimeReferences(contract, artifacts) {
264
+ requireClosedContractArtifactSet(contract);
265
+ const declared = new Map(contract.artifacts.map(artifact => [artifact.artifactSha256, artifact.runtime]));
266
+ if (declared.size !== artifacts.length) {
267
+ throw new Error('Bundle runtime artifacts do not match the deployment contract.');
268
+ }
269
+ for (const artifact of artifacts) {
270
+ if (declared.get(artifact.sha256) !== artifact.runtime) {
271
+ throw new Error(`Bundle artifact ${artifact.sha256} does not match a deployment runtime reference.`);
272
+ }
273
+ }
274
+ }
275
+ export async function verifyDeploymentBundle(bundleDirectory) {
276
+ const root = path.resolve(bundleDirectory);
277
+ const rootStat = await lstat(root);
278
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
279
+ throw new Error(`Deployment bundle path must be a regular directory: ${bundleDirectory}`);
280
+ }
281
+ const manifestBytes = await readBoundedRegularFile(root, DEPLOYMENT_BUNDLE_MANIFEST, 1024 * 1024);
282
+ let input;
283
+ try {
284
+ input = JSON.parse(utf8Decoder.decode(manifestBytes));
285
+ }
286
+ catch (error) {
287
+ throw new Error(`Invalid deployment bundle manifest: ${path.join(root, DEPLOYMENT_BUNDLE_MANIFEST)}\n\n`
288
+ + (error instanceof Error ? error.message : String(error)));
289
+ }
290
+ const preparedManifest = prepareProtocolDeploymentBundleManifest(input);
291
+ const expectedManifestBytes = utf8Encoder.encode(`${preparedManifest.canonical}\n`);
292
+ if (!Buffer.from(manifestBytes).equals(Buffer.from(expectedManifestBytes))) {
293
+ throw new Error('Deployment bundle manifest must contain canonical JSON followed by one newline.');
294
+ }
295
+ await verifyExactEntries(root, preparedManifest.manifest);
296
+ const deployment = preparedManifest.manifest.deployment;
297
+ const deploymentBytes = await readBoundedRegularFile(root, deployment.path, DEFAULT_PROTOCOL_DEPLOYMENT_BUNDLE_LIMITS.maxDeploymentBytes);
298
+ verifyFile(deploymentBytes, deployment);
299
+ let contractInput;
300
+ try {
301
+ contractInput = JSON.parse(utf8Decoder.decode(deploymentBytes));
302
+ }
303
+ catch (error) {
304
+ throw new Error(`Invalid deployment JSON in bundle: ${deployment.path}\n\n`
305
+ + (error instanceof Error ? error.message : String(error)));
306
+ }
307
+ const preparedContract = prepareProtocolDeploymentContract(contractInput);
308
+ if (preparedContract.identity !== deployment.identity) {
309
+ throw new Error('Deployment identity does not match the bundle manifest.');
310
+ }
311
+ for (const artifact of preparedManifest.manifest.artifacts) {
312
+ const bytes = await readBoundedRegularFile(root, artifact.path, DEFAULT_PROTOCOL_DEPLOYMENT_BUNDLE_LIMITS.maxArtifactBytes);
313
+ verifyFile(bytes, artifact);
314
+ }
315
+ verifyRuntimeReferences(preparedContract.contract, preparedManifest.manifest.artifacts);
316
+ return Object.freeze({
317
+ directory: root,
318
+ manifest: preparedManifest.manifest,
319
+ identity: preparedManifest.identity,
320
+ contract: preparedContract.contract,
321
+ });
322
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hypequery/cli",
3
- "version": "1.6.0",
3
+ "version": "1.7.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.3.0"
22
+ "@hypequery/protocol": "0.4.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.0"
40
+ "@hypequery/serve": "0.13.1"
41
41
  },
42
42
  "repository": {
43
43
  "type": "git",