@ghostlygawd/hangar 0.2.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/CHANGELOG.md +101 -0
- package/LICENSE +202 -0
- package/README.md +86 -0
- package/bin/hangar.js +156 -0
- package/lib/doctor.js +68 -0
- package/lib/git.js +96 -0
- package/lib/launch.js +81 -0
- package/lib/limits.js +55 -0
- package/lib/paths.js +40 -0
- package/lib/profiles.js +58 -0
- package/lib/spaces.js +155 -0
- package/lib/store.js +48 -0
- package/lib/sysinfo.js +78 -0
- package/lib/transcripts.js +266 -0
- package/package.json +56 -0
- package/server/bus.js +60 -0
- package/server/gate.js +50 -0
- package/server/index.js +67 -0
- package/server/routes.js +166 -0
- package/web/dist/assets/index-D593hYue.js +49 -0
- package/web/dist/assets/index-Dv9Rj8Ur.css +1 -0
- package/web/dist/index.html +20 -0
package/lib/git.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
/** Run git with args in cwd. Resolves {ok, stdout, stderr, code}; never rejects. */
|
|
4
|
+
export function git(args, cwd) {
|
|
5
|
+
return new Promise((resolve) => {
|
|
6
|
+
execFile('git', args, { cwd, windowsHide: true, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
7
|
+
resolve({
|
|
8
|
+
ok: !err,
|
|
9
|
+
code: err?.code ?? 0,
|
|
10
|
+
stdout: String(stdout).trimEnd(),
|
|
11
|
+
stderr: String(stderr).trimEnd(),
|
|
12
|
+
})
|
|
13
|
+
})
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** True only for the top-level directory of a working tree. */
|
|
18
|
+
export async function isRepoRoot(dir) {
|
|
19
|
+
const r = await git(['rev-parse', '--show-toplevel'], dir)
|
|
20
|
+
if (!r.ok) return false
|
|
21
|
+
return normalize(r.stdout) === normalize(dir)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function normalize(p) {
|
|
25
|
+
return p.replace(/[\\/]+$/, '').replace(/\//g, '\\').toLowerCase()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The repo's default branch: origin/HEAD if known, else main/master if present, else current. */
|
|
29
|
+
export async function defaultBranch(repoDir) {
|
|
30
|
+
const remote = await git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], repoDir)
|
|
31
|
+
if (remote.ok) return remote.stdout.replace(/^origin\//, '')
|
|
32
|
+
for (const candidate of ['main', 'master']) {
|
|
33
|
+
const r = await git(['show-ref', '--verify', '--quiet', `refs/heads/${candidate}`], repoDir)
|
|
34
|
+
if (r.ok) return candidate
|
|
35
|
+
}
|
|
36
|
+
const current = await git(['branch', '--show-current'], repoDir)
|
|
37
|
+
return current.stdout || 'main'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function currentBranch(dir) {
|
|
41
|
+
const r = await git(['branch', '--show-current'], dir)
|
|
42
|
+
return r.stdout
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function isDirty(dir) {
|
|
46
|
+
const r = await git(['status', '--porcelain'], dir)
|
|
47
|
+
return { dirty: r.ok && r.stdout.length > 0, detail: r.stdout }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** [ahead, behind] of branch relative to base. */
|
|
51
|
+
export async function aheadBehind(repoDir, branch, base) {
|
|
52
|
+
const r = await git(['rev-list', '--left-right', '--count', `${base}...${branch}`], repoDir)
|
|
53
|
+
if (!r.ok) return { ahead: 0, behind: 0 }
|
|
54
|
+
const [behind, ahead] = r.stdout.split(/\s+/).map(Number)
|
|
55
|
+
return { ahead: ahead || 0, behind: behind || 0 }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Files changed / insertions / deletions of branch vs base (three-dot: since divergence). */
|
|
59
|
+
export async function diffStat(repoDir, branch, base) {
|
|
60
|
+
const r = await git(['diff', '--shortstat', `${base}...${branch}`], repoDir)
|
|
61
|
+
const out = { files: 0, insertions: 0, deletions: 0 }
|
|
62
|
+
if (!r.ok || !r.stdout) return out
|
|
63
|
+
const files = r.stdout.match(/(\d+) files? changed/)
|
|
64
|
+
const ins = r.stdout.match(/(\d+) insertions?/)
|
|
65
|
+
const del = r.stdout.match(/(\d+) deletions?/)
|
|
66
|
+
if (files) out.files = Number(files[1])
|
|
67
|
+
if (ins) out.insertions = Number(ins[1])
|
|
68
|
+
if (del) out.deletions = Number(del[1])
|
|
69
|
+
return out
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** True when branch is fully merged into base. */
|
|
73
|
+
export async function isMerged(repoDir, branch, base) {
|
|
74
|
+
const r = await git(['merge-base', '--is-ancestor', branch, base], repoDir)
|
|
75
|
+
return r.ok
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Parse `git worktree list --porcelain` into [{path, branch, head}]. */
|
|
79
|
+
export async function worktrees(repoDir) {
|
|
80
|
+
const r = await git(['worktree', 'list', '--porcelain'], repoDir)
|
|
81
|
+
if (!r.ok) return []
|
|
82
|
+
const out = []
|
|
83
|
+
let entry = null
|
|
84
|
+
for (const line of r.stdout.split('\n')) {
|
|
85
|
+
if (line.startsWith('worktree ')) {
|
|
86
|
+
if (entry) out.push(entry)
|
|
87
|
+
entry = { path: line.slice('worktree '.length), branch: null, head: null }
|
|
88
|
+
} else if (line.startsWith('branch ') && entry) {
|
|
89
|
+
entry.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '')
|
|
90
|
+
} else if (line.startsWith('HEAD ') && entry) {
|
|
91
|
+
entry.head = line.slice('HEAD '.length)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (entry) out.push(entry)
|
|
95
|
+
return out
|
|
96
|
+
}
|
package/lib/launch.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process'
|
|
2
|
+
import { claudeHomeFor } from './paths.js'
|
|
3
|
+
|
|
4
|
+
/** Active windows Hangar opened this run: id → {pid, cwd, profile, startedAt} */
|
|
5
|
+
export const launched = new Map()
|
|
6
|
+
let nextId = 1
|
|
7
|
+
|
|
8
|
+
function hasWindowsTerminal() {
|
|
9
|
+
return new Promise((resolve) => {
|
|
10
|
+
execFile('where.exe', ['wt'], { windowsHide: true }, (err) => resolve(!err))
|
|
11
|
+
})
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Single-quote a string for embedding in a PowerShell single-quoted literal. */
|
|
15
|
+
function psq(s) {
|
|
16
|
+
return `'${String(s).replace(/'/g, "''")}'`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The interactive PowerShell command that sets the profile, cd's in, and starts claude. */
|
|
20
|
+
export function buildInner({ cwd, profile = 'default', prompt = '' }) {
|
|
21
|
+
const env = profile !== 'default' ? `$env:CLAUDE_CONFIG_DIR=${psq(claudeHomeFor(profile))}; ` : ''
|
|
22
|
+
const claudeArgs = prompt ? ` ${psq(prompt)}` : ''
|
|
23
|
+
return `${env}Set-Location ${psq(cwd)}; claude${claudeArgs}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Windows Terminal parses `;` in its own command line as a tab/pane delimiter,
|
|
28
|
+
* so the inner command's semicolons would split into separate tabs — setting
|
|
29
|
+
* CLAUDE_CONFIG_DIR in one and starting claude (under the wrong account) in
|
|
30
|
+
* another. Passing the command as a base64 `-EncodedCommand` blob means wt sees
|
|
31
|
+
* no bare `;` at all. Verified: base64 is [A-Za-z0-9+/=] only.
|
|
32
|
+
*/
|
|
33
|
+
export function wtLaunchArgs(cwd, inner) {
|
|
34
|
+
const encoded = Buffer.from(inner, 'utf16le').toString('base64')
|
|
35
|
+
return ['-w', '0', 'nt', '-d', cwd, 'powershell', '-NoExit', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Open a REAL interactive Claude Code window. Never headless, never SDK:
|
|
40
|
+
* a visible terminal the user can type in, exactly like opening it by hand.
|
|
41
|
+
* Returns {ok, id, pid?} — pid is known on the PowerShell-window path.
|
|
42
|
+
*/
|
|
43
|
+
export async function launchSession({ cwd, profile = 'default', prompt = '' }) {
|
|
44
|
+
const inner = buildInner({ cwd, profile, prompt })
|
|
45
|
+
|
|
46
|
+
if (await hasWindowsTerminal()) {
|
|
47
|
+
// wt opens its own window tree; PID of the session shell isn't directly addressable — liveness is best-effort
|
|
48
|
+
const child = spawn('wt', wtLaunchArgs(cwd, inner), {
|
|
49
|
+
detached: true, stdio: 'ignore', windowsHide: false,
|
|
50
|
+
})
|
|
51
|
+
child.unref()
|
|
52
|
+
const id = `w${nextId++}`
|
|
53
|
+
launched.set(id, { id, pid: null, cwd, profile, startedAt: new Date().toISOString(), via: 'wt' })
|
|
54
|
+
return { ok: true, id, pid: null }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Plain PowerShell window via Start-Process -PassThru so we learn the real PID.
|
|
58
|
+
const outer = `$p = Start-Process -FilePath 'powershell' -ArgumentList @('-NoExit','-NoProfile','-ExecutionPolicy','Bypass','-Command',${psq(inner)}) -WorkingDirectory ${psq(cwd)} -PassThru; $p.Id`
|
|
59
|
+
const pid = await new Promise((resolve) => {
|
|
60
|
+
execFile('powershell', ['-NoProfile', '-Command', outer], { windowsHide: true }, (err, stdout) => {
|
|
61
|
+
const n = parseInt(String(stdout).trim(), 10)
|
|
62
|
+
resolve(err || !Number.isFinite(n) ? null : n)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
if (pid === null) return { ok: false, error: 'Failed to open a PowerShell window for the session.' }
|
|
66
|
+
const id = `w${nextId++}`
|
|
67
|
+
launched.set(id, { id, pid, cwd, profile, startedAt: new Date().toISOString(), via: 'powershell' })
|
|
68
|
+
return { ok: true, id, pid }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function liveness() {
|
|
72
|
+
const out = []
|
|
73
|
+
for (const s of launched.values()) {
|
|
74
|
+
let alive = null
|
|
75
|
+
if (s.pid != null) {
|
|
76
|
+
try { process.kill(s.pid, 0); alive = true } catch { alive = false }
|
|
77
|
+
}
|
|
78
|
+
out.push({ ...s, alive })
|
|
79
|
+
}
|
|
80
|
+
return out
|
|
81
|
+
}
|
package/lib/limits.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Limit detection over structured fields ONLY. Message text is never read —
|
|
3
|
+
* a transcript whose *content* talks about rate limits must never trigger.
|
|
4
|
+
* Ported from the proven detector; pure function, no IO.
|
|
5
|
+
*
|
|
6
|
+
* Returns { kind, resetsAt? } or null.
|
|
7
|
+
*/
|
|
8
|
+
export function detectLimit(msg) {
|
|
9
|
+
if (!msg || typeof msg !== 'object') return null
|
|
10
|
+
|
|
11
|
+
// 1. explicit rate-limit event with rejected status
|
|
12
|
+
if (msg.type === 'rate_limit_event') {
|
|
13
|
+
const info = msg.rate_limit_info
|
|
14
|
+
if (info && info.status === 'rejected') {
|
|
15
|
+
const kind = info.rateLimitType ? `rate_limit_event:${info.rateLimitType}` : 'rate_limit_event:rejected'
|
|
16
|
+
return { kind, resetsAt: toIso(info.resetsAt) }
|
|
17
|
+
}
|
|
18
|
+
return null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 2. api retry caused by a rate limit (429); 529 overload is transient and never parks
|
|
22
|
+
if (msg.type === 'system' && msg.subtype === 'api_retry') {
|
|
23
|
+
if (msg.error === 'rate_limit' || msg.error_status === 429) {
|
|
24
|
+
return { kind: 'api_retry:rate_limit', resetsAt: undefined }
|
|
25
|
+
}
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 3. assistant turn flagged with a structured rate-limit error
|
|
30
|
+
if (msg.type === 'assistant' && msg.error === 'rate_limit') {
|
|
31
|
+
return { kind: 'assistant:rate_limit', resetsAt: undefined }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 4. terminal result blocked by a limit
|
|
35
|
+
if (
|
|
36
|
+
msg.type === 'result' &&
|
|
37
|
+
msg.terminal_reason === 'blocking_limit' &&
|
|
38
|
+
msg.subtype !== 'success' &&
|
|
39
|
+
msg.is_error === true
|
|
40
|
+
) {
|
|
41
|
+
return { kind: 'result:blocking_limit', resetsAt: undefined }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function toIso(value) {
|
|
48
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
|
|
49
|
+
const ms = value >= 1e12 ? value : value * 1000
|
|
50
|
+
try {
|
|
51
|
+
return new Date(ms).toISOString()
|
|
52
|
+
} catch {
|
|
53
|
+
return undefined
|
|
54
|
+
}
|
|
55
|
+
}
|
package/lib/paths.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import os from 'node:os'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
/** Root for all Hangar state. Never inside a repo, never inside ~/.claude. */
|
|
5
|
+
export const HANGAR_HOME = process.env.HANGAR_HOME || path.join(os.homedir(), '.hangar')
|
|
6
|
+
|
|
7
|
+
export const CONFIG_FILE = path.join(HANGAR_HOME, 'config.json')
|
|
8
|
+
export const REPOS_FILE = path.join(HANGAR_HOME, 'repos.json')
|
|
9
|
+
export const PROFILES_FILE = path.join(HANGAR_HOME, 'profiles.json')
|
|
10
|
+
export const PARKED_FILE = path.join(HANGAR_HOME, 'parked.json')
|
|
11
|
+
|
|
12
|
+
/** Where profile config homes live. The "default" profile is the user's normal ~/.claude. */
|
|
13
|
+
export const PROFILE_HOMES = path.join(HANGAR_HOME, 'profiles')
|
|
14
|
+
|
|
15
|
+
/** The one canonical default config — reused by the server, the CLI, and the tunnel. */
|
|
16
|
+
export const DEFAULT_CONFIG = { port: 4870, bind: '127.0.0.1', token: '', defaultProfile: 'default' }
|
|
17
|
+
|
|
18
|
+
export function defaultClaudeHome() {
|
|
19
|
+
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Config home for a profile name. "default" → the user's normal claude home. */
|
|
23
|
+
export function claudeHomeFor(profileName) {
|
|
24
|
+
if (!profileName || profileName === 'default') return defaultClaudeHome()
|
|
25
|
+
return path.join(PROFILE_HOMES, profileName)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Claude Code's transcript root for a given config home. */
|
|
29
|
+
export function projectsDirFor(claudeHome) {
|
|
30
|
+
return path.join(claudeHome, 'projects')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Claude Code names each project's transcript directory by replacing every
|
|
35
|
+
* non-alphanumeric character of the absolute cwd with '-'.
|
|
36
|
+
* Verified against real ~/.claude/projects entries on Windows.
|
|
37
|
+
*/
|
|
38
|
+
export function mungeCwd(absPath) {
|
|
39
|
+
return absPath.replace(/[^a-zA-Z0-9]/g, '-')
|
|
40
|
+
}
|
package/lib/profiles.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import { JsonStore } from './store.js'
|
|
3
|
+
import { PROFILES_FILE, PROFILE_HOMES, claudeHomeFor } from './paths.js'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
|
|
6
|
+
const SAFE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,31}$/
|
|
7
|
+
// Registry entries must never carry anything credential-shaped.
|
|
8
|
+
const CREDENTIALISH = /credential|token|api[_-]?key|secret|password|auth|bearer/i
|
|
9
|
+
|
|
10
|
+
export const profilesStore = new JsonStore(PROFILES_FILE, { version: 1, profiles: [] })
|
|
11
|
+
|
|
12
|
+
export function validProfileName(name) {
|
|
13
|
+
return SAFE_NAME.test(name) && !name.includes('..') && name !== 'default'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Reject any entry that smells like a credential made it into the registry. */
|
|
17
|
+
export function validateRegistry(reg) {
|
|
18
|
+
if (!reg || typeof reg !== 'object' || !Array.isArray(reg.profiles)) return 'registry malformed'
|
|
19
|
+
for (const p of reg.profiles) {
|
|
20
|
+
for (const key of Object.keys(p)) {
|
|
21
|
+
if (CREDENTIALISH.test(key)) return `field "${key}" looks credential-like; profiles carry names only`
|
|
22
|
+
}
|
|
23
|
+
if (typeof p.name !== 'string' || !SAFE_NAME.test(p.name)) return `profile name "${p.name}" is not a safe segment`
|
|
24
|
+
}
|
|
25
|
+
return null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** All profiles, always including the implicit default. */
|
|
29
|
+
export function listProfiles() {
|
|
30
|
+
const err = validateRegistry(profilesStore.data)
|
|
31
|
+
const extras = err ? [] : profilesStore.data.profiles
|
|
32
|
+
return [
|
|
33
|
+
{ name: 'default', displayName: 'Default (~/.claude)', home: claudeHomeFor('default'), implicit: true },
|
|
34
|
+
...extras.map((p) => ({ ...p, home: claudeHomeFor(p.name), implicit: false })),
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function addProfile(name, displayName) {
|
|
39
|
+
if (!validProfileName(name)) {
|
|
40
|
+
return { ok: false, status: 400, error: `Invalid profile name "${name}".` }
|
|
41
|
+
}
|
|
42
|
+
if (profilesStore.data.profiles.some((p) => p.name === name)) {
|
|
43
|
+
return { ok: true, existed: true }
|
|
44
|
+
}
|
|
45
|
+
const entry = { name, addedAt: new Date().toISOString() }
|
|
46
|
+
if (displayName) entry.displayName = String(displayName)
|
|
47
|
+
profilesStore.update((d) => d.profiles.push(entry))
|
|
48
|
+
fs.mkdirSync(path.join(PROFILE_HOMES, name), { recursive: true })
|
|
49
|
+
return { ok: true, existed: false, home: claudeHomeFor(name) }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function removeProfile(name) {
|
|
53
|
+
if (name === 'default') return { ok: false, status: 400, error: 'The default profile cannot be removed.' }
|
|
54
|
+
const before = profilesStore.data.profiles.length
|
|
55
|
+
profilesStore.update((d) => { d.profiles = d.profiles.filter((p) => p.name !== name) })
|
|
56
|
+
// the config home on disk is left alone — it may hold a login; removing tracking is not destroying credentials
|
|
57
|
+
return { ok: true, removed: profilesStore.data.profiles.length < before }
|
|
58
|
+
}
|
package/lib/spaces.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import {
|
|
4
|
+
git, defaultBranch, isDirty, aheadBehind, diffStat, isMerged, worktrees,
|
|
5
|
+
} from './git.js'
|
|
6
|
+
|
|
7
|
+
export const SPACES_DIR = '.hangar-spaces'
|
|
8
|
+
const NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/
|
|
9
|
+
|
|
10
|
+
export function validSpaceName(name) {
|
|
11
|
+
return NAME_RE.test(name) && !name.includes('..')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function spacesRoot(repoPath) {
|
|
15
|
+
return path.join(repoPath, SPACES_DIR)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function spacePath(repoPath, name) {
|
|
19
|
+
return path.join(spacesRoot(repoPath), name)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Keep spaces invisible to the repo's own status without touching .gitignore. */
|
|
23
|
+
function ensureExcluded(repoPath) {
|
|
24
|
+
const excludeFile = path.join(repoPath, '.git', 'info', 'exclude')
|
|
25
|
+
try {
|
|
26
|
+
const current = fs.existsSync(excludeFile) ? fs.readFileSync(excludeFile, 'utf8') : ''
|
|
27
|
+
if (!current.split(/\r?\n/).includes(`${SPACES_DIR}/`)) {
|
|
28
|
+
fs.mkdirSync(path.dirname(excludeFile), { recursive: true })
|
|
29
|
+
fs.appendFileSync(excludeFile, `${current.endsWith('\n') || current === '' ? '' : '\n'}${SPACES_DIR}/\n`)
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
// a worktree-style .git file or odd layout — exclusion is cosmetic, never fatal
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function listSpaces(repoPath) {
|
|
37
|
+
const root = spacesRoot(repoPath)
|
|
38
|
+
const [all, base] = await Promise.all([worktrees(repoPath), defaultBranch(repoPath)])
|
|
39
|
+
const mine = all.filter((w) => normalize(w.path).startsWith(normalize(root) + path.sep.toLowerCase()))
|
|
40
|
+
return Promise.all(mine.map(async (w) => {
|
|
41
|
+
const [dirty, ab, stat, merged] = await Promise.all([
|
|
42
|
+
isDirty(w.path),
|
|
43
|
+
w.branch ? aheadBehind(repoPath, w.branch, base) : { ahead: 0, behind: 0 },
|
|
44
|
+
w.branch ? diffStat(repoPath, w.branch, base) : { files: 0, insertions: 0, deletions: 0 },
|
|
45
|
+
w.branch ? isMerged(repoPath, w.branch, base) : false,
|
|
46
|
+
])
|
|
47
|
+
return {
|
|
48
|
+
name: path.basename(w.path),
|
|
49
|
+
path: w.path,
|
|
50
|
+
branch: w.branch,
|
|
51
|
+
base,
|
|
52
|
+
dirty: dirty.dirty,
|
|
53
|
+
dirtyDetail: dirty.detail,
|
|
54
|
+
ahead: ab.ahead,
|
|
55
|
+
behind: ab.behind,
|
|
56
|
+
diff: stat,
|
|
57
|
+
merged,
|
|
58
|
+
deletable: merged && !dirty.dirty,
|
|
59
|
+
}
|
|
60
|
+
}))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function createSpace(repoPath, name) {
|
|
64
|
+
if (!validSpaceName(name)) {
|
|
65
|
+
return { ok: false, status: 400, error: `Invalid space name "${name}". Use letters, digits, dot, dash, underscore (max 64).` }
|
|
66
|
+
}
|
|
67
|
+
const target = spacePath(repoPath, name)
|
|
68
|
+
if (fs.existsSync(target)) {
|
|
69
|
+
return { ok: false, status: 409, error: `Space "${name}" already exists.` }
|
|
70
|
+
}
|
|
71
|
+
ensureExcluded(repoPath)
|
|
72
|
+
const base = await defaultBranch(repoPath)
|
|
73
|
+
const branch = `space/${name}`
|
|
74
|
+
const r = await git(['worktree', 'add', target, '-b', branch, base], repoPath)
|
|
75
|
+
if (!r.ok) {
|
|
76
|
+
return { ok: false, status: 500, error: `git worktree add failed: ${r.stderr || r.stdout}` }
|
|
77
|
+
}
|
|
78
|
+
return { ok: true, name, path: target, branch, base }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** True only when `p` resolves strictly inside `root` (after normalization). */
|
|
82
|
+
function isInside(root, p) {
|
|
83
|
+
return normalize(p).startsWith(normalize(root) + '\\')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The provably-safe rule: delete only when merged into base AND clean.
|
|
88
|
+
* Force is a separate, explicit path that must echo the space name back.
|
|
89
|
+
*
|
|
90
|
+
* Worktree safety is SACRED, so the delete path is validated as strictly as
|
|
91
|
+
* the create path: the name is checked, the resolved target must live inside
|
|
92
|
+
* this repo's .hangar-spaces root, and we refuse to touch the default-branch
|
|
93
|
+
* checkout or anything that escapes the spaces dir.
|
|
94
|
+
*/
|
|
95
|
+
export async function deleteSpace(repoPath, name, { force = false, confirm = '' } = {}) {
|
|
96
|
+
if (!validSpaceName(name)) {
|
|
97
|
+
return { ok: false, status: 400, error: `Invalid space name "${name}". Use letters, digits, dot, dash, underscore (max 64).` }
|
|
98
|
+
}
|
|
99
|
+
const root = spacesRoot(repoPath)
|
|
100
|
+
const target = spacePath(repoPath, name)
|
|
101
|
+
// containment: the resolved target must live strictly inside this repo's spaces root
|
|
102
|
+
if (!isInside(root, target)) {
|
|
103
|
+
return { ok: false, status: 400, error: `Refusing to delete "${name}": it resolves outside ${SPACES_DIR}.` }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const all = await worktrees(repoPath)
|
|
107
|
+
const entry = all.find((w) => normalize(w.path) === normalize(target))
|
|
108
|
+
if (!entry) return { ok: false, status: 404, error: `Space "${name}" not found.` }
|
|
109
|
+
|
|
110
|
+
// SACRED, belt-and-braces: only ever remove a worktree that physically lives
|
|
111
|
+
// under this repo's .hangar-spaces, never the repo checkout, never the default branch.
|
|
112
|
+
if (!isInside(root, entry.path)) {
|
|
113
|
+
return { ok: false, status: 403, error: `Refusing to remove a worktree outside ${SPACES_DIR}.` }
|
|
114
|
+
}
|
|
115
|
+
if (normalize(entry.path) === normalize(repoPath)) {
|
|
116
|
+
return { ok: false, status: 403, error: 'Refusing to remove the repository checkout itself.' }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const base = await defaultBranch(repoPath)
|
|
120
|
+
if (entry.branch && entry.branch === base) {
|
|
121
|
+
return { ok: false, status: 403, error: `Refusing to remove a worktree with the default branch ("${base}") checked out.` }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const dirty = await isDirty(target)
|
|
125
|
+
const merged = entry.branch ? await isMerged(repoPath, entry.branch, base) : false
|
|
126
|
+
|
|
127
|
+
if (!force) {
|
|
128
|
+
if (dirty.dirty) {
|
|
129
|
+
return { ok: false, status: 409, error: `Space "${name}" has uncommitted changes:\n${dirty.detail}` }
|
|
130
|
+
}
|
|
131
|
+
if (!entry.branch) {
|
|
132
|
+
return { ok: false, status: 409, error: `Space "${name}" is on a detached HEAD (no branch). Commit to a branch, or force-delete to discard it.` }
|
|
133
|
+
}
|
|
134
|
+
if (!merged) {
|
|
135
|
+
return { ok: false, status: 409, error: `Branch "${entry.branch}" is not merged into ${base}. Merge it first, or force-delete to discard it.` }
|
|
136
|
+
}
|
|
137
|
+
} else if (confirm !== name) {
|
|
138
|
+
return { ok: false, status: 400, error: `Force-delete requires confirm to equal the space name ("${name}").` }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const discarded = force ? { dirty: dirty.dirty, unmerged: !merged, dirtyDetail: dirty.detail } : null
|
|
142
|
+
const rm = await git(['worktree', 'remove', force ? '--force' : '--no-force', target], repoPath)
|
|
143
|
+
if (!rm.ok) return { ok: false, status: 500, error: `git worktree remove failed: ${rm.stderr}` }
|
|
144
|
+
let warning = null
|
|
145
|
+
if (entry.branch) {
|
|
146
|
+
const br = await git(['branch', force ? '-D' : '-d', entry.branch], repoPath)
|
|
147
|
+
if (!br.ok) warning = `Space removed, but branch "${entry.branch}" could not be deleted: ${br.stderr || br.stdout}`
|
|
148
|
+
}
|
|
149
|
+
await git(['worktree', 'prune'], repoPath)
|
|
150
|
+
return { ok: true, name, discarded, ...(warning ? { warning } : {}) }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function normalize(p) {
|
|
154
|
+
return p.replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase()
|
|
155
|
+
}
|
package/lib/store.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One JSON file, loaded once, written atomically (temp + rename).
|
|
6
|
+
* Rename retries cover Windows AV/indexer EPERM blips.
|
|
7
|
+
*/
|
|
8
|
+
export class JsonStore {
|
|
9
|
+
constructor(filePath, fallback) {
|
|
10
|
+
this.filePath = filePath
|
|
11
|
+
this.fallback = fallback
|
|
12
|
+
this.data = this.#load()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
#load() {
|
|
16
|
+
try {
|
|
17
|
+
const raw = fs.readFileSync(this.filePath, 'utf8')
|
|
18
|
+
return JSON.parse(raw)
|
|
19
|
+
} catch {
|
|
20
|
+
return structuredClone(this.fallback)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
save() {
|
|
25
|
+
fs.mkdirSync(path.dirname(this.filePath), { recursive: true })
|
|
26
|
+
const tmp = `${this.filePath}.${process.pid}.tmp`
|
|
27
|
+
fs.writeFileSync(tmp, JSON.stringify(this.data, null, 2))
|
|
28
|
+
for (let attempt = 0; ; attempt++) {
|
|
29
|
+
try {
|
|
30
|
+
fs.renameSync(tmp, this.filePath)
|
|
31
|
+
return
|
|
32
|
+
} catch (err) {
|
|
33
|
+
if (attempt >= 4) {
|
|
34
|
+
try { fs.unlinkSync(tmp) } catch { /* best effort */ }
|
|
35
|
+
throw err
|
|
36
|
+
}
|
|
37
|
+
const wait = 25 * 2 ** attempt
|
|
38
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, wait)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
update(fn) {
|
|
44
|
+
fn(this.data)
|
|
45
|
+
this.save()
|
|
46
|
+
return this.data
|
|
47
|
+
}
|
|
48
|
+
}
|
package/lib/sysinfo.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import si from 'systeminformation'
|
|
2
|
+
|
|
3
|
+
const HISTORY = 150 // ~5 minutes at 2s
|
|
4
|
+
|
|
5
|
+
/** Samples the machine on a fixed cadence and keeps a short history ring. */
|
|
6
|
+
export class SystemMonitor {
|
|
7
|
+
/**
|
|
8
|
+
* @param {(event: object) => void} emit
|
|
9
|
+
* @param {() => boolean} shouldSample — false skips sampling entirely (nobody watching)
|
|
10
|
+
*/
|
|
11
|
+
constructor(emit, shouldSample = () => true) {
|
|
12
|
+
this.emit = emit
|
|
13
|
+
this.shouldSample = shouldSample
|
|
14
|
+
this.history = []
|
|
15
|
+
this.current = null
|
|
16
|
+
this.processes = []
|
|
17
|
+
this.timer = null
|
|
18
|
+
this.procTimer = null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
start() {
|
|
22
|
+
this.timer = setInterval(() => { if (this.shouldSample()) this.#sample().catch(() => {}) }, 2000)
|
|
23
|
+
// the process sweep is a heavy WMI query — keep it rare and viewer-gated
|
|
24
|
+
this.procTimer = setInterval(() => { if (this.shouldSample()) this.#sampleProcs().catch(() => {}) }, 10000)
|
|
25
|
+
this.#sample().catch(() => {})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
stop() {
|
|
29
|
+
clearInterval(this.timer)
|
|
30
|
+
clearInterval(this.procTimer)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async #sample() {
|
|
34
|
+
const [load, mem, net, disk] = await Promise.all([
|
|
35
|
+
si.currentLoad(),
|
|
36
|
+
si.mem(),
|
|
37
|
+
si.networkStats().catch(() => []),
|
|
38
|
+
si.fsSize().catch(() => []),
|
|
39
|
+
])
|
|
40
|
+
const point = {
|
|
41
|
+
ts: Date.now(),
|
|
42
|
+
cpu: round(load.currentLoad),
|
|
43
|
+
cores: load.cpus?.map((c) => round(c.load)) ?? [],
|
|
44
|
+
memUsed: mem.active,
|
|
45
|
+
memTotal: mem.total,
|
|
46
|
+
netRx: net[0]?.rx_sec ?? 0,
|
|
47
|
+
netTx: net[0]?.tx_sec ?? 0,
|
|
48
|
+
disks: disk.map((d) => ({ fs: d.fs, used: d.used, size: d.size })),
|
|
49
|
+
}
|
|
50
|
+
this.current = point
|
|
51
|
+
this.history.push({ ts: point.ts, cpu: point.cpu, memUsed: point.memUsed })
|
|
52
|
+
if (this.history.length > HISTORY) this.history.shift()
|
|
53
|
+
this.emit({ type: 'sys', point })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async #sampleProcs() {
|
|
57
|
+
const procs = await si.processes()
|
|
58
|
+
const interesting = /^(claude|node|git|powershell|wt|windowsterminal)/i
|
|
59
|
+
this.processes = procs.list
|
|
60
|
+
.filter((p) => interesting.test(p.name))
|
|
61
|
+
.sort((a, b) => b.cpu - a.cpu)
|
|
62
|
+
.slice(0, 25)
|
|
63
|
+
.map((p) => ({ pid: p.pid, name: p.name, cpu: round(p.cpu), memRss: p.memRss, command: clip(p.command, 140) }))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
snapshot() {
|
|
67
|
+
return { current: this.current, history: this.history, processes: this.processes }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function round(n) {
|
|
72
|
+
return Math.round((n ?? 0) * 10) / 10
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function clip(s, n) {
|
|
76
|
+
s = String(s ?? '')
|
|
77
|
+
return s.length > n ? `${s.slice(0, n)}…` : s
|
|
78
|
+
}
|