tmux-ide 2.6.0 → 2.6.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.
- package/bin/cli.js +13 -5
- package/bin/cli.ts +1 -1
- package/bunfig.toml +4 -0
- package/package.json +11 -3
- package/packages/contracts/package.json +22 -0
- package/packages/contracts/src/__tests__/ide-config.test.ts +46 -0
- package/packages/contracts/src/__tests__/terminals.test.ts +87 -0
- package/packages/contracts/src/actions-contract.ts +310 -0
- package/packages/contracts/src/actions-errors.ts +41 -0
- package/packages/contracts/src/domain.ts +36 -0
- package/packages/contracts/src/ide-config.ts +170 -0
- package/packages/contracts/src/index.ts +24 -0
- package/packages/contracts/src/lib-internal/auth.ts +13 -0
- package/packages/contracts/src/lib-internal/hq.ts +38 -0
- package/packages/contracts/src/terminals.ts +116 -0
- package/packages/contracts/src/tmux.ts +60 -0
- package/packages/contracts/src/workspace.ts +67 -0
- package/packages/daemon/src/agent-explain.ts +270 -0
- package/packages/daemon/src/attach.ts +20 -0
- package/packages/daemon/src/bin.ts +4 -0
- package/packages/daemon/src/canonical.ts +7 -0
- package/packages/daemon/src/cli.ts +499 -0
- package/packages/daemon/src/command-center/actions/contract.ts +2 -0
- package/packages/daemon/src/command-center/actions/dispatcher.ts +137 -0
- package/packages/daemon/src/command-center/actions/errors.ts +105 -0
- package/packages/daemon/src/command-center/actions/handlers/_project-context.ts +30 -0
- package/packages/daemon/src/command-center/actions/handlers/_resolve-project.ts +78 -0
- package/packages/daemon/src/command-center/actions/handlers/app-set-remote-access.ts +118 -0
- package/packages/daemon/src/command-center/actions/handlers/config-actions.ts +113 -0
- package/packages/daemon/src/command-center/actions/handlers/daemon-shutdown.ts +38 -0
- package/packages/daemon/src/command-center/actions/handlers/project-activate.ts +30 -0
- package/packages/daemon/src/command-center/actions/handlers/project-launch.ts +70 -0
- package/packages/daemon/src/command-center/actions/handlers/project-open-terminal.ts +87 -0
- package/packages/daemon/src/command-center/actions/handlers/project-restart.ts +38 -0
- package/packages/daemon/src/command-center/actions/handlers/project-stop.ts +62 -0
- package/packages/daemon/src/command-center/actions/handlers/terminal-respawn.ts +119 -0
- package/packages/daemon/src/command-center/actions/handlers/terminal-stop.ts +35 -0
- package/packages/daemon/src/command-center/actions/registry.ts +149 -0
- package/packages/daemon/src/command-center/discovery.ts +96 -0
- package/packages/daemon/src/command-center/index.ts +31 -0
- package/packages/daemon/src/command-center/schemas.ts +85 -0
- package/packages/daemon/src/command-center/server.ts +1260 -0
- package/packages/daemon/src/command-center/ws-events.ts +316 -0
- package/packages/daemon/src/config.ts +549 -0
- package/packages/daemon/src/detect.ts +248 -0
- package/packages/daemon/src/doctor.ts +242 -0
- package/packages/daemon/src/embed.ts +5 -0
- package/packages/daemon/src/index.ts +12 -0
- package/packages/daemon/src/init.ts +211 -0
- package/packages/daemon/src/inspect.ts +178 -0
- package/packages/daemon/src/js-yaml.d.ts +10 -0
- package/packages/daemon/src/launch.ts +349 -0
- package/packages/daemon/src/lib/active-projects.ts +49 -0
- package/packages/daemon/src/lib/agent-discovery.ts +121 -0
- package/packages/daemon/src/lib/app-config.ts +336 -0
- package/packages/daemon/src/lib/app-settings.ts +53 -0
- package/packages/daemon/src/lib/auth/auth-service.ts +227 -0
- package/packages/daemon/src/lib/auth/middleware.ts +56 -0
- package/packages/daemon/src/lib/auth/types.ts +2 -0
- package/packages/daemon/src/lib/auth-token.ts +5 -0
- package/packages/daemon/src/lib/authorship.ts +280 -0
- package/packages/daemon/src/lib/canonical-daemon.ts +122 -0
- package/packages/daemon/src/lib/cli-action-bridge.ts +216 -0
- package/packages/daemon/src/lib/daemon-embed.ts +782 -0
- package/packages/daemon/src/lib/daemon-watchdog.ts +111 -0
- package/packages/daemon/src/lib/daemon.ts +79 -0
- package/packages/daemon/src/lib/dot-path.ts +17 -0
- package/packages/daemon/src/lib/errors.ts +67 -0
- package/packages/daemon/src/lib/filesystem-browser.ts +292 -0
- package/packages/daemon/src/lib/launch-plan.ts +90 -0
- package/packages/daemon/src/lib/log.ts +134 -0
- package/packages/daemon/src/lib/output.ts +76 -0
- package/packages/daemon/src/lib/project-init-runner.ts +150 -0
- package/packages/daemon/src/lib/project-inspect.ts +80 -0
- package/packages/daemon/src/lib/project-onboard.ts +149 -0
- package/packages/daemon/src/lib/project-probe.ts +92 -0
- package/packages/daemon/src/lib/project-registry.ts +296 -0
- package/packages/daemon/src/lib/session-monitor.ts +122 -0
- package/packages/daemon/src/lib/session-options.ts +100 -0
- package/packages/daemon/src/lib/shell.ts +8 -0
- package/packages/daemon/src/lib/sizes.ts +36 -0
- package/packages/daemon/src/lib/skill-sync.ts +155 -0
- package/packages/daemon/src/lib/slugify.ts +10 -0
- package/packages/daemon/src/lib/terminals-store.ts +125 -0
- package/packages/daemon/src/lib/update-check.ts +298 -0
- package/packages/daemon/src/lib/update.ts +158 -0
- package/packages/daemon/src/lib/workspace-registry.ts +229 -0
- package/packages/daemon/src/lib/worktree.ts +289 -0
- package/packages/daemon/src/lib/yaml-io.ts +27 -0
- package/packages/daemon/src/ls.ts +40 -0
- package/packages/daemon/src/restart.ts +24 -0
- package/packages/daemon/src/restore.ts +514 -0
- package/packages/daemon/src/schemas/domain.ts +2 -0
- package/packages/daemon/src/schemas/filesystem.ts +34 -0
- package/packages/daemon/src/schemas/ide-config.ts +2 -0
- package/packages/daemon/src/schemas/index.ts +59 -0
- package/packages/daemon/src/schemas/inspect.ts +67 -0
- package/packages/daemon/src/schemas/registry.ts +55 -0
- package/packages/daemon/src/schemas/ws-events.ts +135 -0
- package/packages/daemon/src/send.ts +171 -0
- package/packages/daemon/src/server/README.md +15 -0
- package/packages/daemon/src/server/index.ts +74 -0
- package/packages/daemon/src/server/pty-bridge.ts +532 -0
- package/packages/daemon/src/server/standalone.ts +18 -0
- package/packages/daemon/src/server/ws-route.ts +483 -0
- package/packages/daemon/src/status.ts +59 -0
- package/packages/daemon/src/stop.ts +28 -0
- package/packages/daemon/src/terminal/NodePtyAdapter.ts +271 -0
- package/packages/daemon/src/terminal/PtyAdapter.ts +140 -0
- package/packages/daemon/src/terminal/README.md +92 -0
- package/packages/daemon/src/tui/chrome/cheatsheet.ts +260 -0
- package/packages/daemon/src/tui/chrome/chip.ts +30 -0
- package/packages/daemon/src/tui/chrome/events.ts +119 -0
- package/packages/daemon/src/tui/chrome/kitty-keys.ts +55 -0
- package/packages/daemon/src/tui/chrome/menu.ts +289 -0
- package/packages/daemon/src/tui/chrome/notify.ts +195 -0
- package/packages/daemon/src/tui/chrome/panels.ts +111 -0
- package/packages/daemon/src/tui/chrome/sidebar.ts +222 -0
- package/packages/daemon/src/tui/chrome/snapshot.ts +425 -0
- package/packages/daemon/src/tui/chrome/statusline.ts +595 -0
- package/packages/daemon/src/tui/chrome/updater.ts +428 -0
- package/packages/daemon/src/tui/chrome/welcome.ts +124 -0
- package/packages/daemon/src/tui/compiled.ts +113 -0
- package/packages/daemon/src/tui/detect/classify.ts +193 -0
- package/packages/daemon/src/tui/detect/manifest-loader.ts +192 -0
- package/packages/daemon/src/tui/detect/manifest.ts +184 -0
- package/packages/daemon/src/tui/detect/manifests.ts +226 -0
- package/packages/daemon/src/tui/detect/process-tree.ts +197 -0
- package/packages/daemon/src/tui/detect/snapshot.ts +70 -0
- package/packages/daemon/src/tui/integrations/claude.ts +176 -0
- package/packages/daemon/src/tui/integrations/offer.ts +145 -0
- package/packages/daemon/src/tui/main.ts +70 -0
- package/packages/daemon/src/tui/mirror/control-client.ts +143 -0
- package/packages/daemon/src/tui/mirror/control.ts +97 -0
- package/packages/daemon/src/tui/mirror/pane-mirror.ts +81 -0
- package/packages/daemon/src/tui/mirror/viewer.tsx +166 -0
- package/packages/daemon/src/tui/team/CONTROL.md +50 -0
- package/packages/daemon/src/tui/team/entry.ts +11 -0
- package/packages/daemon/src/tui/team/fuzzy.ts +133 -0
- package/packages/daemon/src/tui/team/home.ts +170 -0
- package/packages/daemon/src/tui/team/index.tsx +1521 -0
- package/packages/daemon/src/tui/team/input.ts +34 -0
- package/packages/daemon/src/tui/team/keymap.ts +127 -0
- package/packages/daemon/src/tui/team/mouse.ts +29 -0
- package/packages/daemon/src/tui/team/nav.ts +31 -0
- package/packages/daemon/src/tui/team/preview.ts +34 -0
- package/packages/daemon/src/tui/team/projects.ts +191 -0
- package/packages/daemon/src/tui/team/report.ts +73 -0
- package/packages/daemon/src/tui/team/sessions.ts +344 -0
- package/packages/daemon/src/tui/team/tree.ts +62 -0
- package/packages/daemon/src/types.ts +13 -0
- package/packages/daemon/src/ui/index.ts +32 -0
- package/packages/daemon/src/ui/terminal/index.ts +9 -0
- package/packages/daemon/src/ui/types.ts +91 -0
- package/packages/daemon/src/ui/web/base.css +80 -0
- package/packages/daemon/src/ui/web/components/Box.tsx +59 -0
- package/packages/daemon/src/ui/web/components/Input.tsx +32 -0
- package/packages/daemon/src/ui/web/components/ScrollBox.tsx +60 -0
- package/packages/daemon/src/ui/web/components/Text.tsx +28 -0
- package/packages/daemon/src/ui/web/hooks.ts +106 -0
- package/packages/daemon/src/ui/web/index.ts +27 -0
- package/packages/daemon/src/ui/web/render.ts +77 -0
- package/packages/daemon/src/ui/web/utils/color.ts +27 -0
- package/packages/daemon/src/validate.ts +217 -0
- package/packages/daemon/src/widgets/changes/README.md +3 -0
- package/packages/daemon/src/widgets/changes/index.tsx +691 -0
- package/packages/daemon/src/widgets/config/README.md +3 -0
- package/packages/daemon/src/widgets/config/index.tsx +481 -0
- package/packages/daemon/src/widgets/explorer/README.md +3 -0
- package/packages/daemon/src/widgets/explorer/breadcrumbs.tsx +77 -0
- package/packages/daemon/src/widgets/explorer/footer.tsx +20 -0
- package/packages/daemon/src/widgets/explorer/header.tsx +23 -0
- package/packages/daemon/src/widgets/explorer/index.tsx +456 -0
- package/packages/daemon/src/widgets/explorer/tree-model.ts +103 -0
- package/packages/daemon/src/widgets/explorer/tree.tsx +165 -0
- package/packages/daemon/src/widgets/lib/config-model.ts +116 -0
- package/packages/daemon/src/widgets/lib/files.ts +88 -0
- package/packages/daemon/src/widgets/lib/git.ts +88 -0
- package/packages/daemon/src/widgets/lib/grammar.ts +126 -0
- package/packages/daemon/src/widgets/lib/help-overlay.tsx +101 -0
- package/packages/daemon/src/widgets/lib/pane-comms.ts +209 -0
- package/packages/daemon/src/widgets/lib/theme.ts +194 -0
- package/packages/daemon/src/widgets/lib/watcher.ts +132 -0
- package/packages/daemon/src/widgets/preview/README.md +3 -0
- package/packages/daemon/src/widgets/preview/index.tsx +416 -0
- package/packages/daemon/src/widgets/resolve.ts +121 -0
- package/packages/daemon/src/widgets/setup/README.md +3 -0
- package/packages/daemon/src/widgets/setup/agent-naming.tsx +112 -0
- package/packages/daemon/src/widgets/setup/config-tree.tsx +246 -0
- package/packages/daemon/src/widgets/setup/detect-panel.tsx +72 -0
- package/packages/daemon/src/widgets/setup/field-editor.tsx +265 -0
- package/packages/daemon/src/widgets/setup/footer.tsx +107 -0
- package/packages/daemon/src/widgets/setup/index.tsx +341 -0
- package/packages/daemon/src/widgets/setup/layout-picker.tsx +96 -0
- package/packages/daemon/src/widgets/setup/orchestrator-panel.tsx +200 -0
- package/packages/daemon/src/widgets/setup/review-panel.tsx +140 -0
- package/packages/daemon/src/widgets/setup/setup-model.ts +188 -0
- package/packages/daemon/src/widgets/sidebar/index.tsx +527 -0
- package/packages/tmux-bridge/package.json +22 -0
- package/packages/tmux-bridge/src/errors.ts +28 -0
- package/packages/tmux-bridge/src/index.ts +31 -0
- package/packages/tmux-bridge/src/monitor.ts +77 -0
- package/packages/tmux-bridge/src/panes.ts +136 -0
- package/packages/tmux-bridge/src/runner.test.ts +501 -0
- package/packages/tmux-bridge/src/runner.ts +91 -0
- package/packages/tmux-bridge/src/sessions.ts +126 -0
- package/packages/tmux-bridge/src/targeting.test.ts +107 -0
- package/packages/tmux-bridge/src/targeting.ts +90 -0
- package/scripts/postinstall.js +26 -2
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/** Minimal structured logger for tmux-ide daemon processes. */
|
|
2
|
+
|
|
3
|
+
type Level = "debug" | "info" | "warn" | "error";
|
|
4
|
+
|
|
5
|
+
const LEVEL_RANK: Record<Level, number> = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
6
|
+
|
|
7
|
+
let minLevel: Level = (process.env.LOG_LEVEL as Level) ?? "info";
|
|
8
|
+
|
|
9
|
+
export function setLogLevel(level: Level): void {
|
|
10
|
+
minLevel = level;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Ring buffer + subscriber bus
|
|
15
|
+
//
|
|
16
|
+
// Every structured log entry is also captured in an in-memory ring (last
|
|
17
|
+
// `LOG_BUFFER_SIZE` entries) and broadcast to any subscriber. Used by the
|
|
18
|
+
// command-center's `/api/logs/:channel` SSE endpoint so the dashboard
|
|
19
|
+
// BottomPanel Output tab can stream daemon logs without scraping stderr.
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
export interface LogEntry {
|
|
23
|
+
ts: string;
|
|
24
|
+
level: Level;
|
|
25
|
+
component: string;
|
|
26
|
+
msg: string;
|
|
27
|
+
data?: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const LOG_BUFFER_SIZE = 1_000;
|
|
31
|
+
const logBuffer: LogEntry[] = [];
|
|
32
|
+
type Subscriber = (entry: LogEntry) => void;
|
|
33
|
+
const subscribers = new Set<Subscriber>();
|
|
34
|
+
|
|
35
|
+
/** Snapshot the current ring buffer; useful for SSE backfill. */
|
|
36
|
+
export function getLogBuffer(): ReadonlyArray<LogEntry> {
|
|
37
|
+
return logBuffer.slice();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Subscribe to live log entries. Returns an unsubscribe function. The
|
|
42
|
+
* handler runs synchronously inside the writer — keep it cheap; throw
|
|
43
|
+
* inside the handler is caught + logged once so one bad subscriber
|
|
44
|
+
* can't stall the daemon.
|
|
45
|
+
*/
|
|
46
|
+
export function subscribeLogs(handler: Subscriber): () => void {
|
|
47
|
+
subscribers.add(handler);
|
|
48
|
+
return () => {
|
|
49
|
+
subscribers.delete(handler);
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function writeStructuredLog(
|
|
54
|
+
level: Level,
|
|
55
|
+
component: string,
|
|
56
|
+
message: string,
|
|
57
|
+
data?: Record<string, unknown>,
|
|
58
|
+
): void {
|
|
59
|
+
if (LEVEL_RANK[level] < LEVEL_RANK[minLevel]) return;
|
|
60
|
+
const entry: LogEntry = {
|
|
61
|
+
ts: new Date().toISOString(),
|
|
62
|
+
level,
|
|
63
|
+
component,
|
|
64
|
+
msg: message,
|
|
65
|
+
...(data ? { data } : {}),
|
|
66
|
+
};
|
|
67
|
+
// Push to ring + drop oldest when over budget.
|
|
68
|
+
logBuffer.push(entry);
|
|
69
|
+
if (logBuffer.length > LOG_BUFFER_SIZE) logBuffer.shift();
|
|
70
|
+
// Fan out to subscribers; isolate failures.
|
|
71
|
+
for (const sub of subscribers) {
|
|
72
|
+
try {
|
|
73
|
+
sub(entry);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
// Avoid recursion via the logger; write directly to stderr.
|
|
76
|
+
process.stderr.write(
|
|
77
|
+
`[log.ts] subscriber threw: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// Stream form for humans tailing the process. The data field is
|
|
82
|
+
// serialized inline (matches the previous wire shape).
|
|
83
|
+
const wire: Record<string, unknown> = {
|
|
84
|
+
ts: entry.ts,
|
|
85
|
+
level: entry.level,
|
|
86
|
+
component: entry.component,
|
|
87
|
+
msg: entry.msg,
|
|
88
|
+
};
|
|
89
|
+
if (data) Object.assign(wire, data);
|
|
90
|
+
const out = level === "error" ? process.stderr : process.stdout;
|
|
91
|
+
out.write(JSON.stringify(wire) + "\n");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const logger = {
|
|
95
|
+
debug: (component: string, msg: string, data?: Record<string, unknown>) =>
|
|
96
|
+
writeStructuredLog("debug", component, msg, data),
|
|
97
|
+
info: (component: string, msg: string, data?: Record<string, unknown>) =>
|
|
98
|
+
writeStructuredLog("info", component, msg, data),
|
|
99
|
+
warn: (component: string, msg: string, data?: Record<string, unknown>) =>
|
|
100
|
+
writeStructuredLog("warn", component, msg, data),
|
|
101
|
+
error: (component: string, msg: string, data?: Record<string, unknown>) =>
|
|
102
|
+
writeStructuredLog("error", component, msg, data),
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Thin logger shim compatible with VibeTunnel's module logger API (`createLogger`-style methods).
|
|
107
|
+
* Wraps `console.*` (distinct from structured JSON `logger` above).
|
|
108
|
+
*/
|
|
109
|
+
export type LogMethod = (...args: unknown[]) => void;
|
|
110
|
+
|
|
111
|
+
export const log = {
|
|
112
|
+
/** Alias for `info` / console.log (VibeTunnel `createLogger` compatibility). */
|
|
113
|
+
log: (...args: unknown[]) => {
|
|
114
|
+
console.log(...args);
|
|
115
|
+
},
|
|
116
|
+
info: (...args: unknown[]) => {
|
|
117
|
+
console.log(...args);
|
|
118
|
+
},
|
|
119
|
+
warn: (...args: unknown[]) => {
|
|
120
|
+
console.warn(...args);
|
|
121
|
+
},
|
|
122
|
+
error: (...args: unknown[]) => {
|
|
123
|
+
console.error(...args);
|
|
124
|
+
},
|
|
125
|
+
debug: (...args: unknown[]) => {
|
|
126
|
+
console.debug(...args);
|
|
127
|
+
},
|
|
128
|
+
} satisfies {
|
|
129
|
+
log: LogMethod;
|
|
130
|
+
info: LogMethod;
|
|
131
|
+
warn: LogMethod;
|
|
132
|
+
error: LogMethod;
|
|
133
|
+
debug: LogMethod;
|
|
134
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { IdeError, TmuxError } from "./errors.ts";
|
|
2
|
+
import type { IdeConfig } from "../types.ts";
|
|
3
|
+
|
|
4
|
+
export function printLayout(config: IdeConfig): void {
|
|
5
|
+
const INNER = 40;
|
|
6
|
+
const rows = config.rows ?? [];
|
|
7
|
+
if (rows.length === 0) return;
|
|
8
|
+
|
|
9
|
+
for (let r = 0; r < rows.length; r++) {
|
|
10
|
+
const panes = rows[r]!.panes ?? [];
|
|
11
|
+
const count = panes.length || 1;
|
|
12
|
+
const widths: number[] = [];
|
|
13
|
+
let remaining = INNER;
|
|
14
|
+
for (let i = 0; i < count; i++) {
|
|
15
|
+
const w = i < count - 1 ? Math.floor(INNER / count) : remaining;
|
|
16
|
+
widths.push(w);
|
|
17
|
+
remaining -= w;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Top border or mid divider
|
|
21
|
+
if (r === 0) {
|
|
22
|
+
let top = " \u250c";
|
|
23
|
+
for (let i = 0; i < count; i++) {
|
|
24
|
+
top += "\u2500".repeat(widths[i]!);
|
|
25
|
+
top += i < count - 1 ? "\u252c" : "\u2510";
|
|
26
|
+
}
|
|
27
|
+
console.log(top);
|
|
28
|
+
} else {
|
|
29
|
+
console.log(" \u251c" + "\u2500".repeat(INNER + count - 1) + "\u2524");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Content line
|
|
33
|
+
const sizeLabel = rows[r]!.size ?? "";
|
|
34
|
+
let line = " \u2502";
|
|
35
|
+
for (let i = 0; i < count; i++) {
|
|
36
|
+
const title = panes[i]?.title ?? "";
|
|
37
|
+
const w = widths[i]!;
|
|
38
|
+
const pad = Math.max(0, w - title.length);
|
|
39
|
+
const left = Math.floor(pad / 2);
|
|
40
|
+
const right = pad - left;
|
|
41
|
+
line += " ".repeat(left) + title + " ".repeat(right) + "\u2502";
|
|
42
|
+
}
|
|
43
|
+
if (sizeLabel) line += " " + sizeLabel;
|
|
44
|
+
console.log(line);
|
|
45
|
+
|
|
46
|
+
// Bottom border (last row only)
|
|
47
|
+
if (r === rows.length - 1) {
|
|
48
|
+
let bot = " \u2514";
|
|
49
|
+
for (let i = 0; i < count; i++) {
|
|
50
|
+
bot += "\u2500".repeat(widths[i]!);
|
|
51
|
+
bot += i < count - 1 ? "\u2534" : "\u2518";
|
|
52
|
+
}
|
|
53
|
+
console.log(bot);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function outputError(
|
|
59
|
+
message: string,
|
|
60
|
+
code: string,
|
|
61
|
+
{ exitCode = 1 }: { exitCode?: number } = {},
|
|
62
|
+
): never {
|
|
63
|
+
throw new IdeError(message, { code, exitCode });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function printCommandError(
|
|
67
|
+
error: IdeError | TmuxError,
|
|
68
|
+
{ json = false }: { json?: boolean } = {},
|
|
69
|
+
): never {
|
|
70
|
+
if (json) {
|
|
71
|
+
console.error(JSON.stringify(error.toJSON(), null, 2));
|
|
72
|
+
} else {
|
|
73
|
+
console.error(error.message);
|
|
74
|
+
}
|
|
75
|
+
process.exit(error.exitCode ?? 1);
|
|
76
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run `tmux-ide init [--template <id>]` in a target directory and stream the
|
|
3
|
+
* output line-by-line. Used by `POST /api/projects/init` so the dashboard can
|
|
4
|
+
* scaffold new projects from the UI.
|
|
5
|
+
*
|
|
6
|
+
* The spawn helper is pluggable so tests can drive the runner without
|
|
7
|
+
* actually shelling out.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
11
|
+
|
|
12
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The minimal contract of `child_process.spawn` we depend on. Tests pass in
|
|
16
|
+
* a synthetic implementation that emits lines deterministically.
|
|
17
|
+
*/
|
|
18
|
+
export type SpawnFn = (
|
|
19
|
+
command: string,
|
|
20
|
+
args: string[],
|
|
21
|
+
options: { cwd: string },
|
|
22
|
+
) => ChildProcessWithoutNullStreams;
|
|
23
|
+
|
|
24
|
+
export interface InitRunnerOptions {
|
|
25
|
+
template?: string;
|
|
26
|
+
cwd: string;
|
|
27
|
+
onChunk: (chunk: string) => void;
|
|
28
|
+
/** Override for tests. */
|
|
29
|
+
spawnFn?: SpawnFn;
|
|
30
|
+
/** Defaults to 30s. */
|
|
31
|
+
timeoutMs?: number;
|
|
32
|
+
/** The binary to run. Defaults to `tmux-ide`; tests pin to a no-op. */
|
|
33
|
+
command?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class ProjectInitTimeoutError extends Error {
|
|
37
|
+
readonly timeoutMs: number;
|
|
38
|
+
constructor(timeoutMs: number) {
|
|
39
|
+
super(`tmux-ide init timed out after ${timeoutMs}ms`);
|
|
40
|
+
this.name = "ProjectInitTimeoutError";
|
|
41
|
+
this.timeoutMs = timeoutMs;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class ProjectInitFailedError extends Error {
|
|
46
|
+
readonly exitCode: number | null;
|
|
47
|
+
readonly stderr: string;
|
|
48
|
+
constructor(exitCode: number | null, stderr: string) {
|
|
49
|
+
super(`tmux-ide init exited with code ${exitCode ?? "(killed)"}: ${stderr || "no stderr"}`);
|
|
50
|
+
this.name = "ProjectInitFailedError";
|
|
51
|
+
this.exitCode = exitCode;
|
|
52
|
+
this.stderr = stderr;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Buffer stream chunks into newline-delimited lines, emit each line through
|
|
58
|
+
* `onChunk`. Returns a `flush()` to drain any trailing partial line on close.
|
|
59
|
+
*/
|
|
60
|
+
function lineStreamer(onChunk: (chunk: string) => void): {
|
|
61
|
+
push: (text: string) => void;
|
|
62
|
+
flush: () => void;
|
|
63
|
+
} {
|
|
64
|
+
let pending = "";
|
|
65
|
+
return {
|
|
66
|
+
push(text: string): void {
|
|
67
|
+
pending += text;
|
|
68
|
+
let newline = pending.indexOf("\n");
|
|
69
|
+
while (newline >= 0) {
|
|
70
|
+
const line = pending.slice(0, newline).replace(/\r$/, "");
|
|
71
|
+
pending = pending.slice(newline + 1);
|
|
72
|
+
onChunk(line);
|
|
73
|
+
newline = pending.indexOf("\n");
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
flush(): void {
|
|
77
|
+
if (pending.length === 0) return;
|
|
78
|
+
onChunk(pending.replace(/\r$/, ""));
|
|
79
|
+
pending = "";
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Spawn `tmux-ide init` and resolve when it exits. Streams stdout+stderr
|
|
86
|
+
* through `onChunk` line-by-line so callers can fan them out over WebSocket.
|
|
87
|
+
*
|
|
88
|
+
* Resolves with `{ ok: true }` on exit code 0, rejects with
|
|
89
|
+
* `ProjectInitFailedError` (non-zero) or `ProjectInitTimeoutError` (>30s).
|
|
90
|
+
*/
|
|
91
|
+
export async function runInit(options: InitRunnerOptions): Promise<{ ok: true }> {
|
|
92
|
+
const spawnFn = options.spawnFn ?? (spawn as unknown as SpawnFn);
|
|
93
|
+
const command = options.command ?? "tmux-ide";
|
|
94
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
95
|
+
const args = ["init"];
|
|
96
|
+
if (options.template) args.push("--template", options.template);
|
|
97
|
+
|
|
98
|
+
const child = spawnFn(command, args, { cwd: options.cwd });
|
|
99
|
+
const stderrStreamer = lineStreamer(options.onChunk);
|
|
100
|
+
const stdoutStreamer = lineStreamer(options.onChunk);
|
|
101
|
+
let stderrBuffer = "";
|
|
102
|
+
|
|
103
|
+
child.stdout.setEncoding?.("utf-8");
|
|
104
|
+
child.stderr.setEncoding?.("utf-8");
|
|
105
|
+
|
|
106
|
+
child.stdout.on("data", (data: string | Buffer) => {
|
|
107
|
+
stdoutStreamer.push(typeof data === "string" ? data : data.toString("utf-8"));
|
|
108
|
+
});
|
|
109
|
+
child.stderr.on("data", (data: string | Buffer) => {
|
|
110
|
+
const text = typeof data === "string" ? data : data.toString("utf-8");
|
|
111
|
+
stderrBuffer += text;
|
|
112
|
+
stderrStreamer.push(text);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
return new Promise<{ ok: true }>((resolveResult, reject) => {
|
|
116
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
117
|
+
let settled = false;
|
|
118
|
+
|
|
119
|
+
const settle = (fn: () => void): void => {
|
|
120
|
+
if (settled) return;
|
|
121
|
+
settled = true;
|
|
122
|
+
if (timer) clearTimeout(timer);
|
|
123
|
+
stdoutStreamer.flush();
|
|
124
|
+
stderrStreamer.flush();
|
|
125
|
+
fn();
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
timer = setTimeout(() => {
|
|
129
|
+
try {
|
|
130
|
+
child.kill("SIGKILL");
|
|
131
|
+
} catch {
|
|
132
|
+
// child may already be gone
|
|
133
|
+
}
|
|
134
|
+
settle(() => reject(new ProjectInitTimeoutError(timeoutMs)));
|
|
135
|
+
}, timeoutMs);
|
|
136
|
+
timer.unref?.();
|
|
137
|
+
|
|
138
|
+
child.on("error", (err: Error) => {
|
|
139
|
+
settle(() => reject(err));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
child.on("close", (code: number | null) => {
|
|
143
|
+
if (code === 0) {
|
|
144
|
+
settle(() => resolveResult({ ok: true }));
|
|
145
|
+
} else {
|
|
146
|
+
settle(() => reject(new ProjectInitFailedError(code, stderrBuffer.trim())));
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project inspect — looks at an arbitrary directory and reports whether it
|
|
3
|
+
* has an `ide.yml`, basic git identity, and the detected stack signals from
|
|
4
|
+
* `src/detect.ts`. Crucially, this is registry-agnostic: it never touches
|
|
5
|
+
* `~/.tmux-ide/projects.json`, so the dashboard can probe directories the
|
|
6
|
+
* user is just considering before deciding to register them.
|
|
7
|
+
*
|
|
8
|
+
* The git + ide.yml bits delegate to `probeProject` so we don't duplicate
|
|
9
|
+
* the timeout-bounded git invocations. The stack detection delegates to
|
|
10
|
+
* `detectStack` from `src/detect.ts` so the wire format stays in sync with
|
|
11
|
+
* `tmux-ide detect`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync } from "node:fs";
|
|
15
|
+
import { isAbsolute, resolve } from "node:path";
|
|
16
|
+
import { detectStack } from "../detect.ts";
|
|
17
|
+
import type { ProjectInspect, ProjectInspectDetected } from "../schemas/inspect.ts";
|
|
18
|
+
import { probeProject, type ProbeIo } from "./project-probe.ts";
|
|
19
|
+
|
|
20
|
+
export class InspectDirNotFoundError extends Error {
|
|
21
|
+
readonly code = "DIR_NOT_FOUND";
|
|
22
|
+
constructor(dir: string) {
|
|
23
|
+
super(`Directory "${dir}" does not exist`);
|
|
24
|
+
this.name = "InspectDirNotFoundError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const KNOWN_PACKAGE_MANAGERS = new Set(["pnpm", "npm", "yarn", "bun"] as const);
|
|
29
|
+
|
|
30
|
+
function narrowPackageManager(raw: string | null): ProjectInspectDetected["packageManager"] {
|
|
31
|
+
if (!raw) return null;
|
|
32
|
+
return KNOWN_PACKAGE_MANAGERS.has(raw as "pnpm") ? (raw as "pnpm") : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Compute a sensible test command from the detected stack. We don't have a
|
|
37
|
+
* dedicated test detector in `detectStack`, so we compose one from the
|
|
38
|
+
* package manager — best-effort, can be edited by the user in the wizard.
|
|
39
|
+
*/
|
|
40
|
+
function inferTestCommand(packageManager: string | null): string | null {
|
|
41
|
+
if (!packageManager) return null;
|
|
42
|
+
return packageManager === "npm" ? "npm test" : `${packageManager} test`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface InspectIo {
|
|
46
|
+
exists?: (path: string) => boolean;
|
|
47
|
+
probeIo?: ProbeIo;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Inspect a directory and return identity + detected stack. Throws
|
|
52
|
+
* `InspectDirNotFoundError` if the directory doesn't exist; otherwise
|
|
53
|
+
* never throws (probe + detect both swallow internal failures).
|
|
54
|
+
*/
|
|
55
|
+
export async function inspectProject(dir: string, io: InspectIo = {}): Promise<ProjectInspect> {
|
|
56
|
+
const exists = io.exists ?? existsSync;
|
|
57
|
+
const absoluteDir = isAbsolute(dir) ? dir : resolve(dir);
|
|
58
|
+
if (!exists(absoluteDir)) {
|
|
59
|
+
throw new InspectDirNotFoundError(absoluteDir);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const probe = await probeProject(absoluteDir, io.probeIo);
|
|
63
|
+
const stack = detectStack(absoluteDir);
|
|
64
|
+
|
|
65
|
+
const detected: ProjectInspectDetected = {
|
|
66
|
+
packageManager: narrowPackageManager(stack.packageManager),
|
|
67
|
+
frameworks: stack.frameworks,
|
|
68
|
+
devCommand: stack.devCommand,
|
|
69
|
+
testCommand: inferTestCommand(stack.packageManager),
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
name: probe.name,
|
|
74
|
+
dir: probe.dir,
|
|
75
|
+
hasIdeYml: probe.hasIdeYml,
|
|
76
|
+
gitOrigin: probe.gitOrigin,
|
|
77
|
+
gitBranch: probe.gitBranch,
|
|
78
|
+
detected,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project onboard — composes a sensible `ide.yml` from a small set of
|
|
3
|
+
* inputs (project name, agent count, optional dev/test commands) and
|
|
4
|
+
* writes it to disk so the project can be registered.
|
|
5
|
+
*
|
|
6
|
+
* `composeIdeYml` is the pure-logic bit (string in → string out) so the
|
|
7
|
+
* shape of the generated config is exhaustively unit-tested without
|
|
8
|
+
* touching the filesystem. The wrapper around `writeConfig` lives in the
|
|
9
|
+
* server route, which calls this function and then `registerProject`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import yaml from "js-yaml";
|
|
13
|
+
import { existsSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import type { Pane, Row, IdeConfig } from "../schemas/ide-config.ts";
|
|
16
|
+
|
|
17
|
+
export class OnboardConflictError extends Error {
|
|
18
|
+
readonly code = "IDE_YML_EXISTS";
|
|
19
|
+
constructor(path: string) {
|
|
20
|
+
super(`ide.yml already exists at ${path}`);
|
|
21
|
+
this.name = "OnboardConflictError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class OnboardInvalidInputError extends Error {
|
|
26
|
+
readonly code = "INVALID_INPUT";
|
|
27
|
+
constructor(message: string) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "OnboardInvalidInputError";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ComposeIdeYmlInput {
|
|
34
|
+
/** Final project name written to the `name:` field. */
|
|
35
|
+
name: string;
|
|
36
|
+
/** 1, 2, or 3 — how many Claude panes to put in the top row. */
|
|
37
|
+
agents: number;
|
|
38
|
+
/**
|
|
39
|
+
* Optional per-agent pane titles. When provided, length must equal
|
|
40
|
+
* `agents`; otherwise it's ignored and the canonical defaults are used
|
|
41
|
+
* (`Lead`/`Teammate N` for team layouts, `Claude N` for solo).
|
|
42
|
+
*/
|
|
43
|
+
agentNames?: string[];
|
|
44
|
+
/** Dev server command. `null` / undefined → no dev pane. */
|
|
45
|
+
devCommand?: string | null;
|
|
46
|
+
/** Test command. Currently unused in the generated layout but kept for future. */
|
|
47
|
+
testCommand?: string | null;
|
|
48
|
+
/** Lint command. Currently unused in the generated layout but kept for future. */
|
|
49
|
+
lintCommand?: string | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build the `IdeConfig` value the wizard should write. Pure: same input →
|
|
54
|
+
* same output, no io.
|
|
55
|
+
*
|
|
56
|
+
* Layout shape:
|
|
57
|
+
* - Row 0 (70%): N Claude panes, first focused. When `agents > 1`, the
|
|
58
|
+
* `team:` block is added so Claude Code launches with team mode.
|
|
59
|
+
* - Row 1 (30%): Dev pane (if `devCommand` set) + Shell pane.
|
|
60
|
+
*/
|
|
61
|
+
export function composeIdeYmlConfig(input: ComposeIdeYmlInput): IdeConfig {
|
|
62
|
+
if (!Number.isInteger(input.agents) || input.agents < 1 || input.agents > 3) {
|
|
63
|
+
throw new OnboardInvalidInputError(
|
|
64
|
+
`agents must be an integer between 1 and 3 (got ${input.agents})`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
const cleanName = input.name.trim();
|
|
68
|
+
if (!cleanName) {
|
|
69
|
+
throw new OnboardInvalidInputError("name must be a non-empty string");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const agentsCount = input.agents;
|
|
73
|
+
const useTeam = agentsCount > 1;
|
|
74
|
+
|
|
75
|
+
const customNames = input.agentNames;
|
|
76
|
+
if (customNames !== undefined) {
|
|
77
|
+
if (customNames.length !== agentsCount) {
|
|
78
|
+
throw new OnboardInvalidInputError(
|
|
79
|
+
`agentNames length (${customNames.length}) must equal agents (${agentsCount})`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
for (const name of customNames) {
|
|
83
|
+
if (typeof name !== "string" || name.trim() === "") {
|
|
84
|
+
throw new OnboardInvalidInputError("agentNames entries must be non-empty strings");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const topPanes: Pane[] = [];
|
|
90
|
+
for (let i = 0; i < agentsCount; i++) {
|
|
91
|
+
const fallback = useTeam ? (i === 0 ? "Lead" : `Teammate ${i}`) : `Claude ${i + 1}`;
|
|
92
|
+
const customTitle = customNames?.[i]?.trim();
|
|
93
|
+
const pane: Pane = {
|
|
94
|
+
title: customTitle && customTitle.length > 0 ? customTitle : fallback,
|
|
95
|
+
command: "claude",
|
|
96
|
+
};
|
|
97
|
+
if (useTeam) {
|
|
98
|
+
pane.role = i === 0 ? "lead" : "teammate";
|
|
99
|
+
}
|
|
100
|
+
if (i === 0) {
|
|
101
|
+
pane.focus = true;
|
|
102
|
+
}
|
|
103
|
+
topPanes.push(pane);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const bottomPanes: Pane[] = [];
|
|
107
|
+
const devCommand = input.devCommand?.trim();
|
|
108
|
+
if (devCommand) {
|
|
109
|
+
bottomPanes.push({ title: "Dev", command: devCommand });
|
|
110
|
+
}
|
|
111
|
+
bottomPanes.push({ title: "Shell" });
|
|
112
|
+
|
|
113
|
+
const rows: Row[] = [{ size: "70%", panes: topPanes }, { panes: bottomPanes }];
|
|
114
|
+
|
|
115
|
+
const config: IdeConfig = {
|
|
116
|
+
name: cleanName,
|
|
117
|
+
rows,
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
if (useTeam) {
|
|
121
|
+
config.team = { name: cleanName };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return config;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Render a composed `IdeConfig` as YAML. Wrapper over `js-yaml` with the
|
|
129
|
+
* same options used elsewhere so the output shape is identical.
|
|
130
|
+
*/
|
|
131
|
+
export function composeIdeYml(input: ComposeIdeYmlInput): string {
|
|
132
|
+
const config = composeIdeYmlConfig(input);
|
|
133
|
+
return yaml.dump(config, { lineWidth: -1, noRefs: true, quotingType: '"' });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Reject onboarding when `ide.yml` already exists. The caller writes the
|
|
138
|
+
* file separately via `writeConfig`; this helper exists so the route can
|
|
139
|
+
* 409 cleanly without ever overwriting user content.
|
|
140
|
+
*/
|
|
141
|
+
export function assertNoExistingIdeYml(
|
|
142
|
+
dir: string,
|
|
143
|
+
exists: (path: string) => boolean = existsSync,
|
|
144
|
+
): void {
|
|
145
|
+
const path = join(dir, "ide.yml");
|
|
146
|
+
if (exists(path)) {
|
|
147
|
+
throw new OnboardConflictError(path);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project probe — inspects a directory and returns identity facts (name from
|
|
3
|
+
* basename, ide.yml presence, git origin/branch). Pure-ish wrapper over a few
|
|
4
|
+
* filesystem and git child-process calls; the io functions are pluggable for
|
|
5
|
+
* tests so we don't need a full mock filesystem.
|
|
6
|
+
*
|
|
7
|
+
* All git invocations are hard-bounded by a 2s timeout — we never want a
|
|
8
|
+
* single misbehaving repo to hang an HTTP request.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execFile } from "node:child_process";
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import { basename, isAbsolute, resolve } from "node:path";
|
|
14
|
+
|
|
15
|
+
const GIT_TIMEOUT_MS = 2_000;
|
|
16
|
+
|
|
17
|
+
export interface ProjectProbe {
|
|
18
|
+
name: string;
|
|
19
|
+
dir: string;
|
|
20
|
+
hasIdeYml: boolean;
|
|
21
|
+
gitOrigin: string | null;
|
|
22
|
+
gitBranch: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Pluggable io for tests. The defaults are real fs/child_process calls — pass
|
|
27
|
+
* `io` overrides to drive the probe deterministically in unit tests.
|
|
28
|
+
*/
|
|
29
|
+
export interface ProbeIo {
|
|
30
|
+
exists(path: string): boolean;
|
|
31
|
+
runGit(args: string[], cwd: string): Promise<string | null>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const realIo: ProbeIo = {
|
|
35
|
+
exists: existsSync,
|
|
36
|
+
runGit: (args, cwd) =>
|
|
37
|
+
new Promise((resolveResult) => {
|
|
38
|
+
execFile(
|
|
39
|
+
"git",
|
|
40
|
+
["-C", cwd, ...args],
|
|
41
|
+
{ timeout: GIT_TIMEOUT_MS, encoding: "utf-8" },
|
|
42
|
+
(err, stdout) => {
|
|
43
|
+
if (err) {
|
|
44
|
+
resolveResult(null);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
resolveResult(stdout.trim());
|
|
48
|
+
},
|
|
49
|
+
);
|
|
50
|
+
}),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Replace dangerous filename characters and collapse whitespace so the
|
|
55
|
+
* default `name` is safe to use as a tmux session name and a registry key.
|
|
56
|
+
*/
|
|
57
|
+
export function sanitizeName(raw: string): string {
|
|
58
|
+
return raw
|
|
59
|
+
.trim()
|
|
60
|
+
.replace(/\s+/g, "-")
|
|
61
|
+
.replace(/[^A-Za-z0-9._-]/g, "")
|
|
62
|
+
.replace(/^-+|-+$/g, "");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Probe a directory and return its identity. Never throws — returns nulls for
|
|
67
|
+
* any field that can't be determined. The caller decides what's a hard error
|
|
68
|
+
* (e.g. dir doesn't exist).
|
|
69
|
+
*/
|
|
70
|
+
export async function probeProject(dir: string, io: ProbeIo = realIo): Promise<ProjectProbe> {
|
|
71
|
+
const absoluteDir = isAbsolute(dir) ? dir : resolve(dir);
|
|
72
|
+
const rawName = basename(absoluteDir);
|
|
73
|
+
const sanitized = sanitizeName(rawName);
|
|
74
|
+
const name = sanitized.length > 0 ? sanitized : "project";
|
|
75
|
+
|
|
76
|
+
const hasIdeYml = io.exists(`${absoluteDir}/ide.yml`);
|
|
77
|
+
|
|
78
|
+
const [gitOrigin, gitBranch] = await Promise.all([
|
|
79
|
+
io.runGit(["config", "--get", "remote.origin.url"], absoluteDir),
|
|
80
|
+
io.runGit(["branch", "--show-current"], absoluteDir),
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
name,
|
|
85
|
+
dir: absoluteDir,
|
|
86
|
+
hasIdeYml,
|
|
87
|
+
// Treat empty string as null — branch --show-current returns "" on a
|
|
88
|
+
// detached HEAD.
|
|
89
|
+
gitOrigin: gitOrigin && gitOrigin.length > 0 ? gitOrigin : null,
|
|
90
|
+
gitBranch: gitBranch && gitBranch.length > 0 ? gitBranch : null,
|
|
91
|
+
};
|
|
92
|
+
}
|