dot-studio 0.0.1

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 (38) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +214 -0
  3. package/client/assets/index-C2eIILoa.css +41 -0
  4. package/client/assets/index-DUPZ_Lw5.js +616 -0
  5. package/client/assets/index.es-Btlrnc3g.js +1 -0
  6. package/client/index.html +14 -0
  7. package/dist/cli.js +196 -0
  8. package/dist/server/index.js +79 -0
  9. package/dist/server/lib/act-runtime.js +1282 -0
  10. package/dist/server/lib/cache.js +31 -0
  11. package/dist/server/lib/config.js +53 -0
  12. package/dist/server/lib/dot-authoring.js +245 -0
  13. package/dist/server/lib/dot-loader.js +61 -0
  14. package/dist/server/lib/dot-login.js +190 -0
  15. package/dist/server/lib/model-catalog.js +111 -0
  16. package/dist/server/lib/opencode-auth.js +69 -0
  17. package/dist/server/lib/opencode-errors.js +220 -0
  18. package/dist/server/lib/opencode-sidecar.js +144 -0
  19. package/dist/server/lib/opencode.js +12 -0
  20. package/dist/server/lib/package-bin.js +63 -0
  21. package/dist/server/lib/project-config.js +39 -0
  22. package/dist/server/lib/prompt.js +222 -0
  23. package/dist/server/lib/request-context.js +27 -0
  24. package/dist/server/lib/runtime-tools.js +208 -0
  25. package/dist/server/routes/assets.js +161 -0
  26. package/dist/server/routes/chat.js +356 -0
  27. package/dist/server/routes/compile.js +105 -0
  28. package/dist/server/routes/dot.js +270 -0
  29. package/dist/server/routes/health.js +56 -0
  30. package/dist/server/routes/opencode.js +421 -0
  31. package/dist/server/routes/stages.js +137 -0
  32. package/dist/server/start.js +23 -0
  33. package/dist/server/terminal.js +282 -0
  34. package/dist/shared/mcp-config.js +19 -0
  35. package/dist/shared/model-variants.js +50 -0
  36. package/dist/shared/project-mcp.js +22 -0
  37. package/dist/shared/session-metadata.js +26 -0
  38. package/package.json +103 -0
@@ -0,0 +1,31 @@
1
+ // ── Server-side TTL Cache ────────────────────────────────
2
+ // In-memory cache with configurable TTL per key.
3
+ // Prevents repeated slow calls to OpenCode SDK and filesystem scans.
4
+ const store = new Map();
5
+ export function cached(key, ttlMs, fetcher) {
6
+ const now = Date.now();
7
+ const entry = store.get(key);
8
+ if (entry && entry.expiresAt > now) {
9
+ return Promise.resolve(entry.data);
10
+ }
11
+ return fetcher().then(data => {
12
+ store.set(key, { data, expiresAt: now + ttlMs });
13
+ return data;
14
+ });
15
+ }
16
+ export function invalidate(keyPrefix) {
17
+ for (const key of store.keys()) {
18
+ if (key.startsWith(keyPrefix)) {
19
+ store.delete(key);
20
+ }
21
+ }
22
+ }
23
+ export function invalidateAll() {
24
+ store.clear();
25
+ }
26
+ // Default TTLs
27
+ export const TTL = {
28
+ ASSETS: 30_000, // 30s — local files may change
29
+ MCP_SERVERS: 30_000, // 30s
30
+ PROVIDERS: 60_000, // 60s
31
+ };
@@ -0,0 +1,53 @@
1
+ // Server Configuration & Studio Config Helpers
2
+ import fs from 'fs/promises';
3
+ import path from 'path';
4
+ import os from 'os';
5
+ function resolvePort(value, fallback) {
6
+ const parsed = Number.parseInt(value || '', 10);
7
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
8
+ }
9
+ function resolveDefaultProjectDir() {
10
+ if (process.env.PROJECT_DIR) {
11
+ return path.resolve(process.env.PROJECT_DIR);
12
+ }
13
+ if (process.env.DOT_STUDIO_PRODUCTION === '1') {
14
+ return path.resolve(process.cwd());
15
+ }
16
+ return path.resolve(process.cwd(), '..');
17
+ }
18
+ // ── Constants ───────────────────────────────────────────
19
+ export const PORT = resolvePort(process.env.PORT, 3001);
20
+ export const OPENCODE_URL = process.env.OPENCODE_URL || 'http://localhost:4096';
21
+ export const OPENCODE_MANAGED = !process.env.OPENCODE_URL;
22
+ export const DEFAULT_PROJECT_DIR = resolveDefaultProjectDir();
23
+ export const STUDIO_DIR = process.env.STUDIO_DIR || path.join(os.homedir(), '.dot-studio');
24
+ export const STUDIO_CONFIG_PATH = path.join(STUDIO_DIR, 'studio-config.json');
25
+ export const IS_PRODUCTION = process.env.DOT_STUDIO_PRODUCTION === '1';
26
+ // ── Mutable Active Project Dir ──────────────────────────
27
+ let _activeProjectDir = DEFAULT_PROJECT_DIR;
28
+ export function getActiveProjectDir() {
29
+ return _activeProjectDir;
30
+ }
31
+ export function setActiveProjectDir(dir) {
32
+ _activeProjectDir = dir;
33
+ }
34
+ export async function readStudioConfig() {
35
+ try {
36
+ const raw = await fs.readFile(STUDIO_CONFIG_PATH, 'utf-8');
37
+ return JSON.parse(raw);
38
+ }
39
+ catch {
40
+ return {};
41
+ }
42
+ }
43
+ export async function writeStudioConfig(partial) {
44
+ await fs.mkdir(STUDIO_DIR, { recursive: true });
45
+ const current = await readStudioConfig();
46
+ const merged = { ...current, ...partial };
47
+ await fs.writeFile(STUDIO_CONFIG_PATH, JSON.stringify(merged, null, 2), 'utf-8');
48
+ return merged;
49
+ }
50
+ // ── Stages Dir ──────────────────────────────────────────
51
+ export function stagesDir() {
52
+ return path.join(STUDIO_DIR, 'stages');
53
+ }
@@ -0,0 +1,245 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { assetFilePath, ensureDotDir, getGlobalDotDir } from 'dance-of-tal/lib/registry';
4
+ import { getPayloadTags, loadLocalAssetByUrn, parseUrn, publishSingleAsset, resolveDependencies, } from 'dance-of-tal/lib/publishing';
5
+ const SLUG_RE = /^[a-z0-9][a-z0-9._-]{1,98}[a-z0-9]$/;
6
+ function isRecord(value) {
7
+ return !!value && typeof value === 'object' && !Array.isArray(value);
8
+ }
9
+ function sanitizeSlug(value) {
10
+ const slug = value.trim();
11
+ if (!SLUG_RE.test(slug)) {
12
+ throw new Error('Slug must use lowercase letters, numbers, dots, underscores, or hyphens (2-100 chars).');
13
+ }
14
+ return slug;
15
+ }
16
+ function sanitizeTags(value) {
17
+ if (!Array.isArray(value)) {
18
+ return [];
19
+ }
20
+ return value
21
+ .filter((tag) => typeof tag === 'string')
22
+ .map((tag) => tag.trim())
23
+ .filter(Boolean);
24
+ }
25
+ function sanitizeAuthor(value) {
26
+ const author = value.trim().replace(/^@/, '');
27
+ if (!author) {
28
+ throw new Error('Author is required.');
29
+ }
30
+ return author;
31
+ }
32
+ function todayIsoDate() {
33
+ return new Date().toISOString().split('T')[0];
34
+ }
35
+ function normalizeDescription(name, value) {
36
+ return typeof value === 'string' && value.trim() ? value.trim() : name;
37
+ }
38
+ function ensureUrn(value, kind) {
39
+ if (typeof value !== 'string') {
40
+ throw new Error(`${kind} reference must be a string URN.`);
41
+ }
42
+ const urn = value.trim();
43
+ const parts = urn.split('/');
44
+ if (parts.length !== 3 || parts[0] !== kind || !parts[1].startsWith('@') || !parts[2]) {
45
+ throw new Error(`Invalid URN '${urn}'. Expected ${kind}/@<author>/<name>.`);
46
+ }
47
+ return urn;
48
+ }
49
+ function normalizeTalPayload(author, slug, payload) {
50
+ const name = typeof payload.name === 'string' && payload.name.trim() ? payload.name.trim() : slug;
51
+ const content = typeof payload.content === 'string' ? payload.content : '';
52
+ return {
53
+ type: `tal/@${author}/${slug}`,
54
+ slug,
55
+ name,
56
+ description: normalizeDescription(name, payload.description),
57
+ tags: sanitizeTags(payload.tags),
58
+ featuredScore: typeof payload.featuredScore === 'number' ? payload.featuredScore : 0,
59
+ createdAt: typeof payload.createdAt === 'string' && payload.createdAt.trim() ? payload.createdAt : todayIsoDate(),
60
+ content,
61
+ };
62
+ }
63
+ function normalizeDancePayload(author, slug, payload) {
64
+ const name = typeof payload.name === 'string' && payload.name.trim() ? payload.name.trim() : slug;
65
+ const content = typeof payload.content === 'string' ? payload.content : '';
66
+ return {
67
+ type: `dance/@${author}/${slug}`,
68
+ slug,
69
+ name,
70
+ description: normalizeDescription(name, payload.description),
71
+ tags: sanitizeTags(payload.tags),
72
+ content,
73
+ ...(isRecord(payload.schema) ? { schema: payload.schema } : {}),
74
+ ...(isRecord(payload.exemplarSet) ? { exemplarSet: payload.exemplarSet } : {}),
75
+ };
76
+ }
77
+ function normalizePerformerPayload(author, slug, payload) {
78
+ const name = typeof payload.name === 'string' && payload.name.trim() ? payload.name.trim() : slug;
79
+ const danceValue = payload.dance;
80
+ const dance = typeof danceValue === 'string'
81
+ ? ensureUrn(danceValue, 'dance')
82
+ : Array.isArray(danceValue)
83
+ ? danceValue.map((value) => ensureUrn(value, 'dance'))
84
+ : undefined;
85
+ const tal = payload.tal !== undefined && payload.tal !== null ? ensureUrn(payload.tal, 'tal') : undefined;
86
+ const act = payload.act !== undefined && payload.act !== null ? ensureUrn(payload.act, 'act') : undefined;
87
+ if (!tal && (!dance || dance.length === 0)) {
88
+ throw new Error("Performer assets require at least one Tal or Dance reference.");
89
+ }
90
+ return {
91
+ type: `performer/@${author}/${slug}`,
92
+ slug,
93
+ name,
94
+ description: normalizeDescription(name, payload.description),
95
+ tags: sanitizeTags(payload.tags),
96
+ ...(tal ? { tal } : {}),
97
+ ...(dance
98
+ ? { dance: Array.isArray(dance) && dance.length === 1 ? dance[0] : dance }
99
+ : {}),
100
+ ...(act ? { act } : {}),
101
+ ...(typeof payload.model === 'string' && payload.model.trim() ? { model: payload.model.trim() } : {}),
102
+ ...(isRecord(payload.mcp_config) ? { mcp_config: payload.mcp_config } : {}),
103
+ };
104
+ }
105
+ function normalizeActPayload(author, slug, payload) {
106
+ const name = typeof payload.name === 'string' && payload.name.trim() ? payload.name.trim() : slug;
107
+ return {
108
+ type: `act/@${author}/${slug}`,
109
+ slug,
110
+ name,
111
+ description: normalizeDescription(name, payload.description),
112
+ tags: sanitizeTags(payload.tags),
113
+ entryNode: typeof payload.entryNode === 'string' ? payload.entryNode : '',
114
+ nodes: isRecord(payload.nodes) ? payload.nodes : {},
115
+ edges: Array.isArray(payload.edges) ? payload.edges : [],
116
+ ...(typeof payload.maxIterations === 'number' ? { maxIterations: payload.maxIterations } : {}),
117
+ };
118
+ }
119
+ export function normalizeStudioAssetPayload(kind, authorInput, slugInput, payloadInput) {
120
+ if (!isRecord(payloadInput)) {
121
+ throw new Error('Asset payload must be a JSON object.');
122
+ }
123
+ const author = sanitizeAuthor(authorInput);
124
+ const slug = sanitizeSlug(slugInput);
125
+ switch (kind) {
126
+ case 'tal':
127
+ return normalizeTalPayload(author, slug, payloadInput);
128
+ case 'dance':
129
+ return normalizeDancePayload(author, slug, payloadInput);
130
+ case 'performer':
131
+ return normalizePerformerPayload(author, slug, payloadInput);
132
+ case 'act':
133
+ return normalizeActPayload(author, slug, payloadInput);
134
+ }
135
+ }
136
+ export async function readDotAuthUser() {
137
+ try {
138
+ const raw = await fs.readFile(path.join(getGlobalDotDir(), 'auth.json'), 'utf-8');
139
+ const parsed = JSON.parse(raw);
140
+ if (!parsed?.token || !parsed?.username) {
141
+ return null;
142
+ }
143
+ return {
144
+ token: String(parsed.token),
145
+ username: sanitizeAuthor(String(parsed.username)),
146
+ };
147
+ }
148
+ catch {
149
+ return null;
150
+ }
151
+ }
152
+ export async function clearDotAuthUser() {
153
+ try {
154
+ await fs.unlink(path.join(getGlobalDotDir(), 'auth.json'));
155
+ }
156
+ catch (error) {
157
+ if (error?.code !== 'ENOENT') {
158
+ throw error;
159
+ }
160
+ }
161
+ }
162
+ export async function saveLocalStudioAsset(options) {
163
+ const normalized = normalizeStudioAssetPayload(options.kind, options.author, options.slug, options.payload);
164
+ const urn = `${options.kind}/@${sanitizeAuthor(options.author)}/${sanitizeSlug(options.slug)}`;
165
+ await ensureDotDir(options.cwd);
166
+ const filePath = assetFilePath(options.cwd, urn);
167
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
168
+ let existed = true;
169
+ try {
170
+ await fs.access(filePath);
171
+ }
172
+ catch {
173
+ existed = false;
174
+ }
175
+ await fs.writeFile(filePath, JSON.stringify(normalized, null, 2), 'utf-8');
176
+ return {
177
+ urn,
178
+ path: filePath,
179
+ existed,
180
+ payload: normalized,
181
+ };
182
+ }
183
+ export async function publishStudioAsset(options) {
184
+ const slug = sanitizeSlug(options.slug);
185
+ const username = sanitizeAuthor(options.auth.username);
186
+ let localPayload;
187
+ if (options.payload !== undefined) {
188
+ const saved = await saveLocalStudioAsset({
189
+ cwd: options.cwd,
190
+ kind: options.kind,
191
+ author: username,
192
+ slug,
193
+ payload: options.payload,
194
+ });
195
+ localPayload = saved.payload;
196
+ }
197
+ else {
198
+ const urn = `${options.kind}/@${username}/${slug}`;
199
+ const existing = await loadLocalAssetByUrn(options.cwd, urn);
200
+ if (!existing) {
201
+ throw new Error(`Local asset '${urn}' was not found. Save it locally before publishing.`);
202
+ }
203
+ localPayload = existing;
204
+ }
205
+ const dependenciesPublished = [];
206
+ const dependenciesSkipped = [];
207
+ const dependenciesExisting = [];
208
+ if (options.kind === 'performer' || options.kind === 'act') {
209
+ const dependencies = await resolveDependencies(options.cwd, options.kind, localPayload, username);
210
+ const foreignMissing = dependencies.filter((dep) => dep.status === 'foreign_missing');
211
+ if (foreignMissing.length > 0) {
212
+ throw new Error(`Cannot publish because some dependencies are missing from the registry and belong to other authors: ${foreignMissing.map((dep) => dep.urn).join(', ')}.`);
213
+ }
214
+ for (const dependency of dependencies) {
215
+ if (dependency.status === 'exists') {
216
+ dependenciesExisting.push(dependency.urn);
217
+ continue;
218
+ }
219
+ if (!dependency.payload) {
220
+ continue;
221
+ }
222
+ const parsed = parseUrn(dependency.urn);
223
+ if (!parsed) {
224
+ continue;
225
+ }
226
+ const published = await publishSingleAsset(parsed.kind, parsed.name, dependency.payload, sanitizeTags(getPayloadTags(dependency.payload)), options.auth.token);
227
+ if (published) {
228
+ dependenciesPublished.push(dependency.urn);
229
+ }
230
+ else {
231
+ dependenciesSkipped.push(dependency.urn);
232
+ }
233
+ }
234
+ }
235
+ const tags = options.tags && options.tags.length > 0 ? sanitizeTags(options.tags) : sanitizeTags(getPayloadTags(localPayload));
236
+ const mainPublished = await publishSingleAsset(options.kind, slug, localPayload, tags, options.auth.token);
237
+ const urn = `${options.kind}/@${username}/${slug}`;
238
+ return {
239
+ urn,
240
+ published: mainPublished,
241
+ dependenciesPublished,
242
+ dependenciesSkipped,
243
+ dependenciesExisting,
244
+ };
245
+ }
@@ -0,0 +1,61 @@
1
+ import path from 'path';
2
+ import { createHash } from 'crypto';
3
+ import { getOpencode } from './opencode.js';
4
+ import { unwrapOpencodeResult } from './opencode-errors.js';
5
+ import { resolvePackageBin } from './package-bin.js';
6
+ export const CAPABILITY_LOADER_TOOL_NAME = 'load_capability_context';
7
+ function resolveDotCommand() {
8
+ return resolvePackageBin('dance-of-tal', 'dance-of-tal') || 'dance-of-tal';
9
+ }
10
+ export function dotLoaderServerName(cwd) {
11
+ const hash = createHash('sha1').update(path.resolve(cwd)).digest('hex').slice(0, 10);
12
+ return `dot-stage-${hash}`;
13
+ }
14
+ function resolveCapabilityToolId(toolIds) {
15
+ const exact = toolIds.find((toolId) => toolId === CAPABILITY_LOADER_TOOL_NAME);
16
+ if (exact) {
17
+ return exact;
18
+ }
19
+ return toolIds.find((toolId) => (toolId.endsWith(`/${CAPABILITY_LOADER_TOOL_NAME}`)
20
+ || toolId.endsWith(`:${CAPABILITY_LOADER_TOOL_NAME}`)
21
+ || toolId.endsWith(`.${CAPABILITY_LOADER_TOOL_NAME}`))) || null;
22
+ }
23
+ export async function ensureDotLoaderServer(cwd) {
24
+ const serverName = dotLoaderServerName(cwd);
25
+ const oc = await getOpencode();
26
+ const params = { directory: path.resolve(cwd) };
27
+ const statusData = unwrapOpencodeResult(await oc.mcp.status(params)) || {};
28
+ const existing = statusData[serverName];
29
+ if (!existing) {
30
+ unwrapOpencodeResult(await oc.mcp.add({
31
+ ...params,
32
+ name: serverName,
33
+ config: {
34
+ type: 'local',
35
+ command: [resolveDotCommand()],
36
+ enabled: true,
37
+ environment: {
38
+ DANCE_OF_TAL_PROJECT_DIR: path.resolve(cwd),
39
+ },
40
+ },
41
+ }));
42
+ }
43
+ const refreshedStatusData = unwrapOpencodeResult(await oc.mcp.status(params)) || {};
44
+ const status = refreshedStatusData[serverName];
45
+ if (!status || status.status !== 'connected') {
46
+ unwrapOpencodeResult(await oc.mcp.connect({
47
+ name: serverName,
48
+ ...params,
49
+ }));
50
+ }
51
+ const toolIds = unwrapOpencodeResult(await oc.tool.ids(params)) || [];
52
+ const resolvedToolId = resolveCapabilityToolId(toolIds);
53
+ if (!resolvedToolId) {
54
+ throw new Error(`Capability loader tool '${CAPABILITY_LOADER_TOOL_NAME}' is unavailable after MCP connection.`);
55
+ }
56
+ return {
57
+ available: true,
58
+ serverName,
59
+ toolName: resolvedToolId,
60
+ };
61
+ }
@@ -0,0 +1,190 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import http from 'http';
4
+ import crypto from 'crypto';
5
+ import open from 'open';
6
+ import { getGlobalDotDir } from 'dance-of-tal/lib/registry';
7
+ import { readDotAuthUser } from './dot-authoring.js';
8
+ const SUPABASE_URL = process.env.DOT_SUPABASE_URL || 'https://qbildcrfjencoqkngyfw.supabase.co';
9
+ const SUPABASE_ANON_KEY = process.env.DOT_SUPABASE_ANON_KEY || 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InFiaWxkY3JmamVuY29xa25neWZ3Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzIyNjE5MzYsImV4cCI6MjA4NzgzNzkzNn0.9aI9FU-j20w3UIG7BuVtmpAPh3qClz7xTNXjcq7ofNQ';
10
+ const DOT_LOGIN_REDIRECT_URI = 'http://localhost:4242/callback';
11
+ const LOGIN_SERVER_TIMEOUT_MS = 180_000;
12
+ let loginState = null;
13
+ function getAuthFilePath() {
14
+ return path.join(getGlobalDotDir(), 'auth.json');
15
+ }
16
+ async function saveAuthToken(token, username) {
17
+ const authFile = getAuthFilePath();
18
+ await fs.mkdir(path.dirname(authFile), { recursive: true });
19
+ await fs.writeFile(authFile, JSON.stringify({ token, username }, null, 2), 'utf-8');
20
+ }
21
+ function generateCodeVerifier() {
22
+ return crypto.randomBytes(32).toString('base64url');
23
+ }
24
+ function generateCodeChallenge(verifier) {
25
+ return crypto.createHash('sha256').update(verifier).digest('base64url');
26
+ }
27
+ function clearLoginState() {
28
+ if (!loginState) {
29
+ return;
30
+ }
31
+ clearTimeout(loginState.timeout);
32
+ loginState.server.close();
33
+ loginState = null;
34
+ }
35
+ async function releaseStaleLoginPort() {
36
+ const controller = new AbortController();
37
+ const timer = setTimeout(() => controller.abort(), 1_500);
38
+ try {
39
+ // Legacy dot login closes itself when /callback is hit without a code.
40
+ await fetch(DOT_LOGIN_REDIRECT_URI, {
41
+ method: 'GET',
42
+ signal: controller.signal,
43
+ }).catch(() => { });
44
+ await new Promise((resolve) => setTimeout(resolve, 250));
45
+ }
46
+ finally {
47
+ clearTimeout(timer);
48
+ }
49
+ }
50
+ async function listenOnLoginPort(server) {
51
+ const tryListen = async () => new Promise((resolve, reject) => {
52
+ const onError = (error) => {
53
+ server.off('listening', onListening);
54
+ reject(error);
55
+ };
56
+ const onListening = () => {
57
+ server.off('error', onError);
58
+ resolve();
59
+ };
60
+ server.once('error', onError);
61
+ server.once('listening', onListening);
62
+ server.listen(4242);
63
+ });
64
+ try {
65
+ await tryListen();
66
+ }
67
+ catch (error) {
68
+ if (error?.code !== 'EADDRINUSE') {
69
+ throw error;
70
+ }
71
+ await releaseStaleLoginPort();
72
+ try {
73
+ await tryListen();
74
+ }
75
+ catch (retryError) {
76
+ if (retryError?.code === 'EADDRINUSE') {
77
+ throw new Error('Port 4242 is already in use by another process. Finish or close the other DOT login flow, then try again.');
78
+ }
79
+ throw retryError;
80
+ }
81
+ }
82
+ }
83
+ export async function startDotLogin() {
84
+ const auth = await readDotAuthUser();
85
+ if (auth) {
86
+ return {
87
+ started: false,
88
+ alreadyAuthenticated: true,
89
+ username: auth.username,
90
+ };
91
+ }
92
+ if (loginState) {
93
+ return {
94
+ started: false,
95
+ alreadyRunning: true,
96
+ authUrl: loginState.authUrl,
97
+ browserOpened: false,
98
+ };
99
+ }
100
+ const codeVerifier = generateCodeVerifier();
101
+ const codeChallenge = generateCodeChallenge(codeVerifier);
102
+ const authUrl = `${SUPABASE_URL}/auth/v1/authorize?provider=github&redirect_to=${encodeURIComponent(DOT_LOGIN_REDIRECT_URI)}&code_challenge=${codeChallenge}&code_challenge_method=s256`;
103
+ const server = http.createServer(async (req, res) => {
104
+ try {
105
+ const url = new URL(req.url || '/', DOT_LOGIN_REDIRECT_URI);
106
+ if (url.pathname !== '/callback') {
107
+ res.writeHead(404).end('Not Found');
108
+ return;
109
+ }
110
+ const code = url.searchParams.get('code');
111
+ if (!code) {
112
+ res.writeHead(400, { 'Content-Type': 'text/html' });
113
+ res.end("<h2 style='color: red; text-align: center; font-family: sans-serif; margin-top: 50px;'>Authentication failed: No code received. You can close this window.</h2>");
114
+ clearLoginState();
115
+ return;
116
+ }
117
+ res.writeHead(200, { 'Content-Type': 'text/html' });
118
+ res.write("<h2 style='font-family: sans-serif; text-align: center; margin-top: 50px;'>Completing authentication... Please wait.</h2>");
119
+ try {
120
+ const tokenRes = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=pkce`, {
121
+ method: 'POST',
122
+ headers: {
123
+ 'Content-Type': 'application/json',
124
+ apikey: SUPABASE_ANON_KEY,
125
+ },
126
+ body: JSON.stringify({
127
+ auth_code: code,
128
+ code_verifier: codeVerifier,
129
+ }),
130
+ });
131
+ const data = await tokenRes.json();
132
+ if (!tokenRes.ok || !data.access_token) {
133
+ throw new Error(data.error_description || data.msg || 'Failed to exchange token');
134
+ }
135
+ const username = data.user?.user_metadata?.preferred_username || data.user?.user_metadata?.user_name;
136
+ if (!username) {
137
+ throw new Error('Could not determine GitHub username from token.');
138
+ }
139
+ await saveAuthToken(data.access_token, username);
140
+ res.end(`
141
+ <script>
142
+ document.body.innerHTML = "<h2 style='color: green; font-family: sans-serif; text-align: center; margin-top: 50px;'>Authentication Successful! You can safely close this window.</h2>";
143
+ setTimeout(() => window.close(), 3000);
144
+ </script>
145
+ `);
146
+ }
147
+ catch (error) {
148
+ res.end(`
149
+ <script>
150
+ document.body.innerHTML = "<h2 style='color: red; font-family: sans-serif; text-align: center; margin-top: 50px;'>Authentication Failed. ${String(error?.message || 'Unknown error')}</h2>";
151
+ </script>
152
+ `);
153
+ }
154
+ finally {
155
+ clearLoginState();
156
+ }
157
+ }
158
+ catch {
159
+ try {
160
+ res.writeHead(500).end('Server Error');
161
+ }
162
+ catch {
163
+ // ignore
164
+ }
165
+ clearLoginState();
166
+ }
167
+ });
168
+ await listenOnLoginPort(server);
169
+ loginState = {
170
+ server,
171
+ authUrl,
172
+ timeout: setTimeout(() => {
173
+ clearLoginState();
174
+ }, LOGIN_SERVER_TIMEOUT_MS),
175
+ };
176
+ let browserOpened = true;
177
+ try {
178
+ await open(authUrl);
179
+ }
180
+ catch {
181
+ browserOpened = false;
182
+ }
183
+ return {
184
+ started: true,
185
+ alreadyRunning: false,
186
+ alreadyAuthenticated: false,
187
+ authUrl,
188
+ browserOpened,
189
+ };
190
+ }
@@ -0,0 +1,111 @@
1
+ import { getOpencode } from './opencode.js';
2
+ import { readStoredProviderAuthType } from './opencode-auth.js';
3
+ import { normalizeRuntimeVariants, } from '../../shared/model-variants.js';
4
+ const incompatibleModelsByAuthType = {
5
+ openai: {
6
+ // ChatGPT account-backed OpenAI auth rejects these at runtime today.
7
+ oauth: new Set([
8
+ 'codex-mini-latest',
9
+ 'gpt-5.3-codex-spark',
10
+ ]),
11
+ },
12
+ };
13
+ function readCapabilityFlag(model, ...keys) {
14
+ const capabilityRecord = model.capabilities && typeof model.capabilities === 'object'
15
+ ? model.capabilities
16
+ : {};
17
+ for (const key of keys) {
18
+ if (typeof capabilityRecord[key] === 'boolean') {
19
+ return capabilityRecord[key];
20
+ }
21
+ if (typeof model[key] === 'boolean') {
22
+ return model[key];
23
+ }
24
+ }
25
+ return false;
26
+ }
27
+ function readModalities(model) {
28
+ const capabilityRecord = model.capabilities && typeof model.capabilities === 'object'
29
+ ? model.capabilities
30
+ : {};
31
+ const input = Array.isArray(capabilityRecord.input)
32
+ ? capabilityRecord.input.filter((value) => typeof value === 'string')
33
+ : Array.isArray(model.modalities?.input)
34
+ ? model.modalities.input.filter((value) => typeof value === 'string')
35
+ : ['text'];
36
+ const output = Array.isArray(capabilityRecord.output)
37
+ ? capabilityRecord.output.filter((value) => typeof value === 'string')
38
+ : Array.isArray(model.modalities?.output)
39
+ ? model.modalities.output.filter((value) => typeof value === 'string')
40
+ : ['text'];
41
+ return { input, output };
42
+ }
43
+ function isModelVisibleForAuthType(providerId, modelId, authType) {
44
+ if (!authType) {
45
+ return true;
46
+ }
47
+ const blocked = incompatibleModelsByAuthType[providerId]?.[authType];
48
+ if (!blocked) {
49
+ return true;
50
+ }
51
+ return !blocked.has(modelId);
52
+ }
53
+ export async function listRuntimeModels(cwd) {
54
+ const oc = await getOpencode();
55
+ const res = await oc.provider.list({ directory: cwd });
56
+ const data = res.data;
57
+ if (!data?.all || !Array.isArray(data.all)) {
58
+ return [];
59
+ }
60
+ const connectedProviders = new Set(Array.isArray(data.connected)
61
+ ? data.connected.filter((value) => typeof value === 'string')
62
+ : []);
63
+ const authTypes = new Map();
64
+ const models = [];
65
+ for (const provider of data.all) {
66
+ const providerId = typeof provider.id === 'string' ? provider.id : '';
67
+ const providerName = typeof provider.name === 'string' ? provider.name : providerId;
68
+ const connected = connectedProviders.has(providerId);
69
+ if (!authTypes.has(providerId)) {
70
+ authTypes.set(providerId, connected ? await readStoredProviderAuthType(providerId) : null);
71
+ }
72
+ const authType = authTypes.get(providerId) || null;
73
+ const rawModels = provider.models && typeof provider.models === 'object'
74
+ ? provider.models
75
+ : {};
76
+ for (const model of Object.values(rawModels)) {
77
+ const record = model;
78
+ const id = typeof record.id === 'string' ? record.id : '';
79
+ if (!id) {
80
+ continue;
81
+ }
82
+ if (!isModelVisibleForAuthType(providerId, id, authType)) {
83
+ continue;
84
+ }
85
+ models.push({
86
+ provider: providerId,
87
+ providerName,
88
+ id,
89
+ name: typeof record.name === 'string' ? record.name : id,
90
+ connected,
91
+ context: Number(record.limit?.context || 0),
92
+ output: Number(record.limit?.output || 0),
93
+ toolCall: readCapabilityFlag(record, 'toolcall', 'toolCall', 'tool_call'),
94
+ reasoning: readCapabilityFlag(record, 'reasoning'),
95
+ attachment: readCapabilityFlag(record, 'attachment'),
96
+ temperature: readCapabilityFlag(record, 'temperature'),
97
+ modalities: readModalities(record),
98
+ variants: normalizeRuntimeVariants(record.variants),
99
+ });
100
+ }
101
+ }
102
+ return models;
103
+ }
104
+ export async function resolveRuntimeModel(cwd, selection) {
105
+ if (!selection) {
106
+ return null;
107
+ }
108
+ const models = await listRuntimeModels(cwd);
109
+ return models.find((model) => (model.provider === selection.provider
110
+ && model.id === selection.modelId)) || null;
111
+ }