@indigo-gr/claude-code-station 0.3.3 → 0.5.0

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,431 @@
1
+ /**
2
+ * ccs-config-edit.ts — the one way ccs writes repos.yml.
3
+ *
4
+ * `ccs repo add`, `ccs repo remove`, `ccs config set` and the append of
5
+ * `ccs init --auto-discover` all go through editSteps(): lock, read and check
6
+ * the current file, let the caller change the parsed document, write it in
7
+ * place with a `.bak` of the previous text, check it again, and put the old
8
+ * text back when that check fails. Contract: docs/design/machine-interface.md
9
+ * chapter 3.
10
+ *
11
+ * The steps are a generator so the same code runs two ways: straight through
12
+ * (runEditSync, for ccs init) and with a pause at each step where a SIGINT
13
+ * can land (runEditAsync, for the machine commands: Node runs a signal
14
+ * handler only between ticks, never inside synchronous code). An interrupt
15
+ * after the write puts the old text back before the lock is dropped.
16
+ */
17
+
18
+ import {
19
+ closeSync,
20
+ existsSync,
21
+ mkdirSync,
22
+ openSync,
23
+ readFileSync,
24
+ rmdirSync,
25
+ statSync,
26
+ unlinkSync,
27
+ writeFileSync,
28
+ writeSync,
29
+ } from "node:fs";
30
+ import { dirname } from "node:path";
31
+ import { parseDocument, stringify, type Document } from "yaml";
32
+
33
+ import {
34
+ ConfigError,
35
+ getPaths,
36
+ parseConfigSource,
37
+ type CcsConfig,
38
+ type ConfigWarning,
39
+ } from "./ccs-config.ts";
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Stops
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /** Reason words an edit can stop with (a subset of the ccs.cli vocabulary). */
46
+ export type EditCode =
47
+ | "config_missing"
48
+ | "config_not_readable"
49
+ | "config_invalid"
50
+ | "config_changed"
51
+ | "config_locked"
52
+ | "write_failed"
53
+ | "validation_after_write_failed"
54
+ | "restore_failed"
55
+ | "interrupted"
56
+ // the callers' own input checks
57
+ | "missing_argument"
58
+ | "extra_argument"
59
+ | "path_outside_home"
60
+ | "path_not_found"
61
+ | "path_not_directory"
62
+ | "path_taken"
63
+ | "name_taken"
64
+ | "unsafe_characters"
65
+ | "needs_quoting"
66
+ | "invalid_value"
67
+ | "key_not_settable"
68
+ | "last_entry"
69
+ | "flow_style_repos"
70
+ | "repo_not_found";
71
+
72
+ /**
73
+ * Why an edit stopped. `message` is for people and may name the file; the
74
+ * machine commands print only the code (and `configError`'s word).
75
+ */
76
+ export class EditStop extends Error {
77
+ readonly code: EditCode;
78
+ /** config_invalid: the check that failed. */
79
+ readonly configError: ConfigError | undefined;
80
+ constructor(code: EditCode, message: string, configError?: ConfigError) {
81
+ super(message);
82
+ this.name = "EditStop";
83
+ this.code = code;
84
+ this.configError = configError;
85
+ }
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Value checks shared by the writing commands
90
+ // ---------------------------------------------------------------------------
91
+
92
+ // C0, DEL, C1 and the marks that reorder displayed text (same set as
93
+ // ccs-sanitize's stripControlChars).
94
+ const CONTROL_CHAR_RE = /[\x00-\x1f\x7f-\x9f؜‎‏‪-‮⁦-⁩]/;
95
+
96
+ export function hasControlChars(value: string): boolean {
97
+ return CONTROL_CHAR_RE.test(value);
98
+ }
99
+
100
+ /** Length in code points (a surrogate pair counts once). */
101
+ export function codePointLength(value: string): number {
102
+ return [...value].length;
103
+ }
104
+
105
+ /**
106
+ * True when YAML would quote this value (1.6): `2024`, `true`, `~`, `a: b`,
107
+ * `#x`, a leading space. Tools that read repos.yml line by line take the text
108
+ * after `name:` / `path:` as is, so ccs writes those two only when they need
109
+ * no quotes.
110
+ */
111
+ export function needsQuoting(value: string): boolean {
112
+ return stringify(value, { lineWidth: 0 }) !== value + "\n";
113
+ }
114
+
115
+ /** The YAML node of `repos:` is a one-line list (`repos: [ … ]`). */
116
+ export function reposIsFlow(doc: Document): boolean {
117
+ const repos = doc.get("repos", true) as { flow?: boolean } | undefined;
118
+ return Boolean(repos && typeof repos === "object" && repos.flow);
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // The lock
123
+ // ---------------------------------------------------------------------------
124
+
125
+ /** A lock older than this is a crashed writer's; it is taken over. */
126
+ const LOCK_STALE_MS = 10_000;
127
+
128
+ /** Take `<repos.yml>.lock` (O_EXCL); returns the release, or throws EditStop. */
129
+ function acquireLock(lockPath: string): () => void {
130
+ for (let attempt = 0; attempt < 2; attempt++) {
131
+ try {
132
+ const fd = openSync(lockPath, "wx", 0o600);
133
+ try {
134
+ writeSync(fd, `${process.pid} ${new Date().toISOString()}\n`);
135
+ } finally {
136
+ closeSync(fd);
137
+ }
138
+ return () => {
139
+ try {
140
+ unlinkSync(lockPath);
141
+ } catch {
142
+ // already gone
143
+ }
144
+ };
145
+ } catch (err) {
146
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") {
147
+ throw new EditStop("write_failed", `cannot create ${lockPath}: ${(err as Error).message}`);
148
+ }
149
+ try {
150
+ const st = statSync(lockPath);
151
+ if (Date.now() - st.mtimeMs <= LOCK_STALE_MS) break;
152
+ unlinkSync(lockPath); // stale — take over
153
+ } catch {
154
+ // vanished between the two calls — try the create again
155
+ }
156
+ }
157
+ }
158
+ throw new EditStop("config_locked", "another ccs is writing repos.yml (config_locked); try again");
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // The steps
163
+ // ---------------------------------------------------------------------------
164
+
165
+ export interface EditContext {
166
+ /** The parsed document to change in place. */
167
+ doc: Document;
168
+ /** The current config after ccs's checks; null when the file is new. */
169
+ config: CcsConfig | null;
170
+ /** The text read in step 2 (empty when the file is new). */
171
+ source: string;
172
+ }
173
+
174
+ export interface EditOptions {
175
+ /** repos.yml to edit (default: the XDG location). */
176
+ reposYml?: string;
177
+ /**
178
+ * When repos.yml does not exist: the first text to edit (the file and its
179
+ * directory are created, 0600 / 0700). Without it a missing file stops
180
+ * with config_missing.
181
+ */
182
+ createWith?: string;
183
+ /**
184
+ * Change ctx.doc. Throw EditStop to refuse (nothing is written). Return
185
+ * false when nothing needs to change (nothing is written either).
186
+ */
187
+ transform: (ctx: EditContext) => boolean | void;
188
+ /** Test hook: replaces the check after the write. */
189
+ validateAfterWrite?: (text: string, reposYml: string) => void;
190
+ /** Test hook: replaces the write that puts the old text back. */
191
+ restore?: (reposYml: string, text: string) => void;
192
+ }
193
+
194
+ export interface EditResult {
195
+ changed: boolean;
196
+ createdConfig: boolean;
197
+ /** The config after the edit (before it, when nothing changed). */
198
+ config: CcsConfig;
199
+ warnings: ConfigWarning[];
200
+ }
201
+
202
+ /** Options toString() needs to keep lines the user wrote as they are. */
203
+ const TO_STRING = { lineWidth: 0, flowCollectionPadding: false } as const;
204
+
205
+ function check(text: string, reposYml: string, warnings?: ConfigWarning[]): CcsConfig {
206
+ return parseConfigSource(text, reposYml, (w) => warnings?.push(w), false);
207
+ }
208
+
209
+ /**
210
+ * The edit, one yield per point where an interrupt may stop it. Throws
211
+ * EditStop. Drive it with runEditSync or runEditAsync.
212
+ */
213
+ export function* editSteps(opts: EditOptions): Generator<void, EditResult, void> {
214
+ const reposYml = opts.reposYml ?? getPaths().reposYml;
215
+ let creating = false;
216
+ // The first directory mkdir created (taken back when nothing was written).
217
+ let madeDir: string | undefined;
218
+ if (!existsSync(reposYml)) {
219
+ if (opts.createWith === undefined) {
220
+ throw new EditStop("config_missing", "no repos.yml yet (config_missing); `ccs init` creates one");
221
+ }
222
+ creating = true;
223
+ try {
224
+ madeDir = mkdirSync(dirname(reposYml), { recursive: true, mode: 0o700 });
225
+ } catch (err) {
226
+ throw new EditStop("write_failed", `cannot create the config directory: ${(err as Error).message}`);
227
+ }
228
+ }
229
+ const dropMadeDir = (): void => {
230
+ if (!madeDir || existsSync(reposYml)) return;
231
+ // Innermost first; rmdir refuses a directory that is not empty.
232
+ for (let d = dirname(reposYml); d.length >= madeDir.length; d = dirname(d)) {
233
+ try {
234
+ rmdirSync(d);
235
+ } catch {
236
+ break;
237
+ }
238
+ }
239
+ };
240
+
241
+ // 1. lock
242
+ let release: () => void;
243
+ try {
244
+ release = acquireLock(reposYml + ".lock");
245
+ } catch (err) {
246
+ dropMadeDir();
247
+ throw err;
248
+ }
249
+ let wrote = false;
250
+ let settled = false; // the old text is back (or never left)
251
+ let source = "";
252
+ const putBack = (): void => {
253
+ if (!wrote || settled) return;
254
+ if (creating) {
255
+ unlinkSync(reposYml);
256
+ } else if (opts.restore) {
257
+ opts.restore(reposYml, source);
258
+ } else {
259
+ writeFileSync(reposYml, source, { mode: 0o600 });
260
+ }
261
+ settled = true;
262
+ };
263
+
264
+ try {
265
+ yield;
266
+
267
+ // 2. read and check what is there now
268
+ let config: CcsConfig | null = null;
269
+ const warnings: ConfigWarning[] = [];
270
+ if (creating) {
271
+ if (existsSync(reposYml)) {
272
+ throw new EditStop("config_changed", "repos.yml appeared while ccs was about to create it (config_changed); run again");
273
+ }
274
+ source = opts.createWith ?? "";
275
+ } else {
276
+ try {
277
+ source = readFileSync(reposYml, "utf-8");
278
+ } catch (err) {
279
+ throw new EditStop("config_not_readable", `cannot read repos.yml (${(err as Error).message})`);
280
+ }
281
+ try {
282
+ config = check(source, reposYml, warnings);
283
+ } catch (err) {
284
+ if (err instanceof ConfigError) {
285
+ throw new EditStop("config_invalid", err.message, err);
286
+ }
287
+ throw err;
288
+ }
289
+ }
290
+ yield;
291
+
292
+ // 3–4. the caller's checks and change
293
+ const doc = parseDocument(source, { logLevel: "error" });
294
+ const result = opts.transform({ doc, config, source: creating ? "" : source });
295
+ const text = doc.toString(TO_STRING);
296
+ if (result === false || (!creating && text === source)) {
297
+ return { changed: false, createdConfig: false, config: config as CcsConfig, warnings };
298
+ }
299
+ yield;
300
+
301
+ // 5. changed under us since step 2?
302
+ if (creating ? existsSync(reposYml) : readCurrent(reposYml) !== source) {
303
+ throw new EditStop("config_changed", "repos.yml changed while ccs was editing it (config_changed); run again");
304
+ }
305
+
306
+ // 6. backup (one generation, like ccs init)
307
+ if (!creating) {
308
+ try {
309
+ writeFileSync(reposYml + ".bak", source, { mode: 0o600 });
310
+ } catch (err) {
311
+ throw new EditStop("write_failed", `cannot write repos.yml.bak (${(err as Error).message}); repos.yml unchanged`);
312
+ }
313
+ }
314
+
315
+ // 7. write in place: a symlinked repos.yml stays a symlink
316
+ try {
317
+ wrote = true;
318
+ writeFileSync(reposYml, text, { mode: 0o600 });
319
+ } catch (err) {
320
+ try {
321
+ putBack();
322
+ } catch {
323
+ throw new EditStop("restore_failed", restoreHint(reposYml, creating));
324
+ }
325
+ throw new EditStop("write_failed", `cannot write repos.yml (${(err as Error).message}); left as it was`);
326
+ }
327
+ yield;
328
+
329
+ // 8. check what was written
330
+ const after: ConfigWarning[] = [];
331
+ let written: CcsConfig;
332
+ try {
333
+ if (opts.validateAfterWrite) opts.validateAfterWrite(text, reposYml);
334
+ written = check(readCurrent(reposYml) ?? "", reposYml, after);
335
+ } catch (err) {
336
+ try {
337
+ putBack();
338
+ } catch {
339
+ throw new EditStop("restore_failed", restoreHint(reposYml, creating));
340
+ }
341
+ throw new EditStop(
342
+ "validation_after_write_failed",
343
+ `the written repos.yml failed ccs's checks, so the previous text was put back: ${err instanceof Error ? err.message : String(err)}`,
344
+ err instanceof ConfigError ? err : undefined,
345
+ );
346
+ }
347
+ yield;
348
+ settled = true;
349
+ return { changed: true, createdConfig: creating, config: written, warnings: after };
350
+ } catch (err) {
351
+ // An interrupt (or anything unexpected) after the write: old text back.
352
+ if (wrote && !settled) {
353
+ try {
354
+ putBack();
355
+ } catch {
356
+ throw new EditStop("restore_failed", restoreHint(reposYml, creating));
357
+ }
358
+ }
359
+ throw err;
360
+ } finally {
361
+ release();
362
+ dropMadeDir();
363
+ }
364
+ }
365
+
366
+ function readCurrent(path: string): string | null {
367
+ try {
368
+ return readFileSync(path, "utf-8");
369
+ } catch {
370
+ return null;
371
+ }
372
+ }
373
+
374
+ function restoreHint(reposYml: string, creating: boolean): string {
375
+ return creating
376
+ ? "the new repos.yml failed ccs's checks and could not be removed (restore_failed); delete it by hand"
377
+ : `could not put the previous repos.yml back (restore_failed); it is in ${reposYml}.bak — copy that file over repos.yml`;
378
+ }
379
+
380
+ /** Run the steps straight through (no interrupt points). Throws EditStop. */
381
+ export function runEditSync(opts: EditOptions): EditResult {
382
+ const gen = editSteps(opts);
383
+ let step = gen.next();
384
+ while (!step.done) step = gen.next();
385
+ return step.value;
386
+ }
387
+
388
+ /**
389
+ * Milliseconds to wait at each step (test hook: lets a test send SIGINT in
390
+ * the middle of an edit). 0–10000; anything else is ignored.
391
+ */
392
+ function testPauseMs(): number {
393
+ const raw = process.env.CCS_TEST_EDIT_PAUSE_MS ?? "";
394
+ if (!/^[0-9]{1,5}$/.test(raw)) return 0;
395
+ const n = Number(raw);
396
+ return n <= 10_000 ? n : 0;
397
+ }
398
+
399
+ /**
400
+ * Run the steps with a pause at each one where SIGINT can arrive. On SIGINT
401
+ * the edit is undone (the old text back, the lock dropped) and EditStop
402
+ * `interrupted` is thrown.
403
+ */
404
+ export async function runEditAsync(opts: EditOptions): Promise<EditResult> {
405
+ let interrupted = false;
406
+ let wake: (() => void) | null = null;
407
+ const onSigint = (): void => {
408
+ interrupted = true;
409
+ wake?.();
410
+ };
411
+ process.on("SIGINT", onSigint);
412
+ const pause = testPauseMs();
413
+ const gen = editSteps(opts);
414
+ try {
415
+ let step = gen.next();
416
+ while (!step.done) {
417
+ await new Promise<void>((resolve) => {
418
+ wake = resolve;
419
+ if (pause > 0) setTimeout(resolve, pause);
420
+ else setImmediate(resolve);
421
+ });
422
+ wake = null;
423
+ step = interrupted
424
+ ? gen.throw(new EditStop("interrupted", "interrupted; repos.yml left as it was"))
425
+ : gen.next();
426
+ }
427
+ return step.value;
428
+ } finally {
429
+ process.off("SIGINT", onSigint);
430
+ }
431
+ }