@hypequery/cli 1.6.0 → 1.8.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 +47 -13
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +17 -5
- package/dist/commands/deployment.d.ts +9 -1
- package/dist/commands/deployment.d.ts.map +1 -1
- package/dist/commands/deployment.js +174 -3
- package/dist/utils/deployment-bundle.d.ts +19 -0
- package/dist/utils/deployment-bundle.d.ts.map +1 -0
- package/dist/utils/deployment-bundle.js +322 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -138,38 +138,72 @@ semantic keys such as `dataset:orders`.
|
|
|
138
138
|
|
|
139
139
|
### `hypequery deployment:build`
|
|
140
140
|
|
|
141
|
-
Builds deployment
|
|
142
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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 <
|
|
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-
|
|
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
|
|
162
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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.
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
npx hypequery deployment:validate analytics/hypequery-deployment
|
|
183
|
+
```
|
|
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.
|
|
168
190
|
|
|
169
191
|
```bash
|
|
170
|
-
npx hypequery deployment:
|
|
192
|
+
npx hypequery deployment:release analytics/hypequery-deployment \
|
|
193
|
+
--project my-project \
|
|
194
|
+
--environment production
|
|
171
195
|
```
|
|
172
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
|
+
|
|
173
207
|
## Non-interactive Setup
|
|
174
208
|
|
|
175
209
|
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;AAcpC,QAAA,MAAM,OAAO,SAAgB,CAAC;AAa9B,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;;EAKpE;AAsKD,OAAO,EAAE,OAAO,EAAE,CAAC"}
|
package/dist/cli.js
CHANGED
|
@@ -6,7 +6,7 @@ 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
10
|
const program = new Command();
|
|
11
11
|
function getCliVersion() {
|
|
12
12
|
try {
|
|
@@ -111,10 +111,12 @@ program
|
|
|
111
111
|
}));
|
|
112
112
|
program
|
|
113
113
|
.command('deployment:build <api>')
|
|
114
|
-
.description('Build a
|
|
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,10 +125,19 @@ program
|
|
|
123
125
|
}));
|
|
124
126
|
program
|
|
125
127
|
.command('deployment:validate <artifact>')
|
|
126
|
-
.description('
|
|
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
|
}));
|
|
132
|
+
program
|
|
133
|
+
.command('deployment:release <bundle>')
|
|
134
|
+
.description('Prepare a target-bound release from a verified deployment bundle')
|
|
135
|
+
.requiredOption('--project <project>', 'Target project identifier')
|
|
136
|
+
.requiredOption('--environment <environment>', 'Target environment identifier')
|
|
137
|
+
.option('-o, --output <path>', 'Release JSON path (default: beside the bundle)')
|
|
138
|
+
.action(runCommand(async (bundle, options) => {
|
|
139
|
+
await prepareDeploymentReleaseCommand(bundle, options);
|
|
140
|
+
}));
|
|
130
141
|
// Help command
|
|
131
142
|
program
|
|
132
143
|
.command('help [command]')
|
|
@@ -157,8 +168,9 @@ program.on('--help', () => {
|
|
|
157
168
|
console.log(' hypequery generate:types --output analytics/schema.ts');
|
|
158
169
|
console.log(' hypequery generate:datasets');
|
|
159
170
|
console.log(' hypequery generate:manifest analytics/api.ts --output analytics/hypequery-manifest.json');
|
|
160
|
-
console.log(' hypequery deployment:build analytics/api.ts
|
|
161
|
-
console.log(' hypequery deployment:validate analytics/hypequery-deployment
|
|
171
|
+
console.log(' hypequery deployment:build analytics/api.ts');
|
|
172
|
+
console.log(' hypequery deployment:validate analytics/hypequery-deployment');
|
|
173
|
+
console.log(' hypequery deployment:release analytics/hypequery-deployment --project my-project --environment production');
|
|
162
174
|
console.log('');
|
|
163
175
|
console.log('Docs: https://hypequery.com/docs');
|
|
164
176
|
});
|
|
@@ -1,12 +1,20 @@
|
|
|
1
|
-
import { type ProtocolDeploymentContract } from '@hypequery/protocol';
|
|
1
|
+
import { type ProtocolDeploymentContract, type ProtocolDeploymentReleaseEnvelope } 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;
|
|
9
11
|
}
|
|
12
|
+
export interface PrepareDeploymentReleaseOptions {
|
|
13
|
+
project?: string;
|
|
14
|
+
environment?: string;
|
|
15
|
+
output?: string;
|
|
16
|
+
}
|
|
10
17
|
export declare function buildDeploymentCommand(apiPath: string | undefined, options?: BuildDeploymentOptions): Promise<ProtocolDeploymentContract>;
|
|
11
18
|
export declare function validateDeploymentCommand(artifactPath: string | undefined): Promise<ProtocolDeploymentContract>;
|
|
19
|
+
export declare function prepareDeploymentReleaseCommand(bundlePath: string | undefined, options?: PrepareDeploymentReleaseOptions): Promise<ProtocolDeploymentReleaseEnvelope>;
|
|
12
20
|
//# sourceMappingURL=deployment.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"deployment.d.ts","sourceRoot":"","sources":["../../src/commands/deployment.ts"],"names":[],"mappings":"
|
|
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,10 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { lstat, mkdir, readFile, realpath, stat, writeFile } from 'node:fs/promises';
|
|
2
3
|
import path from 'node:path';
|
|
3
|
-
import { prepareProtocolDeploymentContract, } from '@hypequery/protocol';
|
|
4
|
+
import { prepareProtocolDeploymentReleaseEnvelope, 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
|
|
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 {
|
|
@@ -143,3 +217,100 @@ export async function validateDeploymentCommand(artifactPath) {
|
|
|
143
217
|
logger.info(`Identity: ${digest}`);
|
|
144
218
|
return contract;
|
|
145
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,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.
|
|
3
|
+
"version": "1.8.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.
|
|
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.
|
|
40
|
+
"@hypequery/serve": "0.13.2"
|
|
41
41
|
},
|
|
42
42
|
"repository": {
|
|
43
43
|
"type": "git",
|