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,69 @@
1
+ import fs from 'fs/promises';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ function authStoreCandidates() {
5
+ const home = os.homedir();
6
+ const candidates = [
7
+ process.env.OPENCODE_AUTH_PATH,
8
+ process.env.XDG_DATA_HOME ? path.join(process.env.XDG_DATA_HOME, 'opencode', 'auth.json') : null,
9
+ path.join(home, '.local', 'share', 'opencode', 'auth.json'),
10
+ path.join(home, 'Library', 'Application Support', 'opencode', 'auth.json'),
11
+ path.join(home, 'AppData', 'Local', 'opencode', 'auth.json'),
12
+ ];
13
+ return candidates.filter((candidate) => Boolean(candidate));
14
+ }
15
+ async function resolveAuthStorePath() {
16
+ for (const candidate of authStoreCandidates()) {
17
+ try {
18
+ await fs.access(candidate);
19
+ return candidate;
20
+ }
21
+ catch {
22
+ continue;
23
+ }
24
+ }
25
+ return authStoreCandidates()[0];
26
+ }
27
+ export async function readStoredProviderAuthType(providerId) {
28
+ const authPath = await resolveAuthStorePath();
29
+ if (!authPath) {
30
+ return null;
31
+ }
32
+ try {
33
+ const raw = await fs.readFile(authPath, 'utf-8');
34
+ const parsed = JSON.parse(raw);
35
+ const normalized = providerId.replace(/\/+$/, '');
36
+ const entry = parsed[providerId] || parsed[normalized] || parsed[`${normalized}/`];
37
+ if (!entry || typeof entry !== 'object') {
38
+ return null;
39
+ }
40
+ const type = entry.type;
41
+ return typeof type === 'string' && type.trim() ? type.trim() : null;
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ export async function clearStoredProviderAuth(providerId) {
48
+ const authPath = await resolveAuthStorePath();
49
+ if (!authPath) {
50
+ return false;
51
+ }
52
+ let current = {};
53
+ try {
54
+ const raw = await fs.readFile(authPath, 'utf-8');
55
+ current = JSON.parse(raw);
56
+ }
57
+ catch (error) {
58
+ if (error?.code !== 'ENOENT') {
59
+ throw error;
60
+ }
61
+ }
62
+ const normalized = providerId.replace(/\/+$/, '');
63
+ delete current[providerId];
64
+ delete current[normalized];
65
+ delete current[`${normalized}/`];
66
+ await fs.mkdir(path.dirname(authPath), { recursive: true });
67
+ await fs.writeFile(authPath, `${JSON.stringify(current, null, 2)}\n`, { mode: 0o600 });
68
+ return true;
69
+ }
@@ -0,0 +1,220 @@
1
+ export class StudioValidationError extends Error {
2
+ action;
3
+ status;
4
+ constructor(message, action = 'fix_input', status = 400) {
5
+ super(message);
6
+ this.name = 'StudioValidationError';
7
+ this.action = action;
8
+ this.status = status;
9
+ }
10
+ }
11
+ function extractStatus(err) {
12
+ const candidates = [
13
+ err?.status,
14
+ err?.statusCode,
15
+ err?.data?.statusCode,
16
+ err?.response?.status,
17
+ err?.cause?.status,
18
+ err?.cause?.statusCode,
19
+ err?.cause?.response?.status,
20
+ ];
21
+ for (const candidate of candidates) {
22
+ if (typeof candidate === 'number' && Number.isFinite(candidate)) {
23
+ return candidate;
24
+ }
25
+ }
26
+ return undefined;
27
+ }
28
+ function extractBodyMessage(body) {
29
+ if (typeof body !== 'string' || !body.trim()) {
30
+ return null;
31
+ }
32
+ try {
33
+ const parsed = JSON.parse(body);
34
+ if (parsed && typeof parsed === 'object') {
35
+ if (typeof parsed.error === 'string' && parsed.error.trim()) {
36
+ return parsed.error.trim();
37
+ }
38
+ if (typeof parsed.message === 'string' && parsed.message.trim()) {
39
+ return parsed.message.trim();
40
+ }
41
+ }
42
+ }
43
+ catch {
44
+ return body.trim();
45
+ }
46
+ return body.trim();
47
+ }
48
+ function extractMessage(err) {
49
+ const message = [
50
+ err?.data?.message,
51
+ err?.message,
52
+ err?.error?.message,
53
+ err?.cause?.data?.message,
54
+ err?.cause?.message,
55
+ extractBodyMessage(err?.data?.responseBody),
56
+ extractBodyMessage(err?.responseBody),
57
+ ].find((candidate) => typeof candidate === 'string' && candidate.trim().length > 0);
58
+ return message || 'OpenCode request failed.';
59
+ }
60
+ function extractProviderId(err, context) {
61
+ return context.providerId?.trim()
62
+ || err?.data?.providerID
63
+ || err?.providerId
64
+ || context.model?.provider
65
+ || undefined;
66
+ }
67
+ function isProviderAuthError(name, message, status) {
68
+ return name === 'ProviderAuthError'
69
+ || status === 401
70
+ || status === 403
71
+ || /\b(unauthorized|forbidden|authentication|auth\b|api key|credentials?|token expired|provider auth)\b/i.test(message);
72
+ }
73
+ function isModelUnavailableError(message) {
74
+ return /\b(model|provider\/model)\b/i.test(message)
75
+ && /\b(not found|not available|unavailable|unsupported|unknown|invalid|does not exist|missing)\b/i.test(message);
76
+ }
77
+ function isRuntimeUnavailableError(message, status) {
78
+ return status === 502
79
+ || status === 503
80
+ || status === 504
81
+ || /\b(ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENOTFOUND|socket hang up|failed to fetch|network error|connection refused|service unavailable|gateway timeout)\b/i.test(message);
82
+ }
83
+ function isStructuredOutputError(name, message) {
84
+ return name === 'StructuredOutputError'
85
+ || /\b(structured output|json schema|output schema|format validation)\b/i.test(message);
86
+ }
87
+ function isContextOverflowError(name, message) {
88
+ return name === 'ContextOverflowError'
89
+ || /\b(context overflow|context window|prompt is too long|too many tokens|maximum context|exceeds context)\b/i.test(message);
90
+ }
91
+ function isSdkContractError(message, status) {
92
+ return status === 404
93
+ || /\b(no body in sse response|unexpected response|response validation|invalid response|failed to parse|cannot read properties of undefined|not implemented)\b/i.test(message);
94
+ }
95
+ export function normalizeOpencodeError(err, context = {}) {
96
+ if (err instanceof StudioValidationError) {
97
+ return {
98
+ error: err.message,
99
+ detail: err.message,
100
+ code: 'validation',
101
+ action: err.action,
102
+ retryable: false,
103
+ status: err.status,
104
+ };
105
+ }
106
+ const raw = err;
107
+ const name = typeof raw?.name === 'string'
108
+ ? raw.name
109
+ : typeof raw?.error?.name === 'string'
110
+ ? raw.error.name
111
+ : 'UnknownError';
112
+ const detail = extractMessage(raw);
113
+ const status = extractStatus(raw);
114
+ const providerId = extractProviderId(raw, context);
115
+ const modelId = context.model?.modelId;
116
+ const retryable = raw?.data?.isRetryable === true || (!!status && status >= 500);
117
+ if (isProviderAuthError(name, detail, status)) {
118
+ return {
119
+ error: `Provider authentication is missing or expired${providerId ? ` for ${providerId}` : ''}. Reconnect it in Settings and try again.`,
120
+ detail,
121
+ code: 'provider_auth',
122
+ action: 'reconnect_provider',
123
+ retryable: false,
124
+ status: status || 401,
125
+ ...(providerId ? { providerId } : {}),
126
+ ...(modelId ? { modelId } : {}),
127
+ };
128
+ }
129
+ if (isModelUnavailableError(detail)) {
130
+ return {
131
+ error: `The selected model${modelId ? ` (${modelId})` : ''} is unavailable. Choose another model for this performer and try again.`,
132
+ detail,
133
+ code: 'model_unavailable',
134
+ action: 'choose_model',
135
+ retryable: false,
136
+ status: status || 404,
137
+ ...(providerId ? { providerId } : {}),
138
+ ...(modelId ? { modelId } : {}),
139
+ };
140
+ }
141
+ if (isContextOverflowError(name, detail)) {
142
+ return {
143
+ error: 'The current context is too large for the selected model. Reduce context, switch variants, or choose a model with a larger window.',
144
+ detail,
145
+ code: 'context_overflow',
146
+ action: 'reduce_context',
147
+ retryable: false,
148
+ status: status || 400,
149
+ ...(providerId ? { providerId } : {}),
150
+ ...(modelId ? { modelId } : {}),
151
+ };
152
+ }
153
+ if (isStructuredOutputError(name, detail)) {
154
+ return {
155
+ error: 'OpenCode could not satisfy the required structured output format. Retry, simplify the task, or adjust the current act node.',
156
+ detail,
157
+ code: 'structured_output',
158
+ action: 'retry',
159
+ retryable: true,
160
+ status: status || 422,
161
+ ...(providerId ? { providerId } : {}),
162
+ ...(modelId ? { modelId } : {}),
163
+ };
164
+ }
165
+ if (isRuntimeUnavailableError(detail, status)) {
166
+ return {
167
+ error: 'OpenCode is unavailable right now. Retry in a moment or restart OpenCode from Settings.',
168
+ detail,
169
+ code: 'runtime_unavailable',
170
+ action: 'restart_opencode',
171
+ retryable: true,
172
+ status: status || 503,
173
+ ...(providerId ? { providerId } : {}),
174
+ ...(modelId ? { modelId } : {}),
175
+ };
176
+ }
177
+ if (isSdkContractError(detail, status)) {
178
+ return {
179
+ error: 'Studio could not complete this request because the OpenCode API contract looks incompatible. Refresh Studio or restart OpenCode.',
180
+ detail,
181
+ code: 'sdk_contract',
182
+ action: 'refresh_studio',
183
+ retryable: false,
184
+ status: status || 502,
185
+ ...(providerId ? { providerId } : {}),
186
+ ...(modelId ? { modelId } : {}),
187
+ };
188
+ }
189
+ return {
190
+ error: detail,
191
+ detail,
192
+ code: 'unknown',
193
+ action: retryable ? 'retry' : 'fix_input',
194
+ retryable,
195
+ status: status || context.defaultStatus || 500,
196
+ ...(providerId ? { providerId } : {}),
197
+ ...(modelId ? { modelId } : {}),
198
+ };
199
+ }
200
+ export function jsonOpencodeError(c, err, context = {}) {
201
+ const payload = normalizeOpencodeError(err, context);
202
+ return c.json(payload, payload.status);
203
+ }
204
+ export function unwrapOpencodeResult(result) {
205
+ const value = result;
206
+ if (value && typeof value === 'object' && 'error' in value && value.error) {
207
+ throw value.error;
208
+ }
209
+ if (value && typeof value === 'object' && 'data' in value) {
210
+ return value.data;
211
+ }
212
+ return value;
213
+ }
214
+ export function unwrapPromptResult(result) {
215
+ const data = unwrapOpencodeResult(result);
216
+ if (data?.info?.error) {
217
+ throw data.info.error;
218
+ }
219
+ return data;
220
+ }
@@ -0,0 +1,144 @@
1
+ import { spawn } from 'child_process';
2
+ import path from 'path';
3
+ import { DEFAULT_PROJECT_DIR, OPENCODE_MANAGED, OPENCODE_URL } from './config.js';
4
+ import { resolvePackageBin } from './package-bin.js';
5
+ const STARTUP_TIMEOUT_MS = 15_000;
6
+ const HEALTHCHECK_INTERVAL_MS = 250;
7
+ const REACHABILITY_CACHE_MS = 1_000;
8
+ let child = null;
9
+ let startupPromise = null;
10
+ let reachabilityCache = null;
11
+ function sleep(ms) {
12
+ return new Promise((resolve) => setTimeout(resolve, ms));
13
+ }
14
+ function resolvePort() {
15
+ try {
16
+ const url = new URL(OPENCODE_URL);
17
+ if (url.port) {
18
+ return Number(url.port);
19
+ }
20
+ return url.protocol === 'https:' ? 443 : 80;
21
+ }
22
+ catch {
23
+ return 4096;
24
+ }
25
+ }
26
+ function resolveCommand() {
27
+ return process.env.OPENCODE_BIN || resolvePackageBin('opencode-ai', 'opencode') || 'opencode';
28
+ }
29
+ export function isManagedOpencode() {
30
+ return OPENCODE_MANAGED;
31
+ }
32
+ export function canRestartOpencodeSidecar() {
33
+ return !!child;
34
+ }
35
+ export async function isOpencodeReachable() {
36
+ try {
37
+ const url = new URL('/project', OPENCODE_URL);
38
+ url.searchParams.set('directory', path.resolve(DEFAULT_PROJECT_DIR));
39
+ const controller = new AbortController();
40
+ const timeout = setTimeout(() => controller.abort(), 1_500);
41
+ try {
42
+ const response = await fetch(url.toString(), { signal: controller.signal });
43
+ return response.ok;
44
+ }
45
+ finally {
46
+ clearTimeout(timeout);
47
+ }
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ }
53
+ async function getReachability(force = false) {
54
+ if (!force && reachabilityCache && Date.now() - reachabilityCache.checkedAt < REACHABILITY_CACHE_MS) {
55
+ return reachabilityCache.ok;
56
+ }
57
+ const ok = await isOpencodeReachable();
58
+ reachabilityCache = { ok, checkedAt: Date.now() };
59
+ return ok;
60
+ }
61
+ async function waitForReady() {
62
+ const deadline = Date.now() + STARTUP_TIMEOUT_MS;
63
+ while (Date.now() < deadline) {
64
+ if (await isOpencodeReachable()) {
65
+ return;
66
+ }
67
+ if (child && child.exitCode !== null) {
68
+ break;
69
+ }
70
+ await sleep(HEALTHCHECK_INTERVAL_MS);
71
+ }
72
+ throw new Error('OpenCode sidecar did not become ready in time.');
73
+ }
74
+ async function waitForShutdown() {
75
+ const deadline = Date.now() + STARTUP_TIMEOUT_MS;
76
+ while (Date.now() < deadline) {
77
+ if (!(await isOpencodeReachable())) {
78
+ return;
79
+ }
80
+ await sleep(HEALTHCHECK_INTERVAL_MS);
81
+ }
82
+ throw new Error('OpenCode sidecar did not stop in time.');
83
+ }
84
+ export async function ensureOpencodeSidecar() {
85
+ if (!isManagedOpencode()) {
86
+ return;
87
+ }
88
+ if (startupPromise) {
89
+ return startupPromise;
90
+ }
91
+ if (child && child.exitCode === null) {
92
+ if (reachabilityCache?.ok) {
93
+ return;
94
+ }
95
+ if (await getReachability()) {
96
+ return;
97
+ }
98
+ }
99
+ if (await getReachability()) {
100
+ return;
101
+ }
102
+ startupPromise = (async () => {
103
+ if (await getReachability()) {
104
+ return;
105
+ }
106
+ const opencode = spawn(resolveCommand(), ['--port', String(resolvePort()), path.resolve(DEFAULT_PROJECT_DIR)], {
107
+ cwd: path.resolve(DEFAULT_PROJECT_DIR),
108
+ env: process.env,
109
+ stdio: 'ignore',
110
+ });
111
+ child = opencode;
112
+ opencode.once('exit', () => {
113
+ if (child === opencode) {
114
+ child = null;
115
+ }
116
+ reachabilityCache = null;
117
+ });
118
+ await waitForReady();
119
+ reachabilityCache = { ok: true, checkedAt: Date.now() };
120
+ })().finally(() => {
121
+ startupPromise = null;
122
+ });
123
+ return startupPromise;
124
+ }
125
+ export async function restartOpencodeSidecar() {
126
+ if (!isManagedOpencode()) {
127
+ throw new Error('OpenCode restart is only available in managed mode.');
128
+ }
129
+ if (!child) {
130
+ if (await getReachability(true)) {
131
+ throw new Error('Managed OpenCode restart is unavailable because the current daemon was not started by Studio.');
132
+ }
133
+ return ensureOpencodeSidecar();
134
+ }
135
+ const currentChild = child;
136
+ currentChild.kill('SIGTERM');
137
+ await waitForShutdown().catch(async () => {
138
+ currentChild.kill('SIGKILL');
139
+ await waitForShutdown();
140
+ });
141
+ child = null;
142
+ reachabilityCache = null;
143
+ return ensureOpencodeSidecar();
144
+ }
@@ -0,0 +1,12 @@
1
+ // OpenCode SDK Client Singleton
2
+ import { createOpencodeClient } from '@opencode-ai/sdk/v2';
3
+ import { OPENCODE_URL } from './config.js';
4
+ import { ensureOpencodeSidecar } from './opencode-sidecar.js';
5
+ let opencode = null;
6
+ export async function getOpencode() {
7
+ await ensureOpencodeSidecar();
8
+ if (!opencode) {
9
+ opencode = createOpencodeClient({ baseUrl: OPENCODE_URL });
10
+ }
11
+ return opencode;
12
+ }
@@ -0,0 +1,63 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { createRequire } from 'module';
4
+ const require = createRequire(import.meta.url);
5
+ function readJson(pathname) {
6
+ try {
7
+ return JSON.parse(fs.readFileSync(pathname, 'utf-8'));
8
+ }
9
+ catch {
10
+ return null;
11
+ }
12
+ }
13
+ function findPackageJsonFromEntry(packageName, entryPath) {
14
+ let currentDir = path.dirname(entryPath);
15
+ while (true) {
16
+ const packageJsonPath = path.join(currentDir, 'package.json');
17
+ const packageJson = readJson(packageJsonPath);
18
+ if (packageJson?.name === packageName) {
19
+ return packageJsonPath;
20
+ }
21
+ const parentDir = path.dirname(currentDir);
22
+ if (parentDir === currentDir) {
23
+ return null;
24
+ }
25
+ currentDir = parentDir;
26
+ }
27
+ }
28
+ function resolvePackageJsonPath(packageName) {
29
+ try {
30
+ return require.resolve(`${packageName}/package.json`);
31
+ }
32
+ catch {
33
+ try {
34
+ const entryPath = require.resolve(packageName);
35
+ return findPackageJsonFromEntry(packageName, entryPath);
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ }
42
+ function resolveBinPath(packageJsonPath, binName) {
43
+ const packageJson = readJson(packageJsonPath);
44
+ const binField = packageJson?.bin;
45
+ if (!binField) {
46
+ return null;
47
+ }
48
+ if (typeof binField === 'string') {
49
+ return path.resolve(path.dirname(packageJsonPath), binField);
50
+ }
51
+ if (typeof binField[binName] === 'string') {
52
+ return path.resolve(path.dirname(packageJsonPath), binField[binName]);
53
+ }
54
+ const firstBin = Object.values(binField).find((value) => typeof value === 'string');
55
+ return firstBin ? path.resolve(path.dirname(packageJsonPath), firstBin) : null;
56
+ }
57
+ export function resolvePackageBin(packageName, binName) {
58
+ const packageJsonPath = resolvePackageJsonPath(packageName);
59
+ if (!packageJsonPath) {
60
+ return null;
61
+ }
62
+ return resolveBinPath(packageJsonPath, binName);
63
+ }
@@ -0,0 +1,39 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import { extractProjectMcpCatalog, projectMcpEntryEnabled, projectMcpEntryType, } from '../../shared/project-mcp.js';
4
+ export async function readProjectConfigFile(cwd) {
5
+ try {
6
+ const raw = await fs.readFile(path.join(cwd, 'config.json'), 'utf-8');
7
+ const parsed = JSON.parse(raw);
8
+ return parsed && typeof parsed === 'object' ? parsed : {};
9
+ }
10
+ catch {
11
+ return {};
12
+ }
13
+ }
14
+ export async function readProjectMcpCatalog(cwd) {
15
+ const config = await readProjectConfigFile(cwd);
16
+ return extractProjectMcpCatalog(config);
17
+ }
18
+ export function summarizeProjectMcpCatalog(catalog, liveStatus) {
19
+ return Object.keys({
20
+ ...catalog,
21
+ ...liveStatus,
22
+ })
23
+ .sort((left, right) => left.localeCompare(right))
24
+ .map((name) => {
25
+ const config = catalog[name];
26
+ const live = liveStatus[name];
27
+ const status = live?.status || (config ? (projectMcpEntryEnabled(config) ? 'disconnected' : 'disabled') : 'unknown');
28
+ return {
29
+ name,
30
+ status,
31
+ tools: live?.tools || [],
32
+ resources: live?.resources || [],
33
+ enabled: config ? projectMcpEntryEnabled(config) : false,
34
+ defined: !!config,
35
+ configType: projectMcpEntryType(config),
36
+ authStatus: status === 'needs_auth' ? 'needs_auth' : status === 'connected' ? 'ready' : 'n/a',
37
+ };
38
+ });
39
+ }