@yagni-app/code-staging 0.0.0 → 0.1.0-staging.1002.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/LICENSE.md +55 -0
  2. package/README.md +166 -0
  3. package/dist/branding.d.ts +25 -0
  4. package/dist/branding.js +27 -0
  5. package/dist/claudeCompat.d.ts +78 -0
  6. package/dist/claudeCompat.js +198 -0
  7. package/dist/cli.d.ts +55 -0
  8. package/dist/cli.js +326 -0
  9. package/dist/config.d.ts +19 -0
  10. package/dist/config.js +28 -0
  11. package/dist/credentials.d.ts +41 -0
  12. package/dist/credentials.js +75 -0
  13. package/dist/distribution.d.ts +15 -0
  14. package/dist/distribution.js +45 -0
  15. package/dist/doctor.d.ts +94 -0
  16. package/dist/doctor.js +343 -0
  17. package/dist/extension/advisor.d.ts +84 -0
  18. package/dist/extension/advisor.js +101 -0
  19. package/dist/extension/askAdvisorTool.d.ts +83 -0
  20. package/dist/extension/askAdvisorTool.js +188 -0
  21. package/dist/extension/askYagniTool.d.ts +39 -0
  22. package/dist/extension/askYagniTool.js +63 -0
  23. package/dist/extension/bless.d.ts +45 -0
  24. package/dist/extension/bless.js +74 -0
  25. package/dist/extension/branding.d.ts +48 -0
  26. package/dist/extension/branding.js +119 -0
  27. package/dist/extension/chipEditor.d.ts +117 -0
  28. package/dist/extension/chipEditor.js +369 -0
  29. package/dist/extension/config.d.ts +112 -0
  30. package/dist/extension/config.js +108 -0
  31. package/dist/extension/costHud.d.ts +73 -0
  32. package/dist/extension/costHud.js +123 -0
  33. package/dist/extension/decisionCapture.d.ts +52 -0
  34. package/dist/extension/decisionCapture.js +66 -0
  35. package/dist/extension/decisions.d.ts +83 -0
  36. package/dist/extension/decisions.js +200 -0
  37. package/dist/extension/diagnostics.d.ts +41 -0
  38. package/dist/extension/diagnostics.js +110 -0
  39. package/dist/extension/index.d.ts +116 -0
  40. package/dist/extension/index.js +388 -0
  41. package/dist/extension/initDone.d.ts +28 -0
  42. package/dist/extension/initDone.js +66 -0
  43. package/dist/extension/initPass.d.ts +152 -0
  44. package/dist/extension/initPass.js +394 -0
  45. package/dist/extension/nextWorkTool.d.ts +51 -0
  46. package/dist/extension/nextWorkTool.js +80 -0
  47. package/dist/extension/permission.d.ts +91 -0
  48. package/dist/extension/permission.js +236 -0
  49. package/dist/extension/pipeline/activity.d.ts +37 -0
  50. package/dist/extension/pipeline/activity.js +151 -0
  51. package/dist/extension/pipeline/activityFeed.d.ts +59 -0
  52. package/dist/extension/pipeline/activityFeed.js +175 -0
  53. package/dist/extension/pipeline/budget.d.ts +48 -0
  54. package/dist/extension/pipeline/budget.js +68 -0
  55. package/dist/extension/pipeline/checkpoint.d.ts +31 -0
  56. package/dist/extension/pipeline/checkpoint.js +176 -0
  57. package/dist/extension/pipeline/eval.d.ts +168 -0
  58. package/dist/extension/pipeline/eval.js +182 -0
  59. package/dist/extension/pipeline/events.d.ts +56 -0
  60. package/dist/extension/pipeline/events.js +147 -0
  61. package/dist/extension/pipeline/findings.d.ts +42 -0
  62. package/dist/extension/pipeline/findings.js +144 -0
  63. package/dist/extension/pipeline/finish.d.ts +128 -0
  64. package/dist/extension/pipeline/finish.js +307 -0
  65. package/dist/extension/pipeline/goCommand.d.ts +128 -0
  66. package/dist/extension/pipeline/goCommand.js +972 -0
  67. package/dist/extension/pipeline/goCompareCommand.d.ts +48 -0
  68. package/dist/extension/pipeline/goCompareCommand.js +184 -0
  69. package/dist/extension/pipeline/goFlags.d.ts +40 -0
  70. package/dist/extension/pipeline/goFlags.js +46 -0
  71. package/dist/extension/pipeline/goStatusCommands.d.ts +59 -0
  72. package/dist/extension/pipeline/goStatusCommands.js +188 -0
  73. package/dist/extension/pipeline/invocation.d.ts +45 -0
  74. package/dist/extension/pipeline/invocation.js +64 -0
  75. package/dist/extension/pipeline/orchestrator.d.ts +131 -0
  76. package/dist/extension/pipeline/orchestrator.js +636 -0
  77. package/dist/extension/pipeline/personas.d.ts +44 -0
  78. package/dist/extension/pipeline/personas.js +248 -0
  79. package/dist/extension/pipeline/resilience.d.ts +85 -0
  80. package/dist/extension/pipeline/resilience.js +166 -0
  81. package/dist/extension/pipeline/resume.d.ts +18 -0
  82. package/dist/extension/pipeline/resume.js +106 -0
  83. package/dist/extension/pipeline/runRegistry.d.ts +112 -0
  84. package/dist/extension/pipeline/runRegistry.js +202 -0
  85. package/dist/extension/pipeline/runSession.d.ts +152 -0
  86. package/dist/extension/pipeline/runSession.js +167 -0
  87. package/dist/extension/pipeline/runState.d.ts +158 -0
  88. package/dist/extension/pipeline/runState.js +264 -0
  89. package/dist/extension/pipeline/runner.d.ts +79 -0
  90. package/dist/extension/pipeline/runner.js +298 -0
  91. package/dist/extension/pipeline/scrubSecrets.d.ts +15 -0
  92. package/dist/extension/pipeline/scrubSecrets.js +32 -0
  93. package/dist/extension/pipeline/stages.d.ts +51 -0
  94. package/dist/extension/pipeline/stages.js +113 -0
  95. package/dist/extension/pipeline/ticketResolution.d.ts +34 -0
  96. package/dist/extension/pipeline/ticketResolution.js +75 -0
  97. package/dist/extension/pipeline/types.d.ts +436 -0
  98. package/dist/extension/pipeline/types.js +48 -0
  99. package/dist/extension/pipeline/verify.d.ts +277 -0
  100. package/dist/extension/pipeline/verify.js +758 -0
  101. package/dist/extension/pipeline/workspace.d.ts +32 -0
  102. package/dist/extension/pipeline/workspace.js +73 -0
  103. package/dist/extension/pipeline/worktree.d.ts +107 -0
  104. package/dist/extension/pipeline/worktree.js +200 -0
  105. package/dist/extension/provider.d.ts +11 -0
  106. package/dist/extension/provider.js +26 -0
  107. package/dist/extension/recall.d.ts +93 -0
  108. package/dist/extension/recall.js +187 -0
  109. package/dist/extension/recordContextTool.d.ts +38 -0
  110. package/dist/extension/recordContextTool.js +85 -0
  111. package/dist/extension/recordDecisionTool.d.ts +52 -0
  112. package/dist/extension/recordDecisionTool.js +102 -0
  113. package/dist/extension/repoDocs.d.ts +81 -0
  114. package/dist/extension/repoDocs.js +260 -0
  115. package/dist/extension/resilientFetch.d.ts +60 -0
  116. package/dist/extension/resilientFetch.js +133 -0
  117. package/dist/extension/reviewTool.d.ts +34 -0
  118. package/dist/extension/reviewTool.js +81 -0
  119. package/dist/extension/spool.d.ts +92 -0
  120. package/dist/extension/spool.js +266 -0
  121. package/dist/extension/stateHome.d.ts +2 -0
  122. package/dist/extension/stateHome.js +6 -0
  123. package/dist/extension/subagents.d.ts +135 -0
  124. package/dist/extension/subagents.js +281 -0
  125. package/dist/extension/surface.d.ts +10 -0
  126. package/dist/extension/surface.js +12 -0
  127. package/dist/extension/todos.d.ts +110 -0
  128. package/dist/extension/todos.js +217 -0
  129. package/dist/extension/tokenProvider.d.ts +93 -0
  130. package/dist/extension/tokenProvider.js +234 -0
  131. package/dist/launch.d.ts +76 -0
  132. package/dist/launch.js +111 -0
  133. package/dist/login.d.ts +45 -0
  134. package/dist/login.js +142 -0
  135. package/dist/logout.d.ts +14 -0
  136. package/dist/logout.js +34 -0
  137. package/dist/paths.d.ts +31 -0
  138. package/dist/paths.js +87 -0
  139. package/dist/piPackage.d.ts +33 -0
  140. package/dist/piPackage.js +71 -0
  141. package/dist/profiles.d.ts +80 -0
  142. package/dist/profiles.js +222 -0
  143. package/dist/refresh.d.ts +70 -0
  144. package/dist/refresh.js +117 -0
  145. package/dist/upgrade.d.ts +97 -0
  146. package/dist/upgrade.js +284 -0
  147. package/package.json +39 -3
@@ -0,0 +1,76 @@
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
+ /** Distribution-specific root shared with the bundled extension. */
63
+ stateDir?: string;
64
+ /** Base environment to extend (defaults to process.env at call sites). */
65
+ baseEnv?: NodeJS.ProcessEnv;
66
+ /** Clock seam for the token-expiry preflight (defaults to Date.now). */
67
+ now?: () => number;
68
+ /**
69
+ * Launcher-computed agent flags (e.g. Claude-compat `--skill` /
70
+ * `--prompt-template` paths). Inserted before the user's passthrough args so
71
+ * anything the user passes explicitly still wins.
72
+ */
73
+ extraAgentArgs?: string[];
74
+ }
75
+ export declare function buildLaunch(creds: Credentials | null, passthroughArgs: string[], opts: BuildLaunchOptions): LaunchPlan;
76
+ //# sourceMappingURL=launch.d.ts.map
package/dist/launch.js ADDED
@@ -0,0 +1,111 @@
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
+ ...(opts.stateDir ? { YAGNI_CODE_HOME: opts.stateDir } : {}),
66
+ // Rebrand pi's chrome ("pi"/"π" → "YAGNI Code"): pi reads piConfig.name from
67
+ // the package at PI_PACKAGE_DIR (a shadow package the launcher generates).
68
+ ...(opts.piPackageDir ? { PI_PACKAGE_DIR: opts.piPackageDir } : {}),
69
+ // Hermetic config: pin pi's agent dir to a YAGNI-owned location so the
70
+ // user's machine-wide ~/.pi/agent (providers, stored keys, saved default
71
+ // model, theme) can't bleed in or bypass the YAGNI proxy. The model scope
72
+ // is then exactly the backend catalog under the `yagni` provider.
73
+ //
74
+ // pi derives the env var NAME from its app name
75
+ // (`${APP_NAME.toUpperCase()}_CODING_AGENT_DIR`), so once rebranded it reads
76
+ // "YAGNI_CODING_AGENT_DIR". We set that key AND the plain
77
+ // PI_CODING_AGENT_DIR as defense-in-depth: if the rebrand ever fails the app
78
+ // name stays "pi", and the plain key keeps the hermetic boundary intact
79
+ // instead of silently falling back to ~/.pi/agent.
80
+ [agentDirEnvVar()]: opts.agentDir,
81
+ PI_CODING_AGENT_DIR: opts.agentDir,
82
+ // Match the e2b harness defaults: skip pi's update check + telemetry.
83
+ PI_SKIP_VERSION_CHECK: "1",
84
+ PI_TELEMETRY: "0",
85
+ };
86
+ // Always load our extension. Default the provider to `yagni` unless the user
87
+ // explicitly chose one (so power users can still point pi elsewhere).
88
+ const userChoseProvider = passthroughArgs.some(arg => arg === "--provider" || arg.startsWith("--provider="));
89
+ // Default the model to the `balanced` tier (YAG-380). Balanced drives on the
90
+ // judgment-grade `advanced` model — the same driver quality this default gave
91
+ // before — and additionally lets the session escalate hard calls to `peak`
92
+ // through the capped ask_advisor tool. So it is strictly additive: no session
93
+ // reasons worse than it did, and the hard ones can reason better. A user
94
+ // `--model` (e.g. `standard` or `efficient`) still wins. Without this, pi's
95
+ // default-model heuristic could land an interactive session on a weaker tier.
96
+ // Detect if the user explicitly set a model, whether via "--model" as a separate
97
+ // argument or using the equals form "--model=efficient". The previous check only
98
+ // caught the separate form, causing a duplicate "--model balanced" to be added
99
+ // when the equals form was used.
100
+ const userChoseModel = passthroughArgs.some(arg => arg === "--model" || arg.startsWith("--model="));
101
+ const argv = [
102
+ "-e",
103
+ opts.extensionPath,
104
+ ...(userChoseProvider ? [] : ["--provider", "yagni"]),
105
+ ...(userChoseModel ? [] : ["--model", "balanced"]),
106
+ ...(opts.extraAgentArgs ?? []),
107
+ ...passthroughArgs,
108
+ ];
109
+ return { env, argv, warnings };
110
+ }
111
+ //# sourceMappingURL=launch.js.map
@@ -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
@@ -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
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Resolve the dependency entry points the launcher needs: pi's CLI and the
3
+ * YAGNI extension. pi is a package dependency, so we resolve it from this module
4
+ * rather than assuming a global install; the extension ships inside our own dist.
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
+ /**
13
+ * Absolute path to the extension's built entry.
14
+ *
15
+ * Published installs read the copy bundled into this package's own
16
+ * `dist/extension/` (see scripts/bundle-extension.mjs) — pi-extension-yagni is a
17
+ * private workspace package and is never on the registry, so module resolution
18
+ * would fail. In the monorepo the bundled copy only exists after a build, so we
19
+ * fall back to resolving the workspace-linked package, which keeps `pnpm test`
20
+ * and a fresh checkout working before anything is bundled.
21
+ */
22
+ export declare function resolveExtensionPath(): string;
23
+ /**
24
+ * Absolute path to pi's package root — the dir whose package.json names the
25
+ * package. The shadow package dir is built from this (we read its package.json
26
+ * and symlink its `dist/`). Uses the same upward walk as resolvePiCliPath.
27
+ */
28
+ export declare function resolvePiPackageDir(): string;
29
+ /** Absolute path to pi's CLI entry (its package.json `bin.pi`). */
30
+ export declare function resolvePiCliPath(): string;
31
+ //# sourceMappingURL=paths.d.ts.map
package/dist/paths.js ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Resolve the dependency entry points the launcher needs: pi's CLI and the
3
+ * YAGNI extension. pi is a package dependency, so we resolve it from this module
4
+ * rather than assuming a global install; the extension ships inside our own dist.
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 { existsSync, 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
+ /**
19
+ * Absolute path to the extension's built entry.
20
+ *
21
+ * Published installs read the copy bundled into this package's own
22
+ * `dist/extension/` (see scripts/bundle-extension.mjs) — pi-extension-yagni is a
23
+ * private workspace package and is never on the registry, so module resolution
24
+ * would fail. In the monorepo the bundled copy only exists after a build, so we
25
+ * fall back to resolving the workspace-linked package, which keeps `pnpm test`
26
+ * and a fresh checkout working before anything is bundled.
27
+ */
28
+ export function resolveExtensionPath() {
29
+ const bundled = fileURLToPath(new URL("./extension/index.js", import.meta.url));
30
+ if (existsSync(bundled))
31
+ return bundled;
32
+ return resolveModulePath("pi-extension-yagni");
33
+ }
34
+ /**
35
+ * Absolute path to pi's package root — the dir whose package.json names the
36
+ * package. The shadow package dir is built from this (we read its package.json
37
+ * and symlink its `dist/`). Uses the same upward walk as resolvePiCliPath.
38
+ */
39
+ export function resolvePiPackageDir() {
40
+ const mainEntry = resolveModulePath("@earendil-works/pi-coding-agent");
41
+ let dir = dirname(mainEntry);
42
+ for (let i = 0; i < 6; i++) {
43
+ try {
44
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
45
+ if (pkg.name === "@earendil-works/pi-coding-agent") {
46
+ return dir;
47
+ }
48
+ }
49
+ catch {
50
+ // No package.json here (or unreadable) — keep walking up.
51
+ }
52
+ const parent = dirname(dir);
53
+ if (parent === dir)
54
+ break;
55
+ dir = parent;
56
+ }
57
+ // Fallback: pi ships its main entry under <root>/dist/, so the root is two
58
+ // levels up from the resolved entry.
59
+ return dirname(dirname(mainEntry));
60
+ }
61
+ /** Absolute path to pi's CLI entry (its package.json `bin.pi`). */
62
+ export function resolvePiCliPath() {
63
+ // Resolves pi's main entry (e.g. <root>/@earendil-works/pi-coding-agent/dist/index.js).
64
+ const mainEntry = resolveModulePath("@earendil-works/pi-coding-agent");
65
+ // Walk up to the package root (the dir whose package.json names the package)
66
+ // and read `bin.pi` from disk.
67
+ let dir = dirname(mainEntry);
68
+ for (let i = 0; i < 6; i++) {
69
+ try {
70
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
71
+ if (pkg.name === "@earendil-works/pi-coding-agent") {
72
+ const binRel = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.pi ?? "dist/cli.js";
73
+ return join(dir, binRel);
74
+ }
75
+ }
76
+ catch {
77
+ // No package.json here (or unreadable) — keep walking up.
78
+ }
79
+ const parent = dirname(dir);
80
+ if (parent === dir)
81
+ break;
82
+ dir = parent;
83
+ }
84
+ // Fallback: pi ships its CLI alongside its main entry under dist/.
85
+ return join(dirname(mainEntry), "cli.js");
86
+ }
87
+ //# 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