@fluado/enmetu 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +168 -2
  3. package/dist/adapters/clock/systemClockAdapter.d.ts +6 -0
  4. package/dist/adapters/clock/systemClockAdapter.js +12 -0
  5. package/dist/adapters/fs/nodeFileSystemAdapter.d.ts +18 -0
  6. package/dist/adapters/fs/nodeFileSystemAdapter.js +230 -0
  7. package/dist/adapters/git/gitFacade.d.ts +148 -0
  8. package/dist/adapters/git/gitFacade.js +822 -0
  9. package/dist/adapters/git/gitLocatorFacade.d.ts +8 -0
  10. package/dist/adapters/git/gitLocatorFacade.js +72 -0
  11. package/dist/adapters/git/gitProcessAdapter.d.ts +46 -0
  12. package/dist/adapters/git/gitProcessAdapter.js +261 -0
  13. package/dist/adapters/log/nullLogger.d.ts +9 -0
  14. package/dist/adapters/log/nullLogger.js +10 -0
  15. package/dist/adapters/log/stderrLoggerAdapter.d.ts +10 -0
  16. package/dist/adapters/log/stderrLoggerAdapter.js +13 -0
  17. package/dist/adapters/process/nodeProcessAdapter.d.ts +9 -0
  18. package/dist/adapters/process/nodeProcessAdapter.js +56 -0
  19. package/dist/adapters/watch/fsWatchAdapter.d.ts +24 -0
  20. package/dist/adapters/watch/fsWatchAdapter.js +47 -0
  21. package/dist/cli/commands/hookCommand.d.ts +28 -0
  22. package/dist/cli/commands/hookCommand.js +90 -0
  23. package/dist/cli/commands/resumeCommand.d.ts +48 -0
  24. package/dist/cli/commands/resumeCommand.js +124 -0
  25. package/dist/cli/commands/runCommand.d.ts +33 -0
  26. package/dist/cli/commands/runCommand.js +69 -0
  27. package/dist/cli/commands/statusCommand.d.ts +71 -0
  28. package/dist/cli/commands/statusCommand.js +249 -0
  29. package/dist/cli/exitCodes.d.ts +7 -0
  30. package/dist/cli/exitCodes.js +39 -0
  31. package/dist/cli/helpText.d.ts +1 -0
  32. package/dist/cli/helpText.js +16 -0
  33. package/dist/cli/main.d.ts +8 -0
  34. package/dist/cli/main.js +49 -0
  35. package/dist/cli/parseArgs.d.ts +19 -0
  36. package/dist/cli/parseArgs.js +106 -0
  37. package/dist/cli.d.ts +2 -0
  38. package/dist/cli.js +32 -0
  39. package/dist/commit/commitLocalChangesController.d.ts +132 -0
  40. package/dist/commit/commitLocalChangesController.js +358 -0
  41. package/dist/commit/gitIndexLock.d.ts +36 -0
  42. package/dist/commit/gitIndexLock.js +90 -0
  43. package/dist/commit/repairStaleIndexController.d.ts +90 -0
  44. package/dist/commit/repairStaleIndexController.js +150 -0
  45. package/dist/commit/stageGuardedChanges.d.ts +30 -0
  46. package/dist/commit/stageGuardedChanges.js +52 -0
  47. package/dist/contracts/IClock.d.ts +7 -0
  48. package/dist/contracts/IClock.js +1 -0
  49. package/dist/contracts/IDaemon.d.ts +26 -0
  50. package/dist/contracts/IDaemon.js +1 -0
  51. package/dist/contracts/IFileSystem.d.ts +50 -0
  52. package/dist/contracts/IFileSystem.js +1 -0
  53. package/dist/contracts/IGitCommitter.d.ts +60 -0
  54. package/dist/contracts/IGitCommitter.js +1 -0
  55. package/dist/contracts/IGitConfigReader.d.ts +5 -0
  56. package/dist/contracts/IGitConfigReader.js +1 -0
  57. package/dist/contracts/IGitIndexLock.d.ts +44 -0
  58. package/dist/contracts/IGitIndexLock.js +1 -0
  59. package/dist/contracts/IGitReader.d.ts +81 -0
  60. package/dist/contracts/IGitReader.js +1 -0
  61. package/dist/contracts/IGitRunner.d.ts +49 -0
  62. package/dist/contracts/IGitRunner.js +1 -0
  63. package/dist/contracts/IGitWriter.d.ts +39 -0
  64. package/dist/contracts/IGitWriter.js +1 -0
  65. package/dist/contracts/IHaltStore.d.ts +14 -0
  66. package/dist/contracts/IHaltStore.js +1 -0
  67. package/dist/contracts/IHookInstaller.d.ts +14 -0
  68. package/dist/contracts/IHookInstaller.js +1 -0
  69. package/dist/contracts/IInstanceLock.d.ts +26 -0
  70. package/dist/contracts/IInstanceLock.js +1 -0
  71. package/dist/contracts/ILogger.d.ts +11 -0
  72. package/dist/contracts/ILogger.js +1 -0
  73. package/dist/contracts/IOperationJournal.d.ts +12 -0
  74. package/dist/contracts/IOperationJournal.js +1 -0
  75. package/dist/contracts/IPathQuarantine.d.ts +67 -0
  76. package/dist/contracts/IPathQuarantine.js +1 -0
  77. package/dist/contracts/IProcessHost.d.ts +11 -0
  78. package/dist/contracts/IProcessHost.js +1 -0
  79. package/dist/contracts/IRepositoryLocator.d.ts +28 -0
  80. package/dist/contracts/IRepositoryLocator.js +1 -0
  81. package/dist/contracts/IStateRecorder.d.ts +68 -0
  82. package/dist/contracts/IStateRecorder.js +1 -0
  83. package/dist/contracts/IStateStore.d.ts +88 -0
  84. package/dist/contracts/IStateStore.js +1 -0
  85. package/dist/contracts/ISyncCycle.d.ts +23 -0
  86. package/dist/contracts/ISyncCycle.js +1 -0
  87. package/dist/contracts/ISyncCycleRunner.d.ts +4 -0
  88. package/dist/contracts/ISyncCycleRunner.js +1 -0
  89. package/dist/contracts/ISyncScheduler.d.ts +6 -0
  90. package/dist/contracts/ISyncScheduler.js +1 -0
  91. package/dist/contracts/ITextSink.d.ts +3 -0
  92. package/dist/contracts/ITextSink.js +1 -0
  93. package/dist/contracts/IWatcher.d.ts +9 -0
  94. package/dist/contracts/IWatcher.js +1 -0
  95. package/dist/contracts/config.d.ts +20 -0
  96. package/dist/contracts/config.js +1 -0
  97. package/dist/contracts/errors.d.ts +39 -0
  98. package/dist/contracts/errors.js +64 -0
  99. package/dist/contracts/exitCode.d.ts +10 -0
  100. package/dist/contracts/exitCode.js +17 -0
  101. package/dist/contracts/gitTypes.d.ts +113 -0
  102. package/dist/contracts/gitTypes.js +1 -0
  103. package/dist/contracts/library.d.ts +35 -0
  104. package/dist/contracts/library.js +1 -0
  105. package/dist/contracts/logCodes.d.ts +1 -0
  106. package/dist/contracts/logCodes.js +1 -0
  107. package/dist/contracts/runtime.d.ts +16 -0
  108. package/dist/contracts/runtime.js +1 -0
  109. package/dist/contracts/syncTypes.d.ts +75 -0
  110. package/dist/contracts/syncTypes.js +6 -0
  111. package/dist/core/commit/commitMemory.d.ts +3 -0
  112. package/dist/core/commit/commitMemory.js +9 -0
  113. package/dist/core/commit/commitMessage.d.ts +6 -0
  114. package/dist/core/commit/commitMessage.js +12 -0
  115. package/dist/core/config/parseConfig.d.ts +5 -0
  116. package/dist/core/config/parseConfig.js +196 -0
  117. package/dist/core/content/contentRules.d.ts +11 -0
  118. package/dist/core/content/contentRules.js +50 -0
  119. package/dist/core/errors/errorMessage.d.ts +6 -0
  120. package/dist/core/errors/errorMessage.js +14 -0
  121. package/dist/core/errors/withCycleId.d.ts +7 -0
  122. package/dist/core/errors/withCycleId.js +38 -0
  123. package/dist/core/git/enmetuRefs.d.ts +9 -0
  124. package/dist/core/git/enmetuRefs.js +22 -0
  125. package/dist/core/git/porcelain.d.ts +51 -0
  126. package/dist/core/git/porcelain.js +219 -0
  127. package/dist/core/git/rebaseOwnership.d.ts +28 -0
  128. package/dist/core/git/rebaseOwnership.js +29 -0
  129. package/dist/core/git/stoppedRebaseWrites.d.ts +10 -0
  130. package/dist/core/git/stoppedRebaseWrites.js +22 -0
  131. package/dist/core/halt/resumeHint.d.ts +6 -0
  132. package/dist/core/halt/resumeHint.js +9 -0
  133. package/dist/core/log/formatLogLine.d.ts +3 -0
  134. package/dist/core/log/formatLogLine.js +11 -0
  135. package/dist/core/quarantine/keptBlobMessage.d.ts +5 -0
  136. package/dist/core/quarantine/keptBlobMessage.js +8 -0
  137. package/dist/core/quarantine/restoreWarnings.d.ts +16 -0
  138. package/dist/core/quarantine/restoreWarnings.js +36 -0
  139. package/dist/core/schedule/schedulerStep.d.ts +63 -0
  140. package/dist/core/schedule/schedulerStep.js +144 -0
  141. package/dist/core/state/stateGuards.d.ts +21 -0
  142. package/dist/core/state/stateGuards.js +295 -0
  143. package/dist/core/state/stateLiveness.d.ts +10 -0
  144. package/dist/core/state/stateLiveness.js +21 -0
  145. package/dist/core/state/stateRecordStep.d.ts +28 -0
  146. package/dist/core/state/stateRecordStep.js +203 -0
  147. package/dist/core/status/conflictGuide.d.ts +30 -0
  148. package/dist/core/status/conflictGuide.js +86 -0
  149. package/dist/core/status/orphanedCommits.d.ts +15 -0
  150. package/dist/core/status/orphanedCommits.js +24 -0
  151. package/dist/core/status/statusHeadline.d.ts +46 -0
  152. package/dist/core/status/statusHeadline.js +130 -0
  153. package/dist/core/watch/watchFilter.d.ts +2 -0
  154. package/dist/core/watch/watchFilter.js +34 -0
  155. package/dist/daemon/daemon.d.ts +55 -0
  156. package/dist/daemon/daemon.js +277 -0
  157. package/dist/enmetuInstances.d.ts +65 -0
  158. package/dist/enmetuInstances.js +333 -0
  159. package/dist/halt/haltGuardGitWriter.d.ts +23 -0
  160. package/dist/halt/haltGuardGitWriter.js +61 -0
  161. package/dist/halt/haltStore.d.ts +13 -0
  162. package/dist/halt/haltStore.js +49 -0
  163. package/dist/halt/recordHalt.d.ts +19 -0
  164. package/dist/halt/recordHalt.js +20 -0
  165. package/dist/hooks/hookScript.d.ts +22 -0
  166. package/dist/hooks/hookScript.js +52 -0
  167. package/dist/hooks/nullHookInstaller.d.ts +5 -0
  168. package/dist/hooks/nullHookInstaller.js +6 -0
  169. package/dist/hooks/preCommitCheckController.d.ts +23 -0
  170. package/dist/hooks/preCommitCheckController.js +22 -0
  171. package/dist/hooks/preCommitHookInstaller.d.ts +32 -0
  172. package/dist/hooks/preCommitHookInstaller.js +113 -0
  173. package/dist/index.d.ts +18 -0
  174. package/dist/index.js +38 -0
  175. package/dist/library/enmetuLibrary.d.ts +16 -0
  176. package/dist/library/enmetuLibrary.js +62 -0
  177. package/dist/library/readStateController.d.ts +28 -0
  178. package/dist/library/readStateController.js +32 -0
  179. package/dist/library/requestedStopProcessHost.d.ts +28 -0
  180. package/dist/library/requestedStopProcessHost.js +58 -0
  181. package/dist/lock/instanceLock.d.ts +26 -0
  182. package/dist/lock/instanceLock.js +125 -0
  183. package/dist/preflight/repositoryPreflight.d.ts +20 -0
  184. package/dist/preflight/repositoryPreflight.js +42 -0
  185. package/dist/quarantine/pathQuarantine.d.ts +113 -0
  186. package/dist/quarantine/pathQuarantine.js +504 -0
  187. package/dist/recovery/enmetuRebaseAbort.d.ts +108 -0
  188. package/dist/recovery/enmetuRebaseAbort.js +302 -0
  189. package/dist/recovery/operationJournal.d.ts +13 -0
  190. package/dist/recovery/operationJournal.js +37 -0
  191. package/dist/recovery/recoveryController.d.ts +48 -0
  192. package/dist/recovery/recoveryController.js +112 -0
  193. package/dist/state/stateFileStore.d.ts +18 -0
  194. package/dist/state/stateFileStore.js +73 -0
  195. package/dist/state/stateRecorder.d.ts +26 -0
  196. package/dist/state/stateRecorder.js +69 -0
  197. package/dist/state/stateRecordingSyncCycle.d.ts +24 -0
  198. package/dist/state/stateRecordingSyncCycle.js +71 -0
  199. package/dist/sync/hookAbortingSyncScheduler.d.ts +16 -0
  200. package/dist/sync/hookAbortingSyncScheduler.js +27 -0
  201. package/dist/sync/integrateUpstreamController.d.ts +95 -0
  202. package/dist/sync/integrateUpstreamController.js +353 -0
  203. package/dist/sync/syncCycleController.d.ts +71 -0
  204. package/dist/sync/syncCycleController.js +345 -0
  205. package/dist/sync/syncCycleRunner.d.ts +24 -0
  206. package/dist/sync/syncCycleRunner.js +169 -0
  207. package/dist/sync/syncScheduler.d.ts +32 -0
  208. package/dist/sync/syncScheduler.js +97 -0
  209. package/package.json +42 -4
@@ -0,0 +1,23 @@
1
+ import type { FetchResult, PushResult, RebaseResult, StageResult } from '../contracts/gitTypes.js';
2
+ import type { IGitWriter } from '../contracts/IGitWriter.js';
3
+ import type { IHaltStore } from '../contracts/IHaltStore.js';
4
+ /**
5
+ * Decorator: reads the halt marker before every IGitWriter method and rejects
6
+ * with HaltedError while it exists, never calling the wrapped writer (D3).
7
+ */
8
+ export declare class HaltGuardGitWriter implements IGitWriter {
9
+ private readonly inner;
10
+ private readonly halt;
11
+ constructor(inner: IGitWriter, halt: IHaltStore);
12
+ stageAll(): Promise<StageResult>;
13
+ unstage(paths: readonly string[]): Promise<void>;
14
+ fetch(remote: string, remoteRef: string, intoRef: string): Promise<FetchResult>;
15
+ updateRef(ref: string, newSha: string, oldSha: string | null): Promise<void>;
16
+ rebase(ontoSha: string, reflogAction: string): Promise<RebaseResult>;
17
+ abortRebase(): Promise<void>;
18
+ endRebaseAtBranch(branchRef: string): Promise<void>;
19
+ push(remote: string, sha: string, remoteRef: string): Promise<PushResult>;
20
+ storeBlob(path: string): Promise<string>;
21
+ restoreFromIndex(paths: readonly string[]): Promise<void>;
22
+ private refuseWhileHalted;
23
+ }
@@ -0,0 +1,61 @@
1
+ import { HaltedError } from '../contracts/errors.js';
2
+ /**
3
+ * Decorator: reads the halt marker before every IGitWriter method and rejects
4
+ * with HaltedError while it exists, never calling the wrapped writer (D3).
5
+ */
6
+ export class HaltGuardGitWriter {
7
+ inner;
8
+ halt;
9
+ constructor(inner, halt) {
10
+ this.inner = inner;
11
+ this.halt = halt;
12
+ }
13
+ async stageAll() {
14
+ await this.refuseWhileHalted();
15
+ return this.inner.stageAll();
16
+ }
17
+ async unstage(paths) {
18
+ await this.refuseWhileHalted();
19
+ return this.inner.unstage(paths);
20
+ }
21
+ async fetch(remote, remoteRef, intoRef) {
22
+ await this.refuseWhileHalted();
23
+ return this.inner.fetch(remote, remoteRef, intoRef);
24
+ }
25
+ async updateRef(ref, newSha, oldSha) {
26
+ await this.refuseWhileHalted();
27
+ return this.inner.updateRef(ref, newSha, oldSha);
28
+ }
29
+ async rebase(ontoSha, reflogAction) {
30
+ await this.refuseWhileHalted();
31
+ return this.inner.rebase(ontoSha, reflogAction);
32
+ }
33
+ async abortRebase() {
34
+ await this.refuseWhileHalted();
35
+ return this.inner.abortRebase();
36
+ }
37
+ async endRebaseAtBranch(branchRef) {
38
+ await this.refuseWhileHalted();
39
+ return this.inner.endRebaseAtBranch(branchRef);
40
+ }
41
+ async push(remote, sha, remoteRef) {
42
+ await this.refuseWhileHalted();
43
+ return this.inner.push(remote, sha, remoteRef);
44
+ }
45
+ async storeBlob(path) {
46
+ await this.refuseWhileHalted();
47
+ return this.inner.storeBlob(path);
48
+ }
49
+ async restoreFromIndex(paths) {
50
+ await this.refuseWhileHalted();
51
+ return this.inner.restoreFromIndex(paths);
52
+ }
53
+ // Read on every call, never cached: a halt set by an earlier step of the
54
+ // same cycle must stop the very next write.
55
+ async refuseWhileHalted() {
56
+ const record = await this.halt.read();
57
+ if (record !== null) {
58
+ throw new HaltedError(`refusing a git write while halted (${record.code}): ${record.message}`, record.code);
59
+ }
60
+ }
61
+ }
@@ -0,0 +1,13 @@
1
+ import type { IFileSystem } from '../contracts/IFileSystem.js';
2
+ import type { HaltRecord, IHaltStore } from '../contracts/IHaltStore.js';
3
+ export declare const HALT_FILE_NAME = "halt.json";
4
+ /** Service: the persisted halt marker <stateDir>/halt.json (D3, Decision 12). */
5
+ export declare class HaltStore implements IHaltStore {
6
+ private readonly fs;
7
+ private readonly stateDir;
8
+ constructor(fs: IFileSystem, stateDir: string);
9
+ read(): Promise<HaltRecord | null>;
10
+ set(record: HaltRecord): Promise<void>;
11
+ clear(): Promise<void>;
12
+ private path;
13
+ }
@@ -0,0 +1,49 @@
1
+ import { join } from 'node:path';
2
+ import { parseHaltRecord } from '../core/state/stateGuards.js';
3
+ export const HALT_FILE_NAME = 'halt.json';
4
+ // The store has no clock, and the moment a marker became unreadable is not
5
+ // knowable from its bytes; status prints this where it prints the time.
6
+ const UNKNOWN_TIME = 'unknown';
7
+ /** Service: the persisted halt marker <stateDir>/halt.json (D3, Decision 12). */
8
+ export class HaltStore {
9
+ fs;
10
+ stateDir;
11
+ constructor(fs, stateDir) {
12
+ this.fs = fs;
13
+ this.stateDir = stateDir;
14
+ }
15
+ async read() {
16
+ const path = this.path();
17
+ const text = await this.fs.readText(path);
18
+ if (text === null) {
19
+ return null;
20
+ }
21
+ // A marker that exists but cannot be parsed still means "stop": treating it
22
+ // like an absent one would let a corrupt file silently resume writes (fail-safe).
23
+ return (parseHaltRecord(text) ?? {
24
+ code: 'HALT_MARKER_UNREADABLE',
25
+ message: `the halt marker ${path} cannot be parsed`,
26
+ cycleId: null,
27
+ at: UNKNOWN_TIME,
28
+ });
29
+ }
30
+ async set(record) {
31
+ // Built field by field in interface order, so the file is byte-stable.
32
+ const persisted = {
33
+ code: record.code,
34
+ message: record.message,
35
+ cycleId: record.cycleId,
36
+ at: record.at,
37
+ };
38
+ await this.fs.writeAtomic(this.path(), `${JSON.stringify(persisted)}\n`, {
39
+ executable: false,
40
+ tempDir: this.stateDir,
41
+ });
42
+ }
43
+ async clear() {
44
+ await this.fs.remove(this.path());
45
+ }
46
+ path() {
47
+ return join(this.stateDir, HALT_FILE_NAME);
48
+ }
49
+ }
@@ -0,0 +1,19 @@
1
+ import type { IClock } from '../contracts/IClock.js';
2
+ import type { HaltCode, IHaltStore } from '../contracts/IHaltStore.js';
3
+ import type { ILogger } from '../contracts/ILogger.js';
4
+ export interface RecordHaltInput {
5
+ readonly code: HaltCode;
6
+ readonly message: string;
7
+ readonly cycleId: string;
8
+ }
9
+ export interface RecordHaltDeps {
10
+ readonly halt: IHaltStore;
11
+ readonly logger: ILogger;
12
+ readonly clock: IClock;
13
+ }
14
+ /**
15
+ * Persists the halt marker, then logs HALTED with the reason at the moment it
16
+ * is set (D3). The marker comes first: a log line for a halt that was never
17
+ * written would claim a stop that the next start does not honour.
18
+ */
19
+ export declare function recordHalt(input: RecordHaltInput, deps: RecordHaltDeps): Promise<void>;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Persists the halt marker, then logs HALTED with the reason at the moment it
3
+ * is set (D3). The marker comes first: a log line for a halt that was never
4
+ * written would claim a stop that the next start does not honour.
5
+ */
6
+ export async function recordHalt(input, deps) {
7
+ const { code, message, cycleId } = input;
8
+ await deps.halt.set({
9
+ code,
10
+ message,
11
+ cycleId,
12
+ at: new Date(deps.clock.now()).toISOString(),
13
+ });
14
+ deps.logger.log({
15
+ level: 'ERROR',
16
+ code: 'HALTED',
17
+ message: `${code}: ${message}`,
18
+ cycleId,
19
+ });
20
+ }
@@ -0,0 +1,22 @@
1
+ export declare const HOOK_MARKER = "# enmetu-managed-hook v1";
2
+ export interface HookScriptPaths {
3
+ /**
4
+ * The interpreter recorded at install time: an absolute path, or the bare
5
+ * name no search path entry held (design 13.1).
6
+ */
7
+ readonly nodePath: string;
8
+ /** Absolute enmetu CLI script recorded at install time. */
9
+ readonly cliPath: string;
10
+ }
11
+ /** The POSIX sh pre-commit hook of design 1.7; a single quote becomes '\''. */
12
+ export declare function renderHookScript(paths: HookScriptPaths): string;
13
+ /** true when the content carries the enmetu marker line. */
14
+ export declare function isEnmetuHook(content: string): boolean;
15
+ /**
16
+ * The line a person adds to a hook setup enmetu does not own (design 12.5,
17
+ * Decision 70). It names no path, because a hook is shared through the
18
+ * repository and a path recorded on one machine blocks every commit on a
19
+ * clone where it does not exist; it resolves enmetu through PATH and fails
20
+ * open like the installed hook, with a notice on stderr.
21
+ */
22
+ export declare function pathInvocationLine(): string;
@@ -0,0 +1,52 @@
1
+ export const HOOK_MARKER = '# enmetu-managed-hook v1';
2
+ /** The POSIX sh pre-commit hook of design 1.7; a single quote becomes '\''. */
3
+ export function renderHookScript(paths) {
4
+ return [
5
+ '#!/bin/sh',
6
+ HOOK_MARKER,
7
+ '# Written by enmetu at startup; set "installHook": false in enmetu.config.json to stop that.',
8
+ '# Refuses a commit that records conflict markers or a .json file that does not parse.',
9
+ '# To commit anyway: git commit --no-verify',
10
+ `ENMETU_NODE=${shellQuote(paths.nodePath)}`,
11
+ `ENMETU_CLI=${shellQuote(paths.cliPath)}`,
12
+ // The recorded interpreter may vanish with an upgrade (nvm, a moved Bun)
13
+ // or be a bare name no PATH entry held at install time: the same name on
14
+ // the commit's PATH keeps a Bun-only machine checking, node on PATH is
15
+ // the next best interpreter, and failing open beats blocking every commit
16
+ // (design 13.1, Decision 102). The ${...} is shell parameter expansion.
17
+ '[ -x "$ENMETU_NODE" ] || ENMETU_NODE="$(command -v "${ENMETU_NODE##*/}" 2>/dev/null || command -v node 2>/dev/null)"',
18
+ 'if [ -n "$ENMETU_NODE" ] && [ -f "$ENMETU_CLI" ]; then',
19
+ ' exec "$ENMETU_NODE" "$ENMETU_CLI" hook pre-commit',
20
+ 'fi',
21
+ // The recorded CLI is gone after a reinstall or on another clone; an
22
+ // enmetu on PATH (node_modules/.bin under husky, a global install) still
23
+ // checks the commit before the hook gives up.
24
+ 'if command -v enmetu >/dev/null 2>&1; then',
25
+ ' exec enmetu hook pre-commit',
26
+ 'fi',
27
+ 'echo "enmetu: pre-commit check skipped (node or enmetu not found)" >&2',
28
+ 'exit 0',
29
+ '',
30
+ ].join('\n');
31
+ }
32
+ /** true when the content carries the enmetu marker line. */
33
+ export function isEnmetuHook(content) {
34
+ // A whole-line match, so a foreign hook that merely mentions the marker in
35
+ // a longer comment is still foreign and left alone.
36
+ return content.split(/\r?\n/).some((line) => line === HOOK_MARKER);
37
+ }
38
+ /**
39
+ * The line a person adds to a hook setup enmetu does not own (design 12.5,
40
+ * Decision 70). It names no path, because a hook is shared through the
41
+ * repository and a path recorded on one machine blocks every commit on a
42
+ * clone where it does not exist; it resolves enmetu through PATH and fails
43
+ * open like the installed hook, with a notice on stderr.
44
+ */
45
+ export function pathInvocationLine() {
46
+ return 'if command -v enmetu >/dev/null 2>&1; then enmetu hook pre-commit || exit 1; else echo "enmetu: pre-commit check skipped (enmetu not on PATH)" >&2; fi';
47
+ }
48
+ // Inside single quotes sh interprets nothing, so the only character to escape
49
+ // is the single quote itself: close the quote, emit \', reopen.
50
+ function shellQuote(value) {
51
+ return `'${value.replaceAll("'", "'\\''")}'`;
52
+ }
@@ -0,0 +1,5 @@
1
+ import type { HookInstallResult, IHookInstaller } from '../contracts/IHookInstaller.js';
2
+ /** Null Object for installHook: false; returns disabled and touches nothing. */
3
+ export declare class NullHookInstaller implements IHookInstaller {
4
+ ensure(): Promise<HookInstallResult>;
5
+ }
@@ -0,0 +1,6 @@
1
+ /** Null Object for installHook: false; returns disabled and touches nothing. */
2
+ export class NullHookInstaller {
3
+ async ensure() {
4
+ return { kind: 'disabled' };
5
+ }
6
+ }
@@ -0,0 +1,23 @@
1
+ import type { IGitReader } from '../contracts/IGitReader.js';
2
+ import { type ContentViolation } from '../core/content/contentRules.js';
3
+ export interface PreCommitCheckInput {
4
+ /** Always 'hook'. */
5
+ readonly cycleId: string;
6
+ }
7
+ export interface PreCommitCheckDeps {
8
+ readonly git: IGitReader;
9
+ readonly skipJsonCheck: readonly string[];
10
+ }
11
+ export interface PreCommitCheckResult {
12
+ readonly violations: readonly ContentViolation[];
13
+ }
14
+ /**
15
+ * Controller: the hook's check over the index git is about to commit.
16
+ * 1. For each git.stagedFiles(MAX_SCAN_BYTES): findContentViolation (the guard's function, D11).
17
+ * 2. Return the violations in the order git listed the files.
18
+ *
19
+ * The check writes no log line and raises no error of its own, so the cycle
20
+ * id has nothing to be threaded into; a git failure propagates unchanged to
21
+ * hookCommand, which owns the exit code.
22
+ */
23
+ export declare function preCommitCheckController(_input: PreCommitCheckInput, deps: PreCommitCheckDeps): Promise<PreCommitCheckResult>;
@@ -0,0 +1,22 @@
1
+ import { findContentViolation, MAX_SCAN_BYTES, } from '../core/content/contentRules.js';
2
+ /**
3
+ * Controller: the hook's check over the index git is about to commit.
4
+ * 1. For each git.stagedFiles(MAX_SCAN_BYTES): findContentViolation (the guard's function, D11).
5
+ * 2. Return the violations in the order git listed the files.
6
+ *
7
+ * The check writes no log line and raises no error of its own, so the cycle
8
+ * id has nothing to be threaded into; a git failure propagates unchanged to
9
+ * hookCommand, which owns the exit code.
10
+ */
11
+ export async function preCommitCheckController(_input, deps) {
12
+ // 1. Inspect one staged blob at a time, so memory stays at one chunk.
13
+ const violations = [];
14
+ for await (const file of deps.git.stagedFiles(MAX_SCAN_BYTES)) {
15
+ const violation = findContentViolation(file, deps.skipJsonCheck);
16
+ if (violation !== null) {
17
+ violations.push(violation);
18
+ }
19
+ }
20
+ // 2. Git's listing order, so the refusal reads like `git status`.
21
+ return { violations };
22
+ }
@@ -0,0 +1,32 @@
1
+ import type { IFileSystem } from '../contracts/IFileSystem.js';
2
+ import type { IGitConfigReader } from '../contracts/IGitConfigReader.js';
3
+ import type { HookInstallResult, IHookInstaller } from '../contracts/IHookInstaller.js';
4
+ import { type HookScriptPaths } from './hookScript.js';
5
+ /**
6
+ * Service: installs <hooksDir>/pre-commit following rules 1 to 5 of design
7
+ * 1.7 (D10). Reads git configuration only through IGitConfigReader, so it
8
+ * cannot write any; never overwrites a hook without the enmetu marker.
9
+ * A bare-name interpreter (Bun started by name reports the bare name "bun"
10
+ * for its own executable path) is resolved through searchPath before the
11
+ * hook is rendered (design 13.1, Decision 102).
12
+ */
13
+ export declare class PreCommitHookInstaller implements IHookInstaller {
14
+ private readonly config;
15
+ private readonly fs;
16
+ private readonly hooksDir;
17
+ private readonly script;
18
+ /** The PATH the daemon was started with (Runtime.env.PATH); absent: no resolution. */
19
+ private readonly searchPath?;
20
+ constructor(config: IGitConfigReader, fs: IFileSystem, hooksDir: string, script: HookScriptPaths,
21
+ /** The PATH the daemon was started with (Runtime.env.PATH); absent: no resolution. */
22
+ searchPath?: string | undefined);
23
+ ensure(): Promise<HookInstallResult>;
24
+ private install;
25
+ /**
26
+ * The interpreter to record: an absolute nodePath unchanged; for a bare
27
+ * name, <dir>/<name> for the first absolute search path entry where that
28
+ * is an executable file or a link; else the bare name, which the hook
29
+ * looks up on the commit's PATH.
30
+ */
31
+ private interpreterPath;
32
+ }
@@ -0,0 +1,113 @@
1
+ import { isAbsolute, join } from 'node:path';
2
+ import { errorMessage } from '../core/errors/errorMessage.js';
3
+ import { isEnmetuHook, pathInvocationLine, renderHookScript, } from './hookScript.js';
4
+ const HOOK_FILE = 'pre-commit';
5
+ /** For hooks written in another language, where the shell line cannot be pasted (design 12.5). */
6
+ const OTHER_LANGUAGE_NOTE = 'A hook in another language runs "enmetu hook pre-commit" when enmetu is on PATH and refuses the commit when it exits non-zero.';
7
+ /**
8
+ * Service: installs <hooksDir>/pre-commit following rules 1 to 5 of design
9
+ * 1.7 (D10). Reads git configuration only through IGitConfigReader, so it
10
+ * cannot write any; never overwrites a hook without the enmetu marker.
11
+ * A bare-name interpreter (Bun started by name reports the bare name "bun"
12
+ * for its own executable path) is resolved through searchPath before the
13
+ * hook is rendered (design 13.1, Decision 102).
14
+ */
15
+ export class PreCommitHookInstaller {
16
+ config;
17
+ fs;
18
+ hooksDir;
19
+ script;
20
+ searchPath;
21
+ constructor(config, fs, hooksDir, script,
22
+ /** The PATH the daemon was started with (Runtime.env.PATH); absent: no resolution. */
23
+ searchPath) {
24
+ this.config = config;
25
+ this.fs = fs;
26
+ this.hooksDir = hooksDir;
27
+ this.script = script;
28
+ this.searchPath = searchPath;
29
+ }
30
+ async ensure() {
31
+ // The hook is a net, not a cage: every failure here becomes a result the
32
+ // daemon logs (HOOK_FAILED), never an error that stops it from starting.
33
+ try {
34
+ return await this.install();
35
+ }
36
+ catch (error) {
37
+ return {
38
+ kind: 'failed',
39
+ detail: errorMessage(error),
40
+ };
41
+ }
42
+ }
43
+ async install() {
44
+ // 1. A hooks path set at any scope belongs to the person (or to every
45
+ // repository sharing it); writing there would install enmetu into all
46
+ // of them, so the person adds the line themselves.
47
+ const hooksPath = await this.config.configValue('core.hooksPath');
48
+ if (hooksPath !== null) {
49
+ return {
50
+ kind: 'skipped',
51
+ reason: 'HOOKS_PATH_SET',
52
+ instruction: `core.hooksPath is ${hooksPath}; enmetu installs nothing there. To add its check, add this line to the shell hook ${join(hooksPath, HOOK_FILE)}: ${pathInvocationLine()}. ${OTHER_LANGUAGE_NOTE}`,
53
+ };
54
+ }
55
+ // 2. The repository's own hooks directory.
56
+ const target = join(this.hooksDir, HOOK_FILE);
57
+ const existing = await this.fs.readText(target);
58
+ // 3. A hook without the marker is someone else's and stays untouched.
59
+ if (existing !== null && !isEnmetuHook(existing)) {
60
+ return {
61
+ kind: 'skipped',
62
+ reason: 'FOREIGN_HOOK',
63
+ instruction: `${target} exists and is not enmetu's. To add enmetu's check, add this line to it: ${pathInvocationLine()}. ${OTHER_LANGUAGE_NOTE}`,
64
+ };
65
+ }
66
+ // 4. An enmetu hook is rewritten only when the recorded paths changed.
67
+ const rendered = renderHookScript({
68
+ nodePath: await this.interpreterPath(),
69
+ cliPath: this.script.cliPath,
70
+ });
71
+ if (existing === rendered) {
72
+ return { kind: 'unchanged' };
73
+ }
74
+ // 5. The temp file lives in hooksDir, so the rename stays on one file
75
+ // system and git never sees a half-written hook.
76
+ await this.fs.writeAtomic(target, rendered, {
77
+ executable: true,
78
+ tempDir: this.hooksDir,
79
+ });
80
+ return { kind: existing === null ? 'installed' : 'updated' };
81
+ }
82
+ /**
83
+ * The interpreter to record: an absolute nodePath unchanged; for a bare
84
+ * name, <dir>/<name> for the first absolute search path entry where that
85
+ * is an executable file or a link; else the bare name, which the hook
86
+ * looks up on the commit's PATH.
87
+ */
88
+ async interpreterPath() {
89
+ const name = this.script.nodePath;
90
+ if (name.includes('/') || this.searchPath === undefined) {
91
+ return name;
92
+ }
93
+ for (const dir of this.searchPath.split(':')) {
94
+ // An empty or relative entry names the daemon's working directory or a
95
+ // path below it; git runs the hook from the repository root, where
96
+ // that entry points somewhere else.
97
+ if (!isAbsolute(dir)) {
98
+ continue;
99
+ }
100
+ const candidate = join(dir, name);
101
+ // facts is lstat: a link (Homebrew links every binary into its bin
102
+ // directory) says nothing about its target, and the hook's [ -x ]
103
+ // checks the target again at commit time.
104
+ const facts = await this.fs.facts(candidate);
105
+ if (facts !== null &&
106
+ ((facts.kind === 'file' && facts.executable) ||
107
+ facts.kind === 'symlink')) {
108
+ return candidate;
109
+ }
110
+ }
111
+ return name;
112
+ }
113
+ }
@@ -0,0 +1,18 @@
1
+ import type { EnmetuInstance, EnmetuStartOptions, EnmetuStateView } from './contracts/library.js';
2
+ /**
3
+ * The public library entry (package.json "exports", design 12.5). Importing
4
+ * it has no side effect: no signal handler, no process read, no timer. Besides
5
+ * src/cli.ts it is the only file that reads the process (environment,
6
+ * working directory, node binary) and import.meta (the packaged cli.js next
7
+ * to it, recorded in the pre-commit hook, and package.json's version).
8
+ */
9
+ export type { ExitCode } from './contracts/exitCode.js';
10
+ export { EXIT } from './contracts/exitCode.js';
11
+ export type { DaemonState, LastCycle, StateInstance, StateReason, StateRecord, StateTransition, } from './contracts/IStateStore.js';
12
+ export type { ITextSink } from './contracts/ITextSink.js';
13
+ export type { EnmetuInstance, EnmetuStartOptions, EnmetuStateView, StateLiveness, } from './contracts/library.js';
14
+ export type { ConflictKey, CycleOutcomeKind, PauseReason, WaitReason, } from './contracts/syncTypes.js';
15
+ /** Starts enmetu for the repository containing options.repo, in this process. */
16
+ export declare function startEnmetu(options: EnmetuStartOptions): EnmetuInstance;
17
+ /** The state record of the repository containing repo, and whether a live instance wrote it. */
18
+ export declare function readEnmetuState(repo: string): Promise<EnmetuStateView>;
package/dist/index.js ADDED
@@ -0,0 +1,38 @@
1
+ import { createRequire } from 'node:module';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { readEmbeddedState, startEmbedded } from './library/enmetuLibrary.js';
4
+ export { EXIT } from './contracts/exitCode.js';
5
+ /** Starts enmetu for the repository containing options.repo, in this process. */
6
+ export function startEnmetu(options) {
7
+ return startEmbedded(options, processRuntime());
8
+ }
9
+ /** The state record of the repository containing repo, and whether a live instance wrote it. */
10
+ export async function readEnmetuState(repo) {
11
+ return readEmbeddedState(repo, processRuntime());
12
+ }
13
+ /** Read at each call, never at import, so importing the entry touches nothing. */
14
+ function processRuntime() {
15
+ return {
16
+ argv: [],
17
+ env: process.env,
18
+ cwd: process.cwd(),
19
+ execPath: process.execPath,
20
+ // The CLI ships next to this file; the hook records it like `enmetu run`
21
+ // records its own argv[1].
22
+ cliPath: fileURLToPath(new URL('./cli.js', import.meta.url)),
23
+ version: packageVersion(),
24
+ stdout: process.stdout,
25
+ stderr: process.stderr,
26
+ };
27
+ }
28
+ /** package.json sits next to dist/ and src/, one level up from this file in both. */
29
+ function packageVersion() {
30
+ const manifest = createRequire(import.meta.url)('../package.json');
31
+ if (typeof manifest === 'object' &&
32
+ manifest !== null &&
33
+ 'version' in manifest &&
34
+ typeof manifest.version === 'string') {
35
+ return manifest.version;
36
+ }
37
+ return 'unknown';
38
+ }
@@ -0,0 +1,16 @@
1
+ import type { EnmetuInstance, EnmetuStartOptions, EnmetuStateView } from '../contracts/library.js';
2
+ import type { Runtime } from '../contracts/runtime.js';
3
+ /**
4
+ * The library entry's work, with the Runtime passed in so tests can drive it
5
+ * without src/index.ts (which reads the process and its own module URL). startEmbedded
6
+ * runs runCommand with createEmbeddedRunDeps: the same locate, git version
7
+ * gate, config and daemon `enmetu run` uses, but with RequestedStopProcessHost,
8
+ * so stop() takes the place of the first SIGTERM. options.repo is resolved
9
+ * against runtime.cwd. It returns at once; done resolves when the run ends and
10
+ * never rejects: an error runCommand throws is written to the log sink in the
11
+ * form `enmetu: <message>` and mapped by exitCodeFor, like the CLI's main, so a
12
+ * host that never awaits done gets no unhandled rejection (Decision 87).
13
+ */
14
+ export declare function startEmbedded(options: EnmetuStartOptions, runtime: Runtime): EnmetuInstance;
15
+ /** readStateController with createReadStateDeps; repo resolved against runtime.cwd. */
16
+ export declare function readEmbeddedState(repo: string, runtime: Runtime): Promise<EnmetuStateView>;
@@ -0,0 +1,62 @@
1
+ import { resolve } from 'node:path';
2
+ import { runCommand, } from '../cli/commands/runCommand.js';
3
+ import { exitCodeFor } from '../cli/exitCodes.js';
4
+ import { errorMessage } from '../core/errors/errorMessage.js';
5
+ import { createEmbeddedRunDeps, createReadStateDeps, } from '../enmetuInstances.js';
6
+ import { readStateController } from './readStateController.js';
7
+ /**
8
+ * The library entry's work, with the Runtime passed in so tests can drive it
9
+ * without src/index.ts (which reads the process and its own module URL). startEmbedded
10
+ * runs runCommand with createEmbeddedRunDeps: the same locate, git version
11
+ * gate, config and daemon `enmetu run` uses, but with RequestedStopProcessHost,
12
+ * so stop() takes the place of the first SIGTERM. options.repo is resolved
13
+ * against runtime.cwd. It returns at once; done resolves when the run ends and
14
+ * never rejects: an error runCommand throws is written to the log sink in the
15
+ * form `enmetu: <message>` and mapped by exitCodeFor, like the CLI's main, so a
16
+ * host that never awaits done gets no unhandled rejection (Decision 87).
17
+ */
18
+ export function startEmbedded(options, runtime) {
19
+ const embedded = embeddedRuntime(options, runtime);
20
+ const run = createEmbeddedRunDeps(embedded);
21
+ const done = settledRun({ startDir: resolve(runtime.cwd, options.repo) }, run.deps, embedded.stderr);
22
+ return {
23
+ done,
24
+ stop: () => {
25
+ run.requestStop();
26
+ return done;
27
+ },
28
+ };
29
+ }
30
+ /** readStateController with createReadStateDeps; repo resolved against runtime.cwd. */
31
+ export async function readEmbeddedState(repo, runtime) {
32
+ return readStateController({ startDir: resolve(runtime.cwd, repo) }, createReadStateDeps(runtime));
33
+ }
34
+ /** The host's log sink and environment in place of the process's, field by field. */
35
+ function embeddedRuntime(options, runtime) {
36
+ return {
37
+ // A host program has no enmetu command line.
38
+ argv: [],
39
+ env: options.env ?? runtime.env,
40
+ cwd: runtime.cwd,
41
+ execPath: runtime.execPath,
42
+ cliPath: runtime.cliPath,
43
+ version: runtime.version,
44
+ stdout: runtime.stdout,
45
+ stderr: options.log ?? runtime.stderr,
46
+ };
47
+ }
48
+ async function settledRun(input, deps, log) {
49
+ try {
50
+ return (await runCommand(input, deps)).exitCode;
51
+ }
52
+ catch (error) {
53
+ try {
54
+ log.write(`enmetu: ${errorMessage(error)}\n`);
55
+ }
56
+ catch {
57
+ // The sink that failed may be the cause; done must still settle, and
58
+ // the exit code alone tells the host the run failed.
59
+ }
60
+ return exitCodeFor(error);
61
+ }
62
+ }
@@ -0,0 +1,28 @@
1
+ import type { ILockInspector } from '../contracts/IInstanceLock.js';
2
+ import type { IRepositoryLocator, RepositoryPaths } from '../contracts/IRepositoryLocator.js';
3
+ import type { IStateStore } from '../contracts/IStateStore.js';
4
+ import type { EnmetuStateView } from '../contracts/library.js';
5
+ export interface ReadStateInput {
6
+ readonly startDir: string;
7
+ }
8
+ /** The state file and the lock of one repository: the one read seam (design 12.5). */
9
+ export interface StateSources {
10
+ readonly state: IStateStore;
11
+ readonly lock: ILockInspector;
12
+ }
13
+ export interface ReadStateDeps {
14
+ readonly locator: IRepositoryLocator;
15
+ readonly openSources: (paths: RepositoryPaths) => StateSources;
16
+ }
17
+ /**
18
+ * Controller:
19
+ * 1. locator.locate(startDir); not found throws PreflightError
20
+ * NOT_A_REPOSITORY, bare throws BARE_REPOSITORY.
21
+ * 2. state.read(), lock.inspect(), state.read() again: a daemon that writes
22
+ * stopped and releases between the first read and the inspect would
23
+ * otherwise look crashed; the second record is judged with the holder
24
+ * (Decision 86).
25
+ * 3. { liveness: stateLiveness(second record, holder), record: second record }.
26
+ * Reads only: no git command beyond the locator, no network.
27
+ */
28
+ export declare function readStateController(input: ReadStateInput, deps: ReadStateDeps): Promise<EnmetuStateView>;