@retasc/cli 1.36.0 → 1.36.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/CHANGELOG.md CHANGED
@@ -6,6 +6,18 @@ release commits and the issues they reference.
6
6
 
7
7
  Dates are the npm publish date. Each entry names the RTSC issue behind it.
8
8
 
9
+ ## 1.36.1 (2026-08-25)
10
+
11
+ - **RTSC-645** — `retasc gate install` no longer throws away your edits. It rewrites the
12
+ commit-msg hook and the Action on every run, so a gate you had customized (say, one that
13
+ also checks the branch number) used to vanish behind a green "✓ Updated" with nothing
14
+ saying so. Generated files now carry a hash of their own contents, so a later run can tell
15
+ "still exactly what we wrote" from "someone changed this". A changed file is copied to
16
+ `.bak` first, keeping its permissions, and the run tells you where the copy went; a second
17
+ round of edits goes to `.bak.2` rather than overwriting the first. Untouched files are
18
+ replaced silently as before, including when you re-key the gate to a different prefix, so
19
+ the common path gained no prompts and no flags.
20
+
9
21
  ## 1.36.0 (2026-08-25)
10
22
 
11
23
  - **RTSC-520** — the CLI now says when it is out of date. There was no version check
@@ -1,6 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { mkdirSync, writeFileSync, existsSync, chmodSync } from "node:fs";
3
- import { join, dirname } from "node:path";
2
+ import { createHash } from "node:crypto";
3
+ import { mkdirSync, writeFileSync, readFileSync, copyFileSync, statSync, existsSync, chmodSync, } from "node:fs";
4
+ import { join, dirname, relative } from "node:path";
4
5
  import { resolveMcpConn, readMcpJson, workspacePrefix } from "../lib/claim.js";
5
6
  import { claudeLocalRetascEntry } from "../lib/binding.js";
6
7
  import { getBinding } from "../lib/keystore.js";
@@ -163,6 +164,135 @@ function writeFile(path, body, mode) {
163
164
  }
164
165
  }
165
166
  }
167
+ // RTSC-645: never discard someone's edits silently. Both files are rewritten on
168
+ // every run, so a hand-customized gate (e.g. one that also matches the branch
169
+ // number) used to vanish behind a green "✓ Updated". We stamp what we generate
170
+ // with a hash of its own body, so a later run can tell "still exactly what we
171
+ // wrote" from "someone changed this" without keeping any state outside the file.
172
+ //
173
+ // Hashing the body against its OWN stamp — rather than re-rendering the current
174
+ // template and diffing — is what keeps this stable across CLI versions: changing
175
+ // template text would otherwise make every pristine file on every machine look
176
+ // edited and warn on upgrade.
177
+ const STAMP_PREFIX = "# retasc-gate: sha256=";
178
+ const STAMP_RE = /^# retasc-gate: sha256=([0-9a-f]{64})$/;
179
+ /** Compare and hash on normalized text: a CRLF checkout (Git for Windows'
180
+ * autocrlf default) or a stray BOM must not make our own untouched file look
181
+ * edited on every single run. */
182
+ function normalizeText(s) {
183
+ return s.replace(/^/, "").replace(/\r\n/g, "\n");
184
+ }
185
+ function bodyHash(body) {
186
+ return createHash("sha256").update(normalizeText(body), "utf8").digest("hex");
187
+ }
188
+ /** Append the stamp as the final line — after the shebang and after any YAML, so
189
+ * placement never depends on the file's syntax. Both layers take `#` comments. */
190
+ export function withStamp(body) {
191
+ return `${body}${STAMP_PREFIX}${bodyHash(body)}\n`;
192
+ }
193
+ /** Split a stamped file back into (body, recorded hash), or null when the last
194
+ * line isn't one of our stamps. */
195
+ function splitStamp(raw) {
196
+ const trimmed = raw.endsWith("\n") ? raw.slice(0, -1) : raw;
197
+ const cut = trimmed.lastIndexOf("\n");
198
+ const last = cut === -1 ? trimmed : trimmed.slice(cut + 1);
199
+ const m = last.match(STAMP_RE);
200
+ if (!m)
201
+ return null;
202
+ return { body: cut === -1 ? "" : trimmed.slice(0, cut + 1), hash: m[1] };
203
+ }
204
+ /** Classify what's on disk. Anything we can't positively prove we generated —
205
+ * no stamp, a broken stamp, an unreadable file — counts as `modified`, because
206
+ * the cost of a needless backup is a stray file and the cost of the wrong call
207
+ * is someone's work. */
208
+ export function classifyGateFile(path) {
209
+ if (!existsSync(path))
210
+ return "absent";
211
+ let raw;
212
+ try {
213
+ raw = readFileSync(path, "utf8");
214
+ }
215
+ catch {
216
+ return "modified";
217
+ }
218
+ // Normalize BEFORE splitting: on a CRLF checkout the stamp line ends in \r and
219
+ // would never match, so our own untouched file would look edited on every run
220
+ // and pile up a fresh .bak each time.
221
+ const s = splitStamp(normalizeText(raw));
222
+ if (!s)
223
+ return "modified";
224
+ return s.hash === bodyHash(s.body) ? "pristine" : "modified";
225
+ }
226
+ /** Pick where this backup goes. `<path>.bak` when it's free, or already holds
227
+ * exactly these bytes (a repeated run of the same edit shouldn't pile up
228
+ * copies); otherwise the first free `<path>.bak.2`, `.bak.3`, ... Overwriting a
229
+ * backup that holds DIFFERENT bytes would lose the only copy of an earlier
230
+ * edit — someone who edits, installs, edits again and installs again — which is
231
+ * the exact data loss this change exists to stop. */
232
+ function nextBackupPath(path) {
233
+ const first = `${path}.bak`;
234
+ if (!existsSync(first))
235
+ return first;
236
+ // Unreadable source (it classified as `modified` precisely because we couldn't
237
+ // read it): we can't compare, so take a fresh slot rather than crash or clobber.
238
+ let current = null;
239
+ try {
240
+ current = readFileSync(path);
241
+ }
242
+ catch {
243
+ current = null;
244
+ }
245
+ try {
246
+ if (current && readFileSync(first).equals(current))
247
+ return first;
248
+ }
249
+ catch {
250
+ /* unreadable backup: fall through and take a fresh slot rather than clobber it */
251
+ }
252
+ for (let n = 2; n < 100; n++) {
253
+ const candidate = `${path}.bak.${n}`;
254
+ if (!existsSync(candidate))
255
+ return candidate;
256
+ try {
257
+ if (current && readFileSync(candidate).equals(current))
258
+ return candidate;
259
+ }
260
+ catch {
261
+ /* keep looking */
262
+ }
263
+ }
264
+ throw new Error(`refusing to overwrite an existing backup: ${first} through ${path}.bak.99 are all taken — clear the ones you don't need and re-run`);
265
+ }
266
+ /** Copy to the slot `nextBackupPath` picked, preserving the mode so a restored
267
+ * hook is still executable (a `mv` of a 0644 backup would silently stop
268
+ * running). */
269
+ function backupFile(path) {
270
+ const bak = nextBackupPath(path);
271
+ copyFileSync(path, bak);
272
+ try {
273
+ chmodSync(bak, statSync(path).mode & 0o777);
274
+ }
275
+ catch {
276
+ /* best effort (e.g. Windows) */
277
+ }
278
+ return bak;
279
+ }
280
+ /**
281
+ * Write one gate layer, backing up first when the file on disk isn't ours.
282
+ * Returns the line to print, so both layers report identically.
283
+ */
284
+ function writeLayer(root, path, body, mode) {
285
+ const state = classifyGateFile(path);
286
+ const rel = relative(root, path);
287
+ const lines = [];
288
+ if (state === "modified") {
289
+ const bak = backupFile(path);
290
+ lines.push(`⚠ ${rel} had local edits — saved to ${relative(root, bak)} before replacing it.`);
291
+ }
292
+ writeFile(path, withStamp(body), mode);
293
+ lines.push(`✓ ${state === "absent" ? "Wrote" : "Updated"} ${rel}`);
294
+ return lines;
295
+ }
166
296
  /**
167
297
  * Install the commit↔issue gate into the current repo, parameterized by `prefix`.
168
298
  * Writes the requested layers (hook and/or Action) and enables the hook path.
@@ -179,13 +309,14 @@ export function installGate(opts) {
179
309
  if (!hook && !action) {
180
310
  throw new Error("nothing to install — drop --no-hook/--no-action or pick at least one layer");
181
311
  }
312
+ let backedUp = false;
182
313
  if (hook) {
183
314
  const hookPath = join(root, ".githooks", "commit-msg");
184
- const existed = existsSync(hookPath);
185
- writeFile(hookPath, hookTemplate(opts.prefix), 0o755);
186
- const enabled = setHooksPath(root);
187
- console.log(`✓ ${existed ? "Updated" : "Wrote"} commit-msg hook (.githooks/commit-msg).`);
188
- if (enabled) {
315
+ backedUp = classifyGateFile(hookPath) === "modified" || backedUp;
316
+ for (const line of writeLayer(root, hookPath, hookTemplate(opts.prefix), 0o755)) {
317
+ console.log(line);
318
+ }
319
+ if (setHooksPath(root)) {
189
320
  console.log(" Enabled: git config core.hooksPath .githooks");
190
321
  }
191
322
  else {
@@ -194,9 +325,10 @@ export function installGate(opts) {
194
325
  }
195
326
  if (action) {
196
327
  const actionPath = join(root, ".github", "workflows", "check-commit-message.yml");
197
- const existed = existsSync(actionPath);
198
- writeFile(actionPath, actionTemplate(opts.prefix));
199
- console.log(`✓ ${existed ? "Updated" : "Wrote"} GitHub Action (.github/workflows/check-commit-message.yml).`);
328
+ backedUp = classifyGateFile(actionPath) === "modified" || backedUp;
329
+ for (const line of writeLayer(root, actionPath, actionTemplate(opts.prefix))) {
330
+ console.log(line);
331
+ }
200
332
  }
201
333
  console.log("");
202
334
  console.log(`Gate is keyed to prefix "${opts.prefix}" — commits/PRs need ${opts.prefix}-NN or [no-issue].`);
@@ -206,5 +338,8 @@ export function installGate(opts) {
206
338
  if (hook) {
207
339
  console.log("The hook is local fast-feedback only: opt-in per clone (git won't auto-install it) and bypassable with --no-verify.");
208
340
  }
341
+ if (backedUp) {
342
+ console.log("\nA file you had customized was replaced by the standard one. Diff the .bak against it before committing, and re-apply anything you want to keep.");
343
+ }
209
344
  console.log("\nCommit the written files so the gate ships with the repo.");
210
345
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retasc/cli",
3
- "version": "1.36.0",
3
+ "version": "1.36.1",
4
4
  "description": "Retasc CLI — the issue tracker AI agents pull work from. Sign in with GitHub or Google, create projects, mint agent API keys, and wire your agent to the Retasc MCP server in one command.",
5
5
  "type": "module",
6
6
  "bin": {