@hexclave/cli 1.0.61 → 1.0.63

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.
@@ -0,0 +1,407 @@
1
+ import { Command } from "commander";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { getInternalUser } from "../lib/app.js";
5
+ import { isProjectAuthWithSecretServerKey, resolveAuth, resolveProjectId, type ProjectAuth } from "../lib/auth.js";
6
+ import { AuthError, CliError, errorMessage } from "../lib/errors.js";
7
+ import { packageSourceDirectory } from "../lib/source-packaging.js";
8
+
9
+ // The names checked (in order) when --config is not passed; same preference
10
+ // order as `hexclave config push`'s pull-side resolution.
11
+ const CONFIG_FILE_CANDIDATES = ["hexclave.config.ts", "hexclave.config.js", "stack.config.ts", "stack.config.js"];
12
+
13
+ export type DeployOptions = {
14
+ config?: string,
15
+ cloudProjectId?: string,
16
+ secret: string[],
17
+ };
18
+
19
+ const SECRET_KEY_REGEX = /^[a-zA-Z0-9_-]+$/;
20
+ const ENV_VAR_KEY_REGEX = /^[A-Za-z_][A-Za-z0-9_]*$/;
21
+ // Must match the backend's connection value validation: `<serviceId>.<outputKey>`.
22
+ const CONNECTION_VALUE_REGEX = /^[a-zA-Z0-9_-]+\.[A-Za-z0-9_]+$/;
23
+
24
+ /**
25
+ * Parses repeated `--secret KEY=VALUE` options. Values may contain `=` (only
26
+ * the first one separates key from value). Keys are the secret keys named by
27
+ * `type: "secret"` env vars in the config; values are never persisted by
28
+ * Hexclave. Exported for unit tests.
29
+ */
30
+ export function parseSecretOptions(secretOptions: string[]): Map<string, string> {
31
+ const secrets = new Map<string, string>();
32
+ for (const option of secretOptions) {
33
+ const separatorIndex = option.indexOf("=");
34
+ if (separatorIndex <= 0) {
35
+ throw new CliError(`Invalid --secret value ${JSON.stringify(option)}. Expected the KEY=VALUE format.`);
36
+ }
37
+ const key = option.slice(0, separatorIndex);
38
+ const value = option.slice(separatorIndex + 1);
39
+ if (!SECRET_KEY_REGEX.test(key)) {
40
+ throw new CliError(`Invalid --secret key ${JSON.stringify(key)}. Secret keys must contain only letters, numbers, underscores, and hyphens.`);
41
+ }
42
+ if (secrets.has(key)) {
43
+ throw new CliError(`Duplicate --secret key ${JSON.stringify(key)}.`);
44
+ }
45
+ secrets.set(key, value);
46
+ }
47
+ return secrets;
48
+ }
49
+
50
+ // The top-level config section the Deployments app owns. The `-alpha` suffix is
51
+ // part of the public key while the app is in alpha, and it must match the app id
52
+ // in the backend's config schema — a mismatch here silently reads an empty
53
+ // section rather than failing, so it is defined once and reused below.
54
+ const DEPLOYMENTS_CONFIG_SECTION = "deployments-alpha";
55
+
56
+ // The config-side shape of one env var. Mirrors the schema of
57
+ // `deployments-alpha.services.<name>.env.<KEY>` and is sent to the deploy
58
+ // endpoint verbatim.
59
+ export type ServiceEnvVarConfig =
60
+ | { value: string }
61
+ | { type: "secret", key: string }
62
+ | { type: "connection", value: string };
63
+
64
+ export type ServiceDefinition = {
65
+ framework?: string,
66
+ installCommand?: string,
67
+ buildCommand?: string,
68
+ outputDirectory?: string,
69
+ rootDirectory?: string,
70
+ env: Record<string, ServiceEnvVarConfig>,
71
+ };
72
+
73
+ /**
74
+ * Extracts one service's definition from a loaded config module. The config
75
+ * file holds it under `deployments-alpha.services.<name>` — the exact shape that
76
+ * `hexclave config push` pushes as branch config. Exported for unit tests.
77
+ */
78
+ export function extractServiceDefinition(config: unknown, serviceName: string): ServiceDefinition {
79
+ if (config == null || typeof config !== "object") {
80
+ throw new CliError("Config file must export a plain `config` object.");
81
+ }
82
+ const section = (config as Record<string, unknown>)[DEPLOYMENTS_CONFIG_SECTION];
83
+ const services = section != null && typeof section === "object" ? (section as { services?: unknown }).services : undefined;
84
+ if (services == null || typeof services !== "object") {
85
+ throw new CliError(`The config file has no \`${DEPLOYMENTS_CONFIG_SECTION}.services\` section. Add one, e.g.:\n export const config = {\n "${DEPLOYMENTS_CONFIG_SECTION}": {\n services: {\n ${serviceName}: { type: "vercel", rootDirectory: "./", framework: "nextjs" },\n },\n },\n };`);
86
+ }
87
+ const service = (services as Record<string, unknown>)[serviceName];
88
+ if (service == null || typeof service !== "object") {
89
+ const available = Object.keys(services);
90
+ throw new CliError(`No service named ${JSON.stringify(serviceName)} in the config file's \`${DEPLOYMENTS_CONFIG_SECTION}.services\`.${available.length > 0 ? ` Available services: ${available.join(", ")}` : ""}`);
91
+ }
92
+ const record = service as Record<string, unknown>;
93
+ if (record.type !== "vercel") {
94
+ throw new CliError(record.type === undefined
95
+ ? `\`${DEPLOYMENTS_CONFIG_SECTION}.services.${serviceName}\` has no \`type\`. Add \`type: "vercel"\`.`
96
+ : `\`${DEPLOYMENTS_CONFIG_SECTION}.services.${serviceName}.type\` must be "vercel" (got ${JSON.stringify(record.type)}).`);
97
+ }
98
+ const readString = (key: string): string | undefined => {
99
+ const value = record[key];
100
+ if (value === undefined) return undefined;
101
+ if (typeof value !== "string") {
102
+ throw new CliError(`\`${DEPLOYMENTS_CONFIG_SECTION}.services.${serviceName}.${key}\` must be a string.`);
103
+ }
104
+ return value;
105
+ };
106
+ return {
107
+ framework: readString("framework"),
108
+ installCommand: readString("installCommand"),
109
+ buildCommand: readString("buildCommand"),
110
+ outputDirectory: readString("outputDirectory"),
111
+ rootDirectory: readString("rootDirectory"),
112
+ env: extractServiceEnv(record.env, serviceName),
113
+ };
114
+ }
115
+
116
+ function extractServiceEnv(env: unknown, serviceName: string): Record<string, ServiceEnvVarConfig> {
117
+ if (env === undefined) return {};
118
+ if (env == null || typeof env !== "object" || Array.isArray(env)) {
119
+ throw new CliError(`\`${DEPLOYMENTS_CONFIG_SECTION}.services.${serviceName}.env\` must be a record of env var entries, e.g. { MY_VAR: { value: "some-value" } }.`);
120
+ }
121
+ const result: Record<string, ServiceEnvVarConfig> = {};
122
+ for (const [envVarKey, entryValue] of Object.entries(env as Record<string, unknown>)) {
123
+ const path = `\`${DEPLOYMENTS_CONFIG_SECTION}.services.${serviceName}.env.${envVarKey}\``;
124
+ if (!ENV_VAR_KEY_REGEX.test(envVarKey)) {
125
+ throw new CliError(`${path} has an invalid key. Env var keys must start with a letter or underscore and contain only letters, digits, and underscores.`);
126
+ }
127
+ if (entryValue == null || typeof entryValue !== "object") {
128
+ throw new CliError(`${path} must be an object like { value: "..." }, { type: "secret", key: "..." }, or { type: "connection", value: "service.output" }.`);
129
+ }
130
+ const entry = entryValue as { type?: unknown, value?: unknown, key?: unknown };
131
+ switch (entry.type) {
132
+ case undefined: {
133
+ if (typeof entry.value !== "string") {
134
+ throw new CliError(`${path} must have a string \`value\` (or a \`type\` of "secret" or "connection").`);
135
+ }
136
+ if (entry.key !== undefined) {
137
+ throw new CliError(`${path} must not have a \`key\` — that's only for env vars with \`type: "secret"\`.`);
138
+ }
139
+ result[envVarKey] = { value: entry.value };
140
+ break;
141
+ }
142
+ case "secret": {
143
+ if (typeof entry.key !== "string" || !SECRET_KEY_REGEX.test(entry.key)) {
144
+ throw new CliError(`${path} has type "secret" and must have a \`key\` naming the secret to pass at deploy time (letters, numbers, underscores, and hyphens only).`);
145
+ }
146
+ if (entry.value !== undefined) {
147
+ throw new CliError(`${path} has type "secret" and must not have a \`value\` — pass it at deploy time with --secret ${entry.key}=<value> instead, so it is never committed.`);
148
+ }
149
+ result[envVarKey] = { type: "secret", key: entry.key };
150
+ break;
151
+ }
152
+ case "connection": {
153
+ if (typeof entry.value !== "string" || !CONNECTION_VALUE_REGEX.test(entry.value)) {
154
+ throw new CliError(`${path} has type "connection" and must have a \`value\` referencing a service output like "hexclave.projectId".`);
155
+ }
156
+ result[envVarKey] = { type: "connection", value: entry.value };
157
+ break;
158
+ }
159
+ default: {
160
+ throw new CliError(`${path} has an unknown \`type\` ${JSON.stringify(entry.type)}. Supported: "secret", "connection", or no type for a plain value.`);
161
+ }
162
+ }
163
+ }
164
+ return result;
165
+ }
166
+
167
+ /**
168
+ * Checks the provided secrets against the secret keys the env definitions
169
+ * reference, so a missing or misspelled secret fails BEFORE packaging and
170
+ * uploading. The backend re-checks this authoritatively. Exported for unit
171
+ * tests.
172
+ */
173
+ export function assertSecretsMatchEnv(env: Record<string, ServiceEnvVarConfig>, secrets: ReadonlyMap<string, string>): void {
174
+ const referencedKeys = new Set(Object.values(env).flatMap((entry) => "type" in entry && entry.type === "secret" ? [entry.key] : []));
175
+ const missing = [...referencedKeys].filter((key) => !secrets.has(key));
176
+ if (missing.length > 0) {
177
+ throw new CliError(`Missing secret values for: ${missing.join(", ")}. This service's env vars reference these secrets — pass them with --secret <key>=<value>.`);
178
+ }
179
+ const unused = [...secrets.keys()].filter((key) => !referencedKeys.has(key));
180
+ if (unused.length > 0) {
181
+ throw new CliError(`Unknown --secret key(s): ${unused.join(", ")}. No env var of this service references them — check for typos, or add an env var with \`type: "secret"\` referencing them.`);
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Resolves the config file path: --config wins (and must exist); otherwise the
187
+ * first existing candidate in cwd, or undefined when there is none — the
188
+ * config file is optional, deploys then use the service's configuration as
189
+ * stored on the backend (dashboard-configured). Exported for unit tests.
190
+ */
191
+ export function resolveDeployConfigPath(configOption: string | undefined, cwd: string): string | undefined {
192
+ if (configOption != null && configOption !== "") {
193
+ const resolved = path.resolve(cwd, configOption);
194
+ if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
195
+ throw new CliError(`Config file not found: ${resolved}`);
196
+ }
197
+ return resolved;
198
+ }
199
+ for (const candidate of CONFIG_FILE_CANDIDATES) {
200
+ const resolved = path.resolve(cwd, candidate);
201
+ if (fs.existsSync(resolved) && fs.statSync(resolved).isFile()) {
202
+ return resolved;
203
+ }
204
+ }
205
+ return undefined;
206
+ }
207
+
208
+ // Returns a FACTORY rather than a fixed header object: the deploy flow can
209
+ // span minutes (large uploads), and the refresh-token path's access token may
210
+ // expire mid-flow. getTokens() transparently refreshes when needed, so calling
211
+ // the factory per request always yields a valid token; the secret-server-key
212
+ // path is static.
213
+ async function buildAuthHeadersFactory(auth: ProjectAuth): Promise<() => Promise<Record<string, string>>> {
214
+ if (isProjectAuthWithSecretServerKey(auth)) {
215
+ const headers = {
216
+ "x-stack-access-type": "server",
217
+ "x-stack-project-id": auth.projectId,
218
+ "x-stack-secret-server-key": auth.secretServerKey,
219
+ };
220
+ return () => Promise.resolve(headers);
221
+ }
222
+ // Refresh-token auth: the admin access token for a project is simply the
223
+ // internal-project access token of a user who owns it.
224
+ const user = await getInternalUser(auth);
225
+ return async () => {
226
+ const { accessToken } = await user.currentSession.getTokens();
227
+ if (accessToken == null) {
228
+ throw new AuthError("Could not obtain an access token. Run `hexclave login` again.");
229
+ }
230
+ return {
231
+ "x-stack-access-type": "admin",
232
+ "x-stack-project-id": auth.projectId,
233
+ "x-stack-admin-access-token": accessToken,
234
+ };
235
+ };
236
+ }
237
+
238
+ // Returns `any` on purpose: this is a thin JSON transport; each call site
239
+ // immediately validates the specific fields it needs (and errors cleanly on
240
+ // unexpected shapes), so a structural type here would just duplicate that.
241
+ async function deployApiFetch(auth: ProjectAuth, getAuthHeaders: () => Promise<Record<string, string>>, apiPath: string, init: {
242
+ method: string,
243
+ jsonBody?: unknown,
244
+ }): Promise<any> {
245
+ const url = `${auth.apiUrl.replace(/\/$/, "")}/api/latest${apiPath}`;
246
+ const response = await fetch(url, {
247
+ method: init.method,
248
+ headers: {
249
+ ...await getAuthHeaders(),
250
+ ...(init.jsonBody !== undefined ? { "content-type": "application/json" } : {}),
251
+ },
252
+ body: init.jsonBody !== undefined ? JSON.stringify(init.jsonBody) : undefined,
253
+ });
254
+ const text = await response.text();
255
+ if (!response.ok) {
256
+ let message = text;
257
+ try {
258
+ const parsed = JSON.parse(text);
259
+ if (typeof parsed?.error === "string") message = parsed.error;
260
+ else if (typeof parsed?.error?.message === "string") message = parsed.error.message;
261
+ } catch {
262
+ // Response body isn't JSON; use it as-is.
263
+ }
264
+ throw new CliError(`Deploy request failed (${response.status} at ${init.method} ${apiPath}): ${message.slice(0, 1000)}`);
265
+ }
266
+ try {
267
+ return text === "" ? undefined : JSON.parse(text);
268
+ } catch {
269
+ throw new CliError(`Unexpected non-JSON response from the Hexclave API at ${init.method} ${apiPath}.`);
270
+ }
271
+ }
272
+
273
+ async function uploadSource(uploadUrl: string, contentType: string, bytes: Uint8Array): Promise<void> {
274
+ let parsedUrl: URL;
275
+ try {
276
+ parsedUrl = new URL(uploadUrl);
277
+ } catch {
278
+ throw new CliError("The Hexclave API returned an invalid object-storage upload URL.");
279
+ }
280
+ if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") {
281
+ throw new CliError("The Hexclave API returned an upload URL with an unsupported protocol.");
282
+ }
283
+ const response = await fetch(parsedUrl, {
284
+ method: "PUT",
285
+ headers: {
286
+ // This header is signed into the R2/S3 URL and must match exactly.
287
+ "content-type": contentType,
288
+ "content-length": bytes.length.toString(),
289
+ },
290
+ // Copy into a plain ArrayBuffer: TS's BodyInit doesn't accept
291
+ // Uint8Array<ArrayBufferLike>, and slicing also drops any surrounding
292
+ // bytes of a shared buffer.
293
+ body: new Uint8Array(bytes).slice().buffer,
294
+ });
295
+ if (!response.ok) {
296
+ const responseBody = await response.text();
297
+ throw new CliError(`Source upload failed (${response.status} from object storage): ${responseBody.slice(0, 1000)}`);
298
+ }
299
+ }
300
+
301
+ export function registerDeployCommand(program: Command) {
302
+ program
303
+ .command("deploy <service>")
304
+ .description("Deploy a service defined under `deployments-alpha.services` in your hexclave.config.ts. Uploads the service's source directory, waits for Vercel to accept the deployment, then prints the run id without waiting for the remote build to finish.")
305
+ .option("--config <path>", "Path to the config file (default: auto-discover hexclave.config.ts in the current directory)")
306
+ .option("--cloud-project-id <id>", "Hexclave project ID to deploy to (defaults to the HEXCLAVE_PROJECT_ID env var)")
307
+ .option("--secret <KEY=VALUE>", "Value for a secret env var of this deploy (repeatable). KEY is the secret key named by a `type: \"secret\"` env var in the config; the value is pushed to the deployment target and never persisted by Hexclave.", (value: string, previous: string[]) => [...previous, value], [] as string[])
308
+ .addHelpText("after", "\nAuthentication: uses HEXCLAVE_SECRET_SERVER_KEY if set (recommended for CI), otherwise your `hexclave login` session.")
309
+ .action(async (service: string, opts: DeployOptions) => {
310
+ const auth = resolveAuth(resolveProjectId(opts.cloudProjectId));
311
+ const secrets = parseSecretOptions(opts.secret);
312
+ const authHeaders = await buildAuthHeadersFactory(auth);
313
+
314
+ const configPath = resolveDeployConfigPath(opts.config, process.cwd());
315
+ let definition: ServiceDefinition | undefined;
316
+ let rootDirectory: string;
317
+ let ignoreRootDirectory: string;
318
+ if (configPath != null) {
319
+ // Config-as-code mode: the config file's definition (build config and
320
+ // env vars) governs this deploy and is upserted into the service
321
+ // definition by the backend.
322
+ const { createJiti } = await import("jiti");
323
+ const jiti = createJiti(import.meta.url);
324
+ let configModule: { config?: unknown };
325
+ try {
326
+ configModule = await jiti.import(configPath);
327
+ } catch (err: unknown) {
328
+ throw new CliError(`Failed to load config file ${configPath}: ${errorMessage(err)}`);
329
+ }
330
+ if (configModule.config == null) {
331
+ throw new CliError(`Config file ${configPath} must export a \`config\` object (e.g. \`export const config = { "deployments-alpha": { services: { ... } } }\`).`);
332
+ }
333
+ definition = extractServiceDefinition(configModule.config, service);
334
+ // Fail on missing/misspelled secrets BEFORE packaging and uploading.
335
+ // The backend re-checks this authoritatively.
336
+ assertSecretsMatchEnv(definition.env, secrets);
337
+ // The source directory is the service's rootDirectory, resolved
338
+ // relative to the config file (not the cwd) so deploys behave the same
339
+ // from anywhere in the repo.
340
+ ignoreRootDirectory = path.dirname(configPath);
341
+ rootDirectory = path.resolve(ignoreRootDirectory, definition.rootDirectory ?? ".");
342
+ } else {
343
+ // Dashboard mode: no config file, so the service's definition as
344
+ // stored on the backend governs the deploy (the service must already
345
+ // exist there). The root directory decides what gets packaged and is
346
+ // resolved against the cwd; the stored env definitions let us check
347
+ // the provided secrets before uploading.
348
+ const remoteService = await deployApiFetch(auth, authHeaders, `/deployments/services/${encodeURIComponent(service)}`, { method: "GET" });
349
+ const remoteRootDirectory = typeof remoteService?.root_directory === "string" && remoteService.root_directory !== "" ? remoteService.root_directory : ".";
350
+ const remoteEnv: Record<string, ServiceEnvVarConfig> = {};
351
+ for (const envVar of Array.isArray(remoteService?.env) ? remoteService.env : []) {
352
+ if (envVar?.type === "secret" && typeof envVar.secret_key === "string" && typeof envVar.key === "string") {
353
+ remoteEnv[envVar.key] = { type: "secret", key: envVar.secret_key };
354
+ }
355
+ }
356
+ assertSecretsMatchEnv(remoteEnv, secrets);
357
+ ignoreRootDirectory = process.cwd();
358
+ rootDirectory = path.resolve(process.cwd(), remoteRootDirectory);
359
+ console.error(`No config file found — using the service configuration stored in Hexclave (root directory: ${remoteRootDirectory}).`);
360
+ }
361
+ console.error(`Packaging ${rootDirectory}...`);
362
+ const packaged = packageSourceDirectory(rootDirectory, ignoreRootDirectory);
363
+ console.error(`Packaged ${packaged.fileCount} files (${(packaged.tarballGzipped.length / 1024).toFixed(1)} KiB compressed).`);
364
+
365
+ const upload = await deployApiFetch(auth, authHeaders, "/deployments/uploads", { method: "POST" });
366
+ if (typeof upload?.id !== "string" || typeof upload?.upload_url !== "string" || typeof upload?.content_type !== "string") {
367
+ throw new CliError("Unexpected response from the Hexclave API when creating the upload.");
368
+ }
369
+ if (typeof upload.max_bytes === "number" && packaged.tarballGzipped.length > upload.max_bytes) {
370
+ throw new CliError(`The packaged source is too large (${packaged.tarballGzipped.length} bytes, max ${upload.max_bytes}). Check your .gitignore/.vercelignore — build outputs and large assets shouldn't be uploaded.`);
371
+ }
372
+ console.error(`Uploading source...`);
373
+ await uploadSource(upload.upload_url, upload.content_type, packaged.tarballGzipped);
374
+
375
+ console.error(`Starting deployment of ${JSON.stringify(service)}...`);
376
+ // Without a config file, build_config and env are omitted entirely: the
377
+ // backend then uses the stored definition field-by-field. WITH a config
378
+ // file, absent build fields are sent as null ("unset") — the file is the
379
+ // whole truth, so deleting a field from it must actually remove the
380
+ // stored value instead of silently keeping it forever.
381
+ const deployResponse = await deployApiFetch(auth, authHeaders, `/deployments/services/${encodeURIComponent(service)}/deploy`, {
382
+ method: "POST",
383
+ jsonBody: {
384
+ upload_id: upload.id,
385
+ ...(definition !== undefined ? {
386
+ build_config: {
387
+ framework: definition.framework ?? null,
388
+ install_command: definition.installCommand ?? null,
389
+ build_command: definition.buildCommand ?? null,
390
+ output_directory: definition.outputDirectory ?? null,
391
+ root_directory: definition.rootDirectory ?? null,
392
+ },
393
+ env: definition.env,
394
+ } : {}),
395
+ ...(secrets.size > 0 ? { secrets: Object.fromEntries(secrets) } : {}),
396
+ },
397
+ });
398
+ if (typeof deployResponse?.run_id !== "string") {
399
+ throw new CliError("Unexpected response from the Hexclave API when starting the deployment.");
400
+ }
401
+
402
+ // Source preparation is synchronous, but the remote build continues
403
+ // after this returns. CI therefore does NOT fail on a later build
404
+ // failure (a waiting/streaming flag is planned post-MVP).
405
+ console.log(JSON.stringify({ runId: deployResponse.run_id }, null, 2));
406
+ });
407
+ }
@@ -9,6 +9,7 @@ import { DASHBOARD_SERVER_RELATIVE_PATH, dashboardDirOverride, fetchDashboardMan
9
9
  import { devEnvStatePath, ensureLocalDashboardSecret, readDevEnvState, recordLocalDashboardProcess } from "../lib/dev-env-state.js";
10
10
  import { CliError, errorMessage } from "../lib/errors.js";
11
11
  import { DASHBOARD_PORT_ENV_VAR, dashboardPort, dashboardRequest, dashboardUrl, createRemoteDevelopmentEnvironmentSession, type DashboardSessionResponse } from "../lib/local-dashboard.js";
12
+ import { startProgress } from "../lib/progress.js";
12
13
 
13
14
  type ChildCommand = {
14
15
  command: string,
@@ -69,10 +70,6 @@ const REQUIRED_DASHBOARD_RUNTIME_ENV_VARS = new Set([
69
70
  DASHBOARD_PORT_ENV_VAR,
70
71
  ]);
71
72
 
72
- type ProgressLogger = {
73
- stop: (finalMessage?: string) => void,
74
- };
75
-
76
73
  type DashboardSessionState = {
77
74
  session: DashboardSessionResponse,
78
75
  dashboardReachableSinceMs: number,
@@ -150,37 +147,6 @@ function maybeOpenOnboardingPage(session: DashboardSessionResponse, port: number
150
147
  }
151
148
  }
152
149
 
153
- function startProgressLog(message: string): ProgressLogger {
154
- if (!process.stderr.isTTY) {
155
- logDev(`${message}...`);
156
- return {
157
- stop() {
158
- logDev(`${message}... done!`);
159
- },
160
- };
161
- }
162
-
163
- let dotCount = 0;
164
- let stopped = false;
165
- const render = () => {
166
- process.stderr.write(`\r\x1b[2K${LOG_PREFIX}${message}${".".repeat(dotCount)}`);
167
- dotCount = (dotCount + 1) % 4;
168
- };
169
- render();
170
- const timer = setInterval(render, 400);
171
- timer.unref();
172
-
173
- return {
174
- stop() {
175
- if (stopped) return;
176
- stopped = true;
177
- clearInterval(timer);
178
- process.stderr.write("\r\x1b[2K");
179
- logDev(`${message}... done!`);
180
- },
181
- };
182
- }
183
-
184
150
  function dashboardRuntimeRoot(port: number): string {
185
151
  return join(dirname(devEnvStatePath()), `${DASHBOARD_RUNTIME_DIR_NAME}-${port}`);
186
152
  }
@@ -430,6 +396,9 @@ async function startDashboardIfNeeded(options: { apiBaseUrl: string, secret: str
430
396
  // or falls back to cache.
431
397
  const dashboardOverride = dashboardDirOverride();
432
398
  const skipReleaseLookup = devDashboardCommand != null || dashboardOverride != null;
399
+ if (!skipReleaseLookup) {
400
+ logDev("Checking for Hexclave dashboard updates...");
401
+ }
433
402
  const manifest: DashboardManifest | null = skipReleaseLookup ? null : await fetchDashboardManifest();
434
403
  const latestVersion = manifest?.version;
435
404
 
@@ -461,9 +430,11 @@ async function startDashboardIfNeeded(options: { apiBaseUrl: string, secret: str
461
430
 
462
431
  // Download (or reuse a cached copy of) the dashboard build to launch. Not
463
432
  // needed when a custom dev dashboard command runs the dashboard itself.
464
- const release = devDashboardCommand == null ? await resolveDashboardRuntime({ manifest }) : null;
433
+ const release = devDashboardCommand == null
434
+ ? await resolveDashboardRuntime({ manifest, onProgress: (message) => logDev(`${message}...`) })
435
+ : null;
465
436
 
466
- const progress = startProgressLog(`Hexclave dashboard not found on port ${options.port}. Starting now`);
437
+ const progress = startProgress(`Hexclave dashboard not found on port ${options.port}. Starting now`, { prefix: LOG_PREFIX });
467
438
  const dashboardEnv = {
468
439
  ...process.env,
469
440
  NODE_ENV: devDashboardCommand == null ? "production" : "development",
@@ -16,6 +16,7 @@ import { createProjectInteractively } from "../lib/create-project.js";
16
16
  import { AuthError, CliError } from "../lib/errors.js";
17
17
  import { createInitPrompt } from "../lib/init-prompt.js";
18
18
  import { isNonInteractiveEnv } from "../lib/interactive.js";
19
+ import { withProgress } from "../lib/progress.js";
19
20
 
20
21
  const VALID_INIT_MODES = ["create", "create-cloud", "link-config", "link-cloud"] as const;
21
22
  type InitMode = typeof VALID_INIT_MODES[number];
@@ -223,12 +224,14 @@ async function writeProjectKeysToEnv(
223
224
  project: { id: string, app: { createInternalApiKey: (opts: { description: string, expiresAt: Date, hasPublishableClientKey: boolean, hasSecretServerKey: boolean, hasSuperSecretAdminKey: boolean }) => Promise<{ publishableClientKey?: string | null, secretServerKey?: string | null }> } },
224
225
  outputDir: string,
225
226
  ) {
226
- const apiKey = await project.app.createInternalApiKey({
227
- description: "Created by CLI init script",
228
- expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365 * 200), // 200 years
229
- hasPublishableClientKey: true,
230
- hasSecretServerKey: true,
231
- hasSuperSecretAdminKey: false,
227
+ const apiKey = await withProgress("Creating project keys", async () => {
228
+ return await project.app.createInternalApiKey({
229
+ description: "Created by CLI init script",
230
+ expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365 * 200), // 200 years
231
+ hasPublishableClientKey: true,
232
+ hasSecretServerKey: true,
233
+ hasSuperSecretAdminKey: false,
234
+ });
232
235
  });
233
236
 
234
237
  const publishableClientKey = apiKey.publishableClientKey ?? throwErr("createInternalApiKey returned no publishableClientKey despite hasPublishableClientKey=true");
@@ -272,7 +275,7 @@ async function writeProjectKeysToEnv(
272
275
 
273
276
  async function handleCreateCloud(_flags: Record<string, unknown>, opts: InitOptions, outputDir: string): Promise<{ configPath?: string, projectId?: string }> {
274
277
  const sessionAuth = await ensureLoggedInSession();
275
- const user = await getInternalUser(sessionAuth);
278
+ const user = await withProgress("Loading account", async () => await getInternalUser(sessionAuth));
276
279
 
277
280
  const { dashboardUrl } = resolveLoginConfig();
278
281
  const newProject = await createProjectInteractively(user, {
@@ -288,8 +291,12 @@ async function handleCreateCloud(_flags: Record<string, unknown>, opts: InitOpti
288
291
 
289
292
  async function handleLinkFromCloud(_flags: Record<string, unknown>, opts: InitOptions, outputDir: string): Promise<{ configPath?: string, projectId?: string }> {
290
293
  const sessionAuth = await ensureLoggedInSession();
291
- const user = await getInternalUser(sessionAuth);
292
- let projects = await user.listOwnedProjects();
294
+ const { user, ownedProjects } = await withProgress("Loading projects", async () => {
295
+ const user = await getInternalUser(sessionAuth);
296
+ const ownedProjects = await user.listOwnedProjects();
297
+ return { user, ownedProjects };
298
+ });
299
+ let projects = ownedProjects;
293
300
  let autoCreatedProjectId: string | null = null;
294
301
 
295
302
  if (projects.length === 0) {
@@ -3,6 +3,7 @@ import { getInternalUser } from "../lib/app.js";
3
3
  import { resolveLoginConfig, resolveSessionAuth } from "../lib/auth.js";
4
4
  import { createProjectInteractively } from "../lib/create-project.js";
5
5
  import { CliError } from "../lib/errors.js";
6
+ import { withProgress } from "../lib/progress.js";
6
7
 
7
8
  export type ProjectTarget = "cloud" | "local";
8
9
 
@@ -60,8 +61,10 @@ export function registerProjectCommand(program: Command) {
60
61
  const sources = resolveProjectListSources(opts);
61
62
  const results: ProjectListEntry[] = [];
62
63
  const auth = resolveSessionAuth();
63
- const user = await getInternalUser(auth);
64
- const ownedProjects = await user.listOwnedProjects();
64
+ const ownedProjects = await withProgress("Loading projects", async () => {
65
+ const user = await getInternalUser(auth);
66
+ return await user.listOwnedProjects();
67
+ });
65
68
  for (const p of ownedProjects) {
66
69
  const target: ProjectTarget = p.isDevelopmentEnvironment ? "local" : "cloud";
67
70
  if ((target === "cloud" && sources.cloud) || (target === "local" && sources.local)) {
@@ -91,7 +94,7 @@ export function registerProjectCommand(program: Command) {
91
94
  import("../lib/create-project.js"),
92
95
  ]);
93
96
  const auth = resolveSessionAuth();
94
- const user = await getInternalUser(auth);
97
+ const user = await withProgress("Loading account", async () => await getInternalUser(auth));
95
98
  const { dashboardUrl } = resolveLoginConfig();
96
99
 
97
100
  const newProject = await createProjectInteractively(user, {
@@ -1,6 +1,7 @@
1
1
  import { Command } from "commander";
2
2
  import { getInternalUser } from "../lib/app.js";
3
3
  import { resolveSessionAuth } from "../lib/auth.js";
4
+ import { withProgress } from "../lib/progress.js";
4
5
 
5
6
  export function registerWhoamiCommand(program: Command) {
6
7
  program
@@ -9,8 +10,11 @@ export function registerWhoamiCommand(program: Command) {
9
10
  .action(async () => {
10
11
  const flags = program.opts();
11
12
  const auth = resolveSessionAuth();
12
- const user = await getInternalUser(auth);
13
- const teams = await user.listTeams();
13
+ const { user, teams } = await withProgress("Loading account", async () => {
14
+ const user = await getInternalUser(auth);
15
+ const teams = await user.listTeams();
16
+ return { user, teams };
17
+ });
14
18
 
15
19
  const result = {
16
20
  id: user.id,
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ import { cliVersion } from "./lib/own-package.js";
8
8
  import { AuthError, CliError } from "./lib/errors.js";
9
9
  import { registerLoginCommand } from "./commands/login.js";
10
10
  import { registerLogoutCommand } from "./commands/logout.js";
11
+ import { registerDeployCommand } from "./commands/deploy.js";
11
12
  import { registerExecCommand } from "./commands/exec.js";
12
13
  import { registerConfigCommand } from "./commands/config-file.js";
13
14
  import { registerInitCommand } from "./commands/init.js";
@@ -28,6 +29,7 @@ program
28
29
  registerLoginCommand(program);
29
30
  registerLogoutCommand(program);
30
31
  registerExecCommand(program);
32
+ registerDeployCommand(program);
31
33
  registerConfigCommand(program);
32
34
  registerInitCommand(program);
33
35
  registerProjectCommand(program);
@@ -51,9 +53,11 @@ async function main() {
51
53
  console.error(`Error: ${err.message}`);
52
54
  process.exit(1);
53
55
  }
56
+ // Report the failure before flushing telemetry; the flush can consume its
57
+ // full timeout, and users should not stare at a silent CLI after it failed.
58
+ console.error(err);
54
59
  captureError("stack-cli-fatal", err);
55
60
  await Sentry.flush(2000);
56
- console.error(err);
57
61
  process.exit(1);
58
62
  }
59
63
  }
@@ -3,6 +3,7 @@ import type { CurrentInternalUser } from "@hexclave/js";
3
3
  import { DEFAULT_DASHBOARD_URL } from "./auth.js";
4
4
  import { CliError } from "./errors.js";
5
5
  import { isNonInteractiveEnv } from "./interactive.js";
6
+ import { withProgress } from "./progress.js";
6
7
 
7
8
  type CreateProjectOptions = {
8
9
  displayName?: string,
@@ -26,14 +27,16 @@ export async function createProjectInteractively(
26
27
  })).trim();
27
28
  }
28
29
 
29
- const teams = await user.listTeams();
30
- if (teams.length === 0) {
31
- const dashboardUrl = opts.dashboardUrl ?? DEFAULT_DASHBOARD_URL;
32
- throw new CliError(`No teams found on your account. Create a team at ${dashboardUrl} first.`);
33
- }
30
+ return await withProgress("Creating project", async () => {
31
+ const teams = await user.listTeams();
32
+ if (teams.length === 0) {
33
+ const dashboardUrl = opts.dashboardUrl ?? DEFAULT_DASHBOARD_URL;
34
+ throw new CliError(`No teams found on your account. Create a team at ${dashboardUrl} first.`);
35
+ }
34
36
 
35
- return await user.createProject({
36
- displayName,
37
- teamId: teams[0].id,
37
+ return await user.createProject({
38
+ displayName,
39
+ teamId: teams[0].id,
40
+ });
38
41
  });
39
42
  }