Compare commits

..

24 Commits

Author SHA1 Message Date
xiaoju 418d8ee0c8 refactor(cli): replace sql.js with node:sqlite
Drop the sql.js WASM dependency in favour of Node 22's built-in
node:sqlite (DatabaseSync). This eliminates the ~2 MB WASM binary,
removes the async init ceremony, and lets us open databases in
readonly mode directly on disk instead of loading them into memory.

Breaking: requires Node >= 22.5.0 (sqlite support).

- Remove sql.js from cli dependencies
- Rewrite sense-sqlite.ts to use DatabaseSync
- Update sense command (schema/query) — sync API, no more queryAsObjects
- Update tests to use node:sqlite directly
- Remove sql.js from tsup externals

小橘 🍊(NEKO Team)
2026-04-23 08:43:39 +00:00
xiaomo 719c4c1449 Merge pull request 'refactor(cli): replace better-sqlite3 with sql.js (pure WASM) — implements RFC #63' (#64) from refactor/sql-js-migration into main 2026-04-23 07:32:38 +00:00
xiaoju c8bf4bf547 refactor(cli): replace better-sqlite3 with sql.js (pure WASM)
- Remove native C++ addon dependency, no more pnpm approve-builds
- sql.js loads SQLite as WASM, zero compilation required
- WASM init is singleton (once per process)
- Add queryAsObjects() adapter for sql.js columnar → row format
- Tests migrated to sql.js (16 passing)

Implements RFC #63
2026-04-23 07:25:08 +00:00
xiaoju 9b93c4a4d9 chore(cli): bump version to 0.1.8 2026-04-23 07:10:28 +00:00
xiaomo ca14c5f51d Merge pull request 'feat(cli): add nerve sense schema and query commands (closes #60)' (#62) from feat/sense-query into main 2026-04-23 07:06:02 +00:00
xiaomo 1979e0e16c Merge pull request 'refactor: replace dynamic imports with static imports in CLI' (#61) from refactor/static-imports into main 2026-04-23 07:04:31 +00:00
xingyue 9102c6698a chore: remove gitea-access rule from project (belongs in agent local skills) 2026-04-23 15:03:14 +08:00
xiaoju b15fc993f2 feat(cli): add nerve sense schema and query commands
Open each sense SQLite file read-only under data/senses. schema lists CREATE TABLE SQL from sqlite_master; query runs optional SQL or a default SELECT ordered by rowid. Human output uses aligned columns; --json for machine-readable output. Add better-sqlite3 to the CLI package and externalize it in tsup.

Tests cover sense-sqlite helpers and integration against a temp database.

Made-with: Cursor
2026-04-23 07:01:16 +00:00
xingyue 6cc8833b2a chore: add cursor rules and annotate legitimate dynamic imports
- Add .cursor/rules/no-dynamic-import.mdc: ban dynamic import() in
  production code with documented exceptions
- Add .cursor/rules/gitea-access.mdc: tea CLI usage guide
- Add explanatory comments on the 2 legitimate dynamic imports in
  sense-runtime.ts and workflow-worker.ts
2026-04-23 15:00:07 +08:00
xiaomo fc76b862ad Merge pull request 'refactor(cli): replace dynamic imports with static imports — closes #57' (#59) from refactor/static-imports into main 2026-04-23 06:55:46 +00:00
xingyue 787e791aba refactor(cli): replace dynamic imports with static imports
Convert 6 unnecessary `await import()` calls for Node built-in modules
(node:child_process, node:util) and project modules (../workspace.js)
to static top-level imports in init.ts and start.ts.

Closes #57
2026-04-23 14:52:18 +08:00
xiaomo 96188c8cda Merge pull request 'fix(daemon): foreground worker signals and crash diagnostics (closes #55, closes #56)' (#58) from fix/dev-worker-crash into main 2026-04-23 06:48:33 +00:00
xiaoju f1458f8353 fix(daemon): foreground worker signals and crash diagnostics
Ignore SIGINT/SIGTERM only when fork IPC is active (process.send) so terminal signals do not race the kernel shutdown in nerve dev, without breaking standalone worker CLIs (fixes #55).

Pipe worker stderr through the parent with a rolling capture buffer; log exit signal name and stderr tail on worker exit (fixes #56). Apply the same exit logging to workflow workers.

Made-with: Cursor
2026-04-23 06:41:32 +00:00
xiaomo 781f571474 Merge pull request 'refactor: add daemon subcommand group and dev foreground mode' (#54) from refactor/daemon-subcommand into main 2026-04-23 04:24:31 +00:00
xiaoju 640f170de8 refactor: add daemon subcommand group and dev foreground mode
- Create 'nerve daemon' subcommand group: start, stop, status, restart, logs
- Create 'nerve dev' for foreground mode (replaces old start without -d)
- 'nerve daemon start' is always background (removed -d/--daemon flag)
- Keep top-level aliases: nerve start/stop/status/logs → nerve daemon *
- Extract runStopCommand() for restart reuse
- Add daemon-cli tests

Closes #53

小橘 🍊(NEKO Team)
2026-04-23 01:16:13 +00:00
xiaoju 119b1f3722 chore: enforce pnpm publish for all packages unconditionally
小橘 <xiaoju@shazhou.work>
2026-04-23 00:49:39 +00:00
xiaoju 96ea4b46ff chore: add prepublish guard against npm publish with workspace:* deps
小橘 <xiaoju@shazhou.work>
2026-04-23 00:47:56 +00:00
xiaoju 57881533a8 docs: fix publish skill — use pnpm publish for workspace:* conversion
小橘 <xiaoju@shazhou.work>
2026-04-23 00:43:51 +00:00
xiaoju a62a993a82 fix(cli): remove duplicate shebang in daemon-bootstrap causing crash on nerve start -d
小橘 <xiaoju@shazhou.work>
2026-04-23 00:43:18 +00:00
xiaoju 3f22eb4664 release: @uncaged/nerve-core@0.1.3, @uncaged/nerve-daemon@0.1.4, @uncaged/nerve-cli@0.1.5
小橘 <xiaoju@shazhou.work>
2026-04-23 00:35:40 +00:00
xiaoju b5913263e4 docs: add publish and setup skills
小橘 <xiaoju@shazhou.work>
2026-04-23 00:31:27 +00:00
xiaomo d3ecd2a492 Merge pull request 'fix: address review issues #46-#49' (#52) from fix/review-issues-46-49 into main 2026-04-23 00:24:19 +00:00
xiaoju 8763440436 fix: address review issues #46-#49
#46 — EPIPE handler: only silence EPIPE, log other child errors
#47 — lastSignalTs: query sense/signal instead of reflex/run_complete
#48 — SenseInfo: deduplicate to @uncaged/nerve-core, add expectTypeOf test
#49 — IPC client: extract sendAndReceive<T> to eliminate duplication

小橘 <xiaoju@shazhou.work>
2026-04-23 00:22:55 +00:00
xiaomo f270804002 Merge pull request 'feat(daemon): CAS blob store — sha256 content-addressable storage (closes #39)' (#51) from feat/blob-store into main 2026-04-23 00:21:46 +00:00
33 changed files with 1006 additions and 202 deletions
+34
View File
@@ -0,0 +1,34 @@
---
description: Ban dynamic import() in production code — use static imports instead
globs: packages/*/src/**/*.ts
alwaysApply: true
---
# No Dynamic Import in Production Code
## Rule
Do NOT use `await import()` or dynamic `import()` expressions in production source code.
Always use static top-level `import` statements.
## Why
- Static imports enable tree-shaking and bundler optimizations
- They make dependencies explicit and discoverable at a glance
- Dynamic imports of Node built-ins or project modules add unnecessary async overhead
## Exceptions (must include a comment explaining why)
1. **`sense-runtime.ts`** — loads user-authored sense modules whose paths are only known at runtime
2. **`workflow-worker.ts`** — loads user-authored workflow modules whose paths are only known at runtime
When suppressing, add a comment directly above:
```ts
// Dynamic import required: user module path resolved at runtime
const mod = await import(senseIndexPath);
```
## Test Files
Test files (`__tests__/**`) are exempt — dynamic import after `vi.mock()` is standard vitest practice.
+80
View File
@@ -0,0 +1,80 @@
# Skill: Publish @uncaged/nerve packages to npm
## When to use
When releasing a new version of any `@uncaged/nerve-*` package to npm.
## Prerequisites
- npm login with an account that has **owner** access to the `@uncaged` org
- All tests pass: `pnpm -r run test`
- Clean working tree (no uncommitted changes)
## Packages
| Package | Path | npm |
|---------|------|-----|
| `@uncaged/nerve-core` | `packages/core` | [link](https://www.npmjs.com/package/@uncaged/nerve-core) |
| `@uncaged/nerve-daemon` | `packages/daemon` | [link](https://www.npmjs.com/package/@uncaged/nerve-daemon) |
| `@uncaged/nerve-cli` | `packages/cli` | [link](https://www.npmjs.com/package/@uncaged/nerve-cli) |
## Dependency order
`core``daemon``cli`
Always publish in this order. If `core` has changes, bump and publish it first, then update dependents.
## Steps
### 1. Ensure clean state
```bash
git checkout main && git pull origin main
pnpm install
pnpm -r run build
pnpm -r run test
```
### 2. Bump versions
Manually update `version` in each changed package's `package.json`.
Follow semver:
- **patch** (0.1.x): bug fixes, refactors
- **minor** (0.x.0): new features, non-breaking API additions
- **major** (x.0.0): breaking changes
If bumping `core`, also update the `@uncaged/nerve-core` dependency version in `daemon` and `cli` package.json. Same for `daemon``cli`.
### 3. Build
```bash
pnpm -r run build
```
### 4. Publish (in order)
```bash
# Only publish packages that have version bumps
# MUST use pnpm publish (not npm) — pnpm converts workspace:* to real versions
cd packages/core && pnpm publish --access public --no-git-checks
cd packages/daemon && pnpm publish --access public --no-git-checks
cd packages/cli && pnpm publish --access public --no-git-checks
```
### 5. Commit & tag
```bash
git add -A
git commit -m "release: @uncaged/nerve-core@X.Y.Z, @uncaged/nerve-daemon@X.Y.Z, @uncaged/nerve-cli@X.Y.Z"
git tag -a vX.Y.Z -m "Release vX.Y.Z"
git push origin main --tags
```
## Pitfalls
- **Don't publish without building first** — `tsup` output in `dist/` is what npm ships
- **Dependency order matters** — if you publish `daemon` before `core`, npm may resolve the old `core` version
- **`--access public`** is required for scoped packages on first publish; safe to always include
- **Check `npm whoami`** to confirm you're logged in as the right account
- **No changeset tool** — this project uses manual version bumps (no changesets/lerna)
+101
View File
@@ -0,0 +1,101 @@
# Skill: Setup nerve from scratch
## When to use
Setting up the nerve project for local development from a fresh clone.
## Prerequisites
- **Node.js** ≥ 18
- **pnpm** ≥ 9 (`npm install -g pnpm`)
- **Git** access to `git.shazhou.work`
## Steps
### 1. Clone
```bash
git clone https://git.shazhou.work/uncaged/nerve.git
cd nerve
```
### 2. Install dependencies
```bash
pnpm install
```
This installs all workspace packages and links internal dependencies (`core``daemon``cli`).
### 3. Build all packages
```bash
pnpm -r run build
```
Build order is handled automatically by pnpm workspace — `core` builds first, then `daemon`, then `cli`.
### 4. Run tests
```bash
pnpm -r run test
```
Or test individual packages:
```bash
pnpm --filter @uncaged/nerve-core test
pnpm --filter @uncaged/nerve-daemon test
pnpm --filter @uncaged/nerve-cli test
```
### 5. Try the CLI
```bash
# Link the CLI globally
cd packages/cli && npm link
# Initialize a workspace
mkdir ~/my-nerve-workspace && cd ~/my-nerve-workspace
nerve init
# Edit senses in nerve.yaml, then:
nerve start # start the daemon
nerve sense list # list registered senses
nerve stop # stop the daemon
```
### 6. Lint & format
```bash
pnpm run check # biome lint check
pnpm run format # biome auto-format
```
## Project structure
```
nerve/
├── packages/
│ ├── core/ # @uncaged/nerve-core — shared types, log store, blob store
│ ├── daemon/ # @uncaged/nerve-daemon — kernel, sense runtime, workflow manager
│ └── cli/ # @uncaged/nerve-cli — CLI commands (init, start, stop, sense, etc.)
├── docs/ # RFCs, conventions, skills
├── pnpm-workspace.yaml
└── biome.json # linter/formatter config
```
## Key conventions
- **Monorepo** with pnpm workspaces
- **ESM only** — all packages output ESM (`"type": "module"`)
- **tsup** for builds, **vitest** for tests, **biome** for lint/format
- **SQLite** (better-sqlite3) for log store and blob store
- See `docs/coding-conventions.md` for code style rules
## Pitfalls
- **Must build before test** — daemon and cli import compiled output from core
- **better-sqlite3** requires native compilation — if `pnpm install` fails, ensure you have build tools (`build-essential` on Linux, Xcode CLI tools on macOS)
- **Node 18+** required — uses native `fetch`, `crypto.randomUUID`, etc.
- **pnpm only** — don't use npm/yarn, workspace links won't resolve correctly
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@uncaged/nerve-cli", "name": "@uncaged/nerve-cli",
"version": "0.1.4", "version": "0.1.8",
"type": "module", "type": "module",
"bin": { "bin": {
"nerve": "dist/cli.js" "nerve": "dist/cli.js"
@@ -14,6 +14,7 @@
"access": "public" "access": "public"
}, },
"scripts": { "scripts": {
"prepublishOnly": "bash ../../scripts/prepublish-check.sh",
"build": "tsup", "build": "tsup",
"test": "vitest run" "test": "vitest run"
}, },
@@ -22,9 +23,9 @@
"citty": "^0.1.6" "citty": "^0.1.6"
}, },
"devDependencies": { "devDependencies": {
"@uncaged/nerve-daemon": "workspace:*",
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@uncaged/nerve-daemon": "workspace:*",
"vitest": "^4.1.5" "vitest": "^4.1.5"
} }
} }
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { daemonCommand } from "../commands/daemon.js";
import { devCommand } from "../commands/dev.js";
import { daemonStartCommand } from "../commands/start.js";
describe("nerve daemon command group", () => {
it("exposes start, stop, status, restart, and logs subcommands", () => {
const subs = daemonCommand.subCommands;
expect(subs).toBeDefined();
if (!subs) {
throw new Error("expected daemonCommand.subCommands");
}
expect(Object.keys(subs).sort()).toEqual(["logs", "restart", "start", "status", "stop"]);
});
it("shares the same start command object as top-level nerve start alias", () => {
const subs = daemonCommand.subCommands;
expect(subs?.start).toBe(daemonStartCommand);
});
});
describe("nerve dev", () => {
it("is a foreground dev command", () => {
expect(devCommand.meta?.name).toBe("dev");
expect(devCommand.meta?.description).toMatch(/foreground/i);
});
});
@@ -3,6 +3,7 @@
* If the daemon package changes its public API, this file will fail to compile. * If the daemon package changes its public API, this file will fail to compile.
*/ */
import type { SenseInfo } from "@uncaged/nerve-core";
import type { import type {
ArchiveLogsDayResult as DaemonArchiveLogsDayResult, ArchiveLogsDayResult as DaemonArchiveLogsDayResult,
ArchiveLogsOptions as DaemonArchiveLogsOptions, ArchiveLogsOptions as DaemonArchiveLogsOptions,
@@ -10,6 +11,7 @@ import type {
LogEntry as DaemonLogEntry, LogEntry as DaemonLogEntry,
LogQuery as DaemonLogQuery, LogQuery as DaemonLogQuery,
LogStore as DaemonLogStore, LogStore as DaemonLogStore,
SenseInfo as DaemonSenseInfo,
WorkflowRun as DaemonWorkflowRun, WorkflowRun as DaemonWorkflowRun,
WorkflowRunStatus as DaemonWorkflowRunStatus, WorkflowRunStatus as DaemonWorkflowRunStatus,
} from "@uncaged/nerve-daemon"; } from "@uncaged/nerve-daemon";
@@ -27,6 +29,11 @@ import type {
} from "../daemon-types.js"; } from "../daemon-types.js";
describe("daemon-types drift guard", () => { describe("daemon-types drift guard", () => {
it("SenseInfo matches daemon package export (list-senses IPC)", () => {
expectTypeOf<SenseInfo>().toMatchTypeOf<DaemonSenseInfo>();
expectTypeOf<DaemonSenseInfo>().toMatchTypeOf<SenseInfo>();
});
it("WorkflowRunStatus is assignable both ways", () => { it("WorkflowRunStatus is assignable both ways", () => {
expectTypeOf<WorkflowRunStatus>().toMatchTypeOf<DaemonWorkflowRunStatus>(); expectTypeOf<WorkflowRunStatus>().toMatchTypeOf<DaemonWorkflowRunStatus>();
expectTypeOf<DaemonWorkflowRunStatus>().toMatchTypeOf<WorkflowRunStatus>(); expectTypeOf<DaemonWorkflowRunStatus>().toMatchTypeOf<WorkflowRunStatus>();
@@ -13,18 +13,24 @@ import { createServer } from "node:net";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import type { SenseInfo } from "@uncaged/nerve-core";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { listSensesViaDaemon } from "../daemon-client.js";
import type { SenseInfo } from "../daemon-client.js";
import { formatDuration, formatSenseList, sensesFromConfig } from "../commands/sense.js"; import { formatDuration, formatSenseList, sensesFromConfig } from "../commands/sense.js";
import { listSensesViaDaemon } from "../daemon-client.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Fixtures // Fixtures
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const SAMPLE_SENSES: SenseInfo[] = [ const SAMPLE_SENSES: SenseInfo[] = [
{ name: "cpu-usage", group: "system", throttle: 5000, timeout: 3000, lastSignalTs: 1_700_000_000_000 }, {
name: "cpu-usage",
group: "system",
throttle: 5000,
timeout: 3000,
lastSignalTs: 1_700_000_000_000,
},
{ name: "disk-usage", group: "system", throttle: 30000, timeout: null, lastSignalTs: null }, { name: "disk-usage", group: "system", throttle: 30000, timeout: null, lastSignalTs: null },
{ name: "active-tasks", group: "tasks", throttle: 10000, timeout: 30000, lastSignalTs: null }, { name: "active-tasks", group: "tasks", throttle: 10000, timeout: 30000, lastSignalTs: null },
]; ];
@@ -0,0 +1,159 @@
/**
* Tests for sense SQLite helpers used by `nerve sense schema` / `nerve sense query`.
*/
import { mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
assertSenseDbExists,
collectColumnKeys,
defaultPreviewSql,
formatRowsAsAlignedTable,
listTableSqlStatements,
parseSenseQueryArgs,
pickDefaultPreviewTable,
senseDbPath,
} from "../sense-sqlite.js";
let tmpDir: string;
beforeEach(() => {
tmpDir = join(
tmpdir(),
`nerve-sense-sqlite-${Date.now()}-${Math.random().toString(16).slice(2)}`,
);
mkdirSync(join(tmpDir, "data", "senses"), { recursive: true });
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
describe("senseDbPath", () => {
it("points at data/senses/<name>.db under the given root", () => {
expect(senseDbPath("/root", "cpu-usage")).toBe(join("/root", "data", "senses", "cpu-usage.db"));
});
});
describe("assertSenseDbExists", () => {
it("throws when the file is missing", () => {
expect(() => assertSenseDbExists(tmpDir, "nope")).toThrow(/No database at/);
});
it("returns the path when the file exists", () => {
const p = join(tmpDir, "data", "senses", "x.db");
new DatabaseSync(p).close();
expect(assertSenseDbExists(tmpDir, "x")).toBe(p);
});
});
describe("listTableSqlStatements", () => {
it("returns CREATE statements ordered by tbl_name", () => {
const p = join(tmpDir, "data", "senses", "t.db");
const db = new DatabaseSync(p);
db.exec("CREATE TABLE zebra (id INTEGER)");
db.exec("CREATE TABLE alpha (id INTEGER)");
const stmts = listTableSqlStatements(db);
db.close();
expect(stmts).toHaveLength(2);
expect(stmts[0]).toMatch(/^CREATE TABLE alpha/i);
expect(stmts[1]).toMatch(/^CREATE TABLE zebra/i);
});
});
describe("pickDefaultPreviewTable", () => {
it("prefers non-_migrations tables when both exist", () => {
const p = join(tmpDir, "data", "senses", "t.db");
const db = new DatabaseSync(p);
db.exec("CREATE TABLE _migrations (name TEXT PRIMARY KEY)");
db.exec("CREATE TABLE readings (id INTEGER)");
expect(pickDefaultPreviewTable(db)).toBe("readings");
db.close();
});
it("uses _migrations when it is the only table", () => {
const p = join(tmpDir, "data", "senses", "t.db");
const db = new DatabaseSync(p);
db.exec("CREATE TABLE _migrations (name TEXT PRIMARY KEY)");
expect(pickDefaultPreviewTable(db)).toBe("_migrations");
db.close();
});
});
describe("defaultPreviewSql", () => {
it("quotes identifiers for SQL safety", () => {
expect(defaultPreviewSql(`weird"name`)).toContain(`weird""name`);
});
});
describe("parseSenseQueryArgs", () => {
it("parses sense name only", () => {
expect(parseSenseQueryArgs(["cpu"])).toEqual({ name: "cpu", sql: undefined });
});
it("strips --json", () => {
expect(parseSenseQueryArgs(["cpu", "--json"])).toEqual({ name: "cpu", sql: undefined });
expect(parseSenseQueryArgs(["--json", "cpu"])).toEqual({ name: "cpu", sql: undefined });
});
it("joins remaining tokens into SQL", () => {
expect(parseSenseQueryArgs(["cpu", "SELECT", "1"])).toEqual({ name: "cpu", sql: "SELECT 1" });
});
it("throws when name is missing", () => {
expect(() => parseSenseQueryArgs(["--json"])).toThrow(/Missing sense name/);
});
});
describe("formatRowsAsAlignedTable", () => {
it("shows empty marker for no rows", () => {
expect(formatRowsAsAlignedTable([])).toContain("(0 rows)");
});
it("aligns columns from row data", () => {
const out = formatRowsAsAlignedTable([
{ a: 1, b: "x" },
{ a: 22, b: "yy" },
]);
expect(out).toContain("a");
expect(out).toContain("b");
expect(out).toContain("22");
});
});
describe("collectColumnKeys", () => {
it("preserves key order from first row then appends new keys", () => {
expect(
collectColumnKeys([
{ z: 1, a: 2 },
{ a: 3, b: 4 },
]),
).toEqual(["z", "a", "b"]);
});
});
describe("readonly query integration", () => {
it("runs default preview SQL on a real db", () => {
const p = join(tmpDir, "data", "senses", "demo.db");
const rw = new DatabaseSync(p);
rw.exec("CREATE TABLE items (id INTEGER PRIMARY KEY, v TEXT)");
rw.exec("INSERT INTO items (v) VALUES ('a'), ('b')");
rw.close();
const db = new DatabaseSync(p, { readOnly: true });
const table = pickDefaultPreviewTable(db);
expect(table).toBe("items");
if (table === null) {
throw new Error("expected items table");
}
const sql = defaultPreviewSql(table);
const rows = db.prepare(sql).all() as Record<string, unknown>[];
db.close();
expect(rows.length).toBeGreaterThanOrEqual(1);
});
});
+6 -2
View File
@@ -1,9 +1,11 @@
import { defineCommand, runMain } from "citty"; import { defineCommand, runMain } from "citty";
import { daemonCommand } from "./commands/daemon.js";
import { devCommand } from "./commands/dev.js";
import { initCommand } from "./commands/init.js"; import { initCommand } from "./commands/init.js";
import { logsCommand } from "./commands/logs.js"; import { logsCommand } from "./commands/logs.js";
import { senseCommand } from "./commands/sense.js"; import { senseCommand } from "./commands/sense.js";
import { startCommand } from "./commands/start.js"; import { daemonStartCommand } from "./commands/start.js";
import { statusCommand } from "./commands/status.js"; import { statusCommand } from "./commands/status.js";
import { stopCommand } from "./commands/stop.js"; import { stopCommand } from "./commands/stop.js";
import { storeCommand } from "./commands/store.js"; import { storeCommand } from "./commands/store.js";
@@ -17,7 +19,9 @@ const main = defineCommand({
}, },
subCommands: { subCommands: {
init: initCommand, init: initCommand,
start: startCommand, daemon: daemonCommand,
dev: devCommand,
start: daemonStartCommand,
stop: stopCommand, stop: stopCommand,
status: statusCommand, status: statusCommand,
logs: logsCommand, logs: logsCommand,
+31
View File
@@ -0,0 +1,31 @@
import { defineCommand } from "citty";
import { logsCommand } from "./logs.js";
import { daemonStartCommand, runDaemonStartCommand } from "./start.js";
import { statusCommand } from "./status.js";
import { runStopCommand, stopCommand } from "./stop.js";
const daemonRestartCommand = defineCommand({
meta: {
name: "restart",
description: "Stop then start the nerve daemon",
},
async run() {
await runStopCommand();
await runDaemonStartCommand();
},
});
export const daemonCommand = defineCommand({
meta: {
name: "daemon",
description: "Manage the nerve background daemon",
},
subCommands: {
start: daemonStartCommand,
stop: stopCommand,
status: statusCommand,
restart: daemonRestartCommand,
logs: logsCommand,
},
});
+17
View File
@@ -0,0 +1,17 @@
import { defineCommand } from "citty";
import { runForegroundKernelSession } from "../run-foreground-kernel.js";
import { loadDaemonModule } from "../workspace-daemon.js";
import { getNerveRoot } from "../workspace.js";
export const devCommand = defineCommand({
meta: {
name: "dev",
description: "Run the nerve kernel in the foreground (development mode)",
},
async run() {
const nerveRoot = getNerveRoot();
const { createKernel } = await loadDaemonModule(nerveRoot);
await runForegroundKernelSession(nerveRoot, createKernel);
},
});
+4 -8
View File
@@ -1,5 +1,7 @@
import { spawn, execFile } from "node:child_process";
import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { promisify } from "node:util";
import { defineCommand } from "citty"; import { defineCommand } from "citty";
@@ -42,6 +44,8 @@ const GITIGNORE = `data/
node_modules/ node_modules/
`; `;
const execFileAsync = promisify(execFile);
const CPU_SCHEMA_TS = `import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core"; const CPU_SCHEMA_TS = `import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const cpuUsage = sqliteTable("cpu_usage", { export const cpuUsage = sqliteTable("cpu_usage", {
@@ -90,7 +94,6 @@ function writeFile(filePath: string, content: string): void {
} }
async function runCommand(cmd: string, args: string[], cwd: string): Promise<void> { async function runCommand(cmd: string, args: string[], cwd: string): Promise<void> {
const { spawn } = await import("node:child_process");
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const child = spawn(cmd, args, { cwd, stdio: "inherit" }); const child = spawn(cmd, args, { cwd, stdio: "inherit" });
child.on("close", (code) => { child.on("close", (code) => {
@@ -102,10 +105,6 @@ async function runCommand(cmd: string, args: string[], cwd: string): Promise<voi
} }
async function detectPackageManager(): Promise<{ cmd: string; installArgs: string[] }> { async function detectPackageManager(): Promise<{ cmd: string; installArgs: string[] }> {
const { execFile } = await import("node:child_process");
const { promisify } = await import("node:util");
const execFileAsync = promisify(execFile);
for (const pm of ["pnpm", "yarn", "npm"]) { for (const pm of ["pnpm", "yarn", "npm"]) {
try { try {
await execFileAsync(pm, ["--version"]); await execFileAsync(pm, ["--version"]);
@@ -223,9 +222,6 @@ async function tryRequireSqlite(nerveRoot: string): Promise<boolean> {
try { try {
const modulePath = join(nerveRoot, "node_modules", "better-sqlite3"); const modulePath = join(nerveRoot, "node_modules", "better-sqlite3");
// Use a child process to test if the native module loads // Use a child process to test if the native module loads
const { execFile } = await import("node:child_process");
const { promisify } = await import("node:util");
const execFileAsync = promisify(execFile);
await execFileAsync("node", ["-e", `require(${JSON.stringify(modulePath)})`], { await execFileAsync("node", ["-e", `require(${JSON.stringify(modulePath)})`], {
cwd: nerveRoot, cwd: nerveRoot,
timeout: 10_000, timeout: 10_000,
+123 -5
View File
@@ -1,11 +1,20 @@
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { parseNerveConfig } from "@uncaged/nerve-core"; import { type SenseInfo, parseNerveConfig } from "@uncaged/nerve-core";
import { defineCommand } from "citty"; import { defineCommand } from "citty";
import { listSensesViaDaemon, triggerSenseViaDaemon } from "../daemon-client.js"; import { listSensesViaDaemon, triggerSenseViaDaemon } from "../daemon-client.js";
import type { SenseInfo } from "../daemon-client.js"; import {
assertSenseDbExists,
defaultPreviewSql,
formatRowsAsAlignedTable,
listTableSqlStatements,
openSenseDb,
parseSenseQueryArgs,
pickDefaultPreviewTable,
} from "../sense-sqlite.js";
import { getNerveRoot, getSocketPath, isRunning } from "../workspace.js"; import { getNerveRoot, getSocketPath, isRunning } from "../workspace.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -35,8 +44,7 @@ export function formatSenseList(senses: SenseInfo[]): string {
lines.push(` group: ${s.group}\n`); lines.push(` group: ${s.group}\n`);
lines.push(` throttle: ${formatDuration(s.throttle)}\n`); lines.push(` throttle: ${formatDuration(s.throttle)}\n`);
lines.push(` timeout: ${formatDuration(s.timeout)}\n`); lines.push(` timeout: ${formatDuration(s.timeout)}\n`);
const lastSignal = const lastSignal = s.lastSignalTs !== null ? new Date(s.lastSignalTs).toISOString() : "(never)";
s.lastSignalTs !== null ? new Date(s.lastSignalTs).toISOString() : "(never)";
lines.push(` last signal: ${lastSignal}\n`); lines.push(` last signal: ${lastSignal}\n`);
} }
return lines.join(""); return lines.join("");
@@ -72,7 +80,6 @@ const senseListCommand = defineCommand({
}, },
async run() { async run() {
if (!isRunning()) { if (!isRunning()) {
// Daemon not running — show static info from nerve.yaml
process.stderr.write( process.stderr.write(
"⚠️ Daemon is not running — showing static config only (no last signal time).\n\n", "⚠️ Daemon is not running — showing static config only (no last signal time).\n\n",
); );
@@ -141,6 +148,115 @@ const senseTriggerCommand = defineCommand({
}, },
}); });
// ---------------------------------------------------------------------------
// nerve sense schema <name>
// ---------------------------------------------------------------------------
const senseSchemaCommand = defineCommand({
meta: {
name: "schema",
description: "Print CREATE TABLE statements from a sense SQLite database",
},
args: {
name: {
type: "positional",
description: "Sense name (data/senses/<name>.db under the nerve workspace)",
},
json: {
type: "boolean",
description: "Print JSON array of CREATE TABLE SQL strings",
default: false,
},
},
async run({ args }) {
const nerveRoot = getNerveRoot();
let db: DatabaseSync | undefined;
try {
db = openSenseDb(nerveRoot, args.name);
const statements = listTableSqlStatements(db);
if (args.json) {
process.stdout.write(`${JSON.stringify(statements, null, 2)}\n`);
} else if (statements.length === 0) {
process.stdout.write("(no tables)\n");
} else {
for (const sql of statements) {
process.stdout.write(`${sql};\n\n`);
}
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`${msg}\n`);
process.exit(1);
} finally {
db?.close();
}
},
});
// ---------------------------------------------------------------------------
// nerve sense query <name> [sql...]
// ---------------------------------------------------------------------------
const senseQueryCommand = defineCommand({
meta: {
name: "query",
description:
"Run a read-only SQL query against a sense database (default: last 10 rows of the first data table). Pass optional SQL after the sense name; multiple words are joined.",
},
args: {
name: {
type: "positional",
description: "Sense name (data/senses/<name>.db under the nerve workspace)",
},
json: {
type: "boolean",
description: "Print result rows as JSON",
default: false,
},
},
async run({ args, rawArgs }) {
const nerveRoot = getNerveRoot();
let db: DatabaseSync | undefined;
try {
let parsed: { name: string; sql: string | undefined };
try {
parsed = parseSenseQueryArgs(rawArgs);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`${msg}\n`);
process.exit(1);
}
db = openSenseDb(nerveRoot, args.name);
let sql = parsed.sql?.trim();
if (!sql) {
const table = pickDefaultPreviewTable(db);
if (table === null) {
process.stderr.write("❌ No tables found in database.\n");
process.exit(1);
} else {
sql = defaultPreviewSql(table);
}
}
const rows = db.prepare(sql).all() as Record<string, unknown>[];
if (args.json) {
process.stdout.write(`${JSON.stringify(rows, null, 2)}\n`);
} else {
process.stdout.write(formatRowsAsAlignedTable(rows));
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`${msg}\n`);
process.exit(1);
} finally {
db?.close();
}
},
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// nerve sense (parent command) // nerve sense (parent command)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -153,5 +269,7 @@ export const senseCommand = defineCommand({
subCommands: { subCommands: {
list: senseListCommand, list: senseListCommand,
trigger: senseTriggerCommand, trigger: senseTriggerCommand,
schema: senseSchemaCommand,
query: senseQueryCommand,
}, },
}); });
+13 -29
View File
@@ -1,3 +1,4 @@
import { spawn } from "node:child_process";
import { createWriteStream, existsSync } from "node:fs"; import { createWriteStream, existsSync } from "node:fs";
import { mkdir } from "node:fs/promises"; import { mkdir } from "node:fs/promises";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
@@ -5,11 +6,10 @@ import { fileURLToPath } from "node:url";
import { defineCommand } from "citty"; import { defineCommand } from "citty";
import { runForegroundKernelSession } from "../run-foreground-kernel.js";
import { loadDaemonModule } from "../workspace-daemon.js";
import { import {
getLogPath, getLogPath,
getNerveRoot, getNerveRoot,
getSocketPath,
isRunning, isRunning,
readPidFile, readPidFile,
removePidFile, removePidFile,
@@ -52,15 +52,10 @@ function daemonBootstrapScript(): string {
return bootstrapJs; return bootstrapJs;
} }
throw new Error( throw new Error(
`daemon-bootstrap.js not found next to CLI at ${bootstrapJs}. Build the CLI package (e.g. \`pnpm --filter @uncaged/nerve-cli build\`) before using background mode (\`nerve start -d\`).`, `daemon-bootstrap.js not found next to CLI at ${bootstrapJs}. Build the CLI package (e.g. \`pnpm --filter @uncaged/nerve-cli build\`) before using \`nerve daemon start\`.`,
); );
} }
async function runForeground(nerveRoot: string): Promise<void> {
const { createKernel } = await loadDaemonModule(nerveRoot);
await runForegroundKernelSession(nerveRoot, createKernel);
}
async function runDaemon(nerveRoot: string): Promise<void> { async function runDaemon(nerveRoot: string): Promise<void> {
if (isRunning()) { if (isRunning()) {
const pid = readPidFile(); const pid = readPidFile();
@@ -71,7 +66,6 @@ async function runDaemon(nerveRoot: string): Promise<void> {
const logPath = getLogPath(); const logPath = getLogPath();
await mkdir(join(nerveRoot, "logs"), { recursive: true }); await mkdir(join(nerveRoot, "logs"), { recursive: true });
const { spawn } = await import("node:child_process");
const logStream = createWriteStream(logPath, { flags: "a" }); const logStream = createWriteStream(logPath, { flags: "a" });
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
if (logStream.pending) logStream.once("open", () => resolve()); if (logStream.pending) logStream.once("open", () => resolve());
@@ -97,7 +91,6 @@ async function runDaemon(nerveRoot: string): Promise<void> {
writePidFile(pid); writePidFile(pid);
const { getSocketPath } = await import("../workspace.js");
const ready = await waitForSocket(getSocketPath(), 5000); const ready = await waitForSocket(getSocketPath(), 5000);
if (!ready || !isRunning()) { if (!ready || !isRunning()) {
@@ -110,29 +103,20 @@ async function runDaemon(nerveRoot: string): Promise<void> {
process.stdout.write(`✅ Nerve daemon started (pid ${pid}).\n`); process.stdout.write(`✅ Nerve daemon started (pid ${pid}).\n`);
process.stdout.write(` Logs: ${logPath}\n`); process.stdout.write(` Logs: ${logPath}\n`);
process.stdout.write(" Run `nerve stop` to stop.\n"); process.stdout.write(" Run `nerve daemon stop` (or `nerve stop`) to stop.\n");
} }
export const startCommand = defineCommand({ /** Background daemon only — use `nerve dev` for foreground mode. */
export async function runDaemonStartCommand(): Promise<void> {
await runDaemon(getNerveRoot());
}
export const daemonStartCommand = defineCommand({
meta: { meta: {
name: "start", name: "start",
description: "Start the nerve daemon", description: "Start the nerve daemon in the background",
}, },
args: { async run() {
daemon: { await runDaemonStartCommand();
type: "boolean",
alias: "d",
description: "Run as background daemon",
default: false,
},
},
async run({ args }) {
const nerveRoot = getNerveRoot();
if (args.daemon) {
await runDaemon(nerveRoot);
} else {
await runForeground(nerveRoot);
}
}, },
}); });
+38 -33
View File
@@ -15,44 +15,49 @@ async function waitForExit(pid: number, timeoutMs: number): Promise<boolean> {
return false; return false;
} }
/** Core stop logic — also used by `nerve daemon restart`. */
export async function runStopCommand(): Promise<void> {
const pid = readPidFile();
if (pid === null) {
process.stdout.write("⚠️ No PID file found — daemon may not be running.\n");
return;
}
if (!isRunning()) {
process.stdout.write("⚠️ Daemon is not running (stale PID file). Cleaning up.\n");
removePidFile();
return;
}
process.stdout.write(`Stopping nerve daemon (pid ${pid})…\n`);
try {
process.kill(pid, "SIGTERM");
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`❌ Failed to send SIGTERM: ${msg}\n`);
process.exit(1);
}
const graceful = await waitForExit(pid, 10_000);
if (!graceful) {
process.stdout.write("⚠️ Daemon did not exit in 10s — sending SIGKILL.\n");
try {
process.kill(pid, "SIGKILL");
} catch {
// already dead
}
}
removePidFile();
process.stdout.write("✅ Daemon stopped.\n");
}
export const stopCommand = defineCommand({ export const stopCommand = defineCommand({
meta: { meta: {
name: "stop", name: "stop",
description: "Stop the nerve daemon", description: "Stop the nerve daemon",
}, },
async run() { async run() {
const pid = readPidFile(); await runStopCommand();
if (pid === null) {
process.stdout.write("⚠️ No PID file found — daemon may not be running.\n");
return;
}
if (!isRunning()) {
process.stdout.write("⚠️ Daemon is not running (stale PID file). Cleaning up.\n");
removePidFile();
return;
}
process.stdout.write(`Stopping nerve daemon (pid ${pid})…\n`);
try {
process.kill(pid, "SIGTERM");
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`❌ Failed to send SIGTERM: ${msg}\n`);
process.exit(1);
}
const graceful = await waitForExit(pid, 10_000);
if (!graceful) {
process.stdout.write("⚠️ Daemon did not exit in 10s — sending SIGKILL.\n");
try {
process.kill(pid, "SIGKILL");
} catch {
// already dead
}
}
removePidFile();
process.stdout.write("✅ Daemon stopped.\n");
}, },
}); });
-2
View File
@@ -1,5 +1,3 @@
#!/usr/bin/env node
import { runForegroundKernelSession } from "./run-foreground-kernel.js"; import { runForegroundKernelSession } from "./run-foreground-kernel.js";
import { loadDaemonModule } from "./workspace-daemon.js"; import { loadDaemonModule } from "./workspace-daemon.js";
+40 -85
View File
@@ -8,18 +8,14 @@
import { connect } from "node:net"; import { connect } from "node:net";
import type { Socket } from "node:net"; import type { Socket } from "node:net";
import type { SenseInfo } from "@uncaged/nerve-core";
const CONNECT_TIMEOUT_MS = 3_000; const CONNECT_TIMEOUT_MS = 3_000;
const RESPONSE_TIMEOUT_MS = 5_000; const RESPONSE_TIMEOUT_MS = 5_000;
type TriggerResponse = { ok: true } | { ok: false; error: string }; export type { SenseInfo };
export type SenseInfo = { type TriggerResponse = { ok: true } | { ok: false; error: string };
name: string;
group: string;
throttle: number | null;
timeout: number | null;
lastSignalTs: number | null;
};
type ListSensesResponse = { ok: true; senses: SenseInfo[] } | { ok: false; error: string }; type ListSensesResponse = { ok: true; senses: SenseInfo[] } | { ok: false; error: string };
@@ -37,12 +33,36 @@ function parseDaemonResponse(line: string): TriggerResponse {
return { ok: false, error: `Unexpected daemon response: ${line}` }; return { ok: false, error: `Unexpected daemon response: ${line}` };
} }
function sendAndReceive(socketPath: string, message: object): Promise<TriggerResponse> { function parseListSensesResponse(line: string): ListSensesResponse {
try {
const obj = JSON.parse(line) as unknown;
if (obj !== null && typeof obj === "object") {
const r = obj as Record<string, unknown>;
if (r.ok === false && typeof r.error === "string") return { ok: false, error: r.error };
if (r.ok === true && Array.isArray(r.senses))
return { ok: true, senses: r.senses as SenseInfo[] };
}
} catch {
// fall through
}
return { ok: false, error: `Unexpected daemon response: ${line}` };
}
/**
* Connect to the daemon socket, send one JSON request (newline-terminated),
* and resolve with the first non-empty line parsed by `parseFirstLine`.
*/
function sendAndReceive<T>(
socketPath: string,
message: object,
parseFirstLine: (trimmed: string) => T,
responseTimeoutMs: number = RESPONSE_TIMEOUT_MS,
): Promise<T> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let socket: Socket | null = null; let socket: Socket | null = null;
let settled = false; let settled = false;
function settle(result: TriggerResponse | Error): void { function settle(result: T | Error): void {
if (settled) return; if (settled) return;
settled = true; settled = true;
if (socket !== null) { if (socket !== null) {
@@ -65,7 +85,7 @@ function sendAndReceive(socketPath: string, message: object): Promise<TriggerRes
const responseTimer = setTimeout(() => { const responseTimer = setTimeout(() => {
settle(new Error("Timed out waiting for daemon response")); settle(new Error("Timed out waiting for daemon response"));
}, RESPONSE_TIMEOUT_MS); }, responseTimeoutMs);
let buf = ""; let buf = "";
socket?.on("data", (chunk: Buffer) => { socket?.on("data", (chunk: Buffer) => {
@@ -76,7 +96,7 @@ function sendAndReceive(socketPath: string, message: object): Promise<TriggerRes
const trimmed = line.trim(); const trimmed = line.trim();
if (trimmed.length === 0) continue; if (trimmed.length === 0) continue;
clearTimeout(responseTimer); clearTimeout(responseTimer);
settle(parseDaemonResponse(trimmed)); settle(parseFirstLine(trimmed));
return; return;
} }
}); });
@@ -101,18 +121,19 @@ export function triggerWorkflowViaDaemon(
workflow: string, workflow: string,
payload: unknown, payload: unknown,
): Promise<TriggerResponse> { ): Promise<TriggerResponse> {
return sendAndReceive(socketPath, { type: "trigger-workflow", workflow, payload }); return sendAndReceive(
socketPath,
{ type: "trigger-workflow", workflow, payload },
parseDaemonResponse,
);
} }
/** /**
* Send a trigger-sense message to the running daemon via its Unix socket. * Send a trigger-sense message to the running daemon via its Unix socket.
* Resolves with the daemon's response or rejects on connection/timeout errors. * Resolves with the daemon's response or rejects on connection/timeout errors.
*/ */
export function triggerSenseViaDaemon( export function triggerSenseViaDaemon(socketPath: string, sense: string): Promise<TriggerResponse> {
socketPath: string, return sendAndReceive(socketPath, { type: "trigger-sense", sense }, parseDaemonResponse);
sense: string,
): Promise<TriggerResponse> {
return sendAndReceive(socketPath, { type: "trigger-sense", sense });
} }
/** /**
@@ -120,71 +141,5 @@ export function triggerSenseViaDaemon(
* Resolves with the list of registered senses or rejects on connection/timeout errors. * Resolves with the list of registered senses or rejects on connection/timeout errors.
*/ */
export function listSensesViaDaemon(socketPath: string): Promise<ListSensesResponse> { export function listSensesViaDaemon(socketPath: string): Promise<ListSensesResponse> {
return new Promise((resolve, reject) => { return sendAndReceive(socketPath, { type: "list-senses" }, parseListSensesResponse);
let socket: Socket | null = null;
let settled = false;
function settle(result: ListSensesResponse | Error): void {
if (settled) return;
settled = true;
if (socket !== null) {
socket.destroy();
socket = null;
}
if (result instanceof Error) {
reject(result);
} else {
resolve(result);
}
}
const connectTimer = setTimeout(() => {
settle(new Error(`Timed out connecting to daemon socket: ${socketPath}`));
}, CONNECT_TIMEOUT_MS);
socket = connect(socketPath, () => {
clearTimeout(connectTimer);
const responseTimer = setTimeout(() => {
settle(new Error("Timed out waiting for daemon response"));
}, RESPONSE_TIMEOUT_MS);
let buf = "";
socket?.on("data", (chunk: Buffer) => {
buf += chunk.toString("utf8");
const lines = buf.split("\n");
buf = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.length === 0) continue;
clearTimeout(responseTimer);
try {
const obj = JSON.parse(trimmed) as unknown;
if (obj !== null && typeof obj === "object") {
const r = obj as Record<string, unknown>;
if (r.ok === false && typeof r.error === "string") {
settle({ ok: false, error: r.error });
return;
}
if (r.ok === true && Array.isArray(r.senses)) {
settle({ ok: true, senses: r.senses as SenseInfo[] });
return;
}
}
} catch {
// fall through
}
settle({ ok: false, error: `Unexpected daemon response: ${trimmed}` });
return;
}
});
socket?.write(`${JSON.stringify({ type: "list-senses" })}\n`);
});
socket.on("error", (err) => {
clearTimeout(connectTimer);
settle(new Error(`Cannot connect to daemon: ${err.message}`));
});
});
} }
+127
View File
@@ -0,0 +1,127 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
/** SQLite path for a sense under the nerve workspace root. */
export function senseDbPath(nerveRoot: string, senseName: string): string {
return join(nerveRoot, "data", "senses", `${senseName}.db`);
}
export function assertSenseDbExists(nerveRoot: string, senseName: string): string {
const path = senseDbPath(nerveRoot, senseName);
if (!existsSync(path)) {
throw new Error(`No database at ${path}`);
}
return path;
}
/** Open a sense SQLite database in readonly mode using node:sqlite. */
export function openSenseDb(nerveRoot: string, senseName: string): DatabaseSync {
const path = assertSenseDbExists(nerveRoot, senseName);
return new DatabaseSync(path, { readOnly: true });
}
/** `SELECT sql FROM sqlite_master WHERE type='table'` (non-null sql only). */
export function listTableSqlStatements(db: DatabaseSync): string[] {
const rows = db
.prepare(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND sql IS NOT NULL ORDER BY tbl_name`,
)
.all() as { sql: string }[];
return rows.map((r) => r.sql);
}
/**
* Table used for `nerve sense query <name>` with no SQL.
* Prefers real data tables over `_migrations`, then lexicographic by name.
*/
export function pickDefaultPreviewTable(db: DatabaseSync): string | null {
const row = db
.prepare(
`SELECT name FROM sqlite_master
WHERE type = 'table' AND sql IS NOT NULL
AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\'
ORDER BY
CASE WHEN name = '_migrations' THEN 1 ELSE 0 END,
name
LIMIT 1`,
)
.get() as { name: string } | undefined;
return row?.name ?? null;
}
export function defaultPreviewSql(table: string): string {
return `SELECT * FROM "${table.replace(/"/g, '""')}" ORDER BY rowid DESC LIMIT 10`;
}
/** Parse sense name and optional SQL from subcommand raw argv (flags stripped). */
export function parseSenseQueryArgs(rawArgs: string[]): { name: string; sql: string | undefined } {
const pos: string[] = [];
for (let i = 0; i < rawArgs.length; i++) {
const a = rawArgs[i];
if (a === "--json" || a === "--no-json") continue;
if (a.startsWith("-")) {
const eq = a.indexOf("=");
if (eq === -1 && i + 1 < rawArgs.length && !rawArgs[i + 1].startsWith("-")) {
i += 1;
}
continue;
}
pos.push(a);
}
if (pos.length < 1) {
throw new Error("Missing sense name");
}
const name = pos[0];
const sql = pos.length > 1 ? pos.slice(1).join(" ") : undefined;
return { name, sql };
}
function stringifyCell(value: unknown): string {
if (value === null || value === undefined) return "";
if (typeof value === "bigint") return value.toString();
if (typeof value === "number" || typeof value === "boolean") return String(value);
if (typeof value === "string") return value;
if (Buffer.isBuffer(value)) return value.toString("hex");
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
/** Collect column keys in stable order (first row keys, then any extras). */
export function collectColumnKeys(rows: Record<string, unknown>[]): string[] {
const keys: string[] = [];
const seen = new Set<string>();
for (const row of rows) {
for (const k of Object.keys(row)) {
if (!seen.has(k)) {
seen.add(k);
keys.push(k);
}
}
}
return keys;
}
const MAX_CELL = 64;
function truncate(s: string): string {
if (s.length <= MAX_CELL) return s;
return `${s.slice(0, MAX_CELL - 1)}`;
}
/** Plain aligned table for terminal output. */
export function formatRowsAsAlignedTable(rows: Record<string, unknown>[]): string {
if (rows.length === 0) {
return "(0 rows)\n";
}
const cols = collectColumnKeys(rows);
const cells = rows.map((row) => cols.map((c) => truncate(stringifyCell(row[c]))));
const widths = cols.map((c, j) => Math.max(c.length, ...cells.map((r) => r[j].length)));
const sep = widths.map((w) => "-".repeat(w)).join("-+-");
const header = cols.map((c, j) => c.padEnd(widths[j])).join(" | ");
const body = cells.map((r) => r.map((cell, j) => cell.padEnd(widths[j])).join(" | ")).join("\n");
return `${header}\n${sep}\n${body}\n`;
}
+2 -1
View File
@@ -1,10 +1,11 @@
{ {
"name": "@uncaged/nerve-core", "name": "@uncaged/nerve-core",
"version": "0.1.2", "version": "0.1.4",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"scripts": { "scripts": {
"prepublishOnly": "bash ../../scripts/prepublish-check.sh",
"build": "tsup", "build": "tsup",
"test": "vitest run" "test": "vitest run"
}, },
+1
View File
@@ -1,6 +1,7 @@
export type { export type {
Signal, Signal,
SenseConfig, SenseConfig,
SenseInfo,
SenseReflexConfig, SenseReflexConfig,
WorkflowReflexConfig, WorkflowReflexConfig,
ReflexConfig, ReflexConfig,
+9
View File
@@ -12,6 +12,15 @@ export type SenseConfig = {
gracePeriod: number | null; gracePeriod: number | null;
}; };
/** Runtime metadata for a sense (e.g. daemon list-senses IPC). */
export type SenseInfo = {
name: string;
group: string;
throttle: number | null;
timeout: number | null;
lastSignalTs: number | null;
};
export type SenseReflexConfig = { export type SenseReflexConfig = {
kind: "sense"; kind: "sense";
sense: string; sense: string;
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@uncaged/nerve-daemon", "name": "@uncaged/nerve-daemon",
"version": "0.1.3", "version": "0.1.5",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
@@ -11,6 +11,7 @@
"access": "public" "access": "public"
}, },
"scripts": { "scripts": {
"prepublishOnly": "bash ../../scripts/prepublish-check.sh",
"build": "tsup", "build": "tsup",
"test": "vitest run" "test": "vitest run"
}, },
@@ -1,4 +1,7 @@
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { NerveConfig } from "@uncaged/nerve-core"; import type { NerveConfig } from "@uncaged/nerve-core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -44,6 +47,7 @@ vi.mock("node:child_process", () => ({
// Import after mock is set up // Import after mock is set up
const { createKernel } = await import("../kernel.js"); const { createKernel } = await import("../kernel.js");
const { createLogStore } = await import("../log-store.js");
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
@@ -93,6 +97,29 @@ describe("kernel — message routing", () => {
await kernel.stop(); await kernel.stop();
}); });
it("persists emitted signals as sense/signal log entries", async () => {
const tmpDir = mkdtempSync(join(tmpdir(), "nerve-kernel-sig-"));
const logStore = createLogStore(join(tmpDir, "logs.db"));
try {
const config = makeConfig({
senses: {
"cpu-usage": { group: "system", throttle: null, timeout: null, gracePeriod: null },
},
reflexes: [],
});
const kernel = createKernel(config, tmpDir, { logStore });
const child = mockChildren[0];
child.emit("message", { type: "ready" });
child.emit("message", { type: "signal", sense: "cpu-usage", payload: 123 });
const rows = logStore.query({ source: "sense", type: "signal", refId: "cpu-usage" });
expect(rows).toHaveLength(1);
expect(rows[0].payload).toBe(JSON.stringify(123));
await kernel.stop();
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
});
it("routes error message to stderr", async () => { it("routes error message to stderr", async () => {
const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
const config = makeConfig({ const config = makeConfig({
+4 -9
View File
@@ -13,8 +13,12 @@
import { rmSync } from "node:fs"; import { rmSync } from "node:fs";
import { type Server, type Socket, createServer } from "node:net"; import { type Server, type Socket, createServer } from "node:net";
import type { SenseInfo } from "@uncaged/nerve-core";
import type { WorkflowManager } from "./workflow-manager.js"; import type { WorkflowManager } from "./workflow-manager.js";
export type { SenseInfo };
/** JSON message sent by the CLI to trigger a workflow. */ /** JSON message sent by the CLI to trigger a workflow. */
export type TriggerWorkflowRequest = { export type TriggerWorkflowRequest = {
type: "trigger-workflow"; type: "trigger-workflow";
@@ -33,15 +37,6 @@ export type ListSensesRequest = {
type: "list-senses"; type: "list-senses";
}; };
/** Runtime info about a single sense returned by list-senses. */
export type SenseInfo = {
name: string;
group: string;
throttle: number | null;
timeout: number | null;
lastSignalTs: number | null;
};
type DaemonRequest = TriggerWorkflowRequest | TriggerSenseRequest | ListSensesRequest; type DaemonRequest = TriggerWorkflowRequest | TriggerSenseRequest | ListSensesRequest;
type DaemonResponse = type DaemonResponse =
+2
View File
@@ -29,6 +29,8 @@ export {
export { createKernel } from "./kernel.js"; export { createKernel } from "./kernel.js";
export type { Kernel, KernelOptions, KernelHealth } from "./kernel.js"; export type { Kernel, KernelOptions, KernelHealth } from "./kernel.js";
export type { SenseInfo } from "./daemon-ipc.js";
export { createFileWatcher } from "./file-watcher.js"; export { createFileWatcher } from "./file-watcher.js";
export type { FileWatcher, FileChange, FileChangeHandler } from "./file-watcher.js"; export type { FileWatcher, FileChange, FileChangeHandler } from "./file-watcher.js";
+29 -12
View File
@@ -18,11 +18,11 @@ import { readFileSync } from "node:fs";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import type { NerveConfig, Signal } from "@uncaged/nerve-core"; import type { NerveConfig, SenseInfo, Signal } from "@uncaged/nerve-core";
import { parseNerveConfig } from "@uncaged/nerve-core"; import { parseNerveConfig } from "@uncaged/nerve-core";
import { createDaemonIpcServer } from "./daemon-ipc.js"; import { createDaemonIpcServer } from "./daemon-ipc.js";
import type { DaemonIpcServer, SenseInfo } from "./daemon-ipc.js"; import type { DaemonIpcServer } from "./daemon-ipc.js";
import { createFileWatcher } from "./file-watcher.js"; import { createFileWatcher } from "./file-watcher.js";
import type { FileWatcher } from "./file-watcher.js"; import type { FileWatcher } from "./file-watcher.js";
import type { ComputeMessage, ShutdownMessage } from "./ipc.js"; import type { ComputeMessage, ShutdownMessage } from "./ipc.js";
@@ -33,6 +33,11 @@ import { createReflexScheduler } from "./reflex-scheduler.js";
import type { ReflexScheduler } from "./reflex-scheduler.js"; import type { ReflexScheduler } from "./reflex-scheduler.js";
import { createSignalBus } from "./signal-bus.js"; import { createSignalBus } from "./signal-bus.js";
import type { SignalBus } from "./signal-bus.js"; import type { SignalBus } from "./signal-bus.js";
import {
formatCapturedStderrTail,
formatChildExitSummary,
teeCapturedStderr,
} from "./worker-fork-support.js";
import { createWorkflowManager } from "./workflow-manager.js"; import { createWorkflowManager } from "./workflow-manager.js";
import type { WorkflowManager } from "./workflow-manager.js"; import type { WorkflowManager } from "./workflow-manager.js";
@@ -84,12 +89,22 @@ function resolveWorkerScript(): string {
return join(__dir, "sense-worker.js"); return join(__dir, "sense-worker.js");
} }
function spawnWorker(nerveRoot: string, group: string, workerScript: string): ChildProcess { function spawnWorker(
nerveRoot: string,
group: string,
workerScript: string,
stderrTail: { value: string },
): ChildProcess {
const child = fork(workerScript, ["--group", group, "--root", nerveRoot], { const child = fork(workerScript, ["--group", group, "--root", nerveRoot], {
stdio: ["ignore", "inherit", "inherit", "ipc"], stdio: ["ignore", "inherit", "pipe", "ipc"],
}); });
teeCapturedStderr(child, stderrTail);
// Prevent unhandled EPIPE when writing to a child whose IPC channel closed // Prevent unhandled EPIPE when writing to a child whose IPC channel closed
child.on("error", () => {}); child.on("error", (err) => {
if ((err as NodeJS.ErrnoException).code !== "EPIPE") {
console.error("[worker] error:", err.message);
}
});
return child; return child;
} }
@@ -222,8 +237,8 @@ export function createKernel(
ts: Date.now(), ts: Date.now(),
}; };
logStore.append({ logStore.append({
source: "reflex", source: "sense",
type: "run_complete", type: "signal",
refId: msg.sense, refId: msg.sense,
payload: JSON.stringify(msg.payload), payload: JSON.stringify(msg.payload),
ts: signal.ts, ts: signal.ts,
@@ -236,7 +251,8 @@ export function createKernel(
} }
function startWorker(group: string): Promise<void> { function startWorker(group: string): Promise<void> {
const child = spawnWorker(nerveRoot, group, workerScript); const stderrTail = { value: "" };
const child = spawnWorker(nerveRoot, group, workerScript, stderrTail);
let workerReadyResolve: (() => void) | undefined; let workerReadyResolve: (() => void) | undefined;
const workerReady = new Promise<void>((resolve) => { const workerReady = new Promise<void>((resolve) => {
@@ -251,9 +267,10 @@ export function createKernel(
handleWorkerMessage(raw); handleWorkerMessage(raw);
}); });
child.on("exit", (code) => { child.on("exit", (code, signal) => {
const summary = formatChildExitSummary(code, signal ?? null);
process.stderr.write( process.stderr.write(
`[kernel] worker for group "${group}" exited with code ${code ?? "null"}\n`, `[kernel] worker for group "${group}" exited (${summary})${formatCapturedStderrTail(stderrTail.value)}\n`,
); );
// Resolve ready in case the worker exits before sending ready (prevents hangs) // Resolve ready in case the worker exits before sending ready (prevents hangs)
workerReadyResolve?.(); workerReadyResolve?.();
@@ -524,8 +541,8 @@ export function createKernel(
listSenses(): SenseInfo[] { listSenses(): SenseInfo[] {
return Object.entries(config.senses).map(([name, senseConfig]) => { return Object.entries(config.senses).map(([name, senseConfig]) => {
const entries = logStore.query({ const entries = logStore.query({
source: "reflex", source: "sense",
type: "run_complete", type: "signal",
refId: name, refId: name,
}); });
const lastEntry = entries.length > 0 ? entries[entries.length - 1] : null; const lastEntry = entries.length > 0 ? entries[entries.length - 1] : null;
+1
View File
@@ -173,6 +173,7 @@ export async function loadComputeFn(senseIndexPath: string): Promise<Result<Comp
let mod: unknown; let mod: unknown;
try { try {
// Dynamic import required: user-authored sense module, path resolved at runtime
mod = await import(senseIndexPath); mod = await import(senseIndexPath);
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e); const msg = e instanceof Error ? e.message : String(e);
+5
View File
@@ -25,6 +25,7 @@ import type { WorkerToParentMessage } from "./ipc.js";
import { parseParentMessage } from "./ipc.js"; import { parseParentMessage } from "./ipc.js";
import { executeCompute, loadComputeFn, openPeerDb, openSenseDb } from "./sense-runtime.js"; import { executeCompute, loadComputeFn, openPeerDb, openSenseDb } from "./sense-runtime.js";
import type { DrizzleDB, PeerMap, SenseRuntime } from "./sense-runtime.js"; import type { DrizzleDB, PeerMap, SenseRuntime } from "./sense-runtime.js";
import { ignoreSessionBroadcastSignals } from "./worker-fork-support.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// IPC helpers // IPC helpers
@@ -336,6 +337,10 @@ if (!parsed) {
process.exit(1); process.exit(1);
} }
if (typeof process.send === "function") {
ignoreSessionBroadcastSignals();
}
bootstrap(parsed.nerveRoot, parsed.group).catch((e) => { bootstrap(parsed.nerveRoot, parsed.group).catch((e) => {
const msg = e instanceof Error ? e.message : String(e); const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`[sense-worker] Unhandled bootstrap error: ${msg}\n`); process.stderr.write(`[sense-worker] Unhandled bootstrap error: ${msg}\n`);
@@ -0,0 +1,48 @@
import type { ChildProcess } from "node:child_process";
const STDERR_TAIL_MAX_CHARS = 16_384;
/**
* Forked workers inherit the parent's process group. In foreground `nerve dev`,
* terminal-driven SIGINT/SIGTERM is delivered to the whole group, so workers can exit
* on the default handler before the kernel sends `{ type: "shutdown" }` over IPC.
* Swallow these in worker processes so the parent coordinates shutdown (issue #55).
* Only call when `process.send` is defined (fork IPC); standalone `node …-worker.js` keeps default Ctrl+C behaviour.
*/
export function ignoreSessionBroadcastSignals(): void {
const swallow = (): void => {};
process.on("SIGINT", swallow);
process.on("SIGTERM", swallow);
}
export function teeCapturedStderr(child: ChildProcess, tail: { value: string }): void {
const stream = child.stderr;
if (stream === null || stream === undefined) return;
stream.setEncoding("utf8");
stream.on("data", (chunk: string | Buffer) => {
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
process.stderr.write(text);
tail.value = (tail.value + text).slice(-STDERR_TAIL_MAX_CHARS);
});
}
export function formatChildExitSummary(
code: number | null,
signal: NodeJS.Signals | null,
): string {
const codeStr = code === null || code === undefined ? "null" : String(code);
if (signal) {
return `code=${codeStr} signal=${signal}`;
}
return `code=${codeStr}`;
}
export function formatCapturedStderrTail(tail: string, maxChars = 800): string {
const trimmed = tail.trim();
if (trimmed.length === 0) return "";
const normalized = trimmed.replace(/\r?\n/g, "\\n");
if (normalized.length <= maxChars) {
return ` worker_stderr=${normalized}`;
}
return ` worker_stderr=…${normalized.slice(-maxChars)}`;
}
+33 -8
View File
@@ -22,6 +22,11 @@ import type {
import { parseWorkerMessage } from "./ipc.js"; import { parseWorkerMessage } from "./ipc.js";
import type { LogStore } from "./log-store.js"; import type { LogStore } from "./log-store.js";
import type { WorkflowRunStatus } from "./log-store.js"; import type { WorkflowRunStatus } from "./log-store.js";
import {
formatCapturedStderrTail,
formatChildExitSummary,
teeCapturedStderr,
} from "./worker-fork-support.js";
export type WorkflowManager = { export type WorkflowManager = {
/** Trigger a new workflow thread (called by Reflex scheduler). */ /** Trigger a new workflow thread (called by Reflex scheduler). */
@@ -60,6 +65,7 @@ type WorkerEntry = {
stopping: boolean; stopping: boolean;
/** When set, the worker is draining before a hot-reload respawn. */ /** When set, the worker is draining before a hot-reload respawn. */
draining: boolean; draining: boolean;
stderrTail: { value: string };
}; };
// Crash respawn backoff: track crash timestamps per workflow. // Crash respawn backoff: track crash timestamps per workflow.
@@ -85,12 +91,18 @@ function spawnWorkflowWorker(
nerveRoot: string, nerveRoot: string,
workflowName: string, workflowName: string,
workerScript: string, workerScript: string,
stderrTail: { value: string },
): ChildProcess { ): ChildProcess {
const child = fork(workerScript, ["--workflow", workflowName, "--root", nerveRoot], { const child = fork(workerScript, ["--workflow", workflowName, "--root", nerveRoot], {
stdio: ["ignore", "inherit", "inherit", "ipc"], stdio: ["ignore", "inherit", "pipe", "ipc"],
}); });
teeCapturedStderr(child, stderrTail);
// Prevent unhandled EPIPE when writing to a child whose IPC channel closed // Prevent unhandled EPIPE when writing to a child whose IPC channel closed
child.on("error", () => {}); child.on("error", (err) => {
if ((err as NodeJS.ErrnoException).code !== "EPIPE") {
console.error("[worker] error:", err.message);
}
});
return child; return child;
} }
@@ -391,7 +403,11 @@ export function createWorkflowManager(
state.active.clear(); state.active.clear();
} }
function handleWorkerExit(workflowName: string, code: number | null): void { function handleWorkerExit(
workflowName: string,
code: number | null,
signal: NodeJS.Signals | null,
): void {
const entry = workers.get(workflowName); const entry = workers.get(workflowName);
if (entry?.draining) { if (entry?.draining) {
workers.delete(workflowName); workers.delete(workflowName);
@@ -412,8 +428,10 @@ export function createWorkflowManager(
} }
return; return;
} }
const summary = formatChildExitSummary(code, signal);
const stderrExtra = entry !== undefined ? formatCapturedStderrTail(entry.stderrTail.value) : "";
process.stderr.write( process.stderr.write(
`[workflow-manager] worker for "${workflowName}" exited with code ${code ?? "null"}\n`, `[workflow-manager] worker for "${workflowName}" exited (${summary})${stderrExtra}\n`,
); );
handleWorkerCrash(workflowName); handleWorkerCrash(workflowName);
} }
@@ -424,17 +442,24 @@ export function createWorkflowManager(
return existing; return existing;
} }
const child = spawnWorkflowWorker(nerveRoot, workflowName, workerScript); const stderrTail = { value: "" };
const child = spawnWorkflowWorker(nerveRoot, workflowName, workerScript, stderrTail);
child.on("message", (raw: unknown) => { child.on("message", (raw: unknown) => {
handleWorkerMessage(workflowName, raw); handleWorkerMessage(workflowName, raw);
}); });
child.on("exit", (code) => { child.on("exit", (code, signal) => {
handleWorkerExit(workflowName, code); handleWorkerExit(workflowName, code, signal ?? null);
}); });
const entry: WorkerEntry = { workflowName, process: child, stopping: false, draining: false }; const entry: WorkerEntry = {
workflowName,
process: child,
stopping: false,
draining: false,
stderrTail,
};
workers.set(workflowName, entry); workers.set(workflowName, entry);
return entry; return entry;
} }
+6
View File
@@ -21,6 +21,7 @@ import type {
import type { ThreadCommandEventMessage, ThreadEventType, WorkerToParentMessage } from "./ipc.js"; import type { ThreadCommandEventMessage, ThreadEventType, WorkerToParentMessage } from "./ipc.js";
import { parseParentMessage } from "./ipc.js"; import { parseParentMessage } from "./ipc.js";
import { ignoreSessionBroadcastSignals } from "./worker-fork-support.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// IPC helpers // IPC helpers
@@ -197,6 +198,7 @@ async function loadWorkflowDefinition(
); );
} }
// Dynamic import required: user-authored workflow module, path resolved at runtime
const mod = await import(indexPath); const mod = await import(indexPath);
const def: unknown = mod.default ?? mod; const def: unknown = mod.default ?? mod;
@@ -334,6 +336,10 @@ if (!parsed) {
process.exit(1); process.exit(1);
} }
if (typeof process.send === "function") {
ignoreSessionBroadcastSignals();
}
bootstrap(parsed.nerveRoot, parsed.workflow).catch((e) => { bootstrap(parsed.nerveRoot, parsed.workflow).catch((e) => {
const msg = e instanceof Error ? e.message : String(e); const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`[workflow-worker] Unhandled bootstrap error: ${msg}\n`); process.stderr.write(`[workflow-worker] Unhandled bootstrap error: ${msg}\n`);
+9 -2
View File
@@ -60,7 +60,7 @@ importers:
version: 11.10.0 version: 11.10.0
drizzle-orm: drizzle-orm:
specifier: ^0.43.1 specifier: ^0.43.1
version: 0.43.1(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0) version: 0.43.1(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0)(sql.js@1.14.1)
yaml: yaml:
specifier: ^2.8.3 specifier: ^2.8.3
version: 2.8.3 version: 2.8.3
@@ -1071,6 +1071,9 @@ packages:
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
engines: {node: '>= 12'} engines: {node: '>= 12'}
sql.js@1.14.1:
resolution: {integrity: sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==}
stackback@0.0.2: stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
@@ -1692,10 +1695,11 @@ snapshots:
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
drizzle-orm@0.43.1(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0): drizzle-orm@0.43.1(@types/better-sqlite3@7.6.13)(better-sqlite3@11.10.0)(sql.js@1.14.1):
optionalDependencies: optionalDependencies:
'@types/better-sqlite3': 7.6.13 '@types/better-sqlite3': 7.6.13
better-sqlite3: 11.10.0 better-sqlite3: 11.10.0
sql.js: 1.14.1
end-of-stream@1.4.5: end-of-stream@1.4.5:
dependencies: dependencies:
@@ -1997,6 +2001,9 @@ snapshots:
source-map@0.7.6: {} source-map@0.7.6: {}
sql.js@1.14.1:
optional: true
stackback@0.0.2: {} stackback@0.0.2: {}
std-env@4.1.0: {} std-env@4.1.0: {}
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
# All packages must use pnpm publish. Block npm publish unconditionally.
if [ -z "$npm_execpath" ] || [[ "$npm_execpath" != *pnpm* ]]; then
echo "❌ Use 'pnpm publish' instead of 'npm publish'."
echo " pnpm auto-converts workspace:* dependencies to real versions."
exit 1
fi