@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,221 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import yaml from 'yaml';
5
+
6
+ import { assertGitAvailable, looksLikeAuthError, runGit } from './git.js';
7
+ import { parseSkillRepoSource } from './source.js';
8
+
9
+ const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
10
+
11
+ function validateSkillName(skillName) {
12
+ if (typeof skillName !== 'string') return false;
13
+ if (skillName.length < 1 || skillName.length > 64) return false;
14
+ return SKILL_NAME_PATTERN.test(skillName);
15
+ }
16
+
17
+ function parseSkillMd(content) {
18
+ const text = typeof content === 'string' ? content : '';
19
+ const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
20
+ if (!match) {
21
+ return {
22
+ ok: true,
23
+ frontmatter: {},
24
+ warnings: ['Invalid SKILL.md: missing YAML frontmatter delimiter'],
25
+ };
26
+ }
27
+
28
+ try {
29
+ const frontmatter = yaml.parse(match[1]) || {};
30
+ return { ok: true, frontmatter, warnings: [] };
31
+ } catch {
32
+ return {
33
+ ok: true,
34
+ frontmatter: {},
35
+ warnings: ['Invalid SKILL.md: failed to parse YAML frontmatter'],
36
+ };
37
+ }
38
+ }
39
+
40
+ async function safeRm(dir) {
41
+ try {
42
+ await fs.promises.rm(dir, { recursive: true, force: true });
43
+ } catch {
44
+ // ignore
45
+ }
46
+ }
47
+
48
+ async function cloneRepo({ cloneUrl, identity, tempDir }) {
49
+ const preferred = ['clone', '--depth', '1', '--filter=blob:none', '--no-checkout', cloneUrl, tempDir];
50
+ const fallback = ['clone', '--depth', '1', '--no-checkout', cloneUrl, tempDir];
51
+
52
+ const result = await runGit(preferred, { identity, timeoutMs: 60_000 });
53
+ if (result.ok) return { ok: true };
54
+
55
+ const fallbackResult = await runGit(fallback, { identity, timeoutMs: 60_000 });
56
+ if (fallbackResult.ok) return { ok: true };
57
+
58
+ return {
59
+ ok: false,
60
+ error: fallbackResult,
61
+ };
62
+ }
63
+
64
+ export async function scanSkillsRepository({
65
+ source,
66
+ subpath,
67
+ defaultSubpath,
68
+ identity,
69
+ } = {}) {
70
+ const gitCheck = await assertGitAvailable();
71
+ if (!gitCheck.ok) {
72
+ return { ok: false, error: gitCheck.error };
73
+ }
74
+
75
+ const parsed = parseSkillRepoSource(source, { subpath });
76
+ if (!parsed.ok) {
77
+ return { ok: false, error: parsed.error };
78
+ }
79
+
80
+ const effectiveSubpath = parsed.effectiveSubpath || (typeof defaultSubpath === 'string' && defaultSubpath.trim() ? defaultSubpath.trim() : null);
81
+ const cloneUrl = identity?.sshKey ? parsed.cloneUrlSsh : parsed.cloneUrlHttps;
82
+
83
+ const tempBase = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'archcoder-skills-scan-'));
84
+
85
+ try {
86
+ const cloned = await cloneRepo({ cloneUrl, identity, tempDir: tempBase });
87
+ if (!cloned.ok) {
88
+ const msg = `${cloned.error?.stderr || ''}\n${cloned.error?.message || ''}`.trim();
89
+ if (looksLikeAuthError(msg)) {
90
+ return { ok: false, error: { kind: 'authRequired', message: 'Authentication required to access this repository', sshOnly: true } };
91
+ }
92
+ return { ok: false, error: { kind: 'networkError', message: msg || 'Failed to clone repository' } };
93
+ }
94
+
95
+ const toFsPath = (posixPath) => path.join(tempBase, ...String(posixPath || '').split('/').filter(Boolean));
96
+
97
+ const patterns = effectiveSubpath
98
+ ? [`${effectiveSubpath}/SKILL.md`, `${effectiveSubpath}/**/SKILL.md`]
99
+ : ['SKILL.md', '**/SKILL.md'];
100
+
101
+ let skillMdPaths = null;
102
+
103
+ // Fast path: sparse checkout only SKILL.md files, then parse from disk.
104
+ // This avoids one `git show` per skill.
105
+ const sparseInit = await runGit(['-C', tempBase, 'sparse-checkout', 'init', '--no-cone'], { identity, timeoutMs: 15_000 });
106
+ if (sparseInit.ok) {
107
+ const sparseSet = await runGit(['-C', tempBase, 'sparse-checkout', 'set', ...patterns], { identity, timeoutMs: 30_000 });
108
+ if (sparseSet.ok) {
109
+ const checkout = await runGit(['-C', tempBase, 'checkout', '--force', 'HEAD'], { identity, timeoutMs: 60_000 });
110
+ if (checkout.ok) {
111
+ const lsFiles = await runGit(['-C', tempBase, 'ls-files'], { identity, timeoutMs: 15_000 });
112
+ if (lsFiles.ok) {
113
+ skillMdPaths = lsFiles.stdout
114
+ .split(/\r?\n/)
115
+ .map((line) => line.trim())
116
+ .filter(Boolean)
117
+ .filter((p) => p.endsWith('/SKILL.md') || p === 'SKILL.md');
118
+ }
119
+ }
120
+ }
121
+ }
122
+
123
+ // Fallback: list tree and read SKILL.md blobs via git.
124
+ if (!Array.isArray(skillMdPaths)) {
125
+ const listArgs = ['-C', tempBase, 'ls-tree', '-r', '--name-only', 'HEAD'];
126
+ if (effectiveSubpath) {
127
+ listArgs.push('--', effectiveSubpath);
128
+ }
129
+
130
+ const listResult = await runGit(listArgs, { identity, timeoutMs: 30_000 });
131
+ if (!listResult.ok) {
132
+ // If subpath doesn't exist, treat as empty scan.
133
+ return {
134
+ ok: true,
135
+ normalizedRepo: parsed.normalizedRepo,
136
+ effectiveSubpath,
137
+ items: [],
138
+ };
139
+ }
140
+
141
+ skillMdPaths = listResult.stdout
142
+ .split(/\r?\n/)
143
+ .map((line) => line.trim())
144
+ .filter(Boolean)
145
+ .filter((p) => p.endsWith('/SKILL.md') || p === 'SKILL.md');
146
+ }
147
+
148
+ // Root-level SKILL.md doesn't map cleanly to OpenCode's "skill name == folder name" convention.
149
+ const uniqueSkillDirs = Array.from(
150
+ new Set(
151
+ skillMdPaths
152
+ .filter((p) => p !== 'SKILL.md')
153
+ .map((p) => path.posix.dirname(p))
154
+ )
155
+ );
156
+
157
+ const items = [];
158
+ const maxParallel = 10;
159
+ let idx = 0;
160
+
161
+ const worker = async () => {
162
+ while (idx < uniqueSkillDirs.length) {
163
+ const skillDir = uniqueSkillDirs[idx++];
164
+ const skillName = path.posix.basename(skillDir);
165
+ const skillMdPath = path.posix.join(skillDir, 'SKILL.md');
166
+
167
+ const warnings = [];
168
+ let skillMdContent = '';
169
+
170
+ // Prefer filesystem reads when sparse checkout succeeded.
171
+ const filePath = toFsPath(skillMdPath);
172
+ try {
173
+ skillMdContent = await fs.promises.readFile(filePath, 'utf8');
174
+ } catch {
175
+ const showResult = await runGit(['-C', tempBase, 'show', `HEAD:${skillMdPath}`], { identity, timeoutMs: 15_000 });
176
+ if (!showResult.ok) {
177
+ warnings.push('Failed to read SKILL.md');
178
+ } else {
179
+ skillMdContent = showResult.stdout;
180
+ }
181
+ }
182
+
183
+ const parsedMd = parseSkillMd(skillMdContent);
184
+ warnings.push(...(parsedMd.warnings || []));
185
+
186
+ const description = typeof parsedMd.frontmatter?.description === 'string' ? parsedMd.frontmatter.description : undefined;
187
+ const frontmatterName = typeof parsedMd.frontmatter?.name === 'string' ? parsedMd.frontmatter.name : undefined;
188
+
189
+ const installable = validateSkillName(skillName);
190
+ if (!installable) {
191
+ warnings.push('Skill directory name is not a valid OpenCode skill name');
192
+ }
193
+
194
+ items.push({
195
+ repoSource: source,
196
+ repoSubpath: effectiveSubpath || undefined,
197
+ skillDir,
198
+ skillName,
199
+ frontmatterName,
200
+ description,
201
+ installable,
202
+ warnings: warnings.length ? warnings : undefined,
203
+ });
204
+ }
205
+ };
206
+
207
+ await Promise.all(Array.from({ length: Math.min(maxParallel, uniqueSkillDirs.length || 1) }, () => worker()));
208
+
209
+ // Stable ordering for UX
210
+ items.sort((a, b) => a.skillName.localeCompare(b.skillName));
211
+
212
+ return {
213
+ ok: true,
214
+ normalizedRepo: parsed.normalizedRepo,
215
+ effectiveSubpath,
216
+ items,
217
+ };
218
+ } finally {
219
+ await safeRm(tempBase);
220
+ }
221
+ }
@@ -0,0 +1,85 @@
1
+ const GITHUB_HOST = 'github.com';
2
+
3
+ function normalizeGitHubOwnerRepo(owner, repo) {
4
+ const normalizedOwner = String(owner || '').trim();
5
+ const normalizedRepo = String(repo || '').trim().replace(/\.git$/i, '');
6
+ if (!normalizedOwner || !normalizedRepo) {
7
+ return null;
8
+ }
9
+ return { owner: normalizedOwner, repo: normalizedRepo };
10
+ }
11
+
12
+ export function parseSkillRepoSource(input, options = {}) {
13
+ const raw = typeof input === 'string' ? input.trim() : '';
14
+ if (!raw) {
15
+ return { ok: false, error: { kind: 'invalidSource', message: 'Repository source is required' } };
16
+ }
17
+
18
+ const explicitSubpath = typeof options.subpath === 'string' && options.subpath.trim() ? options.subpath.trim() : null;
19
+
20
+ // SSH URL: git@github.com:owner/repo(.git)
21
+ const sshMatch = raw.match(/^git@github\.com:([^/\s]+)\/([^\s#]+)$/i);
22
+ if (sshMatch) {
23
+ const parsed = normalizeGitHubOwnerRepo(sshMatch[1], sshMatch[2]);
24
+ if (!parsed) {
25
+ return { ok: false, error: { kind: 'invalidSource', message: 'Invalid SSH repository URL' } };
26
+ }
27
+
28
+ return {
29
+ ok: true,
30
+ host: GITHUB_HOST,
31
+ owner: parsed.owner,
32
+ repo: parsed.repo,
33
+ cloneUrlSsh: `git@github.com:${parsed.owner}/${parsed.repo}.git`,
34
+ cloneUrlHttps: `https://github.com/${parsed.owner}/${parsed.repo}.git`,
35
+ // For SSH URLs, subpath is only accepted via options.subpath
36
+ effectiveSubpath: explicitSubpath,
37
+ normalizedRepo: `${parsed.owner}/${parsed.repo}`,
38
+ };
39
+ }
40
+
41
+ // HTTPS URL: https://github.com/owner/repo(.git)
42
+ const httpsMatch = raw.match(/^https?:\/\/github\.com\/([^/\s]+)\/([^\s#]+)$/i);
43
+ if (httpsMatch) {
44
+ const parsed = normalizeGitHubOwnerRepo(httpsMatch[1], httpsMatch[2]);
45
+ if (!parsed) {
46
+ return { ok: false, error: { kind: 'invalidSource', message: 'Invalid HTTPS repository URL' } };
47
+ }
48
+
49
+ return {
50
+ ok: true,
51
+ host: GITHUB_HOST,
52
+ owner: parsed.owner,
53
+ repo: parsed.repo,
54
+ cloneUrlSsh: `git@github.com:${parsed.owner}/${parsed.repo}.git`,
55
+ cloneUrlHttps: `https://github.com/${parsed.owner}/${parsed.repo}.git`,
56
+ effectiveSubpath: explicitSubpath,
57
+ normalizedRepo: `${parsed.owner}/${parsed.repo}`,
58
+ };
59
+ }
60
+
61
+ // Shorthand: owner/repo[/subpath...]
62
+ const shorthandMatch = raw.match(/^([^/\s]+)\/([^/\s]+)(?:\/(.+))?$/);
63
+ if (shorthandMatch) {
64
+ const parsed = normalizeGitHubOwnerRepo(shorthandMatch[1], shorthandMatch[2]);
65
+ if (!parsed) {
66
+ return { ok: false, error: { kind: 'invalidSource', message: 'Invalid repository source' } };
67
+ }
68
+
69
+ const shorthandSubpath = typeof shorthandMatch[3] === 'string' && shorthandMatch[3].trim() ? shorthandMatch[3].trim() : null;
70
+ const effectiveSubpath = explicitSubpath || shorthandSubpath;
71
+
72
+ return {
73
+ ok: true,
74
+ host: GITHUB_HOST,
75
+ owner: parsed.owner,
76
+ repo: parsed.repo,
77
+ cloneUrlSsh: `git@github.com:${parsed.owner}/${parsed.repo}.git`,
78
+ cloneUrlHttps: `https://github.com/${parsed.owner}/${parsed.repo}.git`,
79
+ effectiveSubpath,
80
+ normalizedRepo: `${parsed.owner}/${parsed.repo}`,
81
+ };
82
+ }
83
+
84
+ return { ok: false, error: { kind: 'invalidSource', message: 'Unsupported repository source format' } };
85
+ }
@@ -0,0 +1,114 @@
1
+ # Terminal Module Documentation
2
+
3
+ ## Purpose
4
+ This module provides WebSocket protocol utilities for terminal input handling in the web server runtime, including message normalization, control frame parsing, rate limiting, and pathname resolution for terminal WebSocket connections.
5
+
6
+ ## Entrypoints and structure
7
+ - `packages/web/server/lib/terminal/`: Terminal module directory.
8
+ - `index.js`: Stable module entrypoint that re-exports protocol helpers/constants.
9
+ - `input-ws-protocol.js`: Single-file module containing all terminal input WebSocket protocol utilities.
10
+ - `packages/web/server/lib/terminal/input-ws-protocol.test.js`: Test file for protocol utilities.
11
+
12
+ Public API entry point: imported by `packages/web/server/index.js` from `./lib/terminal/index.js`.
13
+
14
+ ## Public exports
15
+
16
+ ### Constants
17
+ - `TERMINAL_INPUT_WS_PATH`: WebSocket endpoint path (`/api/terminal/input-ws`).
18
+ - `TERMINAL_INPUT_WS_CONTROL_TAG_JSON`: Control frame tag byte (0x01) indicating JSON payload.
19
+ - `TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES`: Maximum payload size (64KB).
20
+
21
+ ### Request Parsing
22
+ - `parseRequestPathname(requestUrl)`: Extracts pathname from request URL string. Returns empty string for invalid inputs.
23
+
24
+ ### Message Normalization
25
+ - `normalizeTerminalInputWsMessageToBuffer(rawData)`: Normalizes various data types (Buffer, Uint8Array, ArrayBuffer, string, chunk arrays) to a single Buffer.
26
+ - `normalizeTerminalInputWsMessageToText(rawData)`: Normalizes data to UTF-8 text string. Passes through strings directly, converts binary data to text.
27
+
28
+ ### Control Frame Handling
29
+ - `readTerminalInputWsControlFrame(rawData)`: Parses WebSocket message as control frame. Returns parsed JSON object or null if invalid/malformed. Validates control tag prefix and JSON structure.
30
+ - `createTerminalInputWsControlFrame(payload)`: Creates a control frame with JSON payload. Prepends control tag byte.
31
+
32
+ ### Rate Limiting
33
+ - `pruneRebindTimestamps(timestamps, now, windowMs)`: Filters timestamps to keep only those within the active time window.
34
+ - `isRebindRateLimited(timestamps, maxPerWindow)`: Checks if rebind operations have exceeded rate limit threshold.
35
+
36
+ ## Response contracts
37
+
38
+ ### Control Frame
39
+ Control frames use binary encoding:
40
+ - First byte: `TERMINAL_INPUT_WS_CONTROL_TAG_JSON` (0x01)
41
+ - Remaining bytes: UTF-8 encoded JSON object
42
+ - Parsed result: Object or null on parse failure
43
+
44
+ ### Normalized Buffer
45
+ Input types are normalized to Buffer:
46
+ - `Buffer`: Returned as-is
47
+ - `Uint8Array`/`ArrayBuffer`: Converted to Buffer
48
+ - `String`: Converted to UTF-8 Buffer
49
+ - `Array<Buffer|string|Uint8Array>`: Concatenated to single Buffer
50
+
51
+ ### Rate Limiting
52
+ Rate limiting uses timestamp arrays:
53
+ - `pruneRebindTimestamps`: Returns filtered array of active timestamps
54
+ - `isRebindRateLimited`: Returns boolean indicating if limit is reached
55
+
56
+ ## Usage in web server
57
+
58
+ The terminal protocol utilities are used by `packages/web/server/index.js` for:
59
+ - WebSocket endpoint path definition (`TERMINAL_INPUT_WS_PATH`)
60
+ - Message normalization for input handling
61
+ - Control frame parsing for session binding
62
+ - Rate limiting for session rebind operations
63
+ - Request pathname parsing for WebSocket routing
64
+
65
+ The web server uses these utilities in combination with `bun-pty` or `node-pty` for PTY session management.
66
+
67
+ ## Notes for contributors
68
+
69
+ ### Adding New Control Frame Types
70
+ 1. Define new control tag constants (e.g., `TERMINAL_INPUT_WS_CONTROL_TAG_CUSTOM = 0x02`)
71
+ 2. Update `readTerminalInputWsControlFrame` to handle new tag type
72
+ 3. Update `createTerminalInputWsControlFrame` or create new frame creation function
73
+ 4. Add corresponding tests in `terminal-input-ws-protocol.test.js`
74
+
75
+ ### Message Normalization
76
+ - Always normalize incoming WebSocket messages before processing
77
+ - Use `normalizeTerminalInputWsMessageToBuffer` for binary data
78
+ - Use `normalizeTerminalInputWsMessageToText` for text data (terminal escape sequences)
79
+ - Normalize chunked messages from WebSocket fragmentation handling
80
+
81
+ ### Rate Limiting
82
+ - Rate limiting is time-window based: tracks timestamps within a rolling window
83
+ - Use `pruneRebindTimestamps` to clean up stale timestamps before rate limit checks
84
+ - Configure `maxPerWindow` based on operational requirements (prevent abuse)
85
+
86
+ ### Error Handling
87
+ - `readTerminalInputWsControlFrame` returns null for invalid/malformed frames
88
+ - `parseRequestPathname` returns empty string for invalid URLs
89
+ - Callers should handle null/empty returns gracefully
90
+
91
+ ### Testing
92
+ - Run `bun run type-check`, `bun run lint`, and `bun run build` before finalizing changes
93
+ - Test edge cases: empty payloads, malformed JSON, chunked messages, rate limit boundaries
94
+ - Verify control frame roundtrip: create → read → validate payload equality
95
+ - Test pathname parsing with relative URLs, absolute URLs, and invalid inputs
96
+
97
+ ## Verification notes
98
+
99
+ ### Manual verification
100
+ 1. Start web server and create terminal session via `/api/terminal/create`
101
+ 2. Connect to `/api/terminal/input-ws` WebSocket
102
+ 3. Send control frames with valid/invalid payloads to verify parsing
103
+ 4. Test message normalization with various data types
104
+ 5. Verify rate limiting by issuing rapid rebind requests
105
+
106
+ ### Automated verification
107
+ - Run test file: `bun test packages/web/server/lib/terminal/input-ws-protocol.test.js`
108
+ - Protocol tests should pass covering:
109
+ - WebSocket path constant
110
+ - Control frame encoding/decoding
111
+ - Payload validation
112
+ - Message normalization (all data types)
113
+ - Pathname parsing
114
+ - Rate limiting logic
@@ -0,0 +1,12 @@
1
+ export {
2
+ TERMINAL_INPUT_WS_PATH,
3
+ TERMINAL_INPUT_WS_CONTROL_TAG_JSON,
4
+ TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES,
5
+ parseRequestPathname,
6
+ normalizeTerminalInputWsMessageToBuffer,
7
+ normalizeTerminalInputWsMessageToText,
8
+ readTerminalInputWsControlFrame,
9
+ createTerminalInputWsControlFrame,
10
+ pruneRebindTimestamps,
11
+ isRebindRateLimited,
12
+ } from './input-ws-protocol.js';
@@ -0,0 +1,66 @@
1
+ export const TERMINAL_INPUT_WS_PATH = '/api/terminal/input-ws';
2
+ export const TERMINAL_INPUT_WS_CONTROL_TAG_JSON = 0x01;
3
+ export const TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES = 64 * 1024;
4
+
5
+ export const parseRequestPathname = (requestUrl) => {
6
+ if (typeof requestUrl !== 'string' || requestUrl.length === 0) {
7
+ return '';
8
+ }
9
+
10
+ try {
11
+ return new URL(requestUrl, 'http://localhost').pathname;
12
+ } catch {
13
+ return '';
14
+ }
15
+ };
16
+
17
+ export const normalizeTerminalInputWsMessageToBuffer = (rawData) => {
18
+ if (Buffer.isBuffer(rawData)) {
19
+ return rawData;
20
+ }
21
+
22
+ if (Array.isArray(rawData)) {
23
+ return Buffer.concat(rawData.map((chunk) => (Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))));
24
+ }
25
+
26
+ return Buffer.from(rawData);
27
+ };
28
+
29
+ export const normalizeTerminalInputWsMessageToText = (rawData) => {
30
+ if (typeof rawData === 'string') {
31
+ return rawData;
32
+ }
33
+
34
+ return normalizeTerminalInputWsMessageToBuffer(rawData).toString('utf8');
35
+ };
36
+
37
+ export const readTerminalInputWsControlFrame = (rawData) => {
38
+ if (!rawData) {
39
+ return null;
40
+ }
41
+
42
+ const buffer = normalizeTerminalInputWsMessageToBuffer(rawData);
43
+ if (buffer.length < 2 || buffer[0] !== TERMINAL_INPUT_WS_CONTROL_TAG_JSON) {
44
+ return null;
45
+ }
46
+
47
+ try {
48
+ const parsed = JSON.parse(buffer.subarray(1).toString('utf8'));
49
+ if (!parsed || typeof parsed !== 'object') {
50
+ return null;
51
+ }
52
+ return parsed;
53
+ } catch {
54
+ return null;
55
+ }
56
+ };
57
+
58
+ export const createTerminalInputWsControlFrame = (payload) => {
59
+ const jsonBytes = Buffer.from(JSON.stringify(payload), 'utf8');
60
+ return Buffer.concat([Buffer.from([TERMINAL_INPUT_WS_CONTROL_TAG_JSON]), jsonBytes]);
61
+ };
62
+
63
+ export const pruneRebindTimestamps = (timestamps, now, windowMs) =>
64
+ timestamps.filter((timestamp) => now - timestamp < windowMs);
65
+
66
+ export const isRebindRateLimited = (timestamps, maxPerWindow) => timestamps.length >= maxPerWindow;
@@ -0,0 +1,138 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+
3
+ import {
4
+ TERMINAL_INPUT_WS_CONTROL_TAG_JSON,
5
+ TERMINAL_INPUT_WS_PATH,
6
+ createTerminalInputWsControlFrame,
7
+ isRebindRateLimited,
8
+ normalizeTerminalInputWsMessageToBuffer,
9
+ normalizeTerminalInputWsMessageToText,
10
+ parseRequestPathname,
11
+ pruneRebindTimestamps,
12
+ readTerminalInputWsControlFrame,
13
+ } from './input-ws-protocol.js';
14
+
15
+ describe('terminal input websocket protocol', () => {
16
+ it('uses fixed websocket path', () => {
17
+ expect(TERMINAL_INPUT_WS_PATH).toBe('/api/terminal/input-ws');
18
+ });
19
+
20
+ it('encodes control frames with control tag prefix', () => {
21
+ const frame = createTerminalInputWsControlFrame({ t: 'ok', v: 1 });
22
+ expect(frame[0]).toBe(TERMINAL_INPUT_WS_CONTROL_TAG_JSON);
23
+ });
24
+
25
+ it('roundtrips control frame payload', () => {
26
+ const payload = { t: 'b', s: 'abc123', v: 1 };
27
+ const frame = createTerminalInputWsControlFrame(payload);
28
+ expect(readTerminalInputWsControlFrame(frame)).toEqual(payload);
29
+ });
30
+
31
+ it('rejects control frame without protocol tag', () => {
32
+ const frame = Buffer.from(JSON.stringify({ t: 'b', s: 'abc123' }), 'utf8');
33
+ expect(readTerminalInputWsControlFrame(frame)).toBeNull();
34
+ });
35
+
36
+ it('rejects malformed control json', () => {
37
+ const frame = Buffer.concat([
38
+ Buffer.from([TERMINAL_INPUT_WS_CONTROL_TAG_JSON]),
39
+ Buffer.from('{not json', 'utf8'),
40
+ ]);
41
+ expect(readTerminalInputWsControlFrame(frame)).toBeNull();
42
+ });
43
+
44
+ it('rejects empty control payloads', () => {
45
+ expect(readTerminalInputWsControlFrame(null)).toBeNull();
46
+ expect(readTerminalInputWsControlFrame(undefined)).toBeNull();
47
+ expect(readTerminalInputWsControlFrame(Buffer.alloc(0))).toBeNull();
48
+ });
49
+
50
+ it('rejects control json that is not object', () => {
51
+ const frame = Buffer.concat([
52
+ Buffer.from([TERMINAL_INPUT_WS_CONTROL_TAG_JSON]),
53
+ Buffer.from('"str"', 'utf8'),
54
+ ]);
55
+ expect(readTerminalInputWsControlFrame(frame)).toBeNull();
56
+ });
57
+
58
+ it('parses control frame from chunk arrays', () => {
59
+ const frame = createTerminalInputWsControlFrame({ t: 'bok', v: 1 });
60
+ const chunks = [frame.subarray(0, 2), frame.subarray(2)];
61
+ expect(readTerminalInputWsControlFrame(chunks)).toEqual({ t: 'bok', v: 1 });
62
+ });
63
+
64
+ it('normalizes buffer passthrough', () => {
65
+ const raw = Buffer.from('abc', 'utf8');
66
+ const normalized = normalizeTerminalInputWsMessageToBuffer(raw);
67
+ expect(normalized).toBe(raw);
68
+ expect(normalized.toString('utf8')).toBe('abc');
69
+ });
70
+
71
+ it('normalizes uint8 arrays', () => {
72
+ const normalized = normalizeTerminalInputWsMessageToBuffer(new Uint8Array([97, 98, 99]));
73
+ expect(normalized.toString('utf8')).toBe('abc');
74
+ });
75
+
76
+ it('normalizes array buffer payloads', () => {
77
+ const source = new Uint8Array([97, 98, 99]).buffer;
78
+ const normalized = normalizeTerminalInputWsMessageToBuffer(source);
79
+ expect(normalized.toString('utf8')).toBe('abc');
80
+ });
81
+
82
+ it('normalizes chunk array payloads', () => {
83
+ const normalized = normalizeTerminalInputWsMessageToBuffer([
84
+ Buffer.from('ab', 'utf8'),
85
+ Buffer.from('c', 'utf8'),
86
+ ]);
87
+ expect(normalized.toString('utf8')).toBe('abc');
88
+ });
89
+
90
+ it('normalizes text payload from string', () => {
91
+ expect(normalizeTerminalInputWsMessageToText('\u001b[A')).toBe('\u001b[A');
92
+ });
93
+
94
+ it('normalizes text payload from binary data', () => {
95
+ expect(normalizeTerminalInputWsMessageToText(Buffer.from('\r', 'utf8'))).toBe('\r');
96
+ });
97
+
98
+ it('parses relative request pathname', () => {
99
+ expect(parseRequestPathname('/api/terminal/input-ws?x=1')).toBe('/api/terminal/input-ws');
100
+ });
101
+
102
+ it('parses absolute request pathname', () => {
103
+ expect(parseRequestPathname('http://localhost:3000/api/terminal/input-ws')).toBe('/api/terminal/input-ws');
104
+ });
105
+
106
+ it('returns empty pathname for non-string request url', () => {
107
+ expect(parseRequestPathname(null)).toBe('');
108
+ });
109
+
110
+ it('returns empty pathname for invalid request url', () => {
111
+ expect(parseRequestPathname('http://')).toBe('');
112
+ expect(parseRequestPathname('')).toBe('');
113
+ });
114
+
115
+ it('prunes stale rebind timestamps', () => {
116
+ const now = 1_000;
117
+ const pruned = pruneRebindTimestamps([100, 200, 950, 999], now, 100);
118
+ expect(pruned).toEqual([950, 999]);
119
+ });
120
+
121
+ it('keeps rebind timestamps within active window', () => {
122
+ const now = 1_000;
123
+ const pruned = pruneRebindTimestamps([920, 950, 999], now, 100);
124
+ expect(pruned).toEqual([920, 950, 999]);
125
+ });
126
+
127
+ it('does not rate limit below threshold', () => {
128
+ expect(isRebindRateLimited([1, 2, 3], 4)).toBe(false);
129
+ });
130
+
131
+ it('does not rate limit empty window', () => {
132
+ expect(isRebindRateLimited([], 1)).toBe(false);
133
+ });
134
+
135
+ it('rate limits at threshold', () => {
136
+ expect(isRebindRateLimited([1, 2, 3, 4], 4)).toBe(true);
137
+ });
138
+ });