@yagni-app/code-staging 1.0.7-staging.1267.1 → 1.0.7-staging.1270.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.
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.0.7-staging.1267.1",
3
+ "version": "1.0.7-staging.1270.1",
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": "594ebbbef9ba1c2233382184dad54d1e81a3bc0f"
61
+ "yagniSourceSha": "7148d4d0a99f32505ec662e0bbd209279306b4b9"
62
62
  }