episoda 0.2.0
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/dist/commands/auth.d.ts +22 -0
- package/dist/commands/auth.d.ts.map +1 -0
- package/dist/commands/auth.js +384 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/dev.d.ts +20 -0
- package/dist/commands/dev.d.ts.map +1 -0
- package/dist/commands/dev.js +305 -0
- package/dist/commands/dev.js.map +1 -0
- package/dist/commands/status.d.ts +9 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/status.js +75 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/stop.d.ts +17 -0
- package/dist/commands/stop.d.ts.map +1 -0
- package/dist/commands/stop.js +81 -0
- package/dist/commands/stop.js.map +1 -0
- package/dist/core/auth.d.ts +26 -0
- package/dist/core/auth.d.ts.map +1 -0
- package/dist/core/auth.js +113 -0
- package/dist/core/auth.js.map +1 -0
- package/dist/core/command-protocol.d.ts +262 -0
- package/dist/core/command-protocol.d.ts.map +1 -0
- package/dist/core/command-protocol.js +13 -0
- package/dist/core/command-protocol.js.map +1 -0
- package/dist/core/connection-manager.d.ts +58 -0
- package/dist/core/connection-manager.d.ts.map +1 -0
- package/dist/core/connection-manager.js +215 -0
- package/dist/core/connection-manager.js.map +1 -0
- package/dist/core/errors.d.ts +18 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +55 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/git-executor.d.ts +157 -0
- package/dist/core/git-executor.d.ts.map +1 -0
- package/dist/core/git-executor.js +1605 -0
- package/dist/core/git-executor.js.map +1 -0
- package/dist/core/git-parser.d.ts +40 -0
- package/dist/core/git-parser.d.ts.map +1 -0
- package/dist/core/git-parser.js +194 -0
- package/dist/core/git-parser.js.map +1 -0
- package/dist/core/git-validator.d.ts +42 -0
- package/dist/core/git-validator.d.ts.map +1 -0
- package/dist/core/git-validator.js +102 -0
- package/dist/core/git-validator.js.map +1 -0
- package/dist/core/index.d.ts +17 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +41 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/version.d.ts +9 -0
- package/dist/core/version.d.ts.map +1 -0
- package/dist/core/version.js +19 -0
- package/dist/core/version.js.map +1 -0
- package/dist/core/websocket-client.d.ts +122 -0
- package/dist/core/websocket-client.d.ts.map +1 -0
- package/dist/core/websocket-client.js +438 -0
- package/dist/core/websocket-client.js.map +1 -0
- package/dist/daemon/daemon-manager.d.ts +71 -0
- package/dist/daemon/daemon-manager.d.ts.map +1 -0
- package/dist/daemon/daemon-manager.js +289 -0
- package/dist/daemon/daemon-manager.js.map +1 -0
- package/dist/daemon/daemon-process.d.ts +13 -0
- package/dist/daemon/daemon-process.d.ts.map +1 -0
- package/dist/daemon/daemon-process.js +608 -0
- package/dist/daemon/daemon-process.js.map +1 -0
- package/dist/daemon/machine-id.d.ts +36 -0
- package/dist/daemon/machine-id.d.ts.map +1 -0
- package/dist/daemon/machine-id.js +195 -0
- package/dist/daemon/machine-id.js.map +1 -0
- package/dist/daemon/project-tracker.d.ts +92 -0
- package/dist/daemon/project-tracker.d.ts.map +1 -0
- package/dist/daemon/project-tracker.js +259 -0
- package/dist/daemon/project-tracker.js.map +1 -0
- package/dist/dev-wrapper.d.ts +88 -0
- package/dist/dev-wrapper.d.ts.map +1 -0
- package/dist/dev-wrapper.js +288 -0
- package/dist/dev-wrapper.js.map +1 -0
- package/dist/framework-detector.d.ts +29 -0
- package/dist/framework-detector.d.ts.map +1 -0
- package/dist/framework-detector.js +276 -0
- package/dist/framework-detector.js.map +1 -0
- package/dist/git-helpers/git-credential-helper.d.ts +29 -0
- package/dist/git-helpers/git-credential-helper.d.ts.map +1 -0
- package/dist/git-helpers/git-credential-helper.js +349 -0
- package/dist/git-helpers/git-credential-helper.js.map +1 -0
- package/dist/hooks/post-checkout +296 -0
- package/dist/hooks/pre-commit +139 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +102 -0
- package/dist/index.js.map +1 -0
- package/dist/ipc/ipc-client.d.ts +95 -0
- package/dist/ipc/ipc-client.d.ts.map +1 -0
- package/dist/ipc/ipc-client.js +204 -0
- package/dist/ipc/ipc-client.js.map +1 -0
- package/dist/ipc/ipc-server.d.ts +55 -0
- package/dist/ipc/ipc-server.d.ts.map +1 -0
- package/dist/ipc/ipc-server.js +177 -0
- package/dist/ipc/ipc-server.js.map +1 -0
- package/dist/output.d.ts +48 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +129 -0
- package/dist/output.js.map +1 -0
- package/dist/utils/port-check.d.ts +15 -0
- package/dist/utils/port-check.d.ts.map +1 -0
- package/dist/utils/port-check.js +79 -0
- package/dist/utils/port-check.js.map +1 -0
- package/dist/utils/update-checker.d.ts +23 -0
- package/dist/utils/update-checker.d.ts.map +1 -0
- package/dist/utils/update-checker.js +95 -0
- package/dist/utils/update-checker.js.map +1 -0
- package/package.json +51 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* EP548/EP612: Git Credential Helper Script Generator
|
|
4
|
+
*
|
|
5
|
+
* This module generates the git credential helper script that is installed
|
|
6
|
+
* during `episoda auth`. The script is called by git when credentials are needed.
|
|
7
|
+
*
|
|
8
|
+
* The generated script:
|
|
9
|
+
* 1. Detects the environment (local vs cloud)
|
|
10
|
+
* 2. Calls GET /api/git/credentials with appropriate auth
|
|
11
|
+
* 3. Returns credentials in git credential protocol format
|
|
12
|
+
* 4. Caches tokens locally (5 min TTL) to avoid API calls on every git operation
|
|
13
|
+
*
|
|
14
|
+
* EP612: Removed jq dependency - uses pure bash JSON parsing
|
|
15
|
+
*/
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.CREDENTIAL_HELPER_SCRIPT = void 0;
|
|
18
|
+
exports.generateCredentialHelperScript = generateCredentialHelperScript;
|
|
19
|
+
/**
|
|
20
|
+
* Generate the credential helper script content
|
|
21
|
+
*
|
|
22
|
+
* The script needs to:
|
|
23
|
+
* - Be standalone (no external dependencies - just bash and curl)
|
|
24
|
+
* - Work with curl (available on all platforms)
|
|
25
|
+
* - Handle both local (OAuth) and cloud (machine ID) auth
|
|
26
|
+
* - Cache tokens to avoid hitting API on every git operation
|
|
27
|
+
*/
|
|
28
|
+
function generateCredentialHelperScript(apiUrl) {
|
|
29
|
+
// The script uses bash because it's universally available
|
|
30
|
+
// No jq dependency - uses pure bash for JSON parsing
|
|
31
|
+
return `#!/bin/bash
|
|
32
|
+
#
|
|
33
|
+
# Episoda Git Credential Helper
|
|
34
|
+
# EP548/EP612: Unified git authentication for all environments
|
|
35
|
+
#
|
|
36
|
+
# This script is called by git when credentials are needed.
|
|
37
|
+
# It calls the Episoda API to get a fresh GitHub token.
|
|
38
|
+
#
|
|
39
|
+
# Installation: episoda auth
|
|
40
|
+
# Location: ~/.episoda/bin/git-credential-episoda (local)
|
|
41
|
+
# /usr/local/bin/git-credential-episoda (cloud VM)
|
|
42
|
+
#
|
|
43
|
+
# Git credential protocol:
|
|
44
|
+
# - git calls: git-credential-episoda get
|
|
45
|
+
# - input on stdin: protocol=https\\nhost=github.com\\n
|
|
46
|
+
# - output on stdout: username=x-access-token\\npassword=TOKEN\\n
|
|
47
|
+
#
|
|
48
|
+
# Dependencies: bash, curl (no jq required)
|
|
49
|
+
#
|
|
50
|
+
|
|
51
|
+
set -euo pipefail
|
|
52
|
+
|
|
53
|
+
EPISODA_DIR="\${HOME}/.episoda"
|
|
54
|
+
CONFIG_FILE="\${EPISODA_DIR}/config.json"
|
|
55
|
+
CACHE_FILE="\${EPISODA_DIR}/git-token-cache.json"
|
|
56
|
+
API_URL="${apiUrl}"
|
|
57
|
+
|
|
58
|
+
# Cache TTL in seconds (5 minutes)
|
|
59
|
+
CACHE_TTL=300
|
|
60
|
+
|
|
61
|
+
# Log function (to stderr so git doesn't see it)
|
|
62
|
+
log() {
|
|
63
|
+
if [[ "\${GIT_CREDENTIAL_EPISODA_DEBUG:-}" == "1" ]]; then
|
|
64
|
+
echo "[episoda-git] \$(date '+%H:%M:%S') \$*" >&2
|
|
65
|
+
fi
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# Error log (always shown)
|
|
69
|
+
error() {
|
|
70
|
+
echo "[episoda-git] ERROR: \$*" >&2
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# Pure bash JSON value extraction (no jq needed)
|
|
74
|
+
# Usage: json_get '{"foo":"bar"}' "foo" -> "bar"
|
|
75
|
+
# Handles simple flat JSON and nested paths like "credentials.username"
|
|
76
|
+
json_get() {
|
|
77
|
+
local json="\$1"
|
|
78
|
+
local key="\$2"
|
|
79
|
+
local value=""
|
|
80
|
+
|
|
81
|
+
# Handle nested keys (e.g., "credentials.username")
|
|
82
|
+
if [[ "\$key" == *.* ]]; then
|
|
83
|
+
local outer="\${key%%.*}"
|
|
84
|
+
local inner="\${key#*.}"
|
|
85
|
+
# Extract outer object first, then inner key
|
|
86
|
+
# Match "outer":{...} and extract the {...} part
|
|
87
|
+
local nested
|
|
88
|
+
nested=\$(echo "\$json" | sed -n 's/.*"'\$outer'"[[:space:]]*:[[:space:]]*{\\([^}]*\\)}.*/\\1/p')
|
|
89
|
+
if [[ -n "\$nested" ]]; then
|
|
90
|
+
json_get "{\$nested}" "\$inner"
|
|
91
|
+
return
|
|
92
|
+
fi
|
|
93
|
+
return
|
|
94
|
+
fi
|
|
95
|
+
|
|
96
|
+
# Simple key extraction: "key":"value" or "key": "value"
|
|
97
|
+
# Handle both quoted strings and unquoted values
|
|
98
|
+
value=\$(echo "\$json" | sed -n 's/.*"'\$key'"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p')
|
|
99
|
+
|
|
100
|
+
if [[ -n "\$value" ]]; then
|
|
101
|
+
echo "\$value"
|
|
102
|
+
fi
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
# Check for required dependencies
|
|
106
|
+
check_dependencies() {
|
|
107
|
+
if ! command -v curl >/dev/null 2>&1; then
|
|
108
|
+
error "curl is required but not installed"
|
|
109
|
+
return 1
|
|
110
|
+
fi
|
|
111
|
+
return 0
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
# Parse git credential input from stdin
|
|
115
|
+
parse_input() {
|
|
116
|
+
while IFS= read -r line; do
|
|
117
|
+
[[ -z "\$line" ]] && break
|
|
118
|
+
case "\$line" in
|
|
119
|
+
protocol=*) PROTOCOL="\${line#protocol=}" ;;
|
|
120
|
+
host=*) HOST="\${line#host=}" ;;
|
|
121
|
+
path=*) PATH_="\${line#path=}" ;;
|
|
122
|
+
esac
|
|
123
|
+
done
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
# Parse ISO 8601 date to unix timestamp (cross-platform)
|
|
127
|
+
parse_iso_date() {
|
|
128
|
+
local iso_date="\$1"
|
|
129
|
+
# Try GNU date first (Linux)
|
|
130
|
+
if date -d "\$iso_date" +%s 2>/dev/null; then
|
|
131
|
+
return
|
|
132
|
+
fi
|
|
133
|
+
# Try BSD date (macOS) - strip timezone for parsing
|
|
134
|
+
local clean_date="\${iso_date%+*}" # Remove +00:00
|
|
135
|
+
clean_date="\${clean_date%Z}" # Remove Z
|
|
136
|
+
clean_date="\${clean_date%.*}" # Remove .milliseconds
|
|
137
|
+
if date -jf "%Y-%m-%dT%H:%M:%S" "\$clean_date" +%s 2>/dev/null; then
|
|
138
|
+
return
|
|
139
|
+
fi
|
|
140
|
+
# Fallback: return 0 (expired)
|
|
141
|
+
echo "0"
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
# Check if cached token is still valid
|
|
145
|
+
get_cached_token() {
|
|
146
|
+
if [[ ! -f "\$CACHE_FILE" ]]; then
|
|
147
|
+
log "No cache file"
|
|
148
|
+
return 1
|
|
149
|
+
fi
|
|
150
|
+
|
|
151
|
+
# Read cache file
|
|
152
|
+
local cache_content
|
|
153
|
+
cache_content=\$(cat "\$CACHE_FILE" 2>/dev/null) || return 1
|
|
154
|
+
|
|
155
|
+
# Parse cache file using pure bash
|
|
156
|
+
local expires_at
|
|
157
|
+
expires_at=\$(json_get "\$cache_content" "expires_at")
|
|
158
|
+
|
|
159
|
+
if [[ -z "\$expires_at" ]]; then
|
|
160
|
+
log "No expires_at in cache"
|
|
161
|
+
return 1
|
|
162
|
+
fi
|
|
163
|
+
|
|
164
|
+
# Check if expired (with 60 second buffer)
|
|
165
|
+
local now expires_ts buffer
|
|
166
|
+
now=\$(date +%s)
|
|
167
|
+
expires_ts=\$(parse_iso_date "\$expires_at")
|
|
168
|
+
buffer=60
|
|
169
|
+
|
|
170
|
+
if [[ \$((expires_ts - buffer)) -le \$now ]]; then
|
|
171
|
+
log "Cache expired (expires: \$expires_at)"
|
|
172
|
+
return 1
|
|
173
|
+
fi
|
|
174
|
+
|
|
175
|
+
# Return cached token
|
|
176
|
+
CACHED_TOKEN=\$(json_get "\$cache_content" "password")
|
|
177
|
+
CACHED_USER=\$(json_get "\$cache_content" "username")
|
|
178
|
+
|
|
179
|
+
if [[ -n "\$CACHED_TOKEN" && -n "\$CACHED_USER" ]]; then
|
|
180
|
+
log "Using cached token (expires: \$expires_at)"
|
|
181
|
+
return 0
|
|
182
|
+
fi
|
|
183
|
+
|
|
184
|
+
log "Invalid cache content"
|
|
185
|
+
return 1
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
# Save token to cache
|
|
189
|
+
save_to_cache() {
|
|
190
|
+
local username="\$1"
|
|
191
|
+
local password="\$2"
|
|
192
|
+
local expires_at="\$3"
|
|
193
|
+
|
|
194
|
+
mkdir -p "\$EPISODA_DIR"
|
|
195
|
+
cat > "\$CACHE_FILE" <<CACHE_EOF
|
|
196
|
+
{"username":"\$username","password":"\$password","expires_at":"\$expires_at","cached_at":"\$(date -u +"%Y-%m-%dT%H:%M:%SZ")"}
|
|
197
|
+
CACHE_EOF
|
|
198
|
+
chmod 600 "\$CACHE_FILE"
|
|
199
|
+
log "Token cached until \$expires_at"
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
# Get credentials from Episoda API
|
|
203
|
+
fetch_credentials() {
|
|
204
|
+
local api_url="\${EPISODA_API_URL:-\${API_URL}}"
|
|
205
|
+
local response=""
|
|
206
|
+
local http_code=""
|
|
207
|
+
|
|
208
|
+
# Detect environment - check multiple ways to identify a cloud VM
|
|
209
|
+
local machine_id=""
|
|
210
|
+
|
|
211
|
+
# Check FLY_MACHINE_ID (set by Fly.io on all machines)
|
|
212
|
+
if [[ -n "\${FLY_MACHINE_ID:-}" ]]; then
|
|
213
|
+
machine_id="\$FLY_MACHINE_ID"
|
|
214
|
+
log "Cloud environment detected via FLY_MACHINE_ID: \$machine_id"
|
|
215
|
+
# Legacy: check /app/.machine_id file
|
|
216
|
+
elif [[ -f "/app/.machine_id" ]]; then
|
|
217
|
+
machine_id=\$(cat /app/.machine_id)
|
|
218
|
+
log "Cloud environment detected via /app/.machine_id: \$machine_id"
|
|
219
|
+
fi
|
|
220
|
+
|
|
221
|
+
if [[ -n "\$machine_id" ]]; then
|
|
222
|
+
# Cloud VM: use machine ID header
|
|
223
|
+
log "Fetching credentials for machine: \$machine_id"
|
|
224
|
+
response=\$(curl -s -w "\\n%{http_code}" --max-time 10 "\${api_url}/api/git/credentials" \\
|
|
225
|
+
-H "X-Machine-ID: \$machine_id" \\
|
|
226
|
+
-H "Content-Type: application/json" 2>&1) || {
|
|
227
|
+
error "curl failed: \$response"
|
|
228
|
+
return 1
|
|
229
|
+
}
|
|
230
|
+
else
|
|
231
|
+
# Local: use OAuth token from config
|
|
232
|
+
if [[ ! -f "\$CONFIG_FILE" ]]; then
|
|
233
|
+
error "No config found at \$CONFIG_FILE. Run 'episoda auth' first."
|
|
234
|
+
return 1
|
|
235
|
+
fi
|
|
236
|
+
|
|
237
|
+
# Parse config using pure bash
|
|
238
|
+
local config_content
|
|
239
|
+
config_content=\$(cat "\$CONFIG_FILE" 2>/dev/null) || {
|
|
240
|
+
error "Cannot read config file"
|
|
241
|
+
return 1
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
local access_token project_id
|
|
245
|
+
access_token=\$(json_get "\$config_content" "access_token")
|
|
246
|
+
project_id=\$(json_get "\$config_content" "project_id")
|
|
247
|
+
|
|
248
|
+
if [[ -z "\$access_token" ]]; then
|
|
249
|
+
error "No access token in config. Run 'episoda auth' to authenticate."
|
|
250
|
+
return 1
|
|
251
|
+
fi
|
|
252
|
+
|
|
253
|
+
log "Local environment (project: \$project_id)"
|
|
254
|
+
response=\$(curl -s -w "\\n%{http_code}" --max-time 10 "\${api_url}/api/git/credentials" \\
|
|
255
|
+
-H "Authorization: Bearer \$access_token" \\
|
|
256
|
+
-H "X-Project-ID: \$project_id" \\
|
|
257
|
+
-H "Content-Type: application/json" 2>&1) || {
|
|
258
|
+
error "curl failed: \$response"
|
|
259
|
+
return 1
|
|
260
|
+
}
|
|
261
|
+
fi
|
|
262
|
+
|
|
263
|
+
# Split response and HTTP code
|
|
264
|
+
http_code=\$(echo "\$response" | tail -n1)
|
|
265
|
+
response=\$(echo "\$response" | sed '\$d')
|
|
266
|
+
|
|
267
|
+
# Check HTTP status
|
|
268
|
+
if [[ "\$http_code" != "200" ]]; then
|
|
269
|
+
error "API returned HTTP \$http_code"
|
|
270
|
+
log "Response: \$response"
|
|
271
|
+
return 1
|
|
272
|
+
fi
|
|
273
|
+
|
|
274
|
+
# Parse response using pure bash
|
|
275
|
+
CRED_USERNAME=\$(json_get "\$response" "credentials.username")
|
|
276
|
+
CRED_PASSWORD=\$(json_get "\$response" "credentials.password")
|
|
277
|
+
CRED_EXPIRES=\$(json_get "\$response" "credentials.expires_at")
|
|
278
|
+
|
|
279
|
+
if [[ -z "\$CRED_USERNAME" || -z "\$CRED_PASSWORD" ]]; then
|
|
280
|
+
error "Invalid credentials in response"
|
|
281
|
+
log "Response: \$response"
|
|
282
|
+
return 1
|
|
283
|
+
fi
|
|
284
|
+
|
|
285
|
+
# Cache the token
|
|
286
|
+
save_to_cache "\$CRED_USERNAME" "\$CRED_PASSWORD" "\$CRED_EXPIRES"
|
|
287
|
+
|
|
288
|
+
log "Credentials fetched successfully"
|
|
289
|
+
return 0
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
# Main
|
|
293
|
+
main() {
|
|
294
|
+
local command="\${1:-}"
|
|
295
|
+
|
|
296
|
+
# Check dependencies before processing
|
|
297
|
+
if ! check_dependencies; then
|
|
298
|
+
exit 0 # Exit gracefully so git tries other helpers
|
|
299
|
+
fi
|
|
300
|
+
|
|
301
|
+
case "\$command" in
|
|
302
|
+
get)
|
|
303
|
+
parse_input
|
|
304
|
+
|
|
305
|
+
# Only handle github.com
|
|
306
|
+
if [[ "\${HOST:-}" != "github.com" ]]; then
|
|
307
|
+
log "Not handling host: \${HOST:-unknown}"
|
|
308
|
+
exit 0
|
|
309
|
+
fi
|
|
310
|
+
|
|
311
|
+
# Try cache first
|
|
312
|
+
if get_cached_token; then
|
|
313
|
+
echo "username=\$CACHED_USER"
|
|
314
|
+
echo "password=\$CACHED_TOKEN"
|
|
315
|
+
exit 0
|
|
316
|
+
fi
|
|
317
|
+
|
|
318
|
+
# Fetch fresh credentials
|
|
319
|
+
if fetch_credentials; then
|
|
320
|
+
echo "username=\$CRED_USERNAME"
|
|
321
|
+
echo "password=\$CRED_PASSWORD"
|
|
322
|
+
exit 0
|
|
323
|
+
fi
|
|
324
|
+
|
|
325
|
+
# Failed - let git try other credential helpers
|
|
326
|
+
log "Failed to get credentials, falling back to other helpers"
|
|
327
|
+
exit 0
|
|
328
|
+
;;
|
|
329
|
+
|
|
330
|
+
store|erase)
|
|
331
|
+
# We don't store or erase credentials
|
|
332
|
+
exit 0
|
|
333
|
+
;;
|
|
334
|
+
|
|
335
|
+
*)
|
|
336
|
+
# Unknown command
|
|
337
|
+
exit 0
|
|
338
|
+
;;
|
|
339
|
+
esac
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
main "\$@"
|
|
343
|
+
`;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Get the content of the credential helper for embedding in the CLI
|
|
347
|
+
*/
|
|
348
|
+
exports.CREDENTIAL_HELPER_SCRIPT = generateCredentialHelperScript('https://episoda.dev');
|
|
349
|
+
//# sourceMappingURL=git-credential-helper.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"git-credential-helper.js","sourceRoot":"","sources":["../../src/git-helpers/git-credential-helper.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;GAaG;;;AAWH,wEA4TC;AArUD;;;;;;;;GAQG;AACH,SAAgB,8BAA8B,CAAC,MAAc;IAC3D,0DAA0D;IAC1D,qDAAqD;IACrD,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;WAyBE,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+RhB,CAAA;AACD,CAAC;AAED;;GAEG;AACU,QAAA,wBAAwB,GAAG,8BAA8B,CAAC,qBAAqB,CAAC,CAAA"}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Git post-checkout hook to update module.checkout_* fields for realtime badge updates
|
|
3
|
+
# EP534: Also checks branch locks and auto-reverts if branch is locked by another user
|
|
4
|
+
# EP649: Added support for cloud VM checkout tracking (Fly.io, Codespaces, Gitpod, etc.)
|
|
5
|
+
# EP749: Simplified to use module.checkout_* as single source of truth (removed branch_checkout table)
|
|
6
|
+
# This runs after every successful git checkout (from API or terminal)
|
|
7
|
+
|
|
8
|
+
# Set up logging
|
|
9
|
+
LOG_FILE=".git/hooks/post-checkout.log"
|
|
10
|
+
log() {
|
|
11
|
+
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
# Get hook parameters
|
|
15
|
+
PREV_HEAD=$1
|
|
16
|
+
NEW_HEAD=$2
|
|
17
|
+
BRANCH_CHECKOUT=$3 # 1 if branch checkout, 0 if file checkout
|
|
18
|
+
|
|
19
|
+
# Get the old and new branch names
|
|
20
|
+
OLD_BRANCH=$(git name-rev --name-only $PREV_HEAD 2>/dev/null | sed 's/^remotes\/origin\///')
|
|
21
|
+
NEW_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
|
22
|
+
|
|
23
|
+
# If in detached HEAD state, try to get actual branch from reflog or skip update
|
|
24
|
+
if [ "$NEW_BRANCH" = "HEAD" ]; then
|
|
25
|
+
# Try to get branch from git reflog
|
|
26
|
+
ACTUAL_BRANCH=$(git reflog show --all | grep -o "checkout: moving from .* to \(.*\)" | head -1 | sed 's/.*to //')
|
|
27
|
+
if [ -n "$ACTUAL_BRANCH" ] && [ "$ACTUAL_BRANCH" != "HEAD" ]; then
|
|
28
|
+
NEW_BRANCH="$ACTUAL_BRANCH"
|
|
29
|
+
log "Detected branch from reflog: $NEW_BRANCH"
|
|
30
|
+
else
|
|
31
|
+
# Still HEAD - skip update to avoid breaking UI
|
|
32
|
+
log "Skipping update - detached HEAD state"
|
|
33
|
+
exit 0
|
|
34
|
+
fi
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
log "Branch checkout: $OLD_BRANCH → $NEW_BRANCH"
|
|
38
|
+
|
|
39
|
+
# Get database connection info from .env.local
|
|
40
|
+
if [ -f .env.local ]; then
|
|
41
|
+
export $(grep -v '^#' .env.local | grep -E 'NEXT_PUBLIC_SUPABASE_URL|SUPABASE_SERVICE_ROLE_KEY' | xargs)
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
# EP553: Atomic branch checkout using checkout_branch_atomic() function
|
|
45
|
+
# Combines lock check and checkout update in a single transaction to prevent race conditions
|
|
46
|
+
CHECKOUT_RESULT=$(node -e "
|
|
47
|
+
const { createClient } = require('@supabase/supabase-js');
|
|
48
|
+
const { execSync } = require('child_process');
|
|
49
|
+
|
|
50
|
+
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
51
|
+
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
52
|
+
|
|
53
|
+
if (!supabaseUrl || !supabaseKey) {
|
|
54
|
+
console.log('SKIP:Missing Supabase credentials');
|
|
55
|
+
process.exit(0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const supabase = createClient(supabaseUrl, supabaseKey);
|
|
59
|
+
|
|
60
|
+
(async () => {
|
|
61
|
+
try {
|
|
62
|
+
// EP553: Get user ID and workspace ID from git config
|
|
63
|
+
let userId = null;
|
|
64
|
+
let configuredWorkspaceId = null;
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
userId = execSync('git config episoda.userId', { encoding: 'utf8' }).trim();
|
|
68
|
+
} catch (e) {
|
|
69
|
+
console.log('SKIP:No user configured');
|
|
70
|
+
process.exit(0);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
configuredWorkspaceId = execSync('git config episoda.workspaceId', { encoding: 'utf8' }).trim();
|
|
75
|
+
} catch (e) {
|
|
76
|
+
console.log('SKIP:No workspace configured');
|
|
77
|
+
process.exit(0);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!userId || !configuredWorkspaceId) {
|
|
81
|
+
console.log('SKIP:Configuration incomplete');
|
|
82
|
+
process.exit(0);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// EP553: Verify user exists and is not banned
|
|
86
|
+
const { data: userData, error: userError } = await supabase.auth.admin.getUserById(userId);
|
|
87
|
+
|
|
88
|
+
if (userError || !userData.user) {
|
|
89
|
+
console.log('ERROR:Invalid user ID');
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (userData.user.banned) {
|
|
94
|
+
console.log('ERROR:User account suspended');
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// EP553: Get user's actual workspace via workspace_member table
|
|
99
|
+
const { data: membership, error: membershipError } = await supabase
|
|
100
|
+
.from('workspace_member')
|
|
101
|
+
.select('workspace_id')
|
|
102
|
+
.eq('user_id', userId)
|
|
103
|
+
.maybeSingle();
|
|
104
|
+
|
|
105
|
+
if (membershipError || !membership) {
|
|
106
|
+
console.log('ERROR:User not member of workspace');
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const workspaceId = membership.workspace_id;
|
|
111
|
+
|
|
112
|
+
// EP553: Multi-user machine protection
|
|
113
|
+
if (configuredWorkspaceId !== workspaceId) {
|
|
114
|
+
console.log('ERROR:Workspace mismatch');
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// EP725: Get project ID from git config for main/master checkout tracking
|
|
119
|
+
// This ensures main branch checkouts are recorded with project_id for badge display
|
|
120
|
+
let configuredProjectId = null;
|
|
121
|
+
try {
|
|
122
|
+
configuredProjectId = execSync('git config episoda.projectId', { encoding: 'utf8' }).trim();
|
|
123
|
+
} catch (e) {
|
|
124
|
+
// No projectId configured - will use null (backwards compatible)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// EP556: Extract UID from branch name and look up by UID instead of branch_name
|
|
128
|
+
// This eliminates race conditions during Ready→Doing transitions
|
|
129
|
+
// Branch pattern: module/EP{number}-{description}
|
|
130
|
+
let moduleId = null;
|
|
131
|
+
let projectId = configuredProjectId || null; // EP725: Default to configured project for main/master
|
|
132
|
+
if ('${NEW_BRANCH}' !== 'main' && '${NEW_BRANCH}' !== 'master') {
|
|
133
|
+
const branchMatch = '${NEW_BRANCH}'.match(/^module\/EP(\d+)-/);
|
|
134
|
+
if (branchMatch) {
|
|
135
|
+
const uid = 'EP' + branchMatch[1];
|
|
136
|
+
const { data: module } = await supabase
|
|
137
|
+
.from('module')
|
|
138
|
+
.select('id, project_id')
|
|
139
|
+
.eq('uid', uid)
|
|
140
|
+
.maybeSingle();
|
|
141
|
+
|
|
142
|
+
if (module) {
|
|
143
|
+
moduleId = module.id;
|
|
144
|
+
projectId = module.project_id;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// EP553: Detect environment type based on where the hook is running
|
|
150
|
+
// EP649: Added Fly.io cloud VM detection
|
|
151
|
+
// Check for common cloud development environment variables
|
|
152
|
+
let environment = 'local';
|
|
153
|
+
let cloudMachineId = null;
|
|
154
|
+
|
|
155
|
+
if (process.env.FLY_MACHINE_ID) {
|
|
156
|
+
environment = 'cloud'; // Fly.io VM (Episoda cloud dev)
|
|
157
|
+
cloudMachineId = process.env.FLY_MACHINE_ID;
|
|
158
|
+
} else if (process.env.CODESPACES === 'true' || process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN) {
|
|
159
|
+
environment = 'cloud'; // GitHub Codespaces
|
|
160
|
+
cloudMachineId = process.env.CODESPACE_NAME || null;
|
|
161
|
+
} else if (process.env.GITPOD_WORKSPACE_ID) {
|
|
162
|
+
environment = 'cloud'; // Gitpod
|
|
163
|
+
cloudMachineId = process.env.GITPOD_WORKSPACE_ID;
|
|
164
|
+
} else if (process.env.C9_PROJECT || process.env.AWS_CLOUD9_USER) {
|
|
165
|
+
environment = 'cloud'; // AWS Cloud9
|
|
166
|
+
cloudMachineId = process.env.C9_PROJECT || null;
|
|
167
|
+
} else if (process.env.REPL_ID || process.env.REPLIT_DB_URL) {
|
|
168
|
+
environment = 'cloud'; // Replit
|
|
169
|
+
cloudMachineId = process.env.REPL_ID || null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// EP726: Get device UUID for checkout tracking
|
|
173
|
+
// EP773: Tables renamed: local_device → local_machine, cloud_device → cloud_machine
|
|
174
|
+
// For local: read local_machine.id from git config (cached by CLI daemon)
|
|
175
|
+
// For cloud: read cloud_machine.id from git config (set during VM provisioning)
|
|
176
|
+
let localDeviceId = null;
|
|
177
|
+
let cloudDeviceId = null;
|
|
178
|
+
|
|
179
|
+
if (environment === 'local') {
|
|
180
|
+
// Local checkout: get local_machine.id from git config
|
|
181
|
+
try {
|
|
182
|
+
localDeviceId = execSync('git config episoda.deviceId', { encoding: 'utf8' }).trim();
|
|
183
|
+
} catch (e) {
|
|
184
|
+
// No deviceId configured yet - will use null (backwards compatible)
|
|
185
|
+
}
|
|
186
|
+
} else {
|
|
187
|
+
// Cloud checkout: get cloud_machine.id from git config
|
|
188
|
+
// EP773: Renamed cloud_device → cloud_machine
|
|
189
|
+
try {
|
|
190
|
+
cloudDeviceId = execSync('git config episoda.cloudDeviceId', { encoding: 'utf8' }).trim();
|
|
191
|
+
} catch (e) {
|
|
192
|
+
// No cloudDeviceId configured - try to look up by FLY_MACHINE_ID
|
|
193
|
+
if (cloudMachineId) {
|
|
194
|
+
const { data: cloudMachine } = await supabase
|
|
195
|
+
.from('cloud_machine')
|
|
196
|
+
.select('id')
|
|
197
|
+
.eq('machine_id', cloudMachineId)
|
|
198
|
+
.maybeSingle();
|
|
199
|
+
if (cloudMachine) {
|
|
200
|
+
cloudDeviceId = cloudMachine.id;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// EP726: Call atomic checkout function with unified machine IDs
|
|
207
|
+
// EP768: Renamed p_*_device_id → p_*_machine_id to match function signature
|
|
208
|
+
// - p_local_machine_id: UUID FK to local_machine (for local checkouts)
|
|
209
|
+
// - p_cloud_machine_id: UUID FK to cloud_machine (for cloud checkouts)
|
|
210
|
+
const { data: result, error: rpcError } = await supabase
|
|
211
|
+
.rpc('checkout_branch_atomic', {
|
|
212
|
+
p_branch_name: '${NEW_BRANCH}',
|
|
213
|
+
p_user_id: userId,
|
|
214
|
+
p_workspace_id: workspaceId,
|
|
215
|
+
p_project_id: projectId,
|
|
216
|
+
p_environment: environment,
|
|
217
|
+
p_module_id: moduleId,
|
|
218
|
+
p_cloud_machine_id: cloudDeviceId,
|
|
219
|
+
p_local_machine_id: localDeviceId
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
if (rpcError) {
|
|
223
|
+
console.log('ERROR:' + rpcError.message);
|
|
224
|
+
process.exit(1);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (!result.success) {
|
|
228
|
+
if (result.error === 'BRANCH_LOCKED') {
|
|
229
|
+
console.log('LOCKED:' + result.locked_by + ':' + result.environment);
|
|
230
|
+
} else if (result.error === 'BRANCH_BUSY') {
|
|
231
|
+
console.log('BUSY:' + result.message);
|
|
232
|
+
} else {
|
|
233
|
+
console.log('ERROR:' + (result.message || 'Unknown error'));
|
|
234
|
+
}
|
|
235
|
+
process.exit(0);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Success - branch checked out atomically
|
|
239
|
+
console.log('SUCCESS:${NEW_BRANCH}');
|
|
240
|
+
} catch (err) {
|
|
241
|
+
console.log('ERROR:' + err.message);
|
|
242
|
+
}
|
|
243
|
+
})();
|
|
244
|
+
")
|
|
245
|
+
|
|
246
|
+
# Handle the checkout result
|
|
247
|
+
if [[ "$CHECKOUT_RESULT" == SKIP:* ]]; then
|
|
248
|
+
# Configuration incomplete or credentials missing - skip quietly
|
|
249
|
+
log "Skipping checkout update: ${CHECKOUT_RESULT#SKIP:}"
|
|
250
|
+
exit 0
|
|
251
|
+
elif [[ "$CHECKOUT_RESULT" == ERROR:* ]]; then
|
|
252
|
+
# Fatal error - abort
|
|
253
|
+
echo ""
|
|
254
|
+
echo "❌ ERROR: ${CHECKOUT_RESULT#ERROR:}"
|
|
255
|
+
echo ""
|
|
256
|
+
exit 1
|
|
257
|
+
elif [[ "$CHECKOUT_RESULT" == LOCKED:* ]]; then
|
|
258
|
+
# Branch is locked by another user - revert checkout
|
|
259
|
+
LOCKED_USER=$(echo "$CHECKOUT_RESULT" | cut -d':' -f2)
|
|
260
|
+
LOCK_ENV=$(echo "$CHECKOUT_RESULT" | cut -d':' -f3)
|
|
261
|
+
|
|
262
|
+
echo ""
|
|
263
|
+
echo "❌ ERROR: Branch '$NEW_BRANCH' is currently checked out by $LOCKED_USER in $LOCK_ENV mode."
|
|
264
|
+
echo ""
|
|
265
|
+
echo "This branch is locked to prevent conflicts. Options:"
|
|
266
|
+
echo " 1. Ask $LOCKED_USER to check in via GitHub Integration modal"
|
|
267
|
+
echo " 2. Work on a different module/branch"
|
|
268
|
+
echo " 3. Wait for the lock to be released"
|
|
269
|
+
echo ""
|
|
270
|
+
echo "Reverting to previous branch: $OLD_BRANCH"
|
|
271
|
+
echo ""
|
|
272
|
+
|
|
273
|
+
# Revert to old branch
|
|
274
|
+
git checkout "$OLD_BRANCH" 2>/dev/null
|
|
275
|
+
exit 1
|
|
276
|
+
elif [[ "$CHECKOUT_RESULT" == BUSY:* ]]; then
|
|
277
|
+
# Another user is currently checking out - suggest retry
|
|
278
|
+
echo ""
|
|
279
|
+
echo "⏳ Branch checkout in progress by another user"
|
|
280
|
+
echo "Please try again in a moment"
|
|
281
|
+
echo ""
|
|
282
|
+
echo "Reverting to previous branch: $OLD_BRANCH"
|
|
283
|
+
echo ""
|
|
284
|
+
|
|
285
|
+
# Revert to old branch
|
|
286
|
+
git checkout "$OLD_BRANCH" 2>/dev/null
|
|
287
|
+
exit 1
|
|
288
|
+
elif [[ "$CHECKOUT_RESULT" == SUCCESS:* ]]; then
|
|
289
|
+
# Success
|
|
290
|
+
log "Successfully checked out branch: $NEW_BRANCH"
|
|
291
|
+
exit 0
|
|
292
|
+
else
|
|
293
|
+
# Unexpected result
|
|
294
|
+
log "Unexpected checkout result: $CHECKOUT_RESULT"
|
|
295
|
+
exit 0
|
|
296
|
+
fi
|