teleportation-cli 1.0.0 → 1.0.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/.claude/hooks/heartbeat.mjs +67 -2
- package/.claude/hooks/permission_request.mjs +55 -26
- package/.claude/hooks/pre_tool_use.mjs +29 -2
- package/.claude/hooks/session-register.mjs +64 -5
- package/.claude/hooks/user_prompt_submit.mjs +54 -0
- package/README.md +36 -12
- package/lib/auth/claude-key-extractor.js +196 -0
- package/lib/auth/credentials.js +7 -2
- package/lib/cli/remote-commands.js +649 -0
- package/lib/install/installer.js +22 -7
- package/lib/remote/code-sync.js +213 -0
- package/lib/remote/init-script-robust.js +187 -0
- package/lib/remote/liveport-client.js +417 -0
- package/lib/remote/orchestrator.js +480 -0
- package/lib/remote/pr-creator.js +382 -0
- package/lib/remote/providers/base-provider.js +407 -0
- package/lib/remote/providers/daytona-provider.js +506 -0
- package/lib/remote/providers/fly-provider.js +611 -0
- package/lib/remote/providers/provider-factory.js +228 -0
- package/lib/remote/results-delivery.js +333 -0
- package/lib/remote/session-manager.js +273 -0
- package/lib/remote/state-capture.js +324 -0
- package/lib/remote/vault-client.js +478 -0
- package/lib/session/metadata.js +80 -49
- package/lib/session/mute-checker.js +2 -1
- package/lib/utils/vault-errors.js +353 -0
- package/package.json +5 -5
- package/teleportation-cli.cjs +417 -7
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CodeSync
|
|
3
|
+
*
|
|
4
|
+
* Manages git state synchronization between local and remote machines:
|
|
5
|
+
* - Capture local state (branch, commit, uncommitted changes)
|
|
6
|
+
* - Sync code to remote machine
|
|
7
|
+
* - Pull changes from remote
|
|
8
|
+
* - Conflict detection
|
|
9
|
+
*
|
|
10
|
+
* Integrates with existing GitHandoff system from lib/handoff/git-handoff.js
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { GitHandoff } from '../handoff/git-handoff.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Local state structure
|
|
17
|
+
* @typedef {Object} LocalState
|
|
18
|
+
* @property {string} branch - Current branch name
|
|
19
|
+
* @property {string} commit - Current commit hash
|
|
20
|
+
* @property {string} remoteUrl - Remote repository URL
|
|
21
|
+
* @property {Array<string>} uncommittedChanges - List of uncommitted files
|
|
22
|
+
* @property {boolean} hasUncommitted - Whether there are uncommitted changes
|
|
23
|
+
* @property {number} timestamp - Capture timestamp
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* CodeSync class
|
|
28
|
+
*/
|
|
29
|
+
export class CodeSync {
|
|
30
|
+
/**
|
|
31
|
+
* @param {Object} options
|
|
32
|
+
* @param {string} options.repoPath - Path to git repository
|
|
33
|
+
* @param {string} options.sessionId - Session identifier
|
|
34
|
+
* @param {Object} [options.gitHandoff] - GitHandoff instance (optional, will create if not provided)
|
|
35
|
+
*/
|
|
36
|
+
constructor(options) {
|
|
37
|
+
const { repoPath, sessionId, gitHandoff } = options;
|
|
38
|
+
|
|
39
|
+
if (!repoPath) {
|
|
40
|
+
throw new Error('repoPath is required');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!sessionId) {
|
|
44
|
+
throw new Error('sessionId is required');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
this.repoPath = repoPath;
|
|
48
|
+
this.sessionId = sessionId;
|
|
49
|
+
|
|
50
|
+
// Use provided GitHandoff or create new one
|
|
51
|
+
this.gitHandoff = gitHandoff || new GitHandoff({
|
|
52
|
+
repoPath,
|
|
53
|
+
sessionId,
|
|
54
|
+
branchPrefix: 'teleport/wip-'
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Capture current local git state
|
|
60
|
+
* @returns {Promise<LocalState>}
|
|
61
|
+
*/
|
|
62
|
+
async captureLocalState() {
|
|
63
|
+
// Verify git repository
|
|
64
|
+
const isRepo = await this.gitHandoff.isGitRepo();
|
|
65
|
+
if (!isRepo) {
|
|
66
|
+
throw new Error(`Not a git repository: ${this.repoPath}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Get current state
|
|
70
|
+
const branch = await this.gitHandoff.getCurrentBranch();
|
|
71
|
+
const commit = await this.gitHandoff.getCurrentCommit();
|
|
72
|
+
const remoteUrl = await this.gitHandoff.getRemoteUrl();
|
|
73
|
+
|
|
74
|
+
if (!remoteUrl) {
|
|
75
|
+
throw new Error('No remote URL configured');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const hasUncommitted = await this.gitHandoff.hasUncommittedChanges();
|
|
79
|
+
const uncommittedChanges = hasUncommitted
|
|
80
|
+
? await this.gitHandoff.getChangedFiles()
|
|
81
|
+
: [];
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
branch,
|
|
85
|
+
commit,
|
|
86
|
+
remoteUrl,
|
|
87
|
+
uncommittedChanges,
|
|
88
|
+
hasUncommitted,
|
|
89
|
+
timestamp: Date.now()
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Sync code to remote machine
|
|
95
|
+
* @param {Object} options
|
|
96
|
+
* @param {string} options.machineId - Remote machine ID
|
|
97
|
+
* @param {LocalState} [options.localState] - Pre-captured local state (optional)
|
|
98
|
+
* @returns {Promise<Object>}
|
|
99
|
+
*/
|
|
100
|
+
async syncToRemote(options) {
|
|
101
|
+
const { machineId, localState: providedState } = options;
|
|
102
|
+
|
|
103
|
+
// Use provided state or capture fresh
|
|
104
|
+
const localState = providedState || await this.captureLocalState();
|
|
105
|
+
|
|
106
|
+
// If there are uncommitted changes, commit them as WIP
|
|
107
|
+
let wipCommit = null;
|
|
108
|
+
if (localState.hasUncommitted) {
|
|
109
|
+
const hasChanges = await this.gitHandoff.hasUncommittedChanges();
|
|
110
|
+
if (hasChanges) {
|
|
111
|
+
const result = await this.gitHandoff.commitWip(
|
|
112
|
+
`Remote sync for session ${this.sessionId}`
|
|
113
|
+
);
|
|
114
|
+
wipCommit = result.commit;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Push to WIP branch
|
|
119
|
+
await this.gitHandoff.pushToWipBranch(true); // force push
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
success: true,
|
|
123
|
+
sessionId: this.sessionId,
|
|
124
|
+
machineId,
|
|
125
|
+
branch: localState.branch,
|
|
126
|
+
commit: localState.commit,
|
|
127
|
+
wipCommit,
|
|
128
|
+
syncedAt: Date.now()
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Pull changes from remote machine
|
|
134
|
+
* @param {Object} options
|
|
135
|
+
* @param {string} [options.strategy='merge'] - Sync strategy ('merge' or 'rebase')
|
|
136
|
+
* @param {boolean} [options.stashLocal=true] - Stash local changes before pulling
|
|
137
|
+
* @returns {Promise<Object>}
|
|
138
|
+
*/
|
|
139
|
+
async pullFromRemote(options = {}) {
|
|
140
|
+
const { strategy = 'merge', stashLocal = true } = options;
|
|
141
|
+
|
|
142
|
+
// Fetch WIP branch
|
|
143
|
+
await this.gitHandoff.fetchWipBranch();
|
|
144
|
+
|
|
145
|
+
// Check if there are remote changes
|
|
146
|
+
const remoteCheck = await this.gitHandoff.checkRemoteChanges();
|
|
147
|
+
if (!remoteCheck.hasChanges) {
|
|
148
|
+
return {
|
|
149
|
+
success: true,
|
|
150
|
+
hasChanges: false,
|
|
151
|
+
commit: remoteCheck.localCommit
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Sync from remote
|
|
156
|
+
const result = await this.gitHandoff.syncFromRemote({
|
|
157
|
+
strategy,
|
|
158
|
+
stashLocal
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
success: result.success,
|
|
163
|
+
hasChanges: true,
|
|
164
|
+
commit: result.commit,
|
|
165
|
+
conflicts: result.conflicts
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Get diff summary between local and remote
|
|
171
|
+
* @returns {Promise<Object>}
|
|
172
|
+
*/
|
|
173
|
+
async getDiffSummary() {
|
|
174
|
+
await this.gitHandoff.fetchWipBranch();
|
|
175
|
+
return this.gitHandoff.getDiffSummary();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Detect potential conflicts between local and remote
|
|
180
|
+
* @returns {Promise<Object>}
|
|
181
|
+
*/
|
|
182
|
+
async detectConflicts() {
|
|
183
|
+
// Check remote changes
|
|
184
|
+
const remoteCheck = await this.gitHandoff.checkRemoteChanges();
|
|
185
|
+
|
|
186
|
+
// Check local uncommitted changes
|
|
187
|
+
const hasUncommitted = await this.gitHandoff.hasUncommittedChanges();
|
|
188
|
+
const localChanges = hasUncommitted
|
|
189
|
+
? await this.gitHandoff.getChangedFiles()
|
|
190
|
+
: [];
|
|
191
|
+
|
|
192
|
+
// Get diff summary
|
|
193
|
+
const diff = await this.getDiffSummary();
|
|
194
|
+
|
|
195
|
+
// Determine if branches diverged
|
|
196
|
+
const diverged = diff.ahead > 0 && diff.behind > 0;
|
|
197
|
+
|
|
198
|
+
// Find conflicting files (overlap between remote changes and local uncommitted)
|
|
199
|
+
const conflictingFiles = localChanges.filter((file) =>
|
|
200
|
+
diff.files.includes(file)
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
hasConflicts: conflictingFiles.length > 0 || (remoteCheck.hasChanges && hasUncommitted),
|
|
205
|
+
diverged,
|
|
206
|
+
conflictingFiles,
|
|
207
|
+
remoteChanges: diff.files,
|
|
208
|
+
localChanges
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export default CodeSync;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Robust Init Script Generator
|
|
3
|
+
*
|
|
4
|
+
* Generates an init script with:
|
|
5
|
+
* - Network readiness checks
|
|
6
|
+
* - Detailed error logging
|
|
7
|
+
* - Retry logic for network calls
|
|
8
|
+
* - Step-by-step validation
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export function generateRobustInitScript(sessionConfig, ssh, tunnel, vaultClient, repoUrl, branch) {
|
|
12
|
+
const safeSessionId = escapeShellArg(sessionConfig.id);
|
|
13
|
+
const safeTask = escapeShellArg(sessionConfig.task || '');
|
|
14
|
+
const safePublicKey = escapeShellArg(ssh.publicKey);
|
|
15
|
+
const safeBranch = escapeShellArg(branch);
|
|
16
|
+
const safeRepoUrl = escapeShellArg(repoUrl);
|
|
17
|
+
const safeVaultUrl = escapeShellArg(vaultClient.apiUrl);
|
|
18
|
+
const safeVaultKey = escapeShellArg(vaultClient.apiKey);
|
|
19
|
+
const safeAppId = escapeShellArg(vaultClient.appId);
|
|
20
|
+
const namespace = sessionConfig.vaultNamespace;
|
|
21
|
+
|
|
22
|
+
return `
|
|
23
|
+
set -e
|
|
24
|
+
|
|
25
|
+
log_step() {
|
|
26
|
+
echo "=== [$1] $2 ==="
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
log_error() {
|
|
30
|
+
echo "❌ ERROR: $1" >&2
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
log_success() {
|
|
34
|
+
echo "✅ $1"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# Wait for network to be ready
|
|
38
|
+
wait_for_network() {
|
|
39
|
+
log_step "0" "Waiting for network readiness"
|
|
40
|
+
local max_attempts=30
|
|
41
|
+
local attempt=1
|
|
42
|
+
|
|
43
|
+
while [ $attempt -le $max_attempts ]; do
|
|
44
|
+
if curl -fsSL --max-time 5 https://vault.mechdna.net/api/health >/dev/null 2>&1; then
|
|
45
|
+
log_success "Network is ready (attempt $attempt)"
|
|
46
|
+
return 0
|
|
47
|
+
fi
|
|
48
|
+
echo "Network not ready yet (attempt $attempt/$max_attempts)"
|
|
49
|
+
sleep 2
|
|
50
|
+
attempt=$((attempt + 1))
|
|
51
|
+
done
|
|
52
|
+
|
|
53
|
+
log_error "Network did not become ready after $max_attempts attempts"
|
|
54
|
+
return 1
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# Retry a curl command with exponential backoff
|
|
58
|
+
retry_curl() {
|
|
59
|
+
local max_attempts=3
|
|
60
|
+
local attempt=1
|
|
61
|
+
local wait_time=2
|
|
62
|
+
|
|
63
|
+
while [ $attempt -le $max_attempts ]; do
|
|
64
|
+
if "$@"; then
|
|
65
|
+
return 0
|
|
66
|
+
fi
|
|
67
|
+
local exit_code=$?
|
|
68
|
+
|
|
69
|
+
if [ $attempt -lt $max_attempts ]; then
|
|
70
|
+
echo "⚠️ Curl failed (exit $exit_code), retrying in $wait_time seconds (attempt $attempt/$max_attempts)"
|
|
71
|
+
sleep $wait_time
|
|
72
|
+
wait_time=$((wait_time * 2))
|
|
73
|
+
attempt=$((attempt + 1))
|
|
74
|
+
else
|
|
75
|
+
log_error "Curl failed after $max_attempts attempts (exit code: $exit_code)"
|
|
76
|
+
return $exit_code
|
|
77
|
+
fi
|
|
78
|
+
done
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
log_step "1" "Installing dependencies"
|
|
82
|
+
apt-get update -qq && apt-get install -y -qq git openssh-server curl jq dnsutils
|
|
83
|
+
log_success "Dependencies installed"
|
|
84
|
+
|
|
85
|
+
log_step "2" "Checking DNS resolution"
|
|
86
|
+
if nslookup vault.mechdna.net >/dev/null 2>&1; then
|
|
87
|
+
log_success "DNS resolution working"
|
|
88
|
+
else
|
|
89
|
+
log_error "DNS resolution failed for vault.mechdna.net"
|
|
90
|
+
exit 1
|
|
91
|
+
fi
|
|
92
|
+
|
|
93
|
+
log_step "3" "Waiting for network connectivity"
|
|
94
|
+
wait_for_network || exit 1
|
|
95
|
+
|
|
96
|
+
log_step "4" "Setting up SSH"
|
|
97
|
+
mkdir -p ~/.ssh
|
|
98
|
+
echo ${safePublicKey} >> ~/.ssh/authorized_keys
|
|
99
|
+
chmod 600 ~/.ssh/authorized_keys
|
|
100
|
+
log_success "SSH configured"
|
|
101
|
+
|
|
102
|
+
log_step "5" "Configuring environment variables"
|
|
103
|
+
export VAULT_NAMESPACE="${namespace}"
|
|
104
|
+
export MECH_VAULT_URL=${safeVaultUrl}
|
|
105
|
+
export MECH_API_KEY=${safeVaultKey}
|
|
106
|
+
export MECH_APP_ID=${safeAppId}
|
|
107
|
+
export SESSION_ID=${safeSessionId}
|
|
108
|
+
|
|
109
|
+
echo "VAULT_NAMESPACE=$VAULT_NAMESPACE"
|
|
110
|
+
echo "MECH_VAULT_URL=$MECH_VAULT_URL"
|
|
111
|
+
echo "SESSION_ID=$SESSION_ID"
|
|
112
|
+
log_success "Environment configured"
|
|
113
|
+
|
|
114
|
+
log_step "6" "Fetching secrets from Vault"
|
|
115
|
+
ENV_FILE=/tmp/teleportation.env
|
|
116
|
+
|
|
117
|
+
retry_curl curl -fsSL -X POST "$MECH_VAULT_URL/env-files/export" \
|
|
118
|
+
-H "Content-Type: application/json" \
|
|
119
|
+
-H "X-API-Key: $MECH_API_KEY" \
|
|
120
|
+
-H "X-App-ID: $MECH_APP_ID" \
|
|
121
|
+
-d "$(jq -n --arg namespace "$VAULT_NAMESPACE" --arg format 'env' '{namespace:$namespace,format:$format}')" \
|
|
122
|
+
-o "$ENV_FILE" || exit 1
|
|
123
|
+
|
|
124
|
+
if [ ! -s "$ENV_FILE" ]; then
|
|
125
|
+
log_error "Vault env file is empty"
|
|
126
|
+
exit 1
|
|
127
|
+
fi
|
|
128
|
+
|
|
129
|
+
log_success "Secrets fetched ($(wc -l < "$ENV_FILE") lines)"
|
|
130
|
+
|
|
131
|
+
log_step "7" "Loading environment variables"
|
|
132
|
+
set +x
|
|
133
|
+
set -a
|
|
134
|
+
. "$ENV_FILE"
|
|
135
|
+
set +a
|
|
136
|
+
log_success "Environment loaded"
|
|
137
|
+
|
|
138
|
+
log_step "8" "Configuring Git authentication"
|
|
139
|
+
if [ -n "$GITHUB_TOKEN" ]; then
|
|
140
|
+
git config --global url."https://x-access-token:$GITHUB_TOKEN@github.com/".insteadOf "https://github.com/"
|
|
141
|
+
git config --global url."https://x-access-token:$GITHUB_TOKEN@github.com/".insteadOf "git@github.com:"
|
|
142
|
+
log_success "Git authentication configured"
|
|
143
|
+
else
|
|
144
|
+
echo "⚠️ No GITHUB_TOKEN found, proceeding without authentication"
|
|
145
|
+
fi
|
|
146
|
+
|
|
147
|
+
log_step "9" "Cloning repository"
|
|
148
|
+
git clone ${safeRepoUrl} /workspace || exit 1
|
|
149
|
+
cd /workspace
|
|
150
|
+
log_success "Repository cloned"
|
|
151
|
+
|
|
152
|
+
log_step "10" "Checking out branch"
|
|
153
|
+
git checkout ${safeBranch} || exit 1
|
|
154
|
+
log_success "Branch checked out: ${safeBranch}"
|
|
155
|
+
|
|
156
|
+
log_step "11" "Installing project dependencies"
|
|
157
|
+
bun install || exit 1
|
|
158
|
+
log_success "Dependencies installed"
|
|
159
|
+
|
|
160
|
+
log_step "12" "Starting LivePort tunnel"
|
|
161
|
+
${tunnel.tunnelCommand} &
|
|
162
|
+
TUNNEL_PID=$!
|
|
163
|
+
log_success "Tunnel started (PID: $TUNNEL_PID)"
|
|
164
|
+
|
|
165
|
+
log_step "13" "Registering session with relay"
|
|
166
|
+
retry_curl curl -fsSL -X POST "$RELAY_API_URL/api/sessions/register" \
|
|
167
|
+
-H "Content-Type: application/json" \
|
|
168
|
+
-H "Authorization: Bearer $RELAY_API_KEY" \
|
|
169
|
+
-d "$(jq -n --arg session_id \"$SESSION_ID\" '{session_id:$session_id,meta:{provider:"fly",remote:true}}')" \
|
|
170
|
+
>/dev/null || echo "⚠️ Session registration failed (non-fatal)"
|
|
171
|
+
|
|
172
|
+
log_step "14" "Starting teleportation daemon"
|
|
173
|
+
echo "Task: ${safeTask}"
|
|
174
|
+
bun teleportation-cli.cjs daemon start --session-id ${safeSessionId} --task ${safeTask} || {
|
|
175
|
+
log_error "Daemon failed to start (exit code $?)"
|
|
176
|
+
exit 1
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
log_success "Initialization completed at $(date)"
|
|
180
|
+
echo "=== Daemon is running, container will stay alive ==="
|
|
181
|
+
tail -f /dev/null
|
|
182
|
+
`.trim();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function escapeShellArg(str) {
|
|
186
|
+
return "'" + String(str || '').replace(/'/g, "'\\''") + "'";
|
|
187
|
+
}
|