@jam-mcp/server 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +65 -0
- package/dist/adapters/cache/noop-cache.d.ts +7 -0
- package/dist/adapters/cache/noop-cache.js +12 -0
- package/dist/adapters/credentials/composite.d.ts +30 -0
- package/dist/adapters/credentials/composite.js +79 -0
- package/dist/adapters/credentials/process-env.d.ts +13 -0
- package/dist/adapters/credentials/process-env.js +17 -0
- package/dist/adapters/credentials/secret-store.d.ts +69 -0
- package/dist/adapters/credentials/secret-store.js +259 -0
- package/dist/adapters/credentials/windows-user-env.d.ts +20 -0
- package/dist/adapters/credentials/windows-user-env.js +51 -0
- package/dist/adapters/jira-cloud/adf-to-text.d.ts +12 -0
- package/dist/adapters/jira-cloud/adf-to-text.js +151 -0
- package/dist/adapters/jira-cloud/jira-client.d.ts +32 -0
- package/dist/adapters/jira-cloud/jira-client.js +137 -0
- package/dist/adapters/jira-cloud/jira-read.adapter.d.ts +13 -0
- package/dist/adapters/jira-cloud/jira-read.adapter.js +97 -0
- package/dist/adapters/jira-cloud/jira-write.adapter.d.ts +12 -0
- package/dist/adapters/jira-cloud/jira-write.adapter.js +17 -0
- package/dist/adapters/jira-cloud/mapper.d.ts +34 -0
- package/dist/adapters/jira-cloud/mapper.js +143 -0
- package/dist/adapters/telemetry/console-telemetry.d.ts +13 -0
- package/dist/adapters/telemetry/console-telemetry.js +31 -0
- package/dist/application/get-full-issue-context.d.ts +18 -0
- package/dist/application/get-full-issue-context.js +120 -0
- package/dist/application/get-issue-context.d.ts +19 -0
- package/dist/application/get-issue-context.js +79 -0
- package/dist/application/search-issues.d.ts +22 -0
- package/dist/application/search-issues.js +93 -0
- package/dist/bootstrap/boot-health-gate.d.ts +25 -0
- package/dist/bootstrap/boot-health-gate.js +137 -0
- package/dist/bootstrap/bootstrap-orchestrator.d.ts +15 -0
- package/dist/bootstrap/bootstrap-orchestrator.js +14 -0
- package/dist/bootstrap/host-mcp.d.ts +54 -0
- package/dist/bootstrap/host-mcp.js +98 -0
- package/dist/bootstrap/jira-projects.d.ts +16 -0
- package/dist/bootstrap/jira-projects.js +24 -0
- package/dist/bootstrap/mcp-config-merger.d.ts +58 -0
- package/dist/bootstrap/mcp-config-merger.js +119 -0
- package/dist/bootstrap/migration-target.d.ts +60 -0
- package/dist/bootstrap/migration-target.js +80 -0
- package/dist/bootstrap/project-bindings.d.ts +48 -0
- package/dist/bootstrap/project-bindings.js +112 -0
- package/dist/bootstrap/project-config-bootstrapper.d.ts +37 -0
- package/dist/bootstrap/project-config-bootstrapper.js +69 -0
- package/dist/bootstrap/project-config-resolver.d.ts +53 -0
- package/dist/bootstrap/project-config-resolver.js +59 -0
- package/dist/bootstrap/project-root-resolver.d.ts +22 -0
- package/dist/bootstrap/project-root-resolver.js +43 -0
- package/dist/bootstrap/setup-apply.d.ts +31 -0
- package/dist/bootstrap/setup-apply.js +61 -0
- package/dist/bootstrap/setup-plan.d.ts +102 -0
- package/dist/bootstrap/setup-plan.js +224 -0
- package/dist/bootstrap/setup-state.d.ts +73 -0
- package/dist/bootstrap/setup-state.js +89 -0
- package/dist/bootstrap/workspace-identity.d.ts +52 -0
- package/dist/bootstrap/workspace-identity.js +114 -0
- package/dist/cli/agent-api.d.ts +63 -0
- package/dist/cli/agent-api.js +149 -0
- package/dist/cli/auth.d.ts +39 -0
- package/dist/cli/auth.js +201 -0
- package/dist/cli/doctor.d.ts +7 -0
- package/dist/cli/doctor.js +31 -0
- package/dist/cli/runtime.d.ts +16 -0
- package/dist/cli/runtime.js +97 -0
- package/dist/cli/serve.d.ts +7 -0
- package/dist/cli/serve.js +23 -0
- package/dist/cli/setup-wizard.d.ts +37 -0
- package/dist/cli/setup-wizard.js +338 -0
- package/dist/cli/setup.d.ts +26 -0
- package/dist/cli/setup.js +214 -0
- package/dist/cli/ui.d.ts +118 -0
- package/dist/cli/ui.js +338 -0
- package/dist/cli-entry.d.ts +7 -0
- package/dist/cli-entry.js +124 -0
- package/dist/config/load-config.d.ts +18 -0
- package/dist/config/load-config.js +48 -0
- package/dist/config/schema.d.ts +36 -0
- package/dist/config/schema.js +74 -0
- package/dist/deps.d.ts +50 -0
- package/dist/deps.js +39 -0
- package/dist/domain/completeness.d.ts +63 -0
- package/dist/domain/completeness.js +20 -0
- package/dist/domain/context.d.ts +38 -0
- package/dist/domain/context.js +1 -0
- package/dist/domain/errors.d.ts +27 -0
- package/dist/domain/errors.js +49 -0
- package/dist/domain/issue.d.ts +35 -0
- package/dist/domain/issue.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +17 -0
- package/dist/mcp/create-server.d.ts +9 -0
- package/dist/mcp/create-server.js +32 -0
- package/dist/mcp/tool-result.d.ts +10 -0
- package/dist/mcp/tool-result.js +33 -0
- package/dist/mcp/tools/jira-context.tool.d.ts +3 -0
- package/dist/mcp/tools/jira-context.tool.js +25 -0
- package/dist/mcp/tools/jira-full.tool.d.ts +3 -0
- package/dist/mcp/tools/jira-full.tool.js +27 -0
- package/dist/mcp/tools/jira-search.tool.d.ts +3 -0
- package/dist/mcp/tools/jira-search.tool.js +31 -0
- package/dist/policy/completeness-policy.d.ts +19 -0
- package/dist/policy/completeness-policy.js +34 -0
- package/dist/policy/consistency-policy.d.ts +13 -0
- package/dist/policy/consistency-policy.js +6 -0
- package/dist/policy/field-policy.d.ts +11 -0
- package/dist/policy/field-policy.js +26 -0
- package/dist/policy/output-budget-policy.d.ts +23 -0
- package/dist/policy/output-budget-policy.js +96 -0
- package/dist/policy/pagination-policy.d.ts +15 -0
- package/dist/policy/pagination-policy.js +13 -0
- package/dist/ports/cache.port.d.ts +5 -0
- package/dist/ports/cache.port.js +1 -0
- package/dist/ports/credentials.port.d.ts +27 -0
- package/dist/ports/credentials.port.js +1 -0
- package/dist/ports/jira-read.port.d.ts +73 -0
- package/dist/ports/jira-read.port.js +1 -0
- package/dist/ports/jira-write.port.d.ts +12 -0
- package/dist/ports/jira-write.port.js +1 -0
- package/dist/ports/telemetry.port.d.ts +13 -0
- package/dist/ports/telemetry.port.js +1 -0
- package/package.json +69 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { type RuntimeMode } from "@jam-mcp/launcher";
|
|
2
|
+
import type { CredentialPort, CredentialSource } from "../ports/credentials.port.js";
|
|
3
|
+
import { type HostRunner, type HostState } from "./host-mcp.js";
|
|
4
|
+
import { type McpInspection } from "./mcp-config-merger.js";
|
|
5
|
+
import { type ProjectBinding } from "./project-bindings.js";
|
|
6
|
+
import { type GitRemoteFn } from "./workspace-identity.js";
|
|
7
|
+
export type RuntimeState = {
|
|
8
|
+
configured: boolean;
|
|
9
|
+
mode?: RuntimeMode;
|
|
10
|
+
source?: string;
|
|
11
|
+
version?: string;
|
|
12
|
+
/** Set when a runtime is configured but cannot currently be resolved. */
|
|
13
|
+
error?: string;
|
|
14
|
+
};
|
|
15
|
+
export type CredentialState = {
|
|
16
|
+
present: boolean;
|
|
17
|
+
source: CredentialSource;
|
|
18
|
+
baseUrl?: string;
|
|
19
|
+
email?: string;
|
|
20
|
+
};
|
|
21
|
+
export type ProjectState = {
|
|
22
|
+
root: string;
|
|
23
|
+
hasConfig: boolean;
|
|
24
|
+
configPath?: string;
|
|
25
|
+
key?: string;
|
|
26
|
+
/** Set when a config file exists but could not be parsed. */
|
|
27
|
+
error?: string;
|
|
28
|
+
/** What this user has bound this workspace to, if anything. */
|
|
29
|
+
binding?: ProjectBinding;
|
|
30
|
+
};
|
|
31
|
+
export type SetupState = {
|
|
32
|
+
cwd: string;
|
|
33
|
+
/** Stable id for this workspace - what a personal binding is recorded against. */
|
|
34
|
+
workspaceId: string;
|
|
35
|
+
/**
|
|
36
|
+
* True when `~/.jam/projects.yaml` exists but could not be read. Recorded
|
|
37
|
+
* rather than thrown so planning can refuse to rewrite it, instead of
|
|
38
|
+
* discovering the problem halfway through applying.
|
|
39
|
+
*/
|
|
40
|
+
bindingsUnreadable: boolean;
|
|
41
|
+
runtime: RuntimeState;
|
|
42
|
+
credentials: CredentialState;
|
|
43
|
+
project: ProjectState;
|
|
44
|
+
mcp: McpInspection & {
|
|
45
|
+
jamEntryIsLegacy: boolean;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* The coding agents on this machine and whether each already knows about
|
|
49
|
+
* jam. Empty unless probing was asked for: it costs a process launch per
|
|
50
|
+
* host, which `jam doctor` has no reason to pay.
|
|
51
|
+
*/
|
|
52
|
+
hosts: HostState[];
|
|
53
|
+
};
|
|
54
|
+
export type DetectOptions = {
|
|
55
|
+
cwd?: string;
|
|
56
|
+
/** Injected by tests to isolate ~/.jam. */
|
|
57
|
+
home?: string;
|
|
58
|
+
credentials?: CredentialPort;
|
|
59
|
+
/** Injected by tests so identity never depends on the checkout under test. */
|
|
60
|
+
git?: GitRemoteFn;
|
|
61
|
+
/** Probe the host CLIs. Only the setup paths need this. */
|
|
62
|
+
probeHosts?: boolean;
|
|
63
|
+
/** Injected by tests so no test ever reaches a real host CLI. */
|
|
64
|
+
runHost?: HostRunner;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Snapshot everything setup needs to decide, without changing any of it.
|
|
68
|
+
*
|
|
69
|
+
* Strictly read-only: no file is created, and no config is bootstrapped. That
|
|
70
|
+
* separation is what makes `setup plan` safe to run speculatively, and what
|
|
71
|
+
* lets an agent inspect a machine before proposing anything.
|
|
72
|
+
*/
|
|
73
|
+
export declare function detectSetupState(options?: DetectOptions): SetupState;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { readRuntimeConfig, resolveRuntime, } from "@jam-mcp/launcher";
|
|
2
|
+
import { CompositeCredentialProvider } from "../adapters/credentials/composite.js";
|
|
3
|
+
import { loadConfig } from "../config/load-config.js";
|
|
4
|
+
import { detectHosts } from "./host-mcp.js";
|
|
5
|
+
import { inspectMcpConfig, isLegacyJamEntry } from "./mcp-config-merger.js";
|
|
6
|
+
import { inspectProjectBindings } from "./project-bindings.js";
|
|
7
|
+
import { resolveProjectRoot } from "./project-root-resolver.js";
|
|
8
|
+
import { workspaceIdentity } from "./workspace-identity.js";
|
|
9
|
+
/**
|
|
10
|
+
* Snapshot everything setup needs to decide, without changing any of it.
|
|
11
|
+
*
|
|
12
|
+
* Strictly read-only: no file is created, and no config is bootstrapped. That
|
|
13
|
+
* separation is what makes `setup plan` safe to run speculatively, and what
|
|
14
|
+
* lets an agent inspect a machine before proposing anything.
|
|
15
|
+
*/
|
|
16
|
+
export function detectSetupState(options = {}) {
|
|
17
|
+
const cwd = options.cwd ?? process.cwd();
|
|
18
|
+
const located = resolveProjectRoot(cwd);
|
|
19
|
+
const workspaceId = workspaceIdentity(located.root, {
|
|
20
|
+
...(located.gitRoot ? { gitRoot: located.gitRoot } : {}),
|
|
21
|
+
...(options.git ? { git: options.git } : {}),
|
|
22
|
+
});
|
|
23
|
+
const bindings = inspectProjectBindings(options.home);
|
|
24
|
+
const binding = bindings.bindings.find((b) => b.workspace === workspaceId);
|
|
25
|
+
return {
|
|
26
|
+
cwd,
|
|
27
|
+
workspaceId,
|
|
28
|
+
bindingsUnreadable: bindings.status === "unreadable",
|
|
29
|
+
runtime: detectRuntime(options.home),
|
|
30
|
+
credentials: detectCredentials(options.credentials ?? new CompositeCredentialProvider()),
|
|
31
|
+
project: { ...detectProject(located), ...(binding ? { binding } : {}) },
|
|
32
|
+
mcp: detectMcp(located.root),
|
|
33
|
+
hosts: options.probeHosts ? detectHosts(options.runHost) : [],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function detectRuntime(home) {
|
|
37
|
+
const config = readRuntimeConfig(home);
|
|
38
|
+
if (!config)
|
|
39
|
+
return { configured: false };
|
|
40
|
+
const state = { configured: true, mode: config.runtime.mode };
|
|
41
|
+
if (config.runtime.mode === "development")
|
|
42
|
+
state.source = config.runtime.source;
|
|
43
|
+
try {
|
|
44
|
+
state.version = resolveRuntime(config).version;
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
state.error = err instanceof Error ? err.message : String(err);
|
|
48
|
+
}
|
|
49
|
+
return state;
|
|
50
|
+
}
|
|
51
|
+
function detectCredentials(credentials) {
|
|
52
|
+
const described = credentials.describe();
|
|
53
|
+
const state = {
|
|
54
|
+
present: Boolean(described.baseUrl && described.email && described.hasToken),
|
|
55
|
+
source: described.source,
|
|
56
|
+
};
|
|
57
|
+
// Presence and origin only - the token value never enters this snapshot.
|
|
58
|
+
if (described.baseUrl)
|
|
59
|
+
state.baseUrl = described.baseUrl;
|
|
60
|
+
if (described.email)
|
|
61
|
+
state.email = described.email;
|
|
62
|
+
return state;
|
|
63
|
+
}
|
|
64
|
+
function detectProject(located) {
|
|
65
|
+
const { root, hasConfig } = located;
|
|
66
|
+
if (!hasConfig)
|
|
67
|
+
return { root, hasConfig: false };
|
|
68
|
+
try {
|
|
69
|
+
const loaded = loadConfig(root);
|
|
70
|
+
const state = { root, hasConfig: true };
|
|
71
|
+
if (loaded.path)
|
|
72
|
+
state.configPath = loaded.path;
|
|
73
|
+
if (loaded.config.project.key)
|
|
74
|
+
state.key = loaded.config.project.key;
|
|
75
|
+
return state;
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
return {
|
|
79
|
+
root,
|
|
80
|
+
hasConfig: true,
|
|
81
|
+
error: err instanceof Error ? err.message : String(err),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** Inspection plus whether the existing jam entry predates launcher-based wiring. */
|
|
86
|
+
function detectMcp(root) {
|
|
87
|
+
const inspection = inspectMcpConfig(root);
|
|
88
|
+
return { ...inspection, jamEntryIsLegacy: isLegacyJamEntry(inspection.jamEntry) };
|
|
89
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads a repository's `origin` remote. Injected by tests so no test depends
|
|
3
|
+
* on the checkout it happens to run in.
|
|
4
|
+
*/
|
|
5
|
+
export type GitRemoteFn = (root: string) => string | undefined;
|
|
6
|
+
/**
|
|
7
|
+
* Ask git for `origin`, and treat every failure as "no remote".
|
|
8
|
+
*
|
|
9
|
+
* git missing, not a repository, no `origin`, a stalled filesystem - all of
|
|
10
|
+
* them mean the same thing to a caller, and none of them is worth an error or
|
|
11
|
+
* a log line. `shell: false` unlike the npm probe in `migration-target.ts`:
|
|
12
|
+
* git is a real executable, so routing it through a shell would only add
|
|
13
|
+
* quoting hazards around the user's path.
|
|
14
|
+
*/
|
|
15
|
+
export declare const readGitRemote: GitRemoteFn;
|
|
16
|
+
/**
|
|
17
|
+
* One repository, one identity, whichever URL form was cloned.
|
|
18
|
+
*
|
|
19
|
+
* `git@host:org/repo.git` and `https://host/org/repo` are the same repository
|
|
20
|
+
* and must produce the same string, or a binding made from one clone would be
|
|
21
|
+
* invisible from another.
|
|
22
|
+
*
|
|
23
|
+
* Two rules are load-bearing rather than cosmetic:
|
|
24
|
+
*
|
|
25
|
+
* - **Any userinfo is dropped.** A remote can carry `user:token@`, and a token
|
|
26
|
+
* must never reach `~/.jam/projects.yaml`, a log line or telemetry.
|
|
27
|
+
* - **An explicit non-default port is kept.** `git.example.com:8443/org/repo`
|
|
28
|
+
* and `git.example.com/org/repo` may be different services - self-hosted
|
|
29
|
+
* GitLab makes that ordinary - so only a port that is the scheme's own
|
|
30
|
+
* default is dropped.
|
|
31
|
+
*/
|
|
32
|
+
export declare function canonicalRemote(url: string): string | undefined;
|
|
33
|
+
export type WorkspaceIdentityOptions = {
|
|
34
|
+
/** The repository root, when `root` is inside one. */
|
|
35
|
+
gitRoot?: string;
|
|
36
|
+
/** Injected by tests. */
|
|
37
|
+
git?: GitRemoteFn;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* A stable id for "this workspace", used to bind it to a Jira project.
|
|
41
|
+
*
|
|
42
|
+
* Prefers the canonical remote plus the project's offset inside the
|
|
43
|
+
* repository, so two clones of one repo share a binding and two packages in a
|
|
44
|
+
* monorepo do not. Falls back to the normalised absolute path when there is no
|
|
45
|
+
* remote to ask about - which is honest about its ceiling: a folder that moves
|
|
46
|
+
* loses its binding.
|
|
47
|
+
*
|
|
48
|
+
* No realpath: the preset matcher already compares non-realpathed absolute
|
|
49
|
+
* paths, and resolving symlinks only here would create a case where a preset
|
|
50
|
+
* matches and a binding does not.
|
|
51
|
+
*/
|
|
52
|
+
export declare function workspaceIdentity(root: string, options?: WorkspaceIdentityOptions): string;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { platform } from "node:os";
|
|
3
|
+
import { relative } from "node:path";
|
|
4
|
+
import { normalizePath } from "./project-config-bootstrapper.js";
|
|
5
|
+
/** Local file read, not a network call - a second is already generous. */
|
|
6
|
+
const GIT_TIMEOUT_MS = 2_000;
|
|
7
|
+
/**
|
|
8
|
+
* Ask git for `origin`, and treat every failure as "no remote".
|
|
9
|
+
*
|
|
10
|
+
* git missing, not a repository, no `origin`, a stalled filesystem - all of
|
|
11
|
+
* them mean the same thing to a caller, and none of them is worth an error or
|
|
12
|
+
* a log line. `shell: false` unlike the npm probe in `migration-target.ts`:
|
|
13
|
+
* git is a real executable, so routing it through a shell would only add
|
|
14
|
+
* quoting hazards around the user's path.
|
|
15
|
+
*/
|
|
16
|
+
export const readGitRemote = (root) => {
|
|
17
|
+
const result = spawnSync("git", ["config", "--get", "remote.origin.url"], {
|
|
18
|
+
cwd: root,
|
|
19
|
+
encoding: "utf8",
|
|
20
|
+
timeout: GIT_TIMEOUT_MS,
|
|
21
|
+
shell: false,
|
|
22
|
+
});
|
|
23
|
+
if (result.error || result.status !== 0)
|
|
24
|
+
return undefined;
|
|
25
|
+
const url = result.stdout?.trim();
|
|
26
|
+
return url ? url : undefined;
|
|
27
|
+
};
|
|
28
|
+
const DEFAULT_PORTS = {
|
|
29
|
+
http: "80",
|
|
30
|
+
https: "443",
|
|
31
|
+
ssh: "22",
|
|
32
|
+
git: "9418",
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* One repository, one identity, whichever URL form was cloned.
|
|
36
|
+
*
|
|
37
|
+
* `git@host:org/repo.git` and `https://host/org/repo` are the same repository
|
|
38
|
+
* and must produce the same string, or a binding made from one clone would be
|
|
39
|
+
* invisible from another.
|
|
40
|
+
*
|
|
41
|
+
* Two rules are load-bearing rather than cosmetic:
|
|
42
|
+
*
|
|
43
|
+
* - **Any userinfo is dropped.** A remote can carry `user:token@`, and a token
|
|
44
|
+
* must never reach `~/.jam/projects.yaml`, a log line or telemetry.
|
|
45
|
+
* - **An explicit non-default port is kept.** `git.example.com:8443/org/repo`
|
|
46
|
+
* and `git.example.com/org/repo` may be different services - self-hosted
|
|
47
|
+
* GitLab makes that ordinary - so only a port that is the scheme's own
|
|
48
|
+
* default is dropped.
|
|
49
|
+
*/
|
|
50
|
+
export function canonicalRemote(url) {
|
|
51
|
+
const trimmed = url.trim();
|
|
52
|
+
if (!trimmed)
|
|
53
|
+
return undefined;
|
|
54
|
+
if (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(trimmed)) {
|
|
55
|
+
let parsed;
|
|
56
|
+
try {
|
|
57
|
+
parsed = new URL(trimmed);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
const host = parsed.hostname.toLowerCase();
|
|
63
|
+
if (!host)
|
|
64
|
+
return undefined;
|
|
65
|
+
const scheme = parsed.protocol.replace(":", "").toLowerCase();
|
|
66
|
+
// WHATWG already drops http/https default ports; this covers ssh and git.
|
|
67
|
+
const port = parsed.port && parsed.port !== DEFAULT_PORTS[scheme] ? `:${parsed.port}` : "";
|
|
68
|
+
const path = cleanPath(parsed.pathname);
|
|
69
|
+
return path ? `${host}${port}/${path}` : undefined;
|
|
70
|
+
}
|
|
71
|
+
// scp-like: [user@]host:path. The text after the colon is a path, never a
|
|
72
|
+
// port - reading it as one would turn org/repo into a port number.
|
|
73
|
+
const scp = /^(?:[^@/]+@)?([^:/]+):(.+)$/.exec(trimmed);
|
|
74
|
+
if (scp) {
|
|
75
|
+
const host = scp[1].toLowerCase();
|
|
76
|
+
const path = cleanPath(scp[2]);
|
|
77
|
+
return path ? `${host}/${path}` : undefined;
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
function cleanPath(raw) {
|
|
82
|
+
return raw
|
|
83
|
+
.replace(/^\/+/, "")
|
|
84
|
+
.replace(/\/+$/, "")
|
|
85
|
+
.replace(/\.git$/i, "")
|
|
86
|
+
.toLowerCase();
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* A stable id for "this workspace", used to bind it to a Jira project.
|
|
90
|
+
*
|
|
91
|
+
* Prefers the canonical remote plus the project's offset inside the
|
|
92
|
+
* repository, so two clones of one repo share a binding and two packages in a
|
|
93
|
+
* monorepo do not. Falls back to the normalised absolute path when there is no
|
|
94
|
+
* remote to ask about - which is honest about its ceiling: a folder that moves
|
|
95
|
+
* loses its binding.
|
|
96
|
+
*
|
|
97
|
+
* No realpath: the preset matcher already compares non-realpathed absolute
|
|
98
|
+
* paths, and resolving symlinks only here would create a case where a preset
|
|
99
|
+
* matches and a binding does not.
|
|
100
|
+
*/
|
|
101
|
+
export function workspaceIdentity(root, options = {}) {
|
|
102
|
+
const git = options.git ?? readGitRemote;
|
|
103
|
+
const gitRoot = options.gitRoot;
|
|
104
|
+
const remote = gitRoot ? canonicalRemote(git(gitRoot) ?? "") : undefined;
|
|
105
|
+
if (remote) {
|
|
106
|
+
const offset = toPosix(relative(gitRoot, root));
|
|
107
|
+
return offset ? `git:${remote}#${offset}` : `git:${remote}`;
|
|
108
|
+
}
|
|
109
|
+
return `path:${toPosix(normalizePath(root))}`;
|
|
110
|
+
}
|
|
111
|
+
function toPosix(p) {
|
|
112
|
+
const forward = p.split("\\").join("/");
|
|
113
|
+
return platform() === "win32" ? forward.toLowerCase() : forward;
|
|
114
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { type MigrationTarget } from "../bootstrap/migration-target.js";
|
|
2
|
+
import type { CredentialPort } from "../ports/credentials.port.js";
|
|
3
|
+
import type { HostRunner } from "../bootstrap/host-mcp.js";
|
|
4
|
+
import type { GitRemoteFn } from "../bootstrap/workspace-identity.js";
|
|
5
|
+
/**
|
|
6
|
+
* The machine-readable half of setup.
|
|
7
|
+
*
|
|
8
|
+
* Everything here shares the detect/plan/apply core with the human wizard -
|
|
9
|
+
* only the interface differs. An agent gets structured status codes instead of
|
|
10
|
+
* prose so it never has to infer intent from an error message, and never has
|
|
11
|
+
* to assemble JAM's config files itself.
|
|
12
|
+
*
|
|
13
|
+
* Contract, enforced by tests:
|
|
14
|
+
* stdout - valid JSON and nothing else, no ANSI, no prompts
|
|
15
|
+
* stderr - diagnostics only
|
|
16
|
+
*/
|
|
17
|
+
export type AgentOptions = {
|
|
18
|
+
cwd?: string;
|
|
19
|
+
home?: string;
|
|
20
|
+
explicitKey?: string;
|
|
21
|
+
/** `--shared`: adopt JAM for the team, writing into the repository. */
|
|
22
|
+
shared?: boolean;
|
|
23
|
+
migrate?: boolean;
|
|
24
|
+
/** Injected by tests so a plan never depends on the machine's JIRA_* env. */
|
|
25
|
+
credentials?: CredentialPort;
|
|
26
|
+
/** Injected by tests so a plan never depends on the machine's JAM_PROJECT_KEY. */
|
|
27
|
+
env?: NodeJS.ProcessEnv;
|
|
28
|
+
/** Injected by tests so a plan never shells out to npm to verify a migration target. */
|
|
29
|
+
migrationTarget?: MigrationTarget;
|
|
30
|
+
/** Injected by tests so identity never depends on the checkout under test. */
|
|
31
|
+
git?: GitRemoteFn;
|
|
32
|
+
/** Injected by tests so no test ever registers JAM with a real host. */
|
|
33
|
+
runHost?: HostRunner;
|
|
34
|
+
};
|
|
35
|
+
export declare function emitJson(payload: unknown): void;
|
|
36
|
+
/**
|
|
37
|
+
* `jam setup plan --json` - what setup would do, having done nothing.
|
|
38
|
+
*/
|
|
39
|
+
export declare function setupPlanCommand(options?: AgentOptions): Promise<number>;
|
|
40
|
+
/**
|
|
41
|
+
* `jam setup apply --non-interactive --json` - execute a freshly computed plan.
|
|
42
|
+
*
|
|
43
|
+
* Applies whatever the plan safely can even when a human step remains, and
|
|
44
|
+
* says so via `changesApplied`. A missing credential should not leave a
|
|
45
|
+
* project half-wired.
|
|
46
|
+
*/
|
|
47
|
+
export declare function setupApplyCommand(options?: AgentOptions): Promise<number>;
|
|
48
|
+
/**
|
|
49
|
+
* `jam setup --agent` - one shot: detect, plan, apply what is safe, verify.
|
|
50
|
+
*
|
|
51
|
+
* Stops only where a person is genuinely required (choosing a Jira project,
|
|
52
|
+
* authenticating), and reports exactly how far it got.
|
|
53
|
+
*/
|
|
54
|
+
export declare function setupAgentCommand(options?: AgentOptions): Promise<number>;
|
|
55
|
+
/** `jam doctor --json`. */
|
|
56
|
+
export declare function doctorJsonCommand(options?: AgentOptions): Promise<number>;
|
|
57
|
+
/**
|
|
58
|
+
* `jam auth status --json` - presence and origin only.
|
|
59
|
+
*
|
|
60
|
+
* Never returns the credential itself. An agent needs to know whether
|
|
61
|
+
* authentication is configured, not what it is.
|
|
62
|
+
*/
|
|
63
|
+
export declare function authStatusCommand(options?: AgentOptions): number;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { runHealthGate } from "../bootstrap/boot-health-gate.js";
|
|
2
|
+
import { listVisibleProjects } from "../bootstrap/jira-projects.js";
|
|
3
|
+
import { computeSetupPlanWithPreflight, } from "../bootstrap/migration-target.js";
|
|
4
|
+
import { applySetupPlan } from "../bootstrap/setup-apply.js";
|
|
5
|
+
import { detectSetupState } from "../bootstrap/setup-state.js";
|
|
6
|
+
import { buildDeps } from "../deps.js";
|
|
7
|
+
import { toJamError } from "../domain/errors.js";
|
|
8
|
+
export function emitJson(payload) {
|
|
9
|
+
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
10
|
+
}
|
|
11
|
+
function detect(options) {
|
|
12
|
+
return detectSetupState({
|
|
13
|
+
...(options.cwd ? { cwd: options.cwd } : {}),
|
|
14
|
+
...(options.home ? { home: options.home } : {}),
|
|
15
|
+
...(options.credentials ? { credentials: options.credentials } : {}),
|
|
16
|
+
...(options.git ? { git: options.git } : {}),
|
|
17
|
+
probeHosts: !options.shared,
|
|
18
|
+
...(options.runHost ? { runHost: options.runHost } : {}),
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
function planFrom(state, options) {
|
|
22
|
+
return computeSetupPlanWithPreflight(state, {
|
|
23
|
+
...(options.shared ? { shared: options.shared } : {}),
|
|
24
|
+
...(options.explicitKey ? { explicitKey: options.explicitKey } : {}),
|
|
25
|
+
...(options.migrate ? { migrate: options.migrate } : {}),
|
|
26
|
+
...(options.env ? { env: options.env } : {}),
|
|
27
|
+
...(options.migrationTarget ? { migrationTarget: options.migrationTarget } : {}),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/** Enrich a selection-required plan with the projects the account can see. */
|
|
31
|
+
async function withProjects(plan, options) {
|
|
32
|
+
if (plan.code !== "JAM_PROJECT_SELECTION_REQUIRED")
|
|
33
|
+
return plan;
|
|
34
|
+
const { projects } = await listVisibleProjects(options.credentials);
|
|
35
|
+
return projects.length > 0 ? { ...plan, projects } : plan;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* `jam setup plan --json` - what setup would do, having done nothing.
|
|
39
|
+
*/
|
|
40
|
+
export async function setupPlanCommand(options = {}) {
|
|
41
|
+
const plan = await withProjects(planFrom(detect(options), options), options);
|
|
42
|
+
emitJson({ ...plan, changesApplied: false });
|
|
43
|
+
return plan.requiresUserAction ? 1 : 0;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* `jam setup apply --non-interactive --json` - execute a freshly computed plan.
|
|
47
|
+
*
|
|
48
|
+
* Applies whatever the plan safely can even when a human step remains, and
|
|
49
|
+
* says so via `changesApplied`. A missing credential should not leave a
|
|
50
|
+
* project half-wired.
|
|
51
|
+
*/
|
|
52
|
+
export async function setupApplyCommand(options = {}) {
|
|
53
|
+
const state = detect(options);
|
|
54
|
+
const plan = planFrom(state, options);
|
|
55
|
+
if (plan.code === "JAM_PROJECT_SELECTION_REQUIRED") {
|
|
56
|
+
emitJson({ ...(await withProjects(plan, options)), changesApplied: false });
|
|
57
|
+
return 1;
|
|
58
|
+
}
|
|
59
|
+
if (plan.code === "JAM_PROJECT_CONFIG_INVALID" ||
|
|
60
|
+
plan.code === "JAM_MCP_CONFIG_UNREADABLE" ||
|
|
61
|
+
plan.code === "JAM_BINDINGS_UNREADABLE") {
|
|
62
|
+
emitJson({ ...plan, changesApplied: false });
|
|
63
|
+
return 1;
|
|
64
|
+
}
|
|
65
|
+
const result = applySetupPlan(plan, {
|
|
66
|
+
...(options.home ? { home: options.home } : {}),
|
|
67
|
+
...(options.runHost ? { runHost: options.runHost } : {}),
|
|
68
|
+
});
|
|
69
|
+
emitJson({ ...plan, status: applyStatus(plan), changesApplied: result.changesApplied });
|
|
70
|
+
return plan.requiresUserAction ? 1 : 0;
|
|
71
|
+
}
|
|
72
|
+
function applyStatus(plan) {
|
|
73
|
+
return plan.requiresUserAction ? "user_action_required" : "already_configured";
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* `jam setup --agent` - one shot: detect, plan, apply what is safe, verify.
|
|
77
|
+
*
|
|
78
|
+
* Stops only where a person is genuinely required (choosing a Jira project,
|
|
79
|
+
* authenticating), and reports exactly how far it got.
|
|
80
|
+
*/
|
|
81
|
+
export async function setupAgentCommand(options = {}) {
|
|
82
|
+
const state = detect(options);
|
|
83
|
+
const plan = planFrom(state, options);
|
|
84
|
+
if (plan.code === "JAM_PROJECT_SELECTION_REQUIRED") {
|
|
85
|
+
emitJson({ ...(await withProjects(plan, options)), changesApplied: false });
|
|
86
|
+
return 1;
|
|
87
|
+
}
|
|
88
|
+
if (plan.code === "JAM_PROJECT_CONFIG_INVALID" ||
|
|
89
|
+
plan.code === "JAM_MCP_CONFIG_UNREADABLE" ||
|
|
90
|
+
plan.code === "JAM_BINDINGS_UNREADABLE") {
|
|
91
|
+
emitJson({ ...plan, changesApplied: false });
|
|
92
|
+
return 1;
|
|
93
|
+
}
|
|
94
|
+
const { changesApplied } = applySetupPlan(plan, {
|
|
95
|
+
...(options.home ? { home: options.home } : {}),
|
|
96
|
+
...(options.runHost ? { runHost: options.runHost } : {}),
|
|
97
|
+
});
|
|
98
|
+
if (plan.requiresUserAction) {
|
|
99
|
+
emitJson({ ...plan, changesApplied });
|
|
100
|
+
return 1;
|
|
101
|
+
}
|
|
102
|
+
const health = await gateResult(state.project.root);
|
|
103
|
+
emitJson({
|
|
104
|
+
status: health.passed ? "ready" : "verification_failed",
|
|
105
|
+
changesApplied,
|
|
106
|
+
project: plan.project,
|
|
107
|
+
checks: health.checks,
|
|
108
|
+
});
|
|
109
|
+
return health.passed ? 0 : 1;
|
|
110
|
+
}
|
|
111
|
+
/** `jam doctor --json`. */
|
|
112
|
+
export async function doctorJsonCommand(options = {}) {
|
|
113
|
+
const state = detect(options);
|
|
114
|
+
const health = await gateResult(state.project.root);
|
|
115
|
+
emitJson({
|
|
116
|
+
status: health.passed ? "ready" : "failed",
|
|
117
|
+
...(health.error ? { error: health.error } : {}),
|
|
118
|
+
project: { root: state.project.root, ...(state.project.key ? { key: state.project.key } : {}) },
|
|
119
|
+
checks: health.checks,
|
|
120
|
+
});
|
|
121
|
+
return health.passed ? 0 : 1;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* `jam auth status --json` - presence and origin only.
|
|
125
|
+
*
|
|
126
|
+
* Never returns the credential itself. An agent needs to know whether
|
|
127
|
+
* authentication is configured, not what it is.
|
|
128
|
+
*/
|
|
129
|
+
export function authStatusCommand(options = {}) {
|
|
130
|
+
const { credentials } = detect(options);
|
|
131
|
+
emitJson({
|
|
132
|
+
status: credentials.present ? "configured" : "not_configured",
|
|
133
|
+
...(credentials.present ? {} : { code: "JAM_AUTH_REQUIRED" }),
|
|
134
|
+
source: credentials.source,
|
|
135
|
+
...(credentials.email ? { email: credentials.email } : {}),
|
|
136
|
+
...(credentials.baseUrl ? { baseUrl: credentials.baseUrl } : {}),
|
|
137
|
+
});
|
|
138
|
+
return credentials.present ? 0 : 1;
|
|
139
|
+
}
|
|
140
|
+
async function gateResult(root) {
|
|
141
|
+
try {
|
|
142
|
+
const deps = await buildDeps({ cwd: root, keyFallback: "optional" });
|
|
143
|
+
const gate = await runHealthGate(deps, "full");
|
|
144
|
+
return { passed: gate.passed, checks: gate.checks };
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
return { passed: false, checks: [], error: toJamError(err).message };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type SecretStore, type StoredCredentials } from "../adapters/credentials/secret-store.js";
|
|
2
|
+
import type { CredentialPort } from "../ports/credentials.port.js";
|
|
3
|
+
import { Ui } from "./ui.js";
|
|
4
|
+
/**
|
|
5
|
+
* `jam auth login` / `jam auth logout`.
|
|
6
|
+
*
|
|
7
|
+
* Human-facing on purpose: these are the one step of setup an agent must not
|
|
8
|
+
* perform. An agent that asks a person for a token, or stores one, has done
|
|
9
|
+
* something wrong regardless of intent - so this path prompts, and the agent
|
|
10
|
+
* path stops at JAM_AUTH_REQUIRED and hands the user this command.
|
|
11
|
+
*
|
|
12
|
+
* `jam auth status` stays in the agent API: it is presence and origin only,
|
|
13
|
+
* and its JSON shape is a contract.
|
|
14
|
+
*/
|
|
15
|
+
export type AuthOptions = {
|
|
16
|
+
ui?: Ui;
|
|
17
|
+
/** Injected by tests so the suite never touches a real keychain. */
|
|
18
|
+
store?: SecretStore | undefined;
|
|
19
|
+
/** Reads back what is now in effect. A fresh port each call - see below. */
|
|
20
|
+
readBack?: () => CredentialPort;
|
|
21
|
+
/**
|
|
22
|
+
* Injected by tests. Real verification talks to Jira, which no unit test may
|
|
23
|
+
* do - and the guarantee under test (nothing is stored unless Jira accepted
|
|
24
|
+
* it) is about the ordering, not about the network.
|
|
25
|
+
*/
|
|
26
|
+
verify?: (values: StoredCredentials) => Promise<string | undefined>;
|
|
27
|
+
};
|
|
28
|
+
export declare function authLoginCommand(options?: AuthOptions): Promise<number>;
|
|
29
|
+
export declare function authLogoutCommand(options?: AuthOptions): number;
|
|
30
|
+
/**
|
|
31
|
+
* The origin of any URL from the user's Jira site, or undefined.
|
|
32
|
+
*
|
|
33
|
+
* Parsed rather than trimmed, because a pathname is the failure that actually
|
|
34
|
+
* happens: someone copies the project page URL, and JiraClient resolves each
|
|
35
|
+
* REST path *relative* to the base, so `/jira/software/c/projects/KEY/summary`
|
|
36
|
+
* gets the API path appended to it. Atlassian answers that with its HTML shell
|
|
37
|
+
* at 200, and the only symptom is "Jira returned a non-JSON response".
|
|
38
|
+
*/
|
|
39
|
+
export declare function toJiraOrigin(input: string): string | undefined;
|