@sanqianx/ai-coding-event-bridge-console 0.7.2 → 0.7.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.
Files changed (36) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +81 -81
  3. package/console/index.html +953 -953
  4. package/package.json +26 -26
  5. package/src/agent-detect.js +128 -128
  6. package/src/bridge/bin/commit-boundary.js +64 -64
  7. package/src/bridge/connectors/capture-guard.js +24 -24
  8. package/src/bridge/connectors/claude-code/hook-entry.js +45 -45
  9. package/src/bridge/connectors/claude-code/notify.js +57 -57
  10. package/src/bridge/connectors/codex/cursor-store.js +83 -83
  11. package/src/bridge/connectors/codex/hook-entry.js +215 -215
  12. package/src/bridge/connectors/codex/session-parser.js +100 -100
  13. package/src/bridge/connectors/opencode/hook-entry.js +55 -55
  14. package/src/bridge/core/bridge.js +249 -246
  15. package/src/bridge/core/commit-projection.js +288 -288
  16. package/src/bridge/core/compaction.js +23 -23
  17. package/src/bridge/core/consumer-registry.js +111 -111
  18. package/src/bridge/core/event-schema.js +127 -127
  19. package/src/bridge/core/fs-utils.js +81 -81
  20. package/src/bridge/core/journal-router.js +39 -39
  21. package/src/bridge/core/journal.js +507 -507
  22. package/src/bridge/core/lock.js +96 -96
  23. package/src/bridge/core/normalizer.js +114 -114
  24. package/src/bridge/core/paths.js +12 -12
  25. package/src/bridge/core/project-registry.js +176 -176
  26. package/src/bridge/core/repo-context.js +191 -191
  27. package/src/bridge/core/runtime-home.js +189 -189
  28. package/src/bridge/core/semver.js +23 -23
  29. package/src/bridge/core/turn-identity.js +127 -127
  30. package/src/bridge/index.js +86 -86
  31. package/src/bridge/installers/claude-code/installer.js +181 -181
  32. package/src/bridge/installers/codex/installer.js +114 -114
  33. package/src/bridge/installers/opencode/installer.js +96 -96
  34. package/src/bridge/installers/zcode/installer.js +200 -200
  35. package/src/bridge/query/conversation-query.js +194 -194
  36. package/src/server.js +825 -825
@@ -1,111 +1,111 @@
1
- 'use strict';
2
-
3
- const fs = require('fs');
4
- const path = require('path');
5
- const { CrossProcessLock, lockPathFor } = require('./lock');
6
- const { writeJsonAtomicSync, readJsonIfExists, ensureDirSync } = require('./fs-utils');
7
-
8
- class ConsumerRegistry {
9
- constructor(homeDir) {
10
- // Pure reads on a machine without a Bridge home must not create one;
11
- // the directory is materialized on the first write.
12
- this.homeDir = homeDir;
13
- this.consumersFile = path.join(homeDir, 'consumers.json');
14
- this.lock = new CrossProcessLock(lockPathFor(homeDir, 'install.lock'));
15
- }
16
-
17
- async _withLock(fn) {
18
- return this.lock.withLock(fn);
19
- }
20
-
21
- _read() {
22
- const saved = readJsonIfExists(this.consumersFile);
23
- if (saved && typeof saved === 'object' && Array.isArray(saved.consumers)) {
24
- return saved;
25
- }
26
- return { schema: 'bridge-consumers/v1', consumers: [] };
27
- }
28
-
29
- _write(state) {
30
- ensureDirSync(this.homeDir);
31
- writeJsonAtomicSync(this.consumersFile, state);
32
- }
33
-
34
- async registerConsumer(name, meta = {}) {
35
- if (typeof name !== 'string' || !name) {
36
- throw new TypeError('consumer name is required');
37
- }
38
- return this._withLock(async () => {
39
- const state = this._read();
40
- let entry = state.consumers.find((c) => c.name === name);
41
- if (!entry) {
42
- entry = { name, ack: 0, registeredAt: new Date().toISOString(), meta: {} };
43
- state.consumers.push(entry);
44
- }
45
- entry.meta = { ...(entry.meta || {}), ...meta };
46
- entry.lastSeenAt = new Date().toISOString();
47
- this._write(state);
48
- return { ...entry };
49
- });
50
- }
51
-
52
- async unregisterConsumer(name) {
53
- return this._withLock(async () => {
54
- const state = this._read();
55
- const before = state.consumers.length;
56
- state.consumers = state.consumers.filter((c) => c.name !== name);
57
- this._write(state);
58
- return { removed: before - state.consumers.length };
59
- });
60
- }
61
-
62
- async ackConsumerCursor(name, sequence, { journalId = 'global' } = {}) {
63
- if (!Number.isInteger(sequence) || sequence < 0) {
64
- throw new TypeError('ack sequence must be a non-negative integer');
65
- }
66
- return this._withLock(async () => {
67
- const state = this._read();
68
- const entry = state.consumers.find((c) => c.name === name);
69
- if (!entry) {
70
- const err = new Error(`consumer ${name} is not registered`);
71
- err.code = 'CONSUMER_NOT_REGISTERED';
72
- throw err;
73
- }
74
- if (journalId === 'global') entry.ack = Math.max(entry.ack || 0, sequence);
75
- else {
76
- if (!consumesJournal(entry, journalId)) throw new Error(`consumer ${name} is not registered for journal ${journalId}`);
77
- entry.cursors = { ...(entry.cursors || {}), [journalId]: Math.max((entry.cursors || {})[journalId] || 0, sequence) };
78
- }
79
- entry.lastSeenAt = new Date().toISOString();
80
- this._write(state);
81
- return { name, journalId, ack: journalId === 'global' ? entry.ack : entry.cursors[journalId] };
82
- });
83
- }
84
-
85
- async getConsumers() {
86
- if (!fs.existsSync(this.consumersFile)) return [];
87
- return this._withLock(async () => {
88
- const state = this._read();
89
- return state.consumers.map((c) => ({ ...c }));
90
- });
91
- }
92
-
93
- async getMinAck({ journalId = 'global' } = {}) {
94
- if (!fs.existsSync(this.consumersFile)) return null;
95
- return this._withLock(async () => {
96
- const state = this._read();
97
- const consumers = state.consumers.filter(c => consumesJournal(c, journalId));
98
- if (consumers.length === 0) return null;
99
- return consumers.reduce((min, c) => Math.min(min, journalId === 'global' ? c.ack || 0 : (c.cursors || {})[journalId] || 0), Number.POSITIVE_INFINITY);
100
- });
101
- }
102
- }
103
-
104
- // Legacy consumers are global-only. An explicit wildcard protects journals
105
- // created after registration too; an offline consumer is never expired.
106
- function consumesJournal(consumer, journalId) {
107
- const journals = consumer.meta && consumer.meta.journals;
108
- return Array.isArray(journals) ? journals.includes('*') || journals.includes(journalId) : journalId === 'global';
109
- }
110
-
111
- module.exports = { ConsumerRegistry, consumesJournal };
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { CrossProcessLock, lockPathFor } = require('./lock');
6
+ const { writeJsonAtomicSync, readJsonIfExists, ensureDirSync } = require('./fs-utils');
7
+
8
+ class ConsumerRegistry {
9
+ constructor(homeDir) {
10
+ // Pure reads on a machine without a Bridge home must not create one;
11
+ // the directory is materialized on the first write.
12
+ this.homeDir = homeDir;
13
+ this.consumersFile = path.join(homeDir, 'consumers.json');
14
+ this.lock = new CrossProcessLock(lockPathFor(homeDir, 'install.lock'));
15
+ }
16
+
17
+ async _withLock(fn) {
18
+ return this.lock.withLock(fn);
19
+ }
20
+
21
+ _read() {
22
+ const saved = readJsonIfExists(this.consumersFile);
23
+ if (saved && typeof saved === 'object' && Array.isArray(saved.consumers)) {
24
+ return saved;
25
+ }
26
+ return { schema: 'bridge-consumers/v1', consumers: [] };
27
+ }
28
+
29
+ _write(state) {
30
+ ensureDirSync(this.homeDir);
31
+ writeJsonAtomicSync(this.consumersFile, state);
32
+ }
33
+
34
+ async registerConsumer(name, meta = {}) {
35
+ if (typeof name !== 'string' || !name) {
36
+ throw new TypeError('consumer name is required');
37
+ }
38
+ return this._withLock(async () => {
39
+ const state = this._read();
40
+ let entry = state.consumers.find((c) => c.name === name);
41
+ if (!entry) {
42
+ entry = { name, ack: 0, registeredAt: new Date().toISOString(), meta: {} };
43
+ state.consumers.push(entry);
44
+ }
45
+ entry.meta = { ...(entry.meta || {}), ...meta };
46
+ entry.lastSeenAt = new Date().toISOString();
47
+ this._write(state);
48
+ return { ...entry };
49
+ });
50
+ }
51
+
52
+ async unregisterConsumer(name) {
53
+ return this._withLock(async () => {
54
+ const state = this._read();
55
+ const before = state.consumers.length;
56
+ state.consumers = state.consumers.filter((c) => c.name !== name);
57
+ this._write(state);
58
+ return { removed: before - state.consumers.length };
59
+ });
60
+ }
61
+
62
+ async ackConsumerCursor(name, sequence, { journalId = 'global' } = {}) {
63
+ if (!Number.isInteger(sequence) || sequence < 0) {
64
+ throw new TypeError('ack sequence must be a non-negative integer');
65
+ }
66
+ return this._withLock(async () => {
67
+ const state = this._read();
68
+ const entry = state.consumers.find((c) => c.name === name);
69
+ if (!entry) {
70
+ const err = new Error(`consumer ${name} is not registered`);
71
+ err.code = 'CONSUMER_NOT_REGISTERED';
72
+ throw err;
73
+ }
74
+ if (journalId === 'global') entry.ack = Math.max(entry.ack || 0, sequence);
75
+ else {
76
+ if (!consumesJournal(entry, journalId)) throw new Error(`consumer ${name} is not registered for journal ${journalId}`);
77
+ entry.cursors = { ...(entry.cursors || {}), [journalId]: Math.max((entry.cursors || {})[journalId] || 0, sequence) };
78
+ }
79
+ entry.lastSeenAt = new Date().toISOString();
80
+ this._write(state);
81
+ return { name, journalId, ack: journalId === 'global' ? entry.ack : entry.cursors[journalId] };
82
+ });
83
+ }
84
+
85
+ async getConsumers() {
86
+ if (!fs.existsSync(this.consumersFile)) return [];
87
+ return this._withLock(async () => {
88
+ const state = this._read();
89
+ return state.consumers.map((c) => ({ ...c }));
90
+ });
91
+ }
92
+
93
+ async getMinAck({ journalId = 'global' } = {}) {
94
+ if (!fs.existsSync(this.consumersFile)) return null;
95
+ return this._withLock(async () => {
96
+ const state = this._read();
97
+ const consumers = state.consumers.filter(c => consumesJournal(c, journalId));
98
+ if (consumers.length === 0) return null;
99
+ return consumers.reduce((min, c) => Math.min(min, journalId === 'global' ? c.ack || 0 : (c.cursors || {})[journalId] || 0), Number.POSITIVE_INFINITY);
100
+ });
101
+ }
102
+ }
103
+
104
+ // Legacy consumers are global-only. An explicit wildcard protects journals
105
+ // created after registration too; an offline consumer is never expired.
106
+ function consumesJournal(consumer, journalId) {
107
+ const journals = consumer.meta && consumer.meta.journals;
108
+ return Array.isArray(journals) ? journals.includes('*') || journals.includes(journalId) : journalId === 'global';
109
+ }
110
+
111
+ module.exports = { ConsumerRegistry, consumesJournal };
@@ -1,127 +1,127 @@
1
- 'use strict';
2
-
3
- const { isValidRepoIdentityV1 } = require('./repo-context');
4
-
5
- const SOURCES = ['claude-code', 'codex', 'opencode', 'zcode'];
6
- const IDENTITY_CONFIDENCE_LEVELS = ['exact', 'partial', 'unavailable'];
7
- const CAPTURE_STATUSES = ['complete', 'partial', 'gap'];
8
- const ROLES = ['user', 'assistant'];
9
-
10
- // AUD-ID-001: a shared fake session id aggregates unrelated events.
11
- const FORBIDDEN_SESSION_IDS = new Set(['', 'unknown-session', 'unknown', 'null', 'none']);
12
-
13
- class EventSchemaError extends Error {
14
- constructor(message, field) {
15
- super(message);
16
- this.name = 'EventSchemaError';
17
- this.field = field;
18
- }
19
- }
20
-
21
- function evidenceLevelFor({ sessionId, turnId }) {
22
- if (sessionId && turnId) return 'exact';
23
- if (sessionId || turnId) return 'partial';
24
- return 'unavailable';
25
- }
26
-
27
- const CONFIDENCE_RANK = { unavailable: 0, partial: 1, exact: 2 };
28
-
29
- function clampConfidence(requested, evidence) {
30
- return CONFIDENCE_RANK[requested] <= CONFIDENCE_RANK[evidence] ? requested : evidence;
31
- }
32
-
33
- function raiseConfidenceToEvidence(current, evidence) {
34
- return CONFIDENCE_RANK[evidence] > CONFIDENCE_RANK[current] ? evidence : current;
35
- }
36
-
37
- function validateAndNormalizeEvent(input) {
38
- if (!input || typeof input !== 'object') {
39
- throw new EventSchemaError('event must be an object', 'event');
40
- }
41
- if (!SOURCES.includes(input.source)) {
42
- throw new EventSchemaError(`source must be one of ${SOURCES.join(', ')}`, 'source');
43
- }
44
- if (typeof input.eventType !== 'string' || !input.eventType) {
45
- throw new EventSchemaError('eventType is required', 'eventType');
46
- }
47
- if (input.role !== undefined && input.role !== null && !ROLES.includes(input.role)) {
48
- throw new EventSchemaError('role must be user, assistant or null', 'role');
49
- }
50
- if (input.content !== undefined && input.content !== null && typeof input.content !== 'string') {
51
- throw new EventSchemaError('content must be a string or null', 'content');
52
- }
53
-
54
- let sessionId = input.sessionId === undefined ? null : input.sessionId;
55
- if (sessionId !== null) {
56
- if (typeof sessionId !== 'string') {
57
- throw new EventSchemaError('sessionId must be a string or null', 'sessionId');
58
- }
59
- if (FORBIDDEN_SESSION_IDS.has(sessionId.toLowerCase())) {
60
- throw new EventSchemaError('fake session ids such as unknown-session are forbidden', 'sessionId');
61
- }
62
- }
63
- let turnId = input.turnId === undefined ? null : input.turnId;
64
- if (turnId !== null && typeof turnId !== 'string') {
65
- throw new EventSchemaError('turnId must be a string or null', 'turnId');
66
- }
67
-
68
- if (
69
- input.identityConfidence !== undefined &&
70
- !IDENTITY_CONFIDENCE_LEVELS.includes(input.identityConfidence)
71
- ) {
72
- throw new EventSchemaError('identityConfidence must be exact, partial or unavailable', 'identityConfidence');
73
- }
74
- if (input.repoIdentity !== undefined && input.repoIdentity !== null) {
75
- // New automatic data must use repo-identity/v1 objects from
76
- // resolveRepoContext. Plain strings are legacy records only; malformed
77
- // objects are rejected so a guessed identity can never enter the journal.
78
- if (typeof input.repoIdentity !== 'string' && !isValidRepoIdentityV1(input.repoIdentity)) {
79
- throw new EventSchemaError('repoIdentity must be a valid repo-identity/v1 object, a legacy string, or null', 'repoIdentity');
80
- }
81
- }
82
- if (input.eventKey !== undefined && input.eventKey !== null) {
83
- if (typeof input.eventKey !== 'string' || !input.eventKey) {
84
- throw new EventSchemaError('eventKey must be a non-empty string when present', 'eventKey');
85
- }
86
- }
87
- if (input.captureStatus !== undefined && !CAPTURE_STATUSES.includes(input.captureStatus)) {
88
- throw new EventSchemaError('captureStatus must be complete, partial or gap', 'captureStatus');
89
- }
90
-
91
- // Confidence may never overstate the captured evidence.
92
- const evidenceLevel = evidenceLevelFor({ sessionId, turnId });
93
- const requested = input.identityConfidence || evidenceLevel;
94
- const identityConfidence = clampConfidence(requested, evidenceLevel);
95
-
96
- const normalized = {
97
- source: input.source,
98
- eventType: input.eventType,
99
- role: input.role === undefined ? null : input.role,
100
- sessionId,
101
- turnId,
102
- identityConfidence,
103
- captureStatus: input.captureStatus || 'complete'
104
- };
105
- if (input.content !== undefined) normalized.content = input.content;
106
- if (input.eventKey !== undefined) normalized.eventKey = input.eventKey;
107
- if (input.repoIdentity !== undefined) normalized.repoIdentity = input.repoIdentity;
108
- if (input.projectPath !== undefined) normalized.projectPath = input.projectPath;
109
- if (input.branch !== undefined) normalized.branch = input.branch;
110
- if (input.headAtCapture !== undefined) normalized.headAtCapture = input.headAtCapture;
111
- if (input.capturedAt !== undefined) normalized.capturedAt = input.capturedAt;
112
- if (input.rawEventType !== undefined) normalized.rawEventType = input.rawEventType;
113
- if (input.meta !== undefined) normalized.meta = input.meta;
114
- return normalized;
115
- }
116
-
117
- module.exports = {
118
- validateAndNormalizeEvent,
119
- EventSchemaError,
120
- SOURCES,
121
- IDENTITY_CONFIDENCE_LEVELS,
122
- CAPTURE_STATUSES,
123
- FORBIDDEN_SESSION_IDS,
124
- evidenceLevelFor,
125
- clampConfidence,
126
- raiseConfidenceToEvidence
127
- };
1
+ 'use strict';
2
+
3
+ const { isValidRepoIdentityV1 } = require('./repo-context');
4
+
5
+ const SOURCES = ['claude-code', 'codex', 'opencode', 'zcode'];
6
+ const IDENTITY_CONFIDENCE_LEVELS = ['exact', 'partial', 'unavailable'];
7
+ const CAPTURE_STATUSES = ['complete', 'partial', 'gap'];
8
+ const ROLES = ['user', 'assistant'];
9
+
10
+ // AUD-ID-001: a shared fake session id aggregates unrelated events.
11
+ const FORBIDDEN_SESSION_IDS = new Set(['', 'unknown-session', 'unknown', 'null', 'none']);
12
+
13
+ class EventSchemaError extends Error {
14
+ constructor(message, field) {
15
+ super(message);
16
+ this.name = 'EventSchemaError';
17
+ this.field = field;
18
+ }
19
+ }
20
+
21
+ function evidenceLevelFor({ sessionId, turnId }) {
22
+ if (sessionId && turnId) return 'exact';
23
+ if (sessionId || turnId) return 'partial';
24
+ return 'unavailable';
25
+ }
26
+
27
+ const CONFIDENCE_RANK = { unavailable: 0, partial: 1, exact: 2 };
28
+
29
+ function clampConfidence(requested, evidence) {
30
+ return CONFIDENCE_RANK[requested] <= CONFIDENCE_RANK[evidence] ? requested : evidence;
31
+ }
32
+
33
+ function raiseConfidenceToEvidence(current, evidence) {
34
+ return CONFIDENCE_RANK[evidence] > CONFIDENCE_RANK[current] ? evidence : current;
35
+ }
36
+
37
+ function validateAndNormalizeEvent(input) {
38
+ if (!input || typeof input !== 'object') {
39
+ throw new EventSchemaError('event must be an object', 'event');
40
+ }
41
+ if (!SOURCES.includes(input.source)) {
42
+ throw new EventSchemaError(`source must be one of ${SOURCES.join(', ')}`, 'source');
43
+ }
44
+ if (typeof input.eventType !== 'string' || !input.eventType) {
45
+ throw new EventSchemaError('eventType is required', 'eventType');
46
+ }
47
+ if (input.role !== undefined && input.role !== null && !ROLES.includes(input.role)) {
48
+ throw new EventSchemaError('role must be user, assistant or null', 'role');
49
+ }
50
+ if (input.content !== undefined && input.content !== null && typeof input.content !== 'string') {
51
+ throw new EventSchemaError('content must be a string or null', 'content');
52
+ }
53
+
54
+ let sessionId = input.sessionId === undefined ? null : input.sessionId;
55
+ if (sessionId !== null) {
56
+ if (typeof sessionId !== 'string') {
57
+ throw new EventSchemaError('sessionId must be a string or null', 'sessionId');
58
+ }
59
+ if (FORBIDDEN_SESSION_IDS.has(sessionId.toLowerCase())) {
60
+ throw new EventSchemaError('fake session ids such as unknown-session are forbidden', 'sessionId');
61
+ }
62
+ }
63
+ let turnId = input.turnId === undefined ? null : input.turnId;
64
+ if (turnId !== null && typeof turnId !== 'string') {
65
+ throw new EventSchemaError('turnId must be a string or null', 'turnId');
66
+ }
67
+
68
+ if (
69
+ input.identityConfidence !== undefined &&
70
+ !IDENTITY_CONFIDENCE_LEVELS.includes(input.identityConfidence)
71
+ ) {
72
+ throw new EventSchemaError('identityConfidence must be exact, partial or unavailable', 'identityConfidence');
73
+ }
74
+ if (input.repoIdentity !== undefined && input.repoIdentity !== null) {
75
+ // New automatic data must use repo-identity/v1 objects from
76
+ // resolveRepoContext. Plain strings are legacy records only; malformed
77
+ // objects are rejected so a guessed identity can never enter the journal.
78
+ if (typeof input.repoIdentity !== 'string' && !isValidRepoIdentityV1(input.repoIdentity)) {
79
+ throw new EventSchemaError('repoIdentity must be a valid repo-identity/v1 object, a legacy string, or null', 'repoIdentity');
80
+ }
81
+ }
82
+ if (input.eventKey !== undefined && input.eventKey !== null) {
83
+ if (typeof input.eventKey !== 'string' || !input.eventKey) {
84
+ throw new EventSchemaError('eventKey must be a non-empty string when present', 'eventKey');
85
+ }
86
+ }
87
+ if (input.captureStatus !== undefined && !CAPTURE_STATUSES.includes(input.captureStatus)) {
88
+ throw new EventSchemaError('captureStatus must be complete, partial or gap', 'captureStatus');
89
+ }
90
+
91
+ // Confidence may never overstate the captured evidence.
92
+ const evidenceLevel = evidenceLevelFor({ sessionId, turnId });
93
+ const requested = input.identityConfidence || evidenceLevel;
94
+ const identityConfidence = clampConfidence(requested, evidenceLevel);
95
+
96
+ const normalized = {
97
+ source: input.source,
98
+ eventType: input.eventType,
99
+ role: input.role === undefined ? null : input.role,
100
+ sessionId,
101
+ turnId,
102
+ identityConfidence,
103
+ captureStatus: input.captureStatus || 'complete'
104
+ };
105
+ if (input.content !== undefined) normalized.content = input.content;
106
+ if (input.eventKey !== undefined) normalized.eventKey = input.eventKey;
107
+ if (input.repoIdentity !== undefined) normalized.repoIdentity = input.repoIdentity;
108
+ if (input.projectPath !== undefined) normalized.projectPath = input.projectPath;
109
+ if (input.branch !== undefined) normalized.branch = input.branch;
110
+ if (input.headAtCapture !== undefined) normalized.headAtCapture = input.headAtCapture;
111
+ if (input.capturedAt !== undefined) normalized.capturedAt = input.capturedAt;
112
+ if (input.rawEventType !== undefined) normalized.rawEventType = input.rawEventType;
113
+ if (input.meta !== undefined) normalized.meta = input.meta;
114
+ return normalized;
115
+ }
116
+
117
+ module.exports = {
118
+ validateAndNormalizeEvent,
119
+ EventSchemaError,
120
+ SOURCES,
121
+ IDENTITY_CONFIDENCE_LEVELS,
122
+ CAPTURE_STATUSES,
123
+ FORBIDDEN_SESSION_IDS,
124
+ evidenceLevelFor,
125
+ clampConfidence,
126
+ raiseConfidenceToEvidence
127
+ };
@@ -1,81 +1,81 @@
1
- 'use strict';
2
-
3
- const fs = require('fs');
4
- const path = require('path');
5
-
6
- function fsyncDir(dir) {
7
- try {
8
- const fd = fs.openSync(dir, 'r');
9
- try {
10
- fs.fsyncSync(fd);
11
- } finally {
12
- fs.closeSync(fd);
13
- }
14
- } catch (_) {
15
- // Directory fsync is best effort (not supported for directories on Windows).
16
- }
17
- }
18
-
19
- function appendLineSync(file, line) {
20
- const fd = fs.openSync(file, 'a');
21
- try {
22
- fs.writeSync(fd, line);
23
- fs.fsyncSync(fd);
24
- } finally {
25
- fs.closeSync(fd);
26
- }
27
- }
28
-
29
- function writeJsonAtomicSync(file, value) {
30
- const tmp = `${file}.tmp`;
31
- const fd = fs.openSync(tmp, 'w');
32
- try {
33
- fs.writeSync(fd, `${JSON.stringify(value, null, 2)}\n`);
34
- fs.fsyncSync(fd);
35
- } finally {
36
- fs.closeSync(fd);
37
- }
38
- fs.renameSync(tmp, file);
39
- fsyncDir(path.dirname(file));
40
- }
41
-
42
- function writeTextAtomicSync(file, text) {
43
- const tmp = `${file}.tmp`;
44
- const fd = fs.openSync(tmp, 'w');
45
- try {
46
- fs.writeSync(fd, text);
47
- fs.fsyncSync(fd);
48
- } finally {
49
- fs.closeSync(fd);
50
- }
51
- fs.renameSync(tmp, file);
52
- fsyncDir(path.dirname(file));
53
- }
54
-
55
- function readJsonIfExists(file) {
56
- try {
57
- return JSON.parse(fs.readFileSync(file, 'utf8'));
58
- } catch (err) {
59
- if (err && (err.code === 'ENOENT' || err instanceof SyntaxError)) return null;
60
- throw err;
61
- }
62
- }
63
-
64
- function ensureDirSync(dir) {
65
- fs.mkdirSync(dir, { recursive: true });
66
- }
67
-
68
- function copyDirSync(src, dest) {
69
- ensureDirSync(dest);
70
- for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
71
- const from = path.join(src, entry.name);
72
- const to = path.join(dest, entry.name);
73
- if (entry.isDirectory()) {
74
- copyDirSync(from, to);
75
- } else if (entry.isFile()) {
76
- fs.copyFileSync(from, to);
77
- }
78
- }
79
- }
80
-
81
- module.exports = { fsyncDir, appendLineSync, writeJsonAtomicSync, writeTextAtomicSync, readJsonIfExists, ensureDirSync, copyDirSync };
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ function fsyncDir(dir) {
7
+ try {
8
+ const fd = fs.openSync(dir, 'r');
9
+ try {
10
+ fs.fsyncSync(fd);
11
+ } finally {
12
+ fs.closeSync(fd);
13
+ }
14
+ } catch (_) {
15
+ // Directory fsync is best effort (not supported for directories on Windows).
16
+ }
17
+ }
18
+
19
+ function appendLineSync(file, line) {
20
+ const fd = fs.openSync(file, 'a');
21
+ try {
22
+ fs.writeSync(fd, line);
23
+ fs.fsyncSync(fd);
24
+ } finally {
25
+ fs.closeSync(fd);
26
+ }
27
+ }
28
+
29
+ function writeJsonAtomicSync(file, value) {
30
+ const tmp = `${file}.tmp`;
31
+ const fd = fs.openSync(tmp, 'w');
32
+ try {
33
+ fs.writeSync(fd, `${JSON.stringify(value, null, 2)}\n`);
34
+ fs.fsyncSync(fd);
35
+ } finally {
36
+ fs.closeSync(fd);
37
+ }
38
+ fs.renameSync(tmp, file);
39
+ fsyncDir(path.dirname(file));
40
+ }
41
+
42
+ function writeTextAtomicSync(file, text) {
43
+ const tmp = `${file}.tmp`;
44
+ const fd = fs.openSync(tmp, 'w');
45
+ try {
46
+ fs.writeSync(fd, text);
47
+ fs.fsyncSync(fd);
48
+ } finally {
49
+ fs.closeSync(fd);
50
+ }
51
+ fs.renameSync(tmp, file);
52
+ fsyncDir(path.dirname(file));
53
+ }
54
+
55
+ function readJsonIfExists(file) {
56
+ try {
57
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
58
+ } catch (err) {
59
+ if (err && (err.code === 'ENOENT' || err instanceof SyntaxError)) return null;
60
+ throw err;
61
+ }
62
+ }
63
+
64
+ function ensureDirSync(dir) {
65
+ fs.mkdirSync(dir, { recursive: true });
66
+ }
67
+
68
+ function copyDirSync(src, dest) {
69
+ ensureDirSync(dest);
70
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
71
+ const from = path.join(src, entry.name);
72
+ const to = path.join(dest, entry.name);
73
+ if (entry.isDirectory()) {
74
+ copyDirSync(from, to);
75
+ } else if (entry.isFile()) {
76
+ fs.copyFileSync(from, to);
77
+ }
78
+ }
79
+ }
80
+
81
+ module.exports = { fsyncDir, appendLineSync, writeJsonAtomicSync, writeTextAtomicSync, readJsonIfExists, ensureDirSync, copyDirSync };