@hypequery/cli 1.10.4 → 1.11.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 +72 -13
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +19 -3
- package/dist/commands/deploy.d.ts +2 -0
- package/dist/commands/deploy.d.ts.map +1 -1
- package/dist/commands/deploy.js +28 -3
- package/dist/commands/deployment.d.ts +5 -1
- package/dist/commands/deployment.d.ts.map +1 -1
- package/dist/commands/deployment.js +29 -11
- package/dist/commands/login.d.ts +21 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +241 -0
- package/dist/utils/cloud-credential-store.d.ts +28 -0
- package/dist/utils/cloud-credential-store.d.ts.map +1 -0
- package/dist/utils/cloud-credential-store.js +293 -0
- package/dist/utils/deployment-upload.d.ts.map +1 -1
- package/dist/utils/deployment-upload.js +13 -2
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -182,11 +182,64 @@ are still accepted for metadata-only validation.
|
|
|
182
182
|
npx hypequery deployment:validate analytics/hypequery-deployment
|
|
183
183
|
```
|
|
184
184
|
|
|
185
|
+
### `hypequery login`
|
|
186
|
+
|
|
187
|
+
Authorizes the local CLI through your existing Hypequery Cloud browser
|
|
188
|
+
session. The command uses an S256 PKCE loopback flow, then stores the
|
|
189
|
+
target-scoped deployment token in the operating-system credential vault.
|
|
190
|
+
Tokens expire after 12 hours.
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
npx hypequery login
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Use `--cloud-url <origin>` or `HYPEQUERY_CLOUD_URL` for a self-hosted or local
|
|
197
|
+
Cloud instance. HTTPS is required except for loopback development origins.
|
|
198
|
+
|
|
199
|
+
Once logged in, `hypequery deploy` automatically uses the stored endpoint and
|
|
200
|
+
token. The deployment endpoint is returned by Cloud during the token exchange;
|
|
201
|
+
it is the authenticated upload API, not the browser login endpoint. For
|
|
202
|
+
non-interactive CI usage, set both `HYPEQUERY_API_TOKEN` and
|
|
203
|
+
`HYPEQUERY_DEPLOYMENT_ENDPOINT` (or pass `--endpoint`).
|
|
204
|
+
|
|
205
|
+
Because the CLI also loads a project `.env`, a `HYPEQUERY_API_TOKEN` left there
|
|
206
|
+
takes precedence over the login flow and is rejected unless it is paired with an
|
|
207
|
+
endpoint. Unset it to use `hypequery login`.
|
|
208
|
+
|
|
209
|
+
Token storage requires an operating-system credential vault (Keychain,
|
|
210
|
+
Credential Manager, or a Secret Service implementation such as
|
|
211
|
+
gnome-keyring/KWallet). Headless environments without one should use
|
|
212
|
+
`HYPEQUERY_API_TOKEN` instead of `hypequery login`.
|
|
213
|
+
|
|
214
|
+
### `hypequery logout`
|
|
215
|
+
|
|
216
|
+
Revokes the current Cloud token and removes it from the operating-system
|
|
217
|
+
credential vault:
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
npx hypequery logout
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
If the stored profile is unreadable, `logout` still removes the local state and
|
|
224
|
+
reports that the token was not revoked; it expires on its own.
|
|
225
|
+
|
|
185
226
|
### `hypequery deployment:release`
|
|
186
227
|
|
|
187
228
|
Prepares a deterministic release request from a verified deployment bundle and
|
|
188
|
-
|
|
189
|
-
|
|
229
|
+
a project/environment target. This command does not upload, authorize, or
|
|
230
|
+
execute the release.
|
|
231
|
+
|
|
232
|
+
After `hypequery login` the target is read from the stored Cloud profile, and
|
|
233
|
+
the resolved target is printed alongside the release identity:
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
npx hypequery deployment:release analytics/hypequery-deployment
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
To target something other than the logged-in project and environment, pass both
|
|
240
|
+
flags. A half-specified override is rejected rather than completed from the
|
|
241
|
+
stored profile, so an unset shell variable fails loudly instead of silently
|
|
242
|
+
retargeting the release:
|
|
190
243
|
|
|
191
244
|
```bash
|
|
192
245
|
npx hypequery deployment:release analytics/hypequery-deployment \
|
|
@@ -200,8 +253,10 @@ invalidate bundle verification.
|
|
|
200
253
|
|
|
201
254
|
Options:
|
|
202
255
|
|
|
203
|
-
- `--project <project>`:
|
|
204
|
-
|
|
256
|
+
- `--project <project>`: target project identifier; defaults to the logged-in
|
|
257
|
+
target and must be paired with `--environment`
|
|
258
|
+
- `--environment <environment>`: target environment identifier; defaults to the
|
|
259
|
+
logged-in target and must be paired with `--project`
|
|
205
260
|
- `--output <path>`: release JSON path, default beside the bundle
|
|
206
261
|
|
|
207
262
|
### `hypequery deploy`
|
|
@@ -211,17 +266,21 @@ release. The command verifies both inputs again, requires their bundle
|
|
|
211
266
|
identities to match, and streams only the files declared by the bundle.
|
|
212
267
|
|
|
213
268
|
```bash
|
|
214
|
-
HYPEQUERY_API_TOKEN=<token> \
|
|
215
269
|
npx hypequery deploy analytics/hypequery-deployment \
|
|
216
|
-
--release analytics/hypequery-deployment.release.json
|
|
217
|
-
--endpoint https://deploy.example.com/v1/releases
|
|
270
|
+
--release analytics/hypequery-deployment.release.json
|
|
218
271
|
```
|
|
219
272
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
`
|
|
223
|
-
|
|
224
|
-
|
|
273
|
+
For local development, run `hypequery login` first; the CLI reads the endpoint
|
|
274
|
+
and token from its secure Cloud profile. For CI and manual credentials, set
|
|
275
|
+
`HYPEQUERY_API_TOKEN` together with either `--endpoint` or
|
|
276
|
+
`HYPEQUERY_DEPLOYMENT_ENDPOINT`. The CLI never combines one explicit value with
|
|
277
|
+
the other value from the stored profile. Tokens are never accepted as
|
|
278
|
+
command-line arguments, keeping them out of shell history. The submission
|
|
279
|
+
endpoint must not contain credentials or a URL fragment, and must use HTTPS
|
|
280
|
+
except for `127.0.0.1`/`localhost`, which is permitted for local Cloud
|
|
281
|
+
development and warns that the token is sent in cleartext. The release identity
|
|
282
|
+
is sent as the idempotency key, so an unchanged release can be submitted safely
|
|
283
|
+
again.
|
|
225
284
|
|
|
226
285
|
This command submits immutable deployment inputs. Activation, status changes,
|
|
227
286
|
promotion, and rollback remain control-plane operations.
|
|
@@ -229,7 +288,7 @@ promotion, and rollback remain control-plane operations.
|
|
|
229
288
|
Options:
|
|
230
289
|
|
|
231
290
|
- `--release <path>`: required target-bound release JSON
|
|
232
|
-
- `--endpoint <url>`: HTTPS submission endpoint;
|
|
291
|
+
- `--endpoint <url>`: HTTPS submission endpoint; requires `HYPEQUERY_API_TOKEN`
|
|
233
292
|
|
|
234
293
|
## Non-interactive Setup
|
|
235
294
|
|
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;AAuBpC,QAAA,MAAM,OAAO,SAAgB,CAAC;AAa9B,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;;EAKpE;AAoMD,OAAO,EAAE,OAAO,EAAE,CAAC"}
|
package/dist/cli.js
CHANGED
|
@@ -8,6 +8,7 @@ import { generateDatasetsCommand } from './commands/generate-datasets.js';
|
|
|
8
8
|
import { generateManifestCommand } from './commands/generate-manifest.js';
|
|
9
9
|
import { buildDeploymentCommand, prepareDeploymentReleaseCommand, validateDeploymentCommand, } from './commands/deployment.js';
|
|
10
10
|
import { deployCommand, } from './commands/deploy.js';
|
|
11
|
+
import { loginCommand, logoutCommand, } from './commands/login.js';
|
|
11
12
|
const program = new Command();
|
|
12
13
|
function getCliVersion() {
|
|
13
14
|
try {
|
|
@@ -28,6 +29,19 @@ program
|
|
|
28
29
|
.name('hypequery')
|
|
29
30
|
.description('Type-safe analytics layer for ClickHouse')
|
|
30
31
|
.version(getCliVersion());
|
|
32
|
+
program
|
|
33
|
+
.command('login')
|
|
34
|
+
.description('Authorize this machine with Hypequery Cloud')
|
|
35
|
+
.option('--cloud-url <url>', 'Cloud origin (or HYPEQUERY_CLOUD_URL)')
|
|
36
|
+
.action(runCommand(async (options) => {
|
|
37
|
+
await loginCommand(options);
|
|
38
|
+
}));
|
|
39
|
+
program
|
|
40
|
+
.command('logout')
|
|
41
|
+
.description('Revoke and remove the local Hypequery Cloud credential')
|
|
42
|
+
.action(runCommand(async () => {
|
|
43
|
+
await logoutCommand();
|
|
44
|
+
}));
|
|
31
45
|
function runCommand(action) {
|
|
32
46
|
return async (...args) => {
|
|
33
47
|
try {
|
|
@@ -133,8 +147,8 @@ program
|
|
|
133
147
|
program
|
|
134
148
|
.command('deployment:release <bundle>')
|
|
135
149
|
.description('Prepare a target-bound release from a verified deployment bundle')
|
|
136
|
-
.
|
|
137
|
-
.
|
|
150
|
+
.option('--project <project>', 'Target project identifier (advanced override)')
|
|
151
|
+
.option('--environment <environment>', 'Target environment identifier (advanced override)')
|
|
138
152
|
.option('-o, --output <path>', 'Release JSON path (default: beside the bundle)')
|
|
139
153
|
.action(runCommand(async (bundle, options) => {
|
|
140
154
|
await prepareDeploymentReleaseCommand(bundle, options);
|
|
@@ -143,7 +157,7 @@ program
|
|
|
143
157
|
.command('deploy <bundle>')
|
|
144
158
|
.description('Submit a verified deployment bundle and release')
|
|
145
159
|
.requiredOption('--release <path>', 'Target-bound release JSON path')
|
|
146
|
-
.option('--endpoint <url>', 'HTTPS submission endpoint
|
|
160
|
+
.option('--endpoint <url>', 'HTTPS submission endpoint; requires HYPEQUERY_API_TOKEN')
|
|
147
161
|
.action(runCommand(async (bundle, options) => {
|
|
148
162
|
await deployCommand(bundle, options);
|
|
149
163
|
}));
|
|
@@ -170,6 +184,8 @@ program
|
|
|
170
184
|
program.on('--help', () => {
|
|
171
185
|
console.log('');
|
|
172
186
|
console.log('Examples:');
|
|
187
|
+
console.log(' hypequery login');
|
|
188
|
+
console.log(' hypequery logout');
|
|
173
189
|
console.log(' hypequery init');
|
|
174
190
|
console.log(' hypequery dev');
|
|
175
191
|
console.log(' hypequery dev --port 3000');
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHttpDeploymentUploadTransport, type DeploymentSubmissionResponse } from '../utils/deployment-upload.js';
|
|
2
|
+
import { type StoredCloudCredential } from '../utils/cloud-credential-store.js';
|
|
2
3
|
export interface DeployOptions {
|
|
3
4
|
release?: string;
|
|
4
5
|
endpoint?: string;
|
|
@@ -6,6 +7,7 @@ export interface DeployOptions {
|
|
|
6
7
|
export interface DeployDependencies {
|
|
7
8
|
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
8
9
|
readonly createTransport?: typeof createHttpDeploymentUploadTransport;
|
|
10
|
+
readonly loadCredential?: () => Promise<StoredCloudCredential | null>;
|
|
9
11
|
}
|
|
10
12
|
export declare function deployCommand(bundlePath: string | undefined, options?: DeployOptions, dependencies?: DeployDependencies): Promise<DeploymentSubmissionResponse>;
|
|
11
13
|
//# sourceMappingURL=deploy.d.ts.map
|
|
@@ -1 +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;
|
|
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;AAEvC,OAAO,EAEL,KAAK,qBAAqB,EAC3B,MAAM,oCAAoC,CAAC;AAK5C,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;IACtE,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,OAAO,CAAC,qBAAqB,GAAG,IAAI,CAAC,CAAC;CACvE;AA4DD,wBAAsB,aAAa,CACjC,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,OAAO,GAAE,aAAkB,EAC3B,YAAY,GAAE,kBAAuB,GACpC,OAAO,CAAC,4BAA4B,CAAC,CAoFvC"}
|
package/dist/commands/deploy.js
CHANGED
|
@@ -3,6 +3,7 @@ import { prepareProtocolDeploymentReleaseEnvelope, } from '@hypequery/protocol';
|
|
|
3
3
|
import { verifyDeploymentBundle } from '../utils/deployment-bundle.js';
|
|
4
4
|
import { createHttpDeploymentUploadTransport, DeploymentUploadError, } from '../utils/deployment-upload.js';
|
|
5
5
|
import { logger } from '../utils/logger.js';
|
|
6
|
+
import { loadCloudCredential, } from '../utils/cloud-credential-store.js';
|
|
6
7
|
const MAX_RELEASE_FILE_BYTES = 16 * 1024;
|
|
7
8
|
const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
|
|
8
9
|
async function readReleaseFile(releasePath) {
|
|
@@ -68,8 +69,29 @@ export async function deployCommand(bundlePath, options = {}, dependencies = {})
|
|
|
68
69
|
}
|
|
69
70
|
const releasePath = requiredConfiguration(options.release, 'Missing required --release <path>.');
|
|
70
71
|
const env = dependencies.env ?? process.env;
|
|
71
|
-
|
|
72
|
-
|
|
72
|
+
let deploymentEndpoint = options.endpoint ?? env.HYPEQUERY_DEPLOYMENT_ENDPOINT;
|
|
73
|
+
let token = env.HYPEQUERY_API_TOKEN;
|
|
74
|
+
const hasExplicitEndpoint = Boolean(deploymentEndpoint);
|
|
75
|
+
const hasExplicitToken = Boolean(token);
|
|
76
|
+
if (hasExplicitEndpoint !== hasExplicitToken) {
|
|
77
|
+
throw new Error(hasExplicitEndpoint
|
|
78
|
+
? 'An explicit deployment endpoint requires HYPEQUERY_API_TOKEN.'
|
|
79
|
+
: 'HYPEQUERY_API_TOKEN requires --endpoint or HYPEQUERY_DEPLOYMENT_ENDPOINT.\n\n'
|
|
80
|
+
+ 'If you meant to use `hypequery login`, unset HYPEQUERY_API_TOKEN — '
|
|
81
|
+
+ 'the CLI also reads it from a project .env file.');
|
|
82
|
+
}
|
|
83
|
+
if (!hasExplicitEndpoint) {
|
|
84
|
+
const credential = await (dependencies.loadCredential ?? loadCloudCredential)();
|
|
85
|
+
if (credential) {
|
|
86
|
+
if (Date.parse(credential.expiresAt) <= Date.now()) {
|
|
87
|
+
throw new Error('The stored Cloud credential has expired. Run `hypequery login` again.');
|
|
88
|
+
}
|
|
89
|
+
deploymentEndpoint = credential.deploymentEndpoint;
|
|
90
|
+
token = credential.token;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
deploymentEndpoint = requiredConfiguration(deploymentEndpoint, 'Missing deployment endpoint. Run `hypequery login`, pass --endpoint, or set HYPEQUERY_DEPLOYMENT_ENDPOINT.');
|
|
94
|
+
token = requiredConfiguration(token, 'Missing deployment credential. Run `hypequery login` or set HYPEQUERY_API_TOKEN.');
|
|
73
95
|
let bundle;
|
|
74
96
|
try {
|
|
75
97
|
bundle = await verifyDeploymentBundle(bundlePath);
|
|
@@ -91,7 +113,10 @@ export async function deployCommand(bundlePath, options = {}, dependencies = {})
|
|
|
91
113
|
throw new DeploymentUploadError('HQ_UPLOAD_IDENTITY_MISMATCH', 'Release bundle identity does not match the verified deployment bundle.');
|
|
92
114
|
}
|
|
93
115
|
const createTransport = dependencies.createTransport ?? createHttpDeploymentUploadTransport;
|
|
94
|
-
const transportOptions = {
|
|
116
|
+
const transportOptions = {
|
|
117
|
+
endpoint: deploymentEndpoint,
|
|
118
|
+
token,
|
|
119
|
+
};
|
|
95
120
|
const result = await createTransport(transportOptions).submit(bundle, release);
|
|
96
121
|
logger.success(result.status === 'accepted'
|
|
97
122
|
? 'Deployment release accepted'
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ProtocolDeploymentContract, type ProtocolDeploymentReleaseEnvelope } from '@hypequery/protocol';
|
|
2
|
+
import { type StoredCloudCredential } from '../utils/cloud-credential-store.js';
|
|
2
3
|
export interface BuildDeploymentOptions {
|
|
3
4
|
bundleOutput?: string;
|
|
4
5
|
output?: string;
|
|
@@ -14,7 +15,10 @@ export interface PrepareDeploymentReleaseOptions {
|
|
|
14
15
|
environment?: string;
|
|
15
16
|
output?: string;
|
|
16
17
|
}
|
|
18
|
+
export interface PrepareDeploymentReleaseDependencies {
|
|
19
|
+
loadCredential?: () => Promise<StoredCloudCredential | null>;
|
|
20
|
+
}
|
|
17
21
|
export declare function buildDeploymentCommand(apiPath: string | undefined, options?: BuildDeploymentOptions): Promise<ProtocolDeploymentContract>;
|
|
18
22
|
export declare function validateDeploymentCommand(artifactPath: string | undefined): Promise<ProtocolDeploymentContract>;
|
|
19
|
-
export declare function prepareDeploymentReleaseCommand(bundlePath: string | undefined, options?: PrepareDeploymentReleaseOptions): Promise<ProtocolDeploymentReleaseEnvelope>;
|
|
23
|
+
export declare function prepareDeploymentReleaseCommand(bundlePath: string | undefined, options?: PrepareDeploymentReleaseOptions, dependencies?: PrepareDeploymentReleaseDependencies): Promise<ProtocolDeploymentReleaseEnvelope>;
|
|
20
24
|
//# sourceMappingURL=deployment.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
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;
|
|
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;AAc7B,OAAO,EAEL,KAAK,qBAAqB,EAC3B,MAAM,oCAAoC,CAAC;AAI5C,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;AAED,MAAM,WAAW,oCAAoC;IACnD,cAAc,CAAC,EAAE,MAAM,OAAO,CAAC,qBAAqB,GAAG,IAAI,CAAC,CAAC;CAC9D;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,EAC7C,YAAY,GAAE,oCAAyC,GACtD,OAAO,CAAC,iCAAiC,CAAC,CAgE5C"}
|
|
@@ -6,6 +6,7 @@ import { writeDeploymentBundle, verifyDeploymentBundle, readDeploymentRuntimeFil
|
|
|
6
6
|
import { buildNodeRuntimeArtifact, getDeploymentRuntimeEntrypoints, } from '../utils/deployment-runtime-artifact.js';
|
|
7
7
|
import { loadApiModule } from '../utils/load-api.js';
|
|
8
8
|
import { logger } from '../utils/logger.js';
|
|
9
|
+
import { loadCloudCredential, } from '../utils/cloud-credential-store.js';
|
|
9
10
|
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
|
|
10
11
|
const DEFAULT_BUNDLE_OUTPUT = 'analytics/hypequery-deployment';
|
|
11
12
|
function runtimeName(options) {
|
|
@@ -274,17 +275,33 @@ async function assertOutputIsNotSymbolicLink(outputPath) {
|
|
|
274
275
|
}
|
|
275
276
|
}
|
|
276
277
|
}
|
|
277
|
-
export async function prepareDeploymentReleaseCommand(bundlePath, options = {}) {
|
|
278
|
+
export async function prepareDeploymentReleaseCommand(bundlePath, options = {}, dependencies = {}) {
|
|
278
279
|
if (!bundlePath) {
|
|
279
280
|
throw new Error('Missing deployment bundle path.\n\n'
|
|
280
|
-
+ 'Usage: hypequery deployment:release analytics/hypequery-deployment
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
281
|
+
+ 'Usage: hypequery deployment:release analytics/hypequery-deployment');
|
|
282
|
+
}
|
|
283
|
+
// Passing either flag opts out of the logged-in target entirely. Completing
|
|
284
|
+
// a half-specified override from the stored profile would silently retarget
|
|
285
|
+
// the release — `--project "$P" --environment "$E"` with an unset variable
|
|
286
|
+
// must fail loudly, not bind to whatever the last login used.
|
|
287
|
+
let project;
|
|
288
|
+
let environment;
|
|
289
|
+
if (options.project !== undefined || options.environment !== undefined) {
|
|
290
|
+
if (!options.project)
|
|
291
|
+
throw new Error('Missing required --project <project>.');
|
|
292
|
+
if (!options.environment)
|
|
293
|
+
throw new Error('Missing required --environment <environment>.');
|
|
294
|
+
project = options.project;
|
|
295
|
+
environment = options.environment;
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
const credential = await (dependencies.loadCredential ?? loadCloudCredential)();
|
|
299
|
+
project = credential?.target?.project;
|
|
300
|
+
environment = credential?.target?.environment;
|
|
301
|
+
if (!project || !environment) {
|
|
302
|
+
throw new Error('Missing deployment target. Run `hypequery login` or pass both '
|
|
303
|
+
+ '--project <project> and --environment <environment>.');
|
|
304
|
+
}
|
|
288
305
|
}
|
|
289
306
|
const outputPath = options.output ?? `${bundlePath.replace(/[\\/]+$/, '')}.release.json`;
|
|
290
307
|
assertOutputOutsideBundle(bundlePath, outputPath);
|
|
@@ -302,14 +319,15 @@ export async function prepareDeploymentReleaseCommand(bundlePath, options = {})
|
|
|
302
319
|
version: 1,
|
|
303
320
|
bundleIdentity: bundle.identity,
|
|
304
321
|
target: {
|
|
305
|
-
project
|
|
306
|
-
environment
|
|
322
|
+
project,
|
|
323
|
+
environment,
|
|
307
324
|
},
|
|
308
325
|
});
|
|
309
326
|
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
310
327
|
await assertOutputIsNotSymbolicLink(outputPath);
|
|
311
328
|
await writeFile(outputPath, `${prepared.canonical}\n`, 'utf8');
|
|
312
329
|
logger.success(`Deployment release written to ${outputPath}`);
|
|
330
|
+
logger.info(`Target: ${project}/${environment}`);
|
|
313
331
|
logger.info(`Release identity: ${prepared.identity}`);
|
|
314
332
|
logger.info(`Bundle identity: ${bundle.identity}`);
|
|
315
333
|
return prepared.release;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { deleteCloudCredential, loadCloudCredential, saveCloudCredential } from '../utils/cloud-credential-store.js';
|
|
2
|
+
export interface LoginOptions {
|
|
3
|
+
readonly cloudUrl?: string;
|
|
4
|
+
}
|
|
5
|
+
export interface LoginDependencies {
|
|
6
|
+
readonly fetch?: typeof fetch;
|
|
7
|
+
readonly openBrowser?: (url: string) => Promise<unknown>;
|
|
8
|
+
readonly now?: () => number;
|
|
9
|
+
readonly requestTimeoutMs?: number;
|
|
10
|
+
readonly saveCredential?: typeof saveCloudCredential;
|
|
11
|
+
readonly timeoutMs?: number;
|
|
12
|
+
}
|
|
13
|
+
export interface LogoutDependencies {
|
|
14
|
+
readonly fetch?: typeof fetch;
|
|
15
|
+
readonly loadCredential?: typeof loadCloudCredential;
|
|
16
|
+
readonly deleteCredential?: typeof deleteCloudCredential;
|
|
17
|
+
readonly requestTimeoutMs?: number;
|
|
18
|
+
}
|
|
19
|
+
export declare function loginCommand(options?: LoginOptions, dependencies?: LoginDependencies): Promise<void>;
|
|
20
|
+
export declare function logoutCommand(dependencies?: LogoutDependencies): Promise<void>;
|
|
21
|
+
//# sourceMappingURL=login.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"login.d.ts","sourceRoot":"","sources":["../../src/commands/login.ts"],"names":[],"mappings":"AAMA,OAAO,EAEL,qBAAqB,EACrB,mBAAmB,EAGnB,mBAAmB,EAEpB,MAAM,oCAAoC,CAAC;AAgB5C,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;IAC9B,QAAQ,CAAC,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IACzD,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IAC5B,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IACnC,QAAQ,CAAC,cAAc,CAAC,EAAE,OAAO,mBAAmB,CAAC;IACrD,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;IAC9B,QAAQ,CAAC,cAAc,CAAC,EAAE,OAAO,mBAAmB,CAAC;IACrD,QAAQ,CAAC,gBAAgB,CAAC,EAAE,OAAO,qBAAqB,CAAC;IACzD,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;CACpC;AAsJD,wBAAsB,YAAY,CAChC,OAAO,GAAE,YAAiB,EAC1B,YAAY,GAAE,iBAAsB,iBAoDrC;AAED,wBAAsB,aAAa,CAAC,YAAY,GAAE,kBAAuB,iBAoCxE"}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import open from 'open';
|
|
4
|
+
import { validateProtocolDeploymentReleaseTarget } from '@hypequery/protocol';
|
|
5
|
+
import { CLOUD_DEPLOYMENT_SCOPE, deleteCloudCredential, loadCloudCredential, normalizeCloudDeploymentEndpoint, normalizeCloudOrigin, saveCloudCredential, } from '../utils/cloud-credential-store.js';
|
|
6
|
+
import { logger } from '../utils/logger.js';
|
|
7
|
+
const DEFAULT_CLOUD_URL = 'https://cloud.hypequery.com';
|
|
8
|
+
const LOGIN_TIMEOUT_MS = 5 * 60_000;
|
|
9
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
10
|
+
const MAX_TOKEN_RESPONSE_BYTES = 64 * 1024;
|
|
11
|
+
// Cloud issues 12-hour credentials; allow limited client/server clock skew.
|
|
12
|
+
const MAX_TOKEN_LIFETIME_MS = 13 * 60 * 60_000;
|
|
13
|
+
// Cloud owns the token format. Validate only what protects this client — an
|
|
14
|
+
// opaque, header-safe bearer credential of a sane length — rather than pinning
|
|
15
|
+
// a version prefix or exact length that would break every already-published
|
|
16
|
+
// CLI the day Cloud rotates its token format.
|
|
17
|
+
const TOKEN_PATTERN = /^hqdp_[A-Za-z0-9_-]{16,512}$/;
|
|
18
|
+
const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
|
|
19
|
+
function callbackHtml(success) {
|
|
20
|
+
const title = success ? 'CLI authorized' : 'Authorization failed';
|
|
21
|
+
const message = success
|
|
22
|
+
? 'You can close this window and return to your terminal.'
|
|
23
|
+
: 'Return to your terminal and run the login command again.';
|
|
24
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${title}</title></head><body><main><h1>${title}</h1><p>${message}</p></main></body></html>`;
|
|
25
|
+
}
|
|
26
|
+
async function callbackServer(state, timeoutMs) {
|
|
27
|
+
let settle;
|
|
28
|
+
let reject;
|
|
29
|
+
const code = new Promise((resolve, rejectPromise) => {
|
|
30
|
+
settle = resolve;
|
|
31
|
+
reject = rejectPromise;
|
|
32
|
+
});
|
|
33
|
+
// The caller only awaits `code` after the browser has been opened, which
|
|
34
|
+
// takes long enough for a rejection to be seen as unhandled and terminate
|
|
35
|
+
// the process. This keeps a handler attached from the very first tick.
|
|
36
|
+
code.catch(() => undefined);
|
|
37
|
+
const server = createServer((request, response) => {
|
|
38
|
+
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
39
|
+
if (request.method !== 'GET' || url.pathname !== '/callback') {
|
|
40
|
+
response.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
41
|
+
response.end('Not found');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const returnedState = url.searchParams.get('state');
|
|
45
|
+
const authorizationCode = url.searchParams.get('code');
|
|
46
|
+
const authorizationError = url.searchParams.get('error');
|
|
47
|
+
const stateMatches = returnedState === state;
|
|
48
|
+
const valid = stateMatches
|
|
49
|
+
&& typeof authorizationCode === 'string'
|
|
50
|
+
&& authorizationCode.length >= 1
|
|
51
|
+
&& authorizationCode.length <= 4096;
|
|
52
|
+
response.writeHead(valid ? 200 : 400, {
|
|
53
|
+
'Cache-Control': 'no-store',
|
|
54
|
+
'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'",
|
|
55
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
56
|
+
'Referrer-Policy': 'no-referrer',
|
|
57
|
+
'X-Content-Type-Options': 'nosniff',
|
|
58
|
+
});
|
|
59
|
+
response.end(callbackHtml(valid));
|
|
60
|
+
// Only a matching callback completes the login. Any web page the user has
|
|
61
|
+
// open can issue a no-CORS request to this port, so a mismatch is ignored
|
|
62
|
+
// rather than settled: aborting here would let a background tab cancel a
|
|
63
|
+
// legitimate login. A matching Cloud error still ends the transaction.
|
|
64
|
+
if (valid)
|
|
65
|
+
settle?.(authorizationCode);
|
|
66
|
+
else if (stateMatches && authorizationError) {
|
|
67
|
+
reject?.(new Error('Cloud authorization was not completed.'));
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
server.listen(0, '127.0.0.1');
|
|
71
|
+
await new Promise((resolve, rejectListen) => {
|
|
72
|
+
server.once('listening', resolve);
|
|
73
|
+
server.once('error', rejectListen);
|
|
74
|
+
});
|
|
75
|
+
const address = server.address();
|
|
76
|
+
const timer = setTimeout(() => {
|
|
77
|
+
reject?.(new Error('CLI authorization timed out. Run `hypequery login` again.'));
|
|
78
|
+
}, timeoutMs);
|
|
79
|
+
timer.unref();
|
|
80
|
+
return {
|
|
81
|
+
code,
|
|
82
|
+
redirectUri: `http://127.0.0.1:${address.port}/callback`,
|
|
83
|
+
close: () => {
|
|
84
|
+
clearTimeout(timer);
|
|
85
|
+
server.close();
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
async function boundedJsonResponse(response) {
|
|
90
|
+
if (!response.body)
|
|
91
|
+
return null;
|
|
92
|
+
const reader = response.body.getReader();
|
|
93
|
+
const chunks = [];
|
|
94
|
+
let total = 0;
|
|
95
|
+
try {
|
|
96
|
+
for (;;) {
|
|
97
|
+
const { done, value } = await reader.read();
|
|
98
|
+
if (done)
|
|
99
|
+
break;
|
|
100
|
+
total += value.byteLength;
|
|
101
|
+
if (total > MAX_TOKEN_RESPONSE_BYTES) {
|
|
102
|
+
await reader.cancel().catch(() => undefined);
|
|
103
|
+
throw new Error('Cloud returned an oversized CLI token response.');
|
|
104
|
+
}
|
|
105
|
+
chunks.push(value);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
reader.releaseLock();
|
|
110
|
+
}
|
|
111
|
+
const bytes = new Uint8Array(total);
|
|
112
|
+
let offset = 0;
|
|
113
|
+
for (const chunk of chunks) {
|
|
114
|
+
bytes.set(chunk, offset);
|
|
115
|
+
offset += chunk.byteLength;
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
return JSON.parse(utf8Decoder.decode(bytes));
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function tokenResponse(input, origin, now) {
|
|
125
|
+
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
|
|
126
|
+
throw new Error('Cloud returned an invalid CLI token response.');
|
|
127
|
+
}
|
|
128
|
+
const value = input;
|
|
129
|
+
if (typeof value.access_token !== 'string'
|
|
130
|
+
|| !TOKEN_PATTERN.test(value.access_token)
|
|
131
|
+
|| value.token_type !== 'Bearer'
|
|
132
|
+
|| typeof value.expires_at !== 'string'
|
|
133
|
+
|| !Number.isFinite(Date.parse(value.expires_at))
|
|
134
|
+
|| value.scope !== CLOUD_DEPLOYMENT_SCOPE
|
|
135
|
+
|| typeof value.deployment_endpoint !== 'string'
|
|
136
|
+
|| value.deployment_target === undefined) {
|
|
137
|
+
throw new Error('Cloud returned an invalid CLI token response.');
|
|
138
|
+
}
|
|
139
|
+
const expiresAt = Date.parse(value.expires_at);
|
|
140
|
+
if (expiresAt <= now || expiresAt > now + MAX_TOKEN_LIFETIME_MS) {
|
|
141
|
+
throw new Error('Cloud returned an invalid CLI token expiration.');
|
|
142
|
+
}
|
|
143
|
+
const deploymentEndpoint = normalizeCloudDeploymentEndpoint(value.deployment_endpoint, origin);
|
|
144
|
+
let target;
|
|
145
|
+
try {
|
|
146
|
+
target = validateProtocolDeploymentReleaseTarget(value.deployment_target);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
throw new Error('Cloud returned an invalid CLI deployment target.');
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
cloudUrl: origin,
|
|
153
|
+
deploymentEndpoint,
|
|
154
|
+
expiresAt: value.expires_at,
|
|
155
|
+
scope: value.scope,
|
|
156
|
+
target,
|
|
157
|
+
token: value.access_token,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
export async function loginCommand(options = {}, dependencies = {}) {
|
|
161
|
+
const origin = normalizeCloudOrigin(options.cloudUrl ?? process.env.HYPEQUERY_CLOUD_URL ?? DEFAULT_CLOUD_URL);
|
|
162
|
+
const state = randomBytes(24).toString('base64url');
|
|
163
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
164
|
+
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
|
165
|
+
const callback = await callbackServer(state, dependencies.timeoutMs ?? LOGIN_TIMEOUT_MS);
|
|
166
|
+
const authorizeUrl = new URL('/cli/authorize', origin);
|
|
167
|
+
authorizeUrl.searchParams.set('redirect_uri', callback.redirectUri);
|
|
168
|
+
authorizeUrl.searchParams.set('code_challenge', challenge);
|
|
169
|
+
authorizeUrl.searchParams.set('code_challenge_method', 'S256');
|
|
170
|
+
authorizeUrl.searchParams.set('state', state);
|
|
171
|
+
try {
|
|
172
|
+
logger.info('Opening your browser to authorize Hypequery CLI…');
|
|
173
|
+
await (dependencies.openBrowser ?? open)(authorizeUrl.toString());
|
|
174
|
+
logger.info(`If the browser did not open, visit:\n${authorizeUrl}`);
|
|
175
|
+
const code = await callback.code;
|
|
176
|
+
callback.close();
|
|
177
|
+
const request = dependencies.fetch ?? fetch;
|
|
178
|
+
const response = await request(new URL('/api/cli/token', origin), {
|
|
179
|
+
method: 'POST',
|
|
180
|
+
headers: { 'Content-Type': 'application/json' },
|
|
181
|
+
body: JSON.stringify({
|
|
182
|
+
grant_type: 'authorization_code',
|
|
183
|
+
code,
|
|
184
|
+
code_verifier: verifier,
|
|
185
|
+
redirect_uri: callback.redirectUri,
|
|
186
|
+
}),
|
|
187
|
+
redirect: 'error',
|
|
188
|
+
signal: AbortSignal.timeout(dependencies.requestTimeoutMs ?? REQUEST_TIMEOUT_MS),
|
|
189
|
+
});
|
|
190
|
+
if (!response.ok) {
|
|
191
|
+
await response.body?.cancel().catch(() => undefined);
|
|
192
|
+
throw new Error('Cloud rejected the CLI authorization code. Run `hypequery login` again.');
|
|
193
|
+
}
|
|
194
|
+
const body = await boundedJsonResponse(response);
|
|
195
|
+
const credential = tokenResponse(body, origin, (dependencies.now ?? Date.now)());
|
|
196
|
+
await (dependencies.saveCredential ?? saveCloudCredential)(credential);
|
|
197
|
+
logger.success('Logged in to Hypequery Cloud');
|
|
198
|
+
logger.info(`Credential expires ${new Date(credential.expiresAt).toLocaleString()}`);
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
callback.close();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
export async function logoutCommand(dependencies = {}) {
|
|
205
|
+
const load = dependencies.loadCredential ?? loadCloudCredential;
|
|
206
|
+
const remove = dependencies.deleteCredential ?? deleteCloudCredential;
|
|
207
|
+
let credential = null;
|
|
208
|
+
let unreadable = false;
|
|
209
|
+
try {
|
|
210
|
+
credential = await load();
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
// Logout is the command users reach for when local state is broken, so a
|
|
214
|
+
// corrupt profile or an unreachable vault must not block the cleanup below.
|
|
215
|
+
unreadable = true;
|
|
216
|
+
logger.warn('The stored Cloud credential could not be read; removing local state without revoking. The token will expire automatically.');
|
|
217
|
+
}
|
|
218
|
+
if (!credential && !unreadable) {
|
|
219
|
+
logger.info('You are not logged in to Hypequery Cloud.');
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (credential) {
|
|
223
|
+
try {
|
|
224
|
+
const request = dependencies.fetch ?? fetch;
|
|
225
|
+
const response = await request(new URL('/api/cli/token', credential.cloudUrl), {
|
|
226
|
+
method: 'DELETE',
|
|
227
|
+
headers: { Authorization: `Bearer ${credential.token}` },
|
|
228
|
+
redirect: 'error',
|
|
229
|
+
signal: AbortSignal.timeout(dependencies.requestTimeoutMs ?? REQUEST_TIMEOUT_MS),
|
|
230
|
+
});
|
|
231
|
+
await response.body?.cancel().catch(() => undefined);
|
|
232
|
+
if (!response.ok)
|
|
233
|
+
throw new Error(`Cloud returned HTTP ${response.status}.`);
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
logger.warn('Cloud could not be reached; the local credential was removed and the token will expire automatically.');
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
await remove();
|
|
240
|
+
logger.success('Logged out of Hypequery Cloud');
|
|
241
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type ProtocolDeploymentReleaseTarget } from '@hypequery/protocol';
|
|
2
|
+
export declare const CLOUD_DEPLOYMENT_SCOPE = "deploy:submit";
|
|
3
|
+
export interface StoredCloudCredential {
|
|
4
|
+
readonly cloudUrl: string;
|
|
5
|
+
readonly deploymentEndpoint: string;
|
|
6
|
+
readonly expiresAt: string;
|
|
7
|
+
readonly scope: string;
|
|
8
|
+
readonly target?: ProtocolDeploymentReleaseTarget;
|
|
9
|
+
readonly token: string;
|
|
10
|
+
}
|
|
11
|
+
interface KeyringEntry {
|
|
12
|
+
setPassword(password: string): void | Promise<void>;
|
|
13
|
+
getPassword(): string | null | Promise<string | null>;
|
|
14
|
+
deletePassword(): unknown;
|
|
15
|
+
}
|
|
16
|
+
export interface CloudCredentialStoreDependencies {
|
|
17
|
+
readonly configDirectory?: string;
|
|
18
|
+
readonly createKeyringEntry?: (service: string, account: string) => Promise<KeyringEntry> | KeyringEntry;
|
|
19
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
20
|
+
readonly platform?: NodeJS.Platform;
|
|
21
|
+
}
|
|
22
|
+
export declare function normalizeCloudOrigin(input: string): string;
|
|
23
|
+
export declare function normalizeCloudDeploymentEndpoint(input: string, cloudOrigin: string): string;
|
|
24
|
+
export declare function saveCloudCredential(credential: StoredCloudCredential, dependencies?: CloudCredentialStoreDependencies): Promise<void>;
|
|
25
|
+
export declare function loadCloudCredential(dependencies?: CloudCredentialStoreDependencies): Promise<StoredCloudCredential | null>;
|
|
26
|
+
export declare function deleteCloudCredential(dependencies?: CloudCredentialStoreDependencies): Promise<void>;
|
|
27
|
+
export {};
|
|
28
|
+
//# sourceMappingURL=cloud-credential-store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cloud-credential-store.d.ts","sourceRoot":"","sources":["../../src/utils/cloud-credential-store.ts"],"names":[],"mappings":"AAIA,OAAO,EAEL,KAAK,+BAA+B,EACrC,MAAM,qBAAqB,CAAC;AAI7B,eAAO,MAAM,sBAAsB,kBAAkB,CAAC;AAGtD,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,kBAAkB,EAAE,MAAM,CAAC;IACpC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,CAAC,EAAE,+BAA+B,CAAC;IAClD,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAYD,UAAU,YAAY;IACpB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpD,WAAW,IAAI,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACtD,cAAc,IAAI,OAAO,CAAC;CAC3B;AAQD,MAAM,WAAW,gCAAgC;IAC/C,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,CAAC;IAClC,QAAQ,CAAC,kBAAkB,CAAC,EAAE,CAC5B,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,KACZ,OAAO,CAAC,YAAY,CAAC,GAAG,YAAY,CAAC;IAC1C,QAAQ,CAAC,GAAG,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC;IAC5D,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAC;CACrC;AAyCD,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAc1D;AAED,wBAAgB,gCAAgC,CAC9C,KAAK,EAAE,MAAM,EACb,WAAW,EAAE,MAAM,GAClB,MAAM,CAmBR;AAmFD,wBAAsB,mBAAmB,CACvC,UAAU,EAAE,qBAAqB,EACjC,YAAY,GAAE,gCAAqC,iBAiEpD;AAED,wBAAsB,mBAAmB,CACvC,YAAY,GAAE,gCAAqC,GAClD,OAAO,CAAC,qBAAqB,GAAG,IAAI,CAAC,CAsBvC;AA0BD,wBAAsB,qBAAqB,CACzC,YAAY,GAAE,gCAAqC,iBA0BpD"}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmod, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { validateProtocolDeploymentReleaseTarget, } from '@hypequery/protocol';
|
|
6
|
+
const KEYCHAIN_SERVICE = 'dev.hypequery.cli';
|
|
7
|
+
const PROFILE_FILE = 'cloud-profile.json';
|
|
8
|
+
export const CLOUD_DEPLOYMENT_SCOPE = 'deploy:submit';
|
|
9
|
+
const MAX_KEYCHAIN_ACCOUNT_LENGTH = 2048;
|
|
10
|
+
function defaultConfigDirectory(env, platform) {
|
|
11
|
+
if (env.HYPEQUERY_CONFIG_DIR)
|
|
12
|
+
return env.HYPEQUERY_CONFIG_DIR;
|
|
13
|
+
if (platform === 'win32') {
|
|
14
|
+
return path.join(env.APPDATA ?? env.LOCALAPPDATA ?? homedir(), 'hypequery');
|
|
15
|
+
}
|
|
16
|
+
if (platform === 'darwin') {
|
|
17
|
+
return path.join(homedir(), 'Library', 'Application Support', 'hypequery');
|
|
18
|
+
}
|
|
19
|
+
return path.join(env.XDG_CONFIG_HOME ?? path.join(homedir(), '.config'), 'hypequery');
|
|
20
|
+
}
|
|
21
|
+
function vaultUnavailable(error) {
|
|
22
|
+
return new Error('The operating-system credential vault is unavailable. '
|
|
23
|
+
+ 'Install its keyring service or use HYPEQUERY_API_TOKEN for manual authentication.', { cause: error });
|
|
24
|
+
}
|
|
25
|
+
async function defaultKeyringEntry(service, account) {
|
|
26
|
+
try {
|
|
27
|
+
const { Entry } = await import('@napi-rs/keyring');
|
|
28
|
+
return new Entry(service, account);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
throw vaultUnavailable(error);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function paths(dependencies) {
|
|
35
|
+
const env = dependencies.env ?? process.env;
|
|
36
|
+
const platform = dependencies.platform ?? process.platform;
|
|
37
|
+
const directory = dependencies.configDirectory
|
|
38
|
+
?? defaultConfigDirectory(env, platform);
|
|
39
|
+
return { directory, profile: path.join(directory, PROFILE_FILE) };
|
|
40
|
+
}
|
|
41
|
+
export function normalizeCloudOrigin(input) {
|
|
42
|
+
let url;
|
|
43
|
+
try {
|
|
44
|
+
url = new URL(input);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new Error('Cloud URL must be an absolute HTTPS URL.');
|
|
48
|
+
}
|
|
49
|
+
const loopback = url.protocol === 'http:'
|
|
50
|
+
&& (url.hostname === '127.0.0.1' || url.hostname === 'localhost');
|
|
51
|
+
if ((url.protocol !== 'https:' && !loopback) || url.username || url.password
|
|
52
|
+
|| url.pathname !== '/' || url.search || url.hash) {
|
|
53
|
+
throw new Error('Cloud URL must be an HTTPS origin without a path, query, or credentials.');
|
|
54
|
+
}
|
|
55
|
+
return url.origin;
|
|
56
|
+
}
|
|
57
|
+
export function normalizeCloudDeploymentEndpoint(input, cloudOrigin) {
|
|
58
|
+
let endpoint;
|
|
59
|
+
try {
|
|
60
|
+
endpoint = new URL(input);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
throw new Error('Cloud returned an invalid deployment endpoint.');
|
|
64
|
+
}
|
|
65
|
+
// Origin equality is what binds the token to the Cloud that issued it, and it
|
|
66
|
+
// is the only check that has to hold for a tampered profile to be harmless.
|
|
67
|
+
// Cloud owns its own path layout: pinning an exact submission path here would
|
|
68
|
+
// break every already-published CLI the day that path is versioned or moved.
|
|
69
|
+
if (endpoint.origin !== cloudOrigin
|
|
70
|
+
|| endpoint.username
|
|
71
|
+
|| endpoint.password
|
|
72
|
+
|| endpoint.search
|
|
73
|
+
|| endpoint.hash) {
|
|
74
|
+
throw new Error('Cloud returned an invalid deployment endpoint.');
|
|
75
|
+
}
|
|
76
|
+
return endpoint.toString();
|
|
77
|
+
}
|
|
78
|
+
function parseProfile(input) {
|
|
79
|
+
const parsed = JSON.parse(input);
|
|
80
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
81
|
+
throw new Error('The stored Hypequery Cloud profile is invalid. Run `hypequery login` again.');
|
|
82
|
+
}
|
|
83
|
+
const value = parsed;
|
|
84
|
+
if (value.version !== 1
|
|
85
|
+
|| typeof value.cloudUrl !== 'string'
|
|
86
|
+
|| typeof value.deploymentEndpoint !== 'string'
|
|
87
|
+
|| typeof value.expiresAt !== 'string'
|
|
88
|
+
|| typeof value.scope !== 'string'
|
|
89
|
+
|| typeof value.keychainAccount !== 'string') {
|
|
90
|
+
throw new Error('The stored Hypequery Cloud profile is invalid. Run `hypequery login` again.');
|
|
91
|
+
}
|
|
92
|
+
let cloudUrl;
|
|
93
|
+
let deploymentEndpoint;
|
|
94
|
+
let target;
|
|
95
|
+
try {
|
|
96
|
+
cloudUrl = normalizeCloudOrigin(value.cloudUrl);
|
|
97
|
+
deploymentEndpoint = normalizeCloudDeploymentEndpoint(value.deploymentEndpoint, cloudUrl);
|
|
98
|
+
if (value.keychainAccount !== cloudUrl
|
|
99
|
+
|| !Number.isFinite(Date.parse(value.expiresAt))
|
|
100
|
+
|| value.scope !== CLOUD_DEPLOYMENT_SCOPE) {
|
|
101
|
+
throw new Error('profile invariant mismatch');
|
|
102
|
+
}
|
|
103
|
+
target = value.target === undefined
|
|
104
|
+
? undefined
|
|
105
|
+
: validateProtocolDeploymentReleaseTarget(value.target);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
throw new Error('The stored Hypequery Cloud profile is invalid. Run `hypequery login` again.');
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
version: 1,
|
|
112
|
+
cloudUrl,
|
|
113
|
+
deploymentEndpoint,
|
|
114
|
+
expiresAt: value.expiresAt,
|
|
115
|
+
scope: value.scope,
|
|
116
|
+
...(target ? { target } : {}),
|
|
117
|
+
keychainAccount: value.keychainAccount,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
async function entry(account, dependencies) {
|
|
121
|
+
const create = dependencies.createKeyringEntry ?? defaultKeyringEntry;
|
|
122
|
+
const created = await create(KEYCHAIN_SERVICE, account);
|
|
123
|
+
// Constructing an entry is lazy — the vault is only contacted on get/set/
|
|
124
|
+
// delete — so a locked keyring or a missing Secret Service surfaces here, not
|
|
125
|
+
// at construction. Translate those failures into the same actionable error.
|
|
126
|
+
return {
|
|
127
|
+
async setPassword(password) {
|
|
128
|
+
try {
|
|
129
|
+
await created.setPassword(password);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
throw vaultUnavailable(error);
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
async getPassword() {
|
|
136
|
+
try {
|
|
137
|
+
return await created.getPassword();
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
throw vaultUnavailable(error);
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
async deletePassword() {
|
|
144
|
+
try {
|
|
145
|
+
return await created.deletePassword();
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
throw vaultUnavailable(error);
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
export async function saveCloudCredential(credential, dependencies = {}) {
|
|
154
|
+
const location = paths(dependencies);
|
|
155
|
+
let previousProfile = null;
|
|
156
|
+
try {
|
|
157
|
+
previousProfile = parseProfile(await readFile(location.profile, 'utf8'));
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
// A new login must be able to replace a missing or malformed profile; the
|
|
161
|
+
// only thing lost is the chance to purge a superseded vault entry.
|
|
162
|
+
previousProfile = null;
|
|
163
|
+
}
|
|
164
|
+
const cloudUrl = normalizeCloudOrigin(credential.cloudUrl);
|
|
165
|
+
const deploymentEndpoint = normalizeCloudDeploymentEndpoint(credential.deploymentEndpoint, cloudUrl);
|
|
166
|
+
if (!Number.isFinite(Date.parse(credential.expiresAt))
|
|
167
|
+
|| credential.scope !== CLOUD_DEPLOYMENT_SCOPE) {
|
|
168
|
+
throw new Error('Cannot store an invalid Hypequery Cloud credential.');
|
|
169
|
+
}
|
|
170
|
+
const keychainAccount = cloudUrl;
|
|
171
|
+
const keyring = await entry(keychainAccount, dependencies);
|
|
172
|
+
const previousPassword = await keyring.getPassword();
|
|
173
|
+
await keyring.setPassword(credential.token);
|
|
174
|
+
const profile = {
|
|
175
|
+
version: 1,
|
|
176
|
+
cloudUrl,
|
|
177
|
+
deploymentEndpoint,
|
|
178
|
+
expiresAt: credential.expiresAt,
|
|
179
|
+
scope: credential.scope,
|
|
180
|
+
...(credential.target ? { target: credential.target } : {}),
|
|
181
|
+
keychainAccount,
|
|
182
|
+
};
|
|
183
|
+
const temporary = `${location.profile}.${randomUUID()}.tmp`;
|
|
184
|
+
try {
|
|
185
|
+
await mkdir(location.directory, { recursive: true, mode: 0o700 });
|
|
186
|
+
await chmod(location.directory, 0o700);
|
|
187
|
+
await writeFile(temporary, `${JSON.stringify(profile, null, 2)}\n`, {
|
|
188
|
+
encoding: 'utf8',
|
|
189
|
+
flag: 'wx',
|
|
190
|
+
mode: 0o600,
|
|
191
|
+
});
|
|
192
|
+
await chmod(temporary, 0o600);
|
|
193
|
+
await rename(temporary, location.profile);
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
await unlink(temporary).catch(() => undefined);
|
|
197
|
+
if (previousPassword === null) {
|
|
198
|
+
await keyring.deletePassword().catch(() => undefined);
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
await keyring.setPassword(previousPassword).catch(() => undefined);
|
|
202
|
+
}
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
if (previousProfile && previousProfile.keychainAccount !== keychainAccount) {
|
|
206
|
+
try {
|
|
207
|
+
const previousKeyring = await entry(previousProfile.keychainAccount, dependencies);
|
|
208
|
+
await previousKeyring.deletePassword();
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
// The new profile is already committed; the previous token will expire.
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
export async function loadCloudCredential(dependencies = {}) {
|
|
216
|
+
const location = paths(dependencies);
|
|
217
|
+
let profile;
|
|
218
|
+
try {
|
|
219
|
+
profile = parseProfile(await readFile(location.profile, 'utf8'));
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
if (error.code === 'ENOENT')
|
|
223
|
+
return null;
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
const keyring = await entry(profile.keychainAccount, dependencies);
|
|
227
|
+
const token = await keyring.getPassword();
|
|
228
|
+
if (!token) {
|
|
229
|
+
throw new Error('The Hypequery Cloud token is missing from your credential vault. Run `hypequery login` again.');
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
cloudUrl: profile.cloudUrl,
|
|
233
|
+
deploymentEndpoint: profile.deploymentEndpoint,
|
|
234
|
+
expiresAt: profile.expiresAt,
|
|
235
|
+
scope: profile.scope,
|
|
236
|
+
...(profile.target ? { target: profile.target } : {}),
|
|
237
|
+
token,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Recovers just the vault account from a profile that failed validation, so a
|
|
242
|
+
* corrupted profile can still be cleaned up. Only the account is salvaged, and
|
|
243
|
+
* only under our own keychain service, so the worst a tampered value can do is
|
|
244
|
+
* delete another Hypequery entry that `hypequery login` recreates.
|
|
245
|
+
*/
|
|
246
|
+
function salvageKeychainAccount(input) {
|
|
247
|
+
let parsed;
|
|
248
|
+
try {
|
|
249
|
+
parsed = JSON.parse(input);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
const account = parsed.keychainAccount;
|
|
258
|
+
return typeof account === 'string'
|
|
259
|
+
&& account.length > 0
|
|
260
|
+
&& account.length <= MAX_KEYCHAIN_ACCOUNT_LENGTH
|
|
261
|
+
? account
|
|
262
|
+
: null;
|
|
263
|
+
}
|
|
264
|
+
export async function deleteCloudCredential(dependencies = {}) {
|
|
265
|
+
const location = paths(dependencies);
|
|
266
|
+
let contents = null;
|
|
267
|
+
try {
|
|
268
|
+
contents = await readFile(location.profile, 'utf8');
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
if (error.code !== 'ENOENT')
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
if (contents !== null) {
|
|
275
|
+
let account;
|
|
276
|
+
try {
|
|
277
|
+
account = parseProfile(contents).keychainAccount;
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
// `logout` is what users reach for when their profile is broken, so a
|
|
281
|
+
// malformed profile has to stay removable instead of throwing here.
|
|
282
|
+
account = salvageKeychainAccount(contents);
|
|
283
|
+
}
|
|
284
|
+
if (account !== null) {
|
|
285
|
+
const keyring = await entry(account, dependencies).catch(() => null);
|
|
286
|
+
await keyring?.deletePassword().catch(() => undefined);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
await unlink(location.profile).catch(error => {
|
|
290
|
+
if (error.code !== 'ENOENT')
|
|
291
|
+
throw error;
|
|
292
|
+
});
|
|
293
|
+
}
|
|
@@ -1 +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;
|
|
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;AAEhC,OAAO,KAAK,EAAE,4BAA4B,EAAE,MAAM,uBAAuB,CAAC;AAE1E,YAAY,EAAE,4BAA4B,EAAE,MAAM,uBAAuB,CAAC;AAQ1E,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,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;AA+VD,wBAAgB,mCAAmC,CACjD,OAAO,EAAE,oCAAoC,GAC5C,yBAAyB,CAsF3B"}
|
|
@@ -3,6 +3,7 @@ import { constants, open } from 'node:fs/promises';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { prepareProtocolDeploymentBundleManifest, prepareProtocolDeploymentReleaseEnvelope, } from '@hypequery/protocol';
|
|
5
5
|
import { DEPLOYMENT_BUNDLE_MANIFEST, } from './deployment-bundle.js';
|
|
6
|
+
import { logger } from './logger.js';
|
|
6
7
|
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
|
|
7
8
|
const MAX_RESPONSE_BYTES = 64 * 1024;
|
|
8
9
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
@@ -30,8 +31,18 @@ function endpointUrl(input) {
|
|
|
30
31
|
catch {
|
|
31
32
|
configurationError('Deployment endpoint must be an absolute HTTPS URL.');
|
|
32
33
|
}
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
// Loopback HTTP exists so a local Cloud instance can be developed against.
|
|
35
|
+
// It still puts the bearer token on the wire in cleartext, where any local
|
|
36
|
+
// process listening on that port can read it, so say so rather than let a
|
|
37
|
+
// production token be pointed at localhost silently.
|
|
38
|
+
const loopbackHttp = url.protocol === 'http:'
|
|
39
|
+
&& (url.hostname === '127.0.0.1' || url.hostname === 'localhost');
|
|
40
|
+
if (url.protocol !== 'https:' && !loopbackHttp) {
|
|
41
|
+
configurationError('Deployment endpoint must use HTTPS (HTTP is allowed only for loopback development).');
|
|
42
|
+
}
|
|
43
|
+
if (loopbackHttp) {
|
|
44
|
+
logger.warn(`Submitting to ${url.origin} over plaintext HTTP; the deployment token is `
|
|
45
|
+
+ 'sent unencrypted. Use this only for local Cloud development.');
|
|
35
46
|
}
|
|
36
47
|
if (url.username || url.password) {
|
|
37
48
|
configurationError('Deployment endpoint must not contain credentials.');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hypequery/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.0",
|
|
4
4
|
"description": "Command-line interface for hypequery",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
"@hypequery/deployment": "0.6.0",
|
|
23
23
|
"@hypequery/protocol": "0.9.0"
|
|
24
24
|
},
|
|
25
|
+
"optionalDependencies": {
|
|
26
|
+
"@napi-rs/keyring": "1.3.0"
|
|
27
|
+
},
|
|
25
28
|
"peerDependencies": {
|
|
26
29
|
"@hypequery/clickhouse": "*",
|
|
27
30
|
"@hypequery/serve": "*"
|