@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 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
- an explicit project/environment target. This command does not upload, authorize,
189
- or execute the release.
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>`: required target project identifier
204
- - `--environment <environment>`: required target environment identifier
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
- The token is accepted only through `HYPEQUERY_API_TOKEN`, keeping it out of
221
- shell history. The submission endpoint must use HTTPS, may also be supplied by
222
- `HYPEQUERY_DEPLOYMENT_ENDPOINT`, and must not contain credentials or a URL
223
- fragment. The release identity is sent as the idempotency key, so an unchanged
224
- release can be submitted safely again.
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; defaults to `HYPEQUERY_DEPLOYMENT_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;AAkBpC,QAAA,MAAM,OAAO,SAAgB,CAAC;AAa9B,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;;EAKpE;AAgLD,OAAO,EAAE,OAAO,EAAE,CAAC"}
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
- .requiredOption('--project <project>', 'Target project identifier')
137
- .requiredOption('--environment <environment>', 'Target environment identifier')
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 (or HYPEQUERY_DEPLOYMENT_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;AAMvC,MAAM,WAAW,aAAa;IAC5B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,GAAG,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC;IAC5D,QAAQ,CAAC,eAAe,CAAC,EAAE,OAAO,mCAAmC,CAAC;CACvE;AA4DD,wBAAsB,aAAa,CACjC,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,OAAO,GAAE,aAAkB,EAC3B,YAAY,GAAE,kBAAuB,GACpC,OAAO,CAAC,4BAA4B,CAAC,CA0DvC"}
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"}
@@ -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
- const endpoint = requiredConfiguration(options.endpoint ?? env.HYPEQUERY_DEPLOYMENT_ENDPOINT, 'Missing deployment endpoint. Pass --endpoint or set HYPEQUERY_DEPLOYMENT_ENDPOINT.');
72
- const token = requiredConfiguration(env.HYPEQUERY_API_TOKEN, 'Missing HYPEQUERY_API_TOKEN.');
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 = { endpoint, token };
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;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
+ {"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
- + '--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>.');
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: options.project,
306
- environment: options.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;AAChC,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;AAmVD,wBAAgB,mCAAmC,CACjD,OAAO,EAAE,oCAAoC,GAC5C,yBAAyB,CAsF3B"}
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
- if (url.protocol !== 'https:') {
34
- configurationError('Deployment endpoint must use HTTPS.');
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.10.4",
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": "*"