teleportation-cli 1.3.0 → 1.4.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.
@@ -0,0 +1,233 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { mkdir, readFile, writeFile } from 'fs/promises';
4
+ import { spawn } from 'child_process';
5
+ import { fileURLToPath } from 'url';
6
+ import { homedir, hostname } from 'os';
7
+ import { dirname, join, resolve } from 'path';
8
+ import { loadConfig } from '../config/manager.js';
9
+ import { getRepoSyncContext } from './repo-context.js';
10
+
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = dirname(__filename);
13
+ const TELEPORTATION_DIR = process.env.TELEPORTATION_DIR || resolve(__dirname, '..', '..');
14
+ const SYNC_SCRIPT = join(TELEPORTATION_DIR, 'scripts', 'sync-transcripts.sh');
15
+ const STATE_PATH = join(homedir(), '.teleportation', 'transcript-sync-state.json');
16
+
17
+ function nowIso() {
18
+ return new Date().toISOString();
19
+ }
20
+
21
+ function defaultMachineId() {
22
+ return (hostname() || 'unknown-machine').split('.')[0];
23
+ }
24
+
25
+ async function writeState(state) {
26
+ await mkdir(join(homedir(), '.teleportation'), { recursive: true });
27
+ await writeFile(STATE_PATH, JSON.stringify(state, null, 2), { mode: 0o600 });
28
+ }
29
+
30
+ async function readState() {
31
+ try {
32
+ const content = await readFile(STATE_PATH, 'utf8');
33
+ return JSON.parse(content);
34
+ } catch {
35
+ return {};
36
+ }
37
+ }
38
+
39
+ async function loadSyncConfig() {
40
+ const config = await loadConfig();
41
+ const ts = config.transcriptSync || {};
42
+ const repoContext = getRepoSyncContext(TELEPORTATION_DIR);
43
+
44
+ return {
45
+ enabled: ts.enabled !== false,
46
+ intervalMs: typeof ts.intervalMs === 'number' ? ts.intervalMs : 300000,
47
+ machineId: defaultMachineId(),
48
+ mirrorDir: repoContext.mirrorDir,
49
+ mode: ts.mode || 'local',
50
+ peer: repoContext.peer,
51
+ peerMirrorDir: repoContext.peerMirrorDir,
52
+ repoKey: repoContext.repoKey
53
+ };
54
+ }
55
+
56
+ function buildSyncArgs(syncConfig) {
57
+ const args = [
58
+ '--machine-id', syncConfig.machineId,
59
+ '--mirror-dir', syncConfig.mirrorDir,
60
+ '--mode', syncConfig.mode
61
+ ];
62
+
63
+ if (syncConfig.peer) {
64
+ args.push('--peer', syncConfig.peer);
65
+ }
66
+
67
+ if (syncConfig.peerMirrorDir) {
68
+ args.push('--peer-mirror-dir', syncConfig.peerMirrorDir);
69
+ }
70
+
71
+ return args;
72
+ }
73
+
74
+ async function runSync(syncConfig) {
75
+ if (!syncConfig.enabled) {
76
+ return { skipped: true, reason: 'disabled' };
77
+ }
78
+
79
+ const args = buildSyncArgs(syncConfig);
80
+
81
+ return await new Promise((resolvePromise) => {
82
+ const child = spawn(SYNC_SCRIPT, args, {
83
+ stdio: ['ignore', 'pipe', 'pipe'],
84
+ env: process.env
85
+ });
86
+ activeChild = child;
87
+
88
+ let stdout = '';
89
+ let stderr = '';
90
+
91
+ child.stdout.on('data', (chunk) => {
92
+ stdout += chunk.toString();
93
+ });
94
+ child.stderr.on('data', (chunk) => {
95
+ stderr += chunk.toString();
96
+ });
97
+
98
+ child.on('error', (error) => {
99
+ activeChild = null;
100
+ resolvePromise({
101
+ code: 1,
102
+ stdout,
103
+ stderr: `${stderr}\n${String(error?.message || error)}`
104
+ });
105
+ });
106
+
107
+ child.on('close', (code) => {
108
+ activeChild = null;
109
+ resolvePromise({
110
+ code: code ?? 1,
111
+ stdout,
112
+ stderr
113
+ });
114
+ });
115
+ });
116
+ }
117
+
118
+ let stopping = false;
119
+ let timer = null;
120
+ let activeChild = null;
121
+
122
+ async function tick() {
123
+ if (stopping) return;
124
+
125
+ const startedAt = nowIso();
126
+ let syncConfig = null;
127
+
128
+ try {
129
+ syncConfig = await loadSyncConfig();
130
+ const result = await runSync(syncConfig);
131
+ const previousState = await readState();
132
+ const hasRsyncErrors = /rsync\([^)]+\): error:/i.test(result.stderr || '');
133
+ const success = !!result.skipped || (result.code === 0 && !hasRsyncErrors);
134
+ const state = {
135
+ ...previousState,
136
+ running: true,
137
+ lastRunAt: startedAt,
138
+ lastSuccessAt: success ? nowIso() : (previousState.lastSuccessAt || null),
139
+ lastErrorAt: success ? (previousState.lastErrorAt || null) : nowIso(),
140
+ lastExitCode: result.code ?? 0,
141
+ lastMode: syncConfig.mode,
142
+ lastPeer: syncConfig.peer,
143
+ machineId: syncConfig.machineId,
144
+ repoKey: syncConfig.repoKey,
145
+ enabled: syncConfig.enabled,
146
+ skipped: !!result.skipped,
147
+ skipReason: result.reason || null,
148
+ lastOutput: (result.stdout || '').slice(-4000),
149
+ lastError: (result.stderr || '').slice(-4000)
150
+ };
151
+ await writeState(state);
152
+ } catch (error) {
153
+ const previousState = await readState();
154
+ await writeState({
155
+ ...previousState,
156
+ running: true,
157
+ lastRunAt: startedAt,
158
+ lastSuccessAt: previousState.lastSuccessAt || null,
159
+ lastErrorAt: nowIso(),
160
+ lastExitCode: 1,
161
+ enabled: syncConfig?.enabled ?? true,
162
+ machineId: syncConfig?.machineId ?? defaultMachineId(),
163
+ repoKey: syncConfig?.repoKey ?? null,
164
+ lastMode: syncConfig?.mode ?? 'local',
165
+ lastPeer: syncConfig?.peer ?? null,
166
+ skipped: false,
167
+ skipReason: null,
168
+ lastOutput: '',
169
+ lastError: String(error?.message || error)
170
+ });
171
+ } finally {
172
+ let nextIntervalMs = 300000;
173
+ try {
174
+ if (syncConfig && typeof syncConfig.intervalMs === 'number' && syncConfig.intervalMs >= 10000) {
175
+ nextIntervalMs = syncConfig.intervalMs;
176
+ } else {
177
+ const latestConfig = await loadSyncConfig();
178
+ if (typeof latestConfig.intervalMs === 'number' && latestConfig.intervalMs >= 10000) {
179
+ nextIntervalMs = latestConfig.intervalMs;
180
+ }
181
+ }
182
+ } catch (error) {
183
+ const previousState = await readState();
184
+ await writeState({
185
+ ...previousState,
186
+ running: true,
187
+ lastErrorAt: nowIso(),
188
+ lastExitCode: 1,
189
+ lastError: `Failed to reload transcript sync config: ${String(error?.message || error)}`
190
+ });
191
+ }
192
+ if (!stopping) {
193
+ timer = setTimeout(tick, nextIntervalMs);
194
+ }
195
+ }
196
+ }
197
+
198
+ async function shutdown(signal) {
199
+ stopping = true;
200
+ if (timer) {
201
+ clearTimeout(timer);
202
+ timer = null;
203
+ }
204
+ if (activeChild && !activeChild.killed) {
205
+ try {
206
+ activeChild.kill('SIGTERM');
207
+ } catch {
208
+ // Ignore child shutdown errors during teardown.
209
+ }
210
+ }
211
+
212
+ await writeState({
213
+ running: false,
214
+ stoppedAt: nowIso(),
215
+ stopSignal: signal
216
+ });
217
+ process.exit(0);
218
+ }
219
+
220
+ process.on('SIGTERM', () => {
221
+ shutdown('SIGTERM').catch(() => process.exit(0));
222
+ });
223
+ process.on('SIGINT', () => {
224
+ shutdown('SIGINT').catch(() => process.exit(0));
225
+ });
226
+
227
+ await writeState({
228
+ running: true,
229
+ startedAt: nowIso(),
230
+ pid: process.pid
231
+ });
232
+
233
+ await tick();
@@ -81,6 +81,71 @@ export function sanitizeForLog(text) {
81
81
  return sanitized;
82
82
  }
83
83
 
84
+ /**
85
+ * Fields in event data objects that may contain user-generated text with secrets.
86
+ * Used by sanitizeEventData() to know which fields to sanitize.
87
+ */
88
+ export const SANITIZE_TEXT_FIELDS = ['message', 'summary', 'thinking', 'result', 'error', 'stdout', 'stderr', 'content'];
89
+
90
+ // Keys whose values should always be redacted regardless of value content
91
+ const SENSITIVE_KEY_RE = /^(password|passwd|pwd|secret|token|api[_-]?key|authorization|credentials?)$/i;
92
+
93
+ /**
94
+ * Recursively sanitize all string values in an object/array.
95
+ * Preserves object structure (unlike JSON stringify/regex/parse which can corrupt JSON).
96
+ * Also redacts values for keys that match sensitive names (password, secret, token, etc.).
97
+ *
98
+ * @param {*} obj - Value to sanitize
99
+ * @param {Function} fn - Text sanitization function
100
+ * @param {string} [parentKey] - The key name from the parent object (for key-based redaction)
101
+ * @returns {*} - Sanitized value with structure preserved
102
+ */
103
+ function sanitizeObjectValues(obj, fn, parentKey) {
104
+ if (typeof obj === 'string') {
105
+ // If the parent key is sensitive (e.g. "password"), redact the value entirely
106
+ if (parentKey && SENSITIVE_KEY_RE.test(parentKey)) return '***';
107
+ return fn(obj);
108
+ }
109
+ if (typeof obj !== 'object' || obj === null) return obj;
110
+ if (Array.isArray(obj)) return obj.map(item => sanitizeObjectValues(item, fn));
111
+ const result = {};
112
+ for (const [k, v] of Object.entries(obj)) {
113
+ result[k] = sanitizeObjectValues(v, fn, k);
114
+ }
115
+ return result;
116
+ }
117
+
118
+ /**
119
+ * Sanitize event data to remove sensitive information before storing in timeline.
120
+ * Applies sanitizeForLog to known text fields and tool_input (string or object).
121
+ *
122
+ * @param {Object} eventData - The event data object
123
+ * @param {Function} [sanitizer=sanitizeForLog] - Text sanitization function
124
+ * @returns {Object} - Sanitized event data (shallow copy)
125
+ */
126
+ export function sanitizeEventData(eventData, sanitizer) {
127
+ if (!eventData || typeof eventData !== 'object') return eventData;
128
+
129
+ const fn = sanitizer || sanitizeForLog;
130
+ const sanitized = { ...eventData };
131
+
132
+ for (const field of SANITIZE_TEXT_FIELDS) {
133
+ if (typeof sanitized[field] === 'string') {
134
+ sanitized[field] = fn(sanitized[field]);
135
+ }
136
+ }
137
+
138
+ if (sanitized.tool_input) {
139
+ if (typeof sanitized.tool_input === 'string') {
140
+ sanitized.tool_input = fn(sanitized.tool_input);
141
+ } else if (typeof sanitized.tool_input === 'object') {
142
+ sanitized.tool_input = sanitizeObjectValues(sanitized.tool_input, fn);
143
+ }
144
+ }
145
+
146
+ return sanitized;
147
+ }
148
+
84
149
  /**
85
150
  * Truncates a string to a maximum length and adds ellipsis if needed
86
151
  * @param {string} text - The text to truncate
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teleportation-cli",
3
- "version": "1.3.0",
3
+ "version": "1.4.1",
4
4
  "description": "Remote approval system for Claude Code - approve AI coding changes from your phone",
5
5
  "type": "module",
6
6
  "main": "teleportation-cli.cjs",
@@ -55,6 +55,7 @@
55
55
  "!lib/**/*.log",
56
56
  ".claude/hooks/*.mjs",
57
57
  "!.claude/hooks/*.test.mjs",
58
+ "scripts/sync-transcripts.sh",
58
59
  "teleportation-cli.cjs",
59
60
  "README.md",
60
61
  "LICENSE"
@@ -0,0 +1,272 @@
1
+ #!/usr/bin/env bash
2
+
3
+ set -euo pipefail
4
+
5
+ usage() {
6
+ cat <<'USAGE'
7
+ Usage:
8
+ ./scripts/sync-transcripts.sh [options]
9
+
10
+ Description:
11
+ Mirrors local AI CLI transcripts into a normalized local mirror and optionally
12
+ syncs mirror data to/from a peer machine over ssh+rsync.
13
+
14
+ Options:
15
+ --machine-id ID Machine identifier for local mirror folder.
16
+ Default: short hostname
17
+ --mirror-dir PATH Local mirror root.
18
+ Default: $HOME/.teleportation/transcript-mirror
19
+ --peer HOST Peer in ssh format (user@host or host).
20
+ --peer-mirror-dir PATH Peer mirror root path.
21
+ Default: same as --mirror-dir
22
+ --mode MODE Sync mode: local|push|pull|bidirectional
23
+ Default: local
24
+ --dry-run Print actions without writing files.
25
+ -h, --help Show this help text.
26
+
27
+ Examples:
28
+ ./scripts/sync-transcripts.sh
29
+ ./scripts/sync-transcripts.sh --machine-id mac-mini
30
+ ./scripts/sync-transcripts.sh --peer user@laptop.local --mode push
31
+ ./scripts/sync-transcripts.sh --peer user@mini.local --mode bidirectional
32
+ USAGE
33
+ }
34
+
35
+ require_cmd() {
36
+ if ! command -v "$1" >/dev/null 2>&1; then
37
+ echo "Error: required command not found: $1" >&2
38
+ exit 1
39
+ fi
40
+ }
41
+
42
+ human_bytes() {
43
+ local bytes="$1"
44
+ local units=(B KB MB GB TB)
45
+ local i=0
46
+ local value="$bytes"
47
+
48
+ while [ "$value" -ge 1024 ] && [ "$i" -lt 4 ]; do
49
+ value=$((value / 1024))
50
+ i=$((i + 1))
51
+ done
52
+
53
+ printf "%s %s" "$value" "${units[$i]}"
54
+ }
55
+
56
+ copy_matches() {
57
+ local base_dir="$1"
58
+ local match_type="$2"
59
+ local match_value="$3"
60
+ local dest_dir="$4"
61
+ local dry_run="$5"
62
+
63
+ local count=0
64
+ local total_bytes=0
65
+
66
+ if [ ! -d "$base_dir" ]; then
67
+ printf "%s\t%s\n" "0" "0"
68
+ return 0
69
+ fi
70
+
71
+ local -a find_cmd=(find . -type f)
72
+ case "$match_type" in
73
+ name)
74
+ find_cmd+=(-name "$match_value")
75
+ ;;
76
+ path)
77
+ find_cmd+=(-path "$match_value")
78
+ ;;
79
+ *)
80
+ echo "Error: unsupported match type '$match_type'" >&2
81
+ return 1
82
+ ;;
83
+ esac
84
+
85
+ while IFS= read -r -d '' rel_path; do
86
+ rel_path="${rel_path#./}"
87
+ local src="$base_dir/$rel_path"
88
+ local dest="$dest_dir/$rel_path"
89
+ local dest_parent
90
+ dest_parent="$(dirname "$dest")"
91
+
92
+ local file_size
93
+ file_size="$(stat -f '%z' "$src" 2>/dev/null || stat -c '%s' "$src" 2>/dev/null || echo 0)"
94
+
95
+ if [ "$dry_run" = "true" ]; then
96
+ echo "[dry-run] copy $src -> $dest" >&2
97
+ else
98
+ mkdir -p "$dest_parent"
99
+ rsync -a "$src" "$dest"
100
+ fi
101
+
102
+ count=$((count + 1))
103
+ total_bytes=$((total_bytes + file_size))
104
+ done < <(cd "$base_dir" && "${find_cmd[@]}" -print0)
105
+
106
+ printf "%s\t%s\n" "$count" "$total_bytes"
107
+ }
108
+
109
+ sync_to_peer() {
110
+ local mode="$1"
111
+ local local_root="$2"
112
+ local peer="$3"
113
+ local peer_root="$4"
114
+ local dry_run="$5"
115
+
116
+ if [ -z "$peer" ]; then
117
+ echo "Error: --peer is required for mode '$mode'" >&2
118
+ exit 1
119
+ fi
120
+
121
+ if [[ "$peer" == -* ]] || [[ ! "$peer" =~ ^([A-Za-z0-9._-]+@)?[A-Za-z0-9._-]+$ ]]; then
122
+ echo "Error: invalid --peer value '$peer'" >&2
123
+ exit 1
124
+ fi
125
+
126
+ require_cmd ssh
127
+ require_cmd rsync
128
+
129
+ local rsync_flags=(-az --update)
130
+ if [ "$dry_run" = "true" ]; then
131
+ rsync_flags+=(--dry-run)
132
+ fi
133
+
134
+ if [ "$mode" = "push" ] || [ "$mode" = "bidirectional" ]; then
135
+ if [[ "$peer_root" == "~/"* ]]; then
136
+ ssh "$peer" "mkdir -p $peer_root"
137
+ else
138
+ ssh "$peer" "mkdir -p '$peer_root'"
139
+ fi
140
+ echo "Sync push: $local_root/ -> $peer:$peer_root/"
141
+ rsync "${rsync_flags[@]}" "$local_root/" "$peer:$peer_root/"
142
+ fi
143
+
144
+ if [ "$mode" = "pull" ] || [ "$mode" = "bidirectional" ]; then
145
+ mkdir -p "$local_root"
146
+ echo "Sync pull: $peer:$peer_root/ -> $local_root/"
147
+ rsync "${rsync_flags[@]}" "$peer:$peer_root/" "$local_root/"
148
+ fi
149
+ }
150
+
151
+ main() {
152
+ local machine_id
153
+ machine_id="$(hostname -s 2>/dev/null || hostname)"
154
+ local mirror_dir="${HOME}/.teleportation/transcript-mirror"
155
+ local peer=""
156
+ local peer_mirror_dir=""
157
+ local mode="local"
158
+ local dry_run="false"
159
+
160
+ while [ "$#" -gt 0 ]; do
161
+ case "$1" in
162
+ --machine-id)
163
+ machine_id="$2"
164
+ shift 2
165
+ ;;
166
+ --mirror-dir)
167
+ mirror_dir="$2"
168
+ shift 2
169
+ ;;
170
+ --peer)
171
+ peer="$2"
172
+ shift 2
173
+ ;;
174
+ --peer-mirror-dir)
175
+ peer_mirror_dir="$2"
176
+ shift 2
177
+ ;;
178
+ --mode)
179
+ mode="$2"
180
+ shift 2
181
+ ;;
182
+ --dry-run)
183
+ dry_run="true"
184
+ shift
185
+ ;;
186
+ -h|--help)
187
+ usage
188
+ exit 0
189
+ ;;
190
+ *)
191
+ echo "Error: unknown option '$1'" >&2
192
+ usage
193
+ exit 1
194
+ ;;
195
+ esac
196
+ done
197
+
198
+ case "$mode" in
199
+ local|push|pull|bidirectional) ;;
200
+ *)
201
+ echo "Error: invalid --mode '$mode' (expected local|push|pull|bidirectional)" >&2
202
+ exit 1
203
+ ;;
204
+ esac
205
+
206
+ if [ -z "$peer_mirror_dir" ]; then
207
+ peer_mirror_dir="$mirror_dir"
208
+ fi
209
+
210
+ require_cmd find
211
+ require_cmd rsync
212
+
213
+ local machine_root="$mirror_dir/$machine_id"
214
+ local claude_dest="$machine_root/claude"
215
+ local codex_dest="$machine_root/codex"
216
+ local cursor_dest="$machine_root/cursor"
217
+ local gemini_dest="$machine_root/gemini"
218
+
219
+ local claude_src="${HOME}/.claude/projects"
220
+ local codex_src="${HOME}/.codex/sessions"
221
+ local cursor_src="${HOME}/.cursor/projects"
222
+ local gemini_src="${HOME}/.gemini/tmp"
223
+
224
+ if [ "$dry_run" = "false" ]; then
225
+ mkdir -p "$claude_dest" "$codex_dest" "$cursor_dest" "$gemini_dest"
226
+ fi
227
+
228
+ echo "Transcript mirror"
229
+ echo " machine: $machine_id"
230
+ echo " mirror: $mirror_dir"
231
+ echo " mode: $mode"
232
+ [ -n "$peer" ] && echo " peer: $peer"
233
+ echo ""
234
+
235
+ local claude_out codex_out cursor_out gemini_out
236
+ claude_out="$(copy_matches "$claude_src" "name" "*.jsonl" "$claude_dest" "$dry_run")"
237
+ codex_out="$(copy_matches "$codex_src" "name" "*.jsonl" "$codex_dest" "$dry_run")"
238
+ cursor_out="$(copy_matches "$cursor_src" "path" "*/agent-transcripts/*.txt" "$cursor_dest" "$dry_run")"
239
+ gemini_out="$(copy_matches "$gemini_src" "path" "*/chats/session-*.json" "$gemini_dest" "$dry_run")"
240
+
241
+ local claude_count codex_count cursor_count gemini_count
242
+ local claude_bytes codex_bytes cursor_bytes gemini_bytes
243
+
244
+ claude_count="$(echo "$claude_out" | cut -f1)"
245
+ claude_bytes="$(echo "$claude_out" | cut -f2)"
246
+ codex_count="$(echo "$codex_out" | cut -f1)"
247
+ codex_bytes="$(echo "$codex_out" | cut -f2)"
248
+ cursor_count="$(echo "$cursor_out" | cut -f1)"
249
+ cursor_bytes="$(echo "$cursor_out" | cut -f2)"
250
+ gemini_count="$(echo "$gemini_out" | cut -f1)"
251
+ gemini_bytes="$(echo "$gemini_out" | cut -f2)"
252
+
253
+ local total_count total_bytes
254
+ total_count=$((claude_count + codex_count + cursor_count + gemini_count))
255
+ total_bytes=$((claude_bytes + codex_bytes + cursor_bytes + gemini_bytes))
256
+
257
+ echo "Local mirror summary (copied this run):"
258
+ printf " %-8s %6d files %12d bytes\n" "claude" "$claude_count" "$claude_bytes"
259
+ printf " %-8s %6d files %12d bytes\n" "codex" "$codex_count" "$codex_bytes"
260
+ printf " %-8s %6d files %12d bytes\n" "cursor" "$cursor_count" "$cursor_bytes"
261
+ printf " %-8s %6d files %12d bytes\n" "gemini" "$gemini_count" "$gemini_bytes"
262
+ printf " %-8s %6d files %12d bytes (%s)\n" "total" "$total_count" "$total_bytes" "$(human_bytes "$total_bytes")"
263
+ echo ""
264
+
265
+ if [ "$mode" != "local" ]; then
266
+ sync_to_peer "$mode" "$mirror_dir" "$peer" "$peer_mirror_dir" "$dry_run"
267
+ echo ""
268
+ echo "Peer sync complete."
269
+ fi
270
+ }
271
+
272
+ main "$@"