@twinsearth/fang-dsh-plugin 0.0.0-stage → 3.3.4
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/CLA.md +79 -0
- package/LICENSE-COMMERCIAL +78 -0
- package/LICENSE-CORE +661 -0
- package/LICENSE-SDK +21 -0
- package/LICENSES/cordis-MIT.txt +39 -0
- package/NOTICE +95 -0
- package/README.md +158 -2
- package/client.js +7287 -0
- package/cordis.patch.yml +163 -0
- package/defaults/umbrella-rules.yaml +113 -0
- package/docs/CONTRACT-INTEROP.md +77 -0
- package/docs/CRASH-SAFETY.md +69 -0
- package/docs/DECISION-RELAY.md +46 -0
- package/docs/DSH-ARCHITECTURE-MAP.md +73 -0
- package/docs/FORK-SEED.md +64 -0
- package/docs/MAIN-CITY-GATE.md +58 -0
- package/docs/OVERLAY-MASTER-SWITCH.md +35 -0
- package/docs/PACKAGE-RENAME-v3.3.3.md +40 -0
- package/docs/PM-DECISION-POLICY.md +61 -0
- package/docs/WORKFLOW-UI.md +86 -0
- package/icon.svg +8 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +71 -4
- package/sdk/README.md +65 -0
- package/sdk/examples/status-badge/README.md +16 -0
- package/sdk/examples/status-badge/client.js +60 -0
- package/sdk/examples/status-badge/cordis.patch.yml +7 -0
- package/sdk/examples/status-badge/index.js +12 -0
- package/sdk/examples/status-badge/package.json +24 -0
- package/sdk/templates/plugin/README.md +36 -0
- package/sdk/templates/plugin/client.js +35 -0
- package/sdk/templates/plugin/cordis.patch.yml +8 -0
- package/sdk/templates/plugin/icon.svg +5 -0
- package/sdk/templates/plugin/index.js +23 -0
- package/sdk/templates/plugin/locale/en.json +6 -0
- package/sdk/templates/plugin/package.json +36 -0
- package/src/compose.mjs +67 -0
- package/src/contract/VENDOR.json +16 -0
- package/src/contract/client.js +93 -0
- package/src/contract/constants.js +94 -0
- package/src/contract/envelope.js +83 -0
- package/src/contract/index.js +20 -0
- package/src/contract/negotiate.js +106 -0
- package/src/contract/relay.js +223 -0
- package/src/contract/server.js +77 -0
- package/src/contract/transports.js +319 -0
- package/src/index.mjs +11 -0
- package/src/kernel/core.mjs +100 -0
- package/src/kernel/extensions.mjs +61 -0
- package/src/kernel/fsx.mjs +349 -0
- package/src/kernel/index.mjs +21 -0
- package/src/kernel/ledger.mjs +94 -0
- package/src/kernel/paths.mjs +182 -0
- package/src/kernel/role.mjs +68 -0
- package/src/kernel/rounds.mjs +172 -0
- package/src/kernel/schema.mjs +5 -0
- package/src/kernel/types.mjs +266 -0
- package/src/kernel/vendor/cosmokit.mjs +421 -0
- package/src/kernel/vendor/schemastery.mjs +638 -0
- package/src/lib/adapter-client.mjs +383 -0
- package/src/lib/adapter-protocol.mjs +243 -0
- package/src/lib/ai-secret-store.mjs +202 -0
- package/src/lib/boot-guard.mjs +186 -0
- package/src/lib/branch-store.mjs +84 -0
- package/src/lib/branch.mjs +74 -0
- package/src/lib/bridge-client.mjs +133 -0
- package/src/lib/broker-adapter.mjs +67 -0
- package/src/lib/broker.mjs +45 -0
- package/src/lib/business-producers.mjs +109 -0
- package/src/lib/command-cache.mjs +209 -0
- package/src/lib/compat.mjs +170 -0
- package/src/lib/composer-journal.mjs +128 -0
- package/src/lib/container/index.mjs +806 -0
- package/src/lib/decision-events.mjs +129 -0
- package/src/lib/decision-ingest-client.mjs +41 -0
- package/src/lib/decision-outbox.mjs +129 -0
- package/src/lib/decision-policy.mjs +52 -0
- package/src/lib/default-essence.mjs +61 -0
- package/src/lib/desktop-launch.mjs +138 -0
- package/src/lib/essence.mjs +1211 -0
- package/src/lib/fork-client.mjs +410 -0
- package/src/lib/fork-config-lock.mjs +166 -0
- package/src/lib/fork-seed.mjs +304 -0
- package/src/lib/fork-sessions.mjs +183 -0
- package/src/lib/fork-supervision-settings.mjs +58 -0
- package/src/lib/fork-supervisor.mjs +122 -0
- package/src/lib/fs-guard.mjs +28 -0
- package/src/lib/http-auth.mjs +162 -0
- package/src/lib/idle-audit.mjs +178 -0
- package/src/lib/interaction-poke.mjs +61 -0
- package/src/lib/main-gate.mjs +579 -0
- package/src/lib/mirror-etag.mjs +92 -0
- package/src/lib/mirror-official-source.mjs +93 -0
- package/src/lib/mirror.mjs +478 -0
- package/src/lib/mother-link.mjs +156 -0
- package/src/lib/mutex.mjs +31 -0
- package/src/lib/permissions.mjs +115 -0
- package/src/lib/pnpm-store.mjs +189 -0
- package/src/lib/preferences.mjs +212 -0
- package/src/lib/producer-contract.mjs +162 -0
- package/src/lib/reflow.mjs +41 -0
- package/src/lib/remote.mjs +408 -0
- package/src/lib/restart-contract.mjs +752 -0
- package/src/lib/roots.mjs +30 -0
- package/src/lib/rule-registry.mjs +1127 -0
- package/src/lib/runtime-capture.mjs +307 -0
- package/src/lib/semaphore.mjs +34 -0
- package/src/lib/session-bundle.mjs +138 -0
- package/src/lib/session-format.mjs +672 -0
- package/src/lib/sse-parser.mjs +102 -0
- package/src/lib/ssrf-guard.mjs +170 -0
- package/src/lib/status-store.mjs +257 -0
- package/src/lib/task-handle.mjs +622 -0
- package/src/lib/ui-actions.mjs +55 -0
- package/src/lib/umbrella.mjs +1661 -0
- package/src/lib/upgrade-coordinator.mjs +107 -0
- package/src/lib/upgrade-status.mjs +43 -0
- package/src/plugins/adapter.mjs +1468 -0
- package/src/plugins/approver.mjs +1210 -0
- package/src/plugins/auditor.mjs +330 -0
- package/src/plugins/buffer-keeper.mjs +522 -0
- package/src/plugins/decision-center.mjs +2581 -0
- package/src/plugins/forker.mjs +1610 -0
- package/src/plugins/healer.mjs +702 -0
- package/src/plugins/hot-reloader.mjs +274 -0
- package/src/plugins/imports.mjs +247 -0
- package/src/plugins/orchestrator.mjs +3938 -0
- package/src/plugins/restart-guard.mjs +1022 -0
- package/src/plugins/router.mjs +714 -0
- package/src/plugins/scorer.mjs +661 -0
- package/src/plugins/session-bridge.mjs +166 -0
- package/src/plugins/session-catalog.mjs +771 -0
- package/src/plugins/session-router.mjs +125 -0
- package/src/plugins/session-sync.mjs +171 -0
- package/src/plugins/umbrella.mjs +419 -0
- package/tools/boot-guard.mjs +95 -0
- package/tools/decision-ingest.mjs +53 -0
- package/tools/desktop-launch.mjs +71 -0
- package/tools/fangctl.mjs +408 -0
- package/tools/gate/main-city.mjs +204 -0
- package/tools/mcp/server.mjs +121 -0
- package/tools/mcp/tools.mjs +65 -0
- package/tools/recover-queue.mjs +133 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
|
|
3
|
+
// dsh-Fang kernel — ids, tiny event bus, process resource sampling, exit codes.
|
|
4
|
+
import { randomUUID } from 'node:crypto'
|
|
5
|
+
|
|
6
|
+
export function newId(prefix = 'id') {
|
|
7
|
+
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function newUuid() {
|
|
11
|
+
return randomUUID()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Deterministic, dependency-free event bus (no Node emitter semantics to leak). */
|
|
15
|
+
export function createBus() {
|
|
16
|
+
const map = new Map()
|
|
17
|
+
return {
|
|
18
|
+
on(topic, fn) {
|
|
19
|
+
if (typeof fn !== 'function') return () => {}
|
|
20
|
+
if (!map.has(topic)) map.set(topic, new Set())
|
|
21
|
+
map.get(topic).add(fn)
|
|
22
|
+
return () => map.get(topic)?.delete(fn)
|
|
23
|
+
},
|
|
24
|
+
off(topic, fn) {
|
|
25
|
+
map.get(topic)?.delete(fn)
|
|
26
|
+
},
|
|
27
|
+
emit(topic, payload) {
|
|
28
|
+
const set = map.get(topic)
|
|
29
|
+
if (!set) return 0
|
|
30
|
+
let n = 0
|
|
31
|
+
for (const fn of [...set]) {
|
|
32
|
+
try { fn(payload); n++ } catch { /* a listener must never break the emitter */ }
|
|
33
|
+
}
|
|
34
|
+
return n
|
|
35
|
+
},
|
|
36
|
+
listenerCount(topic) {
|
|
37
|
+
return map.get(topic)?.size ?? 0
|
|
38
|
+
},
|
|
39
|
+
clear(topic) {
|
|
40
|
+
if (topic === undefined) map.clear()
|
|
41
|
+
else map.delete(topic)
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Process-wide bus shared by the 1 + 9 plugins. Every module imports this from the
|
|
48
|
+
* kernel, and the kernel module instance is shared inside one host process, so plugins
|
|
49
|
+
* can talk without a service handshake. Topic names live in `types.mjs` (AUDIT / FORK_*).
|
|
50
|
+
*/
|
|
51
|
+
export const bus = createBus()
|
|
52
|
+
|
|
53
|
+
/** Handle/resource census used by the health window (leak detection). */
|
|
54
|
+
export function sampleResources() {
|
|
55
|
+
const counts = {}
|
|
56
|
+
try {
|
|
57
|
+
for (const t of process.getActiveResourcesInfo()) counts[t] = (counts[t] ?? 0) + 1
|
|
58
|
+
} catch { /* platform without the API */ }
|
|
59
|
+
return counts
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function totalResources(counts) {
|
|
63
|
+
let n = 0
|
|
64
|
+
for (const v of Object.values(counts ?? {})) n += v
|
|
65
|
+
return n
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function diffResources(before, after) {
|
|
69
|
+
const keys = new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})])
|
|
70
|
+
const added = {}
|
|
71
|
+
const removed = {}
|
|
72
|
+
for (const k of keys) {
|
|
73
|
+
const d = (after?.[k] ?? 0) - (before?.[k] ?? 0)
|
|
74
|
+
if (d > 0) added[k] = d
|
|
75
|
+
else if (d < 0) removed[k] = -d
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
added,
|
|
79
|
+
removed,
|
|
80
|
+
leakedRefed: Object.values(added).reduce((a, b) => a + b, 0),
|
|
81
|
+
freed: Object.values(removed).reduce((a, b) => a + b, 0),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const EXIT = {
|
|
86
|
+
OK: 0,
|
|
87
|
+
ERROR: 1,
|
|
88
|
+
/** "guard allowed this restart" — the fork/supervisor contract. */
|
|
89
|
+
RESTART: 75,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const RISK_RANK = { blue: 1, yellow: 2, red: 3 }
|
|
93
|
+
|
|
94
|
+
export function riskAtLeast(risk, bar) {
|
|
95
|
+
return (RISK_RANK[risk] ?? 0) >= (RISK_RANK[bar] ?? 99)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function sleep(ms) {
|
|
99
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
100
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
|
|
3
|
+
// dsh-Fang kernel — commercial (Relay-backed) extension points.
|
|
4
|
+
//
|
|
5
|
+
// v2.0.0 ships the hooks, not the paid features. Every hook is optional and a no-op
|
|
6
|
+
// when absent: the offline development loop never depends on a Relay provider.
|
|
7
|
+
// A future commercial plugin provides the service `fangCommercial` with any subset of:
|
|
8
|
+
//
|
|
9
|
+
// collaboration: { publishChange({taskId, pluginId, files}) -> Promise<void>,
|
|
10
|
+
// onRemoteChange(cb) -> unsubscribe }
|
|
11
|
+
// git: { snapshot({dir, message}) -> Promise<{commit}>,
|
|
12
|
+
// log({dir, limit}) -> Promise<{commits}> }
|
|
13
|
+
// backup: { upload({kind, id, key, body}) -> Promise<{uri}>,
|
|
14
|
+
// status() -> Promise<{lastAt, ok}> }
|
|
15
|
+
// tokenApi: { issue({scope, ttlMs}) -> Promise<{token}> }
|
|
16
|
+
// teams: { whoami() -> Promise<{id, role}>, check({action, group}) -> Promise<{allowed}> }
|
|
17
|
+
//
|
|
18
|
+
// Providers must be async-safe and must never throw into the host: callers wrap them.
|
|
19
|
+
|
|
20
|
+
export const COMMERCIAL = {
|
|
21
|
+
collaboration: 'collaboration',
|
|
22
|
+
git: 'git',
|
|
23
|
+
backup: 'backup',
|
|
24
|
+
tokenApi: 'tokenApi',
|
|
25
|
+
teams: 'teams',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const SERVICE_COMMERCIAL = 'fangCommercial'
|
|
29
|
+
|
|
30
|
+
/** Default config gate: every paid capability is off unless the user enables it. */
|
|
31
|
+
export function commercialConfigSchemaDefaults() {
|
|
32
|
+
return { collaboration: false, git: false, backup: false, tokenApi: false, teams: false }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve one commercial capability. Returns undefined when the feature is disabled by
|
|
37
|
+
* config, the provider service is absent, or that provider does not implement it.
|
|
38
|
+
* @param {object} opts
|
|
39
|
+
* @param {object} opts.commercialHooks Config flag map (see commercialConfigSchemaDefaults)
|
|
40
|
+
* @param {object|undefined} opts.provider ctx.get('fangCommercial')
|
|
41
|
+
* @param {string} opts.capability
|
|
42
|
+
*/
|
|
43
|
+
export function resolveCommercial({ commercialHooks, provider, capability } = {}) {
|
|
44
|
+
if (!commercialHooks || commercialHooks[capability] !== true) return undefined
|
|
45
|
+
if (!provider) return undefined
|
|
46
|
+
const impl = provider[capability]
|
|
47
|
+
if (!impl || typeof impl !== 'object') return undefined
|
|
48
|
+
return impl
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Call one provider method with failure isolation. Never rejects. */
|
|
52
|
+
export async function invokeCommercial({ commercialHooks, provider, capability, method, args = [] } = {}) {
|
|
53
|
+
const impl = resolveCommercial({ commercialHooks, provider, capability })
|
|
54
|
+
if (!impl || typeof impl[method] !== 'function') return { ok: false, error: 'not-configured' }
|
|
55
|
+
try {
|
|
56
|
+
const value = await impl[method](...args)
|
|
57
|
+
return { ok: true, value }
|
|
58
|
+
} catch (e) {
|
|
59
|
+
return { ok: false, error: String(e?.message ?? e) }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
|
|
3
|
+
// dsh-Fang kernel — filesystem helpers.
|
|
4
|
+
// All disk mutation used by Fang plugins goes through here so that:
|
|
5
|
+
// * writes are atomic (tmp + rename),
|
|
6
|
+
// * tree removal is junction-safe (never follows a reparse point into the mother),
|
|
7
|
+
// * digests are stable across platforms.
|
|
8
|
+
import fs from 'node:fs'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
import { createHash } from 'node:crypto'
|
|
11
|
+
|
|
12
|
+
export function ensureDir(p) {
|
|
13
|
+
fs.mkdirSync(p, { recursive: true })
|
|
14
|
+
return p
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function exists(p) {
|
|
18
|
+
try {
|
|
19
|
+
fs.lstatSync(p)
|
|
20
|
+
return true
|
|
21
|
+
} catch {
|
|
22
|
+
return false
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function readJson(file, fallback = null) {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
29
|
+
} catch {
|
|
30
|
+
return fallback
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function readTextSafe(file, fallback = '') {
|
|
35
|
+
try {
|
|
36
|
+
return fs.readFileSync(file, 'utf8')
|
|
37
|
+
} catch {
|
|
38
|
+
return fallback
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let _tmpSeq = 0
|
|
43
|
+
export function tmpName(dir, stem = 'tmp') {
|
|
44
|
+
_tmpSeq = (_tmpSeq + 1) % 1e9
|
|
45
|
+
return path.join(dir, `.${stem}.${process.pid}.${Date.now().toString(36)}.${_tmpSeq}.tmp`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function writeFileAtomic(file, data) {
|
|
49
|
+
ensureDir(path.dirname(file))
|
|
50
|
+
const tmp = tmpName(path.dirname(file), path.basename(file))
|
|
51
|
+
fs.writeFileSync(tmp, data)
|
|
52
|
+
try {
|
|
53
|
+
fs.renameSync(tmp, file)
|
|
54
|
+
} catch {
|
|
55
|
+
// Windows can refuse a rename while a reader holds the file open; retry once, then fall back.
|
|
56
|
+
try {
|
|
57
|
+
fs.copyFileSync(tmp, file)
|
|
58
|
+
} finally {
|
|
59
|
+
try { fs.unlinkSync(tmp) } catch { /* ignore */ }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return file
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function writeJsonAtomic(file, value, { indent = 2 } = {}) {
|
|
66
|
+
return writeFileAtomic(file, JSON.stringify(value, null, indent) + '\n')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function appendJsonl(file, record) {
|
|
70
|
+
ensureDir(path.dirname(file))
|
|
71
|
+
fs.appendFileSync(file, JSON.stringify(record) + '\n', 'utf8')
|
|
72
|
+
return record
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Read a JSONL file into records; unparsable lines are skipped (verifyChain reports them). */
|
|
76
|
+
export function readJsonl(file) {
|
|
77
|
+
const out = []
|
|
78
|
+
let raw = ''
|
|
79
|
+
try { raw = fs.readFileSync(file, 'utf8') } catch { return out }
|
|
80
|
+
for (const line of raw.split('\n')) {
|
|
81
|
+
const t = line.trim()
|
|
82
|
+
if (!t) continue
|
|
83
|
+
try { out.push(JSON.parse(t)) } catch { /* skip */ }
|
|
84
|
+
}
|
|
85
|
+
return out
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function sha256(input) {
|
|
89
|
+
return createHash('sha256').update(input).digest('hex')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function sha256File(file) {
|
|
93
|
+
try {
|
|
94
|
+
return createHash('sha256').update(fs.readFileSync(file)).digest('hex')
|
|
95
|
+
} catch {
|
|
96
|
+
return null
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Stable recursive digest of a tree: sorted rel paths + content hash; links carry their target. */
|
|
101
|
+
export function digestTree(dir, { maxFiles = 20000 } = {}) {
|
|
102
|
+
const out = {}
|
|
103
|
+
let files = 0
|
|
104
|
+
const walk = (d, rel = '') => {
|
|
105
|
+
let names
|
|
106
|
+
try { names = fs.readdirSync(d).sort() } catch { return }
|
|
107
|
+
for (const n of names) {
|
|
108
|
+
if (files >= maxFiles) return
|
|
109
|
+
const abs = path.join(d, n)
|
|
110
|
+
const r = rel ? rel + '/' + n : n
|
|
111
|
+
let st
|
|
112
|
+
try { st = fs.lstatSync(abs) } catch { continue }
|
|
113
|
+
if (st.isSymbolicLink()) {
|
|
114
|
+
try { out[r] = 'link:' + fs.readlinkSync(abs) } catch { out[r] = 'link:?' }
|
|
115
|
+
files++
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
if (st.isDirectory()) { walk(abs, r); continue }
|
|
119
|
+
out[r] = sha256File(abs)
|
|
120
|
+
files++
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
walk(dir)
|
|
124
|
+
return out
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function diffDigests(before, after) {
|
|
128
|
+
const added = []
|
|
129
|
+
const removed = []
|
|
130
|
+
const changed = []
|
|
131
|
+
for (const [k, v] of Object.entries(after ?? {})) {
|
|
132
|
+
if (!(k in (before ?? {}))) added.push(k)
|
|
133
|
+
else if (before[k] !== v) changed.push(k)
|
|
134
|
+
}
|
|
135
|
+
for (const k of Object.keys(before ?? {})) if (!(k in (after ?? {}))) removed.push(k)
|
|
136
|
+
return { added, removed, changed, same: Object.keys(after ?? {}).length - added.length - changed.length }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Copy a tree. Symlinks/junctions are recreated as junctions, never traversed.
|
|
140
|
+
* Safe when `to` lives inside `from`: the destination is created only after the
|
|
141
|
+
* source listing is taken, and the destination itself is never re-entered. */
|
|
142
|
+
export function copyTree(from, to, { filter = null } = {}) {
|
|
143
|
+
let names
|
|
144
|
+
try { names = fs.readdirSync(from) } catch {
|
|
145
|
+
ensureDir(to)
|
|
146
|
+
return to
|
|
147
|
+
}
|
|
148
|
+
ensureDir(to)
|
|
149
|
+
const toAbs = path.resolve(to)
|
|
150
|
+
for (const n of names) {
|
|
151
|
+
const src = path.join(from, n)
|
|
152
|
+
const dst = path.join(to, n)
|
|
153
|
+
if (filter && !filter(n, src)) continue
|
|
154
|
+
if (path.resolve(src) === toAbs) continue
|
|
155
|
+
let st
|
|
156
|
+
try { st = fs.lstatSync(src) } catch { continue }
|
|
157
|
+
if (st.isSymbolicLink()) {
|
|
158
|
+
try {
|
|
159
|
+
const target = fs.readlinkSync(src)
|
|
160
|
+
fs.symlinkSync(target, dst, 'junction')
|
|
161
|
+
} catch { /* ignore */ }
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
164
|
+
if (st.isDirectory()) { copyTree(src, dst, { filter }); continue }
|
|
165
|
+
ensureDir(path.dirname(dst))
|
|
166
|
+
try { fs.copyFileSync(src, dst) } catch { /* ignore */ }
|
|
167
|
+
}
|
|
168
|
+
return to
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function isLink(p) {
|
|
172
|
+
try {
|
|
173
|
+
return fs.lstatSync(p).isSymbolicLink()
|
|
174
|
+
} catch {
|
|
175
|
+
return false
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Copy a tree with hardlinked files: directories are recreated, ordinary files are
|
|
181
|
+
* hardlinked (same volume only; falls back to a copy), and links are recreated as links
|
|
182
|
+
* rather than traversed. Used when the essence runtime's touched packages must be
|
|
183
|
+
* materialized into the essence module tree without duplicating file bytes and without
|
|
184
|
+
* junction cycles (the runtime mirror would otherwise point back at the essence tree).
|
|
185
|
+
* @param {string} from Source directory.
|
|
186
|
+
* @param {string} to Destination directory (created; expected to be empty/new).
|
|
187
|
+
* @returns {string} The destination.
|
|
188
|
+
*/
|
|
189
|
+
export function hardlinkTree(from, to) {
|
|
190
|
+
let names
|
|
191
|
+
try { names = fs.readdirSync(from) } catch {
|
|
192
|
+
ensureDir(to)
|
|
193
|
+
return to
|
|
194
|
+
}
|
|
195
|
+
ensureDir(to)
|
|
196
|
+
for (const name of names) {
|
|
197
|
+
const src = path.join(from, name)
|
|
198
|
+
const dst = path.join(to, name)
|
|
199
|
+
let st
|
|
200
|
+
try { st = fs.lstatSync(src) } catch { continue }
|
|
201
|
+
if (st.isSymbolicLink()) {
|
|
202
|
+
let target = ''
|
|
203
|
+
try { target = fs.readlinkSync(src) } catch { continue }
|
|
204
|
+
try {
|
|
205
|
+
const absolute = path.isAbsolute(target) ? target : path.resolve(from, target)
|
|
206
|
+
const stTarget = fs.lstatSync(absolute)
|
|
207
|
+
if (stTarget.isDirectory()) fs.symlinkSync(absolute, dst, 'junction')
|
|
208
|
+
else fs.copyFileSync(absolute, dst)
|
|
209
|
+
} catch { /* an unmirrorable link is skipped */ }
|
|
210
|
+
continue
|
|
211
|
+
}
|
|
212
|
+
if (st.isDirectory()) { hardlinkTree(src, dst); continue }
|
|
213
|
+
try { fs.linkSync(src, dst) } catch {
|
|
214
|
+
try { fs.copyFileSync(src, dst) } catch { /* skip */ }
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return to
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Create (or replace) a Windows junction / POSIX dir symlink. */
|
|
221
|
+
export function makeJunction(target, linkPath) {
|
|
222
|
+
ensureDir(path.dirname(linkPath))
|
|
223
|
+
if (exists(linkPath)) return linkPath
|
|
224
|
+
fs.symlinkSync(path.resolve(target), linkPath, 'junction')
|
|
225
|
+
return linkPath
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Replace `dest` with a real directory that mirrors `source`'s top-level entries:
|
|
230
|
+
* directories become junctions to their real targets, plain root files are copied.
|
|
231
|
+
* Package contents are never copied, so the module tree stays shared — but every
|
|
232
|
+
* entry is its own link. That is what DSH's profile-scope resolver needs: an
|
|
233
|
+
* importer below a linked root gets the installation's `@deepseek-ai/*` packages
|
|
234
|
+
* supplied (subject to the peer declaration), while a single whole-node_modules
|
|
235
|
+
* junction leaves the importer outside every known scope. Measured consequence of
|
|
236
|
+
* the whole-tree link: plugins that lazily `import('@deepseek-ai/dsh-tools')` in a
|
|
237
|
+
* fork fall back to their offline shim — w2m's shim stamps `required: false` into a
|
|
238
|
+
* property schema, and the provider rejects every request with
|
|
239
|
+
* `Invalid schema for function 'w2m_report'` (v3.1.7 report).
|
|
240
|
+
* Junction-safe: a reparse point in `dest` is unlinked, never entered; link targets
|
|
241
|
+
* are resolved with realpath first so a link-to-link (pnpm) also lands on a dir.
|
|
242
|
+
* @param {string} source Directory whose entries are mirrored (the module tree).
|
|
243
|
+
* @param {string} dest Directory to replace with the mirror.
|
|
244
|
+
* @returns {{ok:boolean, links:string[], files:string[], error?:string}}
|
|
245
|
+
*/
|
|
246
|
+
export function mirrorModuleTree(source, dest) {
|
|
247
|
+
const src = path.resolve(String(source ?? ''))
|
|
248
|
+
if (!exists(src)) return { ok: false, links: [], files: [], error: 'source-missing' }
|
|
249
|
+
let srcStat
|
|
250
|
+
try { srcStat = fs.lstatSync(src) } catch { return { ok: false, links: [], files: [], error: 'source-unreadable' } }
|
|
251
|
+
if (!srcStat.isDirectory() && !srcStat.isSymbolicLink()) return { ok: false, links: [], files: [], error: 'source-not-directory' }
|
|
252
|
+
removeTree(dest)
|
|
253
|
+
ensureDir(dest)
|
|
254
|
+
const links = []
|
|
255
|
+
const files = []
|
|
256
|
+
let names = []
|
|
257
|
+
try { names = fs.readdirSync(src) } catch { names = [] }
|
|
258
|
+
for (const name of names) {
|
|
259
|
+
const from = path.join(src, name)
|
|
260
|
+
const to = path.join(dest, name)
|
|
261
|
+
let real = from
|
|
262
|
+
try {
|
|
263
|
+
if (fs.lstatSync(from).isSymbolicLink()) real = fs.realpathSync(from)
|
|
264
|
+
} catch { continue }
|
|
265
|
+
let targetStat
|
|
266
|
+
try { targetStat = fs.statSync(real) } catch { continue }
|
|
267
|
+
if (targetStat.isDirectory()) {
|
|
268
|
+
try {
|
|
269
|
+
fs.symlinkSync(path.resolve(real), to, 'junction')
|
|
270
|
+
links.push(name)
|
|
271
|
+
} catch { /* an unmirrorable entry is skipped; the remaining tree still boots */ }
|
|
272
|
+
continue
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
fs.copyFileSync(from, to)
|
|
276
|
+
files.push(name)
|
|
277
|
+
} catch { /* ignore */ }
|
|
278
|
+
}
|
|
279
|
+
return { ok: true, links, files }
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Repair a profile `node_modules` that is still a whole-tree junction (v3.1.6 and
|
|
284
|
+
* older) into the per-entry mirror form. A real directory is left untouched.
|
|
285
|
+
* @param {string} modulesPath The profile's node_modules path.
|
|
286
|
+
* @returns {{ok:boolean, changed:boolean, links?:string[], files?:string[], source?:string, error?:string}}
|
|
287
|
+
*/
|
|
288
|
+
export function ensureModuleMirror(modulesPath) {
|
|
289
|
+
const p = String(modulesPath ?? '')
|
|
290
|
+
if (!p || !exists(p)) return { ok: false, changed: false, error: 'node-modules-missing' }
|
|
291
|
+
let st
|
|
292
|
+
try { st = fs.lstatSync(p) } catch { return { ok: false, changed: false, error: 'node-modules-unreadable' } }
|
|
293
|
+
if (!st.isSymbolicLink()) return { ok: true, changed: false, links: [], files: [] }
|
|
294
|
+
let source
|
|
295
|
+
try { source = fs.realpathSync(p) } catch { return { ok: false, changed: false, error: 'node-modules-target-missing' } }
|
|
296
|
+
const mirrored = mirrorModuleTree(source, p)
|
|
297
|
+
return { ...mirrored, changed: true, source }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Remove a file or directory tree. Junction-safe: a reparse point is unlinked,
|
|
302
|
+
* its target is never entered. This is the ONLY removal path plugins may use on
|
|
303
|
+
* fork homes, because a fork home contains a node_modules junction to the mother.
|
|
304
|
+
*/
|
|
305
|
+
export function removeTree(target) {
|
|
306
|
+
let st
|
|
307
|
+
try { st = fs.lstatSync(target) } catch { return false }
|
|
308
|
+
if (st.isSymbolicLink()) {
|
|
309
|
+
try { fs.unlinkSync(target); return true } catch { return false }
|
|
310
|
+
}
|
|
311
|
+
if (!st.isDirectory()) {
|
|
312
|
+
try { fs.unlinkSync(target); return true } catch { return false }
|
|
313
|
+
}
|
|
314
|
+
let names = []
|
|
315
|
+
try { names = fs.readdirSync(target) } catch { names = [] }
|
|
316
|
+
for (const n of names) removeTree(path.join(target, n))
|
|
317
|
+
try { fs.rmdirSync(target) } catch { /* ignore */ }
|
|
318
|
+
return true
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function listFiles(dir, { max = 5000 } = {}) {
|
|
322
|
+
const out = []
|
|
323
|
+
const walk = (d, rel = '') => {
|
|
324
|
+
if (out.length >= max) return
|
|
325
|
+
let names
|
|
326
|
+
try { names = fs.readdirSync(d).sort() } catch { return }
|
|
327
|
+
for (const n of names) {
|
|
328
|
+
if (out.length >= max) return
|
|
329
|
+
const abs = path.join(d, n)
|
|
330
|
+
const r = rel ? rel + '/' + n : n
|
|
331
|
+
let st
|
|
332
|
+
try { st = fs.lstatSync(abs) } catch { continue }
|
|
333
|
+
if (st.isDirectory()) walk(abs, r)
|
|
334
|
+
else out.push(r)
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
walk(dir)
|
|
338
|
+
return out
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function nowMs() {
|
|
342
|
+
return Date.now()
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function iso(ms = Date.now()) {
|
|
346
|
+
return new Date(ms).toISOString()
|
|
347
|
+
}
|
|
348
|
+
// NOTE: `sleep` lives in core.mjs only — two `export *` sources exporting the same
|
|
349
|
+
// name make the whole kernel barrel un-importable (`conflicting star exports`).
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
|
|
3
|
+
// dsh-Fang kernel barrel. Plugin modules import from here:
|
|
4
|
+
// import { createRounds, resolveFangLayout, SERVICE } from '../kernel/index.mjs'
|
|
5
|
+
export * from './core.mjs'
|
|
6
|
+
export * from './fsx.mjs'
|
|
7
|
+
export * from './paths.mjs'
|
|
8
|
+
export * from './rounds.mjs'
|
|
9
|
+
export * from './types.mjs'
|
|
10
|
+
export * from './role.mjs'
|
|
11
|
+
export * from './extensions.mjs'
|
|
12
|
+
export { default as z } from './schema.mjs'
|
|
13
|
+
export {
|
|
14
|
+
GENESIS,
|
|
15
|
+
canonical,
|
|
16
|
+
hashOf,
|
|
17
|
+
lastHash,
|
|
18
|
+
appendChained,
|
|
19
|
+
verifyChain,
|
|
20
|
+
verifyAndAppendBoot,
|
|
21
|
+
} from './ledger.mjs'
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
|
|
3
|
+
// 链式审计账本(共享模块)
|
|
4
|
+
// 每条记录携带 prevHash 与 hash = sha256(prevHash + canonical(record))。
|
|
5
|
+
// 能发现:改字段、改 hash、删中间行、换行序、追加伪造。
|
|
6
|
+
import fs from 'node:fs'
|
|
7
|
+
import { createHash } from 'node:crypto'
|
|
8
|
+
|
|
9
|
+
export const GENESIS = 'GENESIS'
|
|
10
|
+
|
|
11
|
+
/** 规范化:**递归**按键排序 + 去掉 hash/prevHash 自身
|
|
12
|
+
* (TS 规范 audit.canonicalJson 是递归排序;只排顶层会让嵌套键序影响 hash) */
|
|
13
|
+
export function canonical(record) {
|
|
14
|
+
const walk = (x) => {
|
|
15
|
+
if (x === null || typeof x !== 'object') return x
|
|
16
|
+
if (Array.isArray(x)) return x.map(walk)
|
|
17
|
+
const o = {}
|
|
18
|
+
for (const k of Object.keys(x).sort()) o[k] = walk(x[k])
|
|
19
|
+
return o
|
|
20
|
+
}
|
|
21
|
+
const { hash, prevHash, ...rest } = record ?? {}
|
|
22
|
+
return JSON.stringify(walk(rest))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 与 TS 规范 chainHash 完全一致:sha256(canonicalJson(entry) + prev) */
|
|
26
|
+
export function hashOf(record, prevHash) {
|
|
27
|
+
return createHash('sha256').update(canonical(record) + String(prevHash), 'utf8').digest('hex')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readLines(file) {
|
|
31
|
+
let raw = ''
|
|
32
|
+
try { raw = fs.readFileSync(file, 'utf8') } catch { return [] }
|
|
33
|
+
return raw.split('\n').filter(l => l.trim().length > 0)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function lastHash(file) {
|
|
37
|
+
const lines = readLines(file)
|
|
38
|
+
if (!lines.length) return GENESIS
|
|
39
|
+
try { return JSON.parse(lines[lines.length - 1]).hash ?? GENESIS } catch { return null }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 追加一条链式记录,返回 { hash, prevHash, seq } */
|
|
43
|
+
export function appendChained(file, record, opts = {}) {
|
|
44
|
+
const prevHash = lastHash(file)
|
|
45
|
+
if (prevHash === null) {
|
|
46
|
+
// 上一行不可解析 → 不静默续写(否则等于帮着掩盖篡改)
|
|
47
|
+
throw new Error(`ledger tail unparsable: ${file}`)
|
|
48
|
+
}
|
|
49
|
+
const seq = opts.seq ?? readLines(file).length
|
|
50
|
+
const body = { ...record, seq, prevHash }
|
|
51
|
+
const hash = hashOf(body, prevHash)
|
|
52
|
+
const line = JSON.stringify({ ...body, hash }) + '\n'
|
|
53
|
+
fs.appendFileSync(file, line, 'utf8')
|
|
54
|
+
return { hash, prevHash, seq }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 全链校验:不只看最后一条 head,而是逐条重算 + 检查 prevHash 链接。
|
|
59
|
+
* 返回 { ok, count, reason, badIndex, expected, actual }
|
|
60
|
+
*/
|
|
61
|
+
export function verifyChain(file) {
|
|
62
|
+
const lines = readLines(file)
|
|
63
|
+
if (!lines.length) return { ok: true, count: 0, reason: 'empty' }
|
|
64
|
+
let prev = GENESIS
|
|
65
|
+
for (let i = 0; i < lines.length; i++) {
|
|
66
|
+
let rec
|
|
67
|
+
try { rec = JSON.parse(lines[i]) } catch (e) {
|
|
68
|
+
return { ok: false, count: lines.length, badIndex: i, reason: 'unparsable', error: String(e?.message ?? e) }
|
|
69
|
+
}
|
|
70
|
+
if (rec.prevHash !== prev) {
|
|
71
|
+
return { ok: false, count: lines.length, badIndex: i, reason: 'broken-link', expected: prev, actual: rec.prevHash }
|
|
72
|
+
}
|
|
73
|
+
const recomputed = hashOf(rec, rec.prevHash)
|
|
74
|
+
if (recomputed !== rec.hash) {
|
|
75
|
+
return { ok: false, count: lines.length, badIndex: i, reason: 'hash-mismatch', expected: recomputed, actual: rec.hash }
|
|
76
|
+
}
|
|
77
|
+
prev = rec.hash
|
|
78
|
+
}
|
|
79
|
+
return { ok: true, count: lines.length, head: prev }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 启动自检 + 追加 boot 记录(corrupt 时不续写)
|
|
83
|
+
* 注意:与 TS 规范 audit.bootAudit **同名不同义** —— 那个是 sink+marker 的中断检测;
|
|
84
|
+
* 这里只是"校验全链后追加一条 boot 记录",故命名为 verifyAndAppendBoot。 */
|
|
85
|
+
export function verifyAndAppendBoot(file, extra = {}) {
|
|
86
|
+
const v = verifyChain(file)
|
|
87
|
+
if (!v.ok) return { ...v, action: 'refused-write' } // 账本可疑:不往上写
|
|
88
|
+
try {
|
|
89
|
+
const r = appendChained(file, { kind: 'boot-verify', ok: true, count: v.count, ...extra })
|
|
90
|
+
return { ...v, action: 'appended', bootHash: r.hash }
|
|
91
|
+
} catch (e) {
|
|
92
|
+
return { ...v, ok: false, reason: 'append-failed', error: String(e?.message ?? e) }
|
|
93
|
+
}
|
|
94
|
+
}
|