@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 +12 -0
- package/dist/commands/gate.js +145 -10
- package/package.json +1 -1
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
|
package/dist/commands/gate.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
if (
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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.
|
|
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": {
|