@uniqbit/mate-core 0.17.0-canary.8 → 0.17.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/package.json +11 -5
- package/src/cli/commands/artifact/finish/index.ts +0 -9
- package/src/cli/commands/artifact/finish/openspec.ts +2 -2
- package/src/cli/commands/artifact/finish/registry.ts +1 -1
- package/src/cli/commands/artifact/pending/discovery.ts +1 -1
- package/src/cli/commands/artifact/pending/index.ts +0 -16
- package/src/cli/commands/cap/graphify.ts +1 -1
- package/src/cli/commands/cap/index-cmd.ts +1 -1
- package/src/cli/commands/cap/tokensave.ts +4 -3
- package/src/cli/commands/companion/companion.ts +4 -0
- package/src/cli/commands/companion/hub.ts +2 -1
- package/src/cli/commands/companion/list.ts +2 -1
- package/src/cli/commands/companion/prepare.ts +2 -2
- package/src/cli/commands/companion/update.ts +334 -0
- package/src/cli/commands/doctor.ts +2 -1
- package/src/cli/commands/install.ts +59 -5
- package/src/cli/commands/launch/shared.ts +2 -1
- package/src/cli/commands/plugin/plugin.ts +4 -0
- package/src/cli/commands/plugin/verify.ts +25 -0
- package/src/cli/commands/report/highlight.ts +1 -4
- package/src/cli/commands/report/renderer.ts +0 -2
- package/src/cli/commands/studio/access.ts +201 -0
- package/src/cli/commands/studio/index.ts +207 -26
- package/src/cli/commands/studio/openspec-cli.ts +1 -1
- package/src/cli/commands/studio/selection.ts +127 -11
- package/src/cli/commands/studio/server.ts +355 -64
- package/src/cli/commands/studio/terminal-assets.ts +50 -0
- package/src/cli/commands/studio/terminal-launch.ts +71 -0
- package/src/cli/commands/studio/terminal.ts +593 -0
- package/src/cli/commands/studio/vault.ts +320 -75
- package/src/cli/commands/studio/views/client.ts +90 -9
- package/src/cli/commands/studio/views/companion-picker.tsx +9 -5
- package/src/cli/commands/studio/views/companion-selector.tsx +11 -7
- package/src/cli/commands/studio/views/document.tsx +411 -107
- package/src/cli/commands/studio/views/model.ts +14 -1
- package/src/cli/commands/studio/views/selection-fields.tsx +14 -0
- package/src/cli/commands/studio/views/styles.ts +106 -13
- package/src/cli/commands/studio/views/terminal.tsx +426 -0
- package/src/cli/commands/studio/views/vault/state.ts +42 -0
- package/src/cli/commands/studio/views/workflow/steps.ts +10 -3
- package/src/cli/commands/update.ts +11 -8
- package/src/cli/companion-link-wizard.tsx +1 -1
- package/src/cli/companion-selector.tsx +1 -1
- package/src/cli/launch-selector.tsx +1 -1
- package/src/cli/main.ts +2 -1
- package/src/cli/usage.ts +7 -3
- package/src/cli/write-json-stdout.ts +2 -2
- package/src/lib/components/spinner.tsx +2 -2
- package/src/lib/components/startup-progress.tsx +1 -1
- package/src/lib/context-mode-package.ts +1 -1
- package/src/lib/opencode-plugin-package.ts +3 -0
- package/src/lib/orchestrator/adapters/opencode.ts +53 -13
- package/src/lib/orchestrator/companion-hub.ts +3 -3
- package/src/lib/orchestrator/companion-registry-store.ts +1 -1
- package/src/lib/orchestrator/config-store.ts +2 -2
- package/src/lib/orchestrator/editor.ts +4 -12
- package/src/lib/orchestrator/engine-guard.ts +9 -1
- package/src/lib/orchestrator/projection-entries.ts +1 -1
- package/src/lib/orchestrator/projection-record.ts +1 -1
- package/src/lib/orchestrator/projection-types.ts +0 -4
- package/src/lib/orchestrator/repo-local-registry.ts +0 -1
- package/src/lib/orchestrator/root-context.ts +1 -1
- package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
- package/src/lib/orchestrator/setup-preflight.ts +1 -1
- package/src/lib/orchestrator/types.ts +1 -1
- package/src/lib/orchestrator/working-repo-projection.ts +1 -1
- package/src/lib/orchestrator/workspace-inventory.ts +2 -2
- package/src/lib/package-paths.ts +1 -11
- package/src/lib/package-root.ts +43 -0
- package/src/lib/prebuilt-workspace.ts +1 -1
- package/src/lib/public-npm.ts +1 -1
- package/src/opencode/companion-hooks.ts +131 -153
- package/src/opencode/index.ts +12 -6
- package/src/opencode/tui.tsx +46 -49
- package/src/playbooks/companion-guidance.ts +1 -28
- package/src/runtime/companion-sync.ts +0 -3
- package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
- package/src/templates/providers/opencode/opencode.json +6 -4
- package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +500 -408
- package/src/tools/setup/capabilities/context-mode.ts +45 -25
- package/src/tools/setup/capabilities/graphify-shared.ts +3 -3
- package/src/tools/setup/capabilities/graphify.ts +2 -2
- package/src/tools/setup/capabilities/openspec.ts +2 -7
- package/src/tools/setup/capabilities/react-doctor.ts +1 -1
- package/src/tools/setup/capabilities/rtk.ts +2 -2
- package/src/tools/setup/capabilities/tokensave.ts +98 -12
- package/src/tools/setup/context-services.ts +3 -6
- package/src/tools/setup/dynamic-plugins/declarations.ts +47 -0
- package/src/tools/setup/dynamic-plugins/frozen.ts +215 -0
- package/src/tools/setup/dynamic-plugins/hydrate.ts +1 -40
- package/src/tools/setup/dynamic-plugins/loader.ts +16 -0
- package/src/tools/setup/dynamic-plugins/policy.ts +55 -0
- package/src/tools/setup/dynamic-plugins/staging.ts +53 -0
- package/src/tools/setup/dynamic-plugins/verify.ts +70 -0
- package/src/tools/setup/mate.ts +1 -1
- package/src/tools/setup/providers/claude-format.ts +1 -1
- package/src/tools/setup/providers/claude.ts +1 -1
- package/src/tools/setup/providers/opencode-format.ts +220 -25
- package/src/tools/setup/providers/opencode.ts +63 -21
- package/src/tools/setup/runtime-documents.ts +9 -11
- package/src/tools/setup.ts +0 -4
- package/src/cli/commands/studio/views/pairings.tsx +0 -38
- package/src/templates/providers/opencode/tui.json +0 -3
|
@@ -10,6 +10,7 @@ import { createStartupProgress } from "../../../lib/components/startup-progress"
|
|
|
10
10
|
import { ensureUnambiguousCompanion, launchAmbiguityDeps } from "../shared/companion-selection";
|
|
11
11
|
import { runIndexCapCommand } from "../cap/index-cmd";
|
|
12
12
|
import type { LaunchTarget } from "../../launch-selector";
|
|
13
|
+
import { writeJsonStdout } from "../../write-json-stdout";
|
|
13
14
|
|
|
14
15
|
const STEP_LABELS = {
|
|
15
16
|
sync: "Syncing mate",
|
|
@@ -127,7 +128,7 @@ export async function runLaunchToolCommand(
|
|
|
127
128
|
* The printed result stays useful, but the status is what a supervisor
|
|
128
129
|
* reads: a failed agent must fail the launch.
|
|
129
130
|
*/
|
|
130
|
-
|
|
131
|
+
await writeJsonStdout(result);
|
|
131
132
|
if (result.exitCode !== 0) process.exitCode = result.exitCode;
|
|
132
133
|
} catch (error) {
|
|
133
134
|
progress?.failCurrent();
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { usage } from "../../usage";
|
|
2
2
|
import { runPluginInstallCommand } from "./install";
|
|
3
|
+
import { runPluginVerifyCommand } from "./verify";
|
|
3
4
|
|
|
4
5
|
export async function runPluginCommand(
|
|
5
6
|
subcommand: string | undefined,
|
|
@@ -9,6 +10,9 @@ export async function runPluginCommand(
|
|
|
9
10
|
case "install":
|
|
10
11
|
await runPluginInstallCommand(argv);
|
|
11
12
|
return;
|
|
13
|
+
case "verify":
|
|
14
|
+
await runPluginVerifyCommand();
|
|
15
|
+
return;
|
|
12
16
|
default:
|
|
13
17
|
console.error(`Unknown plugin command: ${subcommand ?? ""}`);
|
|
14
18
|
console.error(usage());
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { FRAMEWORK_NAME } from "../../../framework";
|
|
2
|
+
import { resolveInstallContext } from "../../../lib/install";
|
|
3
|
+
import { verifyDeclaredPlugins } from "../../../tools/setup/dynamic-plugins/verify";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @command mate plugin verify
|
|
7
|
+
* @description Strict, installation-free check that every plugin declared by the companion is allowed by `MATE_ALLOWED_PLUGINS`, installed, and loadable with the current environment. Exits non-zero naming each failing package.
|
|
8
|
+
*/
|
|
9
|
+
export async function runPluginVerifyCommand(cwd = process.cwd()): Promise<boolean> {
|
|
10
|
+
const context = await resolveInstallContext(cwd);
|
|
11
|
+
if ((context.kind !== "companion" && context.kind !== "hub") || !context.companionPath) {
|
|
12
|
+
process.stderr.write(`${FRAMEWORK_NAME}: \`plugin verify\` requires a companion context.\n`);
|
|
13
|
+
process.exitCode = 1;
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
const failures = await verifyDeclaredPlugins(context.companionPath);
|
|
17
|
+
for (const failure of failures) {
|
|
18
|
+
process.stderr.write(`${FRAMEWORK_NAME}: plugin ${failure.package}: ${failure.reason}\n`);
|
|
19
|
+
}
|
|
20
|
+
if (failures.length > 0) {
|
|
21
|
+
process.exitCode = 1;
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
@@ -4,10 +4,7 @@ import githubLight from "@tanstack/highlight/themes/github-light";
|
|
|
4
4
|
|
|
5
5
|
/** Sole boundary against `@tanstack/highlight`, so an upstream API break stays a single-file fix. */
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
export const PLAIN_LANGUAGE = "plaintext";
|
|
9
|
-
|
|
10
|
-
export function resolveHighlightLanguage(candidate: string | undefined): string {
|
|
7
|
+
function resolveHighlightLanguage(candidate: string | undefined): string {
|
|
11
8
|
return normalizeLanguage(candidate);
|
|
12
9
|
}
|
|
13
10
|
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/** Shortest operator-supplied token accepted; a generated one is 43 characters. */
|
|
4
|
+
export const MIN_TOKEN_LENGTH = 32;
|
|
5
|
+
|
|
6
|
+
const LOOPBACK_HOSTNAMES = ["localhost", "127.0.0.1", "[::1]"];
|
|
7
|
+
|
|
8
|
+
export type StudioInvocation = "interactive" | "serve";
|
|
9
|
+
|
|
10
|
+
export interface StudioAccessOptions {
|
|
11
|
+
invocation: StudioInvocation;
|
|
12
|
+
terminal: boolean;
|
|
13
|
+
/** Bound interface as passed to the server. */
|
|
14
|
+
hostname: string;
|
|
15
|
+
/** Read at request time: the port is known only once the server has bound. */
|
|
16
|
+
port: () => number;
|
|
17
|
+
allowedHosts?: readonly string[];
|
|
18
|
+
publicOrigin?: string | null;
|
|
19
|
+
token?: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* One server's Host, Origin, and token policy. `null` token means the server
|
|
24
|
+
* is unguarded and issues none.
|
|
25
|
+
*/
|
|
26
|
+
export interface StudioAccess {
|
|
27
|
+
invocation: StudioInvocation;
|
|
28
|
+
guarded: boolean;
|
|
29
|
+
token: string | null;
|
|
30
|
+
cookieName(): string;
|
|
31
|
+
/** Accepted origin for this request, or `null` when its Host is not one Studio answers to. */
|
|
32
|
+
acceptedOrigin(request: Request): string | null;
|
|
33
|
+
originMatches(request: Request, acceptedOrigin: string): boolean;
|
|
34
|
+
hasToken(request: Request): boolean;
|
|
35
|
+
/** Whether a request of this kind needs the token under this invocation. */
|
|
36
|
+
requires(kind: "read" | "save" | "terminal"): boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function generateStudioToken(): string {
|
|
40
|
+
return randomBytes(32).toString("base64url");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function validateStudioToken(value: string): string | null {
|
|
44
|
+
if (value.trim().length < MIN_TOKEN_LENGTH) {
|
|
45
|
+
return `the supplied token must be at least ${MIN_TOKEN_LENGTH} characters`;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `null` for anything other than an exact `http(s)://host[:port]` origin. */
|
|
51
|
+
export function normalizeOrigin(value: string): string | null {
|
|
52
|
+
let parsed: URL;
|
|
53
|
+
try {
|
|
54
|
+
parsed = new URL(value);
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
|
59
|
+
if (parsed.username || parsed.password) return null;
|
|
60
|
+
if (parsed.pathname !== "/" || parsed.search || parsed.hash || value.endsWith("/")) return null;
|
|
61
|
+
return parsed.origin;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Lower-cased `host[:port]` with the HTTP default port dropped, or `null` when unparsable. */
|
|
65
|
+
export function normalizeAuthority(value: string): string | null {
|
|
66
|
+
const trimmed = value.trim();
|
|
67
|
+
if (!trimmed || /[\s/?#@\\]/.test(trimmed)) return null;
|
|
68
|
+
try {
|
|
69
|
+
const parsed = new URL(`http://${trimmed}`);
|
|
70
|
+
return parsed.host;
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function bracketed(hostname: string): string {
|
|
77
|
+
return hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function digest(value: string): Buffer {
|
|
81
|
+
return createHash("sha256").update(value).digest();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Hashing first keeps the comparison constant-time for inputs of any length. */
|
|
85
|
+
export function tokensEqual(candidate: string, expected: string): boolean {
|
|
86
|
+
return timingSafeEqual(digest(candidate), digest(expected));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function readCookie(request: Request, name: string): string | null {
|
|
90
|
+
const header = request.headers.get("cookie");
|
|
91
|
+
if (!header) return null;
|
|
92
|
+
for (const part of header.split(";")) {
|
|
93
|
+
const index = part.indexOf("=");
|
|
94
|
+
if (index < 0) continue;
|
|
95
|
+
if (part.slice(0, index).trim() === name) return part.slice(index + 1).trim();
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function createStudioAccess(options: StudioAccessOptions): StudioAccess {
|
|
101
|
+
const guarded = options.invocation === "serve" || options.terminal;
|
|
102
|
+
const token = guarded ? (options.token ?? generateStudioToken()) : null;
|
|
103
|
+
const publicOrigin = options.publicOrigin ? normalizeOrigin(options.publicOrigin) : null;
|
|
104
|
+
const publicAuthority = publicOrigin ? new URL(publicOrigin).host : null;
|
|
105
|
+
const configured = new Set<string>();
|
|
106
|
+
for (const host of options.allowedHosts ?? []) {
|
|
107
|
+
const authority = normalizeAuthority(host);
|
|
108
|
+
if (authority) configured.add(authority);
|
|
109
|
+
}
|
|
110
|
+
if (publicAuthority) configured.add(publicAuthority);
|
|
111
|
+
|
|
112
|
+
const accepted = (): Set<string> => {
|
|
113
|
+
const port = options.port();
|
|
114
|
+
const hosts = new Set(configured);
|
|
115
|
+
for (const hostname of [...LOOPBACK_HOSTNAMES, bracketed(options.hostname)]) {
|
|
116
|
+
const authority = normalizeAuthority(`${hostname}:${port}`);
|
|
117
|
+
if (authority) hosts.add(authority);
|
|
118
|
+
}
|
|
119
|
+
return hosts;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
invocation: options.invocation,
|
|
124
|
+
guarded,
|
|
125
|
+
token,
|
|
126
|
+
cookieName: () => `mate_studio_${options.port()}`,
|
|
127
|
+
acceptedOrigin(request) {
|
|
128
|
+
const raw = request.headers.get("host") ?? new URL(request.url).host;
|
|
129
|
+
const authority = normalizeAuthority(raw);
|
|
130
|
+
if (!authority || !accepted().has(authority)) return null;
|
|
131
|
+
if (publicOrigin && authority === publicAuthority) return publicOrigin;
|
|
132
|
+
return `http://${authority}`;
|
|
133
|
+
},
|
|
134
|
+
originMatches(request, acceptedOrigin) {
|
|
135
|
+
const origin = request.headers.get("origin");
|
|
136
|
+
if (!origin || origin === "null") return false;
|
|
137
|
+
return normalizeOrigin(origin) === acceptedOrigin;
|
|
138
|
+
},
|
|
139
|
+
hasToken(request) {
|
|
140
|
+
if (!token) return false;
|
|
141
|
+
const value = readCookie(request, `mate_studio_${options.port()}`);
|
|
142
|
+
return value !== null && tokensEqual(value, token);
|
|
143
|
+
},
|
|
144
|
+
requires(kind) {
|
|
145
|
+
if (!guarded) return false;
|
|
146
|
+
if (options.invocation === "serve") return true;
|
|
147
|
+
return kind !== "read";
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const EXCHANGE_HEADERS = {
|
|
153
|
+
"cache-control": "no-store",
|
|
154
|
+
"referrer-policy": "no-referrer",
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Trades a `?token=` navigation for the cookie and a relative redirect, so the
|
|
159
|
+
* browser never leaves the Host it asked for. `null` when the request carries
|
|
160
|
+
* no token parameter.
|
|
161
|
+
*/
|
|
162
|
+
export function exchangeStudioToken(
|
|
163
|
+
request: Request,
|
|
164
|
+
access: StudioAccess,
|
|
165
|
+
acceptedOrigin: string,
|
|
166
|
+
): Response | null {
|
|
167
|
+
const url = new URL(request.url);
|
|
168
|
+
if (!url.searchParams.has("token")) return null;
|
|
169
|
+
if (request.method !== "GET" || !access.token) {
|
|
170
|
+
return new Response("the access token is exchanged only by opening its address", {
|
|
171
|
+
status: 400,
|
|
172
|
+
headers: EXCHANGE_HEADERS,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
const candidate = url.searchParams.get("token") ?? "";
|
|
176
|
+
if (!tokensEqual(candidate, access.token)) {
|
|
177
|
+
return new Response("the access token does not match; open the address Studio printed", {
|
|
178
|
+
status: 403,
|
|
179
|
+
headers: EXCHANGE_HEADERS,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
url.searchParams.delete("token");
|
|
183
|
+
const secure = acceptedOrigin.startsWith("https:") ? "; Secure" : "";
|
|
184
|
+
return new Response(null, {
|
|
185
|
+
status: 303,
|
|
186
|
+
headers: {
|
|
187
|
+
...EXCHANGE_HEADERS,
|
|
188
|
+
location: `${url.pathname}${url.search}`,
|
|
189
|
+
"set-cookie": `${access.cookieName()}=${access.token}; Path=/; HttpOnly; SameSite=Strict${secure}`,
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Address the operator opens: loopback for wildcard or loopback binds, carrying the token only when it was generated. */
|
|
195
|
+
export function studioAddress(hostname: string, port: number, token: string | null): string {
|
|
196
|
+
const wildcard = hostname === "0.0.0.0" || hostname === "::" || hostname === "[::]";
|
|
197
|
+
const loopback = ["127.0.0.1", "localhost", "::1", "[::1]"].includes(hostname);
|
|
198
|
+
const host = wildcard || loopback ? "localhost" : bracketed(hostname);
|
|
199
|
+
const base = `http://${host}:${port}`;
|
|
200
|
+
return token ? `${base}/?token=${token}` : base;
|
|
201
|
+
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
1
3
|
import { FRAMEWORK_NAME } from "../../../framework";
|
|
2
4
|
import { openReportInBrowser } from "../report/delivery";
|
|
5
|
+
import { normalizeAuthority, normalizeOrigin, validateStudioToken } from "./access";
|
|
6
|
+
import { collectStudioInventory, type StudioInventory } from "./inventory";
|
|
3
7
|
import {
|
|
4
8
|
serveUntilInterrupted,
|
|
5
9
|
startStudioServer,
|
|
@@ -7,49 +11,196 @@ import {
|
|
|
7
11
|
type StudioServerHandle,
|
|
8
12
|
type StudioServerOptions,
|
|
9
13
|
} from "./server";
|
|
14
|
+
import { terminalUnsupportedReason } from "./terminal";
|
|
10
15
|
|
|
11
16
|
export interface StudioCommandDeps {
|
|
12
17
|
startStudioServer?: typeof startStudioServer;
|
|
13
18
|
serveUntilInterrupted?: typeof serveUntilInterrupted;
|
|
14
19
|
openInBrowser?: (url: string) => Promise<void>;
|
|
20
|
+
collectStudioInventory?: () => Promise<StudioInventory>;
|
|
21
|
+
terminalUnsupportedReason?: () => string | null;
|
|
22
|
+
env?: NodeJS.ProcessEnv;
|
|
15
23
|
log?: (message: string) => void;
|
|
16
24
|
warn?: (message: string) => void;
|
|
17
25
|
}
|
|
18
26
|
|
|
19
27
|
export type StudioServeArgs = StudioServerOptions & { port: number; hostname: string };
|
|
20
28
|
|
|
21
|
-
export
|
|
29
|
+
export type StudioInteractiveArgs = Pick<
|
|
30
|
+
StudioServerOptions,
|
|
31
|
+
"writable" | "terminal" | "detachMinutes"
|
|
32
|
+
>;
|
|
33
|
+
|
|
34
|
+
interface ParseState {
|
|
35
|
+
writable: boolean;
|
|
36
|
+
terminal: boolean;
|
|
37
|
+
detachMinutes?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Reads one `--flag value` / `--flag=value` pair. `undefined` when `arg` is not
|
|
42
|
+
* that flag; `{ error }` when its value is missing.
|
|
43
|
+
*/
|
|
44
|
+
function flagValue(
|
|
45
|
+
argv: string[],
|
|
46
|
+
index: number,
|
|
47
|
+
flag: string,
|
|
48
|
+
): { value: string; consumed: number } | { error: string } | undefined {
|
|
49
|
+
const arg = argv[index]!;
|
|
50
|
+
if (arg !== flag && !arg.startsWith(`${flag}=`)) return undefined;
|
|
51
|
+
const value = arg === flag ? argv[index + 1] : arg.slice(flag.length + 1);
|
|
52
|
+
if (!value || value.startsWith("--")) return { error: `${flag} requires a value` };
|
|
53
|
+
return { value, consumed: arg === flag ? 2 : 1 };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Flags shared by both invocations; returns how many arguments were consumed, 0 when none. */
|
|
57
|
+
function parseSharedFlag(
|
|
58
|
+
argv: string[],
|
|
59
|
+
index: number,
|
|
60
|
+
state: ParseState,
|
|
61
|
+
): number | { error: string } {
|
|
62
|
+
const arg = argv[index]!;
|
|
63
|
+
if (arg === "--writable") {
|
|
64
|
+
state.writable = true;
|
|
65
|
+
return 1;
|
|
66
|
+
}
|
|
67
|
+
if (arg === "--terminal") {
|
|
68
|
+
state.terminal = true;
|
|
69
|
+
return 1;
|
|
70
|
+
}
|
|
71
|
+
const detach = flagValue(argv, index, "--detach-timeout");
|
|
72
|
+
if (!detach) return 0;
|
|
73
|
+
if ("error" in detach) return detach;
|
|
74
|
+
if (!/^\d+$/.test(detach.value) || Number(detach.value) < 1) {
|
|
75
|
+
return { error: `invalid --detach-timeout value: ${detach.value} (whole minutes, at least 1)` };
|
|
76
|
+
}
|
|
77
|
+
state.detachMinutes = Number(detach.value);
|
|
78
|
+
return detach.consumed;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function checkTerminal(state: ParseState): { error: string } | null {
|
|
82
|
+
if (state.terminal && !state.writable) {
|
|
83
|
+
return { error: "--terminal requires --writable" };
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function parseStudioArgs(argv: string[]): StudioInteractiveArgs | { error: string } {
|
|
89
|
+
const state: ParseState = { writable: false, terminal: false };
|
|
90
|
+
for (let index = 0; index < argv.length;) {
|
|
91
|
+
const consumed = parseSharedFlag(argv, index, state);
|
|
92
|
+
if (typeof consumed === "object") return consumed;
|
|
93
|
+
if (consumed === 0) return { error: `unrecognized studio argument: ${argv[index]}` };
|
|
94
|
+
index += consumed;
|
|
95
|
+
}
|
|
96
|
+
return (
|
|
97
|
+
checkTerminal(state) ?? {
|
|
98
|
+
writable: state.writable,
|
|
99
|
+
...(state.terminal ? { terminal: true } : {}),
|
|
100
|
+
...(state.detachMinutes !== undefined ? { detachMinutes: state.detachMinutes } : {}),
|
|
101
|
+
}
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function parseStudioServeArgs(
|
|
106
|
+
argv: string[],
|
|
107
|
+
env: NodeJS.ProcessEnv = {},
|
|
108
|
+
): StudioServeArgs | { error: string } {
|
|
22
109
|
let port: number | undefined;
|
|
23
110
|
let hostname = STUDIO_HOSTNAME;
|
|
111
|
+
let token: string | undefined;
|
|
112
|
+
let publicOrigin: string | undefined;
|
|
113
|
+
let companion: string | undefined;
|
|
114
|
+
let noGit = false;
|
|
115
|
+
const allowedHosts: string[] = [];
|
|
116
|
+
const state: ParseState = { writable: false, terminal: false };
|
|
24
117
|
|
|
25
|
-
for (let index = 0; index < argv.length;
|
|
118
|
+
for (let index = 0; index < argv.length;) {
|
|
26
119
|
const arg = argv[index]!;
|
|
27
|
-
|
|
120
|
+
const shared = parseSharedFlag(argv, index, state);
|
|
121
|
+
if (typeof shared === "object") return shared;
|
|
122
|
+
if (shared > 0) {
|
|
123
|
+
index += shared;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (arg === "--no-git") {
|
|
127
|
+
noGit = true;
|
|
128
|
+
index += 1;
|
|
28
129
|
continue;
|
|
29
130
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (
|
|
33
|
-
const parsed = Number(value);
|
|
34
|
-
if (!/^\d+$/.test(value) ||
|
|
35
|
-
return { error: `invalid --port value: ${value}` };
|
|
131
|
+
const portFlag = flagValue(argv, index, "--port");
|
|
132
|
+
if (portFlag) {
|
|
133
|
+
if ("error" in portFlag) return portFlag;
|
|
134
|
+
const parsed = Number(portFlag.value);
|
|
135
|
+
if (!/^\d+$/.test(portFlag.value) || parsed < 1 || parsed > 65535) {
|
|
136
|
+
return { error: `invalid --port value: ${portFlag.value}` };
|
|
36
137
|
}
|
|
37
138
|
port = parsed;
|
|
38
|
-
|
|
139
|
+
index += portFlag.consumed;
|
|
39
140
|
continue;
|
|
40
141
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
142
|
+
const valued = (
|
|
143
|
+
[
|
|
144
|
+
["--host", (value: string) => (hostname = value)],
|
|
145
|
+
["--token", (value: string) => (token = value)],
|
|
146
|
+
["--allowed-host", (value: string) => allowedHosts.push(...splitHosts(value))],
|
|
147
|
+
["--public-origin", (value: string) => (publicOrigin = value)],
|
|
148
|
+
["--companion", (value: string) => (companion = value)],
|
|
149
|
+
] as const
|
|
150
|
+
).find(([flag]) => flagValue(argv, index, flag) !== undefined);
|
|
151
|
+
if (valued) {
|
|
152
|
+
const read = flagValue(argv, index, valued[0])!;
|
|
153
|
+
if ("error" in read) return read;
|
|
154
|
+
valued[1](read.value);
|
|
155
|
+
index += read.consumed;
|
|
46
156
|
continue;
|
|
47
157
|
}
|
|
48
158
|
return { error: `unknown studio serve option: ${arg}` };
|
|
49
159
|
}
|
|
50
160
|
|
|
51
161
|
if (port === undefined) return { error: "studio serve requires --port" };
|
|
52
|
-
|
|
162
|
+
const terminalError = checkTerminal(state);
|
|
163
|
+
if (terminalError) return terminalError;
|
|
164
|
+
|
|
165
|
+
token ??= env.MATE_STUDIO_TOKEN || undefined;
|
|
166
|
+
if (token !== undefined) {
|
|
167
|
+
const weak = validateStudioToken(token);
|
|
168
|
+
if (weak) return { error: `--token / MATE_STUDIO_TOKEN: ${weak}` };
|
|
169
|
+
}
|
|
170
|
+
if (env.MATE_STUDIO_ALLOWED_HOSTS)
|
|
171
|
+
allowedHosts.push(...splitHosts(env.MATE_STUDIO_ALLOWED_HOSTS));
|
|
172
|
+
for (const host of allowedHosts) {
|
|
173
|
+
if (!normalizeAuthority(host)) {
|
|
174
|
+
return { error: `--allowed-host / MATE_STUDIO_ALLOWED_HOSTS: invalid host: ${host}` };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
publicOrigin ??= env.MATE_STUDIO_PUBLIC_ORIGIN || undefined;
|
|
178
|
+
if (publicOrigin !== undefined && !normalizeOrigin(publicOrigin)) {
|
|
179
|
+
return {
|
|
180
|
+
error: `--public-origin / MATE_STUDIO_PUBLIC_ORIGIN: expected http(s)://host[:port], got ${publicOrigin}`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
port,
|
|
186
|
+
hostname,
|
|
187
|
+
writable: state.writable,
|
|
188
|
+
invocation: "serve",
|
|
189
|
+
...(state.terminal ? { terminal: true } : {}),
|
|
190
|
+
...(state.detachMinutes !== undefined ? { detachMinutes: state.detachMinutes } : {}),
|
|
191
|
+
...(token !== undefined ? { token } : {}),
|
|
192
|
+
...(allowedHosts.length > 0 ? { allowedHosts } : {}),
|
|
193
|
+
...(publicOrigin !== undefined ? { publicOrigin } : {}),
|
|
194
|
+
...(companion !== undefined ? { launchCompanion: path.resolve(companion) } : {}),
|
|
195
|
+
...(noGit ? { noGit: true } : {}),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function splitHosts(value: string): string[] {
|
|
200
|
+
return value
|
|
201
|
+
.split(",")
|
|
202
|
+
.map((host) => host.trim())
|
|
203
|
+
.filter(Boolean);
|
|
53
204
|
}
|
|
54
205
|
|
|
55
206
|
/**
|
|
@@ -60,8 +211,10 @@ export function parseStudioServeArgs(argv: string[]): StudioServeArgs | { error:
|
|
|
60
211
|
* browser at it. Runs in the foreground on an operating-system-assigned
|
|
61
212
|
* loopback port and dies with the process: no daemon, no stop command, and no
|
|
62
213
|
* state written anywhere.
|
|
63
|
-
* @remarks Both invocations are read-only unless passed `--writable`.
|
|
64
|
-
*
|
|
214
|
+
* @remarks Both invocations are read-only unless passed `--writable`.
|
|
215
|
+
* `--terminal` adds the agent terminal and guards Studio with a per-start
|
|
216
|
+
* access token; `serve` is always guarded. Neither invocation resolves a
|
|
217
|
+
* Repository Link, so it runs from any directory.
|
|
65
218
|
*/
|
|
66
219
|
export async function runStudioCommand(
|
|
67
220
|
argv: string[] = [],
|
|
@@ -75,11 +228,13 @@ export async function runStudioCommand(
|
|
|
75
228
|
return;
|
|
76
229
|
}
|
|
77
230
|
|
|
78
|
-
|
|
79
|
-
|
|
231
|
+
const parsed = parseStudioArgs(argv);
|
|
232
|
+
if ("error" in parsed) {
|
|
233
|
+
warn(`${FRAMEWORK_NAME}: ${parsed.error}`);
|
|
80
234
|
process.exitCode = 1;
|
|
81
235
|
return;
|
|
82
236
|
}
|
|
237
|
+
if (!terminalAvailable(parsed, deps, warn)) return;
|
|
83
238
|
|
|
84
239
|
const start = deps.startStudioServer ?? startStudioServer;
|
|
85
240
|
const serve = deps.serveUntilInterrupted ?? serveUntilInterrupted;
|
|
@@ -87,7 +242,7 @@ export async function runStudioCommand(
|
|
|
87
242
|
|
|
88
243
|
let server;
|
|
89
244
|
try {
|
|
90
|
-
server = start({},
|
|
245
|
+
server = start({}, parsed);
|
|
91
246
|
} catch (error) {
|
|
92
247
|
warn(
|
|
93
248
|
`${FRAMEWORK_NAME}: studio could not bind a port: ${
|
|
@@ -98,35 +253,60 @@ export async function runStudioCommand(
|
|
|
98
253
|
return;
|
|
99
254
|
}
|
|
100
255
|
|
|
101
|
-
log(server.
|
|
256
|
+
log(server.address);
|
|
102
257
|
log("Press Ctrl+C to stop.");
|
|
103
258
|
|
|
104
259
|
try {
|
|
105
|
-
await open(server.
|
|
260
|
+
await open(server.address);
|
|
106
261
|
} catch (error) {
|
|
107
262
|
warn(
|
|
108
263
|
`${FRAMEWORK_NAME}: studio could not open a browser: ${
|
|
109
264
|
error instanceof Error ? error.message : String(error)
|
|
110
265
|
}`,
|
|
111
266
|
);
|
|
112
|
-
warn(`${FRAMEWORK_NAME}: open ${server.
|
|
267
|
+
warn(`${FRAMEWORK_NAME}: open ${server.address} manually; the server keeps serving.`);
|
|
113
268
|
}
|
|
114
269
|
|
|
115
270
|
await serve(server);
|
|
116
271
|
}
|
|
117
272
|
|
|
273
|
+
function terminalAvailable(
|
|
274
|
+
options: Pick<StudioServerOptions, "terminal">,
|
|
275
|
+
deps: StudioCommandDeps,
|
|
276
|
+
warn: (message: string) => void,
|
|
277
|
+
): boolean {
|
|
278
|
+
if (!options.terminal) return true;
|
|
279
|
+
const reason = (deps.terminalUnsupportedReason ?? terminalUnsupportedReason)();
|
|
280
|
+
if (!reason) return true;
|
|
281
|
+
warn(`${FRAMEWORK_NAME}: --terminal ${reason}`);
|
|
282
|
+
process.exitCode = 1;
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
|
|
118
286
|
async function runStudioServeCommand(
|
|
119
287
|
argv: string[],
|
|
120
288
|
deps: StudioCommandDeps,
|
|
121
289
|
log: (message: string) => void,
|
|
122
290
|
warn: (message: string) => void,
|
|
123
291
|
): Promise<void> {
|
|
124
|
-
const parsed = parseStudioServeArgs(argv);
|
|
292
|
+
const parsed = parseStudioServeArgs(argv, deps.env ?? process.env);
|
|
125
293
|
if ("error" in parsed) {
|
|
126
294
|
warn(`${FRAMEWORK_NAME}: ${parsed.error}`);
|
|
127
295
|
process.exitCode = 1;
|
|
128
296
|
return;
|
|
129
297
|
}
|
|
298
|
+
if (!terminalAvailable(parsed, deps, warn)) return;
|
|
299
|
+
|
|
300
|
+
if (parsed.launchCompanion) {
|
|
301
|
+
const inventory = await (deps.collectStudioInventory ?? collectStudioInventory)();
|
|
302
|
+
if (!inventory.companions.some((companion) => companion.path === parsed.launchCompanion)) {
|
|
303
|
+
warn(
|
|
304
|
+
`${FRAMEWORK_NAME}: --companion ${parsed.launchCompanion} is not a registered Companion Repository`,
|
|
305
|
+
);
|
|
306
|
+
process.exitCode = 1;
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
130
310
|
|
|
131
311
|
const start = deps.startStudioServer ?? startStudioServer;
|
|
132
312
|
const serve = deps.serveUntilInterrupted ?? serveUntilInterrupted;
|
|
@@ -143,6 +323,7 @@ async function runStudioServeCommand(
|
|
|
143
323
|
return;
|
|
144
324
|
}
|
|
145
325
|
|
|
146
|
-
log(server.
|
|
326
|
+
log(server.address);
|
|
327
|
+
if (parsed.token) log("Access token: the value pinned by --token / MATE_STUDIO_TOKEN.");
|
|
147
328
|
await serve(server);
|
|
148
329
|
}
|
|
@@ -27,7 +27,7 @@ export const WORKFLOW_CAPABILITY_ID = "openspec";
|
|
|
27
27
|
/** Upper bound on one collection call; a hung wrapper must not hold a request open. */
|
|
28
28
|
const OPENSPEC_TIMEOUT_MS = 60_000;
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
function openSpecWrapperPath(): string {
|
|
31
31
|
return path.join(getWrapperBinPath(), "openspec");
|
|
32
32
|
}
|
|
33
33
|
|