@ozyman42/ozy-cli 0.4.6-windows-x64.0 → 0.4.6

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 (207) hide show
  1. package/bin/ozy +191 -0
  2. package/bin/ozy-signing-agent +191 -0
  3. package/bin/ozy-ssh-keygen +191 -0
  4. package/bin/ozy-virtual-security-key +191 -0
  5. package/dist/commands/git/hosts.js +18 -0
  6. package/dist/commands/git/hosts.js.map +1 -0
  7. package/dist/commands/git/index.js +8 -0
  8. package/dist/commands/git/index.js.map +1 -0
  9. package/dist/commands/git/setup.js +145 -0
  10. package/dist/commands/git/setup.js.map +1 -0
  11. package/dist/commands/index.js +13 -0
  12. package/dist/commands/index.js.map +1 -0
  13. package/dist/commands/npm/index.js +7 -0
  14. package/dist/commands/npm/index.js.map +1 -0
  15. package/dist/commands/npm/setup/git-remote.js +66 -0
  16. package/dist/commands/npm/setup/git-remote.js.map +1 -0
  17. package/dist/commands/npm/setup/index.js +168 -0
  18. package/dist/commands/npm/setup/index.js.map +1 -0
  19. package/dist/commands/npm/setup/npm-auth.js +62 -0
  20. package/dist/commands/npm/setup/npm-auth.js.map +1 -0
  21. package/dist/commands/npm/setup/package-json-validate.js +47 -0
  22. package/dist/commands/npm/setup/package-json-validate.js.map +1 -0
  23. package/dist/commands/npm/setup/package-registry.js +20 -0
  24. package/dist/commands/npm/setup/package-registry.js.map +1 -0
  25. package/dist/commands/npm/setup/setup.test.js +95 -0
  26. package/dist/commands/npm/setup/setup.test.js.map +1 -0
  27. package/dist/commands/npm/setup/trusted-publishing.js +84 -0
  28. package/dist/commands/npm/setup/trusted-publishing.js.map +1 -0
  29. package/dist/commands/npm/setup/workflow.js +19 -0
  30. package/dist/commands/npm/setup/workflow.js.map +1 -0
  31. package/dist/commands/ssh/get-sk-credential.js +104 -0
  32. package/dist/commands/ssh/get-sk-credential.js.map +1 -0
  33. package/dist/commands/ssh/index.js +7 -0
  34. package/dist/commands/ssh/index.js.map +1 -0
  35. package/dist/commands/upgrade.js +81 -0
  36. package/dist/commands/upgrade.js.map +1 -0
  37. package/dist/common/command.js +15 -0
  38. package/dist/common/command.js.map +1 -0
  39. package/dist/common/constants.js +25 -0
  40. package/dist/common/constants.js.map +1 -0
  41. package/dist/common/effective-modules-extensions.js +13 -0
  42. package/dist/common/effective-modules-extensions.js.map +1 -0
  43. package/dist/common/log.js +4 -0
  44. package/dist/common/log.js.map +1 -0
  45. package/dist/common/render-caller-tree.js +20 -0
  46. package/dist/common/render-caller-tree.js.map +1 -0
  47. package/dist/entrypoints/ozy-signing-agent.js +48 -0
  48. package/dist/entrypoints/ozy-signing-agent.js.map +1 -0
  49. package/dist/entrypoints/ozy-ssh-keygen.js +9 -0
  50. package/dist/entrypoints/ozy-ssh-keygen.js.map +1 -0
  51. package/dist/entrypoints/ozy-virtual-security-key.js +18 -0
  52. package/dist/entrypoints/ozy-virtual-security-key.js.map +1 -0
  53. package/dist/entrypoints/ozy.js +3 -0
  54. package/dist/entrypoints/ozy.js.map +1 -0
  55. package/dist/index.js +2 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/internal/thing.js +4 -0
  58. package/dist/internal/thing.js.map +1 -0
  59. package/dist/modules/cli/agent-client/impl.js +109 -0
  60. package/dist/modules/cli/agent-client/impl.js.map +1 -0
  61. package/dist/modules/cli/agent-client/interface.js +11 -0
  62. package/dist/modules/cli/agent-client/interface.js.map +1 -0
  63. package/dist/modules/cli/git/impl.js +124 -0
  64. package/dist/modules/cli/git/impl.js.map +1 -0
  65. package/dist/modules/cli/git/interface.js +3 -0
  66. package/dist/modules/cli/git/interface.js.map +1 -0
  67. package/dist/modules/cli/git/submodule/fix.js +257 -0
  68. package/dist/modules/cli/git/submodule/fix.js.map +1 -0
  69. package/dist/modules/cli/git/submodule/git-state.js +154 -0
  70. package/dist/modules/cli/git/submodule/git-state.js.map +1 -0
  71. package/dist/modules/cli/git/submodule/gitmodules.js +113 -0
  72. package/dist/modules/cli/git/submodule/gitmodules.js.map +1 -0
  73. package/dist/modules/cli/git/submodule/printer.js +232 -0
  74. package/dist/modules/cli/git/submodule/printer.js.map +1 -0
  75. package/dist/modules/cli/git/submodule/resolve-git-dir.js +16 -0
  76. package/dist/modules/cli/git/submodule/resolve-git-dir.js.map +1 -0
  77. package/dist/modules/cli/github/impl.js +137 -0
  78. package/dist/modules/cli/github/impl.js.map +1 -0
  79. package/dist/modules/cli/github/interface.js +2 -0
  80. package/dist/modules/cli/github/interface.js.map +1 -0
  81. package/dist/modules/cli/index.js +9 -0
  82. package/dist/modules/cli/index.js.map +1 -0
  83. package/dist/modules/common/crypto/impl.js +133 -0
  84. package/dist/modules/common/crypto/impl.js.map +1 -0
  85. package/dist/modules/common/crypto/interface.js +2 -0
  86. package/dist/modules/common/crypto/interface.js.map +1 -0
  87. package/dist/modules/common/index.js +10 -0
  88. package/dist/modules/common/index.js.map +1 -0
  89. package/dist/modules/common/kep-map-store/impl.js +42 -0
  90. package/dist/modules/common/kep-map-store/impl.js.map +1 -0
  91. package/dist/modules/common/kep-map-store/interface.js +2 -0
  92. package/dist/modules/common/kep-map-store/interface.js.map +1 -0
  93. package/dist/modules/common/os-platform/caller-info.js +132 -0
  94. package/dist/modules/common/os-platform/caller-info.js.map +1 -0
  95. package/dist/modules/common/os-platform/impl.js +87 -0
  96. package/dist/modules/common/os-platform/impl.js.map +1 -0
  97. package/dist/modules/common/os-platform/interface.js +2 -0
  98. package/dist/modules/common/os-platform/interface.js.map +1 -0
  99. package/dist/modules/common/os-platform/virtual-hid/index.js +12 -0
  100. package/dist/modules/common/os-platform/virtual-hid/index.js.map +1 -0
  101. package/dist/modules/common/os-platform/virtual-hid/linux.js +5 -0
  102. package/dist/modules/common/os-platform/virtual-hid/linux.js.map +1 -0
  103. package/dist/modules/common/os-platform/virtual-hid/mac.js +119 -0
  104. package/dist/modules/common/os-platform/virtual-hid/mac.js.map +1 -0
  105. package/dist/modules/common/os-platform/virtual-hid/windows.js +5 -0
  106. package/dist/modules/common/os-platform/virtual-hid/windows.js.map +1 -0
  107. package/dist/modules/common/ssh-config/impl.js +116 -0
  108. package/dist/modules/common/ssh-config/impl.js.map +1 -0
  109. package/dist/modules/common/ssh-config/interface.js +10 -0
  110. package/dist/modules/common/ssh-config/interface.js.map +1 -0
  111. package/dist/modules/ssh-agent/index.js +8 -0
  112. package/dist/modules/ssh-agent/index.js.map +1 -0
  113. package/dist/modules/ssh-agent/session/impl.js +265 -0
  114. package/dist/modules/ssh-agent/session/impl.js.map +1 -0
  115. package/dist/modules/ssh-agent/session/interface-rpc.js +20 -0
  116. package/dist/modules/ssh-agent/session/interface-rpc.js.map +1 -0
  117. package/dist/modules/ssh-agent/session/interface.js +32 -0
  118. package/dist/modules/ssh-agent/session/interface.js.map +1 -0
  119. package/dist/modules/ssh-agent/session/passkey-prf-page.js +204 -0
  120. package/dist/modules/ssh-agent/session/passkey-prf-page.js.map +1 -0
  121. package/dist/modules/ssh-agent/session/prf-flow.js +103 -0
  122. package/dist/modules/ssh-agent/session/prf-flow.js.map +1 -0
  123. package/dist/modules/ssh-agent/ssh-agent/impl.js +117 -0
  124. package/dist/modules/ssh-agent/ssh-agent/impl.js.map +1 -0
  125. package/dist/modules/ssh-agent/ssh-agent/interface.js +2 -0
  126. package/dist/modules/ssh-agent/ssh-agent/interface.js.map +1 -0
  127. package/dist/scripts/build.js +412 -0
  128. package/dist/scripts/build.js.map +1 -0
  129. package/dist/scripts/check-npm-version.js +8 -0
  130. package/dist/scripts/check-npm-version.js.map +1 -0
  131. package/dist/scripts/kill-existing-agent.js +21 -0
  132. package/dist/scripts/kill-existing-agent.js.map +1 -0
  133. package/dist/scripts/publish.js +46 -0
  134. package/dist/scripts/publish.js.map +1 -0
  135. package/package.json +52 -11
  136. package/src/commands/git/hosts.ts +20 -0
  137. package/src/commands/git/index.ts +9 -0
  138. package/src/commands/git/setup.ts +182 -0
  139. package/src/commands/index.ts +15 -0
  140. package/src/commands/npm/index.ts +8 -0
  141. package/src/commands/npm/setup/git-remote.ts +78 -0
  142. package/src/commands/npm/setup/index.ts +189 -0
  143. package/src/commands/npm/setup/npm-auth.ts +58 -0
  144. package/src/commands/npm/setup/package-json-validate.ts +68 -0
  145. package/src/commands/npm/setup/package-registry.ts +24 -0
  146. package/src/commands/npm/setup/publish-workflow.yml +35 -0
  147. package/src/commands/npm/setup/setup.test.ts +117 -0
  148. package/src/commands/npm/setup/trusted-publishing.ts +106 -0
  149. package/src/commands/npm/setup/workflow.ts +20 -0
  150. package/src/commands/ssh/get-sk-credential.ts +110 -0
  151. package/src/commands/ssh/index.ts +8 -0
  152. package/src/commands/upgrade.ts +90 -0
  153. package/src/common/command.ts +16 -0
  154. package/src/common/constants.ts +26 -0
  155. package/src/common/effective-modules-extensions.ts +24 -0
  156. package/src/common/log.ts +3 -0
  157. package/src/common/render-caller-tree.ts +22 -0
  158. package/src/entrypoints/ozy-signing-agent.ts +48 -0
  159. package/src/entrypoints/ozy-ssh-keygen.ts +10 -0
  160. package/src/entrypoints/ozy-virtual-security-key.ts +21 -0
  161. package/src/entrypoints/ozy.ts +2 -0
  162. package/src/index.ts +1 -0
  163. package/src/internal/thing.ts +3 -0
  164. package/src/modules/cli/agent-client/impl.ts +152 -0
  165. package/src/modules/cli/agent-client/interface.ts +17 -0
  166. package/src/modules/cli/git/impl.ts +133 -0
  167. package/src/modules/cli/git/interface.ts +23 -0
  168. package/src/modules/cli/git/submodule/fix.ts +298 -0
  169. package/src/modules/cli/git/submodule/git-state.ts +217 -0
  170. package/src/modules/cli/git/submodule/gitmodules.ts +126 -0
  171. package/src/modules/cli/git/submodule/printer.ts +256 -0
  172. package/src/modules/cli/git/submodule/resolve-git-dir.ts +14 -0
  173. package/src/modules/cli/github/impl.ts +169 -0
  174. package/src/modules/cli/github/interface.ts +29 -0
  175. package/src/modules/cli/index.ts +16 -0
  176. package/src/modules/common/crypto/impl.ts +173 -0
  177. package/src/modules/common/crypto/interface.ts +20 -0
  178. package/src/modules/common/index.ts +19 -0
  179. package/src/modules/common/kep-map-store/impl.ts +47 -0
  180. package/src/modules/common/kep-map-store/interface.ts +9 -0
  181. package/src/modules/common/os-platform/caller-info.ts +152 -0
  182. package/src/modules/common/os-platform/impl.ts +86 -0
  183. package/src/modules/common/os-platform/interface.ts +16 -0
  184. package/src/modules/common/os-platform/virtual-hid/index.ts +12 -0
  185. package/src/modules/common/os-platform/virtual-hid/linux.ts +7 -0
  186. package/src/modules/common/os-platform/virtual-hid/mac.ts +150 -0
  187. package/src/modules/common/os-platform/virtual-hid/windows.ts +7 -0
  188. package/src/modules/common/ssh-config/impl.ts +130 -0
  189. package/src/modules/common/ssh-config/interface.ts +33 -0
  190. package/src/modules/ssh-agent/index.ts +13 -0
  191. package/src/modules/ssh-agent/session/agent-notes.md +210 -0
  192. package/src/modules/ssh-agent/session/design.md +2 -0
  193. package/src/modules/ssh-agent/session/html-refactor.md +23 -0
  194. package/src/modules/ssh-agent/session/impl.ts +328 -0
  195. package/src/modules/ssh-agent/session/interface-rpc.ts +24 -0
  196. package/src/modules/ssh-agent/session/interface.ts +62 -0
  197. package/src/modules/ssh-agent/session/passkey-prf-page.ts +224 -0
  198. package/src/modules/ssh-agent/session/prf-flow.ts +152 -0
  199. package/src/modules/ssh-agent/ssh-agent/impl.ts +136 -0
  200. package/src/modules/ssh-agent/ssh-agent/interface.ts +9 -0
  201. package/src/scripts/build.ts +506 -0
  202. package/src/scripts/check-npm-version.ts +7 -0
  203. package/src/scripts/command-launcher.js.ejs +168 -0
  204. package/src/scripts/kill-existing-agent.ts +22 -0
  205. package/src/scripts/publish.ts +47 -0
  206. package/src/scripts/windows-bin-shims.md +1104 -0
  207. package/multi-call-binary.exe +0 -0
@@ -0,0 +1,152 @@
1
+ import { Effect, Layer, Match, Option, pipe, Data, FileSystem, Schedule, Result } from "effect";
2
+ import { effunct, implementing, type EffectGen } from "effective-modules";
3
+ import { FetchHttpClient } from "effect/unstable/http";
4
+ import { RpcClient, RpcSerialization } from "effect/unstable/rpc";
5
+ import { AgentRpcGroup } from "@/modules/ssh-agent/session/interface-rpc";
6
+ import { cliModules } from "..";
7
+ import { type AgentRpcClient, type IAgentClient, AgentClientError } from "./interface";
8
+ import { AGENT_CMD_PATH, AGENT_PID_FILE_PATH, AGENT_PORT_FILE_PATH, AGENT_SOCK_FILE_PATH, CURRENT_VERSION } from "@/common/constants";
9
+ import { readFileSync, rmSync } from "node:fs";
10
+ import { log } from "@/common/log";
11
+
12
+ function makeAgentClientLayer(port: number) {
13
+ return pipe(
14
+ RpcClient.layerProtocolHttp({ url: `http://localhost:${port}/rpc` }),
15
+ Layer.provideMerge(RpcSerialization.layerJson),
16
+ Layer.provideMerge(FetchHttpClient.layer)
17
+ );
18
+ }
19
+
20
+ class NoResponseError extends Data.TaggedError("NoResponseError")<{}> {
21
+ public toString() { return "No response from agent."; }
22
+ }
23
+
24
+ class NonMatchingVersionError extends Data.TaggedError("NonMatchingVersionError")<{
25
+ cliVersion: string;
26
+ agentVersion: string;
27
+ }> {
28
+ public toString() {
29
+ return `Agent running but with mismatched version ${this.agentVersion} compared to CLI version ${this.cliVersion}`;
30
+ }
31
+ }
32
+
33
+ type PingError = NoResponseError | NonMatchingVersionError;
34
+
35
+ export class AgentClientImpl extends implementing(cliModules.AgentClient).uses(FileSystem.FileSystem) implements IAgentClient {
36
+ private *readPortFromFile(): EffectGen<Option.Option<number>, never> {
37
+ const result = yield* pipe(
38
+ this.getDependency(FileSystem.FileSystem).readFileString(AGENT_PORT_FILE_PATH),
39
+ Effect.result
40
+ );
41
+ if (Result.isFailure(result)) return Option.none();
42
+ const port = parseInt(result.success.trim());
43
+ if (isNaN(port) || port <= 0 || port > 65535) {
44
+ log(`Read out of bounds port ${port}`);
45
+ return Option.none();
46
+ }
47
+ return Option.some(port);
48
+ }
49
+
50
+ private *pingAtPort(port: number): EffectGen<void, PingError> {
51
+ yield* Effect.scoped(
52
+ Effect.gen(function* () {
53
+ const client = yield* RpcClient.make(AgentRpcGroup);
54
+ const { version } = yield* pipe(
55
+ client.GetVersion({}),
56
+ Effect.catchTag("RpcClientError", () => new NoResponseError())
57
+ );
58
+ if (version !== CURRENT_VERSION)
59
+ yield* new NonMatchingVersionError({ cliVersion: CURRENT_VERSION, agentVersion: version });
60
+ })
61
+ ).pipe(Effect.provide(makeAgentClientLayer(port)));
62
+ }
63
+
64
+ private *ping(): EffectGen<{ port: number }, PingError> {
65
+ const portOption = yield* effunct(this.readPortFromFile)();
66
+ if (Option.isNone(portOption)) return yield* new NoResponseError();
67
+ const port = portOption.value;
68
+ yield* effunct(this.pingAtPort)(port);
69
+ return { port };
70
+ }
71
+
72
+ private *waitForPidFile(expectedPid: number): EffectGen<void, string> {
73
+ const fs = this.getDependency(FileSystem.FileSystem);
74
+ yield* pipe(
75
+ Effect.retry(
76
+ pipe(
77
+ fs.readFileString(AGENT_PID_FILE_PATH),
78
+ Effect.catchTag("PlatformError", () => Effect.fail("pid file not yet written")),
79
+ Effect.flatMap(content => {
80
+ const pid = parseInt(content.trim());
81
+ return pid === expectedPid
82
+ ? Effect.void
83
+ : Effect.fail(`PID mismatch: expected ${expectedPid}, got ${pid}`);
84
+ })
85
+ ),
86
+ Schedule.spaced("50 millis")
87
+ ),
88
+ Effect.timeout("5 seconds"),
89
+ Effect.mapError(() => "Agent did not start within 5 seconds")
90
+ );
91
+ }
92
+
93
+ private *startAgent(): EffectGen<{ pid: number; port: number }, string | PingError> {
94
+ const proc = Bun.spawn([AGENT_CMD_PATH], { stdout: "ignore", stderr: "ignore" });
95
+ yield* effunct(this.waitForPidFile)(proc.pid);
96
+ const { port } = yield* effunct(this.ping)();
97
+ log(`Started agent at PID ${proc.pid}`);
98
+ return { pid: proc.pid, port };
99
+ }
100
+
101
+ *killIfRunning(): EffectGen<void, string> {
102
+ try {
103
+ // Throws if file missing
104
+ const pidFileContent = readFileSync(AGENT_PID_FILE_PATH, "utf-8");
105
+ const PID = parseInt(pidFileContent.trim());
106
+ // If doesn't exist, below line throws
107
+ process.kill(PID, 0);
108
+ log(`Killing agent on PID ${PID}`);
109
+ process.kill(PID, "SIGTERM");
110
+ yield* Effect.sleep(500);
111
+ } catch { }
112
+ try { rmSync(AGENT_PID_FILE_PATH); } catch {}
113
+ try { rmSync(AGENT_PORT_FILE_PATH); } catch {}
114
+ try { rmSync(AGENT_SOCK_FILE_PATH); } catch {}
115
+ }
116
+
117
+ private *ensureRunning(): EffectGen<{ port: number }, string> {
118
+ const pingResult = yield* pipe(effunct(this.ping)(), Effect.result);
119
+ if (Result.isSuccess(pingResult)) return pingResult.success;
120
+ log(pingResult.failure.toString());
121
+ yield* Match.value(pingResult.failure).pipe(
122
+ Match.tag("NonMatchingVersionError", () => effunct(this.killIfRunning)()),
123
+ Match.tag("NoResponseError", () => effunct(this.killIfRunning)()),
124
+ Match.exhaustive
125
+ );
126
+ return yield* pipe(
127
+ effunct(this.startAgent)(),
128
+ Effect.mapError(err =>
129
+ typeof err === "string" ?
130
+ err :
131
+ `Failed to start agent due to ${err.name}: ${err.toString()}`
132
+ )
133
+ );
134
+ }
135
+
136
+ *usingClient<A, E>(use: (client: AgentRpcClient) => EffectGen<A, E>): EffectGen<A, AgentClientError<E>> {
137
+ const { port } = yield* pipe(
138
+ effunct(this.ensureRunning)(),
139
+ Effect.mapError(reason => new AgentClientError.ClientError({ reason }))
140
+ );
141
+ return yield* pipe(
142
+ Effect.scoped(
143
+ Effect.gen(function* () {
144
+ const client = yield* RpcClient.make(AgentRpcGroup);
145
+ return yield* use(client);
146
+ })
147
+ ),
148
+ Effect.provide(makeAgentClientLayer(port)),
149
+ Effect.mapError(e => new AgentClientError.UsageError({ cause: e }))
150
+ );
151
+ }
152
+ }
@@ -0,0 +1,17 @@
1
+ import type { EffectGen } from "effective-modules";
2
+ import type { AgentRpcGroup } from "@/modules/ssh-agent/session/interface-rpc";
3
+ import type { RpcClient, RpcClientError as RpcClientErrorModule, RpcGroup } from "effect/unstable/rpc";
4
+ import { Data } from "effect";
5
+
6
+ export type AgentRpcClient = RpcClient.RpcClient<RpcGroup.Rpcs<AgentRpcGroup>, RpcClientErrorModule.RpcClientError>;
7
+
8
+ export namespace AgentClientError {
9
+ export class UsageError<E> extends Data.TaggedError("AgentClientUsageError")<{ readonly cause: E }> {}
10
+ export class ClientError extends Data.TaggedError("AgentClientError")<{ readonly reason: string }> {}
11
+ }
12
+ export type AgentClientError<E> = AgentClientError.UsageError<E> | AgentClientError.ClientError;
13
+
14
+ export interface IAgentClient {
15
+ killIfRunning(): EffectGen<void, string>;
16
+ usingClient<A, E>(use: (client: AgentRpcClient) => EffectGen<A, E>): EffectGen<A, AgentClientError<E>>;
17
+ }
@@ -0,0 +1,133 @@
1
+ import { implementing, type EffectGen } from "effective-modules";
2
+ import { cliModules } from "..";
3
+ import { commonModules } from "@/modules/common";
4
+ import { $ } from 'bun';
5
+ import type { IGit, SetupRepoInput } from "./interface";
6
+ import { Effect, Option, pipe } from "effect";
7
+ import { existsSync } from "node:fs";
8
+ import { resolve } from "node:path";
9
+ import { SSH_KEYGEN_CMD_PATH, STANDARD_REMOTE_PREFIX } from "@/common/constants";
10
+ import { Gitmodules } from "./submodule/gitmodules";
11
+ import { readGitState } from "./submodule/git-state";
12
+ import { fixGitConfig, fixModuleDirs, fixIndexGitlinks } from "./submodule/fix";
13
+ import { printSyncState, printFixes } from "./submodule/printer";
14
+
15
+ export class GitImpl extends implementing(cliModules.Git).uses(commonModules.OSPlatform, commonModules.SSHConfig) implements IGit {
16
+ private parseOwnerRepo(remote: string): Effect.Effect<{ owner: string; repo: string }, string> {
17
+ const sshMatch = remote.match(/^git@[^:]+:([^/]+)\/(.+?)(?:\.git)?$/);
18
+ if (sshMatch) return Effect.succeed({ owner: sshMatch[1]!, repo: sshMatch[2]! });
19
+ const httpsMatch = remote.match(/^https?:\/\/[^/]+\/([^/]+)\/(.+?)(?:\.git)?$/);
20
+ if (httpsMatch) return Effect.succeed({ owner: httpsMatch[1]!, repo: httpsMatch[2]! });
21
+ return Effect.fail(`Cannot parse owner/repo from remote: ${remote}`);
22
+ }
23
+
24
+ *resolveOwnerAndRepo(maybeOwnerAndRepo: Option.Option<string>): EffectGen<{ owner: string; repo: string; }, string> {
25
+ if (Option.isSome(maybeOwnerAndRepo)) {
26
+ const parts = maybeOwnerAndRepo.value.split('/');
27
+ const [owner, repo] = parts;
28
+ if (parts.length !== 2 || !owner || !repo)
29
+ return yield* Effect.fail(`Cannot parse remote error: Expected "owner/repo", got "${maybeOwnerAndRepo.value}"`);
30
+ return { owner, repo };
31
+ }
32
+
33
+ if (!(yield* this.isGitRepository())) {
34
+ yield* Effect.fail(`not in git repo: Current directory is not a git repository`);
35
+ }
36
+
37
+ const remoteOption = yield* this.getRemoteOrigin();
38
+ if (Option.isNone(remoteOption)) {
39
+ return yield* Effect.fail(`no remote origin: No remote.origin.url — pass owner/repo as argument to clone instead`);
40
+ }
41
+
42
+ return yield* this.parseOwnerRepo(remoteOption.value);
43
+ }
44
+ *isGitRepository(): EffectGen<boolean> {
45
+ return yield* pipe(
46
+ Effect.tryPromise(async () => {
47
+ await $`git rev-parse --is-inside-work-tree`.quiet();
48
+ return true;
49
+ }),
50
+ Effect.catchTag("UnknownError", () => Effect.succeed(false))
51
+ );
52
+ }
53
+ *getRemoteOrigin(): EffectGen<Option.Option<string>> {
54
+ return yield* pipe(
55
+ Effect.tryPromise(async() => {
56
+ return Option.some((await $`git config --get remote.origin.url`.quiet()).text().trim());
57
+ }),
58
+ Effect.catchTag("UnknownError", () => Effect.succeed(Option.none()))
59
+ )
60
+ }
61
+ *clone(owner: string, repo: string): EffectGen<void, string> {
62
+ const result = Bun.spawnSync(['git', 'clone', '--recurse-submodules', `${STANDARD_REMOTE_PREFIX}${owner}/${repo}.git`], { stdout: 'inherit', stderr: 'inherit' });
63
+ if (result.exitCode !== 0)
64
+ return yield* Effect.fail(`git clone failed with exit code ${result.exitCode}`);
65
+ process.chdir(`./${repo}`);
66
+ }
67
+ *setLocalConfig(key: string, value: string): EffectGen<void, string> {
68
+ yield* Effect.tryPromise({
69
+ try: async () => { await $`git config --local ${key} ${value}`.quiet(); },
70
+ catch: (e) => `git config ${key}: ${e instanceof Error ? e.message : String(e)}`,
71
+ });
72
+ }
73
+ private *configureRepo({ dir, remoteURL, user, pubkeyPath }: SetupRepoInput): EffectGen<void, string> {
74
+ yield* Effect.log(`Configuring ${dir}`);
75
+ yield* Effect.tryPromise({
76
+ try: async () => {
77
+ const configs: [string, string][] = [
78
+ ['remote.origin.url', remoteURL],
79
+ ['user.name', user.name],
80
+ ['user.email', user.email],
81
+ ['user.signingkey', pubkeyPath],
82
+ ['gpg.format', 'ssh'],
83
+ ['gpg.ssh.program', SSH_KEYGEN_CMD_PATH],
84
+ ['commit.gpgsign', 'true'],
85
+ ['tag.gpgsign', 'true'],
86
+ ];
87
+ for (const [k, v] of configs) {
88
+ const r = Bun.spawnSync(['git', 'config', '--local', k, v], { cwd: dir, stdout: 'pipe', stderr: 'pipe' });
89
+ if (r.exitCode !== 0) {
90
+ const err = new TextDecoder().decode(r.stderr);
91
+ throw new Error(`git config ${k} failed: ${err.trim()}`);
92
+ }
93
+ }
94
+ },
95
+ catch: (e) => e instanceof Error ? e.message : String(e),
96
+ });
97
+ }
98
+ private *syncSubmoduleState(repoRoot: string, topLevelRoot: string): EffectGen<Gitmodules, string> {
99
+ const gitmodules = yield* Gitmodules.fromRepoRoot(repoRoot);
100
+ if (gitmodules.list.length === 0) return gitmodules;
101
+ const submodulePaths = gitmodules.list.map(m => m.path);
102
+ const state = yield* readGitState(repoRoot, submodulePaths);
103
+ printSyncState(gitmodules, state);
104
+ const fixResults = yield* Effect.all(
105
+ [
106
+ fixGitConfig(repoRoot, gitmodules),
107
+ fixModuleDirs(repoRoot, gitmodules, state.moduleDirs, state.orphanModuleDirs, topLevelRoot),
108
+ fixIndexGitlinks(repoRoot, gitmodules, state.index),
109
+ ],
110
+ { concurrency: "unbounded" },
111
+ );
112
+ printFixes(fixResults.flat());
113
+ return gitmodules;
114
+ }
115
+ *setupRepo(input: SetupRepoInput): EffectGen<void, string> {
116
+ yield* this.setupRepoAt(input, input.dir);
117
+ }
118
+ private *setupRepoAt(input: SetupRepoInput, topLevelRoot: string): EffectGen<void, string> {
119
+ yield* this.configureRepo(input);
120
+ if (!existsSync(resolve(input.dir, '.gitmodules'))) return;
121
+ const gitmodules = yield* this.syncSubmoduleState(input.dir, topLevelRoot);
122
+ for (const m of gitmodules.list) {
123
+ const subDir = resolve(input.dir, m.path);
124
+ if (!existsSync(subDir)) continue;
125
+ yield* this.setupRepoAt({
126
+ dir: subDir,
127
+ remoteURL: m.url,
128
+ user: input.user,
129
+ pubkeyPath: input.pubkeyPath
130
+ }, topLevelRoot);
131
+ }
132
+ }
133
+ }
@@ -0,0 +1,23 @@
1
+ import { type EffectGen} from "effective-modules";
2
+ import { Option } from "effect";
3
+
4
+ export interface GitUser {
5
+ name: string;
6
+ email: string;
7
+ }
8
+
9
+ export interface SetupRepoInput {
10
+ dir: string;
11
+ user: GitUser;
12
+ pubkeyPath: string;
13
+ remoteURL: string;
14
+ }
15
+
16
+ export interface IGit {
17
+ isGitRepository(): EffectGen<boolean>;
18
+ getRemoteOrigin(): EffectGen<Option.Option<string>>;
19
+ resolveOwnerAndRepo(maybeOwnerAndRepo: Option.Option<string>): EffectGen<{owner: string; repo: string;}, string>;
20
+ clone(owner: string, repo: string): EffectGen<void, string>;
21
+ setLocalConfig(key: string, value: string): EffectGen<void, string>;
22
+ setupRepo(params: SetupRepoInput): EffectGen<void, string>;
23
+ }
@@ -0,0 +1,298 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { readFile, writeFile, rename, mkdir, rm } from "node:fs/promises";
3
+ import { resolve, join, dirname, relative } from "node:path";
4
+ import { Effect } from "effect";
5
+ import type { Gitmodules } from "./gitmodules";
6
+ import type { ModuleDirInfo, IndexGitlink } from "./git-state";
7
+ import { resolveGitDir } from "./resolve-git-dir";
8
+
9
+ export interface Fix {
10
+ location: string[];
11
+ action: "synced" | "deleted";
12
+ detail?: string;
13
+ }
14
+
15
+ function parseConfigSubmoduleValues(content: string): Map<string, { url?: string; path?: string }> {
16
+ const entries = new Map<string, { url?: string; path?: string }>();
17
+ let current: { url?: string; path?: string } | null = null;
18
+
19
+ for (const line of content.split("\n")) {
20
+ const headerMatch = line.match(/^\[submodule "(.+)"\]$/);
21
+ if (headerMatch) {
22
+ current = {};
23
+ entries.set(headerMatch[1]!, current);
24
+ continue;
25
+ }
26
+ if (!current) continue;
27
+ const kvMatch = line.match(/^\s+(\w+)\s*=\s*(.+)$/);
28
+ if (!kvMatch) continue;
29
+ const [, key, value] = kvMatch;
30
+ if (key === "url" || key === "path") current[key] = value!.trim();
31
+ }
32
+
33
+ return entries;
34
+ }
35
+
36
+ // Drops any stanza whose name isn't a valid submodule, is a stale duplicate, or
37
+ // has url/path values that no longer match .gitmodules — so it can be re-added fresh.
38
+ function removeExtraConfigSubmodules(content: string, modules: Gitmodules): { result: string; removed: string[] } {
39
+ const byName = new Map(modules.list.map((m) => [m.name, m]));
40
+ const existingValues = parseConfigSubmoduleValues(content);
41
+
42
+ const staleNames = new Set<string>();
43
+ for (const [name, values] of existingValues) {
44
+ const m = byName.get(name);
45
+ if (!m || values.url !== m.url || values.path !== m.path) staleNames.add(name);
46
+ }
47
+
48
+ const removed: string[] = [];
49
+ const seen = new Set<string>();
50
+ const output: string[] = [];
51
+ let dropping = false;
52
+
53
+ for (const line of content.split("\n")) {
54
+ const submoduleMatch = line.match(/^\[submodule "(.+)"\]$/);
55
+ if (submoduleMatch) {
56
+ const name = submoduleMatch[1]!;
57
+ if (!staleNames.has(name) && !seen.has(name)) {
58
+ seen.add(name);
59
+ dropping = false;
60
+ } else {
61
+ removed.push(name);
62
+ dropping = true;
63
+ }
64
+ } else if (/^\[/.test(line)) {
65
+ dropping = false;
66
+ }
67
+ if (!dropping) output.push(line);
68
+ }
69
+
70
+ return { result: output.join("\n"), removed };
71
+ }
72
+
73
+ async function spawnGit(args: string[], cwd: string): Promise<void> {
74
+ const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
75
+ const code = await proc.exited;
76
+ if (code !== 0) {
77
+ const err = await new Response(proc.stderr).text();
78
+ throw new Error(`git ${args.join(" ")} exited with code ${code}\n${err.trim()}`);
79
+ }
80
+ }
81
+
82
+ export function fixGitConfig(repoRoot: string, modules: Gitmodules): Effect.Effect<Fix[], string> {
83
+ return Effect.tryPromise({
84
+ try: async () => {
85
+ const gitDir = await resolveGitDir(repoRoot);
86
+ const configPath = resolve(gitDir, "config");
87
+ const content = await readFile(configPath, "utf8");
88
+ const { result: afterRemoval, removed } = removeExtraConfigSubmodules(content, modules);
89
+
90
+ const existingNames = new Set<string>(
91
+ [...afterRemoval.matchAll(/^\[submodule "(.+)"\]$/gm)].map((m) => m[1]!)
92
+ );
93
+ const missing = modules.list.filter((m) => !existingNames.has(m.name));
94
+
95
+ let result = afterRemoval;
96
+ for (const m of missing) {
97
+ if (!result.endsWith("\n")) result += "\n";
98
+ result += `[submodule "${m.name}"]\n\tactive = true\n\turl = ${m.url}\n\tpath = ${m.path}\n`;
99
+ }
100
+
101
+ if (removed.length === 0 && missing.length === 0) return [];
102
+ await writeFile(configPath, result, "utf8");
103
+ return [
104
+ ...removed.map((name) => ({ location: [".git", "config", name], action: "deleted" as const })),
105
+ ...missing.map((m) => ({ location: [".git", "config", m.name], action: "synced" as const })),
106
+ ];
107
+ },
108
+ catch: (e) => e instanceof Error ? e.message : String(e),
109
+ });
110
+ }
111
+
112
+ const GIT_MODULES_MARKER = ".git/modules/";
113
+
114
+ // A stale redirect's leading "../" prefix can be wrong after a path rename, but the
115
+ // suffix starting at ".git/modules/" always describes a location under the single
116
+ // top-level .git, at any nesting depth — so anchor on that marker instead of trusting
117
+ // the relative resolution.
118
+ function recoverStaleGitDirTarget(redirectContent: string, topLevelRoot: string): string | undefined {
119
+ const match = redirectContent.match(/^gitdir:\s*(.+)$/m);
120
+ if (!match) return undefined;
121
+ const idx = match[1]!.indexOf(GIT_MODULES_MARKER);
122
+ if (idx === -1) return undefined;
123
+ return resolve(topLevelRoot, match[1]!.slice(idx).trim());
124
+ }
125
+
126
+ // Moves existing git data into moduleGitDir instead of abandoning it, whether it's a
127
+ // standalone clone's literal .git directory, or a redirect file (even a stale one
128
+ // left over from a path rename) pointing at git data that still exists.
129
+ async function migrateExistingGitDir(workingTreeDir: string, moduleGitDir: string, topLevelRoot: string): Promise<boolean> {
130
+ const dotGit = join(workingTreeDir, ".git");
131
+ if (!existsSync(dotGit)) return false;
132
+
133
+ if (statSync(dotGit).isDirectory()) {
134
+ await mkdir(dirname(moduleGitDir), { recursive: true });
135
+ await rename(dotGit, moduleGitDir);
136
+ return true;
137
+ }
138
+
139
+ const content = await readFile(dotGit, "utf8");
140
+ const target = recoverStaleGitDirTarget(content, topLevelRoot);
141
+ if (!target || target === moduleGitDir || !existsSync(target)) return false;
142
+ await mkdir(dirname(moduleGitDir), { recursive: true });
143
+ await rename(target, moduleGitDir);
144
+ return true;
145
+ }
146
+
147
+ export function fixModuleDirs(
148
+ repoRoot: string,
149
+ modules: Gitmodules,
150
+ moduleDirs: ModuleDirInfo[],
151
+ orphanModuleDirs: string[],
152
+ topLevelRoot: string,
153
+ ): Effect.Effect<Fix[], string> {
154
+ return Effect.tryPromise({
155
+ try: async () => {
156
+ const fixes: Fix[] = [];
157
+ const modulesRoot = resolve(await resolveGitDir(repoRoot), "modules");
158
+
159
+ // Establish each module's git dir (migrating existing data if any exists
160
+ // anywhere) before the orphan cleanup below runs, so nothing real gets deleted
161
+ // out from under a path rename.
162
+ for (const m of modules.list) {
163
+ const workingTreeDir = resolve(repoRoot, m.path);
164
+ const moduleGitDir = join(modulesRoot, m.path);
165
+ if (!existsSync(workingTreeDir)) continue;
166
+
167
+ const alreadyEstablished = existsSync(moduleGitDir);
168
+ let freshlyCloned = false;
169
+
170
+ if (!alreadyEstablished) {
171
+ const migrated = await migrateExistingGitDir(workingTreeDir, moduleGitDir, topLevelRoot);
172
+ if (migrated) {
173
+ fixes.push({ location: [".git", "modules", ...m.path.split("/")], action: "synced", detail: "migrated" });
174
+ } else {
175
+ await mkdir(dirname(moduleGitDir), { recursive: true });
176
+ await spawnGit(["clone", "--bare", m.url, moduleGitDir], repoRoot);
177
+ fixes.push({ location: [".git", "modules", ...m.path.split("/")], action: "synced", detail: "initialized" });
178
+ freshlyCloned = true;
179
+ }
180
+ }
181
+
182
+ if (freshlyCloned) {
183
+ const configFile = join(moduleGitDir, "config");
184
+ const worktree = relative(moduleGitDir, workingTreeDir);
185
+ await spawnGit(["config", "-f", configFile, "core.bare", "false"], repoRoot);
186
+ await spawnGit(["config", "-f", configFile, "core.worktree", worktree], repoRoot);
187
+ }
188
+
189
+ // A bare clone (whether just made above, or left over from a prior buggy
190
+ // run) never gets an index — populate it from HEAD whenever it's missing,
191
+ // not only right after we ourselves created it.
192
+ if (!existsSync(join(moduleGitDir, "index"))) {
193
+ const headCheck = Bun.spawn(["git", "--git-dir", moduleGitDir, "rev-parse", "--verify", "HEAD"], { stdout: "ignore", stderr: "ignore" });
194
+ if ((await headCheck.exited) !== 0) await spawnGit(["--git-dir", moduleGitDir, "fetch", "origin"], repoRoot);
195
+ await spawnGit(["--git-dir", moduleGitDir, "read-tree", "HEAD"], repoRoot);
196
+ fixes.push({ location: [".git", "modules", ...m.path.split("/")], action: "synced", detail: "populated index" });
197
+ }
198
+
199
+ // Always re-point the working tree's .git redirect — never assume an
200
+ // existing file is already correct.
201
+ const dotGit = join(workingTreeDir, ".git");
202
+ const rel = relative(workingTreeDir, moduleGitDir);
203
+ const desired = `gitdir: ${rel}\n`;
204
+ const current = existsSync(dotGit) && !statSync(dotGit).isDirectory()
205
+ ? await readFile(dotGit, "utf8")
206
+ : undefined;
207
+ if (current !== desired) {
208
+ await writeFile(dotGit, desired, "utf8");
209
+ fixes.push({ location: [...m.path.split("/"), ".git"], action: "synced" });
210
+ }
211
+ }
212
+
213
+ const gmByPath = new Map(modules.list.map((m) => [m.path, m]));
214
+
215
+ for (const d of moduleDirs) {
216
+ const gm = gmByPath.get(d.relativePath);
217
+ if (!gm && existsSync(join(modulesRoot, d.relativePath))) {
218
+ await rm(join(modulesRoot, d.relativePath), { recursive: true, force: true });
219
+ fixes.push({ location: [".git", "modules", ...d.relativePath.split("/")], action: "deleted" });
220
+ }
221
+ }
222
+
223
+ for (const rel of orphanModuleDirs) {
224
+ if (existsSync(join(modulesRoot, rel))) {
225
+ await rm(join(modulesRoot, rel), { recursive: true, force: true });
226
+ fixes.push({ location: [".git", "modules", ...rel.split("/")], action: "deleted" });
227
+ }
228
+ }
229
+
230
+ for (const m of modules.list) {
231
+ const moduleGitDir = join(modulesRoot, m.path);
232
+ if (!existsSync(moduleGitDir)) continue;
233
+ const configFile = join(moduleGitDir, "config");
234
+
235
+ const readConfig = (key: string) =>
236
+ Bun.spawn(["git", "config", "-f", configFile, key], { cwd: repoRoot, stdout: "pipe", stderr: "pipe" });
237
+
238
+ const [currentUrl, currentWorktree] = await Promise.all([
239
+ new Response(readConfig("remote.origin.url").stdout).text().then(t => t.trim()),
240
+ new Response(readConfig("core.worktree").stdout).text().then(t => t.trim()),
241
+ ]);
242
+
243
+ const worktree = relative(moduleGitDir, resolve(repoRoot, m.path));
244
+
245
+ if (currentUrl !== m.url) {
246
+ await spawnGit(["config", "-f", configFile, "remote.origin.url", m.url], repoRoot);
247
+ fixes.push({ location: [".git", "modules", ...m.path.split("/"), "config"], action: "synced", detail: "remote.origin.url" });
248
+ }
249
+ if (currentWorktree !== worktree) {
250
+ await spawnGit(["config", "-f", configFile, "core.worktree", worktree], repoRoot);
251
+ fixes.push({ location: [".git", "modules", ...m.path.split("/"), "config"], action: "synced", detail: "core.worktree" });
252
+ }
253
+ }
254
+
255
+ return fixes;
256
+ },
257
+ catch: (e) => e instanceof Error ? e.message : String(e),
258
+ });
259
+ }
260
+
261
+ export function fixIndexGitlinks(
262
+ repoRoot: string,
263
+ modules: Gitmodules,
264
+ currentIndex: IndexGitlink[],
265
+ ): Effect.Effect<Fix[], string> {
266
+ return Effect.tryPromise({
267
+ try: async () => {
268
+ const currentShas = new Map(currentIndex.map((e) => [e.path, e.commit]));
269
+ const validPaths = new Set(modules.list.map((m) => m.path));
270
+ const fixes: Fix[] = [];
271
+
272
+ for (const entry of currentIndex) {
273
+ if (!validPaths.has(entry.path)) {
274
+ await spawnGit(["update-index", "--force-remove", entry.path], repoRoot);
275
+ fixes.push({ location: [".git", "index"], action: "deleted", detail: entry.path });
276
+ }
277
+ }
278
+
279
+ for (const m of modules.list) {
280
+ const workingTree = resolve(repoRoot, m.path);
281
+ if (!existsSync(workingTree)) continue;
282
+
283
+ const shaProc = Bun.spawn(["git", "rev-parse", "HEAD"], { cwd: workingTree, stdout: "pipe", stderr: "pipe" });
284
+ const sha = (await new Response(shaProc.stdout).text()).trim();
285
+ if (!/^[0-9a-f]{40}$/.test(sha)) continue;
286
+
287
+ const current = currentShas.get(m.path);
288
+ await spawnGit(["update-index", "--add", "--cacheinfo", `160000,${sha},${m.path}`], repoRoot);
289
+ if (current !== sha) {
290
+ fixes.push({ location: [".git", "index"], action: "synced", detail: `${m.path}: ${sha.slice(0, 8)}` });
291
+ }
292
+ }
293
+
294
+ return fixes;
295
+ },
296
+ catch: (e) => e instanceof Error ? e.message : String(e),
297
+ });
298
+ }