skein-js 0.2.1 → 0.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.
- package/README.md +2 -2
- package/dist/index.js +303 -69
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -67,8 +67,8 @@ All commands take `-c, --config <path>` (default `langgraph.json`).
|
|
|
67
67
|
| ------------------ | -------------------- | ----------- | -------------------------------------------------------------- |
|
|
68
68
|
| `-p, --port` | number | `2024` | Port to listen on. |
|
|
69
69
|
| `--host` | host | `127.0.0.1` | Interface to bind. |
|
|
70
|
-
| `--store <driver>` | `memory`, `postgres` | `memory` | `postgres` reads `
|
|
71
|
-
| `--queue <driver>` | `memory`, `redis` | `memory` | `redis` reads `
|
|
70
|
+
| `--store <driver>` | `memory`, `postgres` | `memory` | `postgres` reads `POSTGRES_URI`; also selects `PostgresSaver`. |
|
|
71
|
+
| `--queue <driver>` | `memory`, `redis` | `memory` | `redis` reads `REDIS_URI` (BullMQ queue + Redis Streams bus). |
|
|
72
72
|
| `--no-persist` | — | persists | Don't snapshot dev state to `.skein/` across restarts. |
|
|
73
73
|
| `--no-reload` | — | reloads | Disable hot reload on source change. |
|
|
74
74
|
| `-v, --verbose` | — | off | Log per-run activity (tool calls, interrupts, timing). |
|
package/dist/index.js
CHANGED
|
@@ -5,17 +5,143 @@ 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
|
-
import {
|
|
140
|
+
import {
|
|
141
|
+
createServer,
|
|
142
|
+
createServerModuleRunner,
|
|
143
|
+
searchForWorkspaceRoot
|
|
144
|
+
} from "vite";
|
|
19
145
|
function findFreePort() {
|
|
20
146
|
return new Promise((resolve, reject) => {
|
|
21
147
|
const probe = createNetServer();
|
|
@@ -28,9 +154,14 @@ function findFreePort() {
|
|
|
28
154
|
});
|
|
29
155
|
}
|
|
30
156
|
async function createViteGraphLoader(root, ignored = []) {
|
|
157
|
+
const workspaceRoot = searchForWorkspaceRoot(root);
|
|
31
158
|
const server = await createServer({
|
|
32
159
|
root,
|
|
33
160
|
configFile: false,
|
|
161
|
+
// Resolve tsconfig `paths` natively (vite 8+): vite reads the project's tsconfig, follows
|
|
162
|
+
// `extends` to a base tsconfig (e.g. `tsconfig.base.json`), and honors `baseUrl` + wildcard
|
|
163
|
+
// aliases — so graphs that import workspace packages through path aliases resolve.
|
|
164
|
+
resolve: { tsconfigPaths: true },
|
|
34
165
|
appType: "custom",
|
|
35
166
|
logLevel: "warn",
|
|
36
167
|
server: {
|
|
@@ -40,7 +171,10 @@ async function createViteGraphLoader(root, ignored = []) {
|
|
|
40
171
|
// "port 24678 already in use" error — when two `skein dev`s run at once. Give it an ephemeral
|
|
41
172
|
// port so instances never clash.
|
|
42
173
|
hmr: { port: await findFreePort() },
|
|
43
|
-
watch: { ignored: ["**/node_modules/**", "**/.git/**", "**/dist/**", ...ignored] }
|
|
174
|
+
watch: { ignored: ["**/node_modules/**", "**/.git/**", "**/dist/**", ...ignored] },
|
|
175
|
+
// Aliased lib sources resolve outside the app `root` (e.g. `../../libs/**`). Widen vite's
|
|
176
|
+
// file-serving allowlist to the workspace root so the module runner can transform them.
|
|
177
|
+
fs: { allow: [workspaceRoot] }
|
|
44
178
|
},
|
|
45
179
|
optimizeDeps: { noDiscovery: true }
|
|
46
180
|
});
|
|
@@ -60,39 +194,23 @@ async function createViteGraphLoader(root, ignored = []) {
|
|
|
60
194
|
var RELOAD_DEBOUNCE_MS = 120;
|
|
61
195
|
var AUTOSAVE_MS = 2e3;
|
|
62
196
|
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
|
-
}
|
|
197
|
+
var devLogger = createDevLogger();
|
|
198
|
+
function envPort(fallback) {
|
|
199
|
+
const raw = process.env.PORT;
|
|
200
|
+
if (raw === void 0 || raw.trim() === "") return fallback;
|
|
201
|
+
const port = Number(raw);
|
|
202
|
+
return Number.isInteger(port) && port >= 0 && port <= 65535 ? port : fallback;
|
|
75
203
|
}
|
|
76
|
-
function
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
try {
|
|
80
|
-
return parseEnvFile(readFileSync(envPath, "utf8"));
|
|
81
|
-
} catch {
|
|
82
|
-
return {};
|
|
83
|
-
}
|
|
204
|
+
function envHost(fallback) {
|
|
205
|
+
const host = process.env.HOST;
|
|
206
|
+
return host !== void 0 && host.trim() !== "" ? host : fallback;
|
|
84
207
|
}
|
|
85
208
|
async function runDev(options) {
|
|
86
|
-
const configPath =
|
|
209
|
+
const configPath = path3.resolve(process.cwd(), options.config);
|
|
87
210
|
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);
|
|
211
|
+
await applyProjectEnv(config, configDir);
|
|
212
|
+
const stateDir = path3.join(configDir, STATE_DIR);
|
|
213
|
+
const stateFile = devStateFile(configDir);
|
|
96
214
|
const loader = await createViteGraphLoader(configDir, [`${STATE_DIR}/**`, `**/${STATE_DIR}/**`]);
|
|
97
215
|
const runtime = await buildRuntime({
|
|
98
216
|
configPath,
|
|
@@ -100,22 +218,38 @@ async function runDev(options) {
|
|
|
100
218
|
store: options.store,
|
|
101
219
|
queue: options.queue
|
|
102
220
|
});
|
|
103
|
-
const
|
|
221
|
+
const port = options.portExplicit ? options.port : envPort(options.port);
|
|
222
|
+
const host = options.hostExplicit ? options.host : envHost(options.host);
|
|
104
223
|
const canPersist = options.persist && runtime.snapshotState !== void 0;
|
|
105
224
|
if (options.persist && runtime.snapshotState === void 0) {
|
|
106
225
|
console.log(
|
|
107
226
|
`skein: state persists in ${options.store}/${options.queue}; skipping .skein snapshot.`
|
|
108
227
|
);
|
|
109
228
|
}
|
|
110
|
-
if (canPersist &&
|
|
229
|
+
if (canPersist && existsSync2(stateFile)) {
|
|
111
230
|
try {
|
|
112
|
-
runtime.hydrateState?.(JSON.parse(
|
|
231
|
+
runtime.hydrateState?.(JSON.parse(readFileSync2(stateFile, "utf8")));
|
|
113
232
|
console.log("skein: restored dev state.");
|
|
114
233
|
} catch (error) {
|
|
115
234
|
console.warn(
|
|
116
235
|
`skein: could not restore dev state: ${error instanceof Error ? error.message : String(error)}`
|
|
117
236
|
);
|
|
118
237
|
}
|
|
238
|
+
} else if (canPersist && existsSync2(path3.join(configDir, LANGGRAPH_DIR))) {
|
|
239
|
+
try {
|
|
240
|
+
const imported = await readLanggraphDevState(path3.join(configDir, LANGGRAPH_DIR));
|
|
241
|
+
if (imported) {
|
|
242
|
+
runtime.hydrateState?.(imported);
|
|
243
|
+
const counts = describeSnapshot(imported);
|
|
244
|
+
console.log(
|
|
245
|
+
`skein: imported dev state from ${LANGGRAPH_DIR}/ (${counts.threads} thread(s), ${counts.checkpointedThreads} with history).`
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
} catch (error) {
|
|
249
|
+
console.warn(
|
|
250
|
+
`skein: could not import ${LANGGRAPH_DIR}/: ${error instanceof Error ? error.message : String(error)}`
|
|
251
|
+
);
|
|
252
|
+
}
|
|
119
253
|
}
|
|
120
254
|
if (options.verbose) runtime.deps.logRunActivity = true;
|
|
121
255
|
let server;
|
|
@@ -136,17 +270,23 @@ async function runDev(options) {
|
|
|
136
270
|
process.exitCode = 1;
|
|
137
271
|
return;
|
|
138
272
|
}
|
|
139
|
-
|
|
273
|
+
printBanner(
|
|
274
|
+
{
|
|
275
|
+
host,
|
|
276
|
+
port,
|
|
277
|
+
graphIds: runtime.deps.graphs.ids,
|
|
278
|
+
authPath: config.auth?.path,
|
|
279
|
+
workerCount: 1
|
|
280
|
+
},
|
|
281
|
+
devLogger
|
|
282
|
+
);
|
|
140
283
|
let lastSaved;
|
|
141
284
|
const saveState = () => {
|
|
142
285
|
if (!canPersist || runtime.snapshotState === void 0) return;
|
|
143
286
|
try {
|
|
144
287
|
const serialized = JSON.stringify(runtime.snapshotState());
|
|
145
288
|
if (serialized === lastSaved) return;
|
|
146
|
-
|
|
147
|
-
const tmp = `${stateFile}.tmp`;
|
|
148
|
-
writeFileSync(tmp, serialized);
|
|
149
|
-
renameSync(tmp, stateFile);
|
|
289
|
+
writeDevStateFile(stateFile, serialized);
|
|
150
290
|
lastSaved = serialized;
|
|
151
291
|
} catch (error) {
|
|
152
292
|
console.warn(
|
|
@@ -194,7 +334,7 @@ async function runDev(options) {
|
|
|
194
334
|
}
|
|
195
335
|
};
|
|
196
336
|
loader.watcher.on("change", (file) => {
|
|
197
|
-
if (`${
|
|
337
|
+
if (`${path3.resolve(file)}${path3.sep}`.startsWith(`${stateDir}${path3.sep}`)) return;
|
|
198
338
|
if (pending) clearTimeout(pending);
|
|
199
339
|
pending = setTimeout(() => void reload(), RELOAD_DEBOUNCE_MS);
|
|
200
340
|
});
|
|
@@ -217,8 +357,8 @@ async function runDev(options) {
|
|
|
217
357
|
|
|
218
358
|
// src/docker/commands.ts
|
|
219
359
|
import { spawn, spawnSync } from "child_process";
|
|
220
|
-
import { existsSync as
|
|
221
|
-
import
|
|
360
|
+
import { existsSync as existsSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
361
|
+
import path5 from "path";
|
|
222
362
|
import { loadConfig as loadConfig2 } from "@skein-js/config";
|
|
223
363
|
|
|
224
364
|
// src/docker/compose.ts
|
|
@@ -233,11 +373,17 @@ function generateCompose(options) {
|
|
|
233
373
|
services:
|
|
234
374
|
app:
|
|
235
375
|
build: .
|
|
376
|
+
# Reap zombies + forward signals with an init process (PID 1), so graph-spawned children don't
|
|
377
|
+
# accumulate; the app itself still handles SIGTERM for graceful shutdown.
|
|
378
|
+
init: true
|
|
236
379
|
ports:
|
|
237
380
|
- ${ports}
|
|
238
381
|
environment:
|
|
239
|
-
|
|
240
|
-
|
|
382
|
+
# The container CMD passes no --port; it binds $PORT (as a hosting platform would inject).
|
|
383
|
+
# Set it here so the app listens on the port the mapping above publishes.
|
|
384
|
+
PORT: "${options.containerPort}"
|
|
385
|
+
POSTGRES_URI: postgresql://postgres:postgres@postgres:5432/skein
|
|
386
|
+
REDIS_URI: redis://redis:6379
|
|
241
387
|
depends_on:
|
|
242
388
|
postgres:
|
|
243
389
|
condition: service_healthy
|
|
@@ -275,15 +421,15 @@ volumes:
|
|
|
275
421
|
}
|
|
276
422
|
|
|
277
423
|
// src/docker/dockerfile.ts
|
|
278
|
-
import { existsSync as
|
|
279
|
-
import
|
|
424
|
+
import { existsSync as existsSync3 } from "fs";
|
|
425
|
+
import path4 from "path";
|
|
280
426
|
function baseImage(nodeVersion) {
|
|
281
427
|
const major = nodeVersion?.trim().match(/^\d+/)?.[0] ?? "20";
|
|
282
428
|
return `node:${major}-slim`;
|
|
283
429
|
}
|
|
284
430
|
function detectPackageManager(projectDir) {
|
|
285
|
-
if (
|
|
286
|
-
if (
|
|
431
|
+
if (existsSync3(path4.join(projectDir, "pnpm-lock.yaml"))) return "pnpm";
|
|
432
|
+
if (existsSync3(path4.join(projectDir, "yarn.lock"))) return "yarn";
|
|
287
433
|
return "npm";
|
|
288
434
|
}
|
|
289
435
|
function installSteps(manager) {
|
|
@@ -291,18 +437,18 @@ function installSteps(manager) {
|
|
|
291
437
|
case "pnpm":
|
|
292
438
|
return {
|
|
293
439
|
copyLock: "COPY package.json pnpm-lock.yaml* ./",
|
|
294
|
-
install: "RUN corepack enable && pnpm install --frozen-lockfile"
|
|
440
|
+
install: "RUN --mount=type=cache,target=/root/.local/share/pnpm/store corepack enable && pnpm install --frozen-lockfile"
|
|
295
441
|
};
|
|
296
442
|
case "yarn":
|
|
297
443
|
return {
|
|
298
444
|
copyLock: "COPY package.json yarn.lock* ./",
|
|
299
|
-
install: "RUN corepack enable && yarn install --frozen-lockfile"
|
|
445
|
+
install: "RUN --mount=type=cache,target=/usr/local/share/.cache/yarn corepack enable && yarn install --frozen-lockfile"
|
|
300
446
|
};
|
|
301
447
|
case "npm":
|
|
302
448
|
return {
|
|
303
449
|
copyLock: "COPY package.json package-lock.json* ./",
|
|
304
450
|
// `npm ci` needs a lockfile; fall back to `npm install` when there isn't one.
|
|
305
|
-
install: "RUN npm ci || npm install"
|
|
451
|
+
install: "RUN --mount=type=cache,target=/root/.npm npm ci || npm install"
|
|
306
452
|
};
|
|
307
453
|
}
|
|
308
454
|
}
|
|
@@ -325,23 +471,37 @@ function generateDockerfile(options) {
|
|
|
325
471
|
const { nodeVersion, dockerfileLines = [], port, packageManager } = options;
|
|
326
472
|
const { copyLock, install } = installSteps(packageManager);
|
|
327
473
|
const lines = [
|
|
474
|
+
// The syntax directive must be the first line to be honored — it enables the RUN cache mounts.
|
|
475
|
+
`# syntax=docker/dockerfile:1`,
|
|
328
476
|
`# Generated by skein \u2014 do not edit by hand (regenerated by \`skein build\` / \`skein up\`).`,
|
|
329
477
|
`FROM ${baseImage(nodeVersion)}`,
|
|
478
|
+
// Hand /app to the unprivileged 'node' user (uid 1000, shipped by the base image) so the server
|
|
479
|
+
// never runs as root. corepack/npm need root to install, so install as root, then chown the
|
|
480
|
+
// whole tree — including the root-created node_modules, where vite writes its cache at runtime.
|
|
330
481
|
`WORKDIR /app`,
|
|
331
482
|
``,
|
|
332
|
-
`# Install dependencies first for better layer caching.`,
|
|
483
|
+
`# Install dependencies first for better layer caching (BuildKit cache mount speeds rebuilds).`,
|
|
333
484
|
copyLock,
|
|
334
485
|
install,
|
|
335
486
|
``,
|
|
336
487
|
`# Copy the rest of the project (graphs, langgraph.json, source).`,
|
|
337
488
|
`COPY . .`,
|
|
338
489
|
``,
|
|
490
|
+
`# Give the runtime user ownership of the app + installed node_modules so vite can write its`,
|
|
491
|
+
`# on-disk cache under node_modules/.vite without EACCES.`,
|
|
492
|
+
`RUN chown -R node:node /app`,
|
|
493
|
+
``,
|
|
494
|
+
// Set NODE_ENV only after install so devDependencies (the vite/tsx toolchain `skein dev` loads
|
|
495
|
+
// graphs with) are installed; setting it before install would prune them and break graph loading.
|
|
339
496
|
`ENV NODE_ENV=production`,
|
|
340
497
|
`EXPOSE ${port}`,
|
|
498
|
+
`USER node`,
|
|
341
499
|
...dockerfileLines,
|
|
342
500
|
``,
|
|
343
|
-
`# Serve the Agent Protocol against Postgres + Redis (
|
|
344
|
-
|
|
501
|
+
`# Serve the Agent Protocol against Postgres + Redis (POSTGRES_URI / REDIS_URI from the environment).`,
|
|
502
|
+
`# No --port: the server binds $PORT when the platform injects one (Railway/Fly/Render), else its`,
|
|
503
|
+
`# default. Exec form keeps node as PID 1 so SIGTERM reaches skein's graceful-shutdown handler.`,
|
|
504
|
+
`CMD ["npx", "skein", "dev", "--no-reload", "--no-persist", "--store", "postgres", "--queue", "redis", "--host", "0.0.0.0"]`
|
|
345
505
|
];
|
|
346
506
|
return `${lines.join("\n")}
|
|
347
507
|
`;
|
|
@@ -353,8 +513,11 @@ var DOCKERFILE_NAME = "Dockerfile";
|
|
|
353
513
|
var COMPOSE_NAME = "compose.yaml";
|
|
354
514
|
var DOCKERIGNORE_NAME = ".dockerignore";
|
|
355
515
|
var GENERATED_MARKER = "# Generated by skein";
|
|
516
|
+
function isSkeinGenerated(contents) {
|
|
517
|
+
return contents.split("\n", 3).some((line2) => line2.startsWith(GENERATED_MARKER));
|
|
518
|
+
}
|
|
356
519
|
async function loadConfigContext(configOption) {
|
|
357
|
-
const configPath =
|
|
520
|
+
const configPath = path5.resolve(process.cwd(), configOption);
|
|
358
521
|
const { config, configDir } = await loadConfig2({ configPath });
|
|
359
522
|
return { config, configDir };
|
|
360
523
|
}
|
|
@@ -367,7 +530,7 @@ function renderDockerfile({ config, configDir }, port) {
|
|
|
367
530
|
});
|
|
368
531
|
}
|
|
369
532
|
function defaultImageTag(configDir) {
|
|
370
|
-
const base =
|
|
533
|
+
const base = path5.basename(configDir).toLowerCase().replace(/[^a-z0-9._-]+/g, "-");
|
|
371
534
|
const trimmed = base.replace(/^[-.]+|[-.]+$/g, "");
|
|
372
535
|
return trimmed.length > 0 ? trimmed : "skein-app";
|
|
373
536
|
}
|
|
@@ -401,8 +564,8 @@ function describeExit(result) {
|
|
|
401
564
|
return result.signal !== null ? `killed by ${result.signal}` : `exit ${result.code}`;
|
|
402
565
|
}
|
|
403
566
|
function writeGeneratedFile(file, contents) {
|
|
404
|
-
const name =
|
|
405
|
-
if (
|
|
567
|
+
const name = path5.basename(file);
|
|
568
|
+
if (existsSync4(file) && !isSkeinGenerated(readFileSync3(file, "utf8"))) {
|
|
406
569
|
console.log(`skein: ${name} exists and wasn't generated by skein \u2014 using it as-is.`);
|
|
407
570
|
return "kept-user";
|
|
408
571
|
}
|
|
@@ -414,7 +577,7 @@ async function runDockerfile(options) {
|
|
|
414
577
|
const context = await loadConfigContext(options.config);
|
|
415
578
|
const contents = renderDockerfile(context, CONTAINER_PORT);
|
|
416
579
|
if (options.output !== void 0) {
|
|
417
|
-
const target =
|
|
580
|
+
const target = path5.resolve(process.cwd(), options.output);
|
|
418
581
|
writeFileSync2(target, contents);
|
|
419
582
|
console.log(`skein: wrote ${target}.`);
|
|
420
583
|
return;
|
|
@@ -424,9 +587,9 @@ async function runDockerfile(options) {
|
|
|
424
587
|
async function runBuild(options) {
|
|
425
588
|
const context = await loadConfigContext(options.config);
|
|
426
589
|
if (!requireDocker()) return;
|
|
427
|
-
const dockerfilePath =
|
|
590
|
+
const dockerfilePath = path5.join(context.configDir, DOCKERFILE_NAME);
|
|
428
591
|
writeGeneratedFile(dockerfilePath, renderDockerfile(context, CONTAINER_PORT));
|
|
429
|
-
writeGeneratedFile(
|
|
592
|
+
writeGeneratedFile(path5.join(context.configDir, DOCKERIGNORE_NAME), generateDockerignore());
|
|
430
593
|
const tag = options.tag ?? defaultImageTag(context.configDir);
|
|
431
594
|
console.log(`skein: building image "${tag}"\u2026`);
|
|
432
595
|
const result = await runToCompletion(
|
|
@@ -445,12 +608,12 @@ async function runUp(options) {
|
|
|
445
608
|
const context = await loadConfigContext(options.config);
|
|
446
609
|
if (!requireDocker()) return;
|
|
447
610
|
writeGeneratedFile(
|
|
448
|
-
|
|
611
|
+
path5.join(context.configDir, DOCKERFILE_NAME),
|
|
449
612
|
renderDockerfile(context, CONTAINER_PORT)
|
|
450
613
|
);
|
|
451
|
-
writeGeneratedFile(
|
|
614
|
+
writeGeneratedFile(path5.join(context.configDir, DOCKERIGNORE_NAME), generateDockerignore());
|
|
452
615
|
writeGeneratedFile(
|
|
453
|
-
|
|
616
|
+
path5.join(context.configDir, COMPOSE_NAME),
|
|
454
617
|
generateCompose({ hostPort: options.port, host: options.host, containerPort: CONTAINER_PORT })
|
|
455
618
|
);
|
|
456
619
|
console.log(
|
|
@@ -467,6 +630,65 @@ async function runUp(options) {
|
|
|
467
630
|
}
|
|
468
631
|
}
|
|
469
632
|
|
|
633
|
+
// src/import-command.ts
|
|
634
|
+
import { existsSync as existsSync5 } from "fs";
|
|
635
|
+
import path6 from "path";
|
|
636
|
+
import { loadConfig as loadConfig3 } from "@skein-js/config";
|
|
637
|
+
import {
|
|
638
|
+
describeSnapshot as describeSnapshot2,
|
|
639
|
+
loadSnapshotIntoStore,
|
|
640
|
+
readLanggraphDevState as readLanggraphDevState2
|
|
641
|
+
} from "@skein-js/express";
|
|
642
|
+
import { buildRuntime as buildRuntime2 } from "@skein-js/runtime";
|
|
643
|
+
function summarize(counts) {
|
|
644
|
+
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)`;
|
|
645
|
+
}
|
|
646
|
+
async function runImportLanggraph(options) {
|
|
647
|
+
const configPath = path6.resolve(process.cwd(), options.config);
|
|
648
|
+
const { config, configDir } = await loadConfig3({ configPath });
|
|
649
|
+
const sourceDir = options.from ? path6.resolve(process.cwd(), options.from) : path6.join(configDir, LANGGRAPH_DIR);
|
|
650
|
+
const snapshot = await readLanggraphDevState2(sourceDir);
|
|
651
|
+
if (snapshot === null) {
|
|
652
|
+
console.log(`skein: no LangGraph dev state found at ${sourceDir}; nothing to import.`);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
const counts = describeSnapshot2(snapshot);
|
|
656
|
+
if (options.store === "memory") {
|
|
657
|
+
const stateFile = devStateFile(configDir);
|
|
658
|
+
if (existsSync5(stateFile) && !options.force) {
|
|
659
|
+
console.error(
|
|
660
|
+
`skein: ${path6.relative(process.cwd(), stateFile)} already exists. Re-run with --force to overwrite it (or delete it first).`
|
|
661
|
+
);
|
|
662
|
+
process.exitCode = 1;
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
writeDevStateFile(stateFile, JSON.stringify(snapshot));
|
|
666
|
+
console.log(
|
|
667
|
+
`skein: imported ${summarize(counts)} \u2192 ${path6.relative(process.cwd(), stateFile)}.`
|
|
668
|
+
);
|
|
669
|
+
console.log("skein: run `skein dev` to use it.");
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
await applyProjectEnv(config, configDir);
|
|
673
|
+
const loader = await createViteGraphLoader(configDir);
|
|
674
|
+
try {
|
|
675
|
+
const runtime = await buildRuntime2({
|
|
676
|
+
configPath,
|
|
677
|
+
importModule: loader.importModule,
|
|
678
|
+
store: "postgres",
|
|
679
|
+
queue: "memory"
|
|
680
|
+
});
|
|
681
|
+
try {
|
|
682
|
+
await loadSnapshotIntoStore(snapshot, runtime.deps.store, runtime.deps.checkpointer);
|
|
683
|
+
} finally {
|
|
684
|
+
await runtime.dispose();
|
|
685
|
+
}
|
|
686
|
+
} finally {
|
|
687
|
+
await loader.close();
|
|
688
|
+
}
|
|
689
|
+
console.log(`skein: imported ${summarize(counts)} into Postgres.`);
|
|
690
|
+
}
|
|
691
|
+
|
|
470
692
|
// src/index.ts
|
|
471
693
|
var require2 = createRequire(import.meta.url);
|
|
472
694
|
var { version } = require2("../package.json");
|
|
@@ -490,10 +712,22 @@ var parseQueue = parseChoice(["memory", "redis"]);
|
|
|
490
712
|
var program = new Command().name("skein").description(
|
|
491
713
|
"Agent Protocol server for LangGraph.js \u2014 a drop-in replacement for the LangGraph CLI."
|
|
492
714
|
).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(
|
|
715
|
+
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(
|
|
716
|
+
(options, command) => runDev({
|
|
717
|
+
...options,
|
|
718
|
+
portExplicit: command.getOptionValueSource("port") === "cli",
|
|
719
|
+
hostExplicit: command.getOptionValueSource("host") === "cli"
|
|
720
|
+
})
|
|
721
|
+
);
|
|
494
722
|
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
723
|
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
724
|
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));
|
|
725
|
+
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(
|
|
726
|
+
"--store <driver>",
|
|
727
|
+
"Import target: memory (.skein/dev-state.json) | postgres (POSTGRES_URI)",
|
|
728
|
+
parseStore,
|
|
729
|
+
"memory"
|
|
730
|
+
).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
731
|
try {
|
|
498
732
|
await program.parseAsync(process.argv);
|
|
499
733
|
} catch (error) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "skein-js",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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/
|
|
52
|
-
"@skein-js/
|
|
53
|
-
"@skein-js/
|
|
54
|
-
"@skein-js/runtime": "0.
|
|
51
|
+
"@skein-js/core": "0.4.0",
|
|
52
|
+
"@skein-js/express": "0.4.0",
|
|
53
|
+
"@skein-js/config": "0.4.0",
|
|
54
|
+
"@skein-js/runtime": "0.4.0"
|
|
55
55
|
},
|
|
56
56
|
"peerDependencies": {
|
|
57
57
|
"@langchain/langgraph": "^1.4.0"
|