@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.
- package/dist/index.js +647 -138
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/src/commands/config-file.ts +37 -29
- package/src/commands/deploy.test.ts +221 -0
- package/src/commands/deploy.ts +407 -0
- package/src/commands/dev.ts +8 -37
- package/src/commands/init.ts +16 -9
- package/src/commands/project.ts +6 -3
- package/src/commands/whoami.ts +6 -2
- package/src/index.ts +5 -1
- package/src/lib/create-project.ts +11 -8
- package/src/lib/dashboard-release.ts +9 -3
- package/src/lib/ignore-rules.test.ts +84 -0
- package/src/lib/ignore-rules.ts +133 -0
- package/src/lib/progress.test.ts +73 -0
- package/src/lib/progress.ts +82 -0
- package/src/lib/source-packaging.test.ts +129 -0
- package/src/lib/source-packaging.ts +153 -0
package/dist/index.js
CHANGED
|
@@ -2,19 +2,23 @@
|
|
|
2
2
|
import { n as CliError, r as errorMessage$1, t as AuthError } from "./errors-CyPbl0LV.js";
|
|
3
3
|
import * as Sentry from "@sentry/node";
|
|
4
4
|
import "@hexclave/shared/dist/utils/env";
|
|
5
|
-
import { captureError, registerErrorSink, throwErr } from "@hexclave/shared/dist/utils/errors";
|
|
5
|
+
import { StatusError, captureError, registerErrorSink, throwErr } from "@hexclave/shared/dist/utils/errors";
|
|
6
6
|
import { ignoreUnhandledRejection } from "@hexclave/shared/dist/utils/promises";
|
|
7
7
|
import { sentryBaseConfig } from "@hexclave/shared/dist/utils/sentry";
|
|
8
8
|
import { nicify } from "@hexclave/shared/dist/utils/strings";
|
|
9
9
|
import * as os from "os";
|
|
10
10
|
import { homedir } from "os";
|
|
11
|
-
import * as fs from "fs";
|
|
11
|
+
import * as fs$1 from "fs";
|
|
12
12
|
import { chmodSync, closeSync, cpSync, createReadStream, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "fs";
|
|
13
|
-
import * as path from "path";
|
|
13
|
+
import * as path$1 from "path";
|
|
14
14
|
import { dirname, join, resolve } from "path";
|
|
15
15
|
import { fileURLToPath } from "url";
|
|
16
16
|
import { Command } from "commander";
|
|
17
17
|
import { StackClientApp } from "@hexclave/js";
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { createTar } from "@hexclave/shared/dist/utils/tar";
|
|
21
|
+
import { gzipSync } from "node:zlib";
|
|
18
22
|
import { hexclaveDevEnvStatePath } from "@hexclave/shared/dist/utils/dev-env-state-path";
|
|
19
23
|
import { createHash, randomBytes } from "crypto";
|
|
20
24
|
import { replaceConfigObject } from "@hexclave/shared-backend";
|
|
@@ -126,25 +130,25 @@ function initSentry() {
|
|
|
126
130
|
//#endregion
|
|
127
131
|
//#region src/lib/config.ts
|
|
128
132
|
const ENV_CONFIG_PATH = process.env.STACK_CLI_CONFIG_PATH;
|
|
129
|
-
const HEXCLAVE_CONFIG_PATH = path.join(os.homedir(), ".config", "hexclave", "credentials.json");
|
|
130
|
-
const LEGACY_CONFIG_PATH = path.join(os.homedir(), ".config", "stack-auth", "credentials.json");
|
|
133
|
+
const HEXCLAVE_CONFIG_PATH = path$1.join(os.homedir(), ".config", "hexclave", "credentials.json");
|
|
134
|
+
const LEGACY_CONFIG_PATH = path$1.join(os.homedir(), ".config", "stack-auth", "credentials.json");
|
|
131
135
|
const WRITE_CONFIG_PATH = ENV_CONFIG_PATH ?? HEXCLAVE_CONFIG_PATH;
|
|
132
136
|
function resolveReadConfigPath() {
|
|
133
137
|
if (ENV_CONFIG_PATH != null) return ENV_CONFIG_PATH;
|
|
134
|
-
if (fs.existsSync(HEXCLAVE_CONFIG_PATH)) return HEXCLAVE_CONFIG_PATH;
|
|
135
|
-
if (fs.existsSync(LEGACY_CONFIG_PATH)) return LEGACY_CONFIG_PATH;
|
|
138
|
+
if (fs$1.existsSync(HEXCLAVE_CONFIG_PATH)) return HEXCLAVE_CONFIG_PATH;
|
|
139
|
+
if (fs$1.existsSync(LEGACY_CONFIG_PATH)) return LEGACY_CONFIG_PATH;
|
|
136
140
|
return HEXCLAVE_CONFIG_PATH;
|
|
137
141
|
}
|
|
138
142
|
function readConfigJson() {
|
|
139
143
|
try {
|
|
140
|
-
return JSON.parse(fs.readFileSync(resolveReadConfigPath(), "utf-8"));
|
|
144
|
+
return JSON.parse(fs$1.readFileSync(resolveReadConfigPath(), "utf-8"));
|
|
141
145
|
} catch {
|
|
142
146
|
return {};
|
|
143
147
|
}
|
|
144
148
|
}
|
|
145
149
|
function writeConfigJson(data) {
|
|
146
|
-
fs.mkdirSync(path.dirname(WRITE_CONFIG_PATH), { recursive: true });
|
|
147
|
-
fs.writeFileSync(WRITE_CONFIG_PATH, JSON.stringify(data, null, 2) + "\n", { mode: 384 });
|
|
150
|
+
fs$1.mkdirSync(path$1.dirname(WRITE_CONFIG_PATH), { recursive: true });
|
|
151
|
+
fs$1.writeFileSync(WRITE_CONFIG_PATH, JSON.stringify(data, null, 2) + "\n", { mode: 384 });
|
|
148
152
|
}
|
|
149
153
|
function readConfigValue(key) {
|
|
150
154
|
return readConfigJson()[key];
|
|
@@ -277,6 +281,453 @@ function registerLogoutCommand(program) {
|
|
|
277
281
|
});
|
|
278
282
|
}
|
|
279
283
|
//#endregion
|
|
284
|
+
//#region src/lib/app.ts
|
|
285
|
+
var app_exports = /* @__PURE__ */ __exportAll({
|
|
286
|
+
getAdminProject: () => getAdminProject,
|
|
287
|
+
getInternalApp: () => getInternalApp,
|
|
288
|
+
getInternalUser: () => getInternalUser
|
|
289
|
+
});
|
|
290
|
+
function getInternalApp(auth) {
|
|
291
|
+
return new StackClientApp({
|
|
292
|
+
projectId: "internal",
|
|
293
|
+
publishableClientKey: auth.publishableClientKey,
|
|
294
|
+
baseUrl: auth.apiUrl,
|
|
295
|
+
tokenStore: {
|
|
296
|
+
accessToken: "",
|
|
297
|
+
refreshToken: auth.refreshToken
|
|
298
|
+
},
|
|
299
|
+
noAutomaticPrefetch: true
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
async function getInternalUser(auth) {
|
|
303
|
+
return await getInternalApp(auth).getUser({ or: "throw" });
|
|
304
|
+
}
|
|
305
|
+
async function getAdminProject(auth) {
|
|
306
|
+
const project = (await (await getInternalUser(auth)).listOwnedProjects()).find((p) => p.id === auth.projectId);
|
|
307
|
+
if (!project) throw new AuthError(`Project '${auth.projectId}' not found. Make sure you own this project.`);
|
|
308
|
+
return project;
|
|
309
|
+
}
|
|
310
|
+
//#endregion
|
|
311
|
+
//#region src/lib/ignore-rules.ts
|
|
312
|
+
function escapeRegExpChar(char) {
|
|
313
|
+
return /[.*+?^${}()|[\]\\]/.test(char) ? `\\${char}` : char;
|
|
314
|
+
}
|
|
315
|
+
function globSegmentToRegex(segment) {
|
|
316
|
+
let result = "";
|
|
317
|
+
for (let i = 0; i < segment.length; i++) {
|
|
318
|
+
const char = segment[i];
|
|
319
|
+
if (char === "\\" && i + 1 < segment.length) {
|
|
320
|
+
result += escapeRegExpChar(segment[i + 1]);
|
|
321
|
+
i++;
|
|
322
|
+
} else if (char === "*") result += "[^/]*";
|
|
323
|
+
else if (char === "?") result += "[^/]";
|
|
324
|
+
else if (char === "[") {
|
|
325
|
+
let j = i + 1;
|
|
326
|
+
let negatedClass = false;
|
|
327
|
+
if (segment[j] === "!" || segment[j] === "^") {
|
|
328
|
+
negatedClass = true;
|
|
329
|
+
j++;
|
|
330
|
+
}
|
|
331
|
+
let classEnd = segment[j] === "]" ? segment.indexOf("]", j + 1) : segment.indexOf("]", j);
|
|
332
|
+
if (classEnd === -1) result += escapeRegExpChar(char);
|
|
333
|
+
else {
|
|
334
|
+
const safeInner = [...segment.slice(j, classEnd)].map((c) => c === "-" ? "-" : escapeRegExpChar(c)).join("");
|
|
335
|
+
result += `[${negatedClass ? "^" : ""}${safeInner}]`;
|
|
336
|
+
i = classEnd;
|
|
337
|
+
}
|
|
338
|
+
} else result += escapeRegExpChar(char);
|
|
339
|
+
}
|
|
340
|
+
return result;
|
|
341
|
+
}
|
|
342
|
+
function parseIgnorePattern(line) {
|
|
343
|
+
let pattern = line.replace(/\r$/, "");
|
|
344
|
+
pattern = pattern.replace(/(?<!\\) +$/, "");
|
|
345
|
+
if (pattern === "" || pattern.startsWith("#")) return;
|
|
346
|
+
let negated = false;
|
|
347
|
+
if (pattern.startsWith("!")) {
|
|
348
|
+
negated = true;
|
|
349
|
+
pattern = pattern.slice(1);
|
|
350
|
+
}
|
|
351
|
+
let dirOnly = false;
|
|
352
|
+
if (pattern.endsWith("/")) {
|
|
353
|
+
dirOnly = true;
|
|
354
|
+
pattern = pattern.slice(0, -1);
|
|
355
|
+
}
|
|
356
|
+
if (pattern === "") return;
|
|
357
|
+
const anchored = pattern.includes("/");
|
|
358
|
+
if (pattern.startsWith("/")) pattern = pattern.slice(1);
|
|
359
|
+
const segments = pattern.split("/");
|
|
360
|
+
const regexParts = [];
|
|
361
|
+
for (let i = 0; i < segments.length; i++) {
|
|
362
|
+
const segment = segments[i];
|
|
363
|
+
if (segment === "**") regexParts.push(i === segments.length - 1 ? ".*" : "(?:[^/]+/)*");
|
|
364
|
+
else regexParts.push(globSegmentToRegex(segment) + (i === segments.length - 1 ? "" : "/"));
|
|
365
|
+
}
|
|
366
|
+
const body = regexParts.join("");
|
|
367
|
+
return {
|
|
368
|
+
negated,
|
|
369
|
+
dirOnly,
|
|
370
|
+
regex: new RegExp(`^${anchored ? "" : "(?:[^/]+/)*"}${body}$`)
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
function parseIgnoreFile(content) {
|
|
374
|
+
return content.split("\n").map(parseIgnorePattern).filter((rule) => rule !== void 0);
|
|
375
|
+
}
|
|
376
|
+
//#endregion
|
|
377
|
+
//#region src/lib/source-packaging.ts
|
|
378
|
+
const IGNORE_FILE_NAMES = [".gitignore", ".vercelignore"];
|
|
379
|
+
const ALWAYS_EXCLUDED_DIR_NAMES = /* @__PURE__ */ new Set(["node_modules", ".git"]);
|
|
380
|
+
function relativeToScope(scope, absolutePath) {
|
|
381
|
+
const relativePath = path.relative(scope.baseDirectory, absolutePath);
|
|
382
|
+
if (relativePath === "" || relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) return;
|
|
383
|
+
return relativePath.split(path.sep).join("/");
|
|
384
|
+
}
|
|
385
|
+
function isIgnored(scopes, absolutePath, isDirectory) {
|
|
386
|
+
let ignored = false;
|
|
387
|
+
for (const scope of scopes) {
|
|
388
|
+
const scopedPath = relativeToScope(scope, absolutePath);
|
|
389
|
+
if (scopedPath === void 0) continue;
|
|
390
|
+
for (const rule of scope.rules) {
|
|
391
|
+
if (rule.dirOnly && !isDirectory) continue;
|
|
392
|
+
if (rule.regex.test(scopedPath)) ignored = !rule.negated;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return ignored;
|
|
396
|
+
}
|
|
397
|
+
function readIgnoreScopes(directory) {
|
|
398
|
+
const scopes = [];
|
|
399
|
+
for (const ignoreFileName of IGNORE_FILE_NAMES) {
|
|
400
|
+
const ignoreFilePath = path.join(directory, ignoreFileName);
|
|
401
|
+
if (fs.existsSync(ignoreFilePath) && fs.statSync(ignoreFilePath).isFile()) scopes.push({
|
|
402
|
+
baseDirectory: directory,
|
|
403
|
+
rules: parseIgnoreFile(fs.readFileSync(ignoreFilePath, "utf-8"))
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
return scopes;
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Packages `rootDirectory`. `ignoreRootDirectory` is the outermost directory
|
|
410
|
+
* whose ignore files apply; deploy passes the config directory so a service in
|
|
411
|
+
* a monorepo subdirectory inherits the repository-level .gitignore and
|
|
412
|
+
* .vercelignore rules.
|
|
413
|
+
*/
|
|
414
|
+
function packageSourceDirectory(rootDirectory, ignoreRootDirectory = rootDirectory) {
|
|
415
|
+
const absoluteRootDirectory = path.resolve(rootDirectory);
|
|
416
|
+
const absoluteIgnoreRootDirectory = path.resolve(ignoreRootDirectory);
|
|
417
|
+
const rootStat = fs.statSync(absoluteRootDirectory, { throwIfNoEntry: false });
|
|
418
|
+
if (rootStat == null || !rootStat.isDirectory()) throw new CliError(`Source directory not found: ${absoluteRootDirectory}`);
|
|
419
|
+
const relativeRootFromIgnoreRoot = path.relative(absoluteIgnoreRootDirectory, absoluteRootDirectory);
|
|
420
|
+
if (relativeRootFromIgnoreRoot === ".." || relativeRootFromIgnoreRoot.startsWith(`..${path.sep}`) || path.isAbsolute(relativeRootFromIgnoreRoot)) throw new CliError(`Source directory ${absoluteRootDirectory} must be inside the config directory ${absoluteIgnoreRootDirectory}.`);
|
|
421
|
+
const entries = [];
|
|
422
|
+
let totalBytes = 0;
|
|
423
|
+
const walk = (absoluteDir, relativeDir, parentScopes) => {
|
|
424
|
+
const scopes = [...parentScopes, ...readIgnoreScopes(absoluteDir)];
|
|
425
|
+
const dirents = fs.readdirSync(absoluteDir, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
426
|
+
for (const dirent of dirents) {
|
|
427
|
+
const relativePath = relativeDir === "" ? dirent.name : `${relativeDir}/${dirent.name}`;
|
|
428
|
+
const absolutePath = path.join(absoluteDir, dirent.name);
|
|
429
|
+
if (dirent.isSymbolicLink()) continue;
|
|
430
|
+
if (dirent.isDirectory()) {
|
|
431
|
+
if (ALWAYS_EXCLUDED_DIR_NAMES.has(dirent.name)) continue;
|
|
432
|
+
if (isIgnored(scopes, absolutePath, true)) continue;
|
|
433
|
+
walk(absolutePath, relativePath, scopes);
|
|
434
|
+
} else if (dirent.isFile()) {
|
|
435
|
+
if (isIgnored(scopes, absolutePath, false)) continue;
|
|
436
|
+
const data = fs.readFileSync(absolutePath);
|
|
437
|
+
totalBytes += data.length;
|
|
438
|
+
entries.push({
|
|
439
|
+
path: relativePath,
|
|
440
|
+
data
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
const ancestorScopes = [];
|
|
446
|
+
const relativeSegments = relativeRootFromIgnoreRoot === "" ? [] : relativeRootFromIgnoreRoot.split(path.sep);
|
|
447
|
+
let currentDirectory = absoluteIgnoreRootDirectory;
|
|
448
|
+
for (const segment of relativeSegments) {
|
|
449
|
+
ancestorScopes.push(...readIgnoreScopes(currentDirectory));
|
|
450
|
+
const childDirectory = path.join(currentDirectory, segment);
|
|
451
|
+
if (isIgnored(ancestorScopes, childDirectory, true)) throw new CliError(`No files to deploy in ${absoluteRootDirectory} (the source directory is ignored by a parent .gitignore or .vercelignore).`);
|
|
452
|
+
currentDirectory = childDirectory;
|
|
453
|
+
}
|
|
454
|
+
walk(absoluteRootDirectory, "", ancestorScopes);
|
|
455
|
+
if (entries.length === 0) throw new CliError(`No files to deploy in ${absoluteRootDirectory} (everything is ignored or the directory is empty).`);
|
|
456
|
+
let tarball;
|
|
457
|
+
try {
|
|
458
|
+
tarball = createTar(entries);
|
|
459
|
+
} catch (error) {
|
|
460
|
+
if (error instanceof StatusError) throw new CliError(error.message);
|
|
461
|
+
throw error;
|
|
462
|
+
}
|
|
463
|
+
return {
|
|
464
|
+
tarballGzipped: gzipSync(tarball),
|
|
465
|
+
fileCount: entries.length,
|
|
466
|
+
totalBytes
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
//#endregion
|
|
470
|
+
//#region src/commands/deploy.ts
|
|
471
|
+
const CONFIG_FILE_CANDIDATES = [
|
|
472
|
+
"hexclave.config.ts",
|
|
473
|
+
"hexclave.config.js",
|
|
474
|
+
"stack.config.ts",
|
|
475
|
+
"stack.config.js"
|
|
476
|
+
];
|
|
477
|
+
const SECRET_KEY_REGEX = /^[a-zA-Z0-9_-]+$/;
|
|
478
|
+
const ENV_VAR_KEY_REGEX = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
479
|
+
const CONNECTION_VALUE_REGEX = /^[a-zA-Z0-9_-]+\.[A-Za-z0-9_]+$/;
|
|
480
|
+
/**
|
|
481
|
+
* Parses repeated `--secret KEY=VALUE` options. Values may contain `=` (only
|
|
482
|
+
* the first one separates key from value). Keys are the secret keys named by
|
|
483
|
+
* `type: "secret"` env vars in the config; values are never persisted by
|
|
484
|
+
* Hexclave. Exported for unit tests.
|
|
485
|
+
*/
|
|
486
|
+
function parseSecretOptions(secretOptions) {
|
|
487
|
+
const secrets = /* @__PURE__ */ new Map();
|
|
488
|
+
for (const option of secretOptions) {
|
|
489
|
+
const separatorIndex = option.indexOf("=");
|
|
490
|
+
if (separatorIndex <= 0) throw new CliError(`Invalid --secret value ${JSON.stringify(option)}. Expected the KEY=VALUE format.`);
|
|
491
|
+
const key = option.slice(0, separatorIndex);
|
|
492
|
+
const value = option.slice(separatorIndex + 1);
|
|
493
|
+
if (!SECRET_KEY_REGEX.test(key)) throw new CliError(`Invalid --secret key ${JSON.stringify(key)}. Secret keys must contain only letters, numbers, underscores, and hyphens.`);
|
|
494
|
+
if (secrets.has(key)) throw new CliError(`Duplicate --secret key ${JSON.stringify(key)}.`);
|
|
495
|
+
secrets.set(key, value);
|
|
496
|
+
}
|
|
497
|
+
return secrets;
|
|
498
|
+
}
|
|
499
|
+
const DEPLOYMENTS_CONFIG_SECTION = "deployments-alpha";
|
|
500
|
+
/**
|
|
501
|
+
* Extracts one service's definition from a loaded config module. The config
|
|
502
|
+
* file holds it under `deployments-alpha.services.<name>` — the exact shape that
|
|
503
|
+
* `hexclave config push` pushes as branch config. Exported for unit tests.
|
|
504
|
+
*/
|
|
505
|
+
function extractServiceDefinition(config, serviceName) {
|
|
506
|
+
if (config == null || typeof config !== "object") throw new CliError("Config file must export a plain `config` object.");
|
|
507
|
+
const section = config[DEPLOYMENTS_CONFIG_SECTION];
|
|
508
|
+
const services = section != null && typeof section === "object" ? section.services : void 0;
|
|
509
|
+
if (services == null || typeof services !== "object") 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 };`);
|
|
510
|
+
const service = services[serviceName];
|
|
511
|
+
if (service == null || typeof service !== "object") {
|
|
512
|
+
const available = Object.keys(services);
|
|
513
|
+
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(", ")}` : ""}`);
|
|
514
|
+
}
|
|
515
|
+
const record = service;
|
|
516
|
+
if (record.type !== "vercel") throw new CliError(record.type === void 0 ? `\`${DEPLOYMENTS_CONFIG_SECTION}.services.${serviceName}\` has no \`type\`. Add \`type: "vercel"\`.` : `\`${DEPLOYMENTS_CONFIG_SECTION}.services.${serviceName}.type\` must be "vercel" (got ${JSON.stringify(record.type)}).`);
|
|
517
|
+
const readString = (key) => {
|
|
518
|
+
const value = record[key];
|
|
519
|
+
if (value === void 0) return void 0;
|
|
520
|
+
if (typeof value !== "string") throw new CliError(`\`${DEPLOYMENTS_CONFIG_SECTION}.services.${serviceName}.${key}\` must be a string.`);
|
|
521
|
+
return value;
|
|
522
|
+
};
|
|
523
|
+
return {
|
|
524
|
+
framework: readString("framework"),
|
|
525
|
+
installCommand: readString("installCommand"),
|
|
526
|
+
buildCommand: readString("buildCommand"),
|
|
527
|
+
outputDirectory: readString("outputDirectory"),
|
|
528
|
+
rootDirectory: readString("rootDirectory"),
|
|
529
|
+
env: extractServiceEnv(record.env, serviceName)
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
function extractServiceEnv(env, serviceName) {
|
|
533
|
+
if (env === void 0) return {};
|
|
534
|
+
if (env == null || typeof env !== "object" || Array.isArray(env)) throw new CliError(`\`${DEPLOYMENTS_CONFIG_SECTION}.services.${serviceName}.env\` must be a record of env var entries, e.g. { MY_VAR: { value: "some-value" } }.`);
|
|
535
|
+
const result = {};
|
|
536
|
+
for (const [envVarKey, entryValue] of Object.entries(env)) {
|
|
537
|
+
const path = `\`${DEPLOYMENTS_CONFIG_SECTION}.services.${serviceName}.env.${envVarKey}\``;
|
|
538
|
+
if (!ENV_VAR_KEY_REGEX.test(envVarKey)) 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.`);
|
|
539
|
+
if (entryValue == null || typeof entryValue !== "object") throw new CliError(`${path} must be an object like { value: "..." }, { type: "secret", key: "..." }, or { type: "connection", value: "service.output" }.`);
|
|
540
|
+
const entry = entryValue;
|
|
541
|
+
switch (entry.type) {
|
|
542
|
+
case void 0:
|
|
543
|
+
if (typeof entry.value !== "string") throw new CliError(`${path} must have a string \`value\` (or a \`type\` of "secret" or "connection").`);
|
|
544
|
+
if (entry.key !== void 0) throw new CliError(`${path} must not have a \`key\` — that's only for env vars with \`type: "secret"\`.`);
|
|
545
|
+
result[envVarKey] = { value: entry.value };
|
|
546
|
+
break;
|
|
547
|
+
case "secret":
|
|
548
|
+
if (typeof entry.key !== "string" || !SECRET_KEY_REGEX.test(entry.key)) 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).`);
|
|
549
|
+
if (entry.value !== void 0) 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.`);
|
|
550
|
+
result[envVarKey] = {
|
|
551
|
+
type: "secret",
|
|
552
|
+
key: entry.key
|
|
553
|
+
};
|
|
554
|
+
break;
|
|
555
|
+
case "connection":
|
|
556
|
+
if (typeof entry.value !== "string" || !CONNECTION_VALUE_REGEX.test(entry.value)) throw new CliError(`${path} has type "connection" and must have a \`value\` referencing a service output like "hexclave.projectId".`);
|
|
557
|
+
result[envVarKey] = {
|
|
558
|
+
type: "connection",
|
|
559
|
+
value: entry.value
|
|
560
|
+
};
|
|
561
|
+
break;
|
|
562
|
+
default: throw new CliError(`${path} has an unknown \`type\` ${JSON.stringify(entry.type)}. Supported: "secret", "connection", or no type for a plain value.`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return result;
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Checks the provided secrets against the secret keys the env definitions
|
|
569
|
+
* reference, so a missing or misspelled secret fails BEFORE packaging and
|
|
570
|
+
* uploading. The backend re-checks this authoritatively. Exported for unit
|
|
571
|
+
* tests.
|
|
572
|
+
*/
|
|
573
|
+
function assertSecretsMatchEnv(env, secrets) {
|
|
574
|
+
const referencedKeys = new Set(Object.values(env).flatMap((entry) => "type" in entry && entry.type === "secret" ? [entry.key] : []));
|
|
575
|
+
const missing = [...referencedKeys].filter((key) => !secrets.has(key));
|
|
576
|
+
if (missing.length > 0) throw new CliError(`Missing secret values for: ${missing.join(", ")}. This service's env vars reference these secrets — pass them with --secret <key>=<value>.`);
|
|
577
|
+
const unused = [...secrets.keys()].filter((key) => !referencedKeys.has(key));
|
|
578
|
+
if (unused.length > 0) 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.`);
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Resolves the config file path: --config wins (and must exist); otherwise the
|
|
582
|
+
* first existing candidate in cwd, or undefined when there is none — the
|
|
583
|
+
* config file is optional, deploys then use the service's configuration as
|
|
584
|
+
* stored on the backend (dashboard-configured). Exported for unit tests.
|
|
585
|
+
*/
|
|
586
|
+
function resolveDeployConfigPath(configOption, cwd) {
|
|
587
|
+
if (configOption != null && configOption !== "") {
|
|
588
|
+
const resolved = path.resolve(cwd, configOption);
|
|
589
|
+
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) throw new CliError(`Config file not found: ${resolved}`);
|
|
590
|
+
return resolved;
|
|
591
|
+
}
|
|
592
|
+
for (const candidate of CONFIG_FILE_CANDIDATES) {
|
|
593
|
+
const resolved = path.resolve(cwd, candidate);
|
|
594
|
+
if (fs.existsSync(resolved) && fs.statSync(resolved).isFile()) return resolved;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
async function buildAuthHeadersFactory(auth) {
|
|
598
|
+
if (isProjectAuthWithSecretServerKey(auth)) {
|
|
599
|
+
const headers = {
|
|
600
|
+
"x-stack-access-type": "server",
|
|
601
|
+
"x-stack-project-id": auth.projectId,
|
|
602
|
+
"x-stack-secret-server-key": auth.secretServerKey
|
|
603
|
+
};
|
|
604
|
+
return () => Promise.resolve(headers);
|
|
605
|
+
}
|
|
606
|
+
const user = await getInternalUser(auth);
|
|
607
|
+
return async () => {
|
|
608
|
+
const { accessToken } = await user.currentSession.getTokens();
|
|
609
|
+
if (accessToken == null) throw new AuthError("Could not obtain an access token. Run `hexclave login` again.");
|
|
610
|
+
return {
|
|
611
|
+
"x-stack-access-type": "admin",
|
|
612
|
+
"x-stack-project-id": auth.projectId,
|
|
613
|
+
"x-stack-admin-access-token": accessToken
|
|
614
|
+
};
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
async function deployApiFetch(auth, getAuthHeaders, apiPath, init) {
|
|
618
|
+
const url = `${auth.apiUrl.replace(/\/$/, "")}/api/latest${apiPath}`;
|
|
619
|
+
const response = await fetch(url, {
|
|
620
|
+
method: init.method,
|
|
621
|
+
headers: {
|
|
622
|
+
...await getAuthHeaders(),
|
|
623
|
+
...init.jsonBody !== void 0 ? { "content-type": "application/json" } : {}
|
|
624
|
+
},
|
|
625
|
+
body: init.jsonBody !== void 0 ? JSON.stringify(init.jsonBody) : void 0
|
|
626
|
+
});
|
|
627
|
+
const text = await response.text();
|
|
628
|
+
if (!response.ok) {
|
|
629
|
+
let message = text;
|
|
630
|
+
try {
|
|
631
|
+
const parsed = JSON.parse(text);
|
|
632
|
+
if (typeof parsed?.error === "string") message = parsed.error;
|
|
633
|
+
else if (typeof parsed?.error?.message === "string") message = parsed.error.message;
|
|
634
|
+
} catch {}
|
|
635
|
+
throw new CliError(`Deploy request failed (${response.status} at ${init.method} ${apiPath}): ${message.slice(0, 1e3)}`);
|
|
636
|
+
}
|
|
637
|
+
try {
|
|
638
|
+
return text === "" ? void 0 : JSON.parse(text);
|
|
639
|
+
} catch {
|
|
640
|
+
throw new CliError(`Unexpected non-JSON response from the Hexclave API at ${init.method} ${apiPath}.`);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
async function uploadSource(uploadUrl, contentType, bytes) {
|
|
644
|
+
let parsedUrl;
|
|
645
|
+
try {
|
|
646
|
+
parsedUrl = new URL(uploadUrl);
|
|
647
|
+
} catch {
|
|
648
|
+
throw new CliError("The Hexclave API returned an invalid object-storage upload URL.");
|
|
649
|
+
}
|
|
650
|
+
if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") throw new CliError("The Hexclave API returned an upload URL with an unsupported protocol.");
|
|
651
|
+
const response = await fetch(parsedUrl, {
|
|
652
|
+
method: "PUT",
|
|
653
|
+
headers: {
|
|
654
|
+
"content-type": contentType,
|
|
655
|
+
"content-length": bytes.length.toString()
|
|
656
|
+
},
|
|
657
|
+
body: new Uint8Array(bytes).slice().buffer
|
|
658
|
+
});
|
|
659
|
+
if (!response.ok) {
|
|
660
|
+
const responseBody = await response.text();
|
|
661
|
+
throw new CliError(`Source upload failed (${response.status} from object storage): ${responseBody.slice(0, 1e3)}`);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
function registerDeployCommand(program) {
|
|
665
|
+
program.command("deploy <service>").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.").option("--config <path>", "Path to the config file (default: auto-discover hexclave.config.ts in the current directory)").option("--cloud-project-id <id>", "Hexclave project ID to deploy to (defaults to the HEXCLAVE_PROJECT_ID env var)").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, previous) => [...previous, value], []).addHelpText("after", "\nAuthentication: uses HEXCLAVE_SECRET_SERVER_KEY if set (recommended for CI), otherwise your `hexclave login` session.").action(async (service, opts) => {
|
|
666
|
+
const auth = resolveAuth(resolveProjectId(opts.cloudProjectId));
|
|
667
|
+
const secrets = parseSecretOptions(opts.secret);
|
|
668
|
+
const authHeaders = await buildAuthHeadersFactory(auth);
|
|
669
|
+
const configPath = resolveDeployConfigPath(opts.config, process.cwd());
|
|
670
|
+
let definition;
|
|
671
|
+
let rootDirectory;
|
|
672
|
+
let ignoreRootDirectory;
|
|
673
|
+
if (configPath != null) {
|
|
674
|
+
const { createJiti } = await import("jiti");
|
|
675
|
+
const jiti = createJiti(import.meta.url);
|
|
676
|
+
let configModule;
|
|
677
|
+
try {
|
|
678
|
+
configModule = await jiti.import(configPath);
|
|
679
|
+
} catch (err) {
|
|
680
|
+
throw new CliError(`Failed to load config file ${configPath}: ${errorMessage$1(err)}`);
|
|
681
|
+
}
|
|
682
|
+
if (configModule.config == null) throw new CliError(`Config file ${configPath} must export a \`config\` object (e.g. \`export const config = { "deployments-alpha": { services: { ... } } }\`).`);
|
|
683
|
+
definition = extractServiceDefinition(configModule.config, service);
|
|
684
|
+
assertSecretsMatchEnv(definition.env, secrets);
|
|
685
|
+
ignoreRootDirectory = path.dirname(configPath);
|
|
686
|
+
rootDirectory = path.resolve(ignoreRootDirectory, definition.rootDirectory ?? ".");
|
|
687
|
+
} else {
|
|
688
|
+
const remoteService = await deployApiFetch(auth, authHeaders, `/deployments/services/${encodeURIComponent(service)}`, { method: "GET" });
|
|
689
|
+
const remoteRootDirectory = typeof remoteService?.root_directory === "string" && remoteService.root_directory !== "" ? remoteService.root_directory : ".";
|
|
690
|
+
const remoteEnv = {};
|
|
691
|
+
for (const envVar of Array.isArray(remoteService?.env) ? remoteService.env : []) if (envVar?.type === "secret" && typeof envVar.secret_key === "string" && typeof envVar.key === "string") remoteEnv[envVar.key] = {
|
|
692
|
+
type: "secret",
|
|
693
|
+
key: envVar.secret_key
|
|
694
|
+
};
|
|
695
|
+
assertSecretsMatchEnv(remoteEnv, secrets);
|
|
696
|
+
ignoreRootDirectory = process.cwd();
|
|
697
|
+
rootDirectory = path.resolve(process.cwd(), remoteRootDirectory);
|
|
698
|
+
console.error(`No config file found — using the service configuration stored in Hexclave (root directory: ${remoteRootDirectory}).`);
|
|
699
|
+
}
|
|
700
|
+
console.error(`Packaging ${rootDirectory}...`);
|
|
701
|
+
const packaged = packageSourceDirectory(rootDirectory, ignoreRootDirectory);
|
|
702
|
+
console.error(`Packaged ${packaged.fileCount} files (${(packaged.tarballGzipped.length / 1024).toFixed(1)} KiB compressed).`);
|
|
703
|
+
const upload = await deployApiFetch(auth, authHeaders, "/deployments/uploads", { method: "POST" });
|
|
704
|
+
if (typeof upload?.id !== "string" || typeof upload?.upload_url !== "string" || typeof upload?.content_type !== "string") throw new CliError("Unexpected response from the Hexclave API when creating the upload.");
|
|
705
|
+
if (typeof upload.max_bytes === "number" && packaged.tarballGzipped.length > upload.max_bytes) 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.`);
|
|
706
|
+
console.error(`Uploading source...`);
|
|
707
|
+
await uploadSource(upload.upload_url, upload.content_type, packaged.tarballGzipped);
|
|
708
|
+
console.error(`Starting deployment of ${JSON.stringify(service)}...`);
|
|
709
|
+
const deployResponse = await deployApiFetch(auth, authHeaders, `/deployments/services/${encodeURIComponent(service)}/deploy`, {
|
|
710
|
+
method: "POST",
|
|
711
|
+
jsonBody: {
|
|
712
|
+
upload_id: upload.id,
|
|
713
|
+
...definition !== void 0 ? {
|
|
714
|
+
build_config: {
|
|
715
|
+
framework: definition.framework ?? null,
|
|
716
|
+
install_command: definition.installCommand ?? null,
|
|
717
|
+
build_command: definition.buildCommand ?? null,
|
|
718
|
+
output_directory: definition.outputDirectory ?? null,
|
|
719
|
+
root_directory: definition.rootDirectory ?? null
|
|
720
|
+
},
|
|
721
|
+
env: definition.env
|
|
722
|
+
} : {},
|
|
723
|
+
...secrets.size > 0 ? { secrets: Object.fromEntries(secrets) } : {}
|
|
724
|
+
}
|
|
725
|
+
});
|
|
726
|
+
if (typeof deployResponse?.run_id !== "string") throw new CliError("Unexpected response from the Hexclave API when starting the deployment.");
|
|
727
|
+
console.log(JSON.stringify({ runId: deployResponse.run_id }, null, 2));
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
//#endregion
|
|
280
731
|
//#region src/lib/config-file-path.ts
|
|
281
732
|
function resolveConfigFilePathOption(inputPath, options) {
|
|
282
733
|
const resolved = resolve(inputPath);
|
|
@@ -486,33 +937,6 @@ async function resolveLocalDashboardAuthByConfigPath(configFile) {
|
|
|
486
937
|
};
|
|
487
938
|
}
|
|
488
939
|
//#endregion
|
|
489
|
-
//#region src/lib/app.ts
|
|
490
|
-
var app_exports = /* @__PURE__ */ __exportAll({
|
|
491
|
-
getAdminProject: () => getAdminProject,
|
|
492
|
-
getInternalApp: () => getInternalApp,
|
|
493
|
-
getInternalUser: () => getInternalUser
|
|
494
|
-
});
|
|
495
|
-
function getInternalApp(auth) {
|
|
496
|
-
return new StackClientApp({
|
|
497
|
-
projectId: "internal",
|
|
498
|
-
publishableClientKey: auth.publishableClientKey,
|
|
499
|
-
baseUrl: auth.apiUrl,
|
|
500
|
-
tokenStore: {
|
|
501
|
-
accessToken: "",
|
|
502
|
-
refreshToken: auth.refreshToken
|
|
503
|
-
},
|
|
504
|
-
noAutomaticPrefetch: true
|
|
505
|
-
});
|
|
506
|
-
}
|
|
507
|
-
async function getInternalUser(auth) {
|
|
508
|
-
return await getInternalApp(auth).getUser({ or: "throw" });
|
|
509
|
-
}
|
|
510
|
-
async function getAdminProject(auth) {
|
|
511
|
-
const project = (await (await getInternalUser(auth)).listOwnedProjects()).find((p) => p.id === auth.projectId);
|
|
512
|
-
if (!project) throw new AuthError(`Project '${auth.projectId}' not found. Make sure you own this project.`);
|
|
513
|
-
return project;
|
|
514
|
-
}
|
|
515
|
-
//#endregion
|
|
516
940
|
//#region src/commands/exec.ts
|
|
517
941
|
function getErrorMessage(err) {
|
|
518
942
|
if (err instanceof Error) return err.message;
|
|
@@ -565,6 +989,77 @@ function registerExecCommand(program) {
|
|
|
565
989
|
});
|
|
566
990
|
}
|
|
567
991
|
//#endregion
|
|
992
|
+
//#region src/lib/progress.ts
|
|
993
|
+
const SPINNER_FRAMES$1 = [
|
|
994
|
+
"⠋",
|
|
995
|
+
"⠙",
|
|
996
|
+
"⠹",
|
|
997
|
+
"⠸",
|
|
998
|
+
"⠼",
|
|
999
|
+
"⠴",
|
|
1000
|
+
"⠦",
|
|
1001
|
+
"⠧",
|
|
1002
|
+
"⠇",
|
|
1003
|
+
"⠏"
|
|
1004
|
+
];
|
|
1005
|
+
const SPINNER_INTERVAL_MS = 80;
|
|
1006
|
+
/**
|
|
1007
|
+
* Reports long-running CLI work without contaminating stdout. Interactive
|
|
1008
|
+
* terminals get a single animated line, while redirected output gets durable
|
|
1009
|
+
* lines that remain useful in CI logs.
|
|
1010
|
+
*/
|
|
1011
|
+
function startProgress(initialMessage, options = {}) {
|
|
1012
|
+
const stream = options.stream ?? process.stderr;
|
|
1013
|
+
const prefix = options.prefix ?? "";
|
|
1014
|
+
let message = initialMessage;
|
|
1015
|
+
let stopped = false;
|
|
1016
|
+
if (!stream.isTTY) {
|
|
1017
|
+
stream.write(`${prefix}${message}...\n`);
|
|
1018
|
+
return {
|
|
1019
|
+
update(nextMessage) {
|
|
1020
|
+
if (stopped || nextMessage === message) return;
|
|
1021
|
+
message = nextMessage;
|
|
1022
|
+
stream.write(`${prefix}${message}...\n`);
|
|
1023
|
+
},
|
|
1024
|
+
stop(finalMessage) {
|
|
1025
|
+
if (stopped) return;
|
|
1026
|
+
stopped = true;
|
|
1027
|
+
if (finalMessage != null) stream.write(`${prefix}${finalMessage}\n`);
|
|
1028
|
+
}
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
let frameIndex = 0;
|
|
1032
|
+
const render = () => {
|
|
1033
|
+
stream.write(`\r\x1b[2K${prefix}${SPINNER_FRAMES$1[frameIndex]} ${message}`);
|
|
1034
|
+
frameIndex = (frameIndex + 1) % SPINNER_FRAMES$1.length;
|
|
1035
|
+
};
|
|
1036
|
+
render();
|
|
1037
|
+
const timer = setInterval(render, SPINNER_INTERVAL_MS);
|
|
1038
|
+
timer.unref();
|
|
1039
|
+
return {
|
|
1040
|
+
update(nextMessage) {
|
|
1041
|
+
if (stopped || nextMessage === message) return;
|
|
1042
|
+
message = nextMessage;
|
|
1043
|
+
render();
|
|
1044
|
+
},
|
|
1045
|
+
stop(finalMessage) {
|
|
1046
|
+
if (stopped) return;
|
|
1047
|
+
stopped = true;
|
|
1048
|
+
clearInterval(timer);
|
|
1049
|
+
stream.write("\r\x1B[2K");
|
|
1050
|
+
if (finalMessage != null) stream.write(`${prefix}${finalMessage}\n`);
|
|
1051
|
+
}
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
async function withProgress(message, operation, options) {
|
|
1055
|
+
const progress = startProgress(message, options);
|
|
1056
|
+
try {
|
|
1057
|
+
return await operation();
|
|
1058
|
+
} finally {
|
|
1059
|
+
progress.stop();
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
//#endregion
|
|
568
1063
|
//#region src/commands/config-file.ts
|
|
569
1064
|
const SHOW_ONBOARDING_STACK_CONFIG_VALUE = "show-onboarding";
|
|
570
1065
|
function isConfigOverride(value) {
|
|
@@ -675,16 +1170,16 @@ function sourceToSdkSource(source) {
|
|
|
675
1170
|
}
|
|
676
1171
|
function resolveConfigFilePathForPull(opts, cwd) {
|
|
677
1172
|
if (opts.configFile != null && opts.configFile !== "") return resolveConfigFilePathOption(opts.configFile);
|
|
678
|
-
const hexclaveCandidate = path.join(cwd, "hexclave.config.ts");
|
|
679
|
-
const legacyCandidate = path.join(cwd, "stack.config.ts");
|
|
680
|
-
const candidate = fs.existsSync(hexclaveCandidate) ? hexclaveCandidate : legacyCandidate;
|
|
681
|
-
if (!fs.existsSync(candidate)) return hexclaveCandidate;
|
|
682
|
-
if (fs.statSync(candidate).isDirectory()) throw new CliError(`Default config path points to a directory instead of a file: ${candidate}`);
|
|
1173
|
+
const hexclaveCandidate = path$1.join(cwd, "hexclave.config.ts");
|
|
1174
|
+
const legacyCandidate = path$1.join(cwd, "stack.config.ts");
|
|
1175
|
+
const candidate = fs$1.existsSync(hexclaveCandidate) ? hexclaveCandidate : legacyCandidate;
|
|
1176
|
+
if (!fs$1.existsSync(candidate)) return hexclaveCandidate;
|
|
1177
|
+
if (fs$1.statSync(candidate).isDirectory()) throw new CliError(`Default config path points to a directory instead of a file: ${candidate}`);
|
|
683
1178
|
return candidate;
|
|
684
1179
|
}
|
|
685
1180
|
function assertConfigPullTarget(filePath, opts) {
|
|
686
1181
|
if (opts.overwrite === true) return;
|
|
687
|
-
if (fs.existsSync(filePath)) throw new CliError(`A config file already exists at ${filePath}. Pass --overwrite to replace it with the pulled config, or remove the file first.`);
|
|
1182
|
+
if (fs$1.existsSync(filePath)) throw new CliError(`A config file already exists at ${filePath}. Pass --overwrite to replace it with the pulled config, or remove the file first.`);
|
|
688
1183
|
}
|
|
689
1184
|
function registerConfigCommand(program) {
|
|
690
1185
|
const config = program.command("config").description("Manage project configuration files");
|
|
@@ -692,31 +1187,39 @@ function registerConfigCommand(program) {
|
|
|
692
1187
|
const auth = resolveAuth(resolveProjectId(opts.cloudProjectId));
|
|
693
1188
|
if (!isProjectAuthWithRefreshToken(auth)) throw new CliError("`hexclave config pull` requires `hexclave login`. Remove STACK_SECRET_SERVER_KEY and try again.");
|
|
694
1189
|
const filePath = resolveConfigFilePathForPull(opts, process.cwd());
|
|
695
|
-
if (path.extname(filePath) !== ".ts") throw new CliError("Config file must have a .ts extension. Typed config files require TypeScript.");
|
|
1190
|
+
if (path$1.extname(filePath) !== ".ts") throw new CliError("Config file must have a .ts extension. Typed config files require TypeScript.");
|
|
696
1191
|
assertConfigPullTarget(filePath, opts);
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
1192
|
+
await withProgress("Pulling config", async () => {
|
|
1193
|
+
const configOverride = await (await getAdminProject(auth)).getConfigOverride("branch");
|
|
1194
|
+
if (!isValidConfig(configOverride)) throw new CliError("Pulled branch config is not a valid local config object.");
|
|
1195
|
+
await replaceConfigObject(filePath, configOverride);
|
|
1196
|
+
});
|
|
700
1197
|
console.log(`Config written to ${filePath}`);
|
|
701
1198
|
});
|
|
702
1199
|
config.command("push").description("Push a local config file to branch config").option("--cloud-project-id <id>", "Cloud project ID to push config to (defaults to the STACK_PROJECT_ID env var)").requiredOption("--config-file <path>", "Path to config file (.js or .ts)").option("--source <type>", "Explicit source type for this push. Only 'github' is supported.").option("--source-repo <owner/repo>", "GitHub repository in 'owner/repo' format. Only allowed with --source github.").option("--source-path <path>", "Path to the config file within the source repository. Only allowed with --source github.").option("--source-workflow-path <path>", "Path to the syncing workflow file within the source repository. Only allowed with --source github.").action(async (opts) => {
|
|
703
1200
|
const auth = resolveAuth(resolveProjectId(opts.cloudProjectId));
|
|
704
1201
|
const filePath = resolveConfigFilePathOption(opts.configFile, { mustExist: true });
|
|
705
|
-
const ext = path.extname(filePath);
|
|
1202
|
+
const ext = path$1.extname(filePath);
|
|
706
1203
|
if (ext !== ".js" && ext !== ".ts") throw new CliError("Config file must have a .js or .ts extension.");
|
|
707
|
-
const { createJiti } = await import("jiti");
|
|
708
|
-
const config = parseConfigOverride((await createJiti(import.meta.url).import(filePath)).config);
|
|
709
|
-
if (config == null) throw new CliError(`Config file must export a plain \`config\` object or "show-onboarding". Example: import type { HexclaveConfig } from "${detectImportPackageFromDir(path.dirname(filePath)) ?? "@hexclave/js"}"; export const config: HexclaveConfig = { ... };`);
|
|
710
1204
|
const source = buildConfigPushSource(opts.configFile, {
|
|
711
1205
|
source: opts.source,
|
|
712
1206
|
sourceRepo: opts.sourceRepo,
|
|
713
1207
|
sourcePath: opts.sourcePath,
|
|
714
1208
|
sourceWorkflowPath: opts.sourceWorkflowPath
|
|
715
1209
|
});
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
1210
|
+
const progress = startProgress("Loading config");
|
|
1211
|
+
try {
|
|
1212
|
+
const { createJiti } = await import("jiti");
|
|
1213
|
+
const config = parseConfigOverride((await createJiti(import.meta.url).import(filePath)).config);
|
|
1214
|
+
if (config == null) throw new CliError(`Config file must export a plain \`config\` object or "show-onboarding". Example: import type { HexclaveConfig } from "${detectImportPackageFromDir(path$1.dirname(filePath)) ?? "@hexclave/js"}"; export const config: HexclaveConfig = { ... };`);
|
|
1215
|
+
progress.update("Pushing config");
|
|
1216
|
+
if (isProjectAuthWithSecretServerKey(auth)) await pushConfigWithSecretServerKey(auth, config, source);
|
|
1217
|
+
else {
|
|
1218
|
+
if (!isProjectAuthWithRefreshToken(auth)) throw new CliError("`hexclave config push` requires either STACK_SECRET_SERVER_KEY or `hexclave login`.");
|
|
1219
|
+
await (await getAdminProject(auth)).pushConfig(config, { source: sourceToSdkSource(source) });
|
|
1220
|
+
}
|
|
1221
|
+
} finally {
|
|
1222
|
+
progress.stop();
|
|
720
1223
|
}
|
|
721
1224
|
console.log("Config pushed successfully.");
|
|
722
1225
|
});
|
|
@@ -885,11 +1388,13 @@ async function createProjectInteractively(user, opts = {}) {
|
|
|
885
1388
|
validate: (v) => v.trim().length > 0 || "Display name cannot be empty."
|
|
886
1389
|
})).trim();
|
|
887
1390
|
}
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
1391
|
+
return await withProgress("Creating project", async () => {
|
|
1392
|
+
const teams = await user.listTeams();
|
|
1393
|
+
if (teams.length === 0) throw new CliError(`No teams found on your account. Create a team at ${opts.dashboardUrl ?? "https://app.hexclave.com"} first.`);
|
|
1394
|
+
return await user.createProject({
|
|
1395
|
+
displayName,
|
|
1396
|
+
teamId: teams[0].id
|
|
1397
|
+
});
|
|
893
1398
|
});
|
|
894
1399
|
}
|
|
895
1400
|
//#endregion
|
|
@@ -938,8 +1443,8 @@ function validateOptions(opts) {
|
|
|
938
1443
|
}
|
|
939
1444
|
async function runInit(program, opts) {
|
|
940
1445
|
const flags = program.opts();
|
|
941
|
-
const outputDir = opts.outputDir ? path.resolve(opts.outputDir) : process.cwd();
|
|
942
|
-
if (!fs.existsSync(outputDir)) throw new CliError(`Output directory does not exist: ${outputDir}`);
|
|
1446
|
+
const outputDir = opts.outputDir ? path$1.resolve(opts.outputDir) : process.cwd();
|
|
1447
|
+
if (!fs$1.existsSync(outputDir)) throw new CliError(`Output directory does not exist: ${outputDir}`);
|
|
943
1448
|
validateOptions(opts);
|
|
944
1449
|
console.log("Welcome to Hexclave!\n");
|
|
945
1450
|
let mode;
|
|
@@ -1010,9 +1515,9 @@ async function handleLinkFromConfigFile(opts) {
|
|
|
1010
1515
|
const configPath = resolveConfigFilePathOption(opts.configFile ?? await input({
|
|
1011
1516
|
message: "Path to your existing hexclave.config.ts (or stack.config.ts):",
|
|
1012
1517
|
validate: (value) => {
|
|
1013
|
-
const resolved = path.resolve(value);
|
|
1014
|
-
if (!fs.existsSync(resolved)) return `File not found: ${resolved}`;
|
|
1015
|
-
if (fs.statSync(resolved).isDirectory()) return `--config-file must point to a config file, but got a directory: ${resolved}`;
|
|
1518
|
+
const resolved = path$1.resolve(value);
|
|
1519
|
+
if (!fs$1.existsSync(resolved)) return `File not found: ${resolved}`;
|
|
1520
|
+
if (fs$1.statSync(resolved).isDirectory()) return `--config-file must point to a config file, but got a directory: ${resolved}`;
|
|
1016
1521
|
return true;
|
|
1017
1522
|
}
|
|
1018
1523
|
}), { mustExist: true });
|
|
@@ -1033,12 +1538,14 @@ async function ensureLoggedInSession() {
|
|
|
1033
1538
|
}
|
|
1034
1539
|
}
|
|
1035
1540
|
async function writeProjectKeysToEnv(project, outputDir) {
|
|
1036
|
-
const apiKey = await project
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1541
|
+
const apiKey = await withProgress("Creating project keys", async () => {
|
|
1542
|
+
return await project.app.createInternalApiKey({
|
|
1543
|
+
description: "Created by CLI init script",
|
|
1544
|
+
expiresAt: new Date(Date.now() + 1e3 * 60 * 60 * 24 * 365 * 200),
|
|
1545
|
+
hasPublishableClientKey: true,
|
|
1546
|
+
hasSecretServerKey: true,
|
|
1547
|
+
hasSuperSecretAdminKey: false
|
|
1548
|
+
});
|
|
1042
1549
|
});
|
|
1043
1550
|
const publishableClientKey = apiKey.publishableClientKey ?? throwErr("createInternalApiKey returned no publishableClientKey despite hasPublishableClientKey=true");
|
|
1044
1551
|
const secretServerKey = apiKey.secretServerKey ?? throwErr("createInternalApiKey returned no secretServerKey despite hasSecretServerKey=true");
|
|
@@ -1048,33 +1555,34 @@ async function writeProjectKeysToEnv(project, outputDir) {
|
|
|
1048
1555
|
`NEXT_PUBLIC_HEXCLAVE_PUBLISHABLE_CLIENT_KEY=${publishableClientKey}`,
|
|
1049
1556
|
`HEXCLAVE_SECRET_SERVER_KEY=${secretServerKey}`
|
|
1050
1557
|
].join("\n");
|
|
1051
|
-
const envPath = path.resolve(outputDir, ".env");
|
|
1052
|
-
if (fs.existsSync(envPath)) {
|
|
1053
|
-
const separator = fs.readFileSync(envPath, "utf-8").endsWith("\n") ? "\n" : "\n\n";
|
|
1558
|
+
const envPath = path$1.resolve(outputDir, ".env");
|
|
1559
|
+
if (fs$1.existsSync(envPath)) {
|
|
1560
|
+
const separator = fs$1.readFileSync(envPath, "utf-8").endsWith("\n") ? "\n" : "\n\n";
|
|
1054
1561
|
if (isNonInteractiveEnv()) {
|
|
1055
|
-
fs.appendFileSync(envPath, separator + envLines + "\n");
|
|
1562
|
+
fs$1.appendFileSync(envPath, separator + envLines + "\n");
|
|
1056
1563
|
console.log("\nAppended Hexclave keys to .env");
|
|
1057
1564
|
} else if (await confirm({
|
|
1058
1565
|
message: `.env file already exists. Append Hexclave keys?`,
|
|
1059
1566
|
default: true
|
|
1060
1567
|
})) {
|
|
1061
|
-
fs.appendFileSync(envPath, separator + envLines + "\n");
|
|
1568
|
+
fs$1.appendFileSync(envPath, separator + envLines + "\n");
|
|
1062
1569
|
console.log("\nAppended Hexclave keys to .env");
|
|
1063
1570
|
} else {
|
|
1064
1571
|
console.log("\nHere are your environment variables:\n");
|
|
1065
1572
|
console.log(envLines);
|
|
1066
1573
|
}
|
|
1067
1574
|
} else {
|
|
1068
|
-
fs.writeFileSync(envPath, envLines + "\n");
|
|
1575
|
+
fs$1.writeFileSync(envPath, envLines + "\n");
|
|
1069
1576
|
console.log("\nCreated .env with Hexclave keys");
|
|
1070
1577
|
}
|
|
1071
1578
|
}
|
|
1072
1579
|
async function handleCreateCloud(_flags, opts, outputDir) {
|
|
1073
|
-
const
|
|
1580
|
+
const sessionAuth = await ensureLoggedInSession();
|
|
1581
|
+
const user = await withProgress("Loading account", async () => await getInternalUser(sessionAuth));
|
|
1074
1582
|
const { dashboardUrl } = resolveLoginConfig();
|
|
1075
1583
|
const newProject = await createProjectInteractively(user, {
|
|
1076
1584
|
displayName: opts.displayName,
|
|
1077
|
-
defaultDisplayName: path.basename(outputDir),
|
|
1585
|
+
defaultDisplayName: path$1.basename(outputDir),
|
|
1078
1586
|
dashboardUrl
|
|
1079
1587
|
});
|
|
1080
1588
|
console.log(`\nCreated project: ${newProject.displayName} (${newProject.id})\n`);
|
|
@@ -1082,8 +1590,15 @@ async function handleCreateCloud(_flags, opts, outputDir) {
|
|
|
1082
1590
|
return { projectId: newProject.id };
|
|
1083
1591
|
}
|
|
1084
1592
|
async function handleLinkFromCloud(_flags, opts, outputDir) {
|
|
1085
|
-
const
|
|
1086
|
-
|
|
1593
|
+
const sessionAuth = await ensureLoggedInSession();
|
|
1594
|
+
const { user, ownedProjects } = await withProgress("Loading projects", async () => {
|
|
1595
|
+
const user = await getInternalUser(sessionAuth);
|
|
1596
|
+
return {
|
|
1597
|
+
user,
|
|
1598
|
+
ownedProjects: await user.listOwnedProjects()
|
|
1599
|
+
};
|
|
1600
|
+
});
|
|
1601
|
+
let projects = ownedProjects;
|
|
1087
1602
|
let autoCreatedProjectId = null;
|
|
1088
1603
|
if (projects.length === 0) {
|
|
1089
1604
|
if (opts.selectProjectId) throw new CliError(`Project '${opts.selectProjectId}' not found among your owned projects. Check the ID or omit --select-project-id to create a new project interactively.`);
|
|
@@ -1097,7 +1612,7 @@ async function handleLinkFromCloud(_flags, opts, outputDir) {
|
|
|
1097
1612
|
}
|
|
1098
1613
|
const { dashboardUrl } = resolveLoginConfig();
|
|
1099
1614
|
const newProject = await createProjectInteractively(user, {
|
|
1100
|
-
defaultDisplayName: path.basename(outputDir),
|
|
1615
|
+
defaultDisplayName: path$1.basename(outputDir),
|
|
1101
1616
|
dashboardUrl
|
|
1102
1617
|
});
|
|
1103
1618
|
console.log(`\nCreated project: ${newProject.displayName} (${newProject.id})\n`);
|
|
@@ -1135,7 +1650,7 @@ async function performLogin() {
|
|
|
1135
1650
|
console.log("Login successful!\n");
|
|
1136
1651
|
}
|
|
1137
1652
|
async function handleCreate(opts, outputDir) {
|
|
1138
|
-
const configPath = path.resolve(outputDir, "hexclave.config.ts");
|
|
1653
|
+
const configPath = path$1.resolve(outputDir, "hexclave.config.ts");
|
|
1139
1654
|
console.log(`\nCreating a new config file at ${configPath}!\n`);
|
|
1140
1655
|
let selectedApps;
|
|
1141
1656
|
if (opts.apps) {
|
|
@@ -1157,9 +1672,9 @@ async function handleCreate(opts, outputDir) {
|
|
|
1157
1672
|
}))
|
|
1158
1673
|
});
|
|
1159
1674
|
}
|
|
1160
|
-
const content = renderConfigFileContent({ apps: { installed: Object.fromEntries(selectedApps.map((appId) => [appId, { enabled: true }])) } }, detectImportPackageFromDir(path.dirname(configPath)));
|
|
1161
|
-
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
1162
|
-
if (fs.existsSync(configPath)) {
|
|
1675
|
+
const content = renderConfigFileContent({ apps: { installed: Object.fromEntries(selectedApps.map((appId) => [appId, { enabled: true }])) } }, detectImportPackageFromDir(path$1.dirname(configPath)));
|
|
1676
|
+
fs$1.mkdirSync(path$1.dirname(configPath), { recursive: true });
|
|
1677
|
+
if (fs$1.existsSync(configPath)) {
|
|
1163
1678
|
if (isNonInteractiveEnv()) throw new CliError(`Config file already exists at ${configPath}. Refusing to overwrite in non-interactive mode.`);
|
|
1164
1679
|
if (!await confirm({
|
|
1165
1680
|
message: `Config file already exists at ${configPath}. Overwrite?`,
|
|
@@ -1169,7 +1684,7 @@ async function handleCreate(opts, outputDir) {
|
|
|
1169
1684
|
return { configPath };
|
|
1170
1685
|
}
|
|
1171
1686
|
}
|
|
1172
|
-
fs.writeFileSync(configPath, content);
|
|
1687
|
+
fs$1.writeFileSync(configPath, content);
|
|
1173
1688
|
console.log(`\nConfig file written to ${configPath}`);
|
|
1174
1689
|
return { configPath };
|
|
1175
1690
|
}
|
|
@@ -1199,7 +1714,10 @@ function registerProjectCommand(program) {
|
|
|
1199
1714
|
project.command("list").description("List your projects (defaults to both cloud and development-environment projects)").option("--cloud", "Only list cloud projects").option("--local", "Only list development-environment projects").action(async (opts) => {
|
|
1200
1715
|
const sources = resolveProjectListSources(opts);
|
|
1201
1716
|
const results = [];
|
|
1202
|
-
const
|
|
1717
|
+
const auth = resolveSessionAuth();
|
|
1718
|
+
const ownedProjects = await withProgress("Loading projects", async () => {
|
|
1719
|
+
return await (await getInternalUser(auth)).listOwnedProjects();
|
|
1720
|
+
});
|
|
1203
1721
|
for (const p of ownedProjects) {
|
|
1204
1722
|
const target = p.isDevelopmentEnvironment ? "local" : "cloud";
|
|
1205
1723
|
if (target === "cloud" && sources.cloud || target === "local" && sources.local) results.push({
|
|
@@ -1218,7 +1736,8 @@ function registerProjectCommand(program) {
|
|
|
1218
1736
|
Promise.resolve().then(() => auth_exports),
|
|
1219
1737
|
Promise.resolve().then(() => create_project_exports)
|
|
1220
1738
|
]);
|
|
1221
|
-
const
|
|
1739
|
+
const auth = resolveSessionAuth();
|
|
1740
|
+
const user = await withProgress("Loading account", async () => await getInternalUser(auth));
|
|
1222
1741
|
const { dashboardUrl } = resolveLoginConfig();
|
|
1223
1742
|
const newProject = await createProjectInteractively(user, {
|
|
1224
1743
|
displayName: opts.displayName,
|
|
@@ -1373,7 +1892,7 @@ async function sha256File(path) {
|
|
|
1373
1892
|
await pipeline(createReadStream(path), hash);
|
|
1374
1893
|
return hash.digest("hex");
|
|
1375
1894
|
}
|
|
1376
|
-
async function downloadDashboardRelease(manifest) {
|
|
1895
|
+
async function downloadDashboardRelease(manifest, onProgress) {
|
|
1377
1896
|
const cacheRoot = dashboardCacheRoot();
|
|
1378
1897
|
mkdirSync(cacheRoot, { recursive: true });
|
|
1379
1898
|
const suffix = `${process.pid}-${randomBytes(8).toString("hex")}`;
|
|
@@ -1381,6 +1900,7 @@ async function downloadDashboardRelease(manifest) {
|
|
|
1381
1900
|
const tmpDir = join(cacheRoot, `.extract-${manifest.version}-${suffix}`);
|
|
1382
1901
|
const targetDir = dashboardVersionDir(manifest.version);
|
|
1383
1902
|
try {
|
|
1903
|
+
onProgress?.(`Downloading Hexclave dashboard ${manifest.version}`);
|
|
1384
1904
|
const response = await fetch(manifest.url, {
|
|
1385
1905
|
redirect: "follow",
|
|
1386
1906
|
signal: AbortSignal.timeout(DASHBOARD_DOWNLOAD_TIMEOUT_MS)
|
|
@@ -1388,6 +1908,7 @@ async function downloadDashboardRelease(manifest) {
|
|
|
1388
1908
|
if (!isAllowedDownloadUrl(response.url)) throw new CliError(`Dashboard ${manifest.version} download was redirected to a disallowed URL (${response.url}).`);
|
|
1389
1909
|
if (!response.ok || response.body == null) throw new CliError(`Failed to download dashboard ${manifest.version} (HTTP ${response.status}) from ${manifest.url}.`);
|
|
1390
1910
|
await pipeline(Readable.fromWeb(response.body), createWriteStream(tmpZip));
|
|
1911
|
+
onProgress?.(`Verifying Hexclave dashboard ${manifest.version}`);
|
|
1391
1912
|
const digest = await sha256File(tmpZip);
|
|
1392
1913
|
if (digest !== manifest.sha256) throw new CliError(`Dashboard ${manifest.version} failed its integrity check (expected ${manifest.sha256}, got ${digest}).`);
|
|
1393
1914
|
rmSync(tmpDir, {
|
|
@@ -1395,6 +1916,7 @@ async function downloadDashboardRelease(manifest) {
|
|
|
1395
1916
|
force: true
|
|
1396
1917
|
});
|
|
1397
1918
|
mkdirSync(tmpDir, { recursive: true });
|
|
1919
|
+
onProgress?.(`Extracting Hexclave dashboard ${manifest.version}`);
|
|
1398
1920
|
await extractZip(tmpZip, { dir: tmpDir });
|
|
1399
1921
|
if (!existsSync(join(tmpDir, DASHBOARD_SERVER_RELATIVE_PATH))) throw new CliError(`Dashboard ${manifest.version} archive is missing its server entrypoint.`);
|
|
1400
1922
|
writeFileSync(join(tmpDir, DASHBOARD_COMPLETE_MARKER), `${manifest.sha256}\n`);
|
|
@@ -1433,7 +1955,7 @@ async function resolveDashboardRuntime(opts = {}) {
|
|
|
1433
1955
|
version: manifest.version
|
|
1434
1956
|
};
|
|
1435
1957
|
try {
|
|
1436
|
-
await downloadDashboardRelease(manifest);
|
|
1958
|
+
await downloadDashboardRelease(manifest, opts.onProgress);
|
|
1437
1959
|
return {
|
|
1438
1960
|
root: dashboardVersionDir(manifest.version),
|
|
1439
1961
|
version: manifest.version
|
|
@@ -1548,30 +2070,6 @@ function maybeOpenOnboardingPage(session, port) {
|
|
|
1548
2070
|
if (openUrlInBrowser(url)) logDev(`Onboarding is still pending for project ${session.project_id}. Opened: ${url}`);
|
|
1549
2071
|
else logDev(`Onboarding is still pending for project ${session.project_id}. Open this URL manually: ${url}`);
|
|
1550
2072
|
}
|
|
1551
|
-
function startProgressLog(message) {
|
|
1552
|
-
if (!process.stderr.isTTY) {
|
|
1553
|
-
logDev(`${message}...`);
|
|
1554
|
-
return { stop() {
|
|
1555
|
-
logDev(`${message}... done!`);
|
|
1556
|
-
} };
|
|
1557
|
-
}
|
|
1558
|
-
let dotCount = 0;
|
|
1559
|
-
let stopped = false;
|
|
1560
|
-
const render = () => {
|
|
1561
|
-
process.stderr.write(`\r\x1b[2K${LOG_PREFIX}${message}${".".repeat(dotCount)}`);
|
|
1562
|
-
dotCount = (dotCount + 1) % 4;
|
|
1563
|
-
};
|
|
1564
|
-
render();
|
|
1565
|
-
const timer = setInterval(render, 400);
|
|
1566
|
-
timer.unref();
|
|
1567
|
-
return { stop() {
|
|
1568
|
-
if (stopped) return;
|
|
1569
|
-
stopped = true;
|
|
1570
|
-
clearInterval(timer);
|
|
1571
|
-
process.stderr.write("\r\x1B[2K");
|
|
1572
|
-
logDev(`${message}... done!`);
|
|
1573
|
-
} };
|
|
1574
|
-
}
|
|
1575
2073
|
function dashboardRuntimeRoot(port) {
|
|
1576
2074
|
return join(dirname(devEnvStatePath()), `${DASHBOARD_RUNTIME_DIR_NAME}-${port}`);
|
|
1577
2075
|
}
|
|
@@ -1731,7 +2229,9 @@ async function startDashboardIfNeeded(options) {
|
|
|
1731
2229
|
const url = dashboardUrl(options.port);
|
|
1732
2230
|
const devDashboardCommand = devDashboardCommandFromEnv(process.env);
|
|
1733
2231
|
const dashboardOverride = dashboardDirOverride();
|
|
1734
|
-
const
|
|
2232
|
+
const skipReleaseLookup = devDashboardCommand != null || dashboardOverride != null;
|
|
2233
|
+
if (!skipReleaseLookup) logDev("Checking for Hexclave dashboard updates...");
|
|
2234
|
+
const manifest = skipReleaseLookup ? null : await fetchDashboardManifest();
|
|
1735
2235
|
const latestVersion = manifest?.version;
|
|
1736
2236
|
if (await isDashboardReachable(url, options.secret)) {
|
|
1737
2237
|
const runningDashboard = readDevEnvState().localDashboardsByPort?.[String(options.port)];
|
|
@@ -1751,8 +2251,11 @@ async function startDashboardIfNeeded(options) {
|
|
|
1751
2251
|
return;
|
|
1752
2252
|
}
|
|
1753
2253
|
}
|
|
1754
|
-
const release = devDashboardCommand == null ? await resolveDashboardRuntime({
|
|
1755
|
-
|
|
2254
|
+
const release = devDashboardCommand == null ? await resolveDashboardRuntime({
|
|
2255
|
+
manifest,
|
|
2256
|
+
onProgress: (message) => logDev(`${message}...`)
|
|
2257
|
+
}) : null;
|
|
2258
|
+
const progress = startProgress(`Hexclave dashboard not found on port ${options.port}. Starting now`, { prefix: LOG_PREFIX });
|
|
1756
2259
|
const dashboardEnv = {
|
|
1757
2260
|
...process.env,
|
|
1758
2261
|
NODE_ENV: devDashboardCommand == null ? "production" : "development",
|
|
@@ -2267,7 +2770,7 @@ function registerDoctorCommand(program) {
|
|
|
2267
2770
|
});
|
|
2268
2771
|
}
|
|
2269
2772
|
async function runDoctor(opts) {
|
|
2270
|
-
const projectDir = opts.outputDir ? path.resolve(opts.outputDir) : process.cwd();
|
|
2773
|
+
const projectDir = opts.outputDir ? path$1.resolve(opts.outputDir) : process.cwd();
|
|
2271
2774
|
const pkgRead = readPackageJson(projectDir);
|
|
2272
2775
|
if (pkgRead.kind === "missing") {
|
|
2273
2776
|
if (opts.json) console.log(JSON.stringify({
|
|
@@ -2328,9 +2831,9 @@ function isPackageJson(value) {
|
|
|
2328
2831
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
2329
2832
|
}
|
|
2330
2833
|
function readPackageJson(projectDir) {
|
|
2331
|
-
const pkgPath = path.join(projectDir, "package.json");
|
|
2332
|
-
if (!fs.existsSync(pkgPath)) return { kind: "missing" };
|
|
2333
|
-
const raw = fs.readFileSync(pkgPath, "utf-8");
|
|
2834
|
+
const pkgPath = path$1.join(projectDir, "package.json");
|
|
2835
|
+
if (!fs$1.existsSync(pkgPath)) return { kind: "missing" };
|
|
2836
|
+
const raw = fs$1.readFileSync(pkgPath, "utf-8");
|
|
2334
2837
|
try {
|
|
2335
2838
|
const parsed = JSON.parse(raw);
|
|
2336
2839
|
if (!isPackageJson(parsed)) return {
|
|
@@ -2350,8 +2853,8 @@ function readPackageJson(projectDir) {
|
|
|
2350
2853
|
}
|
|
2351
2854
|
}
|
|
2352
2855
|
function resolveSrcPrefix(framework, projectDir) {
|
|
2353
|
-
if (framework === "next") return fs.existsSync(path.join(projectDir, "src/app")) ? "src/" : "";
|
|
2354
|
-
return fs.existsSync(path.join(projectDir, "src")) ? "src/" : "";
|
|
2856
|
+
if (framework === "next") return fs$1.existsSync(path$1.join(projectDir, "src/app")) ? "src/" : "";
|
|
2857
|
+
return fs$1.existsSync(path$1.join(projectDir, "src")) ? "src/" : "";
|
|
2355
2858
|
}
|
|
2356
2859
|
function resolveFramework(override, pkg, projectDir) {
|
|
2357
2860
|
if (override) {
|
|
@@ -2369,7 +2872,7 @@ function resolveFramework(override, pkg, projectDir) {
|
|
|
2369
2872
|
...pkg.devDependencies ?? {}
|
|
2370
2873
|
};
|
|
2371
2874
|
if (allDeps.next) {
|
|
2372
|
-
if (!(fs.existsSync(path.join(projectDir, "app")) || fs.existsSync(path.join(projectDir, "src/app")))) return {
|
|
2875
|
+
if (!(fs$1.existsSync(path$1.join(projectDir, "app")) || fs$1.existsSync(path$1.join(projectDir, "src/app")))) return {
|
|
2373
2876
|
kind: "unsupported",
|
|
2374
2877
|
reason: "Detected Next.js but no app router (app/ or src/app/). The pages router is not yet supported by Hexclave doctor."
|
|
2375
2878
|
};
|
|
@@ -2536,7 +3039,7 @@ function fileExistsCheck(id, label, candidates, extraHint) {
|
|
|
2536
3039
|
label,
|
|
2537
3040
|
run: (ctx) => {
|
|
2538
3041
|
const resolved = candidates.map((c) => `${ctx.srcPrefix}${c}`);
|
|
2539
|
-
for (const rel of resolved) if (fs.existsSync(path.join(ctx.projectDir, rel))) return {
|
|
3042
|
+
for (const rel of resolved) if (fs$1.existsSync(path$1.join(ctx.projectDir, rel))) return {
|
|
2540
3043
|
id,
|
|
2541
3044
|
label: `${label} found (${rel})`,
|
|
2542
3045
|
status: "pass"
|
|
@@ -2567,8 +3070,8 @@ function layoutWrapsStackProviderCheck() {
|
|
|
2567
3070
|
const candidates = baseCandidates.map((c) => `${ctx.srcPrefix}${c}`);
|
|
2568
3071
|
let foundPath = null;
|
|
2569
3072
|
for (const candidate of candidates) {
|
|
2570
|
-
const full = path.join(ctx.projectDir, candidate);
|
|
2571
|
-
if (fs.existsSync(full)) {
|
|
3073
|
+
const full = path$1.join(ctx.projectDir, candidate);
|
|
3074
|
+
if (fs$1.existsSync(full)) {
|
|
2572
3075
|
foundPath = full;
|
|
2573
3076
|
break;
|
|
2574
3077
|
}
|
|
@@ -2579,10 +3082,10 @@ function layoutWrapsStackProviderCheck() {
|
|
|
2579
3082
|
status: "fail",
|
|
2580
3083
|
detail: `Expected one of: ${candidates.join(", ")}`
|
|
2581
3084
|
};
|
|
2582
|
-
const content = fs.readFileSync(foundPath, "utf-8");
|
|
3085
|
+
const content = fs$1.readFileSync(foundPath, "utf-8");
|
|
2583
3086
|
const importsProvider = /import\s*\{[^}]*\b(?:HexclaveProvider|StackProvider)\b[^}]*\}\s*from\s*["'](?:@hexclave\/next|@stackframe\/stack)["']/.test(content);
|
|
2584
3087
|
const wrapsJsx = /<(?:HexclaveProvider|StackProvider)\b/.test(content);
|
|
2585
|
-
const rel = path.relative(ctx.projectDir, foundPath);
|
|
3088
|
+
const rel = path$1.relative(ctx.projectDir, foundPath);
|
|
2586
3089
|
if (importsProvider && wrapsJsx) return {
|
|
2587
3090
|
id,
|
|
2588
3091
|
label,
|
|
@@ -2665,8 +3168,8 @@ function configFileCheck() {
|
|
|
2665
3168
|
let foundPath = null;
|
|
2666
3169
|
let foundRel = null;
|
|
2667
3170
|
for (const c of candidates) {
|
|
2668
|
-
const full = path.join(ctx.projectDir, c);
|
|
2669
|
-
if (fs.existsSync(full)) {
|
|
3171
|
+
const full = path$1.join(ctx.projectDir, c);
|
|
3172
|
+
if (fs$1.existsSync(full)) {
|
|
2670
3173
|
foundPath = full;
|
|
2671
3174
|
foundRel = c;
|
|
2672
3175
|
break;
|
|
@@ -2721,9 +3224,9 @@ function readEnvFiles(projectDir) {
|
|
|
2721
3224
|
const files = [".env.local", ".env"];
|
|
2722
3225
|
const result = /* @__PURE__ */ new Map();
|
|
2723
3226
|
for (const f of files) {
|
|
2724
|
-
const full = path.join(projectDir, f);
|
|
2725
|
-
if (!fs.existsSync(full)) continue;
|
|
2726
|
-
const content = fs.readFileSync(full, "utf-8");
|
|
3227
|
+
const full = path$1.join(projectDir, f);
|
|
3228
|
+
if (!fs$1.existsSync(full)) continue;
|
|
3229
|
+
const content = fs$1.readFileSync(full, "utf-8");
|
|
2727
3230
|
for (const line of content.split("\n")) {
|
|
2728
3231
|
const trimmed = line.trim();
|
|
2729
3232
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
@@ -2772,8 +3275,13 @@ function registerWhoamiCommand(program) {
|
|
|
2772
3275
|
program.command("whoami").description("Show the currently logged-in Hexclave CLI user").action(async () => {
|
|
2773
3276
|
const flags = program.opts();
|
|
2774
3277
|
const auth = resolveSessionAuth();
|
|
2775
|
-
const user = await
|
|
2776
|
-
|
|
3278
|
+
const { user, teams } = await withProgress("Loading account", async () => {
|
|
3279
|
+
const user = await getInternalUser(auth);
|
|
3280
|
+
return {
|
|
3281
|
+
user,
|
|
3282
|
+
teams: await user.listTeams()
|
|
3283
|
+
};
|
|
3284
|
+
});
|
|
2777
3285
|
const result = {
|
|
2778
3286
|
id: user.id,
|
|
2779
3287
|
displayName: user.displayName,
|
|
@@ -2810,6 +3318,7 @@ program.name("hexclave").description("Hexclave CLI. For more information, go to
|
|
|
2810
3318
|
registerLoginCommand(program);
|
|
2811
3319
|
registerLogoutCommand(program);
|
|
2812
3320
|
registerExecCommand(program);
|
|
3321
|
+
registerDeployCommand(program);
|
|
2813
3322
|
registerConfigCommand(program);
|
|
2814
3323
|
registerInitCommand(program);
|
|
2815
3324
|
registerProjectCommand(program);
|
|
@@ -2834,9 +3343,9 @@ async function main() {
|
|
|
2834
3343
|
console.error(`Error: ${err.message}`);
|
|
2835
3344
|
process.exit(1);
|
|
2836
3345
|
}
|
|
3346
|
+
console.error(err);
|
|
2837
3347
|
captureError("stack-cli-fatal", err);
|
|
2838
3348
|
await Sentry.flush(2e3);
|
|
2839
|
-
console.error(err);
|
|
2840
3349
|
process.exit(1);
|
|
2841
3350
|
}
|
|
2842
3351
|
}
|