@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
package/dist/launch.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
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 { agentDirEnvVar } from "./branding.js";
|
|
7
|
+
/**
|
|
8
|
+
* How close to expiry the token can be before launch warns. A coding session
|
|
9
|
+
* easily outlives a token, so warn early enough that re-logging in before a long
|
|
10
|
+
* task is worthwhile, without nagging on every launch.
|
|
11
|
+
*/
|
|
12
|
+
export const EXPIRY_WARNING_THRESHOLD_MS = 30 * 60 * 1000;
|
|
13
|
+
/**
|
|
14
|
+
* Pure classifier for a token's ISO `expiresAt` against the current time. A
|
|
15
|
+
* missing or unparseable expiry is "unknown" (older profiles predate the field,
|
|
16
|
+
* so we proceed rather than block). Never throws.
|
|
17
|
+
*/
|
|
18
|
+
export function classifyTokenExpiry(expiresAt, nowMs, thresholdMs = EXPIRY_WARNING_THRESHOLD_MS) {
|
|
19
|
+
if (!expiresAt)
|
|
20
|
+
return { kind: "unknown" };
|
|
21
|
+
const expiryMs = Date.parse(expiresAt);
|
|
22
|
+
if (Number.isNaN(expiryMs))
|
|
23
|
+
return { kind: "unknown" };
|
|
24
|
+
const remaining = expiryMs - nowMs;
|
|
25
|
+
if (remaining <= 0)
|
|
26
|
+
return { kind: "expired" };
|
|
27
|
+
if (remaining <= thresholdMs) {
|
|
28
|
+
return { kind: "approaching", minutesRemaining: Math.max(1, Math.ceil(remaining / 60_000)) };
|
|
29
|
+
}
|
|
30
|
+
return { kind: "valid" };
|
|
31
|
+
}
|
|
32
|
+
export function buildLaunch(creds, passthroughArgs, opts) {
|
|
33
|
+
if (!creds?.token) {
|
|
34
|
+
throw new Error("Not logged in. Run `yagni login` first.");
|
|
35
|
+
}
|
|
36
|
+
// Launch-time expiry preflight. An already-expired token would spawn a session
|
|
37
|
+
// that 401s on its very first completion and on every grounding tool, so block
|
|
38
|
+
// it here with an actionable login prompt instead. An approaching expiry is a
|
|
39
|
+
// non-fatal warning the caller surfaces; the session still launches.
|
|
40
|
+
const nowMs = opts.now ? opts.now() : Date.now();
|
|
41
|
+
const expiry = classifyTokenExpiry(creds.expiresAt, nowMs);
|
|
42
|
+
if (expiry.kind === "expired") {
|
|
43
|
+
throw new Error("Your YAGNI Code session has expired. Run `yagni login` to re-authenticate.");
|
|
44
|
+
}
|
|
45
|
+
const warnings = [];
|
|
46
|
+
if (expiry.kind === "approaching") {
|
|
47
|
+
const m = expiry.minutesRemaining;
|
|
48
|
+
warnings.push(`Your YAGNI Code session expires in about ${m} minute${m === 1 ? "" : "s"}. ` +
|
|
49
|
+
"Run `yagni login` to refresh it before it lapses.");
|
|
50
|
+
}
|
|
51
|
+
const env = {
|
|
52
|
+
...(opts.baseEnv ?? {}),
|
|
53
|
+
YAGNI_TOKEN: creds.token,
|
|
54
|
+
YAGNI_BASE_URL: creds.baseUrl,
|
|
55
|
+
// Forward the token's expiry (when known) so the extension can surface a
|
|
56
|
+
// single in-session "expiring soon" notice. Read-only signal: no refresh.
|
|
57
|
+
...(creds.expiresAt ? { YAGNI_TOKEN_EXPIRES_AT: creds.expiresAt } : {}),
|
|
58
|
+
// Forward the bound workspace id (when known) so the extension can key its
|
|
59
|
+
// one-time init-pass marker per workspace (Onramp Door B idempotency).
|
|
60
|
+
...(creds.workspaceId ? { YAGNI_WORKSPACE_ID: creds.workspaceId } : {}),
|
|
61
|
+
// Forward the active profile's file path (when known) so the extension can
|
|
62
|
+
// atomically persist a mid-session token rotation back to the same profile
|
|
63
|
+
// the launcher read (0600). Absent when the caller can't resolve it.
|
|
64
|
+
...(opts.profilePath ? { YAGNI_PROFILE_PATH: opts.profilePath } : {}),
|
|
65
|
+
// Rebrand pi's chrome ("pi"/"π" → "YAGNI Code"): pi reads piConfig.name from
|
|
66
|
+
// the package at PI_PACKAGE_DIR (a shadow package the launcher generates).
|
|
67
|
+
...(opts.piPackageDir ? { PI_PACKAGE_DIR: opts.piPackageDir } : {}),
|
|
68
|
+
// Hermetic config: pin pi's agent dir to a YAGNI-owned location so the
|
|
69
|
+
// user's machine-wide ~/.pi/agent (providers, stored keys, saved default
|
|
70
|
+
// model, theme) can't bleed in or bypass the YAGNI proxy. The model scope
|
|
71
|
+
// is then exactly the backend catalog under the `yagni` provider.
|
|
72
|
+
//
|
|
73
|
+
// pi derives the env var NAME from its app name
|
|
74
|
+
// (`${APP_NAME.toUpperCase()}_CODING_AGENT_DIR`), so once rebranded it reads
|
|
75
|
+
// "YAGNI_CODING_AGENT_DIR". We set that key AND the plain
|
|
76
|
+
// PI_CODING_AGENT_DIR as defense-in-depth: if the rebrand ever fails the app
|
|
77
|
+
// name stays "pi", and the plain key keeps the hermetic boundary intact
|
|
78
|
+
// instead of silently falling back to ~/.pi/agent.
|
|
79
|
+
[agentDirEnvVar()]: opts.agentDir,
|
|
80
|
+
PI_CODING_AGENT_DIR: opts.agentDir,
|
|
81
|
+
// Match the e2b harness defaults: skip pi's update check + telemetry.
|
|
82
|
+
PI_SKIP_VERSION_CHECK: "1",
|
|
83
|
+
PI_TELEMETRY: "0",
|
|
84
|
+
};
|
|
85
|
+
// Always load our extension. Default the provider to `yagni` unless the user
|
|
86
|
+
// explicitly chose one (so power users can still point pi elsewhere).
|
|
87
|
+
const userChoseProvider = passthroughArgs.some(arg => arg === "--provider" || arg.startsWith("--provider="));
|
|
88
|
+
// Default the model to the `advanced` tier — the strongest judgment-grade
|
|
89
|
+
// model, so interactive sessions get the best reasoning by default. A user
|
|
90
|
+
// `--model` (e.g. `standard` or `efficient`) still wins. Without this, pi's
|
|
91
|
+
// default-model heuristic could land an interactive session on a weaker tier.
|
|
92
|
+
// Detect if the user explicitly set a model, whether via "--model" as a separate
|
|
93
|
+
// argument or using the equals form "--model=efficient". The previous check only
|
|
94
|
+
// caught the separate form, causing a duplicate "--model advanced" to be added
|
|
95
|
+
// when the equals form was used.
|
|
96
|
+
const userChoseModel = passthroughArgs.some(arg => arg === "--model" || arg.startsWith("--model="));
|
|
97
|
+
const argv = [
|
|
98
|
+
"-e",
|
|
99
|
+
opts.extensionPath,
|
|
100
|
+
...(userChoseProvider ? [] : ["--provider", "yagni"]),
|
|
101
|
+
...(userChoseModel ? [] : ["--model", "advanced"]),
|
|
102
|
+
...passthroughArgs,
|
|
103
|
+
];
|
|
104
|
+
return { env, argv, warnings };
|
|
105
|
+
}
|
|
106
|
+
//# sourceMappingURL=launch.js.map
|
package/dist/login.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device-code login flow (RFC 8628 client side):
|
|
3
|
+
* 1. POST /api/yagni-code/auth/device → show the user_code + verification URL.
|
|
4
|
+
* 2. Poll POST /api/yagni-code/auth/token until the human approves in the
|
|
5
|
+
* web app, then store the returned token.
|
|
6
|
+
*/
|
|
7
|
+
import type { Credentials } from "./credentials.js";
|
|
8
|
+
export interface LoginDeps {
|
|
9
|
+
baseUrl?: string;
|
|
10
|
+
/** Which profile to store the minted token in. Defaults to the active one. */
|
|
11
|
+
profileName?: string;
|
|
12
|
+
fetchImpl?: typeof fetch;
|
|
13
|
+
now?: () => number;
|
|
14
|
+
sleep?: (ms: number) => Promise<void>;
|
|
15
|
+
log?: (msg: string) => void;
|
|
16
|
+
persist?: (creds: Credentials) => Promise<void>;
|
|
17
|
+
/** Open a URL in the user's default browser. Defaults to cross-platform open. */
|
|
18
|
+
openUrl?: (url: string) => Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
/** Spawns a binary with a literal arg vector (no shell). */
|
|
21
|
+
type OpenRunner = (cmd: string, args: string[]) => Promise<void>;
|
|
22
|
+
/**
|
|
23
|
+
* Resolve the OS-native command + argv to open `url` in the default browser,
|
|
24
|
+
* cross-platform. The URL is always a standalone argv element so it is handed
|
|
25
|
+
* to the OS verbatim — it is NEVER interpolated into a shell string, where
|
|
26
|
+
* `$(...)` / backticks in a hostile verification URL could execute commands.
|
|
27
|
+
*
|
|
28
|
+
* macOS: open <url>
|
|
29
|
+
* Linux: xdg-open <url>
|
|
30
|
+
* Windows: cmd /c start "" <url> (the empty "" is start's window-title slot;
|
|
31
|
+
* without it `start` swallows the URL as the title and opens nothing)
|
|
32
|
+
*/
|
|
33
|
+
export declare function resolveOpenCommand(url: string, platform?: NodeJS.Platform): {
|
|
34
|
+
cmd: string;
|
|
35
|
+
args: string[];
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Open a URL in the user's default browser, best-effort. Uses `execFile` (no
|
|
39
|
+
* shell) so a hostile URL cannot inject commands. Failures are swallowed — the
|
|
40
|
+
* URL is still printed for manual copy. `runner` is injectable for tests.
|
|
41
|
+
*/
|
|
42
|
+
export declare const realOpenUrl: (url: string, runner?: OpenRunner) => Promise<void>;
|
|
43
|
+
export declare function login(deps?: LoginDeps): Promise<Credentials>;
|
|
44
|
+
export {};
|
|
45
|
+
//# sourceMappingURL=login.d.ts.map
|
package/dist/login.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device-code login flow (RFC 8628 client side):
|
|
3
|
+
* 1. POST /api/yagni-code/auth/device → show the user_code + verification URL.
|
|
4
|
+
* 2. Poll POST /api/yagni-code/auth/token until the human approves in the
|
|
5
|
+
* web app, then store the returned token.
|
|
6
|
+
*/
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
import { resolveBaseUrl } from "./config.js";
|
|
9
|
+
import { getActiveProfileName, persistProfileCredentials } from "./profiles.js";
|
|
10
|
+
const realSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
11
|
+
/**
|
|
12
|
+
* Per-poll wall-clock cap so a single hung token request can't stall the whole
|
|
13
|
+
* device-code wait. A timed-out poll is swallowed and the loop tries again until
|
|
14
|
+
* the device code's own deadline.
|
|
15
|
+
*/
|
|
16
|
+
const POLL_REQUEST_TIMEOUT_MS = 10_000;
|
|
17
|
+
/**
|
|
18
|
+
* RFC 8628 `slow_down`: when the server signals we are polling too fast (HTTP
|
|
19
|
+
* 429), lengthen the interval by 5s and keep waiting rather than failing.
|
|
20
|
+
*/
|
|
21
|
+
const SLOW_DOWN_INCREMENT_MS = 5_000;
|
|
22
|
+
/** Whether an error is a per-request timeout/abort (vs. a real network error). */
|
|
23
|
+
function isTimeoutError(err) {
|
|
24
|
+
return err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError");
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the OS-native command + argv to open `url` in the default browser,
|
|
28
|
+
* cross-platform. The URL is always a standalone argv element so it is handed
|
|
29
|
+
* to the OS verbatim — it is NEVER interpolated into a shell string, where
|
|
30
|
+
* `$(...)` / backticks in a hostile verification URL could execute commands.
|
|
31
|
+
*
|
|
32
|
+
* macOS: open <url>
|
|
33
|
+
* Linux: xdg-open <url>
|
|
34
|
+
* Windows: cmd /c start "" <url> (the empty "" is start's window-title slot;
|
|
35
|
+
* without it `start` swallows the URL as the title and opens nothing)
|
|
36
|
+
*/
|
|
37
|
+
export function resolveOpenCommand(url, platform = process.platform) {
|
|
38
|
+
if (platform === "darwin")
|
|
39
|
+
return { cmd: "open", args: [url] };
|
|
40
|
+
if (platform === "win32")
|
|
41
|
+
return { cmd: "cmd", args: ["/c", "start", "", url] };
|
|
42
|
+
return { cmd: "xdg-open", args: [url] };
|
|
43
|
+
}
|
|
44
|
+
const spawnRunner = (cmd, args) => new Promise((resolve, reject) => {
|
|
45
|
+
execFile(cmd, args, (err) => (err ? reject(err) : resolve()));
|
|
46
|
+
});
|
|
47
|
+
/**
|
|
48
|
+
* Open a URL in the user's default browser, best-effort. Uses `execFile` (no
|
|
49
|
+
* shell) so a hostile URL cannot inject commands. Failures are swallowed — the
|
|
50
|
+
* URL is still printed for manual copy. `runner` is injectable for tests.
|
|
51
|
+
*/
|
|
52
|
+
export const realOpenUrl = (url, runner = spawnRunner) => {
|
|
53
|
+
const { cmd, args } = resolveOpenCommand(url);
|
|
54
|
+
return runner(cmd, args).catch(() => {
|
|
55
|
+
// Silently fail — the user can still copy the URL manually.
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
export async function login(deps = {}) {
|
|
59
|
+
const baseUrl = deps.baseUrl ?? resolveBaseUrl();
|
|
60
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
61
|
+
const now = deps.now ?? Date.now;
|
|
62
|
+
const sleep = deps.sleep ?? realSleep;
|
|
63
|
+
const log = deps.log ?? ((m) => process.stdout.write(`${m}\n`));
|
|
64
|
+
// By default the token lands in the active profile; tests inject `persist`.
|
|
65
|
+
const persist = deps.persist ??
|
|
66
|
+
(async (creds) => {
|
|
67
|
+
const profileName = deps.profileName ?? (await getActiveProfileName());
|
|
68
|
+
await persistProfileCredentials(profileName, creds);
|
|
69
|
+
});
|
|
70
|
+
// Open the verification URL automatically; failures are swallowed so the
|
|
71
|
+
// user can still copy it manually.
|
|
72
|
+
const openUrl = deps.openUrl ?? realOpenUrl;
|
|
73
|
+
const startRes = await fetchImpl(`${baseUrl}/api/yagni-code/auth/device`, {
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: { "content-type": "application/json" },
|
|
76
|
+
body: "{}",
|
|
77
|
+
signal: AbortSignal.timeout(POLL_REQUEST_TIMEOUT_MS),
|
|
78
|
+
});
|
|
79
|
+
if (!startRes.ok) {
|
|
80
|
+
throw new Error(`Could not start login (HTTP ${startRes.status}). Is YAGNI Code enabled for your workspace?`);
|
|
81
|
+
}
|
|
82
|
+
const start = (await startRes.json());
|
|
83
|
+
log("");
|
|
84
|
+
log("Opening your browser to authorize YAGNI Code…");
|
|
85
|
+
log(` ${start.verification_url}`);
|
|
86
|
+
log("If it doesn't open automatically, copy the URL above and enter the code:");
|
|
87
|
+
log(` ${start.user_code}`);
|
|
88
|
+
log("");
|
|
89
|
+
log("Waiting for approval…");
|
|
90
|
+
// Best-effort auto-open; the URL is already printed above.
|
|
91
|
+
await openUrl(start.verification_url).catch(() => { });
|
|
92
|
+
const deadline = now() + start.expires_in * 1000;
|
|
93
|
+
// Interval can grow across the loop when the server asks us to slow down, so
|
|
94
|
+
// it's mutable rather than a one-shot constant.
|
|
95
|
+
let intervalMs = Math.max(1, start.interval) * 1000;
|
|
96
|
+
while (now() < deadline) {
|
|
97
|
+
await sleep(intervalMs);
|
|
98
|
+
let res;
|
|
99
|
+
try {
|
|
100
|
+
res = await fetchImpl(`${baseUrl}/api/yagni-code/auth/token`, {
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers: { "content-type": "application/json" },
|
|
103
|
+
body: JSON.stringify({ device_code: start.device_code }),
|
|
104
|
+
signal: AbortSignal.timeout(POLL_REQUEST_TIMEOUT_MS),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
// A per-request timeout (or transient blip) must not abandon a long
|
|
109
|
+
// device-code wait — keep polling until the code's own deadline. A
|
|
110
|
+
// genuine, non-timeout error still propagates.
|
|
111
|
+
if (isTimeoutError(err))
|
|
112
|
+
continue;
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
if (res.status === 200) {
|
|
116
|
+
const data = (await res.json());
|
|
117
|
+
const creds = {
|
|
118
|
+
token: data.token,
|
|
119
|
+
baseUrl,
|
|
120
|
+
workspaceId: data.workspaceId,
|
|
121
|
+
expiresAt: data.expiresAt,
|
|
122
|
+
};
|
|
123
|
+
await persist(creds);
|
|
124
|
+
log("✓ Logged in. Run `yagni` in a repo to start.");
|
|
125
|
+
return creds;
|
|
126
|
+
}
|
|
127
|
+
if (res.status === 428) {
|
|
128
|
+
continue; // authorization_pending — keep polling
|
|
129
|
+
}
|
|
130
|
+
if (res.status === 429) {
|
|
131
|
+
// slow_down — back off by 5s (RFC 8628) and keep polling.
|
|
132
|
+
intervalMs += SLOW_DOWN_INCREMENT_MS;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (res.status === 410) {
|
|
136
|
+
throw new Error("The login code expired before approval. Run `yagni login` again.");
|
|
137
|
+
}
|
|
138
|
+
throw new Error(`Login failed (HTTP ${res.status}).`);
|
|
139
|
+
}
|
|
140
|
+
throw new Error("Login timed out before approval. Run `yagni login` again.");
|
|
141
|
+
}
|
|
142
|
+
//# sourceMappingURL=login.js.map
|
package/dist/logout.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logout: revoke the active profile's token server-side (best-effort) and clear
|
|
3
|
+
* it locally. The environment binding (name + base URL) is kept so a later
|
|
4
|
+
* `yagni login` re-authenticates the same environment without re-`use`.
|
|
5
|
+
*/
|
|
6
|
+
import { type Credentials } from "./credentials.js";
|
|
7
|
+
export interface LogoutDeps {
|
|
8
|
+
fetchImpl?: typeof fetch;
|
|
9
|
+
read?: () => Promise<Credentials | null>;
|
|
10
|
+
remove?: () => Promise<void>;
|
|
11
|
+
log?: (msg: string) => void;
|
|
12
|
+
}
|
|
13
|
+
export declare function logout(deps?: LogoutDeps): Promise<void>;
|
|
14
|
+
//# sourceMappingURL=logout.d.ts.map
|
package/dist/logout.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logout: revoke the active profile's token server-side (best-effort) and clear
|
|
3
|
+
* it locally. The environment binding (name + base URL) is kept so a later
|
|
4
|
+
* `yagni login` re-authenticates the same environment without re-`use`.
|
|
5
|
+
*/
|
|
6
|
+
import { clearProfileToken, credentialsFromProfile, getActiveProfileName, readActiveProfile, } from "./profiles.js";
|
|
7
|
+
export async function logout(deps = {}) {
|
|
8
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
9
|
+
const read = deps.read ?? (async () => credentialsFromProfile(await readActiveProfile()));
|
|
10
|
+
const remove = deps.remove ??
|
|
11
|
+
(async () => {
|
|
12
|
+
await clearProfileToken(await getActiveProfileName());
|
|
13
|
+
});
|
|
14
|
+
const log = deps.log ?? ((m) => process.stdout.write(`${m}\n`));
|
|
15
|
+
const creds = await read();
|
|
16
|
+
if (creds?.token) {
|
|
17
|
+
try {
|
|
18
|
+
await fetchImpl(`${creds.baseUrl}/api/yagni-code/auth/revoke`, {
|
|
19
|
+
method: "POST",
|
|
20
|
+
headers: {
|
|
21
|
+
"content-type": "application/json",
|
|
22
|
+
authorization: `Bearer ${creds.token}`,
|
|
23
|
+
},
|
|
24
|
+
body: "{}",
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// Best-effort: a network failure shouldn't block local logout.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
await remove();
|
|
32
|
+
log("✓ Logged out.");
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=logout.js.map
|
package/dist/paths.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the bundled dependency entry points the launcher needs: pi's CLI and
|
|
3
|
+
* the pi-extension-yagni extension. Both are package dependencies, so we resolve
|
|
4
|
+
* them from this module rather than assuming a global install.
|
|
5
|
+
*
|
|
6
|
+
* We use the ESM-native `import.meta.resolve` (NOT `createRequire().resolve`):
|
|
7
|
+
* pi is an ESM-only package whose `exports` map declares only the `import`
|
|
8
|
+
* condition, so the CommonJS `require` resolver throws ERR_PACKAGE_PATH_NOT_EXPORTED
|
|
9
|
+
* on it. pi's package.json is also not an exported subpath, so we read it from
|
|
10
|
+
* disk after resolving the package's main entry.
|
|
11
|
+
*/
|
|
12
|
+
/** Absolute path to pi-extension-yagni's built entry (dist/index.js). */
|
|
13
|
+
export declare function resolveExtensionPath(): string;
|
|
14
|
+
/**
|
|
15
|
+
* Absolute path to pi's package root — the dir whose package.json names the
|
|
16
|
+
* package. The shadow package dir is built from this (we read its package.json
|
|
17
|
+
* and symlink its `dist/`). Uses the same upward walk as resolvePiCliPath.
|
|
18
|
+
*/
|
|
19
|
+
export declare function resolvePiPackageDir(): string;
|
|
20
|
+
/** Absolute path to pi's CLI entry (its package.json `bin.pi`). */
|
|
21
|
+
export declare function resolvePiCliPath(): string;
|
|
22
|
+
//# sourceMappingURL=paths.d.ts.map
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the bundled dependency entry points the launcher needs: pi's CLI and
|
|
3
|
+
* the pi-extension-yagni extension. Both are package dependencies, so we resolve
|
|
4
|
+
* them from this module rather than assuming a global install.
|
|
5
|
+
*
|
|
6
|
+
* We use the ESM-native `import.meta.resolve` (NOT `createRequire().resolve`):
|
|
7
|
+
* pi is an ESM-only package whose `exports` map declares only the `import`
|
|
8
|
+
* condition, so the CommonJS `require` resolver throws ERR_PACKAGE_PATH_NOT_EXPORTED
|
|
9
|
+
* on it. pi's package.json is also not an exported subpath, so we read it from
|
|
10
|
+
* disk after resolving the package's main entry.
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
function resolveModulePath(specifier) {
|
|
16
|
+
return fileURLToPath(import.meta.resolve(specifier));
|
|
17
|
+
}
|
|
18
|
+
/** Absolute path to pi-extension-yagni's built entry (dist/index.js). */
|
|
19
|
+
export function resolveExtensionPath() {
|
|
20
|
+
return resolveModulePath("pi-extension-yagni");
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Absolute path to pi's package root — the dir whose package.json names the
|
|
24
|
+
* package. The shadow package dir is built from this (we read its package.json
|
|
25
|
+
* and symlink its `dist/`). Uses the same upward walk as resolvePiCliPath.
|
|
26
|
+
*/
|
|
27
|
+
export function resolvePiPackageDir() {
|
|
28
|
+
const mainEntry = resolveModulePath("@earendil-works/pi-coding-agent");
|
|
29
|
+
let dir = dirname(mainEntry);
|
|
30
|
+
for (let i = 0; i < 6; i++) {
|
|
31
|
+
try {
|
|
32
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
33
|
+
if (pkg.name === "@earendil-works/pi-coding-agent") {
|
|
34
|
+
return dir;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// No package.json here (or unreadable) — keep walking up.
|
|
39
|
+
}
|
|
40
|
+
const parent = dirname(dir);
|
|
41
|
+
if (parent === dir)
|
|
42
|
+
break;
|
|
43
|
+
dir = parent;
|
|
44
|
+
}
|
|
45
|
+
// Fallback: pi ships its main entry under <root>/dist/, so the root is two
|
|
46
|
+
// levels up from the resolved entry.
|
|
47
|
+
return dirname(dirname(mainEntry));
|
|
48
|
+
}
|
|
49
|
+
/** Absolute path to pi's CLI entry (its package.json `bin.pi`). */
|
|
50
|
+
export function resolvePiCliPath() {
|
|
51
|
+
// Resolves pi's main entry (e.g. <root>/@earendil-works/pi-coding-agent/dist/index.js).
|
|
52
|
+
const mainEntry = resolveModulePath("@earendil-works/pi-coding-agent");
|
|
53
|
+
// Walk up to the package root (the dir whose package.json names the package)
|
|
54
|
+
// and read `bin.pi` from disk.
|
|
55
|
+
let dir = dirname(mainEntry);
|
|
56
|
+
for (let i = 0; i < 6; i++) {
|
|
57
|
+
try {
|
|
58
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
59
|
+
if (pkg.name === "@earendil-works/pi-coding-agent") {
|
|
60
|
+
const binRel = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.pi ?? "dist/cli.js";
|
|
61
|
+
return join(dir, binRel);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// No package.json here (or unreadable) — keep walking up.
|
|
66
|
+
}
|
|
67
|
+
const parent = dirname(dir);
|
|
68
|
+
if (parent === dir)
|
|
69
|
+
break;
|
|
70
|
+
dir = parent;
|
|
71
|
+
}
|
|
72
|
+
// Fallback: pi ships its CLI alongside its main entry under dist/.
|
|
73
|
+
return join(dirname(mainEntry), "cli.js");
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=paths.js.map
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shadow pi package dir.
|
|
3
|
+
*
|
|
4
|
+
* pi reads its identity (`piConfig.name`) and all bundled assets (theme, TUI
|
|
5
|
+
* assets, export-html, CHANGELOG) relative to the package dir returned by
|
|
6
|
+
* `getPackageDir()`, which honors the `PI_PACKAGE_DIR` env var. To rebrand
|
|
7
|
+
* "pi"/"π" out of the chrome WITHOUT modifying `node_modules` (lost on
|
|
8
|
+
* reinstall) or copying pi wholesale, we generate a tiny shadow dir that:
|
|
9
|
+
*
|
|
10
|
+
* - owns its own `package.json` (pi's, with `piConfig.name` injected), and
|
|
11
|
+
* - symlinks `dist/` (+ CHANGELOG/README/docs/examples) back to the real pi
|
|
12
|
+
* package, so asset/theme resolution still finds the real files.
|
|
13
|
+
*
|
|
14
|
+
* The launcher points `PI_PACKAGE_DIR` at this dir. Regenerated every launch so
|
|
15
|
+
* a pi upgrade or store-path move can never leave a stale package.json or a
|
|
16
|
+
* dangling symlink behind.
|
|
17
|
+
*/
|
|
18
|
+
export interface ShadowPiPackageOptions {
|
|
19
|
+
/** The real pi package root (the dir whose package.json names the package). */
|
|
20
|
+
realPiDir: string;
|
|
21
|
+
/** Where to materialize the shadow package (a hermetic, YAGNI-owned dir). */
|
|
22
|
+
shadowDir: string;
|
|
23
|
+
/** The `piConfig.name` to inject — what pi shows as its app name. */
|
|
24
|
+
name: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Materialize (idempotently) the shadow pi package and return its path.
|
|
28
|
+
*
|
|
29
|
+
* @throws if the real pi package.json cannot be read — the launcher needs it to
|
|
30
|
+
* derive pi's version/name, and a silent fallback would re-leak "pi".
|
|
31
|
+
*/
|
|
32
|
+
export declare function ensureShadowPiPackage(opts: ShadowPiPackageOptions): string;
|
|
33
|
+
//# sourceMappingURL=piPackage.d.ts.map
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shadow pi package dir.
|
|
3
|
+
*
|
|
4
|
+
* pi reads its identity (`piConfig.name`) and all bundled assets (theme, TUI
|
|
5
|
+
* assets, export-html, CHANGELOG) relative to the package dir returned by
|
|
6
|
+
* `getPackageDir()`, which honors the `PI_PACKAGE_DIR` env var. To rebrand
|
|
7
|
+
* "pi"/"π" out of the chrome WITHOUT modifying `node_modules` (lost on
|
|
8
|
+
* reinstall) or copying pi wholesale, we generate a tiny shadow dir that:
|
|
9
|
+
*
|
|
10
|
+
* - owns its own `package.json` (pi's, with `piConfig.name` injected), and
|
|
11
|
+
* - symlinks `dist/` (+ CHANGELOG/README/docs/examples) back to the real pi
|
|
12
|
+
* package, so asset/theme resolution still finds the real files.
|
|
13
|
+
*
|
|
14
|
+
* The launcher points `PI_PACKAGE_DIR` at this dir. Regenerated every launch so
|
|
15
|
+
* a pi upgrade or store-path move can never leave a stale package.json or a
|
|
16
|
+
* dangling symlink behind.
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
// Entries pi resolves relative to its package dir. `dist` carries theme + TUI
|
|
21
|
+
// assets + export templates (the load-bearing ones); the rest back /changelog,
|
|
22
|
+
// /about, etc. Each is symlinked only if it exists in the real package.
|
|
23
|
+
const LINKED_ENTRIES = ["dist", "CHANGELOG.md", "README.md", "docs", "examples"];
|
|
24
|
+
function linkInto(shadowDir, realPiDir, entry) {
|
|
25
|
+
const target = join(realPiDir, entry);
|
|
26
|
+
if (!existsSync(target))
|
|
27
|
+
return;
|
|
28
|
+
const linkPath = join(shadowDir, entry);
|
|
29
|
+
// Clear any prior entry so we never stack or dangle. A symlink must be
|
|
30
|
+
// unlinked directly: rmSync stats THROUGH it, so on a dangling link (target
|
|
31
|
+
// checkout deleted) it sees ENOENT, force-swallows it, and leaves the link
|
|
32
|
+
// in place - then symlinkSync throws EEXIST and the rebrand silently drops.
|
|
33
|
+
if (isSymlink(linkPath)) {
|
|
34
|
+
unlinkSync(linkPath);
|
|
35
|
+
}
|
|
36
|
+
else if (existsSync(linkPath)) {
|
|
37
|
+
rmSync(linkPath, { recursive: true, force: true });
|
|
38
|
+
}
|
|
39
|
+
const type = statSync(target).isDirectory() ? "dir" : "file";
|
|
40
|
+
symlinkSync(target, linkPath, type);
|
|
41
|
+
}
|
|
42
|
+
function isSymlink(p) {
|
|
43
|
+
try {
|
|
44
|
+
return lstatSync(p).isSymbolicLink();
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Materialize (idempotently) the shadow pi package and return its path.
|
|
52
|
+
*
|
|
53
|
+
* @throws if the real pi package.json cannot be read — the launcher needs it to
|
|
54
|
+
* derive pi's version/name, and a silent fallback would re-leak "pi".
|
|
55
|
+
*/
|
|
56
|
+
export function ensureShadowPiPackage(opts) {
|
|
57
|
+
const { realPiDir, shadowDir, name } = opts;
|
|
58
|
+
const realPkgRaw = readFileSync(join(realPiDir, "package.json"), "utf8");
|
|
59
|
+
const realPkg = JSON.parse(realPkgRaw);
|
|
60
|
+
mkdirSync(shadowDir, { recursive: true });
|
|
61
|
+
const shadowPkg = {
|
|
62
|
+
...realPkg,
|
|
63
|
+
piConfig: { ...(realPkg.piConfig ?? {}), name },
|
|
64
|
+
};
|
|
65
|
+
writeFileSync(join(shadowDir, "package.json"), `${JSON.stringify(shadowPkg, null, 2)}\n`);
|
|
66
|
+
for (const entry of LINKED_ENTRIES) {
|
|
67
|
+
linkInto(shadowDir, realPiDir, entry);
|
|
68
|
+
}
|
|
69
|
+
return shadowDir;
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=piPackage.js.map
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment profiles for the yagni-code CLI.
|
|
3
|
+
*
|
|
4
|
+
* A profile is a named binding of an environment (base URL) to its own stored
|
|
5
|
+
* credentials, so you can point YAGNI Code at prod, a local backend, or a
|
|
6
|
+
* staging host and switch between them with a sticky `yagni use <name>`.
|
|
7
|
+
*
|
|
8
|
+
* Storage (each file 0600, the dir 0700, since profiles hold live API tokens):
|
|
9
|
+
* ~/.yagni-code/profiles/<name>.json one profile (name, baseUrl, token, …)
|
|
10
|
+
* ~/.yagni-code/config.json { activeProfile }
|
|
11
|
+
*
|
|
12
|
+
* The model is deliberately small but multi-account-ready: a second login to a
|
|
13
|
+
* different workspace/host is just another profile file. `prod` is the default
|
|
14
|
+
* active profile and the only built-in preset that needs no `--base-url`.
|
|
15
|
+
*/
|
|
16
|
+
import { type Credentials } from "./credentials.js";
|
|
17
|
+
export interface Profile {
|
|
18
|
+
name: string;
|
|
19
|
+
baseUrl: string;
|
|
20
|
+
token?: string;
|
|
21
|
+
workspaceId?: string;
|
|
22
|
+
expiresAt?: string;
|
|
23
|
+
}
|
|
24
|
+
/** The default active profile when nothing has been configured yet. */
|
|
25
|
+
export declare const DEFAULT_PROFILE = "prod";
|
|
26
|
+
export declare function isValidProfileName(name: string): boolean;
|
|
27
|
+
export declare function profilesDir(): string;
|
|
28
|
+
export declare function profilePath(name: string): string;
|
|
29
|
+
export declare function configPath(): string;
|
|
30
|
+
export declare function readProfile(name: string): Promise<Profile | null>;
|
|
31
|
+
export declare function writeProfile(profile: Profile): Promise<void>;
|
|
32
|
+
export declare function deleteProfile(name: string): Promise<void>;
|
|
33
|
+
export declare function listProfileNames(): Promise<string[]>;
|
|
34
|
+
export declare function listProfiles(): Promise<Profile[]>;
|
|
35
|
+
export declare function getActiveProfileName(): Promise<string>;
|
|
36
|
+
export declare function setActiveProfileName(name: string): Promise<void>;
|
|
37
|
+
/** A profile is a superset of Credentials; this yields creds iff it has a token. */
|
|
38
|
+
export declare function credentialsFromProfile(profile: Profile): Credentials | null;
|
|
39
|
+
/**
|
|
40
|
+
* Resolve the active profile, with its base URL. Always returns a profile (so
|
|
41
|
+
* callers get a base URL even before a first login); `token` is undefined when
|
|
42
|
+
* the environment has not been authenticated. `YAGNI_BASE_URL` overrides the
|
|
43
|
+
* stored/preset base URL for this run only.
|
|
44
|
+
*/
|
|
45
|
+
export declare function readActiveProfile(env?: NodeJS.ProcessEnv): Promise<Profile>;
|
|
46
|
+
/** Persist freshly-minted credentials into a profile (keeping the name). */
|
|
47
|
+
export declare function persistProfileCredentials(name: string, creds: Credentials): Promise<void>;
|
|
48
|
+
/**
|
|
49
|
+
* Persist a launch-time token ROTATION into an existing profile without
|
|
50
|
+
* touching its stored base URL. The refresh runs against `creds.baseUrl`, which
|
|
51
|
+
* may be a per-run `YAGNI_BASE_URL` override (readActiveProfile bakes that in);
|
|
52
|
+
* persisting it would make a documented single-run override sticky, silently
|
|
53
|
+
* retargeting every later `yagni` launch at the wrong host. So we rotate only
|
|
54
|
+
* the token fields and keep the profile's own stored base URL. Falls back to the
|
|
55
|
+
* passed base URL only when no profile file exists yet (nothing stored to keep).
|
|
56
|
+
*/
|
|
57
|
+
export declare function persistProfileTokenRotation(name: string, creds: Credentials): Promise<void>;
|
|
58
|
+
/** Clear the stored token for a profile but keep the environment binding. */
|
|
59
|
+
export declare function clearProfileToken(name: string): Promise<void>;
|
|
60
|
+
export declare class UnknownEnvironmentError extends Error {
|
|
61
|
+
constructor(name: string);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Switch the sticky active profile to `name`, creating it if needed. Base URL
|
|
65
|
+
* is resolved from (in order): an explicit `--base-url`, the profile's existing
|
|
66
|
+
* stored URL, or a built-in preset. A name with none of those is an error
|
|
67
|
+
* rather than a silent default. When `--base-url` changes an existing profile's
|
|
68
|
+
* host, the stored token is dropped (it belonged to the old environment).
|
|
69
|
+
*/
|
|
70
|
+
export declare function useProfile(name: string, opts?: {
|
|
71
|
+
baseUrl?: string;
|
|
72
|
+
}): Promise<Profile>;
|
|
73
|
+
/**
|
|
74
|
+
* One-shot migration: adopt a pre-profiles `~/.yagni-code/credentials.json` as
|
|
75
|
+
* a named profile so existing logged-in users keep working. No-op once profiles
|
|
76
|
+
* exist (config.json present or any profile file on disk) or when there is no
|
|
77
|
+
* legacy file. The legacy file is removed only after the profile is written.
|
|
78
|
+
*/
|
|
79
|
+
export declare function migrateLegacyCredentials(): Promise<void>;
|
|
80
|
+
//# sourceMappingURL=profiles.d.ts.map
|