@yagni-app/code 1.0.7 → 1.0.8

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.
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Shared settings-file plumbing for the three YAGNI Code settings tiers:
3
+ * user ~/.yagni-code/config.json (always trusted)
4
+ * project .yagni-code/config.json under the cwd (shared, committed)
5
+ * local .yagni-code/config.local.json under cwd (personal, gitignored)
6
+ *
7
+ * Owns the atomic config mutation (promoted from sandbox/session.ts, the
8
+ * single copy now) and the global-gitignore helper (Claude Code
9
+ * addFileGlobRuleToGitignore parity): on the first write to the local file,
10
+ * the LOCAL_GITIGNORE_ENTRY glob lands in the GLOBAL git ignore file so
11
+ * personal settings are never committed. Fail-soft by design — a gitignore
12
+ * failure never fails the settings write.
13
+ */
14
+ import { spawn } from "node:child_process";
15
+ import { randomUUID } from "node:crypto";
16
+ import { existsSync, lstatSync, mkdirSync, readFileSync, appendFileSync, writeFileSync, renameSync, chmodSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { dirname, join } from "node:path";
19
+ import { logEvent } from "./errorSink.js";
20
+ const LOCAL_GITIGNORE_ENTRY = "**/.yagni-code/config.local.json";
21
+ function isPlainRecord(v) {
22
+ return typeof v === "object" && v !== null && !Array.isArray(v);
23
+ }
24
+ /**
25
+ * Safe config-file mutation shared by every settings writer:
26
+ * - a PARSE FAILURE aborts (never treats a corrupt/half-written file as
27
+ * empty — that would wipe activeProfile/permissions);
28
+ * - the write is ATOMIC (tmp file + rename) so a crash never truncates;
29
+ * - symlinked targets are rejected (a hostile repo's config symlink must
30
+ * not redirect writes to arbitrary paths);
31
+ * - the mutator runs only on a parsed object; a thrown mutator aborts.
32
+ * Throws on any failure — callers decide fail-soft messaging.
33
+ */
34
+ /**
35
+ * Reject when the target's EXISTING parent directory is a symlink — a
36
+ * hostile repo can commit a symlinked `.yagni-code` DIRECTORY, and every
37
+ * local-settings write would then land outside the repo via the symlinked
38
+ * parent (the final-component lstat guard cannot see this). The parent is
39
+ * the repo-committable component (`.yagni-code` for project/local targets,
40
+ * the state home for user targets); ancestors above it are the user's own
41
+ * filesystem, not repo-controlled. Absent parent is fine — mkdirSync
42
+ * creates it fresh, and mkdir does not follow a symlinked path segment it
43
+ * creates itself. Fail closed.
44
+ */
45
+ function assertRealParentDir(target) {
46
+ const dir = dirname(target);
47
+ try {
48
+ const st = lstatSync(dir);
49
+ if (!st.isDirectory() || st.isSymbolicLink()) {
50
+ throw new Error(`${dir} is not a real directory (symlinked parent?) — refusing to write through it`);
51
+ }
52
+ }
53
+ catch (err) {
54
+ if (err.code === "ENOENT")
55
+ return; // absent — created fresh below
56
+ throw err;
57
+ }
58
+ }
59
+ export function mutateConfigJson(target, mutate) {
60
+ let parsed;
61
+ try {
62
+ parsed = JSON.parse(readFileSync(target, "utf-8"));
63
+ }
64
+ catch (err) {
65
+ if (err.code === "ENOENT") {
66
+ parsed = {}; // genuinely fresh file — fine
67
+ }
68
+ else {
69
+ throw new Error(`config at ${target} is not valid JSON — refusing to rewrite it`);
70
+ }
71
+ }
72
+ if (!isPlainRecord(parsed))
73
+ throw new Error(`config at ${target} is not an object`);
74
+ const config = { ...parsed };
75
+ mutate(config);
76
+ mkdirSync(dirname(target), { recursive: true });
77
+ // Reject symlinked targets: lstat must show a regular file (or absent).
78
+ try {
79
+ const st = lstatSync(target);
80
+ if (!st.isFile())
81
+ throw new Error(`${target} is not a regular file (symlink?) — refusing`);
82
+ }
83
+ catch (err) {
84
+ if (err.code !== "ENOENT")
85
+ throw err;
86
+ }
87
+ // And a symlinked PARENT directory: a committed symlinked .yagni-code
88
+ // dir would redirect the write outside the project — the final-component
89
+ // guard above cannot see it.
90
+ assertRealParentDir(target);
91
+ const tmp = `${target}.yagni-tmp-${process.pid}-${randomUUID().slice(0, 8)}`;
92
+ writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
93
+ // chmod: writeFile honors mode only on create — guarantee 0600 even when a
94
+ // same-pid retry reuses an existing tmp file.
95
+ chmodSync(tmp, 0o600);
96
+ renameSync(tmp, target);
97
+ }
98
+ /** The project-local settings path for a session cwd. */
99
+ export function localConfigPath(cwd) {
100
+ return join(cwd, ".yagni-code", "config.local.json");
101
+ }
102
+ /**
103
+ * Mutate the project-local settings file. Every write fire-and-forgets
104
+ * ensureLocalGitignored (Claude parity: the local tier is added to the
105
+ * global git ignore on write, not on session start). The helper is
106
+ * fire-and-forget BY DESIGN — the write's success notice is not gated on
107
+ * two git spawns; when the gitignore step is skipped or fails, the sink
108
+ * warn (gitignore_skipped / gitignore_update_failed) is the designed
109
+ * signal, surfaced via the diagnostics trail.
110
+ *
111
+ * `sinkSource` attributes those sink events to the caller's surface
112
+ * ("permission-rules" for rule saves, "sandbox" for panel/toggle writes) —
113
+ * the diagnostics trail then says which surface's write failed to get
114
+ * ignore-protected, not a blanket permission-rules line for every write.
115
+ */
116
+ export function mutateLocalConfig(cwd, mutate, sinkSource = "permission-rules") {
117
+ mutateConfigJson(localConfigPath(cwd), mutate);
118
+ void ensureLocalGitignored(cwd, process.env, sinkSource);
119
+ }
120
+ // ---------------------------------------------------------------------------
121
+ // Global-gitignore helper (Claude addFileGlobRuleToGitignore parity)
122
+ // ---------------------------------------------------------------------------
123
+ /** Run git <args> in cwd; resolves exit code (never throws). env threads
124
+ * through so the caller's PATH governs git resolution. */
125
+ function git(cwd, args, env) {
126
+ return new Promise((resolve) => {
127
+ try {
128
+ const child = spawn("git", args, { cwd, env, stdio: "ignore" });
129
+ child.on("error", () => resolve(GIT_NOT_INSTALLED));
130
+ child.on("exit", (code) => resolve(code ?? GIT_NOT_INSTALLED));
131
+ }
132
+ catch {
133
+ resolve(GIT_NOT_INSTALLED);
134
+ }
135
+ });
136
+ }
137
+ const GIT_NOT_INSTALLED = -1;
138
+ /**
139
+ * The global git ignore path git actually reads. Git consults
140
+ * $XDG_CONFIG_HOME/git/ignore (defaulting ~/.config/git/ignore) — honoring
141
+ * XDG is a deliberate divergence from Claude (which hardcodes ~/.config):
142
+ * writing a file git will not read when XDG is set would be a silent no-op.
143
+ */
144
+ export function globalGitignorePath(env = process.env) {
145
+ const xdg = env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.trim() !== "" ? env.XDG_CONFIG_HOME : null;
146
+ return join(xdg ?? join(homedir(), ".config"), "git", "ignore");
147
+ }
148
+ /**
149
+ * Ensure the LOCAL_GITIGNORE_ENTRY glob (covering config.local.json at any
150
+ * depth) is ignored by the user's GLOBAL git config so local settings are
151
+ * never committed. Skip when:
152
+ * - cwd is not inside a git repo (nothing to protect);
153
+ * - `git check-ignore` already matches (local or global patterns cover it);
154
+ * - the global ignore file already carries the literal entry.
155
+ * Any failure logs one warn sink event and returns — the settings write it
156
+ * accompanies has already succeeded and must not be walked back.
157
+ */
158
+ export async function ensureLocalGitignored(cwd, env = process.env, sinkSource = "permission-rules") {
159
+ try {
160
+ const inside = await git(cwd, ["rev-parse", "--is-inside-work-tree"], env);
161
+ // -1 = git missing or failed to spawn — protection silently skipped; a
162
+ // warn makes that visible (the file exists un-ignored). 128 = cleanly
163
+ // not a repo — the documented skip, nothing to surface.
164
+ if (inside === GIT_NOT_INSTALLED) {
165
+ logEvent({
166
+ source: sinkSource,
167
+ level: "warn",
168
+ event: "gitignore_skipped",
169
+ fields: { reason: "git-unavailable" },
170
+ });
171
+ return;
172
+ }
173
+ // Not inside a repo (128) or a failed rev-parse: skip, nothing to do.
174
+ if (inside !== 0)
175
+ return;
176
+ // 0 = already ignored by some pattern (project .gitignore or global).
177
+ if ((await git(cwd, ["check-ignore", ".yagni-code/config.local.json"], env)) === 0)
178
+ return;
179
+ const path = globalGitignorePath(env);
180
+ mkdirSync(dirname(path), { recursive: true });
181
+ if (existsSync(path)) {
182
+ const content = readFileSync(path, "utf-8");
183
+ if (content.includes(LOCAL_GITIGNORE_ENTRY))
184
+ return;
185
+ appendFileSync(path, `${content.endsWith("\n") ? "" : "\n"}${LOCAL_GITIGNORE_ENTRY}\n`);
186
+ }
187
+ else {
188
+ writeFileSync(path, `${LOCAL_GITIGNORE_ENTRY}\n`, { mode: 0o644 });
189
+ }
190
+ }
191
+ catch (err) {
192
+ logEvent({
193
+ source: sinkSource,
194
+ level: "warn",
195
+ event: "gitignore_update_failed",
196
+ fields: {
197
+ path: globalGitignorePath(env),
198
+ // The thrown message names the failing operation and the local
199
+ // filesystem path (mkdir/append of the ignore file) — no secret
200
+ // surface, and it makes EACCES-vs-ENOSPC diagnosable.
201
+ error: err instanceof Error ? err.message : String(err),
202
+ },
203
+ });
204
+ }
205
+ }
206
+ //# sourceMappingURL=settingsFiles.js.map
@@ -37,7 +37,11 @@ export interface SignalConfig {
37
37
  export interface TelemetryIdentity {
38
38
  /** Bound workspace id (YAGNI_WORKSPACE_ID) → `organization.id`. */
39
39
  organizationId?: string;
40
- /** The signed-in user's email when the launcher forwards it → `user.email`. */
40
+ /**
41
+ * The signed-in user's email → `user.email`. From `YAGNI_USER_EMAIL` when
42
+ * the launcher forwards it; otherwise learned from the /context boot fetch
43
+ * (`TelemetryHandle.setUserEmail`). Gated by `includeAccountId`.
44
+ */
41
45
  userEmail?: string;
42
46
  /** Shipping CLI version (YAGNI_CODE_VERSION) → `app.version`. */
43
47
  appVersion?: string;
@@ -27,6 +27,13 @@ export interface TelemetryHandle {
27
27
  toolDecision(input: ToolDecisionInput): void;
28
28
  /** The mode holder calls this on every /mode (or shift-tab) change. */
29
29
  permissionModeChanged(fromMode: string, toMode: string): void;
30
+ /**
31
+ * The entry calls this with the account email the /context boot fetch
32
+ * returned. A launcher-forwarded `YAGNI_USER_EMAIL` wins; otherwise every
33
+ * span, metric, and event from here on carries `user.email` (still subject
34
+ * to the `includeAccountId` gate).
35
+ */
36
+ setUserEmail(email: string | undefined): void;
30
37
  /** Test/introspection seam: the live tracker once the SDK is up. */
31
38
  readonly tracker: SessionTelemetry | null;
32
39
  }
@@ -27,6 +27,7 @@ const NOOP_HANDLE = (config) => ({
27
27
  config,
28
28
  toolDecision: () => { },
29
29
  permissionModeChanged: () => { },
30
+ setUserEmail: () => { },
30
31
  tracker: null,
31
32
  });
32
33
  export function registerTelemetry(pi, deps = {}) {
@@ -187,6 +188,20 @@ export function registerTelemetry(pi, deps = {}) {
187
188
  },
188
189
  toolDecision: guard("tool_decision", (input) => tracker?.toolDecision(input)),
189
190
  permissionModeChanged: guard("permission_mode_changed", (from, to) => tracker?.permissionModeChanged(from, to)),
191
+ // Precedence, lowest to highest: this call (the /context boot fetch) <
192
+ // the launcher's YAGNI_USER_EMAIL, which resolveTelemetryConfig already
193
+ // placed on `identity`. So an identity that is set is never overwritten,
194
+ // whichever writer set it; a new writer must slot into this order
195
+ // explicitly rather than rely on call timing. The tracker reads
196
+ // `config.identity` on every attribute set, so this applies whether the
197
+ // SDK is already up or still lazy-loading.
198
+ setUserEmail: (email) => {
199
+ if (config.identity.userEmail)
200
+ return;
201
+ const trimmed = typeof email === "string" ? email.trim() : "";
202
+ if (trimmed)
203
+ config.identity.userEmail = trimmed;
204
+ },
190
205
  };
191
206
  }
192
207
  //# sourceMappingURL=register.js.map
package/dist/upgrade.js CHANGED
@@ -249,13 +249,22 @@ export function parseUpgradeArgs(args) {
249
249
  }
250
250
  return { ok: true, target, method };
251
251
  }
252
+ // npm's informational channels (deprecation warnings, the funding notice,
253
+ // and npm ≥11.13's `allowScripts` notice) are noise under an upgrade the user
254
+ // explicitly asked for; errors and the added-packages summary stay visible.
255
+ // Note the allowScripts notice is advisory only: with the default (non-strict)
256
+ // policy npm still runs the listed scripts, so quieting it changes nothing
257
+ // functionally. Audit findings go to stdout, unaffected by --loglevel.
258
+ const NPM_QUIET_FLAGS = ["--loglevel=error", "--no-fund", "--no-audit"];
252
259
  function installArgv(method, target) {
253
260
  // brew upgrades to whatever the tap formula publishes; it cannot pin a
254
261
  // version, so `target` only tells us an upgrade is worthwhile.
255
262
  if (method === "brew")
256
263
  return ["upgrade", BREW_FORMULA];
257
264
  const spec = `${PACKAGE_NAME}@${target}`;
258
- return method === "npm" ? ["install", "-g", spec] : ["add", "-g", spec];
265
+ return method === "npm"
266
+ ? ["install", "-g", ...NPM_QUIET_FLAGS, spec]
267
+ : ["add", "-g", spec];
259
268
  }
260
269
  function failureHint(method) {
261
270
  if (method === "brew") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -58,5 +58,5 @@
58
58
  "turndown": "^7.2.4",
59
59
  "typebox": "^1.3.15"
60
60
  },
61
- "yagniSourceSha": "49b4f1be0c81923619b1dd54a977da9653deee1d"
61
+ "yagniSourceSha": "9b213ea65690a4544d0cdf093b6903aab8a1456b"
62
62
  }