loopat 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +194 -0
- package/bin/loopat.mjs +65 -0
- package/package.json +52 -0
- package/server/package.json +22 -0
- package/server/src/api-tokens.ts +161 -0
- package/server/src/api-v1-openapi.ts +363 -0
- package/server/src/api-v1.ts +681 -0
- package/server/src/auth.ts +309 -0
- package/server/src/bootstrap.ts +113 -0
- package/server/src/chat.ts +390 -0
- package/server/src/claude-binary.ts +68 -0
- package/server/src/compose.ts +474 -0
- package/server/src/config.ts +783 -0
- package/server/src/files.ts +173 -0
- package/server/src/git-crypt-key.ts +36 -0
- package/server/src/git-host.ts +104 -0
- package/server/src/github.ts +161 -0
- package/server/src/index.ts +3204 -0
- package/server/src/kanban.ts +810 -0
- package/server/src/loop-stats.ts +225 -0
- package/server/src/loop-status.ts +67 -0
- package/server/src/loops.ts +1832 -0
- package/server/src/mcp-oauth.ts +516 -0
- package/server/src/onboarding.ts +105 -0
- package/server/src/paths.ts +190 -0
- package/server/src/personal-keys.ts +60 -0
- package/server/src/plugin-installer.ts +287 -0
- package/server/src/podman.ts +1216 -0
- package/server/src/presets.ts +30 -0
- package/server/src/profiles.ts +177 -0
- package/server/src/providers.ts +45 -0
- package/server/src/serve.ts +275 -0
- package/server/src/session.ts +1496 -0
- package/server/src/system-prompt.ts +90 -0
- package/server/src/term.ts +211 -0
- package/server/src/tiers.ts +762 -0
- package/server/src/vaults.ts +189 -0
- package/server/src/workspace.ts +501 -0
- package/server/templates/.claude-plugin/marketplace.json +13 -0
- package/server/templates/CLAUDE.md +78 -0
- package/server/templates/loop-kinds/distill/CLAUDE.md +46 -0
- package/server/templates/plugins/loopat/.claude-plugin/plugin.json +5 -0
- package/server/templates/plugins/loopat/skills/onboarding/SKILL.md +266 -0
- package/server/templates/plugins/loopat/skills/promote/SKILL.md +53 -0
- package/server/templates/sandbox/Containerfile +113 -0
- package/web/dist/assets/CodeEditor-BGODueTo.js +49 -0
- package/web/dist/assets/Editor-DMS25Vve.js +1 -0
- package/web/dist/assets/Markdown-CnHbW7WK.js +5 -0
- package/web/dist/assets/MilkdownEditor-nqo9_0v5.js +123 -0
- package/web/dist/assets/Terminal-BrP-ENHg.css +1 -0
- package/web/dist/assets/Terminal-CYWvxYam.js +174 -0
- package/web/dist/assets/index-DM5eO-Tv.js +163 -0
- package/web/dist/assets/index-DxIFezwv.css +1 -0
- package/web/dist/assets/w3c-keyname-BOAvb0qz.js +1 -0
- package/web/dist/favicon.svg +1 -0
- package/web/dist/index.html +14 -0
- package/web/dist/logo.png +0 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export const DEFAULT_PROVIDER_PRESETS: Array<{ name: string; baseUrl: string; models: string[] }> = [
|
|
2
|
+
{ name: "Anthropic", baseUrl: "https://api.anthropic.com",
|
|
3
|
+
models: ["claude-sonnet-4-20250514", "claude-opus-4-7-20251101"] },
|
|
4
|
+
{ name: "DeepSeek", baseUrl: "https://api.deepseek.com/anthropic",
|
|
5
|
+
models: ["deepseek-v4-pro[1m]", "deepseek-v4-flash[1m]"] },
|
|
6
|
+
{ name: "Kimi", baseUrl: "https://api.moonshot.cn/anthropic",
|
|
7
|
+
models: ["kimi-k2.6"] },
|
|
8
|
+
{ name: "MiniMax", baseUrl: "https://api.minimaxi.com/anthropic",
|
|
9
|
+
models: ["MiniMax-M2.7"] },
|
|
10
|
+
{ name: "OpenRouter", baseUrl: "https://openrouter.ai/api/v1",
|
|
11
|
+
models: ["anthropic/claude-sonnet-4", "openai/gpt-4o", "google/gemini-2.5-flash"] },
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_MISE_TOOL_PRESETS: Array<{ name: string; suggestedVersion: string; description?: string; backend?: string }> = [
|
|
15
|
+
{ name: "node", suggestedVersion: "22", description: "Node.js runtime" },
|
|
16
|
+
{ name: "python", suggestedVersion: "3.12", description: "Python runtime" },
|
|
17
|
+
{ name: "go", suggestedVersion: "1.22", description: "Go programming language" },
|
|
18
|
+
{ name: "rust", suggestedVersion: "stable", description: "Rust programming language" },
|
|
19
|
+
{ name: "bun", suggestedVersion: "latest", description: "Bun all-in-one runtime" },
|
|
20
|
+
{ name: "java", suggestedVersion: "21", description: "Java Development Kit" },
|
|
21
|
+
{ name: "terraform", suggestedVersion: "1.9", description: "Infrastructure as code", backend: "aqua:hashicorp/terraform" },
|
|
22
|
+
{ name: "lua", suggestedVersion: "5.1", description: "Lua scripting language" },
|
|
23
|
+
{ name: "zig", suggestedVersion: "0.13", description: "Zig general-purpose language" },
|
|
24
|
+
{ name: "ripgrep", suggestedVersion: "14.1", description: "Line-oriented search tool", backend: "aqua:BurntSushi/ripgrep" },
|
|
25
|
+
{ name: "fd", suggestedVersion: "10.2", description: "Fast file finder", backend: "aqua:sharkdp/fd" },
|
|
26
|
+
{ name: "jq", suggestedVersion: "1.7", description: "Command-line JSON processor", backend: "aqua:jqlang/jq" },
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
/** @deprecated — use DEFAULT_PROVIDER_PRESETS */
|
|
30
|
+
export const PROVIDER_PRESETS = DEFAULT_PROVIDER_PRESETS
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profile resolver — CC-native model (post-2026-05 refactor).
|
|
3
|
+
*
|
|
4
|
+
* A "profile" in loopat is a directory under `.loopat/profiles/<name>/`
|
|
5
|
+
* that contains a `.claude/` subdir (the same shape CC's project-tier uses:
|
|
6
|
+
* settings.json + CLAUDE.md + skills/ + agents/). No loopat-invented schema.
|
|
7
|
+
*
|
|
8
|
+
* On loop spawn, loopat:
|
|
9
|
+
* 1. Determines active profiles (user defaults + CLI flags)
|
|
10
|
+
* 2. Merges team's `.loopat/.claude/` + each active profile's `.claude/`
|
|
11
|
+
* + personal layer into loop's `.claude/` (handled by compose.ts)
|
|
12
|
+
* 3. Reads merged settings.json's `enabledPlugins` + `extraKnownMarketplaces`
|
|
13
|
+
* to drive plugin installation (handled by plugin-installer.ts)
|
|
14
|
+
*
|
|
15
|
+
* See docs/composition.md.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync } from "node:fs"
|
|
19
|
+
import { readFile, readdir } from "node:fs/promises"
|
|
20
|
+
import { join } from "node:path"
|
|
21
|
+
import {
|
|
22
|
+
personalClaudeDir,
|
|
23
|
+
personalLoopatConfigPath,
|
|
24
|
+
personalVaultDir,
|
|
25
|
+
workspaceProfileClaudeDir,
|
|
26
|
+
workspaceProfilesDir,
|
|
27
|
+
workspaceTeamClaudeDir,
|
|
28
|
+
} from "./paths"
|
|
29
|
+
|
|
30
|
+
/** personal/<u>/.loopat/config.json fields relevant to profile resolution. */
|
|
31
|
+
export type PersonalProfileConfig = {
|
|
32
|
+
default_profiles?: string[]
|
|
33
|
+
default_vault?: string
|
|
34
|
+
prefs?: Record<string, unknown>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Output of resolveLoopPlan — describes the materialization sources. */
|
|
38
|
+
export type LoopPlan = {
|
|
39
|
+
user: string
|
|
40
|
+
/** `.claude/` dirs to merge into the loop's .claude/, in load order
|
|
41
|
+
* (later sources win on conflicts; team first, profiles in declared order,
|
|
42
|
+
* personal last). */
|
|
43
|
+
claudeSources: Array<{ source: string; dir: string }>
|
|
44
|
+
/** Active profile names (excludes team & personal). */
|
|
45
|
+
profiles: string[]
|
|
46
|
+
/** Vault selection (from personal config or override). */
|
|
47
|
+
vault?: string
|
|
48
|
+
/** Resolved vault dir on host (if exists). */
|
|
49
|
+
vaultDir?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type ResolveInput = {
|
|
53
|
+
user: string
|
|
54
|
+
/** Profiles added via CLI (+name). */
|
|
55
|
+
cliAdded?: string[]
|
|
56
|
+
/** Profiles removed via CLI (-name). */
|
|
57
|
+
cliRemoved?: string[]
|
|
58
|
+
/** Hard override — replaces default_profiles. */
|
|
59
|
+
overrideProfiles?: string[]
|
|
60
|
+
/** Override vault selection. */
|
|
61
|
+
vaultOverride?: string
|
|
62
|
+
/** @deprecated Workdir is the SDK's project tier, read directly via
|
|
63
|
+
* settingSources='project'. It is NOT merged into the user tier any more
|
|
64
|
+
* (otherwise edits to workdir/.claude/ would change a frozen loop's
|
|
65
|
+
* user-tier snapshot, violating principle 1). The field is kept for the
|
|
66
|
+
* loopat CLI which still bundles workdir into a one-shot compose; remove
|
|
67
|
+
* once that path is gone. */
|
|
68
|
+
workdir?: string
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function readPersonalConfig(user: string): Promise<PersonalProfileConfig> {
|
|
72
|
+
const path = personalLoopatConfigPath(user)
|
|
73
|
+
if (!existsSync(path)) return {}
|
|
74
|
+
try {
|
|
75
|
+
return JSON.parse(await readFile(path, "utf8")) as PersonalProfileConfig
|
|
76
|
+
} catch {
|
|
77
|
+
return {}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Compute active profile set: (default_profiles ∪ cliAdded) − cliRemoved,
|
|
83
|
+
* with overrideProfiles replacing default_profiles when set. Order preserved:
|
|
84
|
+
* defaults first, then cliAdded. Team-tier is always implicit (handled by
|
|
85
|
+
* compose); no need to include it here.
|
|
86
|
+
*/
|
|
87
|
+
function computeActiveProfiles(
|
|
88
|
+
cfg: PersonalProfileConfig,
|
|
89
|
+
cliAdded: string[],
|
|
90
|
+
cliRemoved: string[],
|
|
91
|
+
overrideProfiles?: string[],
|
|
92
|
+
): string[] {
|
|
93
|
+
const base = overrideProfiles ?? cfg.default_profiles ?? []
|
|
94
|
+
const removed = new Set(cliRemoved)
|
|
95
|
+
const all = [...base, ...cliAdded]
|
|
96
|
+
const out: string[] = []
|
|
97
|
+
const seen = new Set<string>()
|
|
98
|
+
for (const p of all) {
|
|
99
|
+
if (removed.has(p) || seen.has(p)) continue
|
|
100
|
+
seen.add(p)
|
|
101
|
+
out.push(p)
|
|
102
|
+
}
|
|
103
|
+
return out
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Main entry: produce a LoopPlan from inputs. Pure / no side effects.
|
|
108
|
+
* Validates that named profiles actually have `.claude/` subdirs (otherwise
|
|
109
|
+
* they'd be silently invisible).
|
|
110
|
+
*/
|
|
111
|
+
export async function resolveLoopPlan(input: ResolveInput): Promise<LoopPlan> {
|
|
112
|
+
const { user, cliAdded = [], cliRemoved = [], overrideProfiles, vaultOverride, workdir } = input
|
|
113
|
+
|
|
114
|
+
const cfg = await readPersonalConfig(user)
|
|
115
|
+
const activeNames = computeActiveProfiles(cfg, cliAdded, cliRemoved, overrideProfiles)
|
|
116
|
+
|
|
117
|
+
// Validate
|
|
118
|
+
const profilesRoot = workspaceProfilesDir()
|
|
119
|
+
if (activeNames.length > 0 && !existsSync(profilesRoot)) {
|
|
120
|
+
throw new Error(`workspace profiles dir not found: ${profilesRoot}`)
|
|
121
|
+
}
|
|
122
|
+
for (const name of activeNames) {
|
|
123
|
+
const cdir = workspaceProfileClaudeDir(name)
|
|
124
|
+
if (!existsSync(cdir)) {
|
|
125
|
+
throw new Error(`profile "${name}" has no .claude/ dir at ${cdir}`)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Build claudeSources in merge order
|
|
130
|
+
const claudeSources: LoopPlan["claudeSources"] = []
|
|
131
|
+
const teamDir = workspaceTeamClaudeDir()
|
|
132
|
+
if (existsSync(teamDir)) {
|
|
133
|
+
claudeSources.push({ source: "team", dir: teamDir })
|
|
134
|
+
}
|
|
135
|
+
for (const name of activeNames) {
|
|
136
|
+
claudeSources.push({ source: `profile:${name}`, dir: workspaceProfileClaudeDir(name) })
|
|
137
|
+
}
|
|
138
|
+
// Personal `.claude/` — 4th layer. Same CC-native shape as workspace + profile.
|
|
139
|
+
const personalCdir = personalClaudeDir(user)
|
|
140
|
+
if (existsSync(personalCdir)) {
|
|
141
|
+
claudeSources.push({ source: `personal:${user}`, dir: personalCdir })
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Repo `.claude/` — 5th (highest) layer. CC project-tier from workdir.
|
|
145
|
+
// Optional; only if workdir is set AND has a .claude/ subdir.
|
|
146
|
+
if (workdir) {
|
|
147
|
+
const repoCdir = join(workdir, ".claude")
|
|
148
|
+
if (existsSync(repoCdir)) {
|
|
149
|
+
claudeSources.push({ source: `repo:${workdir}`, dir: repoCdir })
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const vault = vaultOverride ?? cfg.default_vault
|
|
154
|
+
const vaultDir = vault ? personalVaultDir(user, vault) : undefined
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
user,
|
|
158
|
+
claudeSources,
|
|
159
|
+
profiles: activeNames,
|
|
160
|
+
vault,
|
|
161
|
+
vaultDir: vaultDir && existsSync(vaultDir) ? vaultDir : undefined,
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** List available profile names = direct subdirs of profiles/ that contain `.claude/`. */
|
|
166
|
+
export async function listProfiles(): Promise<string[]> {
|
|
167
|
+
const root = workspaceProfilesDir()
|
|
168
|
+
if (!existsSync(root)) return []
|
|
169
|
+
const entries = await readdir(root, { withFileTypes: true })
|
|
170
|
+
const out: string[] = []
|
|
171
|
+
for (const e of entries) {
|
|
172
|
+
if (!e.isDirectory() || e.name.startsWith(".")) continue
|
|
173
|
+
if (!existsSync(workspaceProfileClaudeDir(e.name))) continue
|
|
174
|
+
out.push(e.name)
|
|
175
|
+
}
|
|
176
|
+
return out.sort()
|
|
177
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git-host provider registry bootstrap.
|
|
3
|
+
*
|
|
4
|
+
* - Built-in (open-source) providers self-register via the static imports below.
|
|
5
|
+
* - External / internal providers live OUTSIDE the repo, in
|
|
6
|
+
* `LOOPAT_HOME/extensions/providers/*.{ts,js,mjs}`. `loadExtensionProviders()`
|
|
7
|
+
* dynamically imports each file and registers its default export. An extension
|
|
8
|
+
* is a plain object shaped like GitHostProvider — it does NOT import loopat, so
|
|
9
|
+
* an internal platform's adapter never has to enter the open-source core.
|
|
10
|
+
*/
|
|
11
|
+
import { join } from "node:path"
|
|
12
|
+
import { existsSync } from "node:fs"
|
|
13
|
+
import { readdir } from "node:fs/promises"
|
|
14
|
+
import { pathToFileURL } from "node:url"
|
|
15
|
+
import { registerProvider, type GitHostProvider } from "./git-host"
|
|
16
|
+
import { extensionsProvidersDir } from "./paths"
|
|
17
|
+
|
|
18
|
+
import "./github" // built-in, open-source
|
|
19
|
+
|
|
20
|
+
let extLoaded = false
|
|
21
|
+
|
|
22
|
+
/** Idempotently load external provider extensions from the extensions dir. */
|
|
23
|
+
export async function loadExtensionProviders(): Promise<void> {
|
|
24
|
+
if (extLoaded) return
|
|
25
|
+
extLoaded = true
|
|
26
|
+
const dir = extensionsProvidersDir()
|
|
27
|
+
if (!existsSync(dir)) return
|
|
28
|
+
let files: string[] = []
|
|
29
|
+
try { files = await readdir(dir) } catch { return }
|
|
30
|
+
for (const f of files) {
|
|
31
|
+
if (!/\.(ts|js|mjs)$/.test(f)) continue
|
|
32
|
+
try {
|
|
33
|
+
const mod: any = await import(pathToFileURL(join(dir, f)).href)
|
|
34
|
+
const p = mod.default ?? mod.provider
|
|
35
|
+
if (p?.id && typeof p.authenticate === "function" && typeof p.ensureRepo === "function") {
|
|
36
|
+
registerProvider(p as GitHostProvider)
|
|
37
|
+
console.log(`[loopat] loaded git-host extension: ${p.id}`)
|
|
38
|
+
} else {
|
|
39
|
+
console.warn(`[loopat] ${f}: not a valid GitHostProvider (need id / authenticate / ensureRepo)`)
|
|
40
|
+
}
|
|
41
|
+
} catch (e: any) {
|
|
42
|
+
console.warn(`[loopat] failed to load provider extension ${f}: ${e?.message ?? e}`)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone workspace serve service.
|
|
3
|
+
* Listens on port 7788, serves loop workdirs via subdomain routing.
|
|
4
|
+
* Supports static file serving and HTTP port forwarding.
|
|
5
|
+
*/
|
|
6
|
+
import { createServer, request as httpRequest } from "node:http"
|
|
7
|
+
import { existsSync, statSync, createReadStream, readdirSync, readFileSync as readFileSyncFs } from "node:fs"
|
|
8
|
+
import { join, normalize } from "node:path"
|
|
9
|
+
import { loopsDir, loopWorkdir, loopMetaPath } from "./paths"
|
|
10
|
+
|
|
11
|
+
const SERVE_PORT = Number(process.env.LOOPAT_SERVE_PORT ?? 7788)
|
|
12
|
+
const SERVE_HOST = process.env.LOOPAT_SERVE_HOST ?? "127.0.0.1"
|
|
13
|
+
|
|
14
|
+
// Blocked paths — never served
|
|
15
|
+
const BLOCKED = new Set([
|
|
16
|
+
".git", ".ssh", ".env", "node_modules", ".DS_Store",
|
|
17
|
+
".bun", ".claude", ".vscode", ".idea",
|
|
18
|
+
])
|
|
19
|
+
|
|
20
|
+
function isBlocked(filePath: string): boolean {
|
|
21
|
+
const parts = filePath.split("/").filter(Boolean)
|
|
22
|
+
return parts.some((p) => BLOCKED.has(p) || p.startsWith(".env"))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const MIME_TYPES: Record<string, string> = {
|
|
26
|
+
".html": "text/html",
|
|
27
|
+
".css": "text/css",
|
|
28
|
+
".js": "application/javascript",
|
|
29
|
+
".json": "application/json",
|
|
30
|
+
".png": "image/png",
|
|
31
|
+
".jpg": "image/jpeg",
|
|
32
|
+
".jpeg": "image/jpeg",
|
|
33
|
+
".gif": "image/gif",
|
|
34
|
+
".svg": "image/svg+xml",
|
|
35
|
+
".ico": "image/x-icon",
|
|
36
|
+
".woff": "font/woff",
|
|
37
|
+
".woff2": "font/woff2",
|
|
38
|
+
".ttf": "font/ttf",
|
|
39
|
+
".eot": "application/vnd.ms-fontobject",
|
|
40
|
+
".otf": "font/otf",
|
|
41
|
+
".txt": "text/plain",
|
|
42
|
+
".md": "text/markdown",
|
|
43
|
+
".xml": "application/xml",
|
|
44
|
+
".pdf": "application/pdf",
|
|
45
|
+
".zip": "application/zip",
|
|
46
|
+
".tar": "application/x-tar",
|
|
47
|
+
".gz": "application/gzip",
|
|
48
|
+
".wasm": "application/wasm",
|
|
49
|
+
".webp": "image/webp",
|
|
50
|
+
".mp4": "video/mp4",
|
|
51
|
+
".webm": "video/webm",
|
|
52
|
+
".mp3": "audio/mpeg",
|
|
53
|
+
".ogg": "audio/ogg",
|
|
54
|
+
".wav": "audio/wav",
|
|
55
|
+
".csv": "text/csv",
|
|
56
|
+
".yaml": "text/yaml",
|
|
57
|
+
".yml": "text/yaml",
|
|
58
|
+
".toml": "text/toml",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getMime(path: string): string {
|
|
62
|
+
return MIME_TYPES[normalize(path).split(".").pop() ? `.${normalize(path).split(".").pop()}`.toLowerCase() : ""] || "application/octet-stream"
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
type LoopMeta = {
|
|
66
|
+
id: string
|
|
67
|
+
title: string
|
|
68
|
+
shareEnabled?: boolean
|
|
69
|
+
shareMode?: "static" | "port"
|
|
70
|
+
shareAlias?: string
|
|
71
|
+
sharePort?: number
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Cache: alias -> loop_id
|
|
75
|
+
const aliasCache = new Map<string, string>()
|
|
76
|
+
|
|
77
|
+
function loadMeta(loopId: string): LoopMeta | null {
|
|
78
|
+
const p = loopMetaPath(loopId)
|
|
79
|
+
if (!existsSync(p)) return null
|
|
80
|
+
try {
|
|
81
|
+
return JSON.parse(readFileSyncFs(p, "utf8"))
|
|
82
|
+
} catch {
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function resolveLoop(host: string): { loopId: string; meta: LoopMeta } | null {
|
|
88
|
+
const parts = host.split(".")
|
|
89
|
+
if (parts.length < 2) return null
|
|
90
|
+
const subdomain = parts[0].toLowerCase()
|
|
91
|
+
|
|
92
|
+
// Check alias cache first
|
|
93
|
+
if (aliasCache.has(subdomain)) {
|
|
94
|
+
const loopId = aliasCache.get(subdomain)!
|
|
95
|
+
const meta = loadMeta(loopId)
|
|
96
|
+
if (meta?.shareEnabled) return { loopId, meta }
|
|
97
|
+
aliasCache.delete(subdomain)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Scan all loops
|
|
101
|
+
let dirs: string[]
|
|
102
|
+
try {
|
|
103
|
+
dirs = readdirSync(loopsDir())
|
|
104
|
+
} catch {
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const dir of dirs) {
|
|
109
|
+
const meta = loadMeta(dir)
|
|
110
|
+
if (!meta) continue
|
|
111
|
+
if (!meta.shareEnabled) continue
|
|
112
|
+
const shortId = dir.slice(0, 8)
|
|
113
|
+
if (shortId === subdomain || meta.shareAlias === subdomain) {
|
|
114
|
+
if (meta.shareAlias) aliasCache.set(meta.shareAlias, dir)
|
|
115
|
+
return { loopId: dir, meta }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function rebuildAliasCache() {
|
|
122
|
+
aliasCache.clear()
|
|
123
|
+
let dirs: string[]
|
|
124
|
+
try {
|
|
125
|
+
dirs = readdirSync(loopsDir())
|
|
126
|
+
} catch {
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
for (const dir of dirs) {
|
|
130
|
+
const meta = loadMeta(dir)
|
|
131
|
+
if (meta?.shareEnabled && meta.shareAlias) {
|
|
132
|
+
aliasCache.set(meta.shareAlias, dir)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
rebuildAliasCache()
|
|
138
|
+
setInterval(rebuildAliasCache, 30_000)
|
|
139
|
+
|
|
140
|
+
function serveStaticFile(workdir: string, urlPath: string, res: any): boolean {
|
|
141
|
+
let rel = decodeURIComponent(urlPath)
|
|
142
|
+
if (rel.startsWith("/")) rel = rel.slice(1)
|
|
143
|
+
if (!rel) rel = "index.html"
|
|
144
|
+
|
|
145
|
+
const full = normalize(join(workdir, rel))
|
|
146
|
+
if (!full.startsWith(normalize(workdir))) {
|
|
147
|
+
res.writeHead(403)
|
|
148
|
+
res.end("Forbidden")
|
|
149
|
+
return true
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (isBlocked(rel)) {
|
|
153
|
+
res.writeHead(403)
|
|
154
|
+
res.end("Forbidden")
|
|
155
|
+
return true
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!existsSync(full)) {
|
|
159
|
+
if (existsSync(join(full, "index.html"))) {
|
|
160
|
+
return serveStaticFile(workdir, rel + "/index.html", res)
|
|
161
|
+
}
|
|
162
|
+
res.writeHead(404)
|
|
163
|
+
res.end("Not found")
|
|
164
|
+
return true
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const s = statSync(full)
|
|
168
|
+
if (s.isDirectory()) {
|
|
169
|
+
if (existsSync(join(full, "index.html"))) {
|
|
170
|
+
return serveStaticFile(workdir, rel + "/index.html", res)
|
|
171
|
+
}
|
|
172
|
+
res.writeHead(403)
|
|
173
|
+
res.end("Directory listing not allowed")
|
|
174
|
+
return true
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!s.isFile()) {
|
|
178
|
+
res.writeHead(403)
|
|
179
|
+
res.end("Forbidden")
|
|
180
|
+
return true
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
res.writeHead(200, {
|
|
184
|
+
"Content-Type": getMime(full),
|
|
185
|
+
"Content-Length": s.size,
|
|
186
|
+
"Cache-Control": "no-cache",
|
|
187
|
+
})
|
|
188
|
+
createReadStream(full).pipe(res)
|
|
189
|
+
return true
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function proxyToPort(port: number, req: any, res: any): void {
|
|
193
|
+
const headers: Record<string, string> = { ...req.headers }
|
|
194
|
+
delete headers["host"]
|
|
195
|
+
headers["host"] = `localhost:${port}`
|
|
196
|
+
if (req.socket.remoteAddress) headers["x-forwarded-for"] = req.socket.remoteAddress
|
|
197
|
+
if (req.headers["host"]) headers["x-forwarded-host"] = req.headers["host"]
|
|
198
|
+
|
|
199
|
+
const proxyReq = httpRequest({
|
|
200
|
+
hostname: "127.0.0.1",
|
|
201
|
+
port,
|
|
202
|
+
method: req.method,
|
|
203
|
+
path: req.url,
|
|
204
|
+
headers,
|
|
205
|
+
timeout: 30_000,
|
|
206
|
+
}, (proxyRes: any) => {
|
|
207
|
+
res.writeHead(proxyRes.statusCode, proxyRes.headers)
|
|
208
|
+
proxyRes.pipe(res)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
proxyReq.on("error", () => {
|
|
212
|
+
res.writeHead(502)
|
|
213
|
+
res.end("Port forwarding error - is the service running?")
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
proxyReq.on("timeout", () => {
|
|
217
|
+
proxyReq.destroy()
|
|
218
|
+
res.writeHead(504)
|
|
219
|
+
res.end("Gateway timeout")
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
req.pipe(proxyReq)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const server = createServer((req, res) => {
|
|
226
|
+
const host = (req.headers["host"] ?? "").split(":")[0].toLowerCase()
|
|
227
|
+
const resolved = resolveLoop(host)
|
|
228
|
+
|
|
229
|
+
if (!resolved) {
|
|
230
|
+
res.writeHead(404, { "Content-Type": "text/plain" })
|
|
231
|
+
res.end("No workspace found for this domain")
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const { meta, loopId } = resolved
|
|
236
|
+
|
|
237
|
+
if (!meta.shareEnabled) {
|
|
238
|
+
res.writeHead(403)
|
|
239
|
+
res.end("Workspace sharing is disabled")
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const workdir = loopWorkdir(loopId)
|
|
244
|
+
if (!existsSync(workdir)) {
|
|
245
|
+
res.writeHead(404)
|
|
246
|
+
res.end("Workdir not found")
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (meta.shareMode === "port" && meta.sharePort) {
|
|
251
|
+
if (meta.sharePort < 1024 || meta.sharePort > 65535) {
|
|
252
|
+
res.writeHead(400)
|
|
253
|
+
res.end("Invalid port — must be 1024-65535")
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
proxyToPort(meta.sharePort, req, res)
|
|
257
|
+
} else {
|
|
258
|
+
serveStaticFile(workdir, req.url ?? "/", res)
|
|
259
|
+
}
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
server.on("error", (e: any) => {
|
|
263
|
+
if (e.code === "EADDRINUSE") {
|
|
264
|
+
console.error(`[loopat] workspace serve port ${SERVE_PORT} already in use`)
|
|
265
|
+
} else {
|
|
266
|
+
console.error(`[loopat] workspace serve error:`, e)
|
|
267
|
+
}
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
console.log(`[loopat] workspace serve starting on http://${SERVE_HOST}:${SERVE_PORT}`)
|
|
271
|
+
server.listen(SERVE_PORT, SERVE_HOST, () => {
|
|
272
|
+
console.log(`[loopat] workspace serve listening on http://${SERVE_HOST}:${SERVE_PORT}`)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
export { server }
|