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,157 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { homedir, platform, tmpdir } from "node:os";
4
+
5
+ const STORAGE_STATE_FILE = "storage_state.json";
6
+ const PROFILES_DIR = "profiles";
7
+ const DEFAULT_PROFILE_MARKER = ".default";
8
+
9
+ function _getConfigDir(): string {
10
+ const envOverride = process.env.GEMITERM_CONFIG_DIR;
11
+ if (envOverride) return envOverride;
12
+
13
+ if (platform() === "win32") {
14
+ const appData = process.env.APPDATA;
15
+ if (appData) return join(appData, "gemiterm");
16
+ }
17
+
18
+ return join(homedir(), "gemiterm");
19
+ }
20
+
21
+ function resolvePath(...parts: string[]): string {
22
+ return resolve(join(...parts));
23
+ }
24
+
25
+ function joinPath(...parts: string[]): string {
26
+ return join(...parts);
27
+ }
28
+
29
+ function dirnamePath(path: string): string {
30
+ return dirname(path);
31
+ }
32
+
33
+ function getConfigDir(): string {
34
+ return _getConfigDir();
35
+ }
36
+
37
+ function getProfilesDir(): string {
38
+ return join(getConfigDir(), PROFILES_DIR);
39
+ }
40
+
41
+ function getProfilePath(name: string): string {
42
+ return join(getProfilesDir(), name, STORAGE_STATE_FILE);
43
+ }
44
+
45
+ function getProfileDir(name: string): string {
46
+ return join(getProfilesDir(), name);
47
+ }
48
+
49
+ function getDefaultProfileMarkerPath(): string {
50
+ return join(getProfilesDir(), DEFAULT_PROFILE_MARKER);
51
+ }
52
+
53
+ function getTempFilePath(prefix: string, extension = ".tmp"): string {
54
+ const unique = `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
55
+ return join(tmpdir(), `${unique}${extension}`);
56
+ }
57
+
58
+ function isWSL(): boolean {
59
+ if (process.platform !== "linux") {
60
+ return false;
61
+ }
62
+ try {
63
+ if (existsSync("/proc/version")) {
64
+ const version = readFileSync("/proc/version", "utf-8").toLowerCase();
65
+ if (version.includes("microsoft") || version.includes("wsl")) {
66
+ return true;
67
+ }
68
+ }
69
+ } catch {
70
+ // ignore — fall through to env var check
71
+ }
72
+ const distro = process.env.WSL_DISTRO_NAME;
73
+ return typeof distro === "string" && distro.length > 0;
74
+ }
75
+
76
+ function getProjectRoot(importMetaUrl?: string): string {
77
+ const start = importMetaUrl
78
+ ? dirname(new URL(importMetaUrl).pathname.replace(/^\/([A-Za-z]:)/, "$1"))
79
+ : process.cwd();
80
+ let dir = start;
81
+ for (let i = 0; i < 32; i++) {
82
+ if (existsSync(join(dir, "package.json"))) {
83
+ return dir;
84
+ }
85
+ const parent = dirname(dir);
86
+ if (parent === dir) {
87
+ break;
88
+ }
89
+ dir = parent;
90
+ }
91
+ throw new Error(
92
+ `getProjectRoot: could not find a 'package.json' in any parent of ${start}`,
93
+ );
94
+ }
95
+
96
+ interface PackageJson {
97
+ name: string;
98
+ version: string;
99
+ [key: string]: unknown;
100
+ }
101
+
102
+ const PACKAGE_JSON_FALLBACK: PackageJson = { name: "gemiterm", version: "unknown" };
103
+
104
+ // Build-time constants injected by `scripts/build.ts` via Bun.build `define`.
105
+ // In dev/test runs they are ambient identifiers that resolve to `undefined`.
106
+ // The dead-code branch below (returning PACKAGE_JSON_FALLBACK) is what
107
+ // guarantees `typeof X === "string"` is false at runtime when no define was
108
+ // injected, and also keeps the dist binary small because Bun's bundler
109
+ // tree-shakes the unused literal when `__GEMITERM_VERSION__` is constant-folded.
110
+ declare const __GEMITERM_VERSION__: string | undefined;
111
+ declare const __GEMITERM_NAME__: string | undefined;
112
+
113
+ const BUILD_TIME_PACKAGE: PackageJson | null =
114
+ typeof __GEMITERM_VERSION__ === "string" && __GEMITERM_VERSION__.length > 0
115
+ ? {
116
+ name: typeof __GEMITERM_NAME__ === "string" && __GEMITERM_NAME__.length > 0
117
+ ? __GEMITERM_NAME__
118
+ : PACKAGE_JSON_FALLBACK.name,
119
+ version: __GEMITERM_VERSION__,
120
+ }
121
+ : null;
122
+
123
+ function getPackageJson(importMetaUrl?: string): PackageJson {
124
+ if (BUILD_TIME_PACKAGE !== null) {
125
+ return BUILD_TIME_PACKAGE;
126
+ }
127
+
128
+ try {
129
+ const root = getProjectRoot(importMetaUrl);
130
+ const raw = readFileSync(join(root, "package.json"), "utf-8");
131
+ const parsed = JSON.parse(raw);
132
+ if (parsed && typeof parsed === "object") {
133
+ return parsed as PackageJson;
134
+ }
135
+ } catch {
136
+ // fall through to fallback
137
+ }
138
+ return PACKAGE_JSON_FALLBACK;
139
+ }
140
+
141
+ export {
142
+ resolvePath,
143
+ joinPath,
144
+ dirnamePath,
145
+ getConfigDir,
146
+ getProfilesDir,
147
+ getProfilePath,
148
+ getProfileDir,
149
+ getDefaultProfileMarkerPath,
150
+ getTempFilePath,
151
+ isWSL,
152
+ getProjectRoot,
153
+ getPackageJson,
154
+ STORAGE_STATE_FILE,
155
+ PROFILES_DIR,
156
+ DEFAULT_PROFILE_MARKER,
157
+ };
@@ -0,0 +1,209 @@
1
+ import { ensureDir, existsFile, readJsonFile, writeTextFile, removeDir, renameDir } from "./io.ts";
2
+ import type { Cookie, ProfileStatus } from "../core/types.ts";
3
+ import {
4
+ getProfilePath,
5
+ getProfileDir,
6
+ getDefaultProfileMarkerPath,
7
+ } from "./path-utils.ts";
8
+ import {
9
+ getDefaultProfileName,
10
+ setDefaultProfileName,
11
+ listProfiles,
12
+ } from "./config.ts";
13
+
14
+ const COOKIE_EXPIRY_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;
15
+
16
+ interface StorageState {
17
+ cookies: Cookie[];
18
+ }
19
+
20
+ function validateCookies(cookies: Cookie[]): boolean {
21
+ const names = new Set(cookies.map((c) => c.name));
22
+ return names.has("__Secure-1PSID") && names.has("__Secure-1PSIDTS");
23
+ }
24
+
25
+ function getCookieExpiryTimestamp(cookies: Cookie[]): number | null {
26
+ for (const cookie of cookies) {
27
+ if (cookie.name === "__Secure-1PSIDTS" && cookie.expires > 0) {
28
+ return cookie.expires * 1000;
29
+ }
30
+ }
31
+ return null;
32
+ }
33
+
34
+ function checkCookieFreshness(cookies: Cookie[]): boolean {
35
+ for (const cookie of cookies) {
36
+ if (cookie.name === "__Secure-1PSIDTS" && cookie.expires > 0) {
37
+ const threshold = Date.now() + COOKIE_EXPIRY_THRESHOLD_MS;
38
+ if (cookie.expires * 1000 < threshold) return false;
39
+ }
40
+ }
41
+ return true;
42
+ }
43
+
44
+ export class CookieStorage {
45
+ save(profileName: string, cookies: Cookie[]): void {
46
+ const filePath = getProfilePath(profileName);
47
+ const state: StorageState = { cookies };
48
+ writeTextFile(filePath, JSON.stringify(state, null, 2));
49
+ }
50
+
51
+ load(profileName: string): Cookie[] {
52
+ const filePath = getProfilePath(profileName);
53
+ if (!existsFile(filePath)) {
54
+ throw new Error(
55
+ `No storage state found for profile '${profileName}'. Run 'gemiterm auth' to authenticate.`,
56
+ );
57
+ }
58
+ const state = readJsonFile<StorageState>(filePath);
59
+ return state.cookies ?? [];
60
+ }
61
+
62
+ delete(profileName: string): void {
63
+ const dir = getProfileDir(profileName);
64
+ removeDir(dir);
65
+ }
66
+
67
+ list(): string[] {
68
+ return listProfiles();
69
+ }
70
+ }
71
+
72
+ export class ProfileManager {
73
+ private readonly cookieStorage: CookieStorage;
74
+
75
+ constructor(cookieStorage?: CookieStorage) {
76
+ this.cookieStorage = cookieStorage ?? new CookieStorage();
77
+ }
78
+
79
+ create(profileName: string): void {
80
+ const dir = getProfileDir(profileName);
81
+ if (existsFile(dir)) {
82
+ throw new Error(`Profile '${profileName}' already exists.`);
83
+ }
84
+ const isFirst = listProfiles().length === 0;
85
+ ensureDir(dir);
86
+ if (isFirst) {
87
+ setDefaultProfileName(profileName);
88
+ }
89
+ }
90
+
91
+ delete(name: string): void {
92
+ this.cookieStorage.delete(name);
93
+ if (getDefaultProfileName() === name) {
94
+ const remaining = listProfiles();
95
+ if (remaining.length > 0) {
96
+ setDefaultProfileName(remaining[0]);
97
+ } else {
98
+ const marker = getDefaultProfileMarkerPath();
99
+ if (existsFile(marker)) {
100
+ removeDir(marker);
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ rename(oldName: string, newName: string): void {
107
+ const oldDir = getProfileDir(oldName);
108
+ const newDir = getProfileDir(newName);
109
+ if (!existsFile(oldDir)) {
110
+ throw new Error(`Profile '${oldName}' does not exist.`);
111
+ }
112
+ if (existsFile(newDir)) {
113
+ throw new Error(`Profile '${newName}' already exists.`);
114
+ }
115
+ renameDir(oldDir, newDir);
116
+ if (getDefaultProfileName() === oldName) {
117
+ setDefaultProfileName(newName);
118
+ }
119
+ }
120
+
121
+ setDefault(name: string): void {
122
+ if (!existsFile(getProfileDir(name))) {
123
+ throw new Error(`Profile '${name}' does not exist.`);
124
+ }
125
+ setDefaultProfileName(name);
126
+ }
127
+
128
+ getDefault(): string {
129
+ return getDefaultProfileName();
130
+ }
131
+
132
+ list(): string[] {
133
+ return listProfiles();
134
+ }
135
+
136
+ getStatus(name: string): ProfileStatus {
137
+ const defaultName = getDefaultProfileName();
138
+ const filePath = getProfilePath(name);
139
+ if (!existsFile(filePath)) {
140
+ return {
141
+ name,
142
+ exists: false,
143
+ isActive: false,
144
+ expiresAt: null,
145
+ isDefault: name === defaultName,
146
+ };
147
+ }
148
+ try {
149
+ const cookies = this.cookieStorage.load(name);
150
+ const isActive = validateCookies(cookies) && checkCookieFreshness(cookies);
151
+ const expiresMs = getCookieExpiryTimestamp(cookies);
152
+ let expiresAt: string | null = null;
153
+ if (expiresMs !== null) {
154
+ expiresAt = new Date(expiresMs).toISOString();
155
+ }
156
+ return {
157
+ name,
158
+ exists: true,
159
+ isActive,
160
+ expiresAt,
161
+ isDefault: name === defaultName,
162
+ };
163
+ } catch {
164
+ return {
165
+ name,
166
+ exists: true,
167
+ isActive: false,
168
+ expiresAt: null,
169
+ isDefault: name === defaultName,
170
+ };
171
+ }
172
+ }
173
+
174
+ getAllStatuses(): ProfileStatus[] {
175
+ const defaultName = getDefaultProfileName();
176
+ const profiles = listProfiles();
177
+ return profiles.map((name) => {
178
+ const status = this.getStatus(name);
179
+ return { ...status, isDefault: name === defaultName };
180
+ });
181
+ }
182
+
183
+ hasValidCookies(profileName: string): boolean {
184
+ try {
185
+ const cookies = this.cookieStorage.load(profileName);
186
+ return validateCookies(cookies) && checkCookieFreshness(cookies);
187
+ } catch {
188
+ return false;
189
+ }
190
+ }
191
+
192
+ loadCookiesForApi(profileName: string): { secure1psid: string; secure1psidts: string | null } {
193
+ const cookies = this.cookieStorage.load(profileName);
194
+ if (!checkCookieFreshness(cookies)) {
195
+ throw new Error(
196
+ `Session for profile '${profileName}' appears expired. Run 'gemiterm auth' to re-authenticate.`,
197
+ );
198
+ }
199
+ const map = new Map(cookies.map((c) => [c.name, c.value]));
200
+ const secure1psid = map.get("__Secure-1PSID");
201
+ if (!secure1psid) {
202
+ throw new Error(`Missing required cookie __Secure-1PSID for profile '${profileName}'.`);
203
+ }
204
+ return {
205
+ secure1psid,
206
+ secure1psidts: map.get("__Secure-1PSIDTS") ?? null,
207
+ };
208
+ }
209
+ }
@@ -0,0 +1,28 @@
1
+ import { GemitermError } from "../core/errors.ts";
2
+
3
+ const VALID_PROFILE_NAME_RE = /^[a-zA-Z0-9_-]+$/;
4
+
5
+ export function validateConversationId(id: string): void {
6
+ if (!id || id.trim().length === 0) {
7
+ throw new GemitermError("Conversation ID must not be empty.");
8
+ }
9
+ }
10
+
11
+ export function parseIsoDate(dateStr: string, fieldName: string): number {
12
+ const parsed = Date.parse(dateStr);
13
+ if (Number.isNaN(parsed)) {
14
+ throw new GemitermError(`Invalid ISO date for '${fieldName}': '${dateStr}'.`);
15
+ }
16
+ return parsed;
17
+ }
18
+
19
+ export function validateProfileName(name: string): void {
20
+ if (!name || name.trim().length === 0) {
21
+ throw new GemitermError("Profile name must not be empty.");
22
+ }
23
+ if (!VALID_PROFILE_NAME_RE.test(name)) {
24
+ throw new GemitermError(
25
+ `Profile name '${name}' contains invalid characters. Only alphanumeric characters, hyphens, and underscores are allowed.`,
26
+ );
27
+ }
28
+ }
File without changes
@@ -0,0 +1,135 @@
1
+ import chalk from "chalk";
2
+ import type { Cookie, AuthResult } from "../core/types.ts";
3
+ import type { Logger } from "../infrastructure/logger.ts";
4
+ import type { PlaywrightCliDriver } from "./playwright-cli-driver.ts";
5
+ import type { CookieMonitor } from "./cookie-monitor.ts";
6
+ import type { CookieStorage } from "../infrastructure/storage.ts";
7
+ import { ensureConfigDir, getDefaultProfileName } from "../infrastructure/config.ts";
8
+ import { validateProfileName } from "../infrastructure/validators.ts";
9
+
10
+ const GEMINI_AUTH_URL = "https://gemini.google.com/app";
11
+ const DEFAULT_AUTH_TIMEOUT_MS = 300_000;
12
+
13
+ export interface AuthServiceDeps {
14
+ driver: PlaywrightCliDriver;
15
+ cookieMonitor: CookieMonitor;
16
+ cookieStorage: CookieStorage;
17
+ logger: Logger;
18
+ }
19
+
20
+ export class AuthServiceTimeoutError extends Error {
21
+ constructor(timeoutMs: number) {
22
+ super(`Authentication timed out after ${timeoutMs}ms. No auth cookies detected.`);
23
+ this.name = "AuthServiceTimeoutError";
24
+ }
25
+ }
26
+
27
+ export class AuthService {
28
+ private readonly driver: PlaywrightCliDriver;
29
+ private readonly cookieMonitor: CookieMonitor;
30
+ private readonly cookieStorage: CookieStorage;
31
+ private readonly logger: Logger;
32
+
33
+ constructor(deps: AuthServiceDeps) {
34
+ this.driver = deps.driver;
35
+ this.cookieMonitor = deps.cookieMonitor;
36
+ this.cookieStorage = deps.cookieStorage;
37
+ this.logger = deps.logger;
38
+ }
39
+
40
+ async authenticate(profileName?: string): Promise<AuthResult> {
41
+ const name = profileName ?? getDefaultProfileName();
42
+ validateProfileName(name);
43
+
44
+ this.logger.info(`Starting authentication for profile: ${name}`);
45
+
46
+ try {
47
+ this.notifyUser(name);
48
+ await this.launchBrowser(name);
49
+
50
+ const cookies = await this.waitForLogin(name, DEFAULT_AUTH_TIMEOUT_MS);
51
+ await this.extractCookies(name, cookies);
52
+ const expiresAt = this.getCookieExpiry(cookies);
53
+ this.confirmAuthSuccess(cookies.length, expiresAt, cookies);
54
+ return { cookies, expiresAt };
55
+ } finally {
56
+ await this.closeBrowser(name);
57
+ }
58
+ }
59
+
60
+ notifyUser(profileName: string): void {
61
+ console.log(
62
+ chalk.cyan(`\n🔍 Opening headed browser → ${GEMINI_AUTH_URL} (profile: ${profileName})`),
63
+ );
64
+ console.log(
65
+ chalk.dim(" Log in manually — we'll auto-detect and close the browser when you're in.\n"),
66
+ );
67
+ }
68
+
69
+ async launchBrowser(profileName: string): Promise<void> {
70
+ this.logger.info(`Launching browser for profile: ${profileName}`);
71
+ await this.driver.openHeaded(GEMINI_AUTH_URL, profileName, profileName);
72
+ }
73
+
74
+ async waitForLogin(profileName: string, timeoutMs: number): Promise<Cookie[]> {
75
+ this.logger.info(`Waiting for auth cookies (timeout: ${timeoutMs}ms)...`);
76
+
77
+ return new Promise<Cookie[]>((resolve, reject) => {
78
+ let settled = false;
79
+
80
+ const settle = (fn: () => void) => {
81
+ if (settled) return;
82
+ settled = true;
83
+ fn();
84
+ };
85
+
86
+ const timeoutHandle = setTimeout(() => {
87
+ this.cookieMonitor.stop();
88
+ settle(() => reject(new AuthServiceTimeoutError(timeoutMs)));
89
+ }, timeoutMs);
90
+
91
+ this.cookieMonitor.start(
92
+ profileName,
93
+ (cookies) => {
94
+ clearTimeout(timeoutHandle);
95
+ settle(() => resolve(cookies));
96
+ },
97
+ timeoutMs,
98
+ );
99
+ });
100
+ }
101
+
102
+ async extractCookies(profileName: string, cookies: Cookie[]): Promise<void> {
103
+ ensureConfigDir();
104
+ this.logger.info(`Saving ${cookies.length} cookies for profile: ${profileName}`);
105
+ this.cookieStorage.save(profileName, cookies);
106
+ }
107
+
108
+ confirmAuthSuccess(cookieCount: number, expiresAt: Date | null, cookies: Cookie[] = []): void {
109
+ console.log(chalk.green(`\n✅ Login auto-detected — saving state…`));
110
+ console.log(chalk.green(`\nAuthentication successful! (${cookieCount} cookies captured)`));
111
+ if (expiresAt) {
112
+ console.log(chalk.dim(`Session expires: ${expiresAt.toLocaleString()}`));
113
+ }
114
+ const hasSid = cookies.some((c) => c.name === "__Secure-1PSID");
115
+ console.log(chalk.dim(` Has __Secure-1PSID: ${hasSid ? "✅" : "❌"}`));
116
+ }
117
+
118
+ async closeBrowser(profileName: string): Promise<void> {
119
+ this.logger.info(`Closing browser for profile: ${profileName}`);
120
+ try {
121
+ await this.driver.closeSession(profileName);
122
+ } catch (err) {
123
+ this.logger.warn(`Failed to close browser: ${err}`);
124
+ }
125
+ }
126
+
127
+ private getCookieExpiry(cookies: Cookie[]): Date | null {
128
+ for (const cookie of cookies) {
129
+ if (cookie.name === "__Secure-1PSIDTS" && cookie.expires > 0) {
130
+ return new Date(cookie.expires * 1000);
131
+ }
132
+ }
133
+ return null;
134
+ }
135
+ }
@@ -0,0 +1,159 @@
1
+ import type { Cookie } from "../core/types.ts";
2
+ import type { Logger } from "../infrastructure/logger.ts";
3
+ import type { PlaywrightCliDriver } from "./playwright-cli-driver.ts";
4
+
5
+ const POLL_INTERVAL_MS = 2_000;
6
+ const DEFAULT_TIMEOUT_MS = 300_000;
7
+
8
+ const REQUIRED_COOKIES = new Set(["__Secure-1PSID", "__Secure-1PSIDTS"]);
9
+
10
+ const LOGIN_PROBE_JS = `document.querySelector('a[href^="https://accounts.google.com/SignOutOptions"]') !== null`;
11
+
12
+ type CookiesFoundCallback = (cookies: Cookie[]) => void;
13
+
14
+ export class CookieMonitorTimeoutError extends Error {
15
+ constructor(timeoutMs: number) {
16
+ super(`Cookie monitor timed out after ${timeoutMs}ms`);
17
+ this.name = "CookieMonitorTimeoutError";
18
+ }
19
+ }
20
+
21
+ export class CookieMonitorNotStartedError extends Error {
22
+ constructor() {
23
+ super("Cookie monitor has not been started");
24
+ this.name = "CookieMonitorNotStartedError";
25
+ }
26
+ }
27
+
28
+ export interface CookieMonitorDeps {
29
+ driver: PlaywrightCliDriver;
30
+ logger: Logger;
31
+ }
32
+
33
+ export class CookieMonitor {
34
+ private pollingHandle: ReturnType<typeof setInterval> | null = null;
35
+ private timeoutHandle: ReturnType<typeof setTimeout> | null = null;
36
+ private _stopped = false;
37
+ private _started = false;
38
+ private readonly driver: PlaywrightCliDriver;
39
+ private readonly logger: Logger;
40
+
41
+ constructor(deps: CookieMonitorDeps) {
42
+ this.driver = deps.driver;
43
+ this.logger = deps.logger;
44
+ }
45
+
46
+ get isRunning(): boolean {
47
+ return this._started && !this._stopped;
48
+ }
49
+
50
+ async start(
51
+ session: string,
52
+ onCookiesFound: CookiesFoundCallback,
53
+ timeoutMs = DEFAULT_TIMEOUT_MS,
54
+ ): Promise<void> {
55
+ if (this._started) {
56
+ this.logger.warn("CookieMonitor already started, ignoring duplicate start call");
57
+ return;
58
+ }
59
+
60
+ this._started = true;
61
+ this._stopped = false;
62
+ this.logger.info(`Cookie monitor started for session '${session}' (timeout: ${timeoutMs}ms)`);
63
+
64
+ this.timeoutHandle = setTimeout(() => {
65
+ this.logger.warn("Cookie monitor hard timeout reached");
66
+ this.stop();
67
+ }, timeoutMs);
68
+ this.timeoutHandle.unref();
69
+
70
+ this.pollingHandle = setInterval(async () => {
71
+ if (this._stopped) return;
72
+ await this.poll(session, onCookiesFound);
73
+ }, POLL_INTERVAL_MS);
74
+ }
75
+
76
+ stop(): void {
77
+ if (this._stopped) return;
78
+ this._stopped = true;
79
+
80
+ if (this.pollingHandle !== null) {
81
+ clearInterval(this.pollingHandle);
82
+ this.pollingHandle = null;
83
+ }
84
+
85
+ if (this.timeoutHandle !== null) {
86
+ clearTimeout(this.timeoutHandle);
87
+ this.timeoutHandle = null;
88
+ }
89
+
90
+ this.logger.info("Cookie monitor stopped");
91
+ }
92
+
93
+ async checkLoggedIn(session: string): Promise<boolean> {
94
+ try {
95
+ const result = await this.driver.evalJs(session, LOGIN_PROBE_JS);
96
+ const loggedIn = result.trim() === "true";
97
+ if (loggedIn) {
98
+ this.logger.info("Login detected via sign-out link");
99
+ }
100
+ return loggedIn;
101
+ } catch (err) {
102
+ this.logger.debug(`checkLoggedIn eval failed: ${err}`);
103
+ return false;
104
+ }
105
+ }
106
+
107
+ async checkCookies(session: string): Promise<Cookie[]> {
108
+ try {
109
+ const cookies = await this.driver.cookieListFromState(session);
110
+ const authCookies = cookies.filter((c) => REQUIRED_COOKIES.has(c.name));
111
+
112
+ if (authCookies.length === REQUIRED_COOKIES.size) {
113
+ this.logger.info(`Found all required auth cookies: ${authCookies.map((c) => c.name).join(", ")}`);
114
+ return authCookies;
115
+ }
116
+
117
+ this.logger.debug(
118
+ `Auth cookies not yet complete (have: ${authCookies.map((c) => c.name).join(", ") ?? "none"})`,
119
+ );
120
+ return [];
121
+ } catch (err) {
122
+ this.logger.debug(`checkCookies failed: ${err}`);
123
+ return [];
124
+ }
125
+ }
126
+
127
+ private async poll(
128
+ session: string,
129
+ onCookiesFound: CookiesFoundCallback,
130
+ ): Promise<void> {
131
+ if (this._stopped) return;
132
+
133
+ let signal: string;
134
+ try {
135
+ signal = await this.driver.evalJs(session, LOGIN_PROBE_JS);
136
+ } catch {
137
+ return;
138
+ }
139
+
140
+ if (signal.trim() !== "true") {
141
+ return;
142
+ }
143
+
144
+ let cookies: Cookie[];
145
+ try {
146
+ cookies = await this.driver.cookieListFromState(session);
147
+ } catch {
148
+ return;
149
+ }
150
+
151
+ const authCookies = cookies.filter((c) => REQUIRED_COOKIES.has(c.name));
152
+ if (authCookies.length < REQUIRED_COOKIES.size) {
153
+ return;
154
+ }
155
+
156
+ this.stop();
157
+ onCookiesFound(authCookies);
158
+ }
159
+ }