@langchain/langgraph-cli 1.3.1 → 1.4.1

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,334 @@
1
+ /**
2
+ * HTTP client for the LangGraph host backend (LangSmith Deployment).
3
+ *
4
+ * Ported from the Python CLI's `host_backend.py`. Uses the global `fetch`
5
+ * with a small retry loop on transport-level failures.
6
+ */
7
+ /**
8
+ * Error raised when the host backend returns a non-success response or a
9
+ * request fails at the transport level.
10
+ *
11
+ * @remarks
12
+ * {@link statusCode} is populated for HTTP error responses and left
13
+ * `undefined` for transport-level failures (network, DNS, timeout).
14
+ */
15
+ export class HostBackendError extends Error {
16
+ /** HTTP status code, when the failure came from an HTTP error response. */
17
+ statusCode;
18
+ /**
19
+ * @param message - Human-readable error description.
20
+ * @param statusCode - HTTP status code, if the error came from a response.
21
+ */
22
+ constructor(message, statusCode) {
23
+ super(message);
24
+ this.name = "HostBackendError";
25
+ this.statusCode = statusCode;
26
+ }
27
+ }
28
+ /** Number of times a transport-level failure is retried before giving up. */
29
+ const TRANSPORT_RETRIES = 3;
30
+ /** Per-request timeout in milliseconds, enforced via `AbortController`. */
31
+ const REQUEST_TIMEOUT_MS = 30_000;
32
+ /**
33
+ * Minimal JSON HTTP client for the host backend deployment service.
34
+ *
35
+ * @remarks
36
+ * Wraps the global `fetch`, attaching the API-key (and optional tenant)
37
+ * headers to every request, retrying transport-level failures, and decoding
38
+ * JSON responses. All methods reject with a {@link HostBackendError} on
39
+ * failure.
40
+ */
41
+ export class HostBackendClient {
42
+ /** The normalized base URL (trailing slashes stripped). */
43
+ baseUrl;
44
+ /** Headers sent with every request (API key, accept, optional tenant). */
45
+ headers;
46
+ /**
47
+ * @param baseUrl - Host backend base URL, e.g. `https://api.host.langchain.com`.
48
+ * @param apiKey - LangSmith API key sent as the `X-Api-Key` header.
49
+ * @param tenantId - Optional workspace/tenant ID sent as `X-Tenant-ID`.
50
+ * @throws {@link HostBackendError} if `baseUrl` is empty.
51
+ */
52
+ constructor(baseUrl, apiKey, tenantId) {
53
+ if (!baseUrl) {
54
+ throw new HostBackendError("Host backend URL is required");
55
+ }
56
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
57
+ this.headers = {
58
+ "X-Api-Key": apiKey,
59
+ Accept: "application/json",
60
+ };
61
+ if (tenantId) {
62
+ this.headers["X-Tenant-ID"] = tenantId;
63
+ }
64
+ }
65
+ /**
66
+ * Set or override the tenant (workspace) header on this client. Used after
67
+ * an org-scoped API key prompts for a workspace ID, so subsequent requests
68
+ * are tenant-aware.
69
+ *
70
+ * @param tenantId - Workspace/tenant ID to send as `X-Tenant-ID`.
71
+ */
72
+ setTenantId(tenantId) {
73
+ this.headers["X-Tenant-ID"] = tenantId;
74
+ }
75
+ /**
76
+ * Build a fully-qualified request URL, appending query parameters.
77
+ *
78
+ * @param path - Path relative to {@link baseUrl} (must start with `/`).
79
+ * @param params - Optional query parameters; nullish values are omitted.
80
+ * @returns The absolute URL string.
81
+ */
82
+ buildUrl(path, params) {
83
+ const url = new URL(`${this.baseUrl}${path}`);
84
+ if (params) {
85
+ for (const [key, value] of Object.entries(params)) {
86
+ if (value !== undefined && value !== null) {
87
+ url.searchParams.set(key, String(value));
88
+ }
89
+ }
90
+ }
91
+ return url.toString();
92
+ }
93
+ /**
94
+ * Perform an HTTP request and decode the JSON response.
95
+ *
96
+ * @typeParam T - Expected shape of the decoded response body.
97
+ * @param method - HTTP method (e.g. `GET`, `POST`, `PATCH`, `DELETE`).
98
+ * @param path - Path relative to {@link baseUrl} (must start with `/`).
99
+ * @param options - Optional JSON body and query parameters.
100
+ * @returns The decoded JSON body, or `undefined` for empty responses.
101
+ * @throws {@link HostBackendError} on HTTP error responses, transport
102
+ * failures (after retries), or undecodable JSON bodies.
103
+ */
104
+ async request(method, path, options = {}) {
105
+ const url = this.buildUrl(path, options.params);
106
+ const init = {
107
+ method,
108
+ headers: { ...this.headers },
109
+ };
110
+ if (options.payload !== undefined) {
111
+ init.body = JSON.stringify(options.payload);
112
+ init.headers["Content-Type"] =
113
+ "application/json";
114
+ }
115
+ let lastError;
116
+ for (let attempt = 0; attempt <= TRANSPORT_RETRIES; attempt += 1) {
117
+ const controller = new AbortController();
118
+ const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
119
+ let response;
120
+ try {
121
+ response = await fetch(url, { ...init, signal: controller.signal });
122
+ }
123
+ catch (error) {
124
+ // Transport-level error (network, DNS, timeout). Retry a few times.
125
+ lastError = error;
126
+ if (attempt < TRANSPORT_RETRIES)
127
+ continue;
128
+ throw new HostBackendError(error instanceof Error ? error.message : String(error));
129
+ }
130
+ finally {
131
+ clearTimeout(timeout);
132
+ }
133
+ if (!response.ok) {
134
+ const detail = (await response.text().catch(() => "")) || String(response.status);
135
+ throw new HostBackendError(`${method} ${path} failed with status ${response.status}: ${detail}`, response.status);
136
+ }
137
+ const text = await response.text();
138
+ if (!text)
139
+ return undefined;
140
+ try {
141
+ return JSON.parse(text);
142
+ }
143
+ catch (error) {
144
+ throw new HostBackendError(`Failed to decode response from ${path}: ${error instanceof Error ? error.message : String(error)}`);
145
+ }
146
+ }
147
+ throw new HostBackendError(lastError instanceof Error ? lastError.message : String(lastError));
148
+ }
149
+ /**
150
+ * Create a new deployment (`POST /v2/deployments`).
151
+ *
152
+ * @param args - Deployment creation parameters.
153
+ * @param args.name - Deployment name.
154
+ * @param args.deploymentType - Deployment type, e.g. `dev` or `prod`.
155
+ * @param args.source - Source kind, e.g. `internal_docker` or `internal_source`.
156
+ * @param args.configPath - Config path inside the source archive. Only sent
157
+ * for `internal_source` deployments.
158
+ * @param args.secrets - Optional secrets to attach to the deployment.
159
+ * @returns The created deployment object (expected to contain an `id`).
160
+ */
161
+ createDeployment(args) {
162
+ const payload = {
163
+ name: args.name,
164
+ source: args.source,
165
+ source_config: { deployment_type: args.deploymentType },
166
+ source_revision_config: {},
167
+ };
168
+ if (args.source === "internal_source" && args.configPath) {
169
+ payload.source_revision_config["langgraph_config_path"] = args.configPath;
170
+ }
171
+ if (args.secrets != null) {
172
+ payload.secrets = args.secrets;
173
+ }
174
+ return this.request("POST", "/v2/deployments", { payload });
175
+ }
176
+ /**
177
+ * List deployments (`GET /v2/deployments`).
178
+ *
179
+ * @param nameContains - Optional substring filter on deployment names.
180
+ * @returns A paginated response object with a `resources` array.
181
+ */
182
+ listDeployments(nameContains = "") {
183
+ return this.request("GET", "/v2/deployments", {
184
+ params: { name_contains: nameContains },
185
+ });
186
+ }
187
+ /**
188
+ * Fetch a single deployment (`GET /v2/deployments/{id}`).
189
+ *
190
+ * @param deploymentId - Deployment ID.
191
+ * @returns The deployment object.
192
+ */
193
+ getDeployment(deploymentId) {
194
+ return this.request("GET", `/v2/deployments/${deploymentId}`);
195
+ }
196
+ /**
197
+ * Delete a deployment (`DELETE /v2/deployments/{id}`).
198
+ *
199
+ * @param deploymentId - Deployment ID.
200
+ */
201
+ deleteDeployment(deploymentId) {
202
+ return this.request("DELETE", `/v2/deployments/${deploymentId}`);
203
+ }
204
+ /**
205
+ * Request a short-lived registry push token for a local-build deployment
206
+ * (`POST /v2/deployments/{id}/push-token`).
207
+ *
208
+ * @param deploymentId - Deployment ID.
209
+ * @returns A response containing the push `token` and `registry_url`.
210
+ */
211
+ requestPushToken(deploymentId) {
212
+ return this.request("POST", `/v2/deployments/${deploymentId}/push-token`);
213
+ }
214
+ /**
215
+ * Request a signed GCS upload URL for the source tarball used by remote
216
+ * builds (`POST /v2/deployments/{id}/upload-url`).
217
+ *
218
+ * @param deploymentId - Deployment ID.
219
+ * @returns A response containing the `upload_url` and `object_path`.
220
+ */
221
+ requestUploadUrl(deploymentId) {
222
+ return this.request("POST", `/v2/deployments/${deploymentId}/upload-url`);
223
+ }
224
+ /**
225
+ * Update a deployment to a new pre-built image, creating a new revision
226
+ * (`PATCH /v2/deployments/{id}` with `revision_source: internal_docker`).
227
+ *
228
+ * @param deploymentId - Deployment ID.
229
+ * @param imageUri - Fully-qualified image reference (ideally a digest).
230
+ * @param args - Optional secrets.
231
+ * @param args.secrets - Secrets to set on the deployment.
232
+ * @returns The updated deployment object.
233
+ */
234
+ updateDeployment(deploymentId, imageUri, args = {}) {
235
+ const payload = {
236
+ revision_source: "internal_docker",
237
+ source_revision_config: { image_uri: imageUri },
238
+ };
239
+ if (args.secrets != null) {
240
+ payload.secrets = args.secrets;
241
+ }
242
+ return this.request("PATCH", `/v2/deployments/${deploymentId}`, {
243
+ payload,
244
+ });
245
+ }
246
+ /**
247
+ * Trigger a remote build revision from an uploaded source tarball
248
+ * (`PATCH /v2/deployments/{id}` with `revision_source: internal_source`).
249
+ *
250
+ * @param deploymentId - Deployment ID.
251
+ * @param args - Remote-build parameters.
252
+ * @param args.sourceTarballPath - GCS object path of the uploaded tarball.
253
+ * @param args.configPath - Path to `langgraph.json` inside the tarball.
254
+ * @param args.secrets - Optional secrets to set on the deployment.
255
+ * @param args.installCommand - Optional custom install command.
256
+ * @param args.buildCommand - Optional custom build command.
257
+ * @returns The updated deployment object.
258
+ */
259
+ updateDeploymentInternalSource(deploymentId, args) {
260
+ const payload = {
261
+ revision_source: "internal_source",
262
+ source_revision_config: {
263
+ source_tarball_path: args.sourceTarballPath,
264
+ langgraph_config_path: args.configPath,
265
+ },
266
+ };
267
+ const sourceConfig = {};
268
+ if (args.installCommand != null) {
269
+ sourceConfig.install_command = args.installCommand;
270
+ }
271
+ if (args.buildCommand != null) {
272
+ sourceConfig.build_command = args.buildCommand;
273
+ }
274
+ if (Object.keys(sourceConfig).length) {
275
+ payload.source_config = sourceConfig;
276
+ }
277
+ if (args.secrets != null) {
278
+ payload.secrets = args.secrets;
279
+ }
280
+ return this.request("PATCH", `/v2/deployments/${deploymentId}`, {
281
+ payload,
282
+ });
283
+ }
284
+ /**
285
+ * List a deployment's revisions, newest first
286
+ * (`GET /v2/deployments/{id}/revisions`).
287
+ *
288
+ * @param deploymentId - Deployment ID.
289
+ * @param limit - Maximum number of revisions to return (default `1`).
290
+ * @returns A response object with a `resources` array of revisions.
291
+ */
292
+ listRevisions(deploymentId, limit = 1) {
293
+ return this.request("GET", `/v2/deployments/${deploymentId}/revisions`, {
294
+ params: { limit },
295
+ });
296
+ }
297
+ /**
298
+ * Fetch a single revision (`GET /v2/deployments/{id}/revisions/{revId}`).
299
+ *
300
+ * @param deploymentId - Deployment ID.
301
+ * @param revisionId - Revision ID.
302
+ * @returns The revision object (expected to contain a `status`).
303
+ */
304
+ getRevision(deploymentId, revisionId) {
305
+ return this.request("GET", `/v2/deployments/${deploymentId}/revisions/${revisionId}`);
306
+ }
307
+ /**
308
+ * Fetch build logs for a revision
309
+ * (`POST /v1/projects/{projectId}/revisions/{revId}/build_logs`).
310
+ *
311
+ * @param projectId - Project/deployment ID.
312
+ * @param revisionId - Revision ID.
313
+ * @param payload - Query body (e.g. `order`, `limit`, `offset`).
314
+ * @returns A response object with a `logs` array and optional `next_offset`.
315
+ */
316
+ getBuildLogs(projectId, revisionId, payload) {
317
+ return this.request("POST", `/v1/projects/${projectId}/revisions/${revisionId}/build_logs`, { payload });
318
+ }
319
+ /**
320
+ * Fetch agent-server runtime (deploy) logs. Targets a specific revision when
321
+ * `revisionId` is provided, otherwise the deployment as a whole.
322
+ *
323
+ * @param projectId - Project/deployment ID.
324
+ * @param payload - Query body (e.g. `order`, `limit`, `level`, `query`).
325
+ * @param revisionId - Optional revision ID to scope the logs.
326
+ * @returns A response object with a `logs` array.
327
+ */
328
+ getDeployLogs(projectId, payload, revisionId) {
329
+ const path = revisionId
330
+ ? `/v1/projects/${projectId}/revisions/${revisionId}/deploy_logs`
331
+ : `/v1/projects/${projectId}/deploy_logs`;
332
+ return this.request("POST", path, { payload });
333
+ }
334
+ }
@@ -263,7 +263,12 @@ export async function configToDocker(configPath, config, localDeps, options) {
263
263
  testFile("bun.lockb"),
264
264
  ]);
265
265
  let installCmd = "npm i";
266
- if (yarn) {
266
+ if (options?.installCommand) {
267
+ // Explicit override (JS projects only); takes precedence over lockfile
268
+ // auto-detection. Mirrors the Python CLI's `--install-command`.
269
+ installCmd = options.installCommand;
270
+ }
271
+ else if (yarn) {
267
272
  installCmd = "yarn install";
268
273
  }
269
274
  else if (pnpm) {
@@ -275,6 +280,9 @@ export async function configToDocker(configPath, config, localDeps, options) {
275
280
  else if (bun) {
276
281
  installCmd = "bun i";
277
282
  }
283
+ const buildStep = options?.buildCommand
284
+ ? `RUN ${options.buildCommand}`
285
+ : `RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts`;
278
286
  const lines = [
279
287
  `FROM ${getBaseImage(config)}`,
280
288
  config.dockerfile_lines,
@@ -292,12 +300,7 @@ export async function configToDocker(configPath, config, localDeps, options) {
292
300
  !!config.auth && `ENV LANGGRAPH_AUTH='${JSON.stringify(config.auth)}'`,
293
301
  !!config.http && `ENV LANGGRAPH_HTTP='${JSON.stringify(config.http)}'`,
294
302
  !!localDeps.workingDir && `WORKDIR ${localDeps.workingDir}`,
295
- "node_version" in config
296
- ? [
297
- `RUN ${installCmd}`,
298
- `RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts`,
299
- ]
300
- : undefined,
303
+ "node_version" in config ? [`RUN ${installCmd}`, buildStep] : undefined,
301
304
  ];
302
305
  if (options?.watch && (localDeps.workingDir || localDeps.reloadDir)) {
303
306
  // TODO: hacky, should add as entrypoint to the langgraph-api base image
@@ -355,7 +358,15 @@ export async function configToCompose(configPath, config, options) {
355
358
  const extendEnvIgnore = new Set();
356
359
  if (typeof config.env === "string") {
357
360
  // try to parse out the env file
358
- const envPath = path.resolve(path.dirname(configPath), config.env);
361
+ const projectRoot = path.dirname(configPath);
362
+ const envPath = path.resolve(projectRoot, config.env);
363
+ // Keep the config-supplied env path contained within the project so a
364
+ // malicious langgraph.json can't use `../` or an absolute path to read
365
+ // files outside the project (and surface them to docker compose).
366
+ const rel = path.relative(projectRoot, envPath);
367
+ if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) {
368
+ throw new Error(`env file '${config.env}' specified in langgraph.json is outside the project directory.`);
369
+ }
359
370
  try {
360
371
  const envFileKeys = (await fs.readFile(envPath, "utf-8"))
361
372
  .split("\n")
@@ -79,7 +79,10 @@ const PythonConfigSchema = BaseConfigSchema.merge(z.object({
79
79
  python_version: PythonVersionSchema.default(DEFAULT_PYTHON_VERSION),
80
80
  node_version: NodeVersionSchema.optional(),
81
81
  }));
82
- const NodeConfigSchema = BaseConfigSchema.merge(z.object({ node_version: NodeVersionSchema.default(DEFAULT_NODE_VERSION) }));
82
+ const NodeConfigSchema = BaseConfigSchema.merge(z.object({
83
+ node_version: NodeVersionSchema.default(DEFAULT_NODE_VERSION),
84
+ node_loader: z.string().optional(),
85
+ }));
83
86
  const ConfigSchema = z.union([NodeConfigSchema, PythonConfigSchema]);
84
87
  // TODO: implement this in Python CLI
85
88
  export const getConfig = (config) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@langchain/langgraph-cli",
3
- "version": "1.3.1",
3
+ "version": "1.4.1",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": "^18.19.0 || >=20.16.0"
@@ -29,16 +29,17 @@
29
29
  "execa": "^9.5.2",
30
30
  "exit-hook": "^4.0.0",
31
31
  "extract-zip": "^2.0.1",
32
+ "ignore": "^7.0.5",
32
33
  "langsmith": "^0.7.4",
33
34
  "open": "^10.1.0",
34
35
  "package-manager-detector": "^1.3.0",
35
36
  "stacktrace-parser": "^0.1.10",
36
- "tar": "^7.4.3",
37
+ "tar": "^7.5.16",
37
38
  "winston": "^3.17.0",
38
39
  "winston-console-format": "^1.0.8",
39
40
  "yaml": "^2.8.3",
40
41
  "zod": "^3.25.76 || ^4",
41
- "@langchain/langgraph-api": "1.3.1",
42
+ "@langchain/langgraph-api": "1.4.1",
42
43
  "create-langgraph": "1.1.5"
43
44
  },
44
45
  "devDependencies": {