@algochad/archcoder 2.0.2

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 (157) hide show
  1. package/README.md +113 -0
  2. package/bin/cli-entry.js +55 -0
  3. package/bin/cli-output.js +145 -0
  4. package/bin/cli.js +5108 -0
  5. package/bin/cli.test.js +56 -0
  6. package/dist/apple-touch-icon-120x120.png +0 -0
  7. package/dist/apple-touch-icon-152x152.png +0 -0
  8. package/dist/apple-touch-icon-167x167.png +0 -0
  9. package/dist/apple-touch-icon-180x180.png +0 -0
  10. package/dist/apple-touch-icon.png +0 -0
  11. package/dist/apple-touch-icon.svg +67 -0
  12. package/dist/assets/MultiRunWindow-BZp3MjJP.js +1 -0
  13. package/dist/assets/SettingsWindow-DoGYXpX7.js +1 -0
  14. package/dist/assets/TerminalView-BN7BR5Ff.js +3 -0
  15. package/dist/assets/TimelineDialog-ZQ33oVQR.js +1 -0
  16. package/dist/assets/ToolOutputDialog-Blv3pnug.js +16 -0
  17. package/dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  18. package/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  19. package/dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  20. package/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  21. package/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
  22. package/dist/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
  23. package/dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
  24. package/dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
  25. package/dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
  26. package/dist/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
  27. package/dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
  28. package/dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
  29. package/dist/assets/index-CtCEGYrr.css +1 -0
  30. package/dist/assets/index-o_d2wtWC.js +48 -0
  31. package/dist/assets/main-5QGBtzdq.css +1 -0
  32. package/dist/assets/main-B6oiMU86.js +8033 -0
  33. package/dist/assets/vendor--DbVqbJpV.css +1 -0
  34. package/dist/assets/vendor-.bun-HTKwyaEM.js +10086 -0
  35. package/dist/assets/wasm-CG6Dc4jp.js +1 -0
  36. package/dist/assets/worker-bqd4RMrj.js +155 -0
  37. package/dist/favicon-16.png +0 -0
  38. package/dist/favicon-32.png +0 -0
  39. package/dist/favicon.png +0 -0
  40. package/dist/favicon.svg +67 -0
  41. package/dist/index.html +533 -0
  42. package/dist/logo-dark-192x192.png +0 -0
  43. package/dist/logo-dark-512x512.svg +16 -0
  44. package/dist/logo-light-192x192.png +0 -0
  45. package/dist/logo-light-512x512.svg +16 -0
  46. package/dist/pwa-192.png +0 -0
  47. package/dist/pwa-512.png +0 -0
  48. package/dist/pwa-maskable-192.png +0 -0
  49. package/dist/pwa-maskable-512.png +0 -0
  50. package/dist/site.webmanifest +22 -0
  51. package/dist/sw.js +1 -0
  52. package/package.json +107 -0
  53. package/public/apple-touch-icon-120x120.png +0 -0
  54. package/public/apple-touch-icon-152x152.png +0 -0
  55. package/public/apple-touch-icon-167x167.png +0 -0
  56. package/public/apple-touch-icon-180x180.png +0 -0
  57. package/public/apple-touch-icon.png +0 -0
  58. package/public/apple-touch-icon.svg +67 -0
  59. package/public/favicon-16.png +0 -0
  60. package/public/favicon-32.png +0 -0
  61. package/public/favicon.png +0 -0
  62. package/public/favicon.svg +67 -0
  63. package/public/logo-dark-192x192.png +0 -0
  64. package/public/logo-dark-512x512.svg +16 -0
  65. package/public/logo-light-192x192.png +0 -0
  66. package/public/logo-light-512x512.svg +16 -0
  67. package/public/pwa-192.png +0 -0
  68. package/public/pwa-512.png +0 -0
  69. package/public/pwa-maskable-192.png +0 -0
  70. package/public/pwa-maskable-512.png +0 -0
  71. package/public/site.webmanifest +22 -0
  72. package/server/TERMINAL_INPUT_WS_PROTOCOL.md +44 -0
  73. package/server/index.d.ts +37 -0
  74. package/server/index.js +14694 -0
  75. package/server/lib/cloudflare-tunnel.js +650 -0
  76. package/server/lib/git/DOCUMENTATION.md +146 -0
  77. package/server/lib/git/credentials.js +74 -0
  78. package/server/lib/git/identity-storage.js +110 -0
  79. package/server/lib/git/index.js +6 -0
  80. package/server/lib/git/service.js +3117 -0
  81. package/server/lib/github/DOCUMENTATION.md +170 -0
  82. package/server/lib/github/auth.js +307 -0
  83. package/server/lib/github/device-flow.js +50 -0
  84. package/server/lib/github/index.js +24 -0
  85. package/server/lib/github/octokit.js +10 -0
  86. package/server/lib/github/pr-status.js +478 -0
  87. package/server/lib/github/repo/index.js +55 -0
  88. package/server/lib/installer/desktop.js +289 -0
  89. package/server/lib/installer/download.js +208 -0
  90. package/server/lib/installer/index.js +45 -0
  91. package/server/lib/installer/platform.js +100 -0
  92. package/server/lib/notifications/DOCUMENTATION.md +61 -0
  93. package/server/lib/notifications/index.js +1 -0
  94. package/server/lib/notifications/message.js +49 -0
  95. package/server/lib/notifications/message.test.js +59 -0
  96. package/server/lib/opencode/DOCUMENTATION.md +59 -0
  97. package/server/lib/opencode/agents.js +634 -0
  98. package/server/lib/opencode/auth.js +81 -0
  99. package/server/lib/opencode/commands.js +339 -0
  100. package/server/lib/opencode/index.js +66 -0
  101. package/server/lib/opencode/mcp.js +206 -0
  102. package/server/lib/opencode/providers.js +96 -0
  103. package/server/lib/opencode/shared.js +527 -0
  104. package/server/lib/opencode/skills.js +480 -0
  105. package/server/lib/opencode/tunnel-auth.js +591 -0
  106. package/server/lib/opencode/ui-auth.js +510 -0
  107. package/server/lib/package-manager.js +505 -0
  108. package/server/lib/quota/DOCUMENTATION.md +55 -0
  109. package/server/lib/quota/index.js +24 -0
  110. package/server/lib/quota/providers/claude.js +107 -0
  111. package/server/lib/quota/providers/codex.js +113 -0
  112. package/server/lib/quota/providers/copilot.js +165 -0
  113. package/server/lib/quota/providers/google/api.js +92 -0
  114. package/server/lib/quota/providers/google/auth.js +108 -0
  115. package/server/lib/quota/providers/google/index.js +124 -0
  116. package/server/lib/quota/providers/google/transforms.js +109 -0
  117. package/server/lib/quota/providers/index.js +152 -0
  118. package/server/lib/quota/providers/interface.js +55 -0
  119. package/server/lib/quota/providers/kimi.js +108 -0
  120. package/server/lib/quota/providers/minimax-cn-coding-plan.js +15 -0
  121. package/server/lib/quota/providers/minimax-coding-plan.js +15 -0
  122. package/server/lib/quota/providers/minimax-shared.js +136 -0
  123. package/server/lib/quota/providers/nanogpt.js +124 -0
  124. package/server/lib/quota/providers/ollama-cloud.js +112 -0
  125. package/server/lib/quota/providers/openai.js +91 -0
  126. package/server/lib/quota/providers/openrouter.js +92 -0
  127. package/server/lib/quota/providers/zai.js +91 -0
  128. package/server/lib/quota/utils/auth.js +46 -0
  129. package/server/lib/quota/utils/formatters.js +76 -0
  130. package/server/lib/quota/utils/index.js +10 -0
  131. package/server/lib/quota/utils/transformers.js +55 -0
  132. package/server/lib/skills-catalog/DOCUMENTATION.md +178 -0
  133. package/server/lib/skills-catalog/cache.js +32 -0
  134. package/server/lib/skills-catalog/clawdhub/api.js +158 -0
  135. package/server/lib/skills-catalog/clawdhub/index.js +30 -0
  136. package/server/lib/skills-catalog/clawdhub/install.js +238 -0
  137. package/server/lib/skills-catalog/clawdhub/scan.js +113 -0
  138. package/server/lib/skills-catalog/curated-sources.js +21 -0
  139. package/server/lib/skills-catalog/git.js +77 -0
  140. package/server/lib/skills-catalog/index.js +42 -0
  141. package/server/lib/skills-catalog/install.js +294 -0
  142. package/server/lib/skills-catalog/scan.js +221 -0
  143. package/server/lib/skills-catalog/source.js +85 -0
  144. package/server/lib/terminal/DOCUMENTATION.md +114 -0
  145. package/server/lib/terminal/index.js +12 -0
  146. package/server/lib/terminal/input-ws-protocol.js +66 -0
  147. package/server/lib/terminal/input-ws-protocol.test.js +138 -0
  148. package/server/lib/tts/DOCUMENTATION.md +134 -0
  149. package/server/lib/tts/index.js +16 -0
  150. package/server/lib/tts/service.js +162 -0
  151. package/server/lib/tts/summarization.js +171 -0
  152. package/server/lib/tunnels/index.js +166 -0
  153. package/server/lib/tunnels/providers/cloudflare.js +260 -0
  154. package/server/lib/tunnels/registry.js +51 -0
  155. package/server/lib/tunnels/types.js +219 -0
  156. package/server/lib/utils/lru.js +107 -0
  157. package/server/lib/utils/sse.js +121 -0
@@ -0,0 +1,170 @@
1
+ # GitHub Module Documentation
2
+
3
+ ## Purpose
4
+
5
+ - This module owns GitHub auth, Octokit access, repo resolution, and Pull Request status resolution for OpenChamber.
6
+ - From user perspective, this is the layer that lets the app know which PR belongs to a local branch and keeps that UI feeling current.
7
+
8
+ ## Entrypoints and structure
9
+
10
+ - `packages/web/server/lib/github/index.js`: public server entrypoint.
11
+ - `packages/web/server/lib/github/auth.js`: auth storage, multi-account support, client id, scope config.
12
+ - `packages/web/server/lib/github/device-flow.js`: OAuth device flow.
13
+ - `packages/web/server/lib/github/octokit.js`: Octokit factory for the current auth.
14
+ - `packages/web/server/lib/github/repo/index.js`: remote URL parsing and directory-to-repo resolution.
15
+ - `packages/web/server/lib/github/pr-status.js`: PR lookup across remotes, forks, and upstreams.
16
+ - `packages/web/server/index.js`: API route layer that calls this module.
17
+ - `packages/web/src/api/github.ts`: web client wrapper for GitHub endpoints.
18
+
19
+ ## Public exports
20
+
21
+ ### Auth
22
+
23
+ - `getGitHubAuth()`: current auth entry.
24
+ - `getGitHubAuthAccounts()`: all configured accounts.
25
+ - `setGitHubAuth({ accessToken, scope, tokenType, user, accountId })`: save or update account.
26
+ - `activateGitHubAuth(accountId)`: switch active account.
27
+ - `clearGitHubAuth()`: clear current account.
28
+ - `getGitHubClientId()`: resolve client id.
29
+ - `getGitHubScopes()`: resolve scopes.
30
+ - `GITHUB_AUTH_FILE`: auth file path.
31
+
32
+ ### Device flow
33
+
34
+ - `startDeviceFlow({ clientId, scope })`: request device code.
35
+ - `exchangeDeviceCode({ clientId, deviceCode })`: poll for access token.
36
+
37
+ ### Octokit
38
+
39
+ - `getOctokitOrNull()`: current Octokit or `null`.
40
+
41
+ ### Repo
42
+
43
+ - `parseGitHubRemoteUrl(raw)`: parse SSH or HTTPS remote URL into `{ owner, repo, url }`.
44
+ - `resolveGitHubRepoFromDirectory(directory, remoteName)`: resolve GitHub repo from a local git remote.
45
+
46
+ ## Auth storage and config
47
+
48
+ - Auth storage: `~/.config/openchamber/github-auth.json`
49
+ - Writes are atomic and file mode is `0o600`.
50
+ - Client ID resolution order: `OPENCHAMBER_GITHUB_CLIENT_ID` -> `settings.json` -> default.
51
+ - Scope resolution order: `OPENCHAMBER_GITHUB_SCOPES` -> `settings.json` -> default.
52
+ - Account id resolution order: explicit `accountId` -> user login -> user id -> token prefix.
53
+
54
+ ## PR integration overview
55
+
56
+ - The UI asks `github.prStatus(directory, branch, remote?)` from `packages/web/src/api/github.ts`.
57
+ - That hits `GET /api/github/pr/status` in `packages/web/server/index.js`.
58
+ - The route calls `resolveGitHubPrStatus(...)` in `packages/web/server/lib/github/pr-status.js`.
59
+ - The resolver finds the most likely repo and PR for a local branch.
60
+ - The route then enriches that result with checks, mergeability, and permission-related fields.
61
+ - The client caches and shares the result between sidebar and Git view.
62
+
63
+ ## Consumers of PR data
64
+
65
+ - `packages/ui/src/components/session/SessionSidebar.tsx` reads all PR entries and maps them to `directory::branch`.
66
+ - `packages/ui/src/components/session/sidebar/SessionGroupSection.tsx` renders the compact badge, PR number, title, checks summary, and GitHub link.
67
+ - `packages/ui/src/components/views/git/PullRequestSection.tsx` uses the same shared entry for the full PR workflow.
68
+ - `packages/ui/src/components/ui/MemoryDebugPanel.tsx` reads request counters for debugging.
69
+
70
+ ## How PR resolution works
71
+
72
+ - It reads local git status and remotes first.
73
+ - It ranks remotes in this order: explicit remote, tracking remote, `origin`, `upstream`, then the rest.
74
+ - It resolves those remotes into GitHub repos.
75
+ - It expands each repo through `parent` and `source` so PRs in upstream repos can still be found.
76
+ - It skips PR lookup when the current branch matches that repo's default branch.
77
+ - It first searches for PRs by likely source owner plus exact head branch.
78
+ - If that fails, it falls back to broader GitHub search for the branch name.
79
+ - `403` and `404` during repo lookups are treated as expected gaps, not hard errors.
80
+
81
+ ## Shared client state model
82
+
83
+ - Client key is effectively `directory::branch`.
84
+ - One entry stores last known status, loading state, error, timestamps, watcher count, identity, and resolved remote.
85
+ - Requests are deduplicated by branch signature, not by component instance.
86
+ - This keeps sidebar and Git view aligned and avoids duplicated fetches.
87
+
88
+ ## Persistence
89
+
90
+ - PR state is persisted in local storage under `openchamber.github-pr-status`.
91
+ - Persisted fields include status, timestamps, identity, and resolved remote.
92
+ - Runtime-only details are not persisted.
93
+ - Persisted entries expire after 12 hours.
94
+ - On reload, users get last known state first, then background refresh resumes.
95
+
96
+ ## Polling and refresh model
97
+
98
+ - There are two layers: entry-level polling in `useGitHubPrStatusStore` and repo scanning in `useGitHubPrBackgroundTracking`.
99
+ - Entry-level polling decides when a known branch should revalidate PR state.
100
+ - Background tracking decides which directories and branches should even be watched.
101
+
102
+ ## Entry-level polling rules
103
+
104
+ - Start watching -> immediate refresh.
105
+ - If no PR is found yet -> retry after `2s` and `5s`.
106
+ - Still no PR -> discovery refresh every `5m`.
107
+ - Open PR with pending checks -> refresh about every `1m`.
108
+ - Open PR with non-pending checks -> refresh about every `5m`.
109
+ - Open PR without a stable checks signal -> refresh about every `2m`.
110
+ - Closed or merged PR -> stop regular polling.
111
+ - Hidden tab -> skip polling.
112
+ - Non-forced refreshes use a `90s` TTL.
113
+
114
+ ## Background tracking rules
115
+
116
+ - Track up to `50` likely directories.
117
+ - Sources are current directory, projects, worktrees, active sessions, and archived sessions.
118
+ - Active directory branch TTL is `15s`.
119
+ - Background directory branch TTL is `2m`.
120
+ - Background scan wakes every `15s`, but only fetches directories whose TTL expired.
121
+ - Each scan reads `branch`, `tracking`, `ahead`, and `behind` from git status.
122
+ - If any of those branch signals change, that branch's PR status refreshes immediately.
123
+ - After that, one more delayed refresh runs after `5s` to catch GitHub eventual consistency.
124
+
125
+ ## UI refresh triggers
126
+
127
+ - App or tab becomes visible.
128
+ - Window regains focus.
129
+ - Current branch changes.
130
+ - Tracking branch changes.
131
+ - Ahead or behind changes.
132
+ - User selects a different remote in Git view.
133
+ - GitHub auth state changes.
134
+
135
+ ## Action-based refreshes in Git view
136
+
137
+ - After `Create PR` -> refresh now, then after `2s` and `5s`.
138
+ - After `Merge PR` -> refresh now, then after `2s` and `5s`.
139
+ - After `Mark ready for review` -> refresh now, then after `2s` and `5s`.
140
+ - After `Update PR` -> refresh now, then after `2s` and `5s`.
141
+
142
+ ## Sidebar behavior
143
+
144
+ - Sidebar shows only compact PR state.
145
+ - Aggregation is by `directory::branch`, so multiple sessions on one branch share one signal.
146
+ - If multiple entries exist, sidebar keeps the strongest visible PR state.
147
+ - Visual state is based on PR health, not merge permissions.
148
+
149
+ ## Git view behavior
150
+
151
+ - Git view watches one branch directly.
152
+ - It supports create, edit, mark ready, and merge.
153
+ - It can probe alternate remotes so fork-heavy setups still find the right PR.
154
+ - It uses the same shared store as the sidebar.
155
+
156
+ ## Failure handling
157
+
158
+ - If GitHub is disconnected, API returns `connected: false`.
159
+ - If a repo is private or inaccessible, resolver calls may quietly return no PR.
160
+ - Sidebar stays quiet on missing or inaccessible PR state.
161
+ - Git view is where explicit PR-level problems should be shown.
162
+
163
+ ## Notes for contributors
164
+
165
+ - Keep the UI calm. Do not add noisy diagnostics to the sidebar.
166
+ - Prefer shared state over per-component fetches.
167
+ - Prefer event-shaped refreshes over blind frequent polling.
168
+ - Prefer correctness for fork and multi-remote setups over assuming `origin` is enough.
169
+ - Device flow handles GitHub `authorization_pending` at caller level.
170
+ - Repo parser supports `git@github.com:`, `ssh://git@github.com/`, and `https://github.com/`.
@@ -0,0 +1,307 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+
5
+ const ARCHCODER_DATA_DIR = process.env.ARCHCODER_DATA_DIR
6
+ ? path.resolve(process.env.ARCHCODER_DATA_DIR)
7
+ : path.join(os.homedir(), '.config', 'archcoder');
8
+
9
+ const STORAGE_DIR = ARCHCODER_DATA_DIR;
10
+ const STORAGE_FILE = path.join(STORAGE_DIR, 'github-auth.json');
11
+ const SETTINGS_FILE = path.join(ARCHCODER_DATA_DIR, 'settings.json');
12
+
13
+ const DEFAULT_GITHUB_CLIENT_ID = 'Ov23lizomPOC3eFYo56r';
14
+ const DEFAULT_GITHUB_SCOPES = 'repo read:org workflow read:user user:email';
15
+
16
+ function ensureStorageDir() {
17
+ if (!fs.existsSync(STORAGE_DIR)) {
18
+ fs.mkdirSync(STORAGE_DIR, { recursive: true });
19
+ }
20
+ }
21
+
22
+ function readJsonFile() {
23
+ ensureStorageDir();
24
+ if (!fs.existsSync(STORAGE_FILE)) {
25
+ return null;
26
+ }
27
+ try {
28
+ const raw = fs.readFileSync(STORAGE_FILE, 'utf8');
29
+ const trimmed = raw.trim();
30
+ if (!trimmed) {
31
+ return null;
32
+ }
33
+ const parsed = JSON.parse(trimmed);
34
+ if (!parsed || typeof parsed !== 'object') {
35
+ return null;
36
+ }
37
+ return parsed;
38
+ } catch (error) {
39
+ console.error('Failed to read GitHub auth file:', error);
40
+ return null;
41
+ }
42
+ }
43
+
44
+ function writeJsonFile(payload) {
45
+ ensureStorageDir();
46
+
47
+ // Atomic write so multiple ArchCoder instances can safely share the same file.
48
+ const tmpFile = `${STORAGE_FILE}.${process.pid}.${Date.now()}.tmp`;
49
+ fs.writeFileSync(tmpFile, JSON.stringify(payload, null, 2), 'utf8');
50
+ try {
51
+ fs.chmodSync(tmpFile, 0o600);
52
+ } catch {
53
+ // best-effort
54
+ }
55
+
56
+ fs.renameSync(tmpFile, STORAGE_FILE);
57
+ try {
58
+ fs.chmodSync(STORAGE_FILE, 0o600);
59
+ } catch {
60
+ // best-effort
61
+ }
62
+ }
63
+
64
+ function resolveAccountId({ user, accessToken, accountId }) {
65
+ if (typeof accountId === 'string' && accountId.trim()) {
66
+ return accountId.trim();
67
+ }
68
+ if (user && typeof user.login === 'string' && user.login.trim()) {
69
+ return user.login.trim();
70
+ }
71
+ if (user && typeof user.id === 'number') {
72
+ return String(user.id);
73
+ }
74
+ if (typeof accessToken === 'string' && accessToken.trim()) {
75
+ return `token:${accessToken.slice(0, 8)}`;
76
+ }
77
+ return '';
78
+ }
79
+
80
+ function normalizeAuthEntry(entry) {
81
+ if (!entry || typeof entry !== 'object') return null;
82
+ const accessToken = typeof entry.accessToken === 'string' ? entry.accessToken : '';
83
+ if (!accessToken) return null;
84
+ const user = entry.user && typeof entry.user === 'object'
85
+ ? {
86
+ login: typeof entry.user.login === 'string' ? entry.user.login : null,
87
+ avatarUrl: typeof entry.user.avatarUrl === 'string' ? entry.user.avatarUrl : null,
88
+ id: typeof entry.user.id === 'number' ? entry.user.id : null,
89
+ name: typeof entry.user.name === 'string' ? entry.user.name : null,
90
+ email: typeof entry.user.email === 'string' ? entry.user.email : null,
91
+ }
92
+ : null;
93
+
94
+ const accountId = resolveAccountId({
95
+ user,
96
+ accessToken,
97
+ accountId: typeof entry.accountId === 'string' ? entry.accountId : '',
98
+ });
99
+
100
+ return {
101
+ accessToken,
102
+ scope: typeof entry.scope === 'string' ? entry.scope : '',
103
+ tokenType: typeof entry.tokenType === 'string' ? entry.tokenType : 'bearer',
104
+ createdAt: typeof entry.createdAt === 'number' ? entry.createdAt : null,
105
+ user,
106
+ current: Boolean(entry.current),
107
+ accountId,
108
+ };
109
+ }
110
+
111
+ function normalizeAuthList(raw) {
112
+ const list = (Array.isArray(raw) ? raw : [raw])
113
+ .map((entry) => normalizeAuthEntry(entry))
114
+ .filter(Boolean);
115
+
116
+ if (!list.length) {
117
+ return { list: [], changed: false };
118
+ }
119
+
120
+ let changed = false;
121
+ let currentFound = false;
122
+ list.forEach((entry) => {
123
+ if (entry.current && !currentFound) {
124
+ currentFound = true;
125
+ } else if (entry.current && currentFound) {
126
+ entry.current = false;
127
+ changed = true;
128
+ }
129
+ });
130
+
131
+ if (!currentFound && list[0]) {
132
+ list[0].current = true;
133
+ changed = true;
134
+ }
135
+
136
+ list.forEach((entry) => {
137
+ if (!entry.accountId) {
138
+ entry.accountId = resolveAccountId(entry);
139
+ changed = true;
140
+ }
141
+ });
142
+
143
+ return { list, changed };
144
+ }
145
+
146
+ function readAuthList() {
147
+ const data = readJsonFile();
148
+ if (!data) {
149
+ return [];
150
+ }
151
+ const { list, changed } = normalizeAuthList(data);
152
+ if (changed) {
153
+ writeJsonFile(list);
154
+ }
155
+ return list;
156
+ }
157
+
158
+ function writeAuthList(list) {
159
+ writeJsonFile(list);
160
+ }
161
+
162
+ export function getGitHubAuth() {
163
+ const list = readAuthList();
164
+ if (!list.length) {
165
+ return null;
166
+ }
167
+ const current = list.find((entry) => entry.current) || list[0];
168
+ if (!current?.accessToken) {
169
+ return null;
170
+ }
171
+ return current;
172
+ }
173
+
174
+ export function getGitHubAuthAccounts() {
175
+ const list = readAuthList();
176
+ return list
177
+ .filter((entry) => entry?.user && entry.accountId)
178
+ .map((entry) => ({
179
+ id: entry.accountId,
180
+ user: entry.user,
181
+ scope: entry.scope || '',
182
+ current: Boolean(entry.current),
183
+ }));
184
+ }
185
+
186
+ export function setGitHubAuth({ accessToken, scope, tokenType, user, accountId }) {
187
+ if (!accessToken || typeof accessToken !== 'string') {
188
+ throw new Error('accessToken is required');
189
+ }
190
+ const normalizedUser = user && typeof user === 'object'
191
+ ? {
192
+ login: typeof user.login === 'string' ? user.login : undefined,
193
+ avatarUrl: typeof user.avatarUrl === 'string' ? user.avatarUrl : undefined,
194
+ id: typeof user.id === 'number' ? user.id : undefined,
195
+ name: typeof user.name === 'string' ? user.name : undefined,
196
+ email: typeof user.email === 'string' ? user.email : undefined,
197
+ }
198
+ : undefined;
199
+
200
+ const resolvedAccountId = resolveAccountId({
201
+ user: normalizedUser,
202
+ accessToken,
203
+ accountId,
204
+ });
205
+
206
+ const list = readAuthList();
207
+ const existingIndex = list.findIndex((entry) => entry.accountId === resolvedAccountId);
208
+ const nextEntry = {
209
+ accessToken,
210
+ scope: typeof scope === 'string' ? scope : '',
211
+ tokenType: typeof tokenType === 'string' ? tokenType : 'bearer',
212
+ createdAt: Date.now(),
213
+ user: normalizedUser || null,
214
+ current: true,
215
+ accountId: resolvedAccountId,
216
+ };
217
+
218
+ if (existingIndex >= 0) {
219
+ list[existingIndex] = nextEntry;
220
+ } else {
221
+ list.push(nextEntry);
222
+ }
223
+
224
+ list.forEach((entry, index) => {
225
+ entry.current = index === (existingIndex >= 0 ? existingIndex : list.length - 1);
226
+ });
227
+ writeAuthList(list);
228
+ return nextEntry;
229
+ }
230
+
231
+ export function activateGitHubAuth(accountId) {
232
+ if (typeof accountId !== 'string' || !accountId.trim()) {
233
+ return false;
234
+ }
235
+ const list = readAuthList();
236
+ const index = list.findIndex((entry) => entry.accountId === accountId.trim());
237
+ if (index === -1) {
238
+ return false;
239
+ }
240
+ list.forEach((entry, idx) => {
241
+ entry.current = idx === index;
242
+ });
243
+ writeAuthList(list);
244
+ return true;
245
+ }
246
+
247
+ export function clearGitHubAuth() {
248
+ try {
249
+ const list = readAuthList();
250
+ if (!list.length) {
251
+ return true;
252
+ }
253
+ const remaining = list.filter((entry) => !entry.current);
254
+ if (!remaining.length) {
255
+ if (fs.existsSync(STORAGE_FILE)) {
256
+ fs.unlinkSync(STORAGE_FILE);
257
+ }
258
+ return true;
259
+ }
260
+ remaining.forEach((entry, index) => {
261
+ entry.current = index === 0;
262
+ });
263
+ writeAuthList(remaining);
264
+ return true;
265
+ } catch (error) {
266
+ console.error('Failed to clear GitHub auth file:', error);
267
+ return false;
268
+ }
269
+ }
270
+
271
+ export function getGitHubClientId() {
272
+ const raw = process.env.OPENCHAMBER_GITHUB_CLIENT_ID;
273
+ const clientId = typeof raw === 'string' ? raw.trim() : '';
274
+ if (clientId) return clientId;
275
+
276
+ try {
277
+ if (fs.existsSync(SETTINGS_FILE)) {
278
+ const parsed = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));
279
+ const stored = typeof parsed?.githubClientId === 'string' ? parsed.githubClientId.trim() : '';
280
+ if (stored) return stored;
281
+ }
282
+ } catch {
283
+ // ignore
284
+ }
285
+
286
+ return DEFAULT_GITHUB_CLIENT_ID;
287
+ }
288
+
289
+ export function getGitHubScopes() {
290
+ const raw = process.env.OPENCHAMBER_GITHUB_SCOPES;
291
+ const fromEnv = typeof raw === 'string' ? raw.trim() : '';
292
+ if (fromEnv) return fromEnv;
293
+
294
+ try {
295
+ if (fs.existsSync(SETTINGS_FILE)) {
296
+ const parsed = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));
297
+ const stored = typeof parsed?.githubScopes === 'string' ? parsed.githubScopes.trim() : '';
298
+ if (stored) return stored;
299
+ }
300
+ } catch {
301
+ // ignore
302
+ }
303
+
304
+ return DEFAULT_GITHUB_SCOPES;
305
+ }
306
+
307
+ export const GITHUB_AUTH_FILE = STORAGE_FILE;
@@ -0,0 +1,50 @@
1
+ const DEVICE_CODE_URL = 'https://github.com/login/device/code';
2
+ const ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token';
3
+ const DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
4
+
5
+ const encodeForm = (params) => {
6
+ const body = new URLSearchParams();
7
+ for (const [key, value] of Object.entries(params)) {
8
+ if (value == null) continue;
9
+ body.set(key, String(value));
10
+ }
11
+ return body.toString();
12
+ };
13
+
14
+ async function postForm(url, params) {
15
+ const response = await fetch(url, {
16
+ method: 'POST',
17
+ headers: {
18
+ 'Content-Type': 'application/x-www-form-urlencoded',
19
+ Accept: 'application/json',
20
+ },
21
+ body: encodeForm(params),
22
+ });
23
+
24
+ const payload = await response.json().catch(() => null);
25
+ if (!response.ok) {
26
+ const message = payload?.error_description || payload?.error || response.statusText;
27
+ const error = new Error(message || 'GitHub request failed');
28
+ error.status = response.status;
29
+ error.payload = payload;
30
+ throw error;
31
+ }
32
+ return payload;
33
+ }
34
+
35
+ export async function startDeviceFlow({ clientId, scope }) {
36
+ return postForm(DEVICE_CODE_URL, {
37
+ client_id: clientId,
38
+ scope,
39
+ });
40
+ }
41
+
42
+ export async function exchangeDeviceCode({ clientId, deviceCode }) {
43
+ // GitHub returns 200 with {error: 'authorization_pending'|...} for non-success states.
44
+ const payload = await postForm(ACCESS_TOKEN_URL, {
45
+ client_id: clientId,
46
+ device_code: deviceCode,
47
+ grant_type: DEVICE_GRANT_TYPE,
48
+ });
49
+ return payload;
50
+ }
@@ -0,0 +1,24 @@
1
+ export {
2
+ getGitHubAuth,
3
+ getGitHubAuthAccounts,
4
+ setGitHubAuth,
5
+ activateGitHubAuth,
6
+ clearGitHubAuth,
7
+ getGitHubClientId,
8
+ getGitHubScopes,
9
+ GITHUB_AUTH_FILE,
10
+ } from './auth.js';
11
+
12
+ export {
13
+ startDeviceFlow,
14
+ exchangeDeviceCode,
15
+ } from './device-flow.js';
16
+
17
+ export {
18
+ getOctokitOrNull,
19
+ } from './octokit.js';
20
+
21
+ export {
22
+ parseGitHubRemoteUrl,
23
+ resolveGitHubRepoFromDirectory,
24
+ } from './repo/index.js';
@@ -0,0 +1,10 @@
1
+ import { Octokit } from '@octokit/rest';
2
+ import { getGitHubAuth } from './auth.js';
3
+
4
+ export function getOctokitOrNull() {
5
+ const auth = getGitHubAuth();
6
+ if (!auth?.accessToken) {
7
+ return null;
8
+ }
9
+ return new Octokit({ auth: auth.accessToken });
10
+ }