gemiterm 2.0.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 (184) hide show
  1. package/.env +2 -0
  2. package/.github/workflows/manual-release.yml +34 -0
  3. package/.github/workflows/release.yml +198 -0
  4. package/.github/workflows/test.yml +29 -0
  5. package/.opencode/commands/opsx-apply.md +152 -0
  6. package/.opencode/commands/opsx-archive.md +157 -0
  7. package/.opencode/commands/opsx-explore.md +169 -0
  8. package/.opencode/commands/opsx-propose.md +104 -0
  9. package/.opencode/commands/opsx-sync.md +140 -0
  10. package/.opencode/commands/search-intelligently.md +9 -0
  11. package/.opencode/opencode.json +20 -0
  12. package/.opencode/skills/intellisearch/.version +1 -0
  13. package/.opencode/skills/intellisearch/SKILL.md +84 -0
  14. package/.opencode/skills/intellisearch/references/brave-search.md +67 -0
  15. package/.opencode/skills/intellisearch/references/ddg-search.md +79 -0
  16. package/.opencode/skills/intellisearch/references/deepwiki-tools.md +246 -0
  17. package/.opencode/skills/intellisearch/references/examples.md +97 -0
  18. package/.opencode/skills/intellisearch/references/gh-cli.md +143 -0
  19. package/.opencode/skills/intellisearch/references/google-search.md +40 -0
  20. package/.opencode/skills/intellisearch/references/search-workflow.md +143 -0
  21. package/.opencode/skills/intellisearch/references/workflow.md +115 -0
  22. package/.opencode/skills/openspec-apply-change/SKILL.md +159 -0
  23. package/.opencode/skills/openspec-archive-change/SKILL.md +117 -0
  24. package/.opencode/skills/openspec-explore/SKILL.md +287 -0
  25. package/.opencode/skills/openspec-propose/SKILL.md +111 -0
  26. package/.opencode/skills/openspec-sync-specs/SKILL.md +147 -0
  27. package/.serena/project.yml +133 -0
  28. package/AGENTS.md +136 -0
  29. package/CHANGELOG.md +3 -0
  30. package/README.md +245 -0
  31. package/bun.lock +105 -0
  32. package/docs/INSTALL.md +185 -0
  33. package/docs/PLAYWRIGHT_CLI_API.md +306 -0
  34. package/docs/python-release-for-reference.md +175 -0
  35. package/examples/sample_json_export.json +32 -0
  36. package/examples/sample_markdown_export.md +221 -0
  37. package/examples/usage_examples.sh +222 -0
  38. package/install.ps1 +264 -0
  39. package/install.sh +275 -0
  40. package/openspec/MAESTRO_MIGRATION.md +180 -0
  41. package/openspec/changes/archive/2026-06-08-command-spec-conformance/.openspec.yaml +2 -0
  42. package/openspec/changes/archive/2026-06-08-command-spec-conformance/design.md +194 -0
  43. package/openspec/changes/archive/2026-06-08-command-spec-conformance/proposal.md +52 -0
  44. package/openspec/changes/archive/2026-06-08-command-spec-conformance/specs/multi-profile-conversations/spec.md +133 -0
  45. package/openspec/changes/archive/2026-06-08-command-spec-conformance/tasks.md +95 -0
  46. package/openspec/changes/archive/2026-06-09-refactor-interactive-prompt-loop/.openspec.yaml +2 -0
  47. package/openspec/changes/archive/2026-06-09-refactor-interactive-prompt-loop/design.md +28 -0
  48. package/openspec/changes/archive/2026-06-09-refactor-interactive-prompt-loop/proposal.md +22 -0
  49. package/openspec/changes/archive/2026-06-09-refactor-interactive-prompt-loop/specs/interactive-prompt-loop/spec.md +35 -0
  50. package/openspec/changes/archive/2026-06-09-refactor-interactive-prompt-loop/tasks.md +28 -0
  51. package/openspec/changes/archive/2026-06-09-replace-gemini-api-placeholder-with-gemini-reverse/.openspec.yaml +2 -0
  52. package/openspec/changes/archive/2026-06-09-replace-gemini-api-placeholder-with-gemini-reverse/design.md +323 -0
  53. package/openspec/changes/archive/2026-06-09-replace-gemini-api-placeholder-with-gemini-reverse/proposal.md +122 -0
  54. package/openspec/changes/archive/2026-06-09-replace-gemini-api-placeholder-with-gemini-reverse/specs/no-capability-changes/spec.md +29 -0
  55. package/openspec/changes/archive/2026-06-09-replace-gemini-api-placeholder-with-gemini-reverse/tasks.md +172 -0
  56. package/openspec/changes/archive/2026-06-09-v2-install-migration/.openspec.yaml +2 -0
  57. package/openspec/changes/archive/2026-06-09-v2-install-migration/design.md +241 -0
  58. package/openspec/changes/archive/2026-06-09-v2-install-migration/proposal.md +41 -0
  59. package/openspec/changes/archive/2026-06-09-v2-install-migration/specs/v2-installer/spec.md +205 -0
  60. package/openspec/changes/archive/2026-06-09-v2-install-migration/tasks.md +97 -0
  61. package/openspec/changes/commander-cli-parser/.openspec.yaml +2 -0
  62. package/openspec/changes/commander-cli-parser/design.md +107 -0
  63. package/openspec/changes/commander-cli-parser/proposal.md +31 -0
  64. package/openspec/changes/commander-cli-parser/specs/cli/spec.md +48 -0
  65. package/openspec/changes/commander-cli-parser/tasks.md +33 -0
  66. package/openspec/changes/cross-platform-build-and-ci/.openspec.yaml +2 -0
  67. package/openspec/changes/cross-platform-build-and-ci/design.md +213 -0
  68. package/openspec/changes/cross-platform-build-and-ci/proposal.md +50 -0
  69. package/openspec/changes/cross-platform-build-and-ci/specs/path-and-file-mediation/spec.md +335 -0
  70. package/openspec/changes/cross-platform-build-and-ci/specs/release-pipeline/spec.md +95 -0
  71. package/openspec/changes/cross-platform-build-and-ci/tasks.md +164 -0
  72. package/openspec/config.yaml +20 -0
  73. package/openspec/specs/auth/spec.md +394 -0
  74. package/openspec/specs/cli/spec.md +108 -0
  75. package/openspec/specs/commands/spec.md +409 -0
  76. package/openspec/specs/configuration/spec.md +130 -0
  77. package/openspec/specs/conversations/spec.md +139 -0
  78. package/openspec/specs/domain-model/spec.md +128 -0
  79. package/openspec/specs/formatters/spec.md +158 -0
  80. package/openspec/specs/install-browser/spec.md +146 -0
  81. package/openspec/specs/interactive-prompt-loop/spec.md +41 -0
  82. package/openspec/specs/logger/spec.md +71 -0
  83. package/openspec/specs/mediator/spec.md +72 -0
  84. package/openspec/specs/multi-profile-conversations/spec.md +145 -0
  85. package/openspec/specs/path-utils/spec.md +101 -0
  86. package/openspec/specs/profiles/spec.md +94 -0
  87. package/openspec/specs/storage/spec.md +198 -0
  88. package/openspec/specs/testing/spec.md +237 -0
  89. package/openspec/specs/v2-installer/spec.md +205 -0
  90. package/openspec/specs/validators/spec.md +65 -0
  91. package/package.json +45 -0
  92. package/scripts/build.ts +88 -0
  93. package/scripts/clean-build.ps1 +4 -0
  94. package/scripts/clean-build.sh +5 -0
  95. package/scripts/install-browser.ps1 +5 -0
  96. package/scripts/install-browser.sh +6 -0
  97. package/scripts/lint-path-mediation.ps1 +65 -0
  98. package/scripts/lint-path-mediation.sh +40 -0
  99. package/src/cli/.gitkeep +0 -0
  100. package/src/cli/command-registry.ts +62 -0
  101. package/src/cli/commands/auth-command.ts +179 -0
  102. package/src/cli/commands/continue-command.ts +158 -0
  103. package/src/cli/commands/delete-command.ts +163 -0
  104. package/src/cli/commands/export-all-command.ts +243 -0
  105. package/src/cli/commands/export-command.ts +156 -0
  106. package/src/cli/commands/fetch-command.ts +176 -0
  107. package/src/cli/commands/help.ts +45 -0
  108. package/src/cli/commands/install-browser-command.ts +33 -0
  109. package/src/cli/commands/list-command.ts +226 -0
  110. package/src/cli/commands/new-command.ts +147 -0
  111. package/src/cli/commands/profile-command.ts +226 -0
  112. package/src/cli/commands/status-command.ts +59 -0
  113. package/src/cli/index.ts +213 -0
  114. package/src/cli/utils/interactive-prompt.ts +64 -0
  115. package/src/core/.gitkeep +0 -0
  116. package/src/core/command-handlers.ts +240 -0
  117. package/src/core/errors.ts +41 -0
  118. package/src/core/mediator.ts +58 -0
  119. package/src/core/query-handlers.ts +163 -0
  120. package/src/core/types.ts +43 -0
  121. package/src/infrastructure/.gitkeep +0 -0
  122. package/src/infrastructure/cli-table.ts +45 -0
  123. package/src/infrastructure/config.ts +52 -0
  124. package/src/infrastructure/formatters.ts +130 -0
  125. package/src/infrastructure/io.ts +161 -0
  126. package/src/infrastructure/logger.ts +41 -0
  127. package/src/infrastructure/path-utils.ts +157 -0
  128. package/src/infrastructure/storage.ts +209 -0
  129. package/src/infrastructure/validators.ts +28 -0
  130. package/src/services/.gitkeep +0 -0
  131. package/src/services/auth-service.ts +135 -0
  132. package/src/services/cookie-monitor.ts +159 -0
  133. package/src/services/cookie-storage-service.ts +74 -0
  134. package/src/services/gemini-client-wrapper.ts +239 -0
  135. package/src/services/install-browser-service.ts +205 -0
  136. package/src/services/playwright-cli-driver.ts +269 -0
  137. package/src/services/profile-auth-manager.ts +65 -0
  138. package/src/services/profile-service.ts +121 -0
  139. package/tests/cli/auth-command.test.ts +220 -0
  140. package/tests/cli/command-registry.test.ts +67 -0
  141. package/tests/cli/continue-command.test.ts +100 -0
  142. package/tests/cli/delete-command.test.ts +128 -0
  143. package/tests/cli/export-all-command.test.ts +375 -0
  144. package/tests/cli/install-browser-command.test.ts +48 -0
  145. package/tests/cli/list-command.test.ts +179 -0
  146. package/tests/cli/new-command.test.ts +139 -0
  147. package/tests/cli/status-command.test.ts +106 -0
  148. package/tests/core/query-handlers.test.ts +187 -0
  149. package/tests/fixtures/auth-fixtures.ts +98 -0
  150. package/tests/fixtures/chat-fixtures.ts +87 -0
  151. package/tests/infrastructure/cli-table.test.ts +107 -0
  152. package/tests/infrastructure/formatters.test.ts +202 -0
  153. package/tests/infrastructure/io.test.ts +197 -0
  154. package/tests/infrastructure/logger.test.ts +74 -0
  155. package/tests/infrastructure/storage.test.ts +335 -0
  156. package/tests/infrastructure/validators.test.ts +67 -0
  157. package/tests/integration/commands/auth.test.ts +186 -0
  158. package/tests/integration/commands/continue.test.ts +130 -0
  159. package/tests/integration/commands/delete.test.ts +140 -0
  160. package/tests/integration/commands/export.test.ts +266 -0
  161. package/tests/integration/commands/fetch.test.ts +238 -0
  162. package/tests/integration/commands/list.test.ts +281 -0
  163. package/tests/integration/commands/profile.test.ts +258 -0
  164. package/tests/integration/commands/status.test.ts +223 -0
  165. package/tests/integration/installer-script-shape.test.ts +67 -0
  166. package/tests/parity/compare-outputs.ts +359 -0
  167. package/tests/parity/test-commands-parity.ps1 +82 -0
  168. package/tests/parity/test-commands-parity.sh +61 -0
  169. package/tests/services/auth-service.test.ts +315 -0
  170. package/tests/services/cookie-monitor.test.ts +229 -0
  171. package/tests/services/cookie-storage-service.test.ts +230 -0
  172. package/tests/services/gemini-client-wrapper.test.ts +688 -0
  173. package/tests/services/install-browser-service.test.ts +83 -0
  174. package/tests/services/playwright-cli-driver.test.ts +468 -0
  175. package/tests/services/profile-auth-manager.test.ts +334 -0
  176. package/tests/services/profile-service.test.ts +224 -0
  177. package/tests/setup.ts +95 -0
  178. package/tests/smoke/smoke.test.ts +52 -0
  179. package/tests/unit/config.test.ts +159 -0
  180. package/tests/unit/errors.test.ts +119 -0
  181. package/tests/unit/formatters.test.ts +316 -0
  182. package/tests/unit/mediator.test.ts +152 -0
  183. package/tests/unit/path-utils.test.ts +289 -0
  184. package/tsconfig.json +16 -0
@@ -0,0 +1,74 @@
1
+ import type { Cookie } from "../core/types.ts";
2
+ import type { CookieStorage } from "../infrastructure/storage.ts";
3
+ import type { Logger } from "../infrastructure/logger.ts";
4
+
5
+ const COOKIE_EXPIRY_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;
6
+ const REQUIRED_COOKIE_NAMES = new Set(["__Secure-1PSID", "__Secure-1PSIDTS"]);
7
+
8
+ export interface LoadedCookies {
9
+ secure_1psid: string;
10
+ secure_1psidts: string | null;
11
+ }
12
+
13
+ export interface CookieStorageServiceDeps {
14
+ cookieStorage: CookieStorage;
15
+ logger: Logger;
16
+ }
17
+
18
+ export class CookieStorageService {
19
+ private readonly cookieStorage: CookieStorage;
20
+ private readonly logger: Logger;
21
+
22
+ constructor(deps: CookieStorageServiceDeps) {
23
+ this.cookieStorage = deps.cookieStorage;
24
+ this.logger = deps.logger;
25
+ }
26
+
27
+ loadCookiesForProfile(profileName: string): LoadedCookies {
28
+ const cookies = this.cookieStorage.load(profileName);
29
+ const map = new Map(cookies.map((c) => [c.name, c.value]));
30
+ const secure1psid = map.get("__Secure-1PSID");
31
+ if (!secure1psid) {
32
+ throw new Error(
33
+ `Missing required cookie __Secure-1PSID for profile '${profileName}'. Run 'gemiterm auth' to re-authenticate.`,
34
+ );
35
+ }
36
+ return {
37
+ secure_1psid: secure1psid,
38
+ secure_1psidts: map.get("__Secure-1PSIDTS") ?? null,
39
+ };
40
+ }
41
+
42
+ validateCookies(cookies: Cookie[]): boolean {
43
+ const names = new Set(cookies.map((c) => c.name));
44
+ for (const required of REQUIRED_COOKIE_NAMES) {
45
+ if (!names.has(required)) return false;
46
+ }
47
+ return true;
48
+ }
49
+
50
+ checkCookieFreshness(cookies: Cookie[]): boolean {
51
+ for (const cookie of cookies) {
52
+ if (cookie.name === "__Secure-1PSIDTS" && cookie.expires > 0) {
53
+ const threshold = Date.now() + COOKIE_EXPIRY_THRESHOLD_MS;
54
+ if (cookie.expires * 1000 < threshold) return false;
55
+ }
56
+ }
57
+ return true;
58
+ }
59
+
60
+ getCookieExpiry(cookies: Cookie[]): Date | null {
61
+ const expiresMs = this.getCookieExpiryMs(cookies);
62
+ if (expiresMs === null) return null;
63
+ return new Date(expiresMs);
64
+ }
65
+
66
+ private getCookieExpiryMs(cookies: Cookie[]): number | null {
67
+ for (const cookie of cookies) {
68
+ if (cookie.name === "__Secure-1PSIDTS" && cookie.expires > 0) {
69
+ return cookie.expires * 1000;
70
+ }
71
+ }
72
+ return null;
73
+ }
74
+ }
@@ -0,0 +1,239 @@
1
+ import {
2
+ type ChatInfo as RawChatInfo,
3
+ type ChatHistory,
4
+ type ChatSession,
5
+ type AvailableModel as RawAvailableModel,
6
+ type InitOptions,
7
+ type StartChatOptions,
8
+ type SendMessageOptions,
9
+ GeminiClient,
10
+ AuthError,
11
+ APIError,
12
+ TimeoutError,
13
+ UsageLimitExceeded,
14
+ ModelInvalid,
15
+ TemporarilyBlocked,
16
+ GeminiError,
17
+ } from "gemini-reverse";
18
+ import type { ChatInfo, Message } from "../core/types.ts";
19
+ import type { IGeminiClientService } from "../core/command-handlers.ts";
20
+ import type { IGeminiClientQueryService } from "../core/query-handlers.ts";
21
+ import type { Logger } from "../infrastructure/logger.ts";
22
+ import type { CookieStorageService } from "./cookie-storage-service.ts";
23
+ import { GeminiAPIError, AuthenticationError } from "../core/errors.ts";
24
+
25
+ interface GeminiClientConfig {
26
+ secure1psid: string;
27
+ secure1psidts?: string | null;
28
+ }
29
+
30
+ export class GeminiClientService
31
+ implements IGeminiClientService, IGeminiClientQueryService
32
+ {
33
+ private client: GeminiClient | null = null;
34
+ private initPromise: Promise<void> | null = null;
35
+ private initialized = false;
36
+ readonly logger: Logger;
37
+ readonly cookieStorageService?: CookieStorageService;
38
+ readonly profileName?: string;
39
+
40
+ constructor(config: GeminiClientConfig, logger: Logger, cookieStorageService?: CookieStorageService, profileName?: string) {
41
+ this.logger = logger;
42
+ this.cookieStorageService = cookieStorageService;
43
+ this.profileName = profileName;
44
+ this.client = new GeminiClient({
45
+ secure_1psid: config.secure1psid,
46
+ secure_1psidts: config.secure1psidts ?? null,
47
+ });
48
+ }
49
+
50
+ async init(): Promise<void> {
51
+ if (this.initialized) return;
52
+ if (this.initPromise) return this.initPromise;
53
+ this.initPromise = this.client!.init({
54
+ timeout: 300_000,
55
+ autoClose: false,
56
+ autoRefresh: true,
57
+ refreshInterval: 540_000,
58
+ });
59
+ await this.initPromise;
60
+ this.initialized = true;
61
+ }
62
+
63
+ private toDomainChatInfo(raw: RawChatInfo, profileName?: string): ChatInfo {
64
+ return {
65
+ id: raw.cid,
66
+ title: raw.title,
67
+ isPinned: raw.is_pinned,
68
+ timestamp: raw.timestamp * 1000,
69
+ ...(profileName ? { profile: profileName } : {}),
70
+ };
71
+ }
72
+
73
+ private toDomainMessages(history: ChatHistory, conversationId: string): Message[] {
74
+ return history.turns.map((turn) => ({
75
+ role: turn.role,
76
+ content: turn.text,
77
+ conversationId,
78
+ }));
79
+ }
80
+
81
+ private toDomainModelName(model: RawAvailableModel): string {
82
+ return model.display_name || model.model_name || model.model_id;
83
+ }
84
+
85
+ private translateError(e: unknown): GeminiAPIError | AuthenticationError {
86
+ if (e instanceof AuthError) {
87
+ return new AuthenticationError(
88
+ "Session expired or invalid. Please run 'gemiterm login' again.",
89
+ );
90
+ }
91
+ if (e instanceof TimeoutError) {
92
+ return new GeminiAPIError("Request to Gemini timed out");
93
+ }
94
+ if (e instanceof UsageLimitExceeded) {
95
+ return new GeminiAPIError("Gemini usage limit reached; try again later or switch model");
96
+ }
97
+ if (e instanceof TemporarilyBlocked) {
98
+ return new GeminiAPIError("Temporarily blocked by Gemini; try a proxy or wait");
99
+ }
100
+ if (e instanceof ModelInvalid) {
101
+ return new GeminiAPIError("Model is invalid or unavailable");
102
+ }
103
+ if (e instanceof APIError) {
104
+ const err = new GeminiAPIError(e.message);
105
+ err.cause = e;
106
+ return err;
107
+ }
108
+ if (e instanceof GeminiError) {
109
+ const err = new GeminiAPIError(e.message);
110
+ err.cause = e;
111
+ return err;
112
+ }
113
+ return new GeminiAPIError("Unexpected error: " + String(e));
114
+ }
115
+
116
+ forProfile(profileName: string): GeminiClientService {
117
+ if (!this.cookieStorageService) {
118
+ throw new Error("CookieStorageService is required for forProfile");
119
+ }
120
+ const cookies = this.cookieStorageService.loadCookiesForProfile(profileName);
121
+ return new GeminiClientService(
122
+ { secure1psid: cookies.secure_1psid, secure1psidts: cookies.secure_1psidts },
123
+ this.logger,
124
+ this.cookieStorageService,
125
+ profileName,
126
+ );
127
+ }
128
+
129
+ async profileHasConversation(profileName: string, conversationId: string): Promise<boolean> {
130
+ try {
131
+ const profileClient = this.forProfile(profileName);
132
+ const chats = await profileClient.listChats();
133
+ return chats.some((chat) => chat.id === conversationId);
134
+ } catch {
135
+ return false;
136
+ }
137
+ }
138
+
139
+ async listChats(options?: {
140
+ limit?: number;
141
+ offset?: number;
142
+ search?: string;
143
+ }): Promise<ChatInfo[]> {
144
+ await this.init();
145
+ try {
146
+ const raw = this.client!.listChats();
147
+ let chats: ChatInfo[] = (raw ?? []).map((c) => this.toDomainChatInfo(c, this.profileName));
148
+
149
+ if (options?.search) {
150
+ const query = options.search.toLowerCase();
151
+ chats = chats.filter((c) => c.title.toLowerCase().includes(query));
152
+ }
153
+
154
+ chats.sort((a, b) => b.timestamp - a.timestamp);
155
+
156
+ if (options?.offset) {
157
+ chats = chats.slice(options.offset);
158
+ }
159
+ if (options?.limit) {
160
+ chats = chats.slice(0, options.limit);
161
+ }
162
+
163
+ return chats;
164
+ } catch (e) {
165
+ const err = this.translateError(e);
166
+ this.logger.debug(`listChats failed: ${e}`);
167
+ throw err;
168
+ }
169
+ }
170
+
171
+ async fetchChat(conversationId: string): Promise<Message[]> {
172
+ await this.init();
173
+ try {
174
+ const history = await this.client!.readChat(conversationId);
175
+ if (!history) return [];
176
+ return this.toDomainMessages(history, conversationId);
177
+ } catch (e) {
178
+ const err = this.translateError(e);
179
+ this.logger.debug(`fetchChat failed: ${e}`);
180
+ throw err;
181
+ }
182
+ }
183
+
184
+ async deleteChat(conversationId: string): Promise<void> {
185
+ await this.init();
186
+ try {
187
+ await this.client!.deleteChat(conversationId);
188
+ } catch (e) {
189
+ const err = this.translateError(e);
190
+ this.logger.debug(`deleteChat failed: ${e}`);
191
+ throw err;
192
+ }
193
+ }
194
+
195
+ async sendMessage(conversationId: string, message: string): Promise<string> {
196
+ await this.init();
197
+ try {
198
+ const session = this.client!.startChat({ cid: conversationId });
199
+ const output = await session.sendMessage({ prompt: message });
200
+ return output.text.toString();
201
+ } catch (e) {
202
+ const err = this.translateError(e);
203
+ this.logger.debug(`sendMessage failed: ${e}`);
204
+ throw err;
205
+ }
206
+ }
207
+
208
+ async startNewChat(message: string): Promise<{ response: string; conversationId: string }> {
209
+ await this.init();
210
+ try {
211
+ const session = this.client!.startChat();
212
+ const output = await session.sendMessage({ prompt: message });
213
+ return {
214
+ response: output.text.toString(),
215
+ conversationId: session.cid,
216
+ };
217
+ } catch (e) {
218
+ const err = this.translateError(e);
219
+ this.logger.debug(`startNewChat failed: ${e}`);
220
+ throw err;
221
+ }
222
+ }
223
+
224
+ async listModels(): Promise<string[]> {
225
+ await this.init();
226
+ try {
227
+ const raw = this.client!.listModels();
228
+ return (raw ?? []).map((m) => this.toDomainModelName(m));
229
+ } catch (e) {
230
+ const err = this.translateError(e);
231
+ this.logger.debug(`listModels failed: ${e}`);
232
+ throw err;
233
+ }
234
+ }
235
+
236
+ isAuthenticated(): boolean {
237
+ return this.initialized;
238
+ }
239
+ }
@@ -0,0 +1,205 @@
1
+ import { existsFile, safeReadTextFile } from "../infrastructure/io.ts";
2
+ import { isWSL } from "../infrastructure/path-utils.ts";
3
+ import { join } from "node:path";
4
+ import { spawn } from "node:child_process";
5
+ import { PlaywrightCliError } from "./playwright-cli-driver.ts";
6
+ import { Logger } from "../infrastructure/logger.ts";
7
+
8
+ const BROWSER_NAMES = ["chromium"];
9
+
10
+ interface BrowserCheckResult {
11
+ found: boolean;
12
+ browserName: string;
13
+ path?: string;
14
+ }
15
+
16
+ export class InstallBrowserError extends Error {
17
+ constructor(message: string, public readonly cause?: Error) {
18
+ super(message);
19
+ this.name = "InstallBrowserError";
20
+ }
21
+ }
22
+
23
+ interface WindowsKnownDirs {
24
+ programFiles: string;
25
+ localAppData: string;
26
+ }
27
+
28
+ function getWindowsKnownDirs(): WindowsKnownDirs {
29
+ const programFiles = process.env["ProgramFiles(x86)"] ?? process.env["ProgramFiles"] ?? "C:\\Program Files";
30
+ const localAppData = process.env["LOCALAPPDATA"] ?? join(process.env["USERPROFILE"] ?? "C:\\Users", "AppData", "Local");
31
+ return { programFiles, localAppData };
32
+ }
33
+
34
+ export class InstallBrowserService {
35
+ private readonly logger: Logger;
36
+
37
+ constructor(logger?: Logger) {
38
+ this.logger = logger ?? new Logger("install-browser-service");
39
+ }
40
+
41
+ async install(): Promise<void> {
42
+ this.logger.info("Checking for existing browser installations...");
43
+
44
+ const existing = this.findSystemBrowser();
45
+ if (existing.found) {
46
+ this.logger.info(`Found existing browser: ${existing.browserName} at ${existing.path}`);
47
+ console.log(`Using existing ${existing.browserName} installation.`);
48
+ return;
49
+ }
50
+
51
+ console.log("No suitable browser found. Installing Chromium via Playwright...");
52
+ this.logger.info("Running: bunx @playwright/cli install chromium");
53
+
54
+ try {
55
+ const output = await this.runInstall();
56
+ this.logger.info(`Browser installation output: ${output}`);
57
+ console.log("Chromium installed successfully.");
58
+ } catch (error) {
59
+ if (error instanceof PlaywrightCliError) {
60
+ throw new InstallBrowserError(error.message, error);
61
+ }
62
+ throw new InstallBrowserError(
63
+ `Failed to install Chromium: ${error instanceof Error ? error.message : String(error)}`,
64
+ error instanceof Error ? error : undefined,
65
+ );
66
+ }
67
+ }
68
+
69
+ findSystemBrowser(): BrowserCheckResult {
70
+ if (process.platform === "win32") {
71
+ return this.findWindowsBrowser();
72
+ }
73
+ if (process.platform === "linux") {
74
+ return this.findLinuxBrowser();
75
+ }
76
+ return { found: false, browserName: "none" };
77
+ }
78
+
79
+ private findWindowsBrowser(): BrowserCheckResult {
80
+ const { programFiles, localAppData } = getWindowsKnownDirs();
81
+ const candidates = [
82
+ { name: "Microsoft Edge", paths: [
83
+ join(localAppData, "Microsoft", "Edge", "Application", "msedge.exe"),
84
+ join(programFiles, "Microsoft", "Edge", "Application", "msedge.exe"),
85
+ ]},
86
+ { name: "Google Chrome", paths: [
87
+ join(localAppData, "Google", "Chrome", "Application", "chrome.exe"),
88
+ join(programFiles, "Google", "Chrome", "Application", "chrome.exe"),
89
+ ]},
90
+ ];
91
+
92
+ for (const candidate of candidates) {
93
+ for (const path of candidate.paths) {
94
+ if (existsFile(path)) {
95
+ return { found: true, browserName: candidate.name, path };
96
+ }
97
+ }
98
+ }
99
+
100
+ return { found: false, browserName: "none" };
101
+ }
102
+
103
+ private findLinuxBrowser(): BrowserCheckResult {
104
+ const candidates = [
105
+ { name: "Google Chrome", path: "/usr/bin/google-chrome" },
106
+ { name: "Google Chrome (Beta)", path: "/usr/bin/google-chrome-beta" },
107
+ { name: "Chromium", path: "/usr/bin/chromium" },
108
+ { name: "Chromium Browser", path: "/usr/bin/chromium-browser" },
109
+ { name: "Microsoft Edge", path: "/usr/bin/microsoft-edge" },
110
+ ];
111
+
112
+ for (const candidate of candidates) {
113
+ if (existsFile(candidate.path)) {
114
+ return { found: true, browserName: candidate.name, path: candidate.path };
115
+ }
116
+ }
117
+
118
+ if (isWSL()) {
119
+ return this.findWslBrowser();
120
+ }
121
+
122
+ return { found: false, browserName: "none" };
123
+ }
124
+
125
+ private findWslBrowser(): BrowserCheckResult {
126
+ const windowsRoot = this.getWslWindowsRoot();
127
+ if (!windowsRoot) {
128
+ return { found: false, browserName: "none" };
129
+ }
130
+
131
+ const edgePath = join(windowsRoot, "Program Files (x86)", "Microsoft", "Edge", "Application", "msedge.exe");
132
+ const chromePath = join(windowsRoot, "Program Files", "Google", "Chrome", "Application", "chrome.exe");
133
+
134
+ if (existsFile(edgePath)) {
135
+ return { found: true, browserName: "Microsoft Edge (Windows via WSL)", path: edgePath };
136
+ }
137
+ if (existsFile(chromePath)) {
138
+ return { found: true, browserName: "Google Chrome (Windows via WSL)", path: chromePath };
139
+ }
140
+
141
+ return { found: false, browserName: "none" };
142
+ }
143
+
144
+ private getWslWindowsRoot(): string | null {
145
+ const mountOutput = safeReadTextFile("/proc/mounts");
146
+ if (!mountOutput) {
147
+ return null;
148
+ }
149
+ const lines = mountOutput.split("\n");
150
+ for (const line of lines) {
151
+ if (line.includes("9p") && line.includes("drvfs")) {
152
+ const parts = line.split(/\s+/);
153
+ if (parts.length >= 2) {
154
+ const mountPoint = parts[1];
155
+ if (mountPoint.endsWith("/")) {
156
+ return mountPoint.slice(0, -1);
157
+ }
158
+ return mountPoint;
159
+ }
160
+ }
161
+ }
162
+ return null;
163
+ }
164
+
165
+ private async runInstall(): Promise<string> {
166
+ return new Promise<string>((resolve, reject) => {
167
+ const proc = spawn("bunx", ["@playwright/cli", "install", ...BROWSER_NAMES], {
168
+ stdio: ["ignore", "pipe", "pipe"],
169
+ shell: process.platform === "win32",
170
+ env: { ...process.env },
171
+ });
172
+
173
+ const stdoutChunks: Buffer[] = [];
174
+ const stderrChunks: Buffer[] = [];
175
+
176
+ proc.stdout.on("data", (chunk: Buffer) => {
177
+ stdoutChunks.push(chunk);
178
+ const text = chunk.toString("utf-8");
179
+ this.logger.debug(text.trimEnd());
180
+ });
181
+
182
+ proc.stderr.on("data", (chunk: Buffer) => {
183
+ stderrChunks.push(chunk);
184
+ const text = chunk.toString("utf-8");
185
+ this.logger.debug(text.trimEnd());
186
+ });
187
+
188
+ proc.on("error", (err: Error) => {
189
+ reject(new InstallBrowserError(`Failed to spawn install process: ${err.message}`, err));
190
+ });
191
+
192
+ proc.on("close", (code) => {
193
+ const stdout = Buffer.concat(stdoutChunks).toString("utf-8").trim();
194
+ const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
195
+
196
+ if (code !== 0) {
197
+ reject(new PlaywrightCliError("install chromium", code ?? -1, stderr));
198
+ return;
199
+ }
200
+
201
+ resolve(stdout);
202
+ });
203
+ });
204
+ }
205
+ }