@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
|
@@ -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
|
+
}
|
package/src/commands/dev.ts
CHANGED
|
@@ -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
|
|
433
|
+
const release = devDashboardCommand == null
|
|
434
|
+
? await resolveDashboardRuntime({ manifest, onProgress: (message) => logDev(`${message}...`) })
|
|
435
|
+
: null;
|
|
465
436
|
|
|
466
|
-
const progress =
|
|
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",
|
package/src/commands/init.ts
CHANGED
|
@@ -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
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
|
292
|
-
|
|
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) {
|
package/src/commands/project.ts
CHANGED
|
@@ -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
|
|
64
|
-
|
|
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, {
|
package/src/commands/whoami.ts
CHANGED
|
@@ -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
|
|
13
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
return await user.createProject({
|
|
38
|
+
displayName,
|
|
39
|
+
teamId: teams[0].id,
|
|
40
|
+
});
|
|
38
41
|
});
|
|
39
42
|
}
|