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.
Files changed (111) hide show
  1. package/dist/commands/auth.d.ts +22 -0
  2. package/dist/commands/auth.d.ts.map +1 -0
  3. package/dist/commands/auth.js +384 -0
  4. package/dist/commands/auth.js.map +1 -0
  5. package/dist/commands/dev.d.ts +20 -0
  6. package/dist/commands/dev.d.ts.map +1 -0
  7. package/dist/commands/dev.js +305 -0
  8. package/dist/commands/dev.js.map +1 -0
  9. package/dist/commands/status.d.ts +9 -0
  10. package/dist/commands/status.d.ts.map +1 -0
  11. package/dist/commands/status.js +75 -0
  12. package/dist/commands/status.js.map +1 -0
  13. package/dist/commands/stop.d.ts +17 -0
  14. package/dist/commands/stop.d.ts.map +1 -0
  15. package/dist/commands/stop.js +81 -0
  16. package/dist/commands/stop.js.map +1 -0
  17. package/dist/core/auth.d.ts +26 -0
  18. package/dist/core/auth.d.ts.map +1 -0
  19. package/dist/core/auth.js +113 -0
  20. package/dist/core/auth.js.map +1 -0
  21. package/dist/core/command-protocol.d.ts +262 -0
  22. package/dist/core/command-protocol.d.ts.map +1 -0
  23. package/dist/core/command-protocol.js +13 -0
  24. package/dist/core/command-protocol.js.map +1 -0
  25. package/dist/core/connection-manager.d.ts +58 -0
  26. package/dist/core/connection-manager.d.ts.map +1 -0
  27. package/dist/core/connection-manager.js +215 -0
  28. package/dist/core/connection-manager.js.map +1 -0
  29. package/dist/core/errors.d.ts +18 -0
  30. package/dist/core/errors.d.ts.map +1 -0
  31. package/dist/core/errors.js +55 -0
  32. package/dist/core/errors.js.map +1 -0
  33. package/dist/core/git-executor.d.ts +157 -0
  34. package/dist/core/git-executor.d.ts.map +1 -0
  35. package/dist/core/git-executor.js +1605 -0
  36. package/dist/core/git-executor.js.map +1 -0
  37. package/dist/core/git-parser.d.ts +40 -0
  38. package/dist/core/git-parser.d.ts.map +1 -0
  39. package/dist/core/git-parser.js +194 -0
  40. package/dist/core/git-parser.js.map +1 -0
  41. package/dist/core/git-validator.d.ts +42 -0
  42. package/dist/core/git-validator.d.ts.map +1 -0
  43. package/dist/core/git-validator.js +102 -0
  44. package/dist/core/git-validator.js.map +1 -0
  45. package/dist/core/index.d.ts +17 -0
  46. package/dist/core/index.d.ts.map +1 -0
  47. package/dist/core/index.js +41 -0
  48. package/dist/core/index.js.map +1 -0
  49. package/dist/core/version.d.ts +9 -0
  50. package/dist/core/version.d.ts.map +1 -0
  51. package/dist/core/version.js +19 -0
  52. package/dist/core/version.js.map +1 -0
  53. package/dist/core/websocket-client.d.ts +122 -0
  54. package/dist/core/websocket-client.d.ts.map +1 -0
  55. package/dist/core/websocket-client.js +438 -0
  56. package/dist/core/websocket-client.js.map +1 -0
  57. package/dist/daemon/daemon-manager.d.ts +71 -0
  58. package/dist/daemon/daemon-manager.d.ts.map +1 -0
  59. package/dist/daemon/daemon-manager.js +289 -0
  60. package/dist/daemon/daemon-manager.js.map +1 -0
  61. package/dist/daemon/daemon-process.d.ts +13 -0
  62. package/dist/daemon/daemon-process.d.ts.map +1 -0
  63. package/dist/daemon/daemon-process.js +608 -0
  64. package/dist/daemon/daemon-process.js.map +1 -0
  65. package/dist/daemon/machine-id.d.ts +36 -0
  66. package/dist/daemon/machine-id.d.ts.map +1 -0
  67. package/dist/daemon/machine-id.js +195 -0
  68. package/dist/daemon/machine-id.js.map +1 -0
  69. package/dist/daemon/project-tracker.d.ts +92 -0
  70. package/dist/daemon/project-tracker.d.ts.map +1 -0
  71. package/dist/daemon/project-tracker.js +259 -0
  72. package/dist/daemon/project-tracker.js.map +1 -0
  73. package/dist/dev-wrapper.d.ts +88 -0
  74. package/dist/dev-wrapper.d.ts.map +1 -0
  75. package/dist/dev-wrapper.js +288 -0
  76. package/dist/dev-wrapper.js.map +1 -0
  77. package/dist/framework-detector.d.ts +29 -0
  78. package/dist/framework-detector.d.ts.map +1 -0
  79. package/dist/framework-detector.js +276 -0
  80. package/dist/framework-detector.js.map +1 -0
  81. package/dist/git-helpers/git-credential-helper.d.ts +29 -0
  82. package/dist/git-helpers/git-credential-helper.d.ts.map +1 -0
  83. package/dist/git-helpers/git-credential-helper.js +349 -0
  84. package/dist/git-helpers/git-credential-helper.js.map +1 -0
  85. package/dist/hooks/post-checkout +296 -0
  86. package/dist/hooks/pre-commit +139 -0
  87. package/dist/index.d.ts +8 -0
  88. package/dist/index.d.ts.map +1 -0
  89. package/dist/index.js +102 -0
  90. package/dist/index.js.map +1 -0
  91. package/dist/ipc/ipc-client.d.ts +95 -0
  92. package/dist/ipc/ipc-client.d.ts.map +1 -0
  93. package/dist/ipc/ipc-client.js +204 -0
  94. package/dist/ipc/ipc-client.js.map +1 -0
  95. package/dist/ipc/ipc-server.d.ts +55 -0
  96. package/dist/ipc/ipc-server.d.ts.map +1 -0
  97. package/dist/ipc/ipc-server.js +177 -0
  98. package/dist/ipc/ipc-server.js.map +1 -0
  99. package/dist/output.d.ts +48 -0
  100. package/dist/output.d.ts.map +1 -0
  101. package/dist/output.js +129 -0
  102. package/dist/output.js.map +1 -0
  103. package/dist/utils/port-check.d.ts +15 -0
  104. package/dist/utils/port-check.d.ts.map +1 -0
  105. package/dist/utils/port-check.js +79 -0
  106. package/dist/utils/port-check.js.map +1 -0
  107. package/dist/utils/update-checker.d.ts +23 -0
  108. package/dist/utils/update-checker.d.ts.map +1 -0
  109. package/dist/utils/update-checker.js +95 -0
  110. package/dist/utils/update-checker.js.map +1 -0
  111. 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