@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.
- package/README.md +49 -9
- package/dist/__generated__/gateway.d.ts +4546 -0
- package/dist/__generated__/gateway.d.ts.map +1 -0
- package/dist/__generated__/gateway.js +9 -0
- package/dist/__generated__/gateway.js.map +1 -0
- package/dist/__generated__/talent-profile-zod-schemas.d.ts +1187 -0
- package/dist/__generated__/talent-profile-zod-schemas.d.ts.map +1 -0
- package/dist/__generated__/talent-profile-zod-schemas.js +1136 -0
- package/dist/__generated__/talent-profile-zod-schemas.js.map +1 -0
- package/dist/__generated__/talent-profile.d.ts +1397 -0
- package/dist/__generated__/talent-profile.d.ts.map +1 -0
- package/dist/__generated__/talent-profile.js +9 -0
- package/dist/__generated__/talent-profile.js.map +1 -0
- package/dist/__generated__/zod-schemas.d.ts +2895 -0
- package/dist/__generated__/zod-schemas.d.ts.map +1 -0
- package/dist/__generated__/zod-schemas.js +3121 -0
- package/dist/__generated__/zod-schemas.js.map +1 -0
- package/dist/__tests__/fixtures/profile/builders.d.ts +74 -0
- package/dist/__tests__/fixtures/profile/builders.d.ts.map +1 -0
- package/dist/__tests__/fixtures/profile/builders.js +196 -0
- package/dist/__tests__/fixtures/profile/builders.js.map +1 -0
- package/dist/__tests__/fixtures/profile/data.d.ts +39 -0
- package/dist/__tests__/fixtures/profile/data.d.ts.map +1 -0
- package/dist/__tests__/fixtures/profile/data.js +230 -0
- package/dist/__tests__/fixtures/profile/data.js.map +1 -0
- package/dist/__tests__/fixtures/profile/index.d.ts +9 -0
- package/dist/__tests__/fixtures/profile/index.d.ts.map +1 -0
- package/dist/__tests__/fixtures/profile/index.js +10 -0
- package/dist/__tests__/fixtures/profile/index.js.map +1 -0
- package/dist/__tests__/fixtures/profile/types.d.ts +53 -0
- package/dist/__tests__/fixtures/profile/types.d.ts.map +1 -0
- package/dist/__tests__/fixtures/profile/types.js +4 -0
- package/dist/__tests__/fixtures/profile/types.js.map +1 -0
- package/dist/auth/errors.d.ts +82 -0
- package/dist/auth/errors.d.ts.map +1 -0
- package/dist/auth/errors.js +68 -0
- package/dist/auth/errors.js.map +1 -0
- package/dist/auth.d.ts +192 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +294 -0
- package/dist/auth.js.map +1 -0
- package/dist/config.d.ts +212 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +349 -0
- package/dist/config.js.map +1 -0
- package/dist/configLock.d.ts +50 -0
- package/dist/configLock.d.ts.map +1 -0
- package/dist/configLock.js +88 -0
- package/dist/configLock.js.map +1 -0
- package/dist/configWriter.d.ts +97 -0
- package/dist/configWriter.d.ts.map +1 -0
- package/dist/configWriter.js +687 -0
- package/dist/configWriter.js.map +1 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/kill-switch.d.ts +161 -0
- package/dist/kill-switch.d.ts.map +1 -0
- package/dist/kill-switch.js +235 -0
- package/dist/kill-switch.js.map +1 -0
- package/dist/lib/date.d.ts +58 -0
- package/dist/lib/date.d.ts.map +1 -0
- package/dist/lib/date.js +104 -0
- package/dist/lib/date.js.map +1 -0
- package/dist/lib/diagnostic-log.d.ts +159 -0
- package/dist/lib/diagnostic-log.d.ts.map +1 -0
- package/dist/lib/diagnostic-log.js +186 -0
- package/dist/lib/diagnostic-log.js.map +1 -0
- package/dist/lib/package-version.d.ts +19 -0
- package/dist/lib/package-version.d.ts.map +1 -0
- package/dist/lib/package-version.js +38 -0
- package/dist/lib/package-version.js.map +1 -0
- package/dist/lib/redact.d.ts +153 -0
- package/dist/lib/redact.d.ts.map +1 -0
- package/dist/lib/redact.js +207 -0
- package/dist/lib/redact.js.map +1 -0
- package/dist/lib/text.d.ts +14 -0
- package/dist/lib/text.d.ts.map +1 -0
- package/dist/lib/text.js +21 -0
- package/dist/lib/text.js.map +1 -0
- package/dist/lib/wire-shape.d.ts +131 -0
- package/dist/lib/wire-shape.d.ts.map +1 -0
- package/dist/lib/wire-shape.js +376 -0
- package/dist/lib/wire-shape.js.map +1 -0
- package/dist/onepassword.d.ts +29 -0
- package/dist/onepassword.d.ts.map +1 -0
- package/dist/onepassword.js +112 -0
- package/dist/onepassword.js.map +1 -0
- package/dist/services/_shared/transport.d.ts +148 -0
- package/dist/services/_shared/transport.d.ts.map +1 -0
- package/dist/services/_shared/transport.js +102 -0
- package/dist/services/_shared/transport.js.map +1 -0
- package/dist/services/applications/index.d.ts +210 -0
- package/dist/services/applications/index.d.ts.map +1 -0
- package/dist/services/applications/index.js +240 -0
- package/dist/services/applications/index.js.map +1 -0
- package/dist/services/availability/index.d.ts +254 -0
- package/dist/services/availability/index.d.ts.map +1 -0
- package/dist/services/availability/index.js +310 -0
- package/dist/services/availability/index.js.map +1 -0
- package/dist/services/contracts/index.d.ts +132 -0
- package/dist/services/contracts/index.d.ts.map +1 -0
- package/dist/services/contracts/index.js +211 -0
- package/dist/services/contracts/index.js.map +1 -0
- package/dist/services/engagements/index.d.ts +504 -0
- package/dist/services/engagements/index.d.ts.map +1 -0
- package/dist/services/engagements/index.js +613 -0
- package/dist/services/engagements/index.js.map +1 -0
- package/dist/services/jobs/index.d.ts +490 -0
- package/dist/services/jobs/index.d.ts.map +1 -0
- package/dist/services/jobs/index.js +753 -0
- package/dist/services/jobs/index.js.map +1 -0
- package/dist/services/payments/index.d.ts +415 -0
- package/dist/services/payments/index.d.ts.map +1 -0
- package/dist/services/payments/index.js +636 -0
- package/dist/services/payments/index.js.map +1 -0
- package/dist/services/profile/__tests__/fixtures.d.ts +214 -0
- package/dist/services/profile/__tests__/fixtures.d.ts.map +1 -0
- package/dist/services/profile/__tests__/fixtures.js +176 -0
- package/dist/services/profile/__tests__/fixtures.js.map +1 -0
- package/dist/services/profile/basic/index.d.ts +390 -0
- package/dist/services/profile/basic/index.d.ts.map +1 -0
- package/dist/services/profile/basic/index.js +1007 -0
- package/dist/services/profile/basic/index.js.map +1 -0
- package/dist/services/profile/certifications/index.d.ts +74 -0
- package/dist/services/profile/certifications/index.d.ts.map +1 -0
- package/dist/services/profile/certifications/index.js +169 -0
- package/dist/services/profile/certifications/index.js.map +1 -0
- package/dist/services/profile/education/index.d.ts +73 -0
- package/dist/services/profile/education/index.d.ts.map +1 -0
- package/dist/services/profile/education/index.js +168 -0
- package/dist/services/profile/education/index.js.map +1 -0
- package/dist/services/profile/employment/index.d.ts +111 -0
- package/dist/services/profile/employment/index.d.ts.map +1 -0
- package/dist/services/profile/employment/index.js +202 -0
- package/dist/services/profile/employment/index.js.map +1 -0
- package/dist/services/profile/external/index.d.ts +219 -0
- package/dist/services/profile/external/index.d.ts.map +1 -0
- package/dist/services/profile/external/index.js +560 -0
- package/dist/services/profile/external/index.js.map +1 -0
- package/dist/services/profile/index.d.ts +24 -0
- package/dist/services/profile/index.d.ts.map +1 -0
- package/dist/services/profile/index.js +26 -0
- package/dist/services/profile/index.js.map +1 -0
- package/dist/services/profile/industries/index.d.ts +130 -0
- package/dist/services/profile/industries/index.d.ts.map +1 -0
- package/dist/services/profile/industries/index.js +292 -0
- package/dist/services/profile/industries/index.js.map +1 -0
- package/dist/services/profile/portfolio/index.d.ts +352 -0
- package/dist/services/profile/portfolio/index.d.ts.map +1 -0
- package/dist/services/profile/portfolio/index.js +833 -0
- package/dist/services/profile/portfolio/index.js.map +1 -0
- package/dist/services/profile/resume/index.d.ts +60 -0
- package/dist/services/profile/resume/index.d.ts.map +1 -0
- package/dist/services/profile/resume/index.js +212 -0
- package/dist/services/profile/resume/index.js.map +1 -0
- package/dist/services/profile/reviews/index.d.ts +137 -0
- package/dist/services/profile/reviews/index.d.ts.map +1 -0
- package/dist/services/profile/reviews/index.js +431 -0
- package/dist/services/profile/reviews/index.js.map +1 -0
- package/dist/services/profile/shared.d.ts +127 -0
- package/dist/services/profile/shared.d.ts.map +1 -0
- package/dist/services/profile/shared.js +155 -0
- package/dist/services/profile/shared.js.map +1 -0
- package/dist/services/profile/skills/index.d.ts +212 -0
- package/dist/services/profile/skills/index.d.ts.map +1 -0
- package/dist/services/profile/skills/index.js +461 -0
- package/dist/services/profile/skills/index.js.map +1 -0
- package/dist/services/profile/visas/index.d.ts +74 -0
- package/dist/services/profile/visas/index.d.ts.map +1 -0
- package/dist/services/profile/visas/index.js +306 -0
- package/dist/services/profile/visas/index.js.map +1 -0
- package/dist/services/timesheet/index.d.ts +326 -0
- package/dist/services/timesheet/index.d.ts.map +1 -0
- package/dist/services/timesheet/index.js +324 -0
- package/dist/services/timesheet/index.js.map +1 -0
- package/dist/services/translations.d.ts +79 -0
- package/dist/services/translations.d.ts.map +1 -0
- package/dist/services/translations.js +136 -0
- package/dist/services/translations.js.map +1 -0
- package/dist/transport-resilience.d.ts +136 -0
- package/dist/transport-resilience.d.ts.map +1 -0
- package/dist/transport-resilience.js +247 -0
- package/dist/transport-resilience.js.map +1 -0
- package/dist/transport.d.ts +408 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +691 -0
- package/dist/transport.js.map +1 -0
- package/dist/types.d.ts +41 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +18 -0
- package/dist/types.js.map +1 -0
- package/package.json +40 -12
- 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
|