skein-js 0.2.0 → 0.3.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.
- package/README.md +72 -49
- package/dist/index.js +286 -64
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -1,81 +1,104 @@
|
|
|
1
1
|
# skein-js
|
|
2
2
|
|
|
3
|
-
> The skein
|
|
3
|
+
> The `skein` CLI — a drop-in replacement for the LangGraph CLI (`dev` / `up` / `build` / `dockerfile`).
|
|
4
4
|
|
|
5
|
-
Part of **[skein-js](
|
|
5
|
+
Part of **[skein-js](../../README.md)** — a TypeScript [Agent Protocol](https://github.com/langchain-ai/agent-protocol) server for [LangGraph.js](https://github.com/langchain-ai/langgraphjs), and a drop-in replacement for the LangGraph CLI.
|
|
6
6
|
|
|
7
7
|
**Status:** 🚧 Pre-alpha — `dev`, `up`, `build`, and `dockerfile` all work today.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
This is the only package most projects install. Point `skein` at your existing `langgraph.json` and
|
|
10
|
+
your graph code, config, and clients keep working unchanged — the swap from the LangGraph CLI is a
|
|
11
|
+
one-word change (`langgraph dev` → `skein dev`).
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
## Contents
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
| Local `dev` | free in-memory dev server (Hono) | free in-process dev server, hot reload |
|
|
22
|
-
| Self-hosting in production | requires an enterprise license key (Elastic License 2.0) | Apache-2.0, **no license key** |
|
|
23
|
-
| Production path | LangGraph Platform (hosted, or licensed self-host) | self-hosted Docker Compose you own (`skein up`) |
|
|
24
|
-
| Persistence | platform-managed | your Postgres (+ pgvector) and Redis |
|
|
25
|
-
| HTTP framework | Hono | Express (Fastify / NestJS to follow) |
|
|
26
|
-
|
|
27
|
-
### When the LangGraph Platform is the better call
|
|
28
|
-
|
|
29
|
-
skein-js isn't strictly better — it's a different trade, and it's worth being clear about what the Enterprise plan actually buys. It isn't hosting: even on LangGraph's self-hosted tier you still run your own Postgres, Redis, and infrastructure. What you're paying for is **LangChain's support** — dedicated support, response-time SLAs, and architectural guidance from the team that builds LangGraph — plus enterprise features like SSO, RBAC, and SOC2. If you're an established product with real revenue and you want a vendor standing behind your agent stack, that's genuine value, and the Platform is a sound choice.
|
|
30
|
-
|
|
31
|
-
skein-js gives you the code and full ownership — not a support contract. **It's for you if** you're earlier-stage or cost-sensitive, don't need (or don't want to buy) an enterprise support relationship, want to keep data in your own infrastructure, run a TypeScript/Node stack, or just want to own your agent infrastructure end to end with no license and no vendor lock-in.
|
|
32
|
-
|
|
33
|
-
The graph code and `langgraph.json` stay unchanged — see the full [Why skein-js comparison](../../README.md#why-skein-js) and the [reuse-first architecture](../../docs/reuse.md).
|
|
34
|
-
|
|
35
|
-
## What it does
|
|
36
|
-
|
|
37
|
-
`skein dev` (in-process, hot reload, no Docker), `skein up` (Docker Compose: app + Postgres + Redis), `skein build` / `skein dockerfile`. The one-word swap from `langgraph dev` → `skein dev`.
|
|
38
|
-
|
|
39
|
-
## Reuse
|
|
40
|
-
|
|
41
|
-
Mirrors `@langchain/langgraph-cli` semantics and reads an unchanged `langgraph.json`. Reuses `@skein-js/config` (built on `@langchain/langgraph-api`'s parser) for graph loading.
|
|
15
|
+
- [Install](#install)
|
|
16
|
+
- [Usage](#usage)
|
|
17
|
+
- [Commands](#commands)
|
|
18
|
+
- [`skein dev` flags](#skein-dev-flags)
|
|
19
|
+
- [Self-hosted, no lock-in](#self-hosted-no-lock-in)
|
|
20
|
+
- [When the managed platform may fit you better](#when-the-managed-platform-may-fit-you-better)
|
|
21
|
+
- [Learn more](#learn-more)
|
|
22
|
+
- [License](#license)
|
|
42
23
|
|
|
43
24
|
## Install
|
|
44
25
|
|
|
45
26
|
```bash
|
|
46
|
-
pnpm add skein-js
|
|
27
|
+
pnpm add -D skein-js # or: npm i -D skein-js · yarn add -D skein-js
|
|
47
28
|
```
|
|
48
29
|
|
|
49
30
|
## Usage
|
|
50
31
|
|
|
51
|
-
Swap it into your `package.json` scripts
|
|
52
|
-
|
|
32
|
+
Swap it into your `package.json` scripts — your `langgraph.json` is unchanged:
|
|
33
|
+
|
|
34
|
+
```jsonc
|
|
35
|
+
{
|
|
36
|
+
"scripts": {
|
|
37
|
+
"dev": "skein dev", // was: "langgraph dev"
|
|
38
|
+
"up": "skein up", // was: "langgraph up"
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Then run the dev server (in-process, hot reload, no Docker):
|
|
53
44
|
|
|
54
45
|
```bash
|
|
55
|
-
|
|
46
|
+
pnpm skein dev # → http://127.0.0.1:2024
|
|
56
47
|
```
|
|
57
48
|
|
|
58
|
-
|
|
49
|
+
Talk to it with the official `@langchain/langgraph-sdk` client, `useStream`, Agent Chat UI, or
|
|
50
|
+
LangGraph Studio — any Agent Protocol client works with only a URL change. See the
|
|
51
|
+
[Quick start](../../README.md#quick-start).
|
|
59
52
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
|
63
|
-
|
|
|
64
|
-
| `skein
|
|
65
|
-
| `skein
|
|
53
|
+
## Commands
|
|
54
|
+
|
|
55
|
+
| Command | What it does | LangGraph CLI equivalent | Key flags |
|
|
56
|
+
| ------------------ | -------------------------------------------------------------- | ------------------------ | ---------------------------------------------- |
|
|
57
|
+
| `skein dev` | In-process dev server, hot reload, `.skein/` state, no Docker. | `langgraph dev` | see [`skein dev` flags](#skein-dev-flags) |
|
|
58
|
+
| `skein up` | Self-hosted stack via Docker Compose (app + Postgres + Redis). | `langgraph up` | `-p, --port` (8123) · `--host` (0.0.0.0) |
|
|
59
|
+
| `skein build` | Build a deployable Docker image from the config. | `langgraph build` | `-t, --tag` (defaults to the project dir name) |
|
|
60
|
+
| `skein dockerfile` | Emit a standalone Dockerfile (stdout by default). | `langgraph dockerfile` | `-o, --output <path>` |
|
|
66
61
|
|
|
67
62
|
All commands take `-c, --config <path>` (default `langgraph.json`).
|
|
68
63
|
|
|
64
|
+
## `skein dev` flags
|
|
65
|
+
|
|
66
|
+
| Flag | Values | Default | Notes |
|
|
67
|
+
| ------------------ | -------------------- | ----------- | -------------------------------------------------------------- |
|
|
68
|
+
| `-p, --port` | number | `2024` | Port to listen on. |
|
|
69
|
+
| `--host` | host | `127.0.0.1` | Interface to bind. |
|
|
70
|
+
| `--store <driver>` | `memory`, `postgres` | `memory` | `postgres` reads `DATABASE_URL`; also selects `PostgresSaver`. |
|
|
71
|
+
| `--queue <driver>` | `memory`, `redis` | `memory` | `redis` reads `REDIS_URL` (BullMQ queue + Redis Streams bus). |
|
|
72
|
+
| `--no-persist` | — | persists | Don't snapshot dev state to `.skein/` across restarts. |
|
|
73
|
+
| `--no-reload` | — | reloads | Disable hot reload on source change. |
|
|
74
|
+
| `-v, --verbose` | — | off | Log per-run activity (tool calls, interrupts, timing). |
|
|
75
|
+
|
|
69
76
|
`skein dev --store postgres --queue redis` is a capability the LangGraph CLI does **not** offer: it
|
|
70
77
|
lets you develop against **production-shaped** storage (durable Postgres checkpoints, pgvector
|
|
71
|
-
search, cross-instance Redis streaming) with hot reload and no Docker.
|
|
72
|
-
`
|
|
78
|
+
search, cross-instance Redis streaming) with hot reload and no Docker. Full mapping and the
|
|
79
|
+
annotated `langgraph.json`: [docs/langgraph-cli-compat.md](../../docs/langgraph-cli-compat.md).
|
|
80
|
+
|
|
81
|
+
## Self-hosted, no lock-in
|
|
82
|
+
|
|
83
|
+
skein-js is **Apache-2.0** and builds only on the **MIT-licensed** `@langchain/*` packages, so you
|
|
84
|
+
run your LangGraph.js graphs behind the standard Agent Protocol on **your own infrastructure** — no
|
|
85
|
+
license key, no per-deployment fee, and no vendor lock-in. `skein up` brings up a Docker Compose
|
|
86
|
+
stack (app + your Postgres + your Redis) that you own end to end.
|
|
87
|
+
|
|
88
|
+
## When the managed platform may fit you better
|
|
89
|
+
|
|
90
|
+
skein-js gives you the code and full ownership, not a support contract. If you're an established
|
|
91
|
+
product that wants a vendor standing behind your agent stack — dedicated support with response-time
|
|
92
|
+
SLAs, plus enterprise features like SSO, RBAC, and SOC2 — LangGraph Platform's Enterprise plan is a
|
|
93
|
+
sound choice. Your graph code and `langgraph.json` stay unchanged either way, so switching between
|
|
94
|
+
them is cheap. See the [reuse-first architecture](../../docs/reuse.md) for how little skein-js adds
|
|
95
|
+
on top of the open LangGraph runtime.
|
|
73
96
|
|
|
74
97
|
## Learn more
|
|
75
98
|
|
|
76
|
-
- [
|
|
77
|
-
- [Reuse-first architecture](../../docs/reuse.md)
|
|
78
|
-
- [
|
|
99
|
+
- [LangGraph CLI compatibility](../../docs/langgraph-cli-compat.md) — commands + `langgraph.json` fields
|
|
100
|
+
- [skein-js overview](../../docs/index.md) · [Reuse-first architecture](../../docs/reuse.md) · [Roadmap](../../docs/roadmap.md)
|
|
101
|
+
- [skein-js root README](../../README.md)
|
|
79
102
|
|
|
80
103
|
## License
|
|
81
104
|
|
package/dist/index.js
CHANGED
|
@@ -5,14 +5,136 @@ import { createRequire } from "module";
|
|
|
5
5
|
import { Command, InvalidArgumentError } from "@commander-js/extra-typings";
|
|
6
6
|
|
|
7
7
|
// src/dev-command.ts
|
|
8
|
-
import { existsSync
|
|
9
|
-
import
|
|
10
|
-
import { loadConfig
|
|
8
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
|
|
9
|
+
import path3 from "path";
|
|
10
|
+
import { loadConfig } from "@skein-js/config";
|
|
11
11
|
import {
|
|
12
|
-
createExpressServer
|
|
12
|
+
createExpressServer,
|
|
13
|
+
describeSnapshot,
|
|
14
|
+
readLanggraphDevState
|
|
13
15
|
} from "@skein-js/express";
|
|
14
16
|
import { buildRuntime } from "@skein-js/runtime";
|
|
15
17
|
|
|
18
|
+
// src/colors.ts
|
|
19
|
+
var colorEnabled = process.stdout.isTTY === true && process.env.NO_COLOR === void 0;
|
|
20
|
+
var paint = (code) => (text) => colorEnabled ? `\x1B[${code}m${text}\x1B[0m` : text;
|
|
21
|
+
var green = paint(32);
|
|
22
|
+
var yellow = paint(33);
|
|
23
|
+
var red = paint(31);
|
|
24
|
+
var cyan = paint(36);
|
|
25
|
+
var bold = paint(1);
|
|
26
|
+
var dim = paint(2);
|
|
27
|
+
|
|
28
|
+
// src/banner.ts
|
|
29
|
+
function printBanner(info, logger) {
|
|
30
|
+
const { host, port, graphIds, authPath, workerCount } = info;
|
|
31
|
+
const base = `http://${host}:${port}`;
|
|
32
|
+
console.log();
|
|
33
|
+
console.log(`${bold(green("skein"))} ${dim("\xB7 Agent Protocol dev server")}`);
|
|
34
|
+
console.log();
|
|
35
|
+
console.log(`${dim("API ")} ${cyan(base)}`);
|
|
36
|
+
console.log(`${dim("Docs")} ${cyan(`${base}/docs`)}`);
|
|
37
|
+
console.log();
|
|
38
|
+
for (const id of graphIds) logger.info(`Registering graph with id '${id}'`);
|
|
39
|
+
if (authPath) logger.info(`Loading auth from ${authPath}`);
|
|
40
|
+
logger.info(`Starting ${workerCount} worker${workerCount === 1 ? "" : "s"}`);
|
|
41
|
+
logger.info(`Server running at ${base}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// src/dev-logger.ts
|
|
45
|
+
var INDENT = " ";
|
|
46
|
+
function describeError(error) {
|
|
47
|
+
const head = error.stack ?? `${error.name}: ${error.message}`;
|
|
48
|
+
if (error.cause === void 0) return head;
|
|
49
|
+
const cause = error.cause instanceof Error ? describeError(error.cause) : String(error.cause);
|
|
50
|
+
return `${head}
|
|
51
|
+
caused by: ${cause}`;
|
|
52
|
+
}
|
|
53
|
+
function metaBlock(meta) {
|
|
54
|
+
if (meta === void 0 || meta === null) return "";
|
|
55
|
+
if (meta instanceof Error) {
|
|
56
|
+
const trace = describeError(meta);
|
|
57
|
+
return `
|
|
58
|
+
${INDENT}${dim(trace.replace(/\n/g, `
|
|
59
|
+
${INDENT}`))}`;
|
|
60
|
+
}
|
|
61
|
+
if (typeof meta === "object") {
|
|
62
|
+
const pairs = Object.entries(meta).map(([key, value]) => {
|
|
63
|
+
const rendered = typeof value === "object" && value !== null ? JSON.stringify(value) : String(value);
|
|
64
|
+
return `${dim(`${key}=`)}${rendered}`;
|
|
65
|
+
});
|
|
66
|
+
return pairs.length ? `
|
|
67
|
+
${INDENT}${pairs.join(" ")}` : "";
|
|
68
|
+
}
|
|
69
|
+
return `
|
|
70
|
+
${INDENT}${dim(String(meta))}`;
|
|
71
|
+
}
|
|
72
|
+
function paintHttp(message) {
|
|
73
|
+
if (message.startsWith("<-- ")) return dim(message);
|
|
74
|
+
if (message.startsWith("--> ")) {
|
|
75
|
+
const status = Number(message.match(/ (\d{3}) \d+ms$/)?.[1]);
|
|
76
|
+
if (status >= 500) return red(message);
|
|
77
|
+
if (status >= 400) return yellow(message);
|
|
78
|
+
if (status >= 300) return cyan(message);
|
|
79
|
+
return green(message);
|
|
80
|
+
}
|
|
81
|
+
return message;
|
|
82
|
+
}
|
|
83
|
+
function line(prefix, message, meta) {
|
|
84
|
+
return `${prefix} ${paintHttp(message)}${metaBlock(meta)}`;
|
|
85
|
+
}
|
|
86
|
+
function createDevLogger() {
|
|
87
|
+
return {
|
|
88
|
+
debug: (message, meta) => console.debug(line(dim("debug:"), message, meta)),
|
|
89
|
+
info: (message, meta) => console.log(line(green("info:"), message, meta)),
|
|
90
|
+
warn: (message, meta) => console.warn(line(yellow("warn:"), message, meta)),
|
|
91
|
+
error: (message, meta) => console.error(line(red("error:"), message, meta))
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// src/dev-state.ts
|
|
96
|
+
import { mkdirSync, renameSync, writeFileSync } from "fs";
|
|
97
|
+
import path from "path";
|
|
98
|
+
var STATE_DIR = ".skein";
|
|
99
|
+
var STATE_FILE = "dev-state.json";
|
|
100
|
+
var LANGGRAPH_DIR = ".langgraph_api";
|
|
101
|
+
function devStateFile(configDir) {
|
|
102
|
+
return path.join(configDir, STATE_DIR, STATE_FILE);
|
|
103
|
+
}
|
|
104
|
+
function writeDevStateFile(stateFile, serialized) {
|
|
105
|
+
mkdirSync(path.dirname(stateFile), { recursive: true });
|
|
106
|
+
const tmp = `${stateFile}.tmp`;
|
|
107
|
+
writeFileSync(tmp, serialized);
|
|
108
|
+
renameSync(tmp, stateFile);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// src/project-env.ts
|
|
112
|
+
import { existsSync, readFileSync } from "fs";
|
|
113
|
+
import path2 from "path";
|
|
114
|
+
import { parseEnvFile, resolveEnv } from "@skein-js/config";
|
|
115
|
+
function applyEnv(resolved) {
|
|
116
|
+
for (const [key, value] of Object.entries(resolved)) {
|
|
117
|
+
if (process.env[key] === void 0) process.env[key] = value;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function readConventionalDotEnv(dir) {
|
|
121
|
+
const envPath = path2.join(dir, ".env");
|
|
122
|
+
if (!existsSync(envPath)) return {};
|
|
123
|
+
try {
|
|
124
|
+
return parseEnvFile(readFileSync(envPath, "utf8"));
|
|
125
|
+
} catch {
|
|
126
|
+
return {};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async function applyProjectEnv(config, configDir) {
|
|
130
|
+
const declaredEnvPath = typeof config.env === "string" ? path2.resolve(configDir, config.env) : void 0;
|
|
131
|
+
const conventional = declaredEnvPath === path2.join(configDir, ".env") ? {} : readConventionalDotEnv(configDir);
|
|
132
|
+
applyEnv({ ...conventional, ...await resolveEnv(config, configDir) });
|
|
133
|
+
if (declaredEnvPath !== void 0 && !existsSync(declaredEnvPath)) {
|
|
134
|
+
console.warn(`skein: env file "${config.env}" not found; continuing without it.`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
16
138
|
// src/vite-graph-loader.ts
|
|
17
139
|
import { createServer as createNetServer } from "net";
|
|
18
140
|
import { createServer, createServerModuleRunner } from "vite";
|
|
@@ -60,39 +182,23 @@ async function createViteGraphLoader(root, ignored = []) {
|
|
|
60
182
|
var RELOAD_DEBOUNCE_MS = 120;
|
|
61
183
|
var AUTOSAVE_MS = 2e3;
|
|
62
184
|
var FORCE_EXIT_MS = 5e3;
|
|
63
|
-
var
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
error: (message, meta) => meta === void 0 ? console.error(message) : console.error(message, meta)
|
|
70
|
-
};
|
|
71
|
-
function applyEnv(resolved) {
|
|
72
|
-
for (const [key, value] of Object.entries(resolved)) {
|
|
73
|
-
if (process.env[key] === void 0) process.env[key] = value;
|
|
74
|
-
}
|
|
185
|
+
var devLogger = createDevLogger();
|
|
186
|
+
function envPort(fallback) {
|
|
187
|
+
const raw = process.env.PORT;
|
|
188
|
+
if (raw === void 0 || raw.trim() === "") return fallback;
|
|
189
|
+
const port = Number(raw);
|
|
190
|
+
return Number.isInteger(port) && port >= 0 && port <= 65535 ? port : fallback;
|
|
75
191
|
}
|
|
76
|
-
function
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
try {
|
|
80
|
-
return parseEnvFile(readFileSync(envPath, "utf8"));
|
|
81
|
-
} catch {
|
|
82
|
-
return {};
|
|
83
|
-
}
|
|
192
|
+
function envHost(fallback) {
|
|
193
|
+
const host = process.env.HOST;
|
|
194
|
+
return host !== void 0 && host.trim() !== "" ? host : fallback;
|
|
84
195
|
}
|
|
85
196
|
async function runDev(options) {
|
|
86
|
-
const configPath =
|
|
197
|
+
const configPath = path3.resolve(process.cwd(), options.config);
|
|
87
198
|
const { config, configDir } = await loadConfig({ configPath });
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
if (declaredEnvPath !== void 0 && !existsSync(declaredEnvPath)) {
|
|
92
|
-
console.warn(`skein: env file "${config.env}" not found; continuing without it.`);
|
|
93
|
-
}
|
|
94
|
-
const stateDir = path.join(configDir, STATE_DIR);
|
|
95
|
-
const stateFile = path.join(stateDir, STATE_FILE);
|
|
199
|
+
await applyProjectEnv(config, configDir);
|
|
200
|
+
const stateDir = path3.join(configDir, STATE_DIR);
|
|
201
|
+
const stateFile = devStateFile(configDir);
|
|
96
202
|
const loader = await createViteGraphLoader(configDir, [`${STATE_DIR}/**`, `**/${STATE_DIR}/**`]);
|
|
97
203
|
const runtime = await buildRuntime({
|
|
98
204
|
configPath,
|
|
@@ -100,22 +206,38 @@ async function runDev(options) {
|
|
|
100
206
|
store: options.store,
|
|
101
207
|
queue: options.queue
|
|
102
208
|
});
|
|
103
|
-
const
|
|
209
|
+
const port = options.portExplicit ? options.port : envPort(options.port);
|
|
210
|
+
const host = options.hostExplicit ? options.host : envHost(options.host);
|
|
104
211
|
const canPersist = options.persist && runtime.snapshotState !== void 0;
|
|
105
212
|
if (options.persist && runtime.snapshotState === void 0) {
|
|
106
213
|
console.log(
|
|
107
214
|
`skein: state persists in ${options.store}/${options.queue}; skipping .skein snapshot.`
|
|
108
215
|
);
|
|
109
216
|
}
|
|
110
|
-
if (canPersist &&
|
|
217
|
+
if (canPersist && existsSync2(stateFile)) {
|
|
111
218
|
try {
|
|
112
|
-
runtime.hydrateState?.(JSON.parse(
|
|
219
|
+
runtime.hydrateState?.(JSON.parse(readFileSync2(stateFile, "utf8")));
|
|
113
220
|
console.log("skein: restored dev state.");
|
|
114
221
|
} catch (error) {
|
|
115
222
|
console.warn(
|
|
116
223
|
`skein: could not restore dev state: ${error instanceof Error ? error.message : String(error)}`
|
|
117
224
|
);
|
|
118
225
|
}
|
|
226
|
+
} else if (canPersist && existsSync2(path3.join(configDir, LANGGRAPH_DIR))) {
|
|
227
|
+
try {
|
|
228
|
+
const imported = await readLanggraphDevState(path3.join(configDir, LANGGRAPH_DIR));
|
|
229
|
+
if (imported) {
|
|
230
|
+
runtime.hydrateState?.(imported);
|
|
231
|
+
const counts = describeSnapshot(imported);
|
|
232
|
+
console.log(
|
|
233
|
+
`skein: imported dev state from ${LANGGRAPH_DIR}/ (${counts.threads} thread(s), ${counts.checkpointedThreads} with history).`
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
} catch (error) {
|
|
237
|
+
console.warn(
|
|
238
|
+
`skein: could not import ${LANGGRAPH_DIR}/: ${error instanceof Error ? error.message : String(error)}`
|
|
239
|
+
);
|
|
240
|
+
}
|
|
119
241
|
}
|
|
120
242
|
if (options.verbose) runtime.deps.logRunActivity = true;
|
|
121
243
|
let server;
|
|
@@ -136,17 +258,23 @@ async function runDev(options) {
|
|
|
136
258
|
process.exitCode = 1;
|
|
137
259
|
return;
|
|
138
260
|
}
|
|
139
|
-
|
|
261
|
+
printBanner(
|
|
262
|
+
{
|
|
263
|
+
host,
|
|
264
|
+
port,
|
|
265
|
+
graphIds: runtime.deps.graphs.ids,
|
|
266
|
+
authPath: config.auth?.path,
|
|
267
|
+
workerCount: 1
|
|
268
|
+
},
|
|
269
|
+
devLogger
|
|
270
|
+
);
|
|
140
271
|
let lastSaved;
|
|
141
272
|
const saveState = () => {
|
|
142
273
|
if (!canPersist || runtime.snapshotState === void 0) return;
|
|
143
274
|
try {
|
|
144
275
|
const serialized = JSON.stringify(runtime.snapshotState());
|
|
145
276
|
if (serialized === lastSaved) return;
|
|
146
|
-
|
|
147
|
-
const tmp = `${stateFile}.tmp`;
|
|
148
|
-
writeFileSync(tmp, serialized);
|
|
149
|
-
renameSync(tmp, stateFile);
|
|
277
|
+
writeDevStateFile(stateFile, serialized);
|
|
150
278
|
lastSaved = serialized;
|
|
151
279
|
} catch (error) {
|
|
152
280
|
console.warn(
|
|
@@ -194,7 +322,7 @@ async function runDev(options) {
|
|
|
194
322
|
}
|
|
195
323
|
};
|
|
196
324
|
loader.watcher.on("change", (file) => {
|
|
197
|
-
if (`${
|
|
325
|
+
if (`${path3.resolve(file)}${path3.sep}`.startsWith(`${stateDir}${path3.sep}`)) return;
|
|
198
326
|
if (pending) clearTimeout(pending);
|
|
199
327
|
pending = setTimeout(() => void reload(), RELOAD_DEBOUNCE_MS);
|
|
200
328
|
});
|
|
@@ -217,8 +345,8 @@ async function runDev(options) {
|
|
|
217
345
|
|
|
218
346
|
// src/docker/commands.ts
|
|
219
347
|
import { spawn, spawnSync } from "child_process";
|
|
220
|
-
import { existsSync as
|
|
221
|
-
import
|
|
348
|
+
import { existsSync as existsSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
349
|
+
import path5 from "path";
|
|
222
350
|
import { loadConfig as loadConfig2 } from "@skein-js/config";
|
|
223
351
|
|
|
224
352
|
// src/docker/compose.ts
|
|
@@ -233,9 +361,15 @@ function generateCompose(options) {
|
|
|
233
361
|
services:
|
|
234
362
|
app:
|
|
235
363
|
build: .
|
|
364
|
+
# Reap zombies + forward signals with an init process (PID 1), so graph-spawned children don't
|
|
365
|
+
# accumulate; the app itself still handles SIGTERM for graceful shutdown.
|
|
366
|
+
init: true
|
|
236
367
|
ports:
|
|
237
368
|
- ${ports}
|
|
238
369
|
environment:
|
|
370
|
+
# The container CMD passes no --port; it binds $PORT (as a hosting platform would inject).
|
|
371
|
+
# Set it here so the app listens on the port the mapping above publishes.
|
|
372
|
+
PORT: "${options.containerPort}"
|
|
239
373
|
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/skein
|
|
240
374
|
REDIS_URL: redis://redis:6379
|
|
241
375
|
depends_on:
|
|
@@ -275,15 +409,15 @@ volumes:
|
|
|
275
409
|
}
|
|
276
410
|
|
|
277
411
|
// src/docker/dockerfile.ts
|
|
278
|
-
import { existsSync as
|
|
279
|
-
import
|
|
412
|
+
import { existsSync as existsSync3 } from "fs";
|
|
413
|
+
import path4 from "path";
|
|
280
414
|
function baseImage(nodeVersion) {
|
|
281
415
|
const major = nodeVersion?.trim().match(/^\d+/)?.[0] ?? "20";
|
|
282
416
|
return `node:${major}-slim`;
|
|
283
417
|
}
|
|
284
418
|
function detectPackageManager(projectDir) {
|
|
285
|
-
if (
|
|
286
|
-
if (
|
|
419
|
+
if (existsSync3(path4.join(projectDir, "pnpm-lock.yaml"))) return "pnpm";
|
|
420
|
+
if (existsSync3(path4.join(projectDir, "yarn.lock"))) return "yarn";
|
|
287
421
|
return "npm";
|
|
288
422
|
}
|
|
289
423
|
function installSteps(manager) {
|
|
@@ -291,18 +425,18 @@ function installSteps(manager) {
|
|
|
291
425
|
case "pnpm":
|
|
292
426
|
return {
|
|
293
427
|
copyLock: "COPY package.json pnpm-lock.yaml* ./",
|
|
294
|
-
install: "RUN corepack enable && pnpm install --frozen-lockfile"
|
|
428
|
+
install: "RUN --mount=type=cache,target=/root/.local/share/pnpm/store corepack enable && pnpm install --frozen-lockfile"
|
|
295
429
|
};
|
|
296
430
|
case "yarn":
|
|
297
431
|
return {
|
|
298
432
|
copyLock: "COPY package.json yarn.lock* ./",
|
|
299
|
-
install: "RUN corepack enable && yarn install --frozen-lockfile"
|
|
433
|
+
install: "RUN --mount=type=cache,target=/usr/local/share/.cache/yarn corepack enable && yarn install --frozen-lockfile"
|
|
300
434
|
};
|
|
301
435
|
case "npm":
|
|
302
436
|
return {
|
|
303
437
|
copyLock: "COPY package.json package-lock.json* ./",
|
|
304
438
|
// `npm ci` needs a lockfile; fall back to `npm install` when there isn't one.
|
|
305
|
-
install: "RUN npm ci || npm install"
|
|
439
|
+
install: "RUN --mount=type=cache,target=/root/.npm npm ci || npm install"
|
|
306
440
|
};
|
|
307
441
|
}
|
|
308
442
|
}
|
|
@@ -325,23 +459,37 @@ function generateDockerfile(options) {
|
|
|
325
459
|
const { nodeVersion, dockerfileLines = [], port, packageManager } = options;
|
|
326
460
|
const { copyLock, install } = installSteps(packageManager);
|
|
327
461
|
const lines = [
|
|
462
|
+
// The syntax directive must be the first line to be honored — it enables the RUN cache mounts.
|
|
463
|
+
`# syntax=docker/dockerfile:1`,
|
|
328
464
|
`# Generated by skein \u2014 do not edit by hand (regenerated by \`skein build\` / \`skein up\`).`,
|
|
329
465
|
`FROM ${baseImage(nodeVersion)}`,
|
|
466
|
+
// Hand /app to the unprivileged 'node' user (uid 1000, shipped by the base image) so the server
|
|
467
|
+
// never runs as root. corepack/npm need root to install, so install as root, then chown the
|
|
468
|
+
// whole tree — including the root-created node_modules, where vite writes its cache at runtime.
|
|
330
469
|
`WORKDIR /app`,
|
|
331
470
|
``,
|
|
332
|
-
`# Install dependencies first for better layer caching.`,
|
|
471
|
+
`# Install dependencies first for better layer caching (BuildKit cache mount speeds rebuilds).`,
|
|
333
472
|
copyLock,
|
|
334
473
|
install,
|
|
335
474
|
``,
|
|
336
475
|
`# Copy the rest of the project (graphs, langgraph.json, source).`,
|
|
337
476
|
`COPY . .`,
|
|
338
477
|
``,
|
|
478
|
+
`# Give the runtime user ownership of the app + installed node_modules so vite can write its`,
|
|
479
|
+
`# on-disk cache under node_modules/.vite without EACCES.`,
|
|
480
|
+
`RUN chown -R node:node /app`,
|
|
481
|
+
``,
|
|
482
|
+
// Set NODE_ENV only after install so devDependencies (the vite/tsx toolchain `skein dev` loads
|
|
483
|
+
// graphs with) are installed; setting it before install would prune them and break graph loading.
|
|
339
484
|
`ENV NODE_ENV=production`,
|
|
340
485
|
`EXPOSE ${port}`,
|
|
486
|
+
`USER node`,
|
|
341
487
|
...dockerfileLines,
|
|
342
488
|
``,
|
|
343
489
|
`# Serve the Agent Protocol against Postgres + Redis (DATABASE_URL / REDIS_URL from the environment).`,
|
|
344
|
-
|
|
490
|
+
`# No --port: the server binds $PORT when the platform injects one (Railway/Fly/Render), else its`,
|
|
491
|
+
`# default. Exec form keeps node as PID 1 so SIGTERM reaches skein's graceful-shutdown handler.`,
|
|
492
|
+
`CMD ["npx", "skein", "dev", "--no-reload", "--no-persist", "--store", "postgres", "--queue", "redis", "--host", "0.0.0.0"]`
|
|
345
493
|
];
|
|
346
494
|
return `${lines.join("\n")}
|
|
347
495
|
`;
|
|
@@ -353,8 +501,11 @@ var DOCKERFILE_NAME = "Dockerfile";
|
|
|
353
501
|
var COMPOSE_NAME = "compose.yaml";
|
|
354
502
|
var DOCKERIGNORE_NAME = ".dockerignore";
|
|
355
503
|
var GENERATED_MARKER = "# Generated by skein";
|
|
504
|
+
function isSkeinGenerated(contents) {
|
|
505
|
+
return contents.split("\n", 3).some((line2) => line2.startsWith(GENERATED_MARKER));
|
|
506
|
+
}
|
|
356
507
|
async function loadConfigContext(configOption) {
|
|
357
|
-
const configPath =
|
|
508
|
+
const configPath = path5.resolve(process.cwd(), configOption);
|
|
358
509
|
const { config, configDir } = await loadConfig2({ configPath });
|
|
359
510
|
return { config, configDir };
|
|
360
511
|
}
|
|
@@ -367,7 +518,7 @@ function renderDockerfile({ config, configDir }, port) {
|
|
|
367
518
|
});
|
|
368
519
|
}
|
|
369
520
|
function defaultImageTag(configDir) {
|
|
370
|
-
const base =
|
|
521
|
+
const base = path5.basename(configDir).toLowerCase().replace(/[^a-z0-9._-]+/g, "-");
|
|
371
522
|
const trimmed = base.replace(/^[-.]+|[-.]+$/g, "");
|
|
372
523
|
return trimmed.length > 0 ? trimmed : "skein-app";
|
|
373
524
|
}
|
|
@@ -401,8 +552,8 @@ function describeExit(result) {
|
|
|
401
552
|
return result.signal !== null ? `killed by ${result.signal}` : `exit ${result.code}`;
|
|
402
553
|
}
|
|
403
554
|
function writeGeneratedFile(file, contents) {
|
|
404
|
-
const name =
|
|
405
|
-
if (
|
|
555
|
+
const name = path5.basename(file);
|
|
556
|
+
if (existsSync4(file) && !isSkeinGenerated(readFileSync3(file, "utf8"))) {
|
|
406
557
|
console.log(`skein: ${name} exists and wasn't generated by skein \u2014 using it as-is.`);
|
|
407
558
|
return "kept-user";
|
|
408
559
|
}
|
|
@@ -414,7 +565,7 @@ async function runDockerfile(options) {
|
|
|
414
565
|
const context = await loadConfigContext(options.config);
|
|
415
566
|
const contents = renderDockerfile(context, CONTAINER_PORT);
|
|
416
567
|
if (options.output !== void 0) {
|
|
417
|
-
const target =
|
|
568
|
+
const target = path5.resolve(process.cwd(), options.output);
|
|
418
569
|
writeFileSync2(target, contents);
|
|
419
570
|
console.log(`skein: wrote ${target}.`);
|
|
420
571
|
return;
|
|
@@ -424,9 +575,9 @@ async function runDockerfile(options) {
|
|
|
424
575
|
async function runBuild(options) {
|
|
425
576
|
const context = await loadConfigContext(options.config);
|
|
426
577
|
if (!requireDocker()) return;
|
|
427
|
-
const dockerfilePath =
|
|
578
|
+
const dockerfilePath = path5.join(context.configDir, DOCKERFILE_NAME);
|
|
428
579
|
writeGeneratedFile(dockerfilePath, renderDockerfile(context, CONTAINER_PORT));
|
|
429
|
-
writeGeneratedFile(
|
|
580
|
+
writeGeneratedFile(path5.join(context.configDir, DOCKERIGNORE_NAME), generateDockerignore());
|
|
430
581
|
const tag = options.tag ?? defaultImageTag(context.configDir);
|
|
431
582
|
console.log(`skein: building image "${tag}"\u2026`);
|
|
432
583
|
const result = await runToCompletion(
|
|
@@ -445,12 +596,12 @@ async function runUp(options) {
|
|
|
445
596
|
const context = await loadConfigContext(options.config);
|
|
446
597
|
if (!requireDocker()) return;
|
|
447
598
|
writeGeneratedFile(
|
|
448
|
-
|
|
599
|
+
path5.join(context.configDir, DOCKERFILE_NAME),
|
|
449
600
|
renderDockerfile(context, CONTAINER_PORT)
|
|
450
601
|
);
|
|
451
|
-
writeGeneratedFile(
|
|
602
|
+
writeGeneratedFile(path5.join(context.configDir, DOCKERIGNORE_NAME), generateDockerignore());
|
|
452
603
|
writeGeneratedFile(
|
|
453
|
-
|
|
604
|
+
path5.join(context.configDir, COMPOSE_NAME),
|
|
454
605
|
generateCompose({ hostPort: options.port, host: options.host, containerPort: CONTAINER_PORT })
|
|
455
606
|
);
|
|
456
607
|
console.log(
|
|
@@ -467,6 +618,65 @@ async function runUp(options) {
|
|
|
467
618
|
}
|
|
468
619
|
}
|
|
469
620
|
|
|
621
|
+
// src/import-command.ts
|
|
622
|
+
import { existsSync as existsSync5 } from "fs";
|
|
623
|
+
import path6 from "path";
|
|
624
|
+
import { loadConfig as loadConfig3 } from "@skein-js/config";
|
|
625
|
+
import {
|
|
626
|
+
describeSnapshot as describeSnapshot2,
|
|
627
|
+
loadSnapshotIntoStore,
|
|
628
|
+
readLanggraphDevState as readLanggraphDevState2
|
|
629
|
+
} from "@skein-js/express";
|
|
630
|
+
import { buildRuntime as buildRuntime2 } from "@skein-js/runtime";
|
|
631
|
+
function summarize(counts) {
|
|
632
|
+
return `${counts.assistants} assistant(s), ${counts.threads} thread(s), ${counts.runs} run(s), ${counts.items} store item(s), checkpoint history for ${counts.checkpointedThreads} thread(s)`;
|
|
633
|
+
}
|
|
634
|
+
async function runImportLanggraph(options) {
|
|
635
|
+
const configPath = path6.resolve(process.cwd(), options.config);
|
|
636
|
+
const { config, configDir } = await loadConfig3({ configPath });
|
|
637
|
+
const sourceDir = options.from ? path6.resolve(process.cwd(), options.from) : path6.join(configDir, LANGGRAPH_DIR);
|
|
638
|
+
const snapshot = await readLanggraphDevState2(sourceDir);
|
|
639
|
+
if (snapshot === null) {
|
|
640
|
+
console.log(`skein: no LangGraph dev state found at ${sourceDir}; nothing to import.`);
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
const counts = describeSnapshot2(snapshot);
|
|
644
|
+
if (options.store === "memory") {
|
|
645
|
+
const stateFile = devStateFile(configDir);
|
|
646
|
+
if (existsSync5(stateFile) && !options.force) {
|
|
647
|
+
console.error(
|
|
648
|
+
`skein: ${path6.relative(process.cwd(), stateFile)} already exists. Re-run with --force to overwrite it (or delete it first).`
|
|
649
|
+
);
|
|
650
|
+
process.exitCode = 1;
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
writeDevStateFile(stateFile, JSON.stringify(snapshot));
|
|
654
|
+
console.log(
|
|
655
|
+
`skein: imported ${summarize(counts)} \u2192 ${path6.relative(process.cwd(), stateFile)}.`
|
|
656
|
+
);
|
|
657
|
+
console.log("skein: run `skein dev` to use it.");
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
await applyProjectEnv(config, configDir);
|
|
661
|
+
const loader = await createViteGraphLoader(configDir);
|
|
662
|
+
try {
|
|
663
|
+
const runtime = await buildRuntime2({
|
|
664
|
+
configPath,
|
|
665
|
+
importModule: loader.importModule,
|
|
666
|
+
store: "postgres",
|
|
667
|
+
queue: "memory"
|
|
668
|
+
});
|
|
669
|
+
try {
|
|
670
|
+
await loadSnapshotIntoStore(snapshot, runtime.deps.store, runtime.deps.checkpointer);
|
|
671
|
+
} finally {
|
|
672
|
+
await runtime.dispose();
|
|
673
|
+
}
|
|
674
|
+
} finally {
|
|
675
|
+
await loader.close();
|
|
676
|
+
}
|
|
677
|
+
console.log(`skein: imported ${summarize(counts)} into Postgres.`);
|
|
678
|
+
}
|
|
679
|
+
|
|
470
680
|
// src/index.ts
|
|
471
681
|
var require2 = createRequire(import.meta.url);
|
|
472
682
|
var { version } = require2("../package.json");
|
|
@@ -490,10 +700,22 @@ var parseQueue = parseChoice(["memory", "redis"]);
|
|
|
490
700
|
var program = new Command().name("skein").description(
|
|
491
701
|
"Agent Protocol server for LangGraph.js \u2014 a drop-in replacement for the LangGraph CLI."
|
|
492
702
|
).version(version, "-v, --version");
|
|
493
|
-
program.command("dev").description("Run the in-process dev server with hot reload (no Docker).").option("-c, --config <path>", "Path to langgraph.json", "langgraph.json").option("-p, --port <port>", "Port to bind", parsePort, 2024).option("--host <host>", "Host to bind", "127.0.0.1").option("--no-reload", "Disable hot reload").option("--no-persist", "Don't persist dev state to .skein/ between restarts").option("--store <driver>", "Store driver: memory | postgres", parseStore, "memory").option("--queue <driver>", "Queue driver: memory | redis", parseQueue, "memory").option("-v, --verbose", "Log per-run activity: start/finish, tool calls, and interrupts").action(
|
|
703
|
+
program.command("dev").description("Run the in-process dev server with hot reload (no Docker).").option("-c, --config <path>", "Path to langgraph.json", "langgraph.json").option("-p, --port <port>", "Port to bind", parsePort, 2024).option("--host <host>", "Host to bind", "127.0.0.1").option("--no-reload", "Disable hot reload").option("--no-persist", "Don't persist dev state to .skein/ between restarts").option("--store <driver>", "Store driver: memory | postgres", parseStore, "memory").option("--queue <driver>", "Queue driver: memory | redis", parseQueue, "memory").option("-v, --verbose", "Log per-run activity: start/finish, tool calls, and interrupts").action(
|
|
704
|
+
(options, command) => runDev({
|
|
705
|
+
...options,
|
|
706
|
+
portExplicit: command.getOptionValueSource("port") === "cli",
|
|
707
|
+
hostExplicit: command.getOptionValueSource("host") === "cli"
|
|
708
|
+
})
|
|
709
|
+
);
|
|
494
710
|
program.command("up").description("Bring up the production stack (Docker Compose: app + Postgres + Redis).").option("-c, --config <path>", "Path to langgraph.json", "langgraph.json").option("-p, --port <port>", "Port to expose", parsePort, 8123).option("--host <host>", "Host to bind", "0.0.0.0").action((options) => runUp(options));
|
|
495
711
|
program.command("build").description("Build a deployable Docker image from the config.").option("-c, --config <path>", "Path to langgraph.json", "langgraph.json").option("-t, --tag <tag>", "Image tag (defaults to the project directory name)").action((options) => runBuild(options));
|
|
496
712
|
program.command("dockerfile").description("Emit a standalone Dockerfile from the config.").option("-c, --config <path>", "Path to langgraph.json", "langgraph.json").option("-o, --output <path>", "Write the Dockerfile here instead of stdout").action((options) => runDockerfile(options));
|
|
713
|
+
program.command("import-langgraph").description("Import an existing LangGraph in-memory dev state (.langgraph_api/) into skein.").option("-c, --config <path>", "Path to langgraph.json", "langgraph.json").option(
|
|
714
|
+
"--store <driver>",
|
|
715
|
+
"Import target: memory (.skein/dev-state.json) | postgres (DATABASE_URL)",
|
|
716
|
+
parseStore,
|
|
717
|
+
"memory"
|
|
718
|
+
).option("--from <dir>", "Source .langgraph_api directory (defaults to alongside langgraph.json)").option("--force", "Overwrite an existing .skein/dev-state.json (memory target)", false).action((options) => runImportLanggraph(options));
|
|
497
719
|
try {
|
|
498
720
|
await program.parseAsync(process.argv);
|
|
499
721
|
} catch (error) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "skein-js",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "The skein-js CLI — a drop-in replacement for the LangGraph CLI (dev/up/build/dockerfile).",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Maina Wycliffe <wmmaina7@gmail.com>",
|
|
@@ -48,10 +48,10 @@
|
|
|
48
48
|
"@langchain/langgraph-checkpoint-postgres": "^1.0.4",
|
|
49
49
|
"commander": "~13.1.0",
|
|
50
50
|
"vite": "^8.1.0",
|
|
51
|
-
"@skein-js/core": "0.
|
|
52
|
-
"@skein-js/config": "0.
|
|
53
|
-
"@skein-js/express": "0.
|
|
54
|
-
"@skein-js/runtime": "0.
|
|
51
|
+
"@skein-js/core": "0.3.0",
|
|
52
|
+
"@skein-js/config": "0.3.0",
|
|
53
|
+
"@skein-js/express": "0.3.0",
|
|
54
|
+
"@skein-js/runtime": "0.3.0"
|
|
55
55
|
},
|
|
56
56
|
"peerDependencies": {
|
|
57
57
|
"@langchain/langgraph": "^1.4.0"
|