@yagni-app/code 0.1.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.md +55 -0
- package/README.md +161 -0
- package/dist/branding.d.ts +25 -0
- package/dist/branding.js +27 -0
- package/dist/cli.d.ts +59 -0
- package/dist/cli.js +277 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.js +26 -0
- package/dist/credentials.d.ts +41 -0
- package/dist/credentials.js +74 -0
- package/dist/doctor.d.ts +88 -0
- package/dist/doctor.js +317 -0
- package/dist/launch.d.ts +68 -0
- package/dist/launch.js +106 -0
- package/dist/login.d.ts +45 -0
- package/dist/login.js +142 -0
- package/dist/logout.d.ts +14 -0
- package/dist/logout.js +34 -0
- package/dist/paths.d.ts +22 -0
- package/dist/paths.js +75 -0
- package/dist/piPackage.d.ts +33 -0
- package/dist/piPackage.js +71 -0
- package/dist/profiles.d.ts +80 -0
- package/dist/profiles.js +219 -0
- package/dist/refresh.d.ts +70 -0
- package/dist/refresh.js +117 -0
- package/package.json +46 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential storage for the yagni-code CLI: `~/.yagni-code/credentials.json`,
|
|
3
|
+
* written 0600 (owner read/write only) since it holds a live API token.
|
|
4
|
+
*/
|
|
5
|
+
export interface Credentials {
|
|
6
|
+
token: string;
|
|
7
|
+
baseUrl: string;
|
|
8
|
+
workspaceId: string;
|
|
9
|
+
expiresAt?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function _setYagniCodeHomeForTest(dir: string | null): void;
|
|
12
|
+
export declare function credentialsDir(): string;
|
|
13
|
+
export declare function credentialsPath(): string;
|
|
14
|
+
/**
|
|
15
|
+
* Isolated pi config/state dir for YAGNI Code: `~/.yagni-code/agent/<profile>`.
|
|
16
|
+
*
|
|
17
|
+
* The launcher pins pi's `PI_CODING_AGENT_DIR` here so YAGNI Code never reads
|
|
18
|
+
* the user's machine-wide `~/.pi/agent` — keeping its own providers (only
|
|
19
|
+
* `yagni`), stored auth, model scope (the backend catalog), and theme out of
|
|
20
|
+
* the agent. Everything routes through the YAGNI proxy; the user's local
|
|
21
|
+
* Together/OpenRouter keys and saved default model cannot bleed in or bypass it.
|
|
22
|
+
*
|
|
23
|
+
* Scoping the dir per profile keeps each environment's pi state (session
|
|
24
|
+
* history, theme, settings) separate, so pointing at staging never bleeds prod
|
|
25
|
+
* state. Called with no profile it returns the legacy un-scoped dir.
|
|
26
|
+
*/
|
|
27
|
+
export declare function agentDir(profileName?: string): string;
|
|
28
|
+
/**
|
|
29
|
+
* Hermetic shadow pi package dir: `~/.yagni-code/pi-package`.
|
|
30
|
+
*
|
|
31
|
+
* Holds a generated `package.json` (pi's, with `piConfig.name` set so the
|
|
32
|
+
* terminal title/process name read "YAGNI Code" instead of "pi"/"π") plus
|
|
33
|
+
* symlinks back to the real pi package's assets. The launcher pins pi's
|
|
34
|
+
* `PI_PACKAGE_DIR` here. Kept under the YAGNI-owned dir so it never collides
|
|
35
|
+
* with the user's machine-wide pi install.
|
|
36
|
+
*/
|
|
37
|
+
export declare function piPackageDir(): string;
|
|
38
|
+
export declare function writeCredentials(creds: Credentials, path?: string): Promise<void>;
|
|
39
|
+
export declare function readCredentials(path?: string): Promise<Credentials | null>;
|
|
40
|
+
export declare function deleteCredentials(path?: string): Promise<void>;
|
|
41
|
+
//# sourceMappingURL=credentials.d.ts.map
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential storage for the yagni-code CLI: `~/.yagni-code/credentials.json`,
|
|
3
|
+
* written 0600 (owner read/write only) since it holds a live API token.
|
|
4
|
+
*/
|
|
5
|
+
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
/**
|
|
9
|
+
* Test seam: when set, all path resolution roots here instead of
|
|
10
|
+
* `~/.yagni-code`, so tests can exercise profile/credential storage against a
|
|
11
|
+
* tmpdir without touching the real home dir. Mirrors the `_setTestPool` style
|
|
12
|
+
* used elsewhere in the repo (no public env var). Pass `null` to restore.
|
|
13
|
+
*/
|
|
14
|
+
let homeOverride = null;
|
|
15
|
+
export function _setYagniCodeHomeForTest(dir) {
|
|
16
|
+
homeOverride = dir;
|
|
17
|
+
}
|
|
18
|
+
export function credentialsDir() {
|
|
19
|
+
return homeOverride ?? join(homedir(), ".yagni-code");
|
|
20
|
+
}
|
|
21
|
+
export function credentialsPath() {
|
|
22
|
+
return join(credentialsDir(), "credentials.json");
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Isolated pi config/state dir for YAGNI Code: `~/.yagni-code/agent/<profile>`.
|
|
26
|
+
*
|
|
27
|
+
* The launcher pins pi's `PI_CODING_AGENT_DIR` here so YAGNI Code never reads
|
|
28
|
+
* the user's machine-wide `~/.pi/agent` — keeping its own providers (only
|
|
29
|
+
* `yagni`), stored auth, model scope (the backend catalog), and theme out of
|
|
30
|
+
* the agent. Everything routes through the YAGNI proxy; the user's local
|
|
31
|
+
* Together/OpenRouter keys and saved default model cannot bleed in or bypass it.
|
|
32
|
+
*
|
|
33
|
+
* Scoping the dir per profile keeps each environment's pi state (session
|
|
34
|
+
* history, theme, settings) separate, so pointing at staging never bleeds prod
|
|
35
|
+
* state. Called with no profile it returns the legacy un-scoped dir.
|
|
36
|
+
*/
|
|
37
|
+
export function agentDir(profileName) {
|
|
38
|
+
const base = join(credentialsDir(), "agent");
|
|
39
|
+
return profileName ? join(base, profileName) : base;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Hermetic shadow pi package dir: `~/.yagni-code/pi-package`.
|
|
43
|
+
*
|
|
44
|
+
* Holds a generated `package.json` (pi's, with `piConfig.name` set so the
|
|
45
|
+
* terminal title/process name read "YAGNI Code" instead of "pi"/"π") plus
|
|
46
|
+
* symlinks back to the real pi package's assets. The launcher pins pi's
|
|
47
|
+
* `PI_PACKAGE_DIR` here. Kept under the YAGNI-owned dir so it never collides
|
|
48
|
+
* with the user's machine-wide pi install.
|
|
49
|
+
*/
|
|
50
|
+
export function piPackageDir() {
|
|
51
|
+
return join(credentialsDir(), "pi-package");
|
|
52
|
+
}
|
|
53
|
+
export async function writeCredentials(creds, path = credentialsPath()) {
|
|
54
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
55
|
+
await writeFile(path, `${JSON.stringify(creds, null, 2)}\n`, { mode: 0o600 });
|
|
56
|
+
// writeFile honors mode only on create; chmod guarantees 0600 on overwrite too.
|
|
57
|
+
await chmod(path, 0o600);
|
|
58
|
+
}
|
|
59
|
+
export async function readCredentials(path = credentialsPath()) {
|
|
60
|
+
try {
|
|
61
|
+
const raw = await readFile(path, "utf8");
|
|
62
|
+
const parsed = JSON.parse(raw);
|
|
63
|
+
if (!parsed || typeof parsed.token !== "string")
|
|
64
|
+
return null;
|
|
65
|
+
return parsed;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export async function deleteCredentials(path = credentialsPath()) {
|
|
72
|
+
await rm(path, { force: true });
|
|
73
|
+
}
|
|
74
|
+
//# sourceMappingURL=credentials.js.map
|
package/dist/doctor.d.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `yagni doctor` — a green/red readiness checklist.
|
|
3
|
+
*
|
|
4
|
+
* Diagnoses whether a `yagni` launch will actually work: the pi engine is built,
|
|
5
|
+
* the extension is built, the active profile has a live (unexpired) token, the
|
|
6
|
+
* backend is reachable with YAGNI Code enabled, the state dir is locked down, and
|
|
7
|
+
* `gh` is available for `/go --pr`. Every failing/soft check carries a one-line
|
|
8
|
+
* actionable fix.
|
|
9
|
+
*
|
|
10
|
+
* The check LOGIC is pure (given probe inputs) so it is fully unit-testable; the
|
|
11
|
+
* impure probes (fs, fetch, profile read) are injectable seams. `runDoctor`
|
|
12
|
+
* returns the process exit code: 0 when all REQUIRED checks pass, 1 otherwise.
|
|
13
|
+
* Advisory checks (loose perms, missing `gh`) never flip the exit code.
|
|
14
|
+
*/
|
|
15
|
+
import { type TokenExpiryStatus } from "./launch.js";
|
|
16
|
+
import { type Profile } from "./profiles.js";
|
|
17
|
+
export type CheckStatus = "ok" | "warn" | "fail";
|
|
18
|
+
export interface CheckResult {
|
|
19
|
+
/** Short column label, e.g. "pi engine". */
|
|
20
|
+
name: string;
|
|
21
|
+
status: CheckStatus;
|
|
22
|
+
/** One-line current state. */
|
|
23
|
+
detail: string;
|
|
24
|
+
/** Actionable fix, shown when the check is not `ok`. */
|
|
25
|
+
hint?: string;
|
|
26
|
+
/** Only `required` failures flip the exit code to 1. */
|
|
27
|
+
required: boolean;
|
|
28
|
+
}
|
|
29
|
+
export interface PiEngineProbe {
|
|
30
|
+
binPath?: string;
|
|
31
|
+
binExists: boolean;
|
|
32
|
+
version?: string;
|
|
33
|
+
}
|
|
34
|
+
export interface ExtensionProbe {
|
|
35
|
+
path?: string;
|
|
36
|
+
exists: boolean;
|
|
37
|
+
}
|
|
38
|
+
export interface StateDirProbe {
|
|
39
|
+
path: string;
|
|
40
|
+
exists: boolean;
|
|
41
|
+
/** The dir's permission bits (mode & 0o777), or null when it doesn't exist. */
|
|
42
|
+
mode: number | null;
|
|
43
|
+
}
|
|
44
|
+
export type BackendProbe = {
|
|
45
|
+
kind: "skipped";
|
|
46
|
+
} | {
|
|
47
|
+
kind: "status";
|
|
48
|
+
status: number;
|
|
49
|
+
} | {
|
|
50
|
+
kind: "network";
|
|
51
|
+
};
|
|
52
|
+
export declare function checkPiEngine(probe: PiEngineProbe): CheckResult;
|
|
53
|
+
export declare function checkExtension(probe: ExtensionProbe): CheckResult;
|
|
54
|
+
export declare function checkProfileToken(profile: Pick<Profile, "name" | "token">): CheckResult;
|
|
55
|
+
export declare function checkTokenExpiry(status: TokenExpiryStatus): CheckResult;
|
|
56
|
+
export declare function checkBackend(probe: BackendProbe): CheckResult;
|
|
57
|
+
export declare function checkStateDir(probe: StateDirProbe): CheckResult;
|
|
58
|
+
export declare function checkGh(onPath: boolean): CheckResult;
|
|
59
|
+
export interface DoctorReport {
|
|
60
|
+
checks: CheckResult[];
|
|
61
|
+
exitCode: number;
|
|
62
|
+
}
|
|
63
|
+
/** Exit 1 iff any REQUIRED check failed; advisory warns never flip it. */
|
|
64
|
+
export declare function buildDoctorReport(checks: CheckResult[]): DoctorReport;
|
|
65
|
+
export declare function formatDoctorReport(report: DoctorReport): string;
|
|
66
|
+
export interface DoctorDeps {
|
|
67
|
+
now?: () => number;
|
|
68
|
+
probePiEngine?: () => PiEngineProbe;
|
|
69
|
+
probeExtension?: () => ExtensionProbe;
|
|
70
|
+
readActiveProfile?: () => Promise<Profile>;
|
|
71
|
+
probeBackend?: (baseUrl: string, token: string) => Promise<BackendProbe>;
|
|
72
|
+
probeStateDir?: () => StateDirProbe;
|
|
73
|
+
ghOnPath?: () => boolean;
|
|
74
|
+
log?: (msg: string) => void;
|
|
75
|
+
}
|
|
76
|
+
/** Whether a `gh` executable is resolvable on PATH (no subprocess spawn). */
|
|
77
|
+
export declare function ghOnPathDefault(env?: NodeJS.ProcessEnv): boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Gather every check result against the (injectable) probes. Pure ordering; each
|
|
80
|
+
* individual check is a pure function of its probe.
|
|
81
|
+
*/
|
|
82
|
+
export declare function gatherChecks(deps?: DoctorDeps): Promise<CheckResult[]>;
|
|
83
|
+
/**
|
|
84
|
+
* Run the full doctor flow: gather → assemble → print → return the exit code.
|
|
85
|
+
* Never throws (a probe failure degrades to a failing/soft check, not a crash).
|
|
86
|
+
*/
|
|
87
|
+
export declare function runDoctor(deps?: DoctorDeps): Promise<number>;
|
|
88
|
+
//# sourceMappingURL=doctor.d.ts.map
|
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `yagni doctor` — a green/red readiness checklist.
|
|
3
|
+
*
|
|
4
|
+
* Diagnoses whether a `yagni` launch will actually work: the pi engine is built,
|
|
5
|
+
* the extension is built, the active profile has a live (unexpired) token, the
|
|
6
|
+
* backend is reachable with YAGNI Code enabled, the state dir is locked down, and
|
|
7
|
+
* `gh` is available for `/go --pr`. Every failing/soft check carries a one-line
|
|
8
|
+
* actionable fix.
|
|
9
|
+
*
|
|
10
|
+
* The check LOGIC is pure (given probe inputs) so it is fully unit-testable; the
|
|
11
|
+
* impure probes (fs, fetch, profile read) are injectable seams. `runDoctor`
|
|
12
|
+
* returns the process exit code: 0 when all REQUIRED checks pass, 1 otherwise.
|
|
13
|
+
* Advisory checks (loose perms, missing `gh`) never flip the exit code.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
16
|
+
import { delimiter, join } from "node:path";
|
|
17
|
+
import { credentialsDir } from "./credentials.js";
|
|
18
|
+
import { classifyTokenExpiry } from "./launch.js";
|
|
19
|
+
import { resolveExtensionPath, resolvePiCliPath, resolvePiPackageDir } from "./paths.js";
|
|
20
|
+
import { readActiveProfile } from "./profiles.js";
|
|
21
|
+
// ── Pure check builders ─────────────────────────────────────────────────────
|
|
22
|
+
export function checkPiEngine(probe) {
|
|
23
|
+
if (!probe.binPath || !probe.binExists) {
|
|
24
|
+
return {
|
|
25
|
+
name: "pi engine",
|
|
26
|
+
status: "fail",
|
|
27
|
+
detail: "not found or not built",
|
|
28
|
+
hint: "reinstall it: npm i -g @yagni-app/code (monorepo: npm i -g ./packages/yagni-code-cli)",
|
|
29
|
+
required: true,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
name: "pi engine",
|
|
34
|
+
status: "ok",
|
|
35
|
+
detail: probe.version ? `installed and built (v${probe.version})` : "installed and built",
|
|
36
|
+
required: true,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export function checkExtension(probe) {
|
|
40
|
+
if (!probe.path || !probe.exists) {
|
|
41
|
+
return {
|
|
42
|
+
name: "extension",
|
|
43
|
+
status: "fail",
|
|
44
|
+
detail: "pi-extension-yagni not built (dist/index.js missing)",
|
|
45
|
+
hint: "reinstall it: npm i -g @yagni-app/code (monorepo: pnpm --filter pi-extension-yagni build)",
|
|
46
|
+
required: true,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
return { name: "extension", status: "ok", detail: "pi-extension-yagni built", required: true };
|
|
50
|
+
}
|
|
51
|
+
export function checkProfileToken(profile) {
|
|
52
|
+
if (!profile.token) {
|
|
53
|
+
return {
|
|
54
|
+
name: "profile",
|
|
55
|
+
status: "fail",
|
|
56
|
+
detail: `not logged in to "${profile.name}"`,
|
|
57
|
+
hint: "run `yagni login`",
|
|
58
|
+
required: true,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
name: "profile",
|
|
63
|
+
status: "ok",
|
|
64
|
+
detail: `logged in to "${profile.name}"`,
|
|
65
|
+
required: true,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export function checkTokenExpiry(status) {
|
|
69
|
+
switch (status.kind) {
|
|
70
|
+
case "expired":
|
|
71
|
+
return {
|
|
72
|
+
name: "token",
|
|
73
|
+
status: "fail",
|
|
74
|
+
detail: "expired",
|
|
75
|
+
hint: "run `yagni login` to re-authenticate",
|
|
76
|
+
required: true,
|
|
77
|
+
};
|
|
78
|
+
case "approaching":
|
|
79
|
+
return {
|
|
80
|
+
name: "token",
|
|
81
|
+
status: "warn",
|
|
82
|
+
detail: `expires in about ${status.minutesRemaining} minute${status.minutesRemaining === 1 ? "" : "s"}`,
|
|
83
|
+
hint: "run `yagni login` (launch also auto-refreshes within 7 days of expiry)",
|
|
84
|
+
required: false,
|
|
85
|
+
};
|
|
86
|
+
case "valid":
|
|
87
|
+
return { name: "token", status: "ok", detail: "valid", required: false };
|
|
88
|
+
case "unknown":
|
|
89
|
+
default:
|
|
90
|
+
return { name: "token", status: "ok", detail: "no expiry recorded (older login)", required: false };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export function checkBackend(probe) {
|
|
94
|
+
if (probe.kind === "skipped") {
|
|
95
|
+
return {
|
|
96
|
+
name: "backend",
|
|
97
|
+
status: "warn",
|
|
98
|
+
detail: "skipped (not logged in)",
|
|
99
|
+
hint: "run `yagni login`",
|
|
100
|
+
required: false,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (probe.kind === "network") {
|
|
104
|
+
return {
|
|
105
|
+
name: "backend",
|
|
106
|
+
status: "warn",
|
|
107
|
+
detail: "could not reach the backend (network)",
|
|
108
|
+
hint: "check your connection, or `yagni use <env>` to point at the right host",
|
|
109
|
+
required: false,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const { status } = probe;
|
|
113
|
+
if (status === 200) {
|
|
114
|
+
return { name: "backend", status: "ok", detail: "reachable, YAGNI Code enabled", required: true };
|
|
115
|
+
}
|
|
116
|
+
if (status === 401 || status === 403) {
|
|
117
|
+
return {
|
|
118
|
+
name: "backend",
|
|
119
|
+
status: "fail",
|
|
120
|
+
detail: `token rejected (HTTP ${status})`,
|
|
121
|
+
hint: "run `yagni login` to re-authenticate",
|
|
122
|
+
required: true,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
if (status === 404) {
|
|
126
|
+
return {
|
|
127
|
+
name: "backend",
|
|
128
|
+
status: "fail",
|
|
129
|
+
detail: "YAGNI Code is not enabled for this workspace (HTTP 404)",
|
|
130
|
+
hint: "ask an admin to enable the yagni_code.enabled feature flag",
|
|
131
|
+
required: true,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
name: "backend",
|
|
136
|
+
status: "warn",
|
|
137
|
+
detail: `unexpected response (HTTP ${status})`,
|
|
138
|
+
hint: "retry in a moment; if it persists, contact support",
|
|
139
|
+
required: false,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
export function checkStateDir(probe) {
|
|
143
|
+
if (!probe.exists || probe.mode === null) {
|
|
144
|
+
return {
|
|
145
|
+
name: "state dir",
|
|
146
|
+
status: "ok",
|
|
147
|
+
detail: `${probe.path} not created yet`,
|
|
148
|
+
required: false,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
// Any group/other bit set on a dir that holds live tokens is a leak.
|
|
152
|
+
if ((probe.mode & 0o077) !== 0) {
|
|
153
|
+
return {
|
|
154
|
+
name: "state dir",
|
|
155
|
+
status: "warn",
|
|
156
|
+
detail: `${probe.path} is ${toOctal(probe.mode)} (should be 0700)`,
|
|
157
|
+
hint: `run: chmod 700 ${probe.path}`,
|
|
158
|
+
required: false,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
return { name: "state dir", status: "ok", detail: `${probe.path} is 0700`, required: false };
|
|
162
|
+
}
|
|
163
|
+
export function checkGh(onPath) {
|
|
164
|
+
if (onPath) {
|
|
165
|
+
return {
|
|
166
|
+
name: "gh (optional)",
|
|
167
|
+
status: "ok",
|
|
168
|
+
detail: "on PATH (for `yagni /go --pr`)",
|
|
169
|
+
required: false,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
name: "gh (optional)",
|
|
174
|
+
status: "warn",
|
|
175
|
+
detail: "not found (only needed for `yagni /go --pr`)",
|
|
176
|
+
hint: "install the GitHub CLI: https://cli.github.com",
|
|
177
|
+
required: false,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
function toOctal(mode) {
|
|
181
|
+
return `0${(mode & 0o777).toString(8).padStart(3, "0")}`;
|
|
182
|
+
}
|
|
183
|
+
/** Exit 1 iff any REQUIRED check failed; advisory warns never flip it. */
|
|
184
|
+
export function buildDoctorReport(checks) {
|
|
185
|
+
const failedRequired = checks.some((c) => c.required && c.status === "fail");
|
|
186
|
+
return { checks, exitCode: failedRequired ? 1 : 0 };
|
|
187
|
+
}
|
|
188
|
+
const SYMBOL = { ok: "✓", warn: "!", fail: "✗" };
|
|
189
|
+
export function formatDoctorReport(report) {
|
|
190
|
+
const lines = ["YAGNI Code doctor", ""];
|
|
191
|
+
const width = Math.max(...report.checks.map((c) => c.name.length), 0);
|
|
192
|
+
for (const c of report.checks) {
|
|
193
|
+
lines.push(` ${SYMBOL[c.status]} ${c.name.padEnd(width)} ${c.detail}`);
|
|
194
|
+
if (c.status !== "ok" && c.hint) {
|
|
195
|
+
lines.push(` ${" ".repeat(width + 3)}→ ${c.hint}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
lines.push("");
|
|
199
|
+
const problems = report.checks.filter((c) => c.required && c.status === "fail").length;
|
|
200
|
+
const advisories = report.checks.filter((c) => c.status === "warn").length;
|
|
201
|
+
if (problems > 0) {
|
|
202
|
+
lines.push(`${problems} problem${problems === 1 ? "" : "s"} found — see the hints above.`);
|
|
203
|
+
}
|
|
204
|
+
else if (advisories > 0) {
|
|
205
|
+
lines.push(`All required checks passed (${advisories} advisory note${advisories === 1 ? "" : "s"}).`);
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
lines.push("All checks passed. You're ready to run `yagni`.");
|
|
209
|
+
}
|
|
210
|
+
return lines.join("\n");
|
|
211
|
+
}
|
|
212
|
+
function defaultProbePiEngine() {
|
|
213
|
+
try {
|
|
214
|
+
const binPath = resolvePiCliPath();
|
|
215
|
+
const binExists = existsSync(binPath);
|
|
216
|
+
let version;
|
|
217
|
+
try {
|
|
218
|
+
const pkg = JSON.parse(readFileSync(join(resolvePiPackageDir(), "package.json"), "utf8"));
|
|
219
|
+
version = typeof pkg.version === "string" ? pkg.version : undefined;
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
/* version is nice-to-have */
|
|
223
|
+
}
|
|
224
|
+
return { binPath, binExists, version };
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return { binExists: false };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function defaultProbeExtension() {
|
|
231
|
+
try {
|
|
232
|
+
const path = resolveExtensionPath();
|
|
233
|
+
return { path, exists: existsSync(path) };
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return { exists: false };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function defaultProbeStateDir() {
|
|
240
|
+
const path = credentialsDir();
|
|
241
|
+
try {
|
|
242
|
+
const st = statSync(path);
|
|
243
|
+
return { path, exists: true, mode: st.mode & 0o777 };
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
return { path, exists: false, mode: null };
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
async function defaultProbeBackend(baseUrl, token) {
|
|
250
|
+
if (!token)
|
|
251
|
+
return { kind: "skipped" };
|
|
252
|
+
try {
|
|
253
|
+
const res = await fetch(`${baseUrl}/api/yagni-code/models`, {
|
|
254
|
+
method: "GET",
|
|
255
|
+
headers: { authorization: `Bearer ${token}` },
|
|
256
|
+
signal: AbortSignal.timeout(10_000),
|
|
257
|
+
});
|
|
258
|
+
return { kind: "status", status: res.status };
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return { kind: "network" };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/** Whether a `gh` executable is resolvable on PATH (no subprocess spawn). */
|
|
265
|
+
export function ghOnPathDefault(env = process.env) {
|
|
266
|
+
const raw = env.PATH ?? "";
|
|
267
|
+
const names = process.platform === "win32" ? ["gh.exe", "gh.cmd", "gh"] : ["gh"];
|
|
268
|
+
for (const dir of raw.split(delimiter)) {
|
|
269
|
+
if (!dir)
|
|
270
|
+
continue;
|
|
271
|
+
for (const name of names) {
|
|
272
|
+
if (existsSync(join(dir, name)))
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Gather every check result against the (injectable) probes. Pure ordering; each
|
|
280
|
+
* individual check is a pure function of its probe.
|
|
281
|
+
*/
|
|
282
|
+
export async function gatherChecks(deps = {}) {
|
|
283
|
+
const now = deps.now ?? Date.now;
|
|
284
|
+
const probePiEngine = deps.probePiEngine ?? defaultProbePiEngine;
|
|
285
|
+
const probeExtension = deps.probeExtension ?? defaultProbeExtension;
|
|
286
|
+
const readProfile = deps.readActiveProfile ?? readActiveProfile;
|
|
287
|
+
const probeBackend = deps.probeBackend ?? defaultProbeBackend;
|
|
288
|
+
const probeStateDir = deps.probeStateDir ?? defaultProbeStateDir;
|
|
289
|
+
const ghOnPath = deps.ghOnPath ?? (() => ghOnPathDefault());
|
|
290
|
+
const checks = [];
|
|
291
|
+
checks.push(checkPiEngine(probePiEngine()));
|
|
292
|
+
checks.push(checkExtension(probeExtension()));
|
|
293
|
+
const profile = await readProfile();
|
|
294
|
+
checks.push(checkProfileToken(profile));
|
|
295
|
+
if (profile.token) {
|
|
296
|
+
checks.push(checkTokenExpiry(classifyTokenExpiry(profile.expiresAt, now())));
|
|
297
|
+
}
|
|
298
|
+
const backend = profile.token
|
|
299
|
+
? await probeBackend(profile.baseUrl, profile.token)
|
|
300
|
+
: { kind: "skipped" };
|
|
301
|
+
checks.push(checkBackend(backend));
|
|
302
|
+
checks.push(checkStateDir(probeStateDir()));
|
|
303
|
+
checks.push(checkGh(ghOnPath()));
|
|
304
|
+
return checks;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Run the full doctor flow: gather → assemble → print → return the exit code.
|
|
308
|
+
* Never throws (a probe failure degrades to a failing/soft check, not a crash).
|
|
309
|
+
*/
|
|
310
|
+
export async function runDoctor(deps = {}) {
|
|
311
|
+
const log = deps.log ?? ((m) => process.stdout.write(`${m}\n`));
|
|
312
|
+
const checks = await gatherChecks(deps);
|
|
313
|
+
const report = buildDoctorReport(checks);
|
|
314
|
+
log(formatDoctorReport(report));
|
|
315
|
+
return report.exitCode;
|
|
316
|
+
}
|
|
317
|
+
//# sourceMappingURL=doctor.js.map
|
package/dist/launch.d.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the configured pi launch. `buildLaunch` is pure (no spawn, no fs) so it
|
|
3
|
+
* is fully unit-testable: it returns the child environment and the pi argv that
|
|
4
|
+
* registers pi-extension-yagni and defaults the provider to `yagni`.
|
|
5
|
+
*/
|
|
6
|
+
import type { Credentials } from "./credentials.js";
|
|
7
|
+
export interface LaunchPlan {
|
|
8
|
+
env: NodeJS.ProcessEnv;
|
|
9
|
+
argv: string[];
|
|
10
|
+
/**
|
|
11
|
+
* Non-fatal launch-time notices for the caller to surface (e.g. an
|
|
12
|
+
* expiry-approaching warning). Empty when there is nothing to say.
|
|
13
|
+
*/
|
|
14
|
+
warnings: string[];
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* How close to expiry the token can be before launch warns. A coding session
|
|
18
|
+
* easily outlives a token, so warn early enough that re-logging in before a long
|
|
19
|
+
* task is worthwhile, without nagging on every launch.
|
|
20
|
+
*/
|
|
21
|
+
export declare const EXPIRY_WARNING_THRESHOLD_MS: number;
|
|
22
|
+
export type TokenExpiryStatus = {
|
|
23
|
+
kind: "unknown";
|
|
24
|
+
} | {
|
|
25
|
+
kind: "valid";
|
|
26
|
+
} | {
|
|
27
|
+
kind: "approaching";
|
|
28
|
+
minutesRemaining: number;
|
|
29
|
+
} | {
|
|
30
|
+
kind: "expired";
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Pure classifier for a token's ISO `expiresAt` against the current time. A
|
|
34
|
+
* missing or unparseable expiry is "unknown" (older profiles predate the field,
|
|
35
|
+
* so we proceed rather than block). Never throws.
|
|
36
|
+
*/
|
|
37
|
+
export declare function classifyTokenExpiry(expiresAt: string | undefined, nowMs: number, thresholdMs?: number): TokenExpiryStatus;
|
|
38
|
+
export interface BuildLaunchOptions {
|
|
39
|
+
/** Absolute path to pi-extension-yagni's entry (resolved by the caller). */
|
|
40
|
+
extensionPath: string;
|
|
41
|
+
/**
|
|
42
|
+
* Isolated pi config dir. Pinning this keeps YAGNI Code hermetic: it never
|
|
43
|
+
* reads the user's machine-wide `~/.pi/agent`, so local providers
|
|
44
|
+
* (Together/OpenRouter), stored keys, a saved default model, and theme cannot
|
|
45
|
+
* bleed in or route around the YAGNI proxy.
|
|
46
|
+
*/
|
|
47
|
+
agentDir: string;
|
|
48
|
+
/**
|
|
49
|
+
* Shadow pi package dir to expose as `PI_PACKAGE_DIR` so pi rebrands its
|
|
50
|
+
* chrome to "YAGNI Code". Optional: if the shadow package could not be
|
|
51
|
+
* generated, we omit it and pi runs un-rebranded but still hermetic.
|
|
52
|
+
*/
|
|
53
|
+
piPackageDir?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Absolute path of the active profile's JSON file. Forwarded to the child as
|
|
56
|
+
* `YAGNI_PROFILE_PATH` so the extension can persist a mid-session token
|
|
57
|
+
* rotation back to the same profile the launcher read. Optional: omitted when
|
|
58
|
+
* the caller cannot resolve it (the extension then keeps its rotation
|
|
59
|
+
* in-process only, still fail-soft).
|
|
60
|
+
*/
|
|
61
|
+
profilePath?: string;
|
|
62
|
+
/** Base environment to extend (defaults to process.env at call sites). */
|
|
63
|
+
baseEnv?: NodeJS.ProcessEnv;
|
|
64
|
+
/** Clock seam for the token-expiry preflight (defaults to Date.now). */
|
|
65
|
+
now?: () => number;
|
|
66
|
+
}
|
|
67
|
+
export declare function buildLaunch(creds: Credentials | null, passthroughArgs: string[], opts: BuildLaunchOptions): LaunchPlan;
|
|
68
|
+
//# sourceMappingURL=launch.d.ts.map
|