@ttctl/core 0.0.0 → 0.1.0-rc.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 (195) hide show
  1. package/README.md +49 -9
  2. package/dist/__generated__/gateway.d.ts +4546 -0
  3. package/dist/__generated__/gateway.d.ts.map +1 -0
  4. package/dist/__generated__/gateway.js +9 -0
  5. package/dist/__generated__/gateway.js.map +1 -0
  6. package/dist/__generated__/talent-profile-zod-schemas.d.ts +1187 -0
  7. package/dist/__generated__/talent-profile-zod-schemas.d.ts.map +1 -0
  8. package/dist/__generated__/talent-profile-zod-schemas.js +1136 -0
  9. package/dist/__generated__/talent-profile-zod-schemas.js.map +1 -0
  10. package/dist/__generated__/talent-profile.d.ts +1397 -0
  11. package/dist/__generated__/talent-profile.d.ts.map +1 -0
  12. package/dist/__generated__/talent-profile.js +9 -0
  13. package/dist/__generated__/talent-profile.js.map +1 -0
  14. package/dist/__generated__/zod-schemas.d.ts +2895 -0
  15. package/dist/__generated__/zod-schemas.d.ts.map +1 -0
  16. package/dist/__generated__/zod-schemas.js +3121 -0
  17. package/dist/__generated__/zod-schemas.js.map +1 -0
  18. package/dist/__tests__/fixtures/profile/builders.d.ts +74 -0
  19. package/dist/__tests__/fixtures/profile/builders.d.ts.map +1 -0
  20. package/dist/__tests__/fixtures/profile/builders.js +196 -0
  21. package/dist/__tests__/fixtures/profile/builders.js.map +1 -0
  22. package/dist/__tests__/fixtures/profile/data.d.ts +39 -0
  23. package/dist/__tests__/fixtures/profile/data.d.ts.map +1 -0
  24. package/dist/__tests__/fixtures/profile/data.js +230 -0
  25. package/dist/__tests__/fixtures/profile/data.js.map +1 -0
  26. package/dist/__tests__/fixtures/profile/index.d.ts +9 -0
  27. package/dist/__tests__/fixtures/profile/index.d.ts.map +1 -0
  28. package/dist/__tests__/fixtures/profile/index.js +10 -0
  29. package/dist/__tests__/fixtures/profile/index.js.map +1 -0
  30. package/dist/__tests__/fixtures/profile/types.d.ts +53 -0
  31. package/dist/__tests__/fixtures/profile/types.d.ts.map +1 -0
  32. package/dist/__tests__/fixtures/profile/types.js +4 -0
  33. package/dist/__tests__/fixtures/profile/types.js.map +1 -0
  34. package/dist/auth/errors.d.ts +82 -0
  35. package/dist/auth/errors.d.ts.map +1 -0
  36. package/dist/auth/errors.js +68 -0
  37. package/dist/auth/errors.js.map +1 -0
  38. package/dist/auth.d.ts +192 -0
  39. package/dist/auth.d.ts.map +1 -0
  40. package/dist/auth.js +294 -0
  41. package/dist/auth.js.map +1 -0
  42. package/dist/config.d.ts +212 -0
  43. package/dist/config.d.ts.map +1 -0
  44. package/dist/config.js +349 -0
  45. package/dist/config.js.map +1 -0
  46. package/dist/configLock.d.ts +50 -0
  47. package/dist/configLock.d.ts.map +1 -0
  48. package/dist/configLock.js +88 -0
  49. package/dist/configLock.js.map +1 -0
  50. package/dist/configWriter.d.ts +97 -0
  51. package/dist/configWriter.d.ts.map +1 -0
  52. package/dist/configWriter.js +687 -0
  53. package/dist/configWriter.js.map +1 -0
  54. package/dist/index.d.ts +37 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.js +28 -0
  57. package/dist/index.js.map +1 -0
  58. package/dist/kill-switch.d.ts +161 -0
  59. package/dist/kill-switch.d.ts.map +1 -0
  60. package/dist/kill-switch.js +235 -0
  61. package/dist/kill-switch.js.map +1 -0
  62. package/dist/lib/date.d.ts +58 -0
  63. package/dist/lib/date.d.ts.map +1 -0
  64. package/dist/lib/date.js +104 -0
  65. package/dist/lib/date.js.map +1 -0
  66. package/dist/lib/diagnostic-log.d.ts +159 -0
  67. package/dist/lib/diagnostic-log.d.ts.map +1 -0
  68. package/dist/lib/diagnostic-log.js +186 -0
  69. package/dist/lib/diagnostic-log.js.map +1 -0
  70. package/dist/lib/package-version.d.ts +19 -0
  71. package/dist/lib/package-version.d.ts.map +1 -0
  72. package/dist/lib/package-version.js +38 -0
  73. package/dist/lib/package-version.js.map +1 -0
  74. package/dist/lib/redact.d.ts +153 -0
  75. package/dist/lib/redact.d.ts.map +1 -0
  76. package/dist/lib/redact.js +207 -0
  77. package/dist/lib/redact.js.map +1 -0
  78. package/dist/lib/text.d.ts +14 -0
  79. package/dist/lib/text.d.ts.map +1 -0
  80. package/dist/lib/text.js +21 -0
  81. package/dist/lib/text.js.map +1 -0
  82. package/dist/lib/wire-shape.d.ts +131 -0
  83. package/dist/lib/wire-shape.d.ts.map +1 -0
  84. package/dist/lib/wire-shape.js +376 -0
  85. package/dist/lib/wire-shape.js.map +1 -0
  86. package/dist/onepassword.d.ts +29 -0
  87. package/dist/onepassword.d.ts.map +1 -0
  88. package/dist/onepassword.js +112 -0
  89. package/dist/onepassword.js.map +1 -0
  90. package/dist/services/_shared/transport.d.ts +148 -0
  91. package/dist/services/_shared/transport.d.ts.map +1 -0
  92. package/dist/services/_shared/transport.js +102 -0
  93. package/dist/services/_shared/transport.js.map +1 -0
  94. package/dist/services/applications/index.d.ts +210 -0
  95. package/dist/services/applications/index.d.ts.map +1 -0
  96. package/dist/services/applications/index.js +240 -0
  97. package/dist/services/applications/index.js.map +1 -0
  98. package/dist/services/availability/index.d.ts +254 -0
  99. package/dist/services/availability/index.d.ts.map +1 -0
  100. package/dist/services/availability/index.js +310 -0
  101. package/dist/services/availability/index.js.map +1 -0
  102. package/dist/services/contracts/index.d.ts +132 -0
  103. package/dist/services/contracts/index.d.ts.map +1 -0
  104. package/dist/services/contracts/index.js +211 -0
  105. package/dist/services/contracts/index.js.map +1 -0
  106. package/dist/services/engagements/index.d.ts +504 -0
  107. package/dist/services/engagements/index.d.ts.map +1 -0
  108. package/dist/services/engagements/index.js +613 -0
  109. package/dist/services/engagements/index.js.map +1 -0
  110. package/dist/services/jobs/index.d.ts +490 -0
  111. package/dist/services/jobs/index.d.ts.map +1 -0
  112. package/dist/services/jobs/index.js +753 -0
  113. package/dist/services/jobs/index.js.map +1 -0
  114. package/dist/services/payments/index.d.ts +415 -0
  115. package/dist/services/payments/index.d.ts.map +1 -0
  116. package/dist/services/payments/index.js +636 -0
  117. package/dist/services/payments/index.js.map +1 -0
  118. package/dist/services/profile/__tests__/fixtures.d.ts +214 -0
  119. package/dist/services/profile/__tests__/fixtures.d.ts.map +1 -0
  120. package/dist/services/profile/__tests__/fixtures.js +176 -0
  121. package/dist/services/profile/__tests__/fixtures.js.map +1 -0
  122. package/dist/services/profile/basic/index.d.ts +390 -0
  123. package/dist/services/profile/basic/index.d.ts.map +1 -0
  124. package/dist/services/profile/basic/index.js +1007 -0
  125. package/dist/services/profile/basic/index.js.map +1 -0
  126. package/dist/services/profile/certifications/index.d.ts +74 -0
  127. package/dist/services/profile/certifications/index.d.ts.map +1 -0
  128. package/dist/services/profile/certifications/index.js +169 -0
  129. package/dist/services/profile/certifications/index.js.map +1 -0
  130. package/dist/services/profile/education/index.d.ts +73 -0
  131. package/dist/services/profile/education/index.d.ts.map +1 -0
  132. package/dist/services/profile/education/index.js +168 -0
  133. package/dist/services/profile/education/index.js.map +1 -0
  134. package/dist/services/profile/employment/index.d.ts +111 -0
  135. package/dist/services/profile/employment/index.d.ts.map +1 -0
  136. package/dist/services/profile/employment/index.js +202 -0
  137. package/dist/services/profile/employment/index.js.map +1 -0
  138. package/dist/services/profile/external/index.d.ts +219 -0
  139. package/dist/services/profile/external/index.d.ts.map +1 -0
  140. package/dist/services/profile/external/index.js +560 -0
  141. package/dist/services/profile/external/index.js.map +1 -0
  142. package/dist/services/profile/index.d.ts +24 -0
  143. package/dist/services/profile/index.d.ts.map +1 -0
  144. package/dist/services/profile/index.js +26 -0
  145. package/dist/services/profile/index.js.map +1 -0
  146. package/dist/services/profile/industries/index.d.ts +130 -0
  147. package/dist/services/profile/industries/index.d.ts.map +1 -0
  148. package/dist/services/profile/industries/index.js +292 -0
  149. package/dist/services/profile/industries/index.js.map +1 -0
  150. package/dist/services/profile/portfolio/index.d.ts +352 -0
  151. package/dist/services/profile/portfolio/index.d.ts.map +1 -0
  152. package/dist/services/profile/portfolio/index.js +833 -0
  153. package/dist/services/profile/portfolio/index.js.map +1 -0
  154. package/dist/services/profile/resume/index.d.ts +60 -0
  155. package/dist/services/profile/resume/index.d.ts.map +1 -0
  156. package/dist/services/profile/resume/index.js +212 -0
  157. package/dist/services/profile/resume/index.js.map +1 -0
  158. package/dist/services/profile/reviews/index.d.ts +137 -0
  159. package/dist/services/profile/reviews/index.d.ts.map +1 -0
  160. package/dist/services/profile/reviews/index.js +431 -0
  161. package/dist/services/profile/reviews/index.js.map +1 -0
  162. package/dist/services/profile/shared.d.ts +127 -0
  163. package/dist/services/profile/shared.d.ts.map +1 -0
  164. package/dist/services/profile/shared.js +155 -0
  165. package/dist/services/profile/shared.js.map +1 -0
  166. package/dist/services/profile/skills/index.d.ts +212 -0
  167. package/dist/services/profile/skills/index.d.ts.map +1 -0
  168. package/dist/services/profile/skills/index.js +461 -0
  169. package/dist/services/profile/skills/index.js.map +1 -0
  170. package/dist/services/profile/visas/index.d.ts +74 -0
  171. package/dist/services/profile/visas/index.d.ts.map +1 -0
  172. package/dist/services/profile/visas/index.js +306 -0
  173. package/dist/services/profile/visas/index.js.map +1 -0
  174. package/dist/services/timesheet/index.d.ts +326 -0
  175. package/dist/services/timesheet/index.d.ts.map +1 -0
  176. package/dist/services/timesheet/index.js +324 -0
  177. package/dist/services/timesheet/index.js.map +1 -0
  178. package/dist/services/translations.d.ts +79 -0
  179. package/dist/services/translations.d.ts.map +1 -0
  180. package/dist/services/translations.js +136 -0
  181. package/dist/services/translations.js.map +1 -0
  182. package/dist/transport-resilience.d.ts +136 -0
  183. package/dist/transport-resilience.d.ts.map +1 -0
  184. package/dist/transport-resilience.js +247 -0
  185. package/dist/transport-resilience.js.map +1 -0
  186. package/dist/transport.d.ts +408 -0
  187. package/dist/transport.d.ts.map +1 -0
  188. package/dist/transport.js +691 -0
  189. package/dist/transport.js.map +1 -0
  190. package/dist/types.d.ts +41 -0
  191. package/dist/types.d.ts.map +1 -0
  192. package/dist/types.js +18 -0
  193. package/dist/types.js.map +1 -0
  194. package/package.json +40 -12
  195. package/index.js +0 -7
@@ -0,0 +1,687 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (C) 2026 Oleksii PELYKH
3
+ import { chmod, lstat, mkdir, open, readFile, rename, stat, unlink } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { dirname, join, resolve } from "node:path";
6
+ import { performance } from "node:perf_hooks";
7
+ import { parseDocument } from "yaml";
8
+ import { ConfigError, ConfigLoadSchema, ConfigWriteSchema } from "./config.js";
9
+ import { acquireConfigLock } from "./configLock.js";
10
+ /**
11
+ * Module-load capture of `TTCTL_DEBUG_CONFIG=1`. Read ONCE so each emit
12
+ * site collapses to a constant-folded `if (DEBUG_ENABLED)` branch in V8 —
13
+ * the env-unset path pays zero runtime overhead (NFR-DEBUG-3 "Wish: 0µs").
14
+ *
15
+ * Tests that exercise both env-set and env-unset paths re-import the
16
+ * module via dynamic `import()` after mutating `process.env`. See
17
+ * `__tests__/persistAuthToken-debug.test.ts`.
18
+ */
19
+ const DEBUG_ENABLED = process.env["TTCTL_DEBUG_CONFIG"] === "1";
20
+ /**
21
+ * Emit one structured debug record (one JSON object per line) to stderr
22
+ * when `TTCTL_DEBUG_CONFIG=1`. Lazy record construction via the
23
+ * `makeRecord` thunk keeps the env-unset path zero-allocation: V8
24
+ * eliminates the dead branch on subsequent JIT passes and the closure
25
+ * body never runs.
26
+ *
27
+ * Output stream is stderr so JSON wire-format on stdout (`-o json`) stays
28
+ * uncontaminated.
29
+ */
30
+ function emitDebug(makeRecord) {
31
+ if (!DEBUG_ENABLED)
32
+ return;
33
+ process.stderr.write(JSON.stringify(makeRecord()) + "\n");
34
+ }
35
+ /**
36
+ * Failure surface for `persistAuthToken` and `clearAuthToken`. Carries the
37
+ * YAML config path AND, for persist-failures-after-signin, the captured
38
+ * bearer token verbatim as a "rescue line" so the operator can manually
39
+ * write it to disk before re-running signin.
40
+ *
41
+ * The bearer is INTENTIONALLY in the message — without it, a write-back
42
+ * failure (read-only filesystem, full disk, kernel I/O error) silently
43
+ * loses the just-acquired session and forces a full re-signin. The cost of
44
+ * a bearer in stderr is bounded: the user sees it in their own terminal,
45
+ * the persist failure is rare, and the recovery is "save this manually".
46
+ */
47
+ export class AuthTokenPersistError extends Error {
48
+ configPath;
49
+ cause;
50
+ bearerRescue;
51
+ name = "AuthTokenPersistError";
52
+ constructor(message, configPath, cause, bearerRescue) {
53
+ super(message);
54
+ this.configPath = configPath;
55
+ this.cause = cause;
56
+ this.bearerRescue = bearerRescue;
57
+ }
58
+ }
59
+ /**
60
+ * Sync-root prefixes that are KNOWN to replicate filesystem state to
61
+ * remote storage (off-host backup, cross-device sync). Persisting a
62
+ * bearer token under one of these paths effectively replicates it. We
63
+ * refuse the write to keep the bearer on-host.
64
+ *
65
+ * Resolved against the user's home directory at runtime — so a config
66
+ * file at `~/Library/Mobile Documents/com~apple~CloudDocs/.ttctl.yaml`
67
+ * is rejected (iCloud Drive on macOS), as is `~/Dropbox/.ttctl.yaml`
68
+ * (Dropbox), `~/iCloud Drive/.ttctl.yaml` (alternate iCloud naming),
69
+ * `~/OneDrive/.ttctl.yaml`, `~/Google Drive/.ttctl.yaml`, `~/Box/.ttctl.yaml`.
70
+ *
71
+ * The list is conservative and curated. Aggressive expansion (e.g. matching
72
+ * any directory named "Sync" or "Backup") would produce false positives;
73
+ * users with bespoke sync setups that we don't catch can rely on direnv
74
+ * + per-project config to keep the file out of replication.
75
+ */
76
+ const SYNC_ROOT_RELATIVE_PREFIXES = [
77
+ join("Library", "Mobile Documents"),
78
+ "Dropbox",
79
+ "iCloud Drive",
80
+ "OneDrive",
81
+ join("Google Drive"),
82
+ "Box",
83
+ "Box Sync",
84
+ ];
85
+ /**
86
+ * Resolve sync-root prefixes against `homedir()` — once per call so that
87
+ * tests redirecting `HOME` see the redirected paths. Returned paths are
88
+ * absolute and resolve trailing slashes via `node:path.resolve`.
89
+ */
90
+ function syncRootPrefixes(home) {
91
+ return SYNC_ROOT_RELATIVE_PREFIXES.map((rel) => resolve(home, rel));
92
+ }
93
+ /**
94
+ * Determine whether `absolutePath` falls under any known sync-root prefix.
95
+ * The check is prefix-based with a path-segment boundary so a sibling like
96
+ * `~/DropboxOther/` does not falsely match `~/Dropbox/`.
97
+ */
98
+ function isUnderSyncRoot(absolutePath, home) {
99
+ const prefixes = syncRootPrefixes(home);
100
+ for (const prefix of prefixes) {
101
+ // Match either the prefix exactly OR the prefix followed by a path separator
102
+ // so siblings like "/home/u/DropboxOther" don't false-positive against "/home/u/Dropbox".
103
+ if (absolutePath === prefix || absolutePath.startsWith(prefix + "/") || absolutePath.startsWith(prefix + "\\")) {
104
+ return prefix;
105
+ }
106
+ }
107
+ return null;
108
+ }
109
+ /**
110
+ * Pre-flight gate run before any read/mutate path. Refuses the operation
111
+ * with `ConfigError(PERMISSION)` when:
112
+ * - The path is a symbolic link (`lstat.isSymbolicLink()`). Closes the
113
+ * attack where an adversary swaps `~/.ttctl.yaml` for a symlink to a
114
+ * world-writable file under their control, redirecting the captured
115
+ * bearer to that file.
116
+ * - The path resolves under a known sync-root prefix. Closes the
117
+ * unintended off-host replication of bearer state.
118
+ *
119
+ * Returns the absolute path on success.
120
+ */
121
+ async function assertSafePath(configPath) {
122
+ const absolute = resolve(configPath);
123
+ // Sync-root check — applies on POSIX (where the prefixes match) and is a
124
+ // no-op on Windows (the relative prefixes don't resolve to the user's
125
+ // OneDrive sync folder reliably; Windows users with OneDrive must use
126
+ // direnv to redirect TTCTL_CONFIG_FILE).
127
+ if (process.platform !== "win32") {
128
+ const prefix = isUnderSyncRoot(absolute, homedir());
129
+ if (prefix !== null) {
130
+ throw new ConfigError(`Refusing to write config under known sync-root ${prefix}: ${absolute}. ` +
131
+ `Sync clients (iCloud/Dropbox/OneDrive/Google Drive/Box) replicate filesystem ` +
132
+ `state off-host; persisting a captured bearer token there would replicate ` +
133
+ `it. Move the config out of the sync root, or set TTCTL_CONFIG_FILE to a ` +
134
+ `path under your home directory.`, "PERMISSION", absolute);
135
+ }
136
+ }
137
+ // Symlink check — only applies if the file already exists. A non-existent
138
+ // path falls through (the open() call below will create it normally).
139
+ try {
140
+ const lstatResult = await lstat(absolute);
141
+ if (lstatResult.isSymbolicLink()) {
142
+ throw new ConfigError(`Refusing to mutate symlinked config file: ${absolute}. ` +
143
+ `A symlink could redirect the captured bearer to an attacker-controlled location. ` +
144
+ `Replace the symlink with a regular file containing the same auth credentials.`, "PERMISSION", absolute);
145
+ }
146
+ }
147
+ catch (err) {
148
+ if (err.code !== "ENOENT") {
149
+ // EACCES, EPERM, etc. — surface as PERMISSION
150
+ if (err instanceof ConfigError)
151
+ throw err;
152
+ throw new ConfigError(`Cannot lstat config file: ${err.message}`, "PERMISSION", absolute);
153
+ }
154
+ }
155
+ return absolute;
156
+ }
157
+ /**
158
+ * Module-private error surfaced by {@link writeAtomicAt0600} on any
159
+ * file-IO failure inside its boundary. Carries a `stage` discriminator
160
+ * (which of the open/write/rename steps failed) AND the underlying
161
+ * `cause` so callers can wrap into their domain error class without
162
+ * losing the original `NodeJS.ErrnoException` (`code: "EACCES"` etc).
163
+ *
164
+ * Caller errors thrown FROM the `preRename` hook propagate as-is — the
165
+ * helper does NOT wrap them. This lets the locked persist path keep
166
+ * raising `AuthTokenPersistError` directly from the mtime-drift refusal.
167
+ */
168
+ class AtomicWriteError extends Error {
169
+ stage;
170
+ tmpPath;
171
+ cause;
172
+ name = "AtomicWriteError";
173
+ constructor(message, stage, tmpPath, cause) {
174
+ super(message);
175
+ this.stage = stage;
176
+ this.tmpPath = tmpPath;
177
+ this.cause = cause;
178
+ }
179
+ }
180
+ /**
181
+ * Atomic-write scaffolding shared by {@link runYamlMutationLocked} (the
182
+ * locked persist/clear path) and {@link writeNewConfig} (the bootstrap
183
+ * path). Encapsulates the dance both callers had inlined verbatim:
184
+ *
185
+ * 1. `mkdir(dirname(absolutePath), { recursive: true })` — parent dir
186
+ * 2. `open(tmpPath, "w", 0o600)` — open temp at mode 0600
187
+ * 3. `await fh.writeFile(content, "utf8"); await fh.sync(); await fh.close()`
188
+ * in nested try/finally (close always runs)
189
+ * 4. `tempfile_written` event
190
+ * 5. Defensive `chmod(tmpPath, 0o600)` (FUSE-tolerant) + optional
191
+ * mode read-back when `onEvent` provided
192
+ * 6. `final_state` event
193
+ * 7. Optional `preRename` hook (mtime drift check or any caller gate)
194
+ * 8. `rename(tmpPath, absolutePath)` — atomic publish
195
+ * 9. `rename_completed` event
196
+ * 10. Parent-dir fsync for power-loss durability (POSIX-only, best-effort)
197
+ *
198
+ * Behaviors deliberately NOT in this helper (because they differ between
199
+ * the two callers): cross-process advisory lock, stat baseline / mtime
200
+ * drift check, `emitDebug()` formatting (the persist path threads events
201
+ * through `onEvent` and constructs its `DebugRecord`-shaped lines there),
202
+ * schema validation, pre-existence + force gate, sync-root / symlink
203
+ * `assertSafePath` (the callers run this once before invoking the helper).
204
+ *
205
+ * Error mapping:
206
+ * - `open()` failure → `AtomicWriteError(stage="open", cause)` (no unlink — nothing to clean)
207
+ * - `write/sync/close` failure → `AtomicWriteError(stage="write", cause)` after best-effort `unlink(tmpPath)`
208
+ * - `preRename` throw → propagates as-is (caller's class), after best-effort `unlink(tmpPath)`
209
+ * - `rename()` failure → `AtomicWriteError(stage="rename", cause)` after best-effort `unlink(tmpPath)`
210
+ *
211
+ * Parent-dir fsync is unconditional best-effort: any failure (FUSE refusal,
212
+ * network FS, missing inode) is swallowed silently. The rename has already
213
+ * succeeded; skipping dir-fsync only weakens power-loss durability.
214
+ */
215
+ async function writeAtomicAt0600(absolutePath, content, opts = {}) {
216
+ const tmpPath = `${absolutePath}.tmp`;
217
+ await mkdir(dirname(absolutePath), { recursive: true });
218
+ // O_WRONLY | O_CREAT | O_TRUNC with mode 0o600. Existing tmp from a
219
+ // prior crashed write is overwritten.
220
+ let fh;
221
+ try {
222
+ fh = await open(tmpPath, "w", 0o600);
223
+ }
224
+ catch (err) {
225
+ throw new AtomicWriteError(`Cannot open temp file ${tmpPath}: ${err.message}`, "open", tmpPath, err);
226
+ }
227
+ const sizeBytes = Buffer.byteLength(content, "utf8");
228
+ // After open(), any throw must best-effort unlink the tmp file. `stage`
229
+ // lets the catch differentiate caller-class throws (preRename) from
230
+ // helper-stage failures that must be wrapped in AtomicWriteError.
231
+ let stage = "write";
232
+ try {
233
+ try {
234
+ await fh.writeFile(content, "utf8");
235
+ await fh.sync();
236
+ }
237
+ finally {
238
+ await fh.close();
239
+ }
240
+ opts.onEvent?.({ type: "tempfile_written", tmpPath, sizeBytes });
241
+ // Defensive re-chmod in case umask filtered the open() mode.
242
+ let modeApplied = "0600";
243
+ if (process.platform !== "win32") {
244
+ try {
245
+ await chmod(tmpPath, 0o600);
246
+ }
247
+ catch {
248
+ // Some FUSE filesystems refuse POSIX perm changes. The rename
249
+ // below still produces a valid file. Don't block here.
250
+ }
251
+ if (opts.onEvent !== undefined) {
252
+ // Read back the actual mode so the trace surfaces filesystem reality
253
+ // (e.g., FUSE-clamped 0644) rather than the intent.
254
+ try {
255
+ const tmpStat = await stat(tmpPath);
256
+ modeApplied = (tmpStat.mode & 0o777).toString(8).padStart(3, "0");
257
+ }
258
+ catch {
259
+ modeApplied = "stat_failed";
260
+ }
261
+ }
262
+ }
263
+ else {
264
+ modeApplied = "n/a";
265
+ }
266
+ opts.onEvent?.({ type: "final_state", modeApplied });
267
+ // preRename hook runs between chmod and rename. Used by the locked
268
+ // persist path to assert mtime hasn't drifted under us. Hook errors
269
+ // propagate as-is (caller's class), bypassing AtomicWriteError wrap.
270
+ if (opts.preRename) {
271
+ stage = "preRename";
272
+ await opts.preRename();
273
+ }
274
+ stage = "rename";
275
+ await rename(tmpPath, absolutePath);
276
+ opts.onEvent?.({ type: "rename_completed" });
277
+ }
278
+ catch (err) {
279
+ // Best-effort cleanup of the temp file if anything failed after open().
280
+ try {
281
+ await unlink(tmpPath);
282
+ }
283
+ catch {
284
+ /* ignore */
285
+ }
286
+ if (stage === "preRename")
287
+ throw err;
288
+ throw new AtomicWriteError(`Atomic write to ${absolutePath} failed at stage '${stage}': ${err.message}`, stage, tmpPath, err);
289
+ }
290
+ // Fsync the parent directory — without this, a power-loss between rename
291
+ // and the next implicit metadata flush can leave the file invisible.
292
+ // POSIX-only; NTFS journaling covers this on Windows.
293
+ if (process.platform !== "win32") {
294
+ try {
295
+ const dirHandle = await open(dirname(absolutePath), "r");
296
+ try {
297
+ await dirHandle.sync();
298
+ }
299
+ finally {
300
+ await dirHandle.close();
301
+ }
302
+ }
303
+ catch {
304
+ // FUSE / network filesystems may refuse fsync on directory handles.
305
+ // Rename succeeded; skipping dir-fsync only weakens power-loss
306
+ // durability, not process-crash atomicity.
307
+ }
308
+ }
309
+ }
310
+ /**
311
+ * Atomic YAML mutation primitive shared by `persistAuthToken` and
312
+ * `clearAuthToken`. The flow:
313
+ *
314
+ * 0. Path safety gate (symlink + sync-root refusal). Throws ConfigError
315
+ * on failure — caller surfaces verbatim. Read-only; does not race.
316
+ * 1. Acquire advisory write-back lock via `acquireConfigLock` (sibling
317
+ * `<path>.lock` directory, atomic `mkdir`-based, cross-platform).
318
+ * Released in `finally` regardless of error path. Lock contention
319
+ * timeout is ≤1s — never blocks indefinitely.
320
+ * 2. Stat baseline — captures mtime so a concurrent overwrite can be
321
+ * detected before we commit our changes. The lock prevents another
322
+ * ttctl process from racing here, but the mtime check is preserved
323
+ * as a second line: a lock-disregarding writer (e.g., manual editor
324
+ * save) is still caught.
325
+ * 3. Read raw YAML; apply mutation via `parseDocument` + `setIn` /
326
+ * `deleteIn` (surgical mutation preserves comments and key order).
327
+ * 4. Validate the post-mutation shape against ConfigWriteSchema (catches
328
+ * pre-existing schema violations before we write a new file that
329
+ * LOCKS those violations in place).
330
+ * 5. Write to `<path>.tmp` at mode 0600, fsync, defensive chmod 0600,
331
+ * then concurrency re-check (statSync mtime vs baseline) — refuse
332
+ * and unlink temp if mtime moved.
333
+ * 6. Atomic rename → final path. Fsync parent dir for crash durability.
334
+ */
335
+ async function performYamlMutation(configPath, mutation) {
336
+ const op = mutation.kind === "set-token" ? "persist" : "clear";
337
+ const absolute = await assertSafePath(configPath);
338
+ // `prewrite_checks` reflects the gates assertSafePath enforces (symlink
339
+ // refusal, sync-root refusal). World-writable refusal lives in
340
+ // `loadConfigFile` (read-time, not write-time); `perm_ok` here is `true`
341
+ // by construction since persist always runs against a config the caller
342
+ // just loaded successfully. The field is preserved for symmetry with the
343
+ // design table even though its value is constant in this code path.
344
+ emitDebug(() => ({
345
+ ts: new Date().toISOString(),
346
+ op,
347
+ path: absolute,
348
+ event: "prewrite_checks",
349
+ symlink_ok: true,
350
+ syncroot_ok: true,
351
+ perm_ok: true,
352
+ }));
353
+ // Acquire lock BEFORE the stat baseline so a concurrent writer can't
354
+ // race us between the stat and the rename. The lock target is a sibling
355
+ // `<absolute>.lock` directory (NOT a self-lock on the config file) —
356
+ // self-locking is broken under our atomic-rename pattern because rename
357
+ // swaps the inode, leaving any flock() on the old inode unenforced
358
+ // against the post-rename writer.
359
+ const lockStart = performance.now();
360
+ const lock = await acquireConfigLock(absolute);
361
+ const lockAcquiredAt = performance.now();
362
+ emitDebug(() => ({
363
+ ts: new Date().toISOString(),
364
+ op,
365
+ path: absolute,
366
+ event: "lock_acquired",
367
+ wait_ms: lockAcquiredAt - lockStart,
368
+ }));
369
+ try {
370
+ await runYamlMutationLocked(absolute, mutation, op);
371
+ }
372
+ catch (err) {
373
+ // Emit BEFORE the finally so the error event lands in the trace
374
+ // before the corresponding lock_released. error_message is the throw
375
+ // message verbatim — bearer-rescue lives in
376
+ // `AuthTokenPersistError.bearerRescue` (separate field) and is NOT
377
+ // included in `.message`, so this is bearer-safe by construction.
378
+ emitDebug(() => ({
379
+ ts: new Date().toISOString(),
380
+ op,
381
+ path: absolute,
382
+ event: "error",
383
+ error_class: err.name || "Error",
384
+ error_message: err.message,
385
+ lock_held_at_error: true,
386
+ }));
387
+ throw err;
388
+ }
389
+ finally {
390
+ // Best-effort release. If the release itself fails, signal-exit (a
391
+ // proper-lockfile transitive dep) registers an exit-handler that
392
+ // auto-cleans the lock on process death. We log nothing here — a
393
+ // failed release is recoverable on the next run.
394
+ try {
395
+ await lock.release();
396
+ }
397
+ catch {
398
+ /* ignore */
399
+ }
400
+ emitDebug(() => ({
401
+ ts: new Date().toISOString(),
402
+ op,
403
+ path: absolute,
404
+ event: "lock_released",
405
+ duration_ms: performance.now() - lockAcquiredAt,
406
+ }));
407
+ }
408
+ }
409
+ /**
410
+ * Inner write/rename/fsync flow, run with the advisory lock already held.
411
+ * Split out from `performYamlMutation` so the lock-acquire/release `try`
412
+ * boundary is visually obvious (no tangled exception bookkeeping). Every
413
+ * error path inside this function unwinds through the outer `finally` →
414
+ * lock release.
415
+ */
416
+ async function runYamlMutationLocked(absolute, mutation, op) {
417
+ // Stat baseline — used to detect concurrent overwrites.
418
+ let beforeMtime;
419
+ let beforeMode;
420
+ try {
421
+ const beforeStat = await stat(absolute);
422
+ beforeMtime = beforeStat.mtimeMs;
423
+ beforeMode = beforeStat.mode & 0o777;
424
+ }
425
+ catch (err) {
426
+ if (err.code !== "ENOENT") {
427
+ throw new AuthTokenPersistError(`Cannot stat config file at ${absolute}: ${err.message}`, absolute, err, mutation.rescueBearer);
428
+ }
429
+ // ENOENT — file doesn't exist yet. Caller bug: persistAuthToken should
430
+ // never fire on a path with no preexisting config (signin must have
431
+ // resolved a real config). Fail clearly rather than silently creating
432
+ // a new file that the user didn't author.
433
+ throw new AuthTokenPersistError(`Cannot ${mutation.kind === "set-token" ? "persist token to" : "clear token from"} ` +
434
+ `non-existent config file: ${absolute}. ` +
435
+ `Author a config first (see README § Configuration) and re-run signin.`, absolute, err, mutation.rescueBearer);
436
+ }
437
+ emitDebug(() => ({
438
+ ts: new Date().toISOString(),
439
+ op,
440
+ path: absolute,
441
+ event: "stat_baseline",
442
+ mtime_ms: beforeMtime,
443
+ mode_octal: beforeMode.toString(8).padStart(3, "0"),
444
+ }));
445
+ let raw;
446
+ try {
447
+ raw = await readFile(absolute, "utf8");
448
+ }
449
+ catch (err) {
450
+ throw new AuthTokenPersistError(`Cannot read config file at ${absolute}: ${err.message}`, absolute, err, mutation.rescueBearer);
451
+ }
452
+ // Surgical mutation — preserves comments, key order, and scalar styles
453
+ // (e.g. quoted vs unquoted op:// strings) per DQ-2 in the design doc.
454
+ // `strict: false` permits anchors / unknown YAML features without
455
+ // throwing; the schema validation below catches actual shape errors.
456
+ const doc = parseDocument(raw, { strict: false });
457
+ if (doc.errors.length > 0) {
458
+ throw new AuthTokenPersistError(`Cannot parse config file at ${absolute}: ${doc.errors[0]?.message ?? "unknown YAML error"}`, absolute, undefined, mutation.rescueBearer);
459
+ }
460
+ mutation.apply(doc);
461
+ // Validate the post-mutation shape. Catches pre-existing config drift
462
+ // BEFORE we lock it into a new file. ConfigWriteSchema is permissive so
463
+ // a transient empty `auth: {}` is acceptable mid-lifecycle.
464
+ const postMutation = doc.toJS();
465
+ const validation = ConfigWriteSchema.safeParse(postMutation);
466
+ if (!validation.success) {
467
+ const summary = validation.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
468
+ throw new AuthTokenPersistError(`Config write validation failed: ${summary}. Refusing to write malformed config to ${absolute}.`, absolute, undefined, mutation.rescueBearer);
469
+ }
470
+ const updated = String(doc);
471
+ try {
472
+ await writeAtomicAt0600(absolute, updated, {
473
+ onEvent: (event) => {
474
+ switch (event.type) {
475
+ case "tempfile_written":
476
+ emitDebug(() => ({
477
+ ts: new Date().toISOString(),
478
+ op,
479
+ path: absolute,
480
+ event: "tempfile_written",
481
+ temp_path: event.tmpPath ?? "",
482
+ size_bytes: event.sizeBytes ?? 0,
483
+ }));
484
+ return;
485
+ case "final_state":
486
+ emitDebug(() => ({
487
+ ts: new Date().toISOString(),
488
+ op,
489
+ path: absolute,
490
+ event: "final_state",
491
+ mode_applied: event.modeApplied ?? "unknown",
492
+ }));
493
+ return;
494
+ case "rename_completed":
495
+ emitDebug(() => ({
496
+ ts: new Date().toISOString(),
497
+ op,
498
+ path: absolute,
499
+ event: "rename_completed",
500
+ }));
501
+ return;
502
+ }
503
+ },
504
+ preRename: async () => {
505
+ // Concurrency re-check — refuse if the source moved underneath us
506
+ // (mid-session backup-restore or a lock-disregarding writer like
507
+ // a manual editor save). The cross-process lock catches sibling
508
+ // ttctl processes; mtime drift catches anything else.
509
+ const after = await stat(absolute);
510
+ const mtimeDelta = after.mtimeMs - beforeMtime;
511
+ const driftPass = after.mtimeMs === beforeMtime;
512
+ emitDebug(() => ({
513
+ ts: new Date().toISOString(),
514
+ op,
515
+ path: absolute,
516
+ event: "mtime_drift_check",
517
+ delta_ms: mtimeDelta,
518
+ pass: driftPass,
519
+ }));
520
+ if (!driftPass) {
521
+ throw new AuthTokenPersistError(`Config file at ${absolute} was modified concurrently (mtime drift detected). ` +
522
+ `Refusing to overwrite. Re-run the command after verifying the file's current ` +
523
+ `contents are what you expect.`, absolute, undefined, mutation.rescueBearer);
524
+ }
525
+ },
526
+ });
527
+ }
528
+ catch (err) {
529
+ if (err instanceof AuthTokenPersistError)
530
+ throw err;
531
+ if (err instanceof AtomicWriteError) {
532
+ const message = err.stage === "open"
533
+ ? `Cannot open temp file ${err.tmpPath}: ${err.cause.message}`
534
+ : `Failed to write config file at ${absolute}: ${err.cause.message}`;
535
+ throw new AuthTokenPersistError(message, absolute, err.cause, mutation.rescueBearer);
536
+ }
537
+ throw new AuthTokenPersistError(`Failed to write config file at ${absolute}: ${err.message}`, absolute, err, mutation.rescueBearer);
538
+ }
539
+ }
540
+ /**
541
+ * Persist a captured bearer token to the SAME YAML config file the user
542
+ * authored. Surgically mutates `auth.token` via `yaml.parseDocument` +
543
+ * `setIn` to preserve comments, key order, and scalar styles. Atomic on
544
+ * POSIX (temp + rename + fsync).
545
+ *
546
+ * Path safety gates fire BEFORE the read: symlinks and sync-root paths are
547
+ * refused with `ConfigError(PERMISSION)` (not `AuthTokenPersistError`)
548
+ * because they reflect static filesystem state, not a transient I/O
549
+ * failure. Callers route both error classes through the same user-visible
550
+ * "signin couldn't persist the token" message.
551
+ *
552
+ * On any I/O failure after the bearer was captured, the error message
553
+ * carries the bearer verbatim as a "rescue line" — the operator can copy
554
+ * it into the config manually rather than redoing signin.
555
+ *
556
+ * Cross-process write-back is serialized by an advisory sibling lockfile
557
+ * (`<configPath>.lock`, atomic-mkdir-based via `proper-lockfile`). Two
558
+ * concurrent ttctl processes (e.g., CLI signin AND a long-running MCP
559
+ * tool call) cannot interleave their write-paths — one waits up to 1s and
560
+ * sees the other's committed file, then proceeds with its own
561
+ * read/parse/mutate/write cycle on the up-to-date contents. On contention
562
+ * timeout, throws `ConfigError(LOCKED)` — never blocks indefinitely.
563
+ *
564
+ * The mtime-drift check inside the locked region is the second line of
565
+ * defense for lock-disregarding writers (manual editor save during a
566
+ * persist window) — those still get caught and refused.
567
+ */
568
+ export async function persistAuthToken(configPath, token) {
569
+ if (token === "") {
570
+ // signOut removes the field; an empty-string token is a caller bug.
571
+ throw new AuthTokenPersistError(`Refusing to persist empty token to ${configPath}. To remove the token, call clearAuthToken instead.`, configPath);
572
+ }
573
+ await performYamlMutation(configPath, {
574
+ kind: "set-token",
575
+ apply: (doc) => {
576
+ doc.setIn(["auth", "token"], doc.createNode(token));
577
+ },
578
+ rescueBearer: token,
579
+ });
580
+ }
581
+ /**
582
+ * Remove the `auth.token` field from the YAML config file. Distinct from
583
+ * "set token to empty string" — `deleteIn` removes the field entirely so
584
+ * the next load sees Form A/B (credentials only) or Form C/Empty
585
+ * depending on what remains.
586
+ *
587
+ * Per security-architect's Stage 2 input: post-signout state must NEVER
588
+ * be `auth: { token: "" }` — empty-string is data and could be replayed
589
+ * as a (malformed) bearer. Removal is the only safe shape.
590
+ */
591
+ export async function clearAuthToken(configPath) {
592
+ await performYamlMutation(configPath, {
593
+ kind: "clear-token",
594
+ apply: (doc) => {
595
+ doc.deleteIn(["auth", "token"]);
596
+ },
597
+ });
598
+ }
599
+ /**
600
+ * Write a freshly-authored config file to `configPath`.
601
+ *
602
+ * Sibling primitive to `performYamlMutation` for the bootstrap case
603
+ * (`ttctl auth init`): no pre-existing file is read or mutated — the YAML
604
+ * content is supplied verbatim by the caller from interactive prompts. The
605
+ * same atomic / safety invariants apply:
606
+ *
607
+ * - Path safety gate (`assertSafePath`) — refuses sync-roots and
608
+ * pre-existing symlinks at `configPath`. A non-existent path is
609
+ * accepted (the whole point of init).
610
+ * - Pre-existence gate — refuses unless `opts.force` is true. The
611
+ * refusal is `ConfigError(PERMISSION)` so CLI/MCP error renderers
612
+ * route it through the same exit-code + message contract as the
613
+ * other write-path failures.
614
+ * - Schema validation — the supplied content is parsed and validated
615
+ * against `ConfigLoadSchema` BEFORE we touch disk. The strict load
616
+ * schema catches malformed YAML composed by an interactive flow
617
+ * (e.g. quoting bug) before we lock it in place.
618
+ * - Atomic write — temp at `0o600`, fsync, defensive chmod, rename,
619
+ * fsync(parent dir). Same dance as `persistAuthToken`'s temp path so
620
+ * a crashed init never leaves a partial config.
621
+ *
622
+ * On `force` overwrite: the existing file is replaced atomically; the
623
+ * mtime-drift guard from `performYamlMutation` does NOT apply here because
624
+ * the bootstrap operation explicitly asks for replacement. Symlink and
625
+ * sync-root refusals still fire.
626
+ *
627
+ * Parent directories are created with `mkdir -p` semantics (the bootstrap
628
+ * is allowed to create its own `~/.ttctl.yaml` even if that means creating
629
+ * a fresh home — though in practice the parent is always `homedir()` which
630
+ * already exists; the recursive flag is for `--config <path>` cases that
631
+ * specify a not-yet-existing project dir).
632
+ */
633
+ export async function writeNewConfig(configPath, content, opts) {
634
+ const absolute = await assertSafePath(configPath);
635
+ // Pre-existence gate. `lstat` (via assertSafePath) only refuses on
636
+ // SYMLINK; a regular-file existence is permitted by the safety check
637
+ // and must be gated separately by `force` here.
638
+ let exists = false;
639
+ try {
640
+ await stat(absolute);
641
+ exists = true;
642
+ }
643
+ catch (err) {
644
+ if (err.code !== "ENOENT") {
645
+ throw new ConfigError(`Cannot stat config file: ${err.message}`, "PERMISSION", absolute);
646
+ }
647
+ }
648
+ if (exists && !opts.force) {
649
+ throw new ConfigError(`Refusing to overwrite existing config at ${absolute}. ` + `Use --force to replace, or edit the file manually.`, "PERMISSION", absolute);
650
+ }
651
+ // Validate the supplied YAML against the strict load schema BEFORE
652
+ // writing. Catches malformed shapes early so we never persist a config
653
+ // that the next `ttctl` invocation will reject at load time.
654
+ let parsedContent;
655
+ try {
656
+ const doc = parseDocument(content, { strict: false });
657
+ if (doc.errors.length > 0) {
658
+ throw new ConfigError(`Cannot parse generated config content: ${doc.errors[0]?.message ?? "unknown YAML error"}`, "VALIDATION", absolute);
659
+ }
660
+ parsedContent = doc.toJS();
661
+ }
662
+ catch (err) {
663
+ if (err instanceof ConfigError)
664
+ throw err;
665
+ throw new ConfigError(`Cannot parse generated config content: ${err.message}`, "VALIDATION", absolute);
666
+ }
667
+ const validation = ConfigLoadSchema.safeParse(parsedContent);
668
+ if (!validation.success) {
669
+ const summary = validation.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
670
+ throw new ConfigError(`Generated config failed validation: ${summary}. Refusing to write malformed config to ${absolute}.`, "VALIDATION", absolute);
671
+ }
672
+ try {
673
+ await writeAtomicAt0600(absolute, content);
674
+ }
675
+ catch (err) {
676
+ if (err instanceof ConfigError)
677
+ throw err;
678
+ if (err instanceof AtomicWriteError) {
679
+ const message = err.stage === "open"
680
+ ? `Cannot open temp file ${err.tmpPath}: ${err.cause.message}`
681
+ : `Failed to write config file at ${absolute}: ${err.cause.message}`;
682
+ throw new ConfigError(message, "PERMISSION", absolute);
683
+ }
684
+ throw new ConfigError(`Failed to write config file at ${absolute}: ${err.message}`, "PERMISSION", absolute);
685
+ }
686
+ }
687
+ //# sourceMappingURL=configWriter.js.map