@searls/turbocommit 0.11.0 → 0.12.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/lib/git.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const { execSync } = require('child_process')
2
+ const path = require('path')
2
3
 
3
4
  function git (args, opts = {}) {
4
5
  const cwd = opts.cwd || process.cwd()
@@ -17,6 +18,16 @@ function gitRoot (cwd) {
17
18
  }
18
19
  }
19
20
 
21
+ function gitCommonDir (cwd) {
22
+ try {
23
+ const out = git('rev-parse --git-common-dir', { cwd })
24
+ if (!out) return null
25
+ return path.isAbsolute(out) ? out : path.resolve(cwd, out)
26
+ } catch {
27
+ return null
28
+ }
29
+ }
30
+
20
31
  function hasChanges (cwd) {
21
32
  try {
22
33
  git('diff --quiet HEAD', { cwd })
@@ -72,6 +83,7 @@ function esc (s) {
72
83
  module.exports = {
73
84
  git,
74
85
  gitRoot,
86
+ gitCommonDir,
75
87
  hasChanges,
76
88
  addAndCommit,
77
89
  hasCommits,
package/lib/redact.js ADDED
@@ -0,0 +1,156 @@
1
+ const SAFE_NAMES = new Set([
2
+ // Shell / OS
3
+ 'HOME', 'USER', 'SHELL', 'PATH', 'PWD', 'OLDPWD', 'LANG', 'TERM',
4
+ 'EDITOR', 'VISUAL', 'PAGER', 'HOSTNAME', 'LOGNAME', 'DISPLAY', 'TZ',
5
+ 'TMPDIR', 'SHLVL', '_', 'BROWSER', 'COMMAND_MODE', 'COLUMNS', 'LINES',
6
+ 'MANPATH', 'INFOPATH',
7
+
8
+ // Terminal
9
+ 'COLORTERM', 'TERM_PROGRAM', 'TERM_PROGRAM_VERSION', 'TERM_SESSION_ID',
10
+ 'ITERM_SESSION_ID', 'ITERM_PROFILE', 'APPLE_TERMINAL_ID',
11
+
12
+ // Locale
13
+ 'LC_ALL', 'LC_COLLATE', 'LC_CTYPE', 'LC_MESSAGES', 'LC_MONETARY',
14
+ 'LC_NUMERIC', 'LC_TIME',
15
+
16
+ // XDG
17
+ 'XDG_DATA_HOME', 'XDG_CONFIG_HOME', 'XDG_STATE_HOME', 'XDG_CACHE_HOME',
18
+ 'XDG_RUNTIME_DIR', 'XDG_DATA_DIRS', 'XDG_CONFIG_DIRS', 'XDG_SESSION_TYPE',
19
+ 'XDG_CURRENT_DESKTOP', 'XDG_SESSION_CLASS', 'XDG_SESSION_ID', 'XDG_SEAT',
20
+ 'XDG_VTNR', 'XDG_MENU_PREFIX',
21
+
22
+ // Dev environment
23
+ 'NODE_ENV', 'RAILS_ENV', 'RACK_ENV', 'GO_ENV', 'MIX_ENV', 'FLASK_ENV',
24
+ 'DEBUG', 'VERBOSE', 'LOG_LEVEL', 'PORT', 'HOST', 'CI',
25
+ 'NVM_DIR', 'NVM_BIN', 'NVM_INC', 'NVM_CD_FLAGS',
26
+ 'VOLTA_HOME', 'HOMEBREW_PREFIX', 'HOMEBREW_CELLAR', 'HOMEBREW_REPOSITORY',
27
+ 'HOMEBREW_SHELLENV_PREFIX',
28
+
29
+ // SSH / GPG (socket paths, not keys)
30
+ 'SSH_AUTH_SOCK', 'SSH_AGENT_PID', 'GPG_AGENT_INFO', 'GPG_TTY',
31
+
32
+ // Git
33
+ 'GIT_EDITOR', 'GIT_PAGER', 'GIT_TERMINAL_PROMPT'
34
+ ])
35
+
36
+ // Values that are common English words, config toggles, or well-known
37
+ // non-secret defaults. Checked case-insensitively.
38
+ const SAFE_VALUES = new Set([
39
+ // Boolean-like
40
+ 'true', 'false', 'yes', 'no', 'on', 'off',
41
+
42
+ // Environment modes
43
+ 'development', 'production', 'staging', 'test', 'testing',
44
+ 'dev', 'prod', 'local', 'ci', 'qa', 'sandbox', 'preview',
45
+ 'canary', 'demo', 'benchmark', 'profile', 'uat', 'integration',
46
+ 'preprod', 'pre-production', 'stage', 'deployment',
47
+
48
+ // Log levels
49
+ 'trace', 'debug', 'info', 'warn', 'warning', 'error', 'fatal',
50
+ 'critical', 'notice', 'verbose', 'silent', 'quiet', 'off',
51
+
52
+ // Common toggles/modes
53
+ 'enabled', 'disabled', 'always', 'never', 'auto', 'default',
54
+ 'manual', 'none', 'null', 'undefined', 'inherit', 'unset',
55
+ 'strict', 'lax', 'permissive', 'enforcing', 'noninteractive',
56
+
57
+ // Editors, pagers, browsers (bare names)
58
+ 'vim', 'vi', 'nano', 'emacs', 'nvim', 'code', 'subl', 'mate',
59
+ 'micro', 'ed', 'pico', 'gedit', 'kate', 'hx', 'kak', 'kakoune',
60
+ 'joe', 'emacsclient', 'less', 'more', 'most', 'cat',
61
+ 'open', 'firefox', 'chromium', 'links', 'lynx', 'w3m', 'safari', 'opera',
62
+
63
+ // Common English words that appear as env values
64
+ 'allow', 'deny', 'block', 'drop', 'accept', 'reject',
65
+ 'public', 'private', 'master', 'main', 'release',
66
+ 'read', 'write', 'root', 'latest', 'stable', 'edge',
67
+ 'screen', 'random', 'node', 'ruby', 'python', 'go', 'java',
68
+ 'rust', 'php', 'slim', 'alpine',
69
+ 'localhost',
70
+
71
+ // Platform/architecture identifiers
72
+ 'linux', 'darwin', 'win32', 'windows', 'freebsd', 'openbsd',
73
+ 'netbsd', 'sunos', 'aix', 'android', 'cygwin', 'msys',
74
+ 'x86_64', 'amd64', 'x64', 'arm64', 'aarch64', 'i386', 'i686',
75
+ 'ia32', 'armv7l', 'arm', 'ppc64le', 's390x', 'riscv64',
76
+
77
+ // TERM values
78
+ 'xterm', 'xterm-256color', 'xterm-color', 'xterm-16color',
79
+ 'screen', 'screen-256color', 'tmux', 'tmux-256color',
80
+ 'linux', 'vt100', 'vt220', 'dumb', 'ansi', 'rxvt',
81
+ 'rxvt-unicode', 'rxvt-unicode-256color', 'alacritty', 'kitty',
82
+ 'wezterm', 'eterm-color', 'foot', 'foot-extra',
83
+ 'gnome-256color', 'konsole-256color', 'st-256color',
84
+ 'putty', 'putty-256color',
85
+
86
+ // COLORTERM
87
+ 'truecolor', '24bit',
88
+
89
+ // TERM_PROGRAM
90
+ 'apple_terminal', 'iterm.app', 'vscode', 'hyper',
91
+
92
+ // Docker/CI identifiers
93
+ 'bullseye', 'bookworm', 'jammy', 'lts',
94
+ 'travis-ci', 'circleci', 'codeship', 'github-actions',
95
+ 'gitlab-ci', 'jenkins', 'buildkite',
96
+
97
+ // System users
98
+ 'nobody', 'www-data', 'daemon', 'nonroot', 'appuser',
99
+ 'nginx', 'postgres', 'mysql', 'redis',
100
+
101
+ // XDG session types
102
+ 'x11', 'wayland', 'tty',
103
+
104
+ // Metasyntactic / genuinely-never-a-credential values
105
+ 'todo', 'fixme', 'tbd', 'n/a',
106
+ 'xxx', 'yyy', 'zzz', 'foo', 'bar', 'baz',
107
+ 'lorem', 'temp', 'tmp', 'blah'
108
+ ])
109
+
110
+ // Pattern-based rules for values that match known non-secret formats
111
+ const SAFE_VALUE_PATTERNS = [
112
+ /^\d+$/, // pure numeric
113
+ /^(.)\1+$/, // repeated single char (xxxx, ****, ....)
114
+ /^\d+\.\d+\.\d+(-[\w.]+)?$/, // semver (18.17.1, 1.0.0-beta.1)
115
+ /^[a-z]{2}_[A-Z]{2}([.\w-]*)?$/, // locale (en_US.UTF-8)
116
+ /^(C|POSIX)(\.UTF-8)?$/, // C/POSIX locale
117
+ /^[A-Z][a-z]+\/[A-Za-z_]+$/, // timezone (America/New_York)
118
+ /^(UTC|Etc\/UTC|US\/\w+)$/, // timezone shorthand
119
+ /^\/[a-z][\w/.-]*$/, // unix path (/usr/local/bin)
120
+ /^(https?|redis|mongodb|postgres(ql)?|mysql|amqp|smtp|memcached|elasticsearch):\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0|::1)/, // localhost URLs
121
+ /^(xterm|screen|tmux|linux|vt\d+|dumb|ansi|rxvt|alacritty|kitty|wezterm|foot)(-[\w]+)*$/, // TERM identifiers
122
+ /^(localhost)?:\d+(\.\d+)?$/, // X11 DISPLAY (:0, :0.0)
123
+ /^wayland-\d+$/, // Wayland display
124
+ /^sqlite:(\/\/\/)?(:\w+:|[\w./]+)$/ // sqlite URLs
125
+ ]
126
+
127
+ function isSafeValue (value) {
128
+ if (!value || value.length <= 3) return true
129
+ if (SAFE_VALUES.has(value.toLowerCase())) return true
130
+ for (const pattern of SAFE_VALUE_PATTERNS) {
131
+ if (pattern.test(value)) return true
132
+ }
133
+ return false
134
+ }
135
+
136
+ function buildRedactions (env) {
137
+ env = env || process.env
138
+ const entries = []
139
+ for (const [name, value] of Object.entries(env)) {
140
+ if (SAFE_NAMES.has(name)) continue
141
+ if (isSafeValue(value)) continue
142
+ entries.push({ name, value })
143
+ }
144
+ entries.sort((a, b) => b.value.length - a.value.length)
145
+ return entries
146
+ }
147
+
148
+ function redact (text, redactions) {
149
+ if (!text) return text
150
+ for (const { name, value } of redactions) {
151
+ text = text.replaceAll(value, '[REDACTED:' + name + ']')
152
+ }
153
+ return text
154
+ }
155
+
156
+ module.exports = { buildRedactions, redact }
package/lib/run.js CHANGED
@@ -8,6 +8,7 @@ const { gitRoot, hasChanges, addAndCommit, hasCommits, currentBranch, pushClean
8
8
  const { logEvent } = require('./log')
9
9
  const { wrapText } = require('./wrap')
10
10
  const { hasTrackedModifications, cleanupTracking } = require('./track')
11
+ const { redact, buildRedactions } = require('./redact')
11
12
  const { getAncestors, savePending, collectPending, cleanupConsumed, cleanupStale, readWatermark, saveWatermark, resolveParentCommit } = require('./session')
12
13
 
13
14
  /**
@@ -187,9 +188,12 @@ function run (input) {
187
188
  const coauthor = resolveCoauthor(config, hookInput.transcript_path, root)
188
189
  const tag = coauthor ? '\n\n' + coauthor : ''
189
190
 
190
- const sha = addAndCommit(root, headline, wrappedBody + tag)
191
+ const redactions = buildRedactions()
192
+ const safeHeadline = redact(headline, redactions)
193
+ const safeBody = redact(wrappedBody + tag, redactions)
194
+ const sha = addAndCommit(root, safeHeadline, safeBody)
191
195
 
192
- logEvent('success', { project, branch, context, title: headline })
196
+ logEvent('success', { project, branch, context, title: safeHeadline })
193
197
 
194
198
  if (config.push === true) {
195
199
  if (pushClean(root)) {
package/lib/session.js CHANGED
@@ -1,28 +1,36 @@
1
1
  const fs = require('fs')
2
2
  const path = require('path')
3
3
  const { ensureDir } = require('./io')
4
+ const { gitCommonDir } = require('./git')
4
5
 
5
6
  const BREADCRUMB_THRESHOLD_MS = 2000
6
7
  const STALE_TTL_MS = 24 * 60 * 60 * 1000
7
8
 
8
9
  function turbocommitDir (root) {
9
- return path.join(root, '.git', 'turbocommit')
10
+ if (!root) return null
11
+ const gitDir = gitCommonDir(root)
12
+ if (!gitDir) return null
13
+ return path.join(gitDir, 'turbocommit')
10
14
  }
11
15
 
12
16
  function breadcrumbDir (root) {
13
- return path.join(turbocommitDir(root), 'breadcrumbs')
17
+ const base = turbocommitDir(root)
18
+ return base && path.join(base, 'breadcrumbs')
14
19
  }
15
20
 
16
21
  function chainDir (root) {
17
- return path.join(turbocommitDir(root), 'chains')
22
+ const base = turbocommitDir(root)
23
+ return base && path.join(base, 'chains')
18
24
  }
19
25
 
20
26
  function pendingDir (root) {
21
- return path.join(turbocommitDir(root), 'pending')
27
+ const base = turbocommitDir(root)
28
+ return base && path.join(base, 'pending')
22
29
  }
23
30
 
24
31
  function watermarkDir (root) {
25
- return path.join(turbocommitDir(root), 'watermarks')
32
+ const base = turbocommitDir(root)
33
+ return base && path.join(base, 'watermarks')
26
34
  }
27
35
 
28
36
  /**
@@ -42,6 +50,7 @@ function handleSessionEnd (input, root) {
42
50
  if (!sessionId) return
43
51
 
44
52
  const dir = breadcrumbDir(root)
53
+ if (!dir) return
45
54
  ensureDir(dir)
46
55
  const data = { session_id: sessionId, timestamp: Date.now() }
47
56
  fs.writeFileSync(path.join(dir, sessionId + '.json'), JSON.stringify(data) + '\n')
@@ -67,7 +76,7 @@ function handleSessionStart (input, root) {
67
76
  if (source !== 'clear' && source !== 'resume') return
68
77
 
69
78
  const dir = breadcrumbDir(root)
70
- if (!fs.existsSync(dir)) return
79
+ if (!dir || !fs.existsSync(dir)) return
71
80
 
72
81
  // Scan breadcrumbs for closest match
73
82
  const now = Date.now()
@@ -108,6 +117,7 @@ function handleSessionStart (input, root) {
108
117
 
109
118
  // Write chain for this session
110
119
  const cDir = chainDir(root)
120
+ if (!cDir) return
111
121
  ensureDir(cDir)
112
122
  const chain = { parent: best.session_id, ancestors }
113
123
  fs.writeFileSync(path.join(cDir, sessionId + '.json'), JSON.stringify(chain) + '\n')
@@ -134,7 +144,9 @@ function getAncestors (root, sessionId) {
134
144
  */
135
145
  let pendingSeq = 0
136
146
  function savePending (root, sessionId, transcript) {
137
- const dir = path.join(pendingDir(root), sessionId)
147
+ const base = pendingDir(root)
148
+ if (!base) return
149
+ const dir = path.join(base, sessionId)
138
150
  ensureDir(dir)
139
151
  const timestamp = String(Date.now()) + '-' + String(pendingSeq++).padStart(4, '0')
140
152
  fs.writeFileSync(path.join(dir, timestamp + '.txt'), transcript)
@@ -146,8 +158,10 @@ function savePending (root, sessionId, transcript) {
146
158
  */
147
159
  function collectPending (root, sessionIds) {
148
160
  const results = []
161
+ const base = pendingDir(root)
162
+ if (!base) return results
149
163
  for (const sid of sessionIds) {
150
- const dir = path.join(pendingDir(root), sid)
164
+ const dir = path.join(base, sid)
151
165
  let files
152
166
  try {
153
167
  files = fs.readdirSync(dir).sort()
@@ -183,6 +197,7 @@ function readWatermark (root, sessionId) {
183
197
  */
184
198
  function saveWatermark (root, sessionId, pairs, commit) {
185
199
  const dir = watermarkDir(root)
200
+ if (!dir) return
186
201
  ensureDir(dir)
187
202
  fs.writeFileSync(path.join(dir, sessionId + '.json'), JSON.stringify({ pairs, commit }) + '\n')
188
203
  }
@@ -209,9 +224,11 @@ function resolveParentCommit (root, sessionId) {
209
224
  * Delete consumed pending + chain files after commit.
210
225
  */
211
226
  function cleanupConsumed (root, sessionIds) {
227
+ const base = pendingDir(root)
228
+ if (!base) return
212
229
  for (const sid of sessionIds) {
213
230
  // Remove pending dir
214
- const dir = path.join(pendingDir(root), sid)
231
+ const dir = path.join(base, sid)
215
232
  try {
216
233
  const files = fs.readdirSync(dir)
217
234
  for (const file of files) {
@@ -232,6 +249,7 @@ function cleanupStale (root, maxAgeMs) {
232
249
  const ttl = maxAgeMs != null ? maxAgeMs : STALE_TTL_MS
233
250
  const now = Date.now()
234
251
  const base = turbocommitDir(root)
252
+ if (!base) return
235
253
 
236
254
  for (const sub of ['breadcrumbs', 'chains', 'tracking', 'watermarks']) {
237
255
  const dir = path.join(base, sub)
package/lib/track.js CHANGED
@@ -1,16 +1,21 @@
1
1
  const fs = require('fs')
2
2
  const path = require('path')
3
3
  const { ensureDir } = require('./io')
4
+ const { turbocommitDir } = require('./session')
4
5
 
5
6
  /**
6
- * Directory under .git where turbocommit stores tracking state.
7
+ * Directory under the git common dir where turbocommit stores tracking state.
8
+ * Returns null when the root isn't resolvable to a git repo (including when
9
+ * run outside a repo, which is not expected during normal operation).
7
10
  */
8
11
  function trackingDir (root) {
9
- return path.join(root, '.git', 'turbocommit', 'tracking')
12
+ const base = turbocommitDir(root)
13
+ return base && path.join(base, 'tracking')
10
14
  }
11
15
 
12
16
  function trackingPath (root, sessionId) {
13
- return path.join(trackingDir(root), sessionId + '.jsonl')
17
+ const dir = trackingDir(root)
18
+ return dir && path.join(dir, sessionId + '.jsonl')
14
19
  }
15
20
 
16
21
  /**
@@ -72,6 +77,7 @@ function handleTrack (input, root) {
72
77
  if (toolName === 'Bash' && typeof toolInput.command !== 'string') return
73
78
 
74
79
  const file = trackingPath(root, sessionId)
80
+ if (!file) return
75
81
  ensureDir(path.dirname(file))
76
82
  fs.appendFileSync(file, JSON.stringify(entry) + '\n')
77
83
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@searls/turbocommit",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
4
4
  "description": "Auto-commit after every Claude Code turn",
5
5
  "bin": {
6
6
  "turbocommit": "./cli.js"