@uniqbit/mate-core 0.17.0-canary.1 → 0.17.0-canary.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/package.json +7 -2
  2. package/src/cli/commands/artifact/finish/index.ts +0 -9
  3. package/src/cli/commands/artifact/finish/openspec.ts +2 -2
  4. package/src/cli/commands/artifact/finish/registry.ts +1 -1
  5. package/src/cli/commands/artifact/pending/discovery.ts +1 -1
  6. package/src/cli/commands/artifact/pending/index.ts +0 -15
  7. package/src/cli/commands/cap/graphify.ts +1 -1
  8. package/src/cli/commands/companion/hub.ts +2 -1
  9. package/src/cli/commands/companion/list.ts +2 -1
  10. package/src/cli/commands/companion/prepare.ts +2 -2
  11. package/src/cli/commands/doctor.ts +2 -1
  12. package/src/cli/commands/launch/claude.ts +2 -3
  13. package/src/cli/commands/launch/opencode.ts +2 -3
  14. package/src/cli/commands/launch/shared.ts +21 -36
  15. package/src/cli/commands/report/highlight.ts +1 -4
  16. package/src/cli/commands/report/renderer.ts +0 -2
  17. package/src/cli/commands/studio/access.ts +203 -0
  18. package/src/cli/commands/studio/index.ts +207 -26
  19. package/src/cli/commands/studio/openspec-cli.ts +1 -1
  20. package/src/cli/commands/studio/selection.ts +1 -1
  21. package/src/cli/commands/studio/server.ts +281 -18
  22. package/src/cli/commands/studio/terminal-assets.ts +50 -0
  23. package/src/cli/commands/studio/terminal-launch.ts +71 -0
  24. package/src/cli/commands/studio/terminal.ts +593 -0
  25. package/src/cli/commands/studio/vault.ts +1 -6
  26. package/src/cli/commands/studio/views/document.tsx +13 -2
  27. package/src/cli/commands/studio/views/model.ts +10 -0
  28. package/src/cli/commands/studio/views/styles.ts +12 -0
  29. package/src/cli/commands/studio/views/terminal.tsx +232 -0
  30. package/src/cli/commands/studio/views/workflow/steps.ts +10 -3
  31. package/src/cli/commands/update.ts +6 -6
  32. package/src/cli/companion-link-wizard.tsx +1 -1
  33. package/src/cli/companion-selector.tsx +1 -1
  34. package/src/cli/launch-selector.tsx +1 -1
  35. package/src/cli/main.ts +23 -0
  36. package/src/cli/usage.ts +6 -4
  37. package/src/cli/write-json-stdout.ts +2 -2
  38. package/src/lib/components/spinner.tsx +2 -2
  39. package/src/lib/components/startup-progress.tsx +1 -1
  40. package/src/lib/context-mode-package.ts +1 -1
  41. package/src/lib/node-engine-version.ts +15 -0
  42. package/src/lib/orchestrator/adapters/opencode.ts +23 -0
  43. package/src/lib/orchestrator/companion-hub.ts +3 -3
  44. package/src/lib/orchestrator/companion-registry-store.ts +1 -1
  45. package/src/lib/orchestrator/config-store.ts +2 -2
  46. package/src/lib/orchestrator/editor.ts +4 -12
  47. package/src/lib/orchestrator/projection-entries.ts +1 -1
  48. package/src/lib/orchestrator/projection-record.ts +1 -1
  49. package/src/lib/orchestrator/projection-types.ts +0 -4
  50. package/src/lib/orchestrator/repo-local-registry.ts +0 -1
  51. package/src/lib/orchestrator/root-context.ts +1 -1
  52. package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
  53. package/src/lib/orchestrator/setup-preflight.ts +1 -1
  54. package/src/lib/orchestrator/types.ts +1 -1
  55. package/src/lib/orchestrator/working-repo-projection.ts +1 -1
  56. package/src/lib/orchestrator/workspace-inventory.ts +2 -2
  57. package/src/lib/package-paths.ts +1 -11
  58. package/src/lib/package-root.ts +43 -0
  59. package/src/lib/prebuilt-workspace.ts +15 -5
  60. package/src/lib/preinstalled-plugins.ts +26 -2
  61. package/src/lib/public-npm.ts +1 -1
  62. package/src/playbooks/companion-guidance.ts +1 -28
  63. package/src/runtime/companion-sync.ts +0 -3
  64. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
  65. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +43 -43
  66. package/src/tools/setup/capabilities/context-mode.ts +29 -2
  67. package/src/tools/setup/capabilities/graphify-shared.ts +3 -3
  68. package/src/tools/setup/capabilities/graphify.ts +2 -2
  69. package/src/tools/setup/capabilities/openspec.ts +14 -9
  70. package/src/tools/setup/capabilities/react-doctor.ts +1 -1
  71. package/src/tools/setup/capabilities/rtk.ts +2 -2
  72. package/src/tools/setup/context-services.ts +3 -6
  73. package/src/tools/setup/mate.ts +1 -1
  74. package/src/tools/setup/providers/claude-format.ts +1 -1
  75. package/src/tools/setup/providers/claude.ts +1 -1
  76. package/src/tools/setup/providers/opencode.ts +0 -2
  77. package/src/tools/setup/runtime-documents.ts +1 -7
  78. package/src/tools/setup.ts +0 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniqbit/mate-core",
3
- "version": "0.17.0-canary.1",
3
+ "version": "0.17.0-canary.11",
4
4
  "description": "Core framework and plugin APIs for Mate.",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -23,7 +23,7 @@
23
23
  "registry": "https://registry.npmjs.org/"
24
24
  },
25
25
  "scripts": {
26
- "test": "CI=1 bun test",
26
+ "test": "CI=1 bun ../../test/run-isolated.ts",
27
27
  "typecheck": "bunx tsc --noEmit -p ../../tsconfig.json",
28
28
  "lint": "oxlint src/",
29
29
  "lint:fix": "oxlint src/ --fix",
@@ -36,6 +36,11 @@
36
36
  "@opentui/keymap": "^0.4.5",
37
37
  "@opentui/solid": "^0.4.5",
38
38
  "@tanstack/highlight": "0.0.10",
39
+ "@xterm/addon-clipboard": "^0.2.0",
40
+ "@xterm/addon-fit": "^0.11.0",
41
+ "@xterm/addon-unicode11": "^0.9.0",
42
+ "@xterm/addon-web-links": "^0.12.0",
43
+ "@xterm/xterm": "^6.0.0",
39
44
  "hono": "^4.13.5",
40
45
  "ink": "^7.1.0",
41
46
  "react": "^19.2.7",
@@ -1,10 +1 @@
1
1
  export { runArtifactPublishCommand } from "./command";
2
- export type { PublishCommandDeps } from "./command";
3
- export type { FinishResult, FinishStep, FinishStatus } from "./engine";
4
- export type {
5
- ArtifactFinisher,
6
- FinishContext,
7
- FinisherFactory,
8
- Produced,
9
- ResolveResult,
10
- } from "./finisher";
@@ -269,7 +269,7 @@ async function resolved(
269
269
  }
270
270
 
271
271
  /** Tag namespace segregating spec publications from dated change anchors. */
272
- export const SPEC_TAG_NAMESPACE = "openspec/specs";
272
+ const SPEC_TAG_NAMESPACE = "openspec/specs";
273
273
  /** Spec ids named individually before the remainder collapses into a count. */
274
274
  const SPEC_LABEL_LIMIT = 3;
275
275
 
@@ -297,7 +297,7 @@ function specLabel(paths: string[]): string {
297
297
  }
298
298
 
299
299
  /** Commit subject for a spec publication; it belongs to no change, so it names specs, never an anchor. */
300
- export function specCommitSubject(paths: string[]): string {
300
+ function specCommitSubject(paths: string[]): string {
301
301
  const ids = specIds(paths);
302
302
  const named = ids.slice(0, SPEC_LABEL_LIMIT);
303
303
  const remaining = ids.length - named.length;
@@ -5,7 +5,7 @@ import { openspecFinisher } from "./openspec";
5
5
  * Artifact-kind → finisher factory. openspec is the only kind for now; a future ADR
6
6
  * finisher registers here and is selected via `--type`, with no engine changes.
7
7
  */
8
- export const FINISHERS: Record<string, FinisherFactory> = {
8
+ const FINISHERS: Record<string, FinisherFactory> = {
9
9
  openspec: openspecFinisher,
10
10
  };
11
11
 
@@ -52,7 +52,7 @@ export interface ArchiveEntry {
52
52
  const ARCHIVE_ANCHOR_PATTERN = /^\d{4}-\d{2}-\d{2}-.+$/;
53
53
  const ARCHIVE_DATE_PREFIX = /^\d{4}-\d{2}-\d{2}-/;
54
54
 
55
- export const ARCHIVE_RELATIVE_DIR = "openspec/changes/archive";
55
+ const ARCHIVE_RELATIVE_DIR = "openspec/changes/archive";
56
56
  export const SPECS_RELATIVE_DIR = "openspec/specs";
57
57
 
58
58
  /** The tag `mate artifact publish` creates, never a recomputed date. */
@@ -1,17 +1,2 @@
1
1
  export { runArtifactPendingCommand } from "./command";
2
2
  export type { PendingCommandDeps, PendingResult } from "./command";
3
- export {
4
- ARCHIVE_RELATIVE_DIR,
5
- SPECS_RELATIVE_DIR,
6
- discoverArchives,
7
- finishMarker,
8
- pendingArchives,
9
- unattributedSpecs,
10
- } from "./discovery";
11
- export type {
12
- ArchiveEntry,
13
- CommitState,
14
- SpecAttribution,
15
- UnattributedSpec,
16
- UncommittedSpecChange,
17
- } from "./discovery";
@@ -29,7 +29,7 @@ const GRAPHIFY_PATH_COMMANDS = new Set([
29
29
 
30
30
  const GRAPHIFY_GRAPH_COMMANDS = new Set(["query", "path", "explain", "affected", "tree"]);
31
31
 
32
- export function deriveGraphifyPaths(companionPath: string, repositoryId: string): GraphifyPaths {
32
+ function deriveGraphifyPaths(companionPath: string, repositoryId: string): GraphifyPaths {
33
33
  const graphifyOut = path.join(
34
34
  companionPath,
35
35
  GRAPHIFY_STORE_SEGMENT,
@@ -8,6 +8,7 @@ import {
8
8
  updateHubPlugins,
9
9
  } from "../../../lib/orchestrator/companion-hub";
10
10
  import { GlobalConfigStore } from "../../../lib/orchestrator/global-config-store";
11
+ import { writeJsonStdout } from "../../write-json-stdout";
11
12
 
12
13
  function positionalArgs(argv: string[]): string[] {
13
14
  const values: string[] = [];
@@ -71,7 +72,7 @@ async function runHubSync(argv: string[]): Promise<void> {
71
72
  const results = await syncHub(process.cwd());
72
73
  const plugins = await updateHubPlugins(process.cwd());
73
74
  if (argv.includes("--json")) {
74
- console.log(JSON.stringify({ companions: results, plugins }));
75
+ await writeJsonStdout({ companions: results, plugins }, { space: 0 });
75
76
  return;
76
77
  }
77
78
  for (const result of results) console.log(`${result.id}: ${result.status} (${result.message})`);
@@ -1,6 +1,7 @@
1
1
  import { CompanionStore } from "../../../lib/orchestrator/companion-store";
2
2
  import { resolveFrameworkContext } from "../../../lib/orchestrator/framework-context";
3
3
  import { renderRepoListTable } from "../../repo-list-table";
4
+ import { writeJsonStdout } from "../../write-json-stdout";
4
5
 
5
6
  /**
6
7
  * @command mate companion list
@@ -23,7 +24,7 @@ export async function runCompanionListCommand(argv: string[] = []): Promise<void
23
24
  const forceJson = argv.includes("--json");
24
25
 
25
26
  if (forceJson || !process.stdout.isTTY) {
26
- console.log(JSON.stringify({ repositories }, null, 2));
27
+ await writeJsonStdout({ repositories });
27
28
  return;
28
29
  }
29
30
 
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import { FRAMEWORK_NAME } from "../../../framework";
4
4
  import { preparePrebuiltWorkspace } from "../../../lib/prebuilt-workspace-prepare";
5
5
 
6
- export const companionPrepareCommandDeps = {
6
+ const companionPrepareCommandDeps = {
7
7
  preparePrebuiltWorkspace,
8
8
  };
9
9
 
@@ -13,7 +13,7 @@ interface ParsedPrepareArgs {
13
13
  error?: string;
14
14
  }
15
15
 
16
- export function parsePrepareArgs(argv: string[]): ParsedPrepareArgs {
16
+ function parsePrepareArgs(argv: string[]): ParsedPrepareArgs {
17
17
  let bundle: string | undefined;
18
18
  const positional: string[] = [];
19
19
 
@@ -10,6 +10,7 @@ import { resolveRootContext, type RootContext } from "../../lib/orchestrator/roo
10
10
  import type { CapabilityConfig, FrameworkConfig, HubMember } from "../../lib/orchestrator/types";
11
11
  import { getRequiredPluginDrift } from "../../tools/setup/policy";
12
12
  import { resolveCommandOnPath } from "../../tools/setup/utils";
13
+ import { writeJsonStdout } from "../write-json-stdout";
13
14
  import { printSection, renderKeyValueTable, renderTable } from "./status";
14
15
 
15
16
  interface DoctorDeps {
@@ -354,7 +355,7 @@ function renderHumanReport(report: DoctorReport): void {
354
355
  export async function runDoctorCommand(argv: string[] = [], deps: DoctorDeps = {}): Promise<void> {
355
356
  const report = await collectDoctorReport(deps);
356
357
  if (argv.includes("--json")) {
357
- console.log(JSON.stringify(report, null, 2));
358
+ await writeJsonStdout(report);
358
359
  return;
359
360
  }
360
361
  renderHumanReport(report);
@@ -3,13 +3,12 @@ import { makeLaunchCommand } from "./shared";
3
3
  /**
4
4
  * @command mate claude [-- ...agentArgs]
5
5
  * @description Launches Claude Code for the active working repository, or the
6
- * Companion Repository with `-- --companion`. TTY launches ask for confirmation;
7
- * non-interactive launches proceed without it.
6
+ * Companion Repository with `-- --companion`, without an interactive confirmation.
8
7
  * @flags
9
8
  * - `-- <args>` — arguments forwarded to the launched `claude` process.
10
9
  * - `-- --no-git` — skip companion Git synchronization for this launch.
11
10
  * - `-- --companion` — launch against the Companion Repository without a Working Repository.
12
- * - `-- --yes` — skip the TTY confirmation.
11
+ * - `-- --yes` — accepted as a Mate compatibility no-op; launches are always prompt-free.
13
12
  * @remarks When invoked via `directPassthrough` (the top-level `mate claude`
14
13
  * alias), all args before `--` are treated as agent args rather than being
15
14
  * parsed as launch options.
@@ -3,13 +3,12 @@ import { makeLaunchCommand } from "./shared";
3
3
  /**
4
4
  * @command mate opencode [-- ...agentArgs]
5
5
  * @description Launches OpenCode for the active working repository, or the
6
- * Companion Repository with `-- --companion`. TTY launches ask for confirmation;
7
- * non-interactive launches proceed without it.
6
+ * Companion Repository with `-- --companion`, without an interactive confirmation.
8
7
  * @flags
9
8
  * - `-- <args>` — arguments forwarded to the launched `opencode` process.
10
9
  * - `-- --no-git` — skip companion Git synchronization for this launch.
11
10
  * - `-- --companion` — launch against the Companion Repository without a Working Repository.
12
- * - `-- --yes` — skip the TTY confirmation.
11
+ * - `-- --yes` — accepted as a Mate compatibility no-op; launches are always prompt-free.
13
12
  * @remarks When invoked via `directPassthrough` (the top-level `mate
14
13
  * opencode` alias), all args before `--` are treated as agent args rather
15
14
  * than being parsed as launch options.
@@ -9,8 +9,8 @@ import {
9
9
  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
- import { confirm } from "../../confirm";
13
12
  import type { LaunchTarget } from "../../launch-selector";
13
+ import { writeJsonStdout } from "../../write-json-stdout";
14
14
 
15
15
  const STEP_LABELS = {
16
16
  sync: "Syncing mate",
@@ -22,7 +22,19 @@ export interface ParsedLaunchArgs {
22
22
  agentArgs: string[];
23
23
  skipGit?: boolean;
24
24
  scope?: LaunchScope;
25
- skipConfirmation?: boolean;
25
+ }
26
+
27
+ const MATE_LAUNCH_OPTIONS = new Set(["--no-git", "--companion", "--yes"]);
28
+
29
+ function parseMateLaunchArgs(agentArgs: string[]): ParsedLaunchArgs {
30
+ const skipGit = agentArgs.includes("--no-git");
31
+ const companion = agentArgs.includes("--companion");
32
+
33
+ return {
34
+ agentArgs: agentArgs.filter((arg) => !MATE_LAUNCH_OPTIONS.has(arg)),
35
+ ...(skipGit ? { skipGit: true } : {}),
36
+ ...(companion ? { scope: "companion" as const } : {}),
37
+ };
26
38
  }
27
39
 
28
40
  export { ensureUnambiguousCompanion, launchAmbiguityDeps };
@@ -31,14 +43,12 @@ export interface LaunchCommandDeps {
31
43
  createLauncher: () => Pick<FrameworkLauncher, "prepare">;
32
44
  createProgress: typeof createStartupProgress;
33
45
  runIndexCapCommand: typeof runIndexCapCommand;
34
- confirm: typeof confirm;
35
46
  }
36
47
 
37
48
  export const launchCommandDeps: LaunchCommandDeps = {
38
49
  createLauncher: () => new FrameworkLauncher(),
39
50
  createProgress: createStartupProgress,
40
51
  runIndexCapCommand,
41
- confirm,
42
52
  };
43
53
 
44
54
  export function parseDirectLaunchArgs(argv: string[]): ParsedLaunchArgs {
@@ -47,15 +57,10 @@ export function parseDirectLaunchArgs(argv: string[]): ParsedLaunchArgs {
47
57
 
48
58
  const beforeSeparator = argv.slice(0, separatorIndex);
49
59
  const afterSeparator = argv.slice(separatorIndex + 1);
50
- const skipGit = afterSeparator.includes("--no-git");
51
- const companion = afterSeparator.includes("--companion");
52
- const skipConfirmation = afterSeparator.includes("--yes");
53
- const reserved = new Set(["--no-git", "--companion", "--yes"]);
60
+ const parsed = parseMateLaunchArgs(afterSeparator);
54
61
  return {
55
- agentArgs: [...beforeSeparator, ...afterSeparator.filter((arg) => !reserved.has(arg))],
56
- ...(skipGit ? { skipGit: true } : {}),
57
- ...(companion ? { scope: "companion" as const } : {}),
58
- ...(skipConfirmation ? { skipConfirmation: true } : {}),
62
+ ...parsed,
63
+ agentArgs: [...beforeSeparator, ...parsed.agentArgs],
59
64
  };
60
65
  }
61
66
 
@@ -70,23 +75,13 @@ export function parseLaunchArgs(argv: string[]): ParsedLaunchArgs | null {
70
75
  return null;
71
76
  }
72
77
 
73
- const skipGit = agentArgs.includes("--no-git");
74
- const companion = agentArgs.includes("--companion");
75
- const skipConfirmation = agentArgs.includes("--yes");
76
- const reserved = new Set(["--no-git", "--companion", "--yes"]);
77
- return {
78
- agentArgs: agentArgs.filter((arg) => !reserved.has(arg)),
79
- ...(skipGit ? { skipGit: true } : {}),
80
- ...(companion ? { scope: "companion" as const } : {}),
81
- ...(skipConfirmation ? { skipConfirmation: true } : {}),
82
- };
78
+ return parseMateLaunchArgs(agentArgs);
83
79
  }
84
80
 
85
81
  /**
86
82
  * @description Shared launch execution used by `mate claude`, `mate opencode`,
87
83
  * and the direct `mate claude` / `mate opencode` commands: resolves the launch via {@link FrameworkLauncher.prepare},
88
- * confirms with the user in a TTY unless `--yes` was supplied, re-syncs capability indexes via
89
- * `runIndexCapCommand`, then executes, prints the JSON result, and adopts the
84
+ * re-syncs capability indexes via `runIndexCapCommand`, then executes, prints the JSON result, and adopts the
90
85
  * agent's outcome as the command's exit status (128 + signal for a signalled stop).
91
86
  * @remarks Exits non-zero with a targeted message for `ToolNotAllowedError`
92
87
  * (repo policy disallows this tool) and `RepositoryNotSelectedError` (no
@@ -96,7 +91,6 @@ export async function runLaunchToolCommand(
96
91
  tool: LaunchTarget,
97
92
  args: string[],
98
93
  options: {
99
- skipConfirmation?: boolean;
100
94
  skipGit?: boolean;
101
95
  scope?: LaunchScope;
102
96
  } = {},
@@ -122,14 +116,6 @@ export async function runLaunchToolCommand(
122
116
  // Ink's line tracking, corrupting later re-renders (e.g. duplicated title).
123
117
  progress?.stop();
124
118
 
125
- if (!options.skipConfirmation && process.stdin.isTTY) {
126
- const ok = await launchCommandDeps.confirm("Continue? [y/N] ");
127
- if (!ok) {
128
- process.stderr.write("Aborted.\n");
129
- process.exit(1);
130
- }
131
- }
132
-
133
119
  if (options.scope !== "companion") {
134
120
  await launchCommandDeps.runIndexCapCommand([], {
135
121
  onStepStart: (step) => process.stdout.write(`${STEP_LABELS[step]}...\n`),
@@ -142,7 +128,7 @@ export async function runLaunchToolCommand(
142
128
  * The printed result stays useful, but the status is what a supervisor
143
129
  * reads: a failed agent must fail the launch.
144
130
  */
145
- console.log(JSON.stringify(result, null, 2));
131
+ await writeJsonStdout(result);
146
132
  if (result.exitCode !== 0) process.exitCode = result.exitCode;
147
133
  } catch (error) {
148
134
  progress?.failCurrent();
@@ -176,7 +162,7 @@ export async function runLaunchToolCommand(
176
162
  /**
177
163
  * Builds the `mate <tool>` command handler: parses launch args (direct
178
164
  * passthrough or flagged), guards against ambiguous companions, then launches
179
- * via {@link runLaunchToolCommand}; non-interactive launches skip confirmation.
165
+ * via {@link runLaunchToolCommand} without an interactive confirmation.
180
166
  */
181
167
  export function makeLaunchCommand(tool: LaunchTarget) {
182
168
  return async function runLaunchCommand(
@@ -197,7 +183,6 @@ export function makeLaunchCommand(tool: LaunchTarget) {
197
183
  }
198
184
 
199
185
  await runLaunchToolCommand(tool, parsed.agentArgs, {
200
- skipConfirmation: !process.stdin.isTTY || parsed.skipConfirmation,
201
186
  skipGit: parsed.skipGit,
202
187
  scope: parsed.scope,
203
188
  });
@@ -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
- /** Language every unknown or unsupported candidate resolves to. */
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
 
@@ -456,5 +456,3 @@ const VISUAL_SECTION_CSS = `
456
456
  }`;
457
457
 
458
458
  type ReportMetricsSection = Extract<ReportSection, { type: "metrics" }>;
459
-
460
- export { escapeHTML };
@@ -0,0 +1,203 @@
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
+ export type StudioRequirement = "none" | "token";
23
+
24
+ /**
25
+ * One server's Host, Origin, and token policy. `null` token means the server
26
+ * is unguarded and issues none.
27
+ */
28
+ export interface StudioAccess {
29
+ invocation: StudioInvocation;
30
+ guarded: boolean;
31
+ token: string | null;
32
+ cookieName(): string;
33
+ /** Accepted origin for this request, or `null` when its Host is not one Studio answers to. */
34
+ acceptedOrigin(request: Request): string | null;
35
+ originMatches(request: Request, acceptedOrigin: string): boolean;
36
+ hasToken(request: Request): boolean;
37
+ /** Whether a request of this kind needs the token under this invocation. */
38
+ requires(kind: "read" | "save" | "terminal"): boolean;
39
+ }
40
+
41
+ export function generateStudioToken(): string {
42
+ return randomBytes(32).toString("base64url");
43
+ }
44
+
45
+ export function validateStudioToken(value: string): string | null {
46
+ if (value.trim().length < MIN_TOKEN_LENGTH) {
47
+ return `the supplied token must be at least ${MIN_TOKEN_LENGTH} characters`;
48
+ }
49
+ return null;
50
+ }
51
+
52
+ /** `null` for anything other than an exact `http(s)://host[:port]` origin. */
53
+ export function normalizeOrigin(value: string): string | null {
54
+ let parsed: URL;
55
+ try {
56
+ parsed = new URL(value);
57
+ } catch {
58
+ return null;
59
+ }
60
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
61
+ if (parsed.username || parsed.password) return null;
62
+ if (parsed.pathname !== "/" || parsed.search || parsed.hash || value.endsWith("/")) return null;
63
+ return parsed.origin;
64
+ }
65
+
66
+ /** Lower-cased `host[:port]` with the HTTP default port dropped, or `null` when unparsable. */
67
+ export function normalizeAuthority(value: string): string | null {
68
+ const trimmed = value.trim();
69
+ if (!trimmed || /[\s/?#@\\]/.test(trimmed)) return null;
70
+ try {
71
+ const parsed = new URL(`http://${trimmed}`);
72
+ return parsed.host;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ function bracketed(hostname: string): string {
79
+ return hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname;
80
+ }
81
+
82
+ function digest(value: string): Buffer {
83
+ return createHash("sha256").update(value).digest();
84
+ }
85
+
86
+ /** Hashing first keeps the comparison constant-time for inputs of any length. */
87
+ export function tokensEqual(candidate: string, expected: string): boolean {
88
+ return timingSafeEqual(digest(candidate), digest(expected));
89
+ }
90
+
91
+ function readCookie(request: Request, name: string): string | null {
92
+ const header = request.headers.get("cookie");
93
+ if (!header) return null;
94
+ for (const part of header.split(";")) {
95
+ const index = part.indexOf("=");
96
+ if (index < 0) continue;
97
+ if (part.slice(0, index).trim() === name) return part.slice(index + 1).trim();
98
+ }
99
+ return null;
100
+ }
101
+
102
+ export function createStudioAccess(options: StudioAccessOptions): StudioAccess {
103
+ const guarded = options.invocation === "serve" || options.terminal;
104
+ const token = guarded ? (options.token ?? generateStudioToken()) : null;
105
+ const publicOrigin = options.publicOrigin ? normalizeOrigin(options.publicOrigin) : null;
106
+ const publicAuthority = publicOrigin ? new URL(publicOrigin).host : null;
107
+ const configured = new Set<string>();
108
+ for (const host of options.allowedHosts ?? []) {
109
+ const authority = normalizeAuthority(host);
110
+ if (authority) configured.add(authority);
111
+ }
112
+ if (publicAuthority) configured.add(publicAuthority);
113
+
114
+ const accepted = (): Set<string> => {
115
+ const port = options.port();
116
+ const hosts = new Set(configured);
117
+ for (const hostname of [...LOOPBACK_HOSTNAMES, bracketed(options.hostname)]) {
118
+ const authority = normalizeAuthority(`${hostname}:${port}`);
119
+ if (authority) hosts.add(authority);
120
+ }
121
+ return hosts;
122
+ };
123
+
124
+ return {
125
+ invocation: options.invocation,
126
+ guarded,
127
+ token,
128
+ cookieName: () => `mate_studio_${options.port()}`,
129
+ acceptedOrigin(request) {
130
+ const raw = request.headers.get("host") ?? new URL(request.url).host;
131
+ const authority = normalizeAuthority(raw);
132
+ if (!authority || !accepted().has(authority)) return null;
133
+ if (publicOrigin && authority === publicAuthority) return publicOrigin;
134
+ return `http://${authority}`;
135
+ },
136
+ originMatches(request, acceptedOrigin) {
137
+ const origin = request.headers.get("origin");
138
+ if (!origin || origin === "null") return false;
139
+ return normalizeOrigin(origin) === acceptedOrigin;
140
+ },
141
+ hasToken(request) {
142
+ if (!token) return false;
143
+ const value = readCookie(request, `mate_studio_${options.port()}`);
144
+ return value !== null && tokensEqual(value, token);
145
+ },
146
+ requires(kind) {
147
+ if (!guarded) return false;
148
+ if (options.invocation === "serve") return true;
149
+ return kind !== "read";
150
+ },
151
+ };
152
+ }
153
+
154
+ const EXCHANGE_HEADERS = {
155
+ "cache-control": "no-store",
156
+ "referrer-policy": "no-referrer",
157
+ };
158
+
159
+ /**
160
+ * Trades a `?token=` navigation for the cookie and a relative redirect, so the
161
+ * browser never leaves the Host it asked for. `null` when the request carries
162
+ * no token parameter.
163
+ */
164
+ export function exchangeStudioToken(
165
+ request: Request,
166
+ access: StudioAccess,
167
+ acceptedOrigin: string,
168
+ ): Response | null {
169
+ const url = new URL(request.url);
170
+ if (!url.searchParams.has("token")) return null;
171
+ if (request.method !== "GET" || !access.token) {
172
+ return new Response("the access token is exchanged only by opening its address", {
173
+ status: 400,
174
+ headers: EXCHANGE_HEADERS,
175
+ });
176
+ }
177
+ const candidate = url.searchParams.get("token") ?? "";
178
+ if (!tokensEqual(candidate, access.token)) {
179
+ return new Response("the access token does not match; open the address Studio printed", {
180
+ status: 403,
181
+ headers: EXCHANGE_HEADERS,
182
+ });
183
+ }
184
+ url.searchParams.delete("token");
185
+ const secure = acceptedOrigin.startsWith("https:") ? "; Secure" : "";
186
+ return new Response(null, {
187
+ status: 303,
188
+ headers: {
189
+ ...EXCHANGE_HEADERS,
190
+ location: `${url.pathname}${url.search}`,
191
+ "set-cookie": `${access.cookieName()}=${access.token}; Path=/; HttpOnly; SameSite=Strict${secure}`,
192
+ },
193
+ });
194
+ }
195
+
196
+ /** Address the operator opens: loopback for wildcard or loopback binds, carrying the token only when it was generated. */
197
+ export function studioAddress(hostname: string, port: number, token: string | null): string {
198
+ const wildcard = hostname === "0.0.0.0" || hostname === "::" || hostname === "[::]";
199
+ const loopback = ["127.0.0.1", "localhost", "::1", "[::1]"].includes(hostname);
200
+ const host = wildcard || loopback ? "localhost" : bracketed(hostname);
201
+ const base = `http://${host}:${port}`;
202
+ return token ? `${base}/?token=${token}` : base;
203
+ }