@langchain/langgraph-cli 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1199 @@
1
+ /**
2
+ * `langgraph deploy` command tree, ported from the Python CLI's `deploy.py`.
3
+ *
4
+ * Builds and deploys a LangGraph project (graph, createAgent, createDeepAgent,
5
+ * ...) to LangSmith Deployment. Supports local Docker builds (build → push →
6
+ * update) and remote source builds (tar → upload → trigger), plus the
7
+ * `list`, `revisions list`, `delete`, and `logs` subcommands.
8
+ */
9
+ import { $ } from "execa";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import { promises as fs } from "node:fs";
13
+ import readline from "node:readline";
14
+ import dotenv from "dotenv";
15
+ import { gracefulExit } from "exit-hook";
16
+ import { builder } from "./utils/builder.mjs";
17
+ import { withAnalytics } from "./utils/analytics.mjs";
18
+ import { getProjectPath } from "./utils/project.mjs";
19
+ import { getConfig } from "../utils/config.mjs";
20
+ import { assembleLocalDeps, configToDocker, getBaseImage, } from "../docker/docker.mjs";
21
+ import { getExecaOptions } from "../docker/shell.mjs";
22
+ import { HostBackendClient, HostBackendError, } from "./utils/host-backend.mjs";
23
+ import { Emitter, Spinner } from "./utils/deploy-output.mjs";
24
+ import { createArchive, BYTES_PER_MIB } from "./utils/archive.mjs";
25
+ import { API_KEY_ENV_NAMES, DEFAULT_HOST_URL, DEPLOYMENT_NAME_ENV, TERMINAL_STATUSES, envWithoutDeploymentName, findDeploymentIdByName, formatDeploymentsTable, formatLogEntry, formatRevisionsTable, getDeploymentStatusUrl, hasDisallowedBuildCommandContent, isPathWithin, levelColor, normalizeImageTag, normalizeName, secretsFromEnv, smithDashboardBaseUrl, } from "./utils/deploy-helpers.mjs";
26
+ // ---------------------------------------------------------------------------
27
+ // Module-level state (mirrors the Python globals `_emitter` / `_no_input`).
28
+ // ---------------------------------------------------------------------------
29
+ let emitter = new Emitter(false);
30
+ let noInput = false;
31
+ // ---------------------------------------------------------------------------
32
+ // Small utilities
33
+ // ---------------------------------------------------------------------------
34
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
35
+ const NO_COLOR = process.env.NO_COLOR != null || !process.stdout.isTTY;
36
+ const COLORS = { cyan: "36", green: "32", yellow: "33", red: "31" };
37
+ function paint(message, color) {
38
+ if (NO_COLOR)
39
+ return message;
40
+ return `\u001b[${COLORS[color]}m${message}\u001b[0m`;
41
+ }
42
+ function rec(value) {
43
+ return value && typeof value === "object"
44
+ ? value
45
+ : {};
46
+ }
47
+ function isPlainObject(value) {
48
+ return value != null && typeof value === "object" && !Array.isArray(value);
49
+ }
50
+ async function exists(target) {
51
+ try {
52
+ await fs.stat(target);
53
+ return true;
54
+ }
55
+ catch {
56
+ return false;
57
+ }
58
+ }
59
+ function escapeRegExp(value) {
60
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
61
+ }
62
+ // ---------------------------------------------------------------------------
63
+ // Interactive prompts
64
+ // ---------------------------------------------------------------------------
65
+ function promptText(question, defaultValue) {
66
+ return new Promise((resolve) => {
67
+ const rl = readline.createInterface({
68
+ input: process.stdin,
69
+ output: process.stdout,
70
+ });
71
+ const suffix = defaultValue ? ` [${defaultValue}]` : "";
72
+ rl.question(`${question}${suffix}: `, (answer) => {
73
+ rl.close();
74
+ const trimmed = answer.trim();
75
+ resolve(trimmed || defaultValue || "");
76
+ });
77
+ });
78
+ }
79
+ function promptHidden(question) {
80
+ return new Promise((resolve) => {
81
+ const rl = readline.createInterface({
82
+ input: process.stdin,
83
+ output: process.stdout,
84
+ terminal: true,
85
+ });
86
+ let muted = false;
87
+ // @ts-expect-error -- override internal writer to suppress echo
88
+ rl._writeToOutput = (chunk) => {
89
+ if (!muted)
90
+ rl.output.write(chunk);
91
+ };
92
+ rl.question(`${question}: `, (answer) => {
93
+ rl.close();
94
+ process.stdout.write("\n");
95
+ resolve(answer.trim());
96
+ });
97
+ muted = true;
98
+ });
99
+ }
100
+ async function confirm(question) {
101
+ const answer = await promptText(question);
102
+ return ["y", "yes"].includes(answer.trim().toLowerCase());
103
+ }
104
+ /**
105
+ * Wrap a command action so failures surface as a clean message (or a JSON
106
+ * error event in `--json` mode) and exit non-zero, rather than dumping a stack
107
+ * trace through the global uncaught-exception handler.
108
+ */
109
+ function action(handler) {
110
+ return async (...args) => {
111
+ try {
112
+ await handler(...args);
113
+ }
114
+ catch (error) {
115
+ const message = error instanceof Error ? error.message : String(error);
116
+ if (emitter.jsonMode) {
117
+ emitter.error(message);
118
+ }
119
+ else {
120
+ process.stderr.write(paint(`Error: ${message}\n`, "red"));
121
+ }
122
+ gracefulExit(1);
123
+ }
124
+ };
125
+ }
126
+ // ---------------------------------------------------------------------------
127
+ // Config / env / secrets
128
+ // ---------------------------------------------------------------------------
129
+ function validateDeployCommands(installCommand, buildCommand) {
130
+ if (installCommand && hasDisallowedBuildCommandContent(installCommand)) {
131
+ throw new Error("install_command contains disallowed characters or patterns.");
132
+ }
133
+ if (buildCommand && hasDisallowedBuildCommandContent(buildCommand)) {
134
+ throw new Error("build_command contains disallowed characters or patterns.");
135
+ }
136
+ }
137
+ function warnNonWolfiDistro(rawConfig) {
138
+ const distro = rawConfig.image_distro ?? "debian";
139
+ if (distro === "wolfi")
140
+ return;
141
+ emitter.note("⚠️ Security Recommendation: Consider switching to Wolfi Linux for enhanced security.");
142
+ emitter.note(" Wolfi is a security-oriented, minimal Linux distribution designed for containers.");
143
+ emitter.note(' To switch, add \'"image_distro": "wolfi"\' to your langgraph.json config file.');
144
+ }
145
+ async function resolveEnvPath(rawConfig, configPath) {
146
+ const envField = rawConfig.env;
147
+ if (isPlainObject(envField) && Object.keys(envField).length)
148
+ return null;
149
+ if (typeof envField === "string") {
150
+ const projectRoot = path.dirname(configPath);
151
+ const envPath = path.resolve(projectRoot, envField);
152
+ if (!isPathWithin(projectRoot, envPath)) {
153
+ emitter.note(`Ignoring env file '${envField}' specified in langgraph.json: ` +
154
+ "the path escapes the project directory.");
155
+ return null;
156
+ }
157
+ if (!(await exists(envPath))) {
158
+ emitter.note(`Warning: env file '${envField}' specified in langgraph.json not found.`);
159
+ return null;
160
+ }
161
+ return envPath;
162
+ }
163
+ return path.join(process.cwd(), ".env");
164
+ }
165
+ async function parseEnvFromConfig(rawConfig, configPath) {
166
+ const envField = rawConfig.env;
167
+ if (isPlainObject(envField) && Object.keys(envField).length) {
168
+ const out = {};
169
+ for (const [key, value] of Object.entries(envField)) {
170
+ out[String(key)] = String(value);
171
+ }
172
+ return out;
173
+ }
174
+ const envPath = await resolveEnvPath(rawConfig, configPath);
175
+ if (envPath === null || !(await exists(envPath)))
176
+ return {};
177
+ const parsed = dotenv.parse(await fs.readFile(envPath));
178
+ const out = {};
179
+ for (const [key, value] of Object.entries(parsed)) {
180
+ if (value != null)
181
+ out[key] = value;
182
+ }
183
+ return out;
184
+ }
185
+ async function setEnvKey(envPath, key, value) {
186
+ let content = "";
187
+ try {
188
+ content = await fs.readFile(envPath, "utf-8");
189
+ }
190
+ catch {
191
+ // file does not exist yet
192
+ }
193
+ const lines = content.length ? content.split(/\r?\n/) : [];
194
+ const pattern = new RegExp(`^\\s*(?:export\\s+)?${escapeRegExp(key)}\\s*=`);
195
+ const newLine = `${key}=${value}`;
196
+ const idx = lines.findIndex((line) => pattern.test(line));
197
+ if (idx >= 0) {
198
+ lines[idx] = newLine;
199
+ }
200
+ else if (lines.length && lines[lines.length - 1] === "") {
201
+ lines.splice(lines.length - 1, 0, newLine);
202
+ }
203
+ else {
204
+ lines.push(newLine);
205
+ }
206
+ let out = lines.join("\n");
207
+ if (!out.endsWith("\n"))
208
+ out += "\n";
209
+ await fs.writeFile(envPath, out);
210
+ }
211
+ // ---------------------------------------------------------------------------
212
+ // Host backend client factory + tenant handling
213
+ // ---------------------------------------------------------------------------
214
+ async function createHostBackendClient(hostUrl, apiKey, envVars) {
215
+ let resolvedEnv = envVars;
216
+ if (resolvedEnv === undefined) {
217
+ resolvedEnv = await parseEnvFromConfig({}, path.join(process.cwd(), "langgraph.json"));
218
+ }
219
+ let resolvedApiKey = apiKey;
220
+ if (!resolvedApiKey) {
221
+ for (const name of API_KEY_ENV_NAMES) {
222
+ if (resolvedEnv[name]) {
223
+ resolvedApiKey = resolvedEnv[name];
224
+ break;
225
+ }
226
+ if (process.env[name]) {
227
+ resolvedApiKey = process.env[name];
228
+ break;
229
+ }
230
+ }
231
+ }
232
+ if (!resolvedApiKey) {
233
+ if (noInput) {
234
+ throw new Error("No LangSmith API key found. Set LANGSMITH_API_KEY in the environment or .env file.");
235
+ }
236
+ process.stdout.write(paint("No LangSmith API key found. Create one at Settings > API Keys in LangSmith.\n", "yellow"));
237
+ resolvedApiKey = await promptHidden("Enter LangSmith API key");
238
+ }
239
+ const tenantId = resolvedEnv.LANGSMITH_TENANT_ID || process.env.LANGSMITH_TENANT_ID;
240
+ return new HostBackendClient(hostUrl, resolvedApiKey, tenantId);
241
+ }
242
+ async function callWithOptionalTenant(client, operation) {
243
+ let promptedForTenant = false;
244
+ for (;;) {
245
+ try {
246
+ return await operation(client);
247
+ }
248
+ catch (error) {
249
+ if (!(error instanceof HostBackendError))
250
+ throw error;
251
+ if (!promptedForTenant &&
252
+ error.statusCode === 403 &&
253
+ error.message.includes("requires workspace specification")) {
254
+ if (noInput) {
255
+ throw new Error("API key is org-scoped and requires a workspace ID. Set LANGSMITH_TENANT_ID " +
256
+ "in your .env file or use a workspace-scoped API key.");
257
+ }
258
+ process.stdout.write(paint("Your API key is org-scoped and requires a workspace ID.\n", "yellow"));
259
+ process.stdout.write(paint("Find your workspace ID in LangSmith under Settings > Workspaces.\n", "yellow"));
260
+ const workspaceId = await promptText("Workspace ID");
261
+ client.setTenantId(workspaceId);
262
+ promptedForTenant = true;
263
+ continue;
264
+ }
265
+ if (error.statusCode === 403 &&
266
+ error.message.toLowerCase().includes("not enabled")) {
267
+ const smithBase = smithDashboardBaseUrl(client.baseUrl);
268
+ throw new HostBackendError("LangSmith Deployment is not enabled for this organization. " +
269
+ `Enable it at ${smithBase}/host/deployments (ensure this matches the ` +
270
+ "organization for your API key).", 403);
271
+ }
272
+ throw error;
273
+ }
274
+ }
275
+ }
276
+ // ---------------------------------------------------------------------------
277
+ // Build mode resolution
278
+ // ---------------------------------------------------------------------------
279
+ const DOCKER_NOT_INSTALLED = "Docker is required but not installed.\n" +
280
+ "Install Docker Desktop: https://docs.docker.com/get-docker/";
281
+ const DOCKER_NOT_RUNNING = "Docker is installed but not running.\nStart Docker and try again.";
282
+ async function canBuildLocally() {
283
+ let opts;
284
+ try {
285
+ opts = await getExecaOptions({ reject: false });
286
+ }
287
+ catch {
288
+ return [false, DOCKER_NOT_INSTALLED];
289
+ }
290
+ try {
291
+ const info = await $(opts) `docker info`;
292
+ if (info.exitCode !== 0)
293
+ return [false, DOCKER_NOT_RUNNING];
294
+ }
295
+ catch {
296
+ return [false, DOCKER_NOT_RUNNING];
297
+ }
298
+ if (os.arch() !== "x64") {
299
+ try {
300
+ const buildx = await $(opts) `docker buildx version`;
301
+ if (buildx.exitCode !== 0) {
302
+ return [
303
+ false,
304
+ "Docker Buildx is required but not installed.\n" +
305
+ `Your machine architecture (${os.arch()}) requires Buildx to ` +
306
+ "cross-compile images for linux/amd64.\n" +
307
+ "Install Buildx: https://docs.docker.com/build/install-buildx/",
308
+ ];
309
+ }
310
+ }
311
+ catch {
312
+ return [
313
+ false,
314
+ "Docker Buildx is required but not installed.\n" +
315
+ "Install Buildx: https://docs.docker.com/build/install-buildx/",
316
+ ];
317
+ }
318
+ }
319
+ return [true, null];
320
+ }
321
+ async function resolveBuildMode(remoteBuildFlag) {
322
+ const [supported, localBuildError] = await canBuildLocally();
323
+ if (remoteBuildFlag === true)
324
+ return [true, localBuildError];
325
+ if (remoteBuildFlag === false) {
326
+ if (!supported) {
327
+ throw new Error(`${localBuildError || "Unable to build locally."}\n\n` +
328
+ "Or re-run with --remote to use remote builds.");
329
+ }
330
+ return [false, null];
331
+ }
332
+ return [!supported, localBuildError];
333
+ }
334
+ async function resolveDeployment(client, step, deploymentId, name, notFoundMessage) {
335
+ if (deploymentId) {
336
+ emitter.step(step, `Using deployment ${deploymentId}`);
337
+ await callWithOptionalTenant(client, (c) => c.getDeployment(deploymentId));
338
+ return { deploymentId, needsCreation: false, step: step + 1 };
339
+ }
340
+ emitter.step(step, `Looking up deployment '${name}'`);
341
+ const response = await callWithOptionalTenant(client, (c) => c.listDeployments(name));
342
+ const foundId = findDeploymentIdByName(response, name);
343
+ if (foundId) {
344
+ emitter.info(`Found existing deployment (ID: ${foundId})`);
345
+ return { deploymentId: foundId, needsCreation: false, step: step + 1 };
346
+ }
347
+ emitter.warn(notFoundMessage);
348
+ return { deploymentId: undefined, needsCreation: true, step: step + 1 };
349
+ }
350
+ async function createDeployment(client, step, args) {
351
+ emitter.step(step, `Creating deployment '${args.name}'`);
352
+ const created = await client.createDeployment({
353
+ name: args.name,
354
+ deploymentType: args.deploymentType,
355
+ source: args.source,
356
+ secrets: args.secrets,
357
+ });
358
+ const id = typeof created.id === "string" ? created.id : undefined;
359
+ if (!id) {
360
+ throw new HostBackendError("POST /v2/deployments succeeded but response missing a valid 'id'");
361
+ }
362
+ emitter.info(`Deployment ID: ${id}`, { deployment_id: id });
363
+ return { id, step: step + 1 };
364
+ }
365
+ // ---------------------------------------------------------------------------
366
+ // Status polling + result
367
+ // ---------------------------------------------------------------------------
368
+ function emitDeploymentStatusUrl(updated, deploymentId, hostUrl) {
369
+ const url = getDeploymentStatusUrl(updated, deploymentId, hostUrl);
370
+ if (url)
371
+ emitter.statusUrl(url);
372
+ return url;
373
+ }
374
+ async function pollRevisionStatus(client, deploymentId, options) {
375
+ const revisionsResp = await client.listRevisions(deploymentId, 1);
376
+ const resources = Array.isArray(revisionsResp.resources)
377
+ ? revisionsResp.resources
378
+ : [];
379
+ if (!resources.length)
380
+ return ["", null];
381
+ const revisionId = String(rec(resources[0]).id);
382
+ let lastStatus = "";
383
+ const deadline = Date.now() + options.timeoutSeconds * 1000;
384
+ const startTime = Date.now();
385
+ let lastHeartbeat = startTime;
386
+ const json = emitter.jsonMode;
387
+ let interrupted = false;
388
+ const onSigint = () => {
389
+ interrupted = true;
390
+ };
391
+ process.on("SIGINT", onSigint);
392
+ const spinner = new Spinner(options.progressMessage, {
393
+ elapsed: true,
394
+ jsonMode: json,
395
+ });
396
+ if (options.progressMessage)
397
+ spinner.set(options.progressMessage);
398
+ try {
399
+ while (Date.now() < deadline) {
400
+ if (interrupted) {
401
+ spinner.set("");
402
+ if (options.onInterrupt) {
403
+ options.onInterrupt(revisionId);
404
+ gracefulExit(1);
405
+ return [lastStatus, revisionId];
406
+ }
407
+ }
408
+ const rev = await client.getRevision(deploymentId, revisionId);
409
+ const status = typeof rec(rev).status === "string"
410
+ ? rec(rev).status
411
+ : "UNKNOWN";
412
+ if (status !== lastStatus) {
413
+ spinner.set("");
414
+ if (lastStatus) {
415
+ emitter.statusChange(lastStatus, (Date.now() - startTime) / 1000);
416
+ }
417
+ lastStatus = status;
418
+ if (TERMINAL_STATUSES.has(status))
419
+ break;
420
+ spinner.set(`${status}...`);
421
+ lastHeartbeat = Date.now();
422
+ }
423
+ else if (json && Date.now() - lastHeartbeat > 10_000) {
424
+ emitter.heartbeat(lastStatus, (Date.now() - startTime) / 1000);
425
+ lastHeartbeat = Date.now();
426
+ }
427
+ if (options.onPoll) {
428
+ await options.onPoll(status, revisionId, (message) => spinner.set(message));
429
+ }
430
+ await sleep(options.pollIntervalSeconds * 1000);
431
+ }
432
+ spinner.set("");
433
+ }
434
+ finally {
435
+ spinner.stop();
436
+ process.off("SIGINT", onSigint);
437
+ }
438
+ return [lastStatus, revisionId];
439
+ }
440
+ async function printDeploymentResult(client, deploymentId, lastStatus, options) {
441
+ const depInfo = await client.getDeployment(deploymentId);
442
+ const sourceConfig = rec(depInfo).source_config;
443
+ const customUrl = isPlainObject(sourceConfig) && typeof sourceConfig.custom_url === "string"
444
+ ? sourceConfig.custom_url
445
+ : null;
446
+ if (lastStatus === "DEPLOYED") {
447
+ emitter.result("succeeded", {
448
+ deploymentId,
449
+ url: customUrl,
450
+ statusUrl: options.statusUrl,
451
+ });
452
+ }
453
+ else if (["BUILD_FAILED", "DEPLOY_FAILED", "CREATE_FAILED"].includes(lastStatus)) {
454
+ emitter.result("failed", { deploymentId, statusUrl: options.statusUrl });
455
+ gracefulExit(1);
456
+ }
457
+ else {
458
+ emitter.result("timed_out", {
459
+ deploymentId,
460
+ statusUrl: options.statusUrl,
461
+ fallbackStatusMessage: `Check status in the LangSmith ${options.dashboardLabel}.`,
462
+ });
463
+ }
464
+ }
465
+ // ---------------------------------------------------------------------------
466
+ // Docker push auth + digest resolution
467
+ // ---------------------------------------------------------------------------
468
+ async function dockerConfigForToken(registryHost, token) {
469
+ const authB64 = Buffer.from(`oauth2accesstoken:${token}`).toString("base64");
470
+ const data = { auths: { [registryHost]: { auth: authB64 } } };
471
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "langgraph-docker-"));
472
+ await fs.writeFile(path.join(dir, "config.json"), JSON.stringify(data));
473
+ return {
474
+ dir,
475
+ cleanup: async () => {
476
+ await fs.rm(dir, { recursive: true, force: true });
477
+ },
478
+ };
479
+ }
480
+ async function resolvePushedImageDigest(opts, remoteImage) {
481
+ // rsplit on ":" preserves any ":port" in the registry host.
482
+ const repoNoTag = remoteImage.slice(0, remoteImage.lastIndexOf(":"));
483
+ let digests = [];
484
+ try {
485
+ const { stdout } = await $(opts) `docker image inspect --format ${"{{json .RepoDigests}}"} ${remoteImage}`;
486
+ digests = JSON.parse(String(stdout || "[]")) || [];
487
+ }
488
+ catch {
489
+ digests = [];
490
+ }
491
+ for (const digest of digests) {
492
+ if (typeof digest === "string" &&
493
+ digest.startsWith(`${repoNoTag}@sha256:`)) {
494
+ return digest;
495
+ }
496
+ }
497
+ emitter.warn(`Could not resolve image digest for ${remoteImage}; falling back to the ` +
498
+ "tag-based reference. Re-run with --verbose for details.");
499
+ return remoteImage;
500
+ }
501
+ // ---------------------------------------------------------------------------
502
+ // GCS upload
503
+ // ---------------------------------------------------------------------------
504
+ /** Chunk size used when streaming the source archive upload (1 MiB). */
505
+ const UPLOAD_CHUNK_SIZE = 1024 * 1024;
506
+ async function uploadToGcs(signedUrl, filePath, fileSize) {
507
+ const sizeMb = fileSize / BYTES_PER_MIB;
508
+ const handle = await fs.open(filePath, "r");
509
+ let closed = false;
510
+ const closeHandle = async () => {
511
+ if (closed)
512
+ return;
513
+ closed = true;
514
+ await handle.close().catch(() => { });
515
+ };
516
+ let uploaded = 0;
517
+ let lastPct = -1;
518
+ emitter.uploadProgress(sizeMb, 0);
519
+ // Stream the archive in chunks so progress reflects bytes actually sent
520
+ // (instead of buffering the whole file in memory and jumping 0% -> 100%).
521
+ const body = new ReadableStream({
522
+ async pull(controller) {
523
+ const buffer = new Uint8Array(UPLOAD_CHUNK_SIZE);
524
+ const { bytesRead } = await handle.read(buffer, 0, UPLOAD_CHUNK_SIZE, uploaded);
525
+ if (bytesRead === 0) {
526
+ await closeHandle();
527
+ controller.close();
528
+ return;
529
+ }
530
+ uploaded += bytesRead;
531
+ controller.enqueue(buffer.subarray(0, bytesRead));
532
+ const pct = fileSize ? Math.floor((uploaded * 100) / fileSize) : 100;
533
+ if (pct !== lastPct) {
534
+ emitter.uploadProgress(sizeMb, pct);
535
+ lastPct = pct;
536
+ }
537
+ },
538
+ async cancel() {
539
+ await closeHandle();
540
+ },
541
+ });
542
+ try {
543
+ // `duplex: "half"` is required by undici when sending a streaming body.
544
+ const response = await fetch(signedUrl, {
545
+ method: "PUT",
546
+ headers: {
547
+ "Content-Type": "application/gzip",
548
+ "Content-Length": String(fileSize),
549
+ "X-Goog-Content-Length-Range": "0,209715200",
550
+ },
551
+ body,
552
+ duplex: "half",
553
+ });
554
+ if (!response.ok) {
555
+ const detail = await response.text().catch(() => "");
556
+ throw new Error(`Upload failed with status ${response.status}: ${detail}`);
557
+ }
558
+ }
559
+ finally {
560
+ await closeHandle();
561
+ }
562
+ if (lastPct !== 100)
563
+ emitter.uploadProgress(sizeMb, 100);
564
+ if (!emitter.jsonMode)
565
+ process.stdout.write("\n");
566
+ }
567
+ async function runLocalBuild(args) {
568
+ const projectDir = path.dirname(args.configPath);
569
+ const localDeps = await assembleLocalDeps(args.configPath, args.config);
570
+ // Resolve the base image, honoring `--base-image` / `--api-version`. When
571
+ // neither is supplied this equals the config's own base image, so the
572
+ // rewrite below is a no-op.
573
+ const baseImageRef = args.baseImage ?? getBaseImage(args.config, args.apiVersion);
574
+ const generatedDockerfile = await configToDocker(args.configPath, args.config, localDeps, {
575
+ watch: false,
576
+ dockerCommand: "build",
577
+ installCommand: args.installCommand,
578
+ buildCommand: args.buildCommand,
579
+ });
580
+ // `configToDocker` emits `FROM ${getBaseImage(config)}` using the config's
581
+ // own base image; rewrite the first FROM instruction so the resolved base
582
+ // image (including any override) is what gets built.
583
+ const dockerfile = generatedDockerfile.replace(/^FROM .*$/m, `FROM ${baseImageRef}`);
584
+ const needsBuildx = os.arch() !== "x64";
585
+ const localTag = `langgraph-deploy-tmp:${Math.floor(Date.now() / 1000)}`;
586
+ const baseOpts = await getExecaOptions({ cwd: projectDir });
587
+ const stdio = args.verbose
588
+ ? { stdout: "inherit", stderr: "inherit" }
589
+ : { stdout: "ignore", stderr: "ignore" };
590
+ let step = args.step;
591
+ // -- Pull base image --
592
+ if (args.pull) {
593
+ await $({ ...baseOpts, ...stdio }) `docker pull ${baseImageRef}`;
594
+ }
595
+ // -- Build image --
596
+ emitter.step(step, "Building image");
597
+ const buildCmd = needsBuildx
598
+ ? [
599
+ "buildx",
600
+ "build",
601
+ "--platform",
602
+ "linux/amd64",
603
+ "--load",
604
+ ...(args.verbose ? [] : ["--progress=quiet"]),
605
+ "-f",
606
+ "-",
607
+ "-t",
608
+ localTag,
609
+ projectDir,
610
+ ...args.dockerBuildArgs,
611
+ ]
612
+ : ["build", "-f", "-", "-t", localTag, projectDir, ...args.dockerBuildArgs];
613
+ const buildSpinner = new Spinner("Building...", {
614
+ elapsed: !args.verbose,
615
+ jsonMode: emitter.jsonMode,
616
+ });
617
+ if (!args.verbose)
618
+ buildSpinner.set("Building...");
619
+ try {
620
+ await $({ ...baseOpts, ...stdio, input: dockerfile }) `docker ${buildCmd}`;
621
+ }
622
+ finally {
623
+ buildSpinner.stop();
624
+ }
625
+ step += 1;
626
+ // -- Request push token --
627
+ emitter.step(step, "Requesting push token");
628
+ let pushData;
629
+ try {
630
+ pushData = await args.client.requestPushToken(args.deploymentId);
631
+ }
632
+ catch (error) {
633
+ if (error instanceof HostBackendError &&
634
+ error.statusCode === 400 &&
635
+ error.message.includes("only available for 'internal_docker' source deployments")) {
636
+ throw new Error(`Deployment '${args.deploymentId}' was not created by 'langgraph deploy' ` +
637
+ "and cannot be updated with this command.\n" +
638
+ "Please create a new deployment by running 'langgraph deploy' without " +
639
+ "--deployment-id, or use a different --name.");
640
+ }
641
+ throw error;
642
+ }
643
+ const deploymentToken = typeof pushData.token === "string" ? pushData.token : undefined;
644
+ const registryUrl = typeof pushData.registry_url === "string"
645
+ ? pushData.registry_url
646
+ : undefined;
647
+ if (!deploymentToken || !registryUrl) {
648
+ throw new Error("Push token response missing token or registry_url");
649
+ }
650
+ step += 1;
651
+ let normalizedRegistry = registryUrl.replace(/\/+$/, "");
652
+ if (normalizedRegistry.includes("://")) {
653
+ normalizedRegistry = normalizedRegistry.slice(normalizedRegistry.indexOf("//") + 2);
654
+ }
655
+ const repoSeed = args.imageName || args.name || path.basename(projectDir);
656
+ const repoName = normalizeName(repoSeed);
657
+ const tagValue = normalizeImageTag(args.tag);
658
+ const remoteImage = `${normalizedRegistry}/${repoName}:${tagValue}`;
659
+ const registryHost = normalizedRegistry.split("/")[0];
660
+ const { dir: cfgDir, cleanup } = await dockerConfigForToken(registryHost, deploymentToken);
661
+ try {
662
+ // -- Login --
663
+ emitter.step(step, `Logging into ${registryHost}`);
664
+ const tokenInput = deploymentToken.endsWith("\n")
665
+ ? deploymentToken
666
+ : `${deploymentToken}\n`;
667
+ await $({
668
+ ...baseOpts,
669
+ ...stdio,
670
+ input: tokenInput,
671
+ }) `docker --config ${cfgDir} login -u oauth2accesstoken --password-stdin ${registryHost}`;
672
+ step += 1;
673
+ // -- Tag + push --
674
+ emitter.step(step, `Pushing image ${remoteImage}`);
675
+ await $({ ...baseOpts, ...stdio }) `docker tag ${localTag} ${remoteImage}`;
676
+ const maxPushRetries = 3;
677
+ for (let attempt = 0; attempt < maxPushRetries; attempt += 1) {
678
+ const pushSpinner = new Spinner("Pushing...", {
679
+ elapsed: !args.verbose,
680
+ jsonMode: emitter.jsonMode,
681
+ });
682
+ if (!args.verbose)
683
+ pushSpinner.set("Pushing...");
684
+ try {
685
+ await $({
686
+ ...baseOpts,
687
+ ...stdio,
688
+ }) `docker --config ${cfgDir} push ${remoteImage}`;
689
+ pushSpinner.stop();
690
+ break;
691
+ }
692
+ catch (error) {
693
+ pushSpinner.stop();
694
+ if (attempt < maxPushRetries - 1) {
695
+ emitter.warn(`Push failed, retrying (attempt ${attempt + 2} of ${maxPushRetries})...`);
696
+ }
697
+ else {
698
+ throw error;
699
+ }
700
+ }
701
+ }
702
+ step += 1;
703
+ }
704
+ finally {
705
+ await cleanup();
706
+ }
707
+ const resolvedImage = await resolvePushedImageDigest(baseOpts, remoteImage);
708
+ // -- Update deployment --
709
+ emitter.step(step, `Updating deployment ${args.deploymentId}`);
710
+ const updated = await args.client.updateDeployment(args.deploymentId, resolvedImage, { secrets: args.secrets });
711
+ return {
712
+ updated: rec(updated),
713
+ progressMessage: "Deploying...",
714
+ timeoutSeconds: 300,
715
+ pollIntervalSeconds: 1,
716
+ noResultMessage: "Deployment updated",
717
+ showBuildLogsOnFailure: false,
718
+ };
719
+ }
720
+ async function runRemoteBuild(args) {
721
+ let step = args.step;
722
+ emitter.step(step, "Creating source archive");
723
+ const archive = await createArchive(args.configPath, {
724
+ onWarn: (message) => emitter.warn(message),
725
+ });
726
+ let configRel;
727
+ try {
728
+ emitter.info(`Archive created (${(archive.fileSize / BYTES_PER_MIB).toFixed(1)} MB)`);
729
+ configRel = archive.configRel;
730
+ step += 1;
731
+ emitter.step(step, "Requesting upload URL");
732
+ const uploadData = await args.client.requestUploadUrl(args.deploymentId);
733
+ const signedUrl = typeof uploadData.upload_url === "string"
734
+ ? uploadData.upload_url
735
+ : undefined;
736
+ const objectPath = typeof uploadData.object_path === "string"
737
+ ? uploadData.object_path
738
+ : undefined;
739
+ if (!signedUrl || !objectPath) {
740
+ throw new Error("Upload URL response missing required fields");
741
+ }
742
+ step += 1;
743
+ emitter.step(step, "Uploading source");
744
+ await uploadToGcs(signedUrl, archive.archivePath, archive.fileSize);
745
+ step += 1;
746
+ emitter.step(step, "Triggering remote build");
747
+ const updated = await args.client.updateDeploymentInternalSource(args.deploymentId, {
748
+ sourceTarballPath: objectPath,
749
+ configPath: configRel,
750
+ secrets: args.secrets,
751
+ installCommand: args.installCommand,
752
+ buildCommand: args.buildCommand,
753
+ });
754
+ let logOffset = null;
755
+ let logsHeaderPrinted = false;
756
+ const onPoll = async (status, revisionId, setProgress) => {
757
+ if (!(args.verbose &&
758
+ (status === "AWAITING_BUILD" || status === "BUILDING"))) {
759
+ return;
760
+ }
761
+ try {
762
+ const payload = logOffset
763
+ ? { order: "asc", limit: 50, offset: logOffset }
764
+ : { order: "asc", limit: 50 };
765
+ const logsResp = await args.client.getBuildLogs(args.deploymentId, revisionId, payload);
766
+ const entries = Array.isArray(logsResp.logs)
767
+ ? logsResp.logs
768
+ : [];
769
+ const hasOutput = entries.some((entry) => entry.message);
770
+ if (hasOutput) {
771
+ setProgress("");
772
+ if (!logsHeaderPrinted) {
773
+ emitter.info(`${status} (build logs):`);
774
+ logsHeaderPrinted = true;
775
+ }
776
+ }
777
+ for (const entry of entries) {
778
+ if (entry.message)
779
+ emitter.log(String(entry.message));
780
+ }
781
+ if (typeof logsResp.next_offset === "string") {
782
+ logOffset = logsResp.next_offset;
783
+ }
784
+ if (hasOutput)
785
+ setProgress(`${status}...`);
786
+ }
787
+ catch {
788
+ // best-effort log streaming
789
+ }
790
+ };
791
+ const onInterrupt = (revisionId) => {
792
+ emitter.warn(`\nInterrupted. Deployment ID: ${args.deploymentId}, Revision ID: ${revisionId}`);
793
+ emitter.warn("The build will continue remotely.");
794
+ };
795
+ return {
796
+ updated: rec(updated),
797
+ progressMessage: "",
798
+ timeoutSeconds: 900,
799
+ pollIntervalSeconds: 3,
800
+ noResultMessage: "Build triggered",
801
+ onPoll,
802
+ onInterrupt,
803
+ showBuildLogsOnFailure: true,
804
+ };
805
+ }
806
+ finally {
807
+ await archive.cleanup();
808
+ }
809
+ }
810
+ async function runDeploy(dockerBuildArgs, opts, remoteBuildFlag) {
811
+ emitter = new Emitter(opts.json);
812
+ noInput = !opts.input;
813
+ emitter.note("Note: 'langgraphjs deploy' is in beta. Expect frequent updates and improvements.");
814
+ if (!opts.json)
815
+ process.stdout.write("\n");
816
+ if (!["dev", "prod"].includes(opts.deploymentType)) {
817
+ throw new Error(`Invalid --deployment-type '${opts.deploymentType}' (expected 'dev' or 'prod').`);
818
+ }
819
+ // -- 1. Preflight --
820
+ validateDeployCommands(opts.installCommand, opts.buildCommand);
821
+ const configPath = await getProjectPath(opts.config);
822
+ const rawConfig = JSON.parse(await fs.readFile(configPath, "utf-8"));
823
+ const config = getConfig(rawConfig);
824
+ warnNonWolfiDistro(rawConfig);
825
+ const envVars = await parseEnvFromConfig(rawConfig, configPath);
826
+ let name = opts.name;
827
+ let deploymentId = opts.deploymentId;
828
+ if (!deploymentId && !name) {
829
+ name = envVars[DEPLOYMENT_NAME_ENV] ?? process.env[DEPLOYMENT_NAME_ENV];
830
+ }
831
+ if (!deploymentId && !name) {
832
+ const defaultName = normalizeName(path.basename(process.cwd()));
833
+ name = noInput
834
+ ? defaultName
835
+ : await promptText("Deployment name", defaultName);
836
+ }
837
+ if (name && !deploymentId) {
838
+ name = normalizeName(name);
839
+ if (!noInput) {
840
+ const envPath = await resolveEnvPath(rawConfig, configPath);
841
+ if (envPath) {
842
+ await setEnvKey(envPath, DEPLOYMENT_NAME_ENV, name);
843
+ emitter.info(`Saved deployment name to ${envPath}`);
844
+ }
845
+ }
846
+ }
847
+ const secrets = secretsFromEnv(envWithoutDeploymentName(envVars), (skipped) => emitter.note(`Skipping reserved env var: ${skipped}`));
848
+ const [useRemoteBuild, localBuildError] = await resolveBuildMode(remoteBuildFlag);
849
+ if (useRemoteBuild && remoteBuildFlag === undefined && localBuildError) {
850
+ emitter.note(`${localBuildError}\nUsing remote build instead.`);
851
+ if (!opts.json)
852
+ process.stdout.write("\n");
853
+ }
854
+ // -- 2. Resolve / create deployment --
855
+ const client = await createHostBackendClient(opts.hostUrl, opts.apiKey, envVars);
856
+ let step = 1;
857
+ const resolved = await resolveDeployment(client, step, deploymentId, name, useRemoteBuild
858
+ ? "No deployment found. Will create."
859
+ : "No deployment found. Will create after build.");
860
+ deploymentId = resolved.deploymentId;
861
+ step = resolved.step;
862
+ if (resolved.needsCreation) {
863
+ const created = await createDeployment(client, step, {
864
+ name: name,
865
+ deploymentType: opts.deploymentType,
866
+ source: useRemoteBuild ? "internal_source" : "internal_docker",
867
+ secrets,
868
+ });
869
+ deploymentId = created.id;
870
+ step = created.step;
871
+ }
872
+ if (!deploymentId) {
873
+ throw new Error("Failed to determine deployment ID");
874
+ }
875
+ // -- 3. Build (divergent path) --
876
+ const buildResult = useRemoteBuild
877
+ ? await runRemoteBuild({
878
+ client,
879
+ deploymentId,
880
+ step,
881
+ configPath,
882
+ verbose: opts.verbose,
883
+ installCommand: opts.installCommand,
884
+ buildCommand: opts.buildCommand,
885
+ secrets,
886
+ })
887
+ : await runLocalBuild({
888
+ client,
889
+ deploymentId,
890
+ step,
891
+ configPath,
892
+ config,
893
+ verbose: opts.verbose,
894
+ pull: opts.pull,
895
+ apiVersion: opts.apiVersion,
896
+ baseImage: opts.baseImage,
897
+ imageName: opts.imageName,
898
+ name,
899
+ tag: opts.tag,
900
+ installCommand: opts.installCommand,
901
+ buildCommand: opts.buildCommand,
902
+ dockerBuildArgs,
903
+ secrets,
904
+ });
905
+ // -- 4. Shared wait + result --
906
+ const depStatusUrl = emitDeploymentStatusUrl(buildResult.updated, deploymentId, opts.hostUrl);
907
+ if (!opts.wait) {
908
+ emitter.info(buildResult.noResultMessage);
909
+ return;
910
+ }
911
+ const [lastStatus, revisionId] = await pollRevisionStatus(client, deploymentId, {
912
+ progressMessage: buildResult.progressMessage,
913
+ timeoutSeconds: buildResult.timeoutSeconds,
914
+ pollIntervalSeconds: buildResult.pollIntervalSeconds,
915
+ onPoll: buildResult.onPoll,
916
+ onInterrupt: buildResult.onInterrupt,
917
+ });
918
+ if (!lastStatus) {
919
+ emitter.info(buildResult.noResultMessage);
920
+ return;
921
+ }
922
+ if (buildResult.showBuildLogsOnFailure &&
923
+ lastStatus === "BUILD_FAILED" &&
924
+ !opts.verbose &&
925
+ revisionId) {
926
+ emitter.error("Last build log lines:");
927
+ try {
928
+ const logsResp = await client.getBuildLogs(deploymentId, revisionId, {
929
+ order: "desc",
930
+ limit: 30,
931
+ });
932
+ const entries = Array.isArray(logsResp.logs)
933
+ ? [...logsResp.logs].reverse()
934
+ : [];
935
+ for (const entry of entries) {
936
+ if (entry.message)
937
+ emitter.log(String(entry.message));
938
+ }
939
+ }
940
+ catch {
941
+ emitter.error("(failed to fetch build logs)");
942
+ }
943
+ emitter.warn("Re-run with --verbose to see full build output.");
944
+ }
945
+ await printDeploymentResult(client, deploymentId, lastStatus, {
946
+ dashboardLabel: "Deployment dashboard",
947
+ statusUrl: depStatusUrl,
948
+ });
949
+ }
950
+ // ---------------------------------------------------------------------------
951
+ // Subcommand handlers
952
+ // ---------------------------------------------------------------------------
953
+ async function runList(opts) {
954
+ emitter = new Emitter(false);
955
+ noInput = false;
956
+ const client = await createHostBackendClient(opts.hostUrl, opts.apiKey);
957
+ const response = await callWithOptionalTenant(client, (c) => c.listDeployments(opts.nameContains));
958
+ const resources = Array.isArray(rec(response).resources)
959
+ ? rec(response).resources
960
+ : [];
961
+ const deployments = resources.filter(isPlainObject);
962
+ if (!deployments.length) {
963
+ process.stdout.write("No deployments found.\n");
964
+ return;
965
+ }
966
+ process.stdout.write(`${formatDeploymentsTable(deployments)}\n`);
967
+ }
968
+ async function runRevisionsList(deploymentId, opts) {
969
+ emitter = new Emitter(false);
970
+ noInput = false;
971
+ const client = await createHostBackendClient(opts.hostUrl, opts.apiKey);
972
+ const limit = Number.parseInt(opts.limit, 10) || 10;
973
+ const response = await callWithOptionalTenant(client, (c) => c.listRevisions(deploymentId, limit));
974
+ const resources = Array.isArray(rec(response).resources)
975
+ ? rec(response).resources
976
+ : [];
977
+ const revisions = resources.filter(isPlainObject);
978
+ if (!revisions.length) {
979
+ process.stdout.write(`No revisions found for deployment ${deploymentId}.\n`);
980
+ return;
981
+ }
982
+ process.stdout.write(`${formatRevisionsTable(revisions)}\n`);
983
+ }
984
+ async function runDelete(deploymentId, opts) {
985
+ emitter = new Emitter(false);
986
+ noInput = false;
987
+ if (!opts.force) {
988
+ const confirmed = await confirm(paint(`Are you sure you want to delete deployment ID ${deploymentId}? (Y/n)`, "yellow"));
989
+ if (!confirmed) {
990
+ process.stdout.write("Aborted.\n");
991
+ gracefulExit(1);
992
+ return;
993
+ }
994
+ }
995
+ const client = await createHostBackendClient(opts.hostUrl, opts.apiKey);
996
+ await callWithOptionalTenant(client, (c) => c.deleteDeployment(deploymentId));
997
+ process.stdout.write(paint(`Deleted deployment ${deploymentId}.\n`, "green"));
998
+ }
999
+ async function runLogs(opts) {
1000
+ emitter = new Emitter(false);
1001
+ noInput = false;
1002
+ const envVars = await parseEnvFromConfig({}, path.join(process.cwd(), "langgraph.json"));
1003
+ const client = await createHostBackendClient(opts.hostUrl, opts.apiKey, envVars);
1004
+ let name = opts.name;
1005
+ if (!opts.deploymentId && !name) {
1006
+ name = envVars[DEPLOYMENT_NAME_ENV] ?? process.env[DEPLOYMENT_NAME_ENV];
1007
+ }
1008
+ if (!opts.deploymentId && !name) {
1009
+ throw new Error("Either --deployment-id or --name is required.");
1010
+ }
1011
+ let depId;
1012
+ if (opts.deploymentId) {
1013
+ depId = opts.deploymentId;
1014
+ }
1015
+ else {
1016
+ const response = await callWithOptionalTenant(client, (c) => c.listDeployments(name));
1017
+ const found = findDeploymentIdByName(response, name);
1018
+ if (!found)
1019
+ throw new Error(`Deployment '${name}' not found.`);
1020
+ depId = found;
1021
+ }
1022
+ let revisionId = opts.revisionId;
1023
+ if (opts.type === "build" && !revisionId) {
1024
+ const revisionsResp = await client.listRevisions(depId, 1);
1025
+ const resources = Array.isArray(rec(revisionsResp).resources)
1026
+ ? rec(revisionsResp).resources
1027
+ : [];
1028
+ if (!resources.length) {
1029
+ throw new Error("No revisions found for this deployment. Cannot fetch build logs.");
1030
+ }
1031
+ revisionId = String(rec(resources[0]).id);
1032
+ process.stdout.write(paint(`Using latest revision: ${revisionId}\n`, "cyan"));
1033
+ }
1034
+ const limit = Number.parseInt(opts.limit, 10) || 100;
1035
+ const payload = { limit, order: "desc" };
1036
+ if (opts.level)
1037
+ payload.level = opts.level.toUpperCase();
1038
+ if (opts.query)
1039
+ payload.query = opts.query;
1040
+ if (opts.startTime)
1041
+ payload.start_time = opts.startTime;
1042
+ if (opts.endTime)
1043
+ payload.end_time = opts.endTime;
1044
+ const fetchLogs = async (requestPayload) => {
1045
+ const resp = opts.type === "build"
1046
+ ? await client.getBuildLogs(depId, revisionId, requestPayload)
1047
+ : await client.getDeployLogs(depId, requestPayload, revisionId);
1048
+ const logs = rec(resp).logs;
1049
+ return Array.isArray(logs) ? logs : [];
1050
+ };
1051
+ const printEntries = (entries, reverse = false) => {
1052
+ const iterable = reverse ? [...entries].reverse() : entries;
1053
+ for (const entry of iterable) {
1054
+ const line = formatLogEntry(entry);
1055
+ const color = levelColor(entry.level);
1056
+ process.stdout.write(`${color ? paint(line, color) : line}\n`);
1057
+ }
1058
+ };
1059
+ const initial = await fetchLogs(payload);
1060
+ printEntries(initial, true);
1061
+ if (!opts.follow) {
1062
+ if (!initial.length) {
1063
+ process.stdout.write(paint("No log entries found.\n", "yellow"));
1064
+ }
1065
+ return;
1066
+ }
1067
+ payload.order = "asc";
1068
+ const seenIds = new Set();
1069
+ for (const entry of initial) {
1070
+ if (entry.id)
1071
+ seenIds.add(String(entry.id));
1072
+ }
1073
+ const updateStartTime = (ts) => {
1074
+ if (ts == null)
1075
+ return;
1076
+ if (typeof ts === "number") {
1077
+ payload.start_time = new Date(ts).toISOString();
1078
+ }
1079
+ else {
1080
+ payload.start_time = String(ts);
1081
+ }
1082
+ };
1083
+ if (initial.length)
1084
+ updateStartTime(initial[0].timestamp);
1085
+ let stopped = false;
1086
+ process.on("SIGINT", () => {
1087
+ stopped = true;
1088
+ process.stdout.write("\nStopped.\n");
1089
+ gracefulExit(0);
1090
+ });
1091
+ while (!stopped) {
1092
+ await sleep(2000);
1093
+ const entries = await fetchLogs(payload);
1094
+ const fresh = entries.filter((entry) => !seenIds.has(String(entry.id ?? "")));
1095
+ if (fresh.length) {
1096
+ printEntries(fresh);
1097
+ for (const entry of fresh) {
1098
+ if (entry.id)
1099
+ seenIds.add(String(entry.id));
1100
+ }
1101
+ updateStartTime(fresh[fresh.length - 1].timestamp);
1102
+ }
1103
+ }
1104
+ }
1105
+ // ---------------------------------------------------------------------------
1106
+ // Command registration
1107
+ // ---------------------------------------------------------------------------
1108
+ const deploy = builder
1109
+ .command("deploy")
1110
+ .description("[Beta] Build and deploy a LangGraph image to LangSmith Deployment.")
1111
+ .option("-c, --config <path>", "Path to configuration file", process.cwd())
1112
+ .option("--api-key <key>", "API key (or LANGGRAPH_HOST_API_KEY / LANGSMITH_API_KEY / LANGCHAIN_API_KEY).")
1113
+ .option("--name <name>", "Deployment name (or LANGSMITH_DEPLOYMENT_NAME). Defaults to the current directory name.")
1114
+ .option("--deployment-id <id>", "ID of an existing deployment to update.")
1115
+ .option("--deployment-type <type>", "Deployment type when creating: 'dev' or 'prod'.", "dev")
1116
+ .option("--no-wait", "Skip waiting for deployment status.")
1117
+ .option("--verbose", "Show more output from the server logs.", false)
1118
+ .option("--host-url <url>", "Host backend URL.", DEFAULT_HOST_URL)
1119
+ .option("--image-name <name>", "Image repository name for the pushed image.")
1120
+ .option("-t, --tag <tag>", "Tag to use for the pushed deployment image.", "latest")
1121
+ .option("--no-pull", "Do not pull the latest base image before building locally.")
1122
+ .option("--base-image <image>", "Override the base image.")
1123
+ .option("--install-command <cmd>", "Custom install command (remote build).")
1124
+ .option("--build-command <cmd>", "Custom build command (remote build).")
1125
+ .option("--api-version <version>", "Pin the langgraph-api version.")
1126
+ .option("--remote", "Force a remote build.")
1127
+ .option("--no-remote", "Force a local build.")
1128
+ .option("--json", "Emit structured JSON-lines to stdout instead of human-readable text.", false)
1129
+ .option("--no-input", "Never prompt for input; fail with an error if a required value is missing.")
1130
+ .argument("[dockerBuildArgs...]", "Extra arguments passed through to docker build")
1131
+ .passThroughOptions()
1132
+ .allowUnknownOption()
1133
+ .exitOverride((error) => gracefulExit(error.exitCode))
1134
+ .hook("preAction", withAnalytics((command) => ({
1135
+ config: command.opts().config !== process.cwd(),
1136
+ remote: command.opts().remote !== true,
1137
+ json: !!command.opts().json,
1138
+ no_wait: !command.opts().wait,
1139
+ deployment_id: !!command.opts().deploymentId,
1140
+ })))
1141
+ .action(action(async (dockerBuildArgs, opts, command) => {
1142
+ const source = command.getOptionValueSource("remote");
1143
+ const remoteBuildFlag = source === "cli" ? opts.remote : undefined;
1144
+ await runDeploy(dockerBuildArgs ?? [], opts, remoteBuildFlag);
1145
+ }));
1146
+ deploy
1147
+ .command("list")
1148
+ .description("[Beta] List LangSmith Deployments.")
1149
+ .option("--api-key <key>", "API key.")
1150
+ .option("--host-url <url>", "Host backend URL.", DEFAULT_HOST_URL)
1151
+ .option("--name-contains <value>", "Only show deployments whose names contain this value.", "")
1152
+ .exitOverride((error) => gracefulExit(error.exitCode))
1153
+ .action(action(async (opts) => {
1154
+ await runList(opts);
1155
+ }));
1156
+ const revisions = deploy
1157
+ .command("revisions")
1158
+ .description("[Beta] Manage deployment revisions.");
1159
+ revisions
1160
+ .command("list")
1161
+ .description("[Beta] List revisions for a LangSmith Deployment.")
1162
+ .argument("<deploymentId>", "Deployment ID (see 'deploy list').")
1163
+ .option("--api-key <key>", "API key.")
1164
+ .option("--host-url <url>", "Host backend URL.", DEFAULT_HOST_URL)
1165
+ .option("--limit <n>", "Maximum number of revisions to return.", "10")
1166
+ .exitOverride((error) => gracefulExit(error.exitCode))
1167
+ .action(action(async (deploymentId, opts) => {
1168
+ await runRevisionsList(deploymentId, opts);
1169
+ }));
1170
+ deploy
1171
+ .command("delete")
1172
+ .description("[Beta] Delete a LangSmith Deployment.")
1173
+ .argument("<deploymentId>", "Deployment ID (see 'deploy list').")
1174
+ .option("--api-key <key>", "API key.")
1175
+ .option("--host-url <url>", "Host backend URL.", DEFAULT_HOST_URL)
1176
+ .option("--force", "Delete without prompting for confirmation.", false)
1177
+ .exitOverride((error) => gracefulExit(error.exitCode))
1178
+ .action(action(async (deploymentId, opts) => {
1179
+ await runDelete(deploymentId, opts);
1180
+ }));
1181
+ deploy
1182
+ .command("logs")
1183
+ .description("[Beta] Fetch LangSmith Deployment logs.")
1184
+ .option("--api-key <key>", "API key.")
1185
+ .option("--name <name>", "Deployment name (or LANGSMITH_DEPLOYMENT_NAME).")
1186
+ .option("--deployment-id <id>", "Deployment ID.")
1187
+ .option("--type <type>", "Log stream: 'deploy' (runtime) or 'build' (remote build logs).", "deploy")
1188
+ .option("--revision-id <id>", "Specific revision ID.")
1189
+ .option("--level <level>", "Filter by log level.")
1190
+ .option("--limit <n>", "Max log entries to fetch.", "100")
1191
+ .option("-q, --query <query>", "Search string filter.")
1192
+ .option("--start-time <ts>", "ISO8601 start time.")
1193
+ .option("--end-time <ts>", "ISO8601 end time.")
1194
+ .option("-f, --follow", "Continuously poll for new logs.", false)
1195
+ .option("--host-url <url>", "Host backend URL.", DEFAULT_HOST_URL)
1196
+ .exitOverride((error) => gracefulExit(error.exitCode))
1197
+ .action(action(async (opts) => {
1198
+ await runLogs(opts);
1199
+ }));