gitgreen 0.1.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/init.js ADDED
@@ -0,0 +1,773 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.runInit = void 0;
7
+ const child_process_1 = require("child_process");
8
+ const fs_1 = __importDefault(require("fs"));
9
+ const path_1 = __importDefault(require("path"));
10
+ const axios_1 = __importDefault(require("axios"));
11
+ const prompts_1 = __importDefault(require("prompts"));
12
+ const kleur_1 = require("kleur");
13
+ const hasGlab = () => {
14
+ try {
15
+ (0, child_process_1.execSync)('glab --version', { stdio: 'ignore' });
16
+ return true;
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ };
22
+ const glabAuthenticated = () => {
23
+ try {
24
+ (0, child_process_1.execSync)('glab auth status', { stdio: 'ignore' });
25
+ return true;
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ };
31
+ const getGitRemoteProject = () => {
32
+ try {
33
+ const remote = (0, child_process_1.execSync)('git remote get-url origin', { encoding: 'utf8' }).trim();
34
+ const match = remote.match(/gitlab\.com[:/](.+?)(\.git)?$/);
35
+ return match ? match[1] : null;
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ };
41
+ const setVariableGlab = (project, key, value, masked) => {
42
+ try {
43
+ (0, child_process_1.execSync)(`glab variable delete ${key} -R "${project}" 2>/dev/null || true`, { stdio: 'ignore' });
44
+ const maskFlag = masked ? '--masked' : '';
45
+ (0, child_process_1.execSync)(`echo "${value}" | glab variable set ${key} -R "${project}" ${maskFlag}`, { stdio: 'pipe' });
46
+ return true;
47
+ }
48
+ catch {
49
+ return false;
50
+ }
51
+ };
52
+ const setVariableApi = async (baseUrl, pat, projectPath, key, value, masked) => {
53
+ const projectEncoded = encodeURIComponent(projectPath);
54
+ const apiUrl = `${baseUrl}/api/v4/projects/${projectEncoded}/variables`;
55
+ try {
56
+ // Delete existing
57
+ await axios_1.default.delete(`${apiUrl}/${key}`, {
58
+ headers: { 'PRIVATE-TOKEN': pat }
59
+ }).catch(() => { });
60
+ // Create new
61
+ await axios_1.default.post(apiUrl, {
62
+ key,
63
+ value,
64
+ masked,
65
+ protected: false
66
+ }, {
67
+ headers: { 'PRIVATE-TOKEN': pat }
68
+ });
69
+ return true;
70
+ }
71
+ catch (err) {
72
+ return false;
73
+ }
74
+ };
75
+ const setVariable = async (auth, project, key, value, masked = false) => {
76
+ if (auth.type === 'glab') {
77
+ return setVariableGlab(project, key, value, masked);
78
+ }
79
+ else {
80
+ return setVariableApi(auth.baseUrl, auth.pat, project, key, value, masked);
81
+ }
82
+ };
83
+ const generateCiJob = (runnerTag) => {
84
+ const tagsSection = runnerTag ? `\n tags:\n - ${runnerTag}` : '';
85
+ return `# GitGreen carbon analysis (generated by gitgreen init)
86
+ # Fetches real CPU and RAM metrics from GCP Monitoring API
87
+
88
+ carbon_analysis:
89
+ stage: test${tagsSection}
90
+ before_script:
91
+ - echo "$GCP_SA_KEY_BASE64" | base64 -d > /tmp/gcp-key.json
92
+ - gcloud auth activate-service-account --key-file=/tmp/gcp-key.json
93
+ - npm install ./gitgreen-cli-0.1.0.tgz
94
+ script:
95
+ - |
96
+ TOKEN=$(gcloud auth print-access-token)
97
+ START_TIME=$(date -u -d "$CI_PIPELINE_CREATED_AT" +%Y-%m-%dT%H:%M:%SZ)
98
+ END_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
99
+
100
+ CPU_FILTER="resource.type=\\"gce_instance\\" AND resource.labels.instance_id=\\"$GCP_INSTANCE_ID\\" AND resource.labels.zone=\\"$GCP_ZONE\\" AND metric.type=\\"compute.googleapis.com/instance/cpu/utilization\\""
101
+ CPU_ENCODED=$(python3 -c "import urllib.parse; print(urllib.parse.quote('''$CPU_FILTER'''))")
102
+
103
+ RAM_USED_FILTER="resource.type=\\"gce_instance\\" AND resource.labels.instance_id=\\"$GCP_INSTANCE_ID\\" AND metric.type=\\"compute.googleapis.com/instance/memory/balloon/ram_used\\""
104
+ RAM_USED_ENCODED=$(python3 -c "import urllib.parse; print(urllib.parse.quote('''$RAM_USED_FILTER'''))")
105
+
106
+ RAM_SIZE_FILTER="resource.type=\\"gce_instance\\" AND resource.labels.instance_id=\\"$GCP_INSTANCE_ID\\" AND metric.type=\\"compute.googleapis.com/instance/memory/balloon/ram_size\\""
107
+ RAM_SIZE_ENCODED=$(python3 -c "import urllib.parse; print(urllib.parse.quote('''$RAM_SIZE_FILTER'''))")
108
+
109
+ # Calculate expected data points (GCP reports every 60s)
110
+ START_SEC=$(date -d "$START_TIME" +%s)
111
+ NOW_SEC=$(date +%s)
112
+ DURATION_SEC=$((NOW_SEC - START_SEC))
113
+ EXPECTED_POINTS=$((DURATION_SEC / 60))
114
+ [ "$EXPECTED_POINTS" -lt 1 ] && EXPECTED_POINTS=1
115
+
116
+ echo "Pipeline duration: \${DURATION_SEC}s, expecting ~\$EXPECTED_POINTS data points"
117
+ echo "Waiting for metrics data (GCP has ~3 min lag)..."
118
+
119
+ for i in $(seq 1 30); do
120
+ END_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
121
+ CPU_DATA=$(curl -s -H "Authorization: Bearer $TOKEN" \\
122
+ "https://monitoring.googleapis.com/v3/projects/$GCP_PROJECT_ID/timeSeries?filter=$CPU_ENCODED&interval.startTime=$START_TIME&interval.endTime=$END_TIME")
123
+
124
+ POINTS=$(echo "$CPU_DATA" | python3 -c "import sys,json; d=json.load(sys.stdin); print(sum(len(ts.get('points',[])) for ts in d.get('timeSeries',[])))" 2>/dev/null || echo "0")
125
+
126
+ if [ "$POINTS" -ge "$EXPECTED_POINTS" ]; then
127
+ echo "Got $POINTS CPU data points (expected $EXPECTED_POINTS)"
128
+ break
129
+ fi
130
+ echo "Waiting for data... got $POINTS/$EXPECTED_POINTS (attempt $i/30)"
131
+ sleep 10
132
+ done
133
+
134
+ # Fetch RAM data
135
+ RAM_USED_DATA=$(curl -s -H "Authorization: Bearer $TOKEN" \\
136
+ "https://monitoring.googleapis.com/v3/projects/$GCP_PROJECT_ID/timeSeries?filter=$RAM_USED_ENCODED&interval.startTime=$START_TIME&interval.endTime=$END_TIME")
137
+
138
+ RAM_SIZE_DATA=$(curl -s -H "Authorization: Bearer $TOKEN" \\
139
+ "https://monitoring.googleapis.com/v3/projects/$GCP_PROJECT_ID/timeSeries?filter=$RAM_SIZE_ENCODED&interval.startTime=$START_TIME&interval.endTime=$END_TIME")
140
+
141
+ # Save timeseries to files
142
+ echo "$CPU_DATA" > /tmp/cpu_timeseries.json
143
+ echo "$RAM_USED_DATA" > /tmp/ram_used_timeseries.json
144
+ echo "$RAM_SIZE_DATA" > /tmp/ram_size_timeseries.json
145
+
146
+ CMD="./node_modules/.bin/gitgreen --provider gcp --machine $MACHINE_TYPE --region $GCP_ZONE --cpu-timeseries /tmp/cpu_timeseries.json --ram-used-timeseries /tmp/ram_used_timeseries.json --ram-size-timeseries /tmp/ram_size_timeseries.json --out-md carbon-report.md --out-json carbon-report.json --no-gitlab"
147
+ [ -n "\${CARBON_BUDGET_GRAMS:-}" ] && CMD="$CMD --budget $CARBON_BUDGET_GRAMS"
148
+ [ "\${FAIL_ON_BUDGET:-}" = "true" ] && CMD="$CMD --fail-on-budget"
149
+
150
+ eval "$CMD"
151
+ cat carbon-report.md
152
+ artifacts:
153
+ paths:
154
+ - carbon-report.md
155
+ - carbon-report.json
156
+ expire_in: 30 days
157
+ `;
158
+ };
159
+ const runInit = async (opts = {}) => {
160
+ console.log((0, kleur_1.bold)('\nGitGreen Setup\n'));
161
+ // Step 1: GitLab Authentication
162
+ console.log((0, kleur_1.gray)('Step 1: GitLab Authentication'));
163
+ console.log((0, kleur_1.gray)('─'.repeat(40)));
164
+ let auth;
165
+ const useGlab = hasGlab() && glabAuthenticated();
166
+ if (useGlab) {
167
+ console.log((0, kleur_1.green)('Using glab CLI (authenticated)'));
168
+ auth = { type: 'glab', baseUrl: 'https://gitlab.com' };
169
+ }
170
+ else {
171
+ if (hasGlab()) {
172
+ console.log('glab found but not authenticated. Run: glab auth login');
173
+ console.log('Or provide a Personal Access Token.\n');
174
+ }
175
+ else {
176
+ console.log('glab CLI not found. Using GitLab API.\n');
177
+ }
178
+ const { pat } = await (0, prompts_1.default)({
179
+ type: 'password',
180
+ name: 'pat',
181
+ message: 'GitLab Personal Access Token (api scope)'
182
+ });
183
+ if (!pat) {
184
+ console.log((0, kleur_1.red)('PAT required'));
185
+ process.exit(1);
186
+ }
187
+ const { baseUrl } = await (0, prompts_1.default)({
188
+ type: 'text',
189
+ name: 'baseUrl',
190
+ message: 'GitLab URL',
191
+ initial: 'https://gitlab.com'
192
+ });
193
+ auth = { type: 'pat', pat, baseUrl };
194
+ }
195
+ // Step 2: Project
196
+ console.log((0, kleur_1.gray)('\nStep 2: GitLab Project'));
197
+ console.log((0, kleur_1.gray)('─'.repeat(40)));
198
+ const detectedProject = getGitRemoteProject();
199
+ const { projectPath } = await (0, prompts_1.default)({
200
+ type: 'text',
201
+ name: 'projectPath',
202
+ message: 'Project (owner/repo)',
203
+ initial: detectedProject || ''
204
+ });
205
+ if (!projectPath) {
206
+ console.log((0, kleur_1.red)('Project required'));
207
+ process.exit(1);
208
+ }
209
+ // Step 3: Cloud Provider
210
+ console.log((0, kleur_1.gray)('\nStep 3: Cloud Provider'));
211
+ console.log((0, kleur_1.gray)('─'.repeat(40)));
212
+ const { provider } = await (0, prompts_1.default)({
213
+ type: 'select',
214
+ name: 'provider',
215
+ message: 'Runner cloud provider',
216
+ choices: [
217
+ { title: 'GCP (Google Cloud)', value: 'gcp' },
218
+ { title: 'AWS', value: 'aws' },
219
+ { title: 'Manual configuration', value: 'manual' }
220
+ ]
221
+ });
222
+ if (provider === 'aws') {
223
+ console.log((0, kleur_1.red)('\nAWS support coming soon.'));
224
+ process.exit(1);
225
+ }
226
+ if (provider === 'manual') {
227
+ console.log((0, kleur_1.red)('\nManual configuration coming soon.'));
228
+ process.exit(1);
229
+ }
230
+ // Check if gcloud is available
231
+ let hasGcloud = false;
232
+ try {
233
+ (0, child_process_1.execSync)('gcloud --version', { stdio: 'ignore' });
234
+ hasGcloud = true;
235
+ }
236
+ catch { }
237
+ if (!hasGcloud) {
238
+ console.log((0, kleur_1.red)('gcloud CLI not found. Install it from https://cloud.google.com/sdk/docs/install'));
239
+ process.exit(1);
240
+ }
241
+ // GCP Project Selection (needed for both runner provisioning and config)
242
+ console.log((0, kleur_1.gray)('\nStep 4: GCP Project'));
243
+ console.log((0, kleur_1.gray)('─'.repeat(40)));
244
+ let gcpProjectId;
245
+ console.log((0, kleur_1.gray)('Fetching GCP projects...'));
246
+ let projects = [];
247
+ try {
248
+ const projectsJson = (0, child_process_1.execSync)('gcloud projects list --format="json"', { encoding: 'utf8' });
249
+ projects = JSON.parse(projectsJson).map((p) => ({ id: p.projectId, name: p.name }));
250
+ }
251
+ catch { }
252
+ if (projects.length > 0) {
253
+ const projectChoices = [
254
+ ...projects.map(p => ({ title: `${p.id} (${p.name})`, value: p.id })),
255
+ { title: 'Enter manually', value: '_manual_' }
256
+ ];
257
+ const { selectedProject } = await (0, prompts_1.default)({
258
+ type: 'select',
259
+ name: 'selectedProject',
260
+ message: 'GCP Project',
261
+ choices: projectChoices
262
+ });
263
+ if (selectedProject === '_manual_') {
264
+ const { manualProject } = await (0, prompts_1.default)({
265
+ type: 'text',
266
+ name: 'manualProject',
267
+ message: 'GCP Project ID'
268
+ });
269
+ gcpProjectId = manualProject;
270
+ }
271
+ else {
272
+ gcpProjectId = selectedProject;
273
+ }
274
+ }
275
+ else {
276
+ const { manualProject } = await (0, prompts_1.default)({
277
+ type: 'text',
278
+ name: 'manualProject',
279
+ message: 'GCP Project ID'
280
+ });
281
+ gcpProjectId = manualProject;
282
+ }
283
+ // Step 5: Runner Setup
284
+ console.log((0, kleur_1.gray)('\nStep 5: Runner Setup'));
285
+ console.log((0, kleur_1.gray)('─'.repeat(40)));
286
+ const { runnerSetup } = await (0, prompts_1.default)({
287
+ type: 'select',
288
+ name: 'runnerSetup',
289
+ message: 'GitLab Runner',
290
+ choices: [
291
+ { title: 'Use existing runner', value: 'existing' },
292
+ { title: 'Provision new GCP runner VM', value: 'provision-gcp' },
293
+ { title: 'Provision new AWS runner (coming soon)', value: 'provision-aws' }
294
+ ]
295
+ });
296
+ if (runnerSetup === 'provision-aws') {
297
+ console.log((0, kleur_1.red)('\nAWS runner provisioning coming soon.'));
298
+ process.exit(1);
299
+ }
300
+ let gcpZone = '';
301
+ let gcpInstanceId = '';
302
+ let machineType = '';
303
+ let runnerTag = '';
304
+ if (runnerSetup === 'provision-gcp') {
305
+ console.log((0, kleur_1.gray)('\nProvisioning GCP Runner VM...'));
306
+ console.log((0, kleur_1.gray)('─'.repeat(40)));
307
+ const { vmZone } = await (0, prompts_1.default)({
308
+ type: 'text',
309
+ name: 'vmZone',
310
+ message: 'GCP zone for runner VM',
311
+ initial: 'us-central1-a'
312
+ });
313
+ gcpZone = vmZone;
314
+ const { vmMachineType } = await (0, prompts_1.default)({
315
+ type: 'text',
316
+ name: 'vmMachineType',
317
+ message: 'Machine type',
318
+ initial: 'e2-standard-4'
319
+ });
320
+ machineType = vmMachineType;
321
+ const { vmName } = await (0, prompts_1.default)({
322
+ type: 'text',
323
+ name: 'vmName',
324
+ message: 'Runner VM name',
325
+ initial: 'gcp-carbon-runner'
326
+ });
327
+ const { vmTag } = await (0, prompts_1.default)({
328
+ type: 'text',
329
+ name: 'vmTag',
330
+ message: 'Runner tag (for routing CI jobs)',
331
+ initial: 'gcp'
332
+ });
333
+ runnerTag = vmTag;
334
+ // Create runner token via GitLab API
335
+ let runnerToken = '';
336
+ if (auth.type === 'glab') {
337
+ console.log((0, kleur_1.gray)('Creating runner token via GitLab API...'));
338
+ try {
339
+ const projectEncoded = encodeURIComponent(projectPath);
340
+ // Get project ID first
341
+ const projectInfoJson = (0, child_process_1.execSync)(`glab api "projects/${projectEncoded}"`, { encoding: 'utf8' });
342
+ const projectInfo = JSON.parse(projectInfoJson);
343
+ const projectId = projectInfo.id;
344
+ const createResult = (0, child_process_1.execSync)(`glab api --method POST "user/runners" -f runner_type=project_type -f project_id=${projectId} -f description="${vmName}" -f tag_list="${runnerTag}" -f run_untagged=true`, { encoding: 'utf8' });
345
+ const runnerData = JSON.parse(createResult);
346
+ runnerToken = runnerData.token;
347
+ console.log((0, kleur_1.green)(`Runner token created (ID: ${runnerData.id})`));
348
+ }
349
+ catch (err) {
350
+ console.log((0, kleur_1.gray)('Could not create token via API, falling back to manual...'));
351
+ }
352
+ }
353
+ // Fallback: ask user for token
354
+ if (!runnerToken) {
355
+ console.log((0, kleur_1.gray)('\nCreate a runner token in GitLab (check "Run untagged jobs"):'));
356
+ console.log((0, kleur_1.green)(` https://gitlab.com/${projectPath}/-/runners/new`));
357
+ const { manualToken } = await (0, prompts_1.default)({
358
+ type: 'password',
359
+ name: 'manualToken',
360
+ message: 'GitLab runner token (glrt-...)'
361
+ });
362
+ runnerToken = manualToken;
363
+ }
364
+ if (!runnerToken) {
365
+ console.log((0, kleur_1.red)('Runner token required'));
366
+ process.exit(1);
367
+ }
368
+ // Create service account
369
+ const saName = 'gitlab-runner';
370
+ const saEmail = `${saName}@${gcpProjectId}.iam.gserviceaccount.com`;
371
+ console.log((0, kleur_1.gray)(`Creating service account: ${saEmail}`));
372
+ try {
373
+ (0, child_process_1.execSync)(`gcloud iam service-accounts create ${saName} --project=${gcpProjectId} --display-name="GitLab Runner" 2>/dev/null || true`, { stdio: 'pipe' });
374
+ }
375
+ catch { }
376
+ // Grant monitoring role
377
+ console.log((0, kleur_1.gray)('Granting Monitoring Viewer role...'));
378
+ try {
379
+ (0, child_process_1.execSync)(`gcloud projects add-iam-policy-binding ${gcpProjectId} --member="serviceAccount:${saEmail}" --role="roles/monitoring.viewer" --quiet 2>/dev/null`, { stdio: 'pipe' });
380
+ }
381
+ catch { }
382
+ // Check if VM exists
383
+ let vmExists = false;
384
+ try {
385
+ (0, child_process_1.execSync)(`gcloud compute instances describe ${vmName} --project=${gcpProjectId} --zone=${gcpZone} 2>/dev/null`, { stdio: 'pipe' });
386
+ vmExists = true;
387
+ }
388
+ catch { }
389
+ const startupScript = `#!/usr/bin/env bash
390
+ set -euo pipefail
391
+ apt-get update
392
+ apt-get install -y curl ca-certificates python3
393
+
394
+ # Install Node.js 20.x
395
+ curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
396
+ apt-get install -y nodejs
397
+
398
+ # Install GitLab Runner
399
+ curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh | bash
400
+ apt-get install -y gitlab-runner
401
+
402
+ INSTANCE_ID=$(curl -s -H "Metadata-Flavor: Google" http://metadata.google.internal/computeMetadata/v1/instance/id)
403
+ TOKEN=$(curl -s -H "Metadata-Flavor: Google" http://metadata.google.internal/computeMetadata/v1/instance/attributes/runner-token)
404
+
405
+ gitlab-runner register \\
406
+ --non-interactive \\
407
+ --url https://gitlab.com/ \\
408
+ --token "$TOKEN" \\
409
+ --executor shell \\
410
+ --description "${vmName}"
411
+
412
+ echo "$INSTANCE_ID" > /opt/gitlab-runner-instance-id.txt
413
+
414
+ systemctl enable gitlab-runner
415
+ systemctl start gitlab-runner
416
+ `;
417
+ const tmpFile = `/tmp/gitgreen-startup-${Date.now()}.sh`;
418
+ fs_1.default.writeFileSync(tmpFile, startupScript);
419
+ if (vmExists) {
420
+ console.log((0, kleur_1.gray)(`VM ${vmName} exists, re-registering runner...`));
421
+ try {
422
+ (0, child_process_1.execSync)(`gcloud compute instances add-metadata ${vmName} --project=${gcpProjectId} --zone=${gcpZone} --metadata=runner-token="${runnerToken}"`, { stdio: 'pipe' });
423
+ (0, child_process_1.execSync)(`gcloud compute ssh ${vmName} --project=${gcpProjectId} --zone=${gcpZone} --command="sudo gitlab-runner unregister --all-runners 2>/dev/null || true; INSTANCE_ID=\\$(curl -s -H 'Metadata-Flavor: Google' http://metadata.google.internal/computeMetadata/v1/instance/id); TOKEN=\\$(curl -s -H 'Metadata-Flavor: Google' http://metadata.google.internal/computeMetadata/v1/instance/attributes/runner-token); sudo gitlab-runner register --non-interactive --url https://gitlab.com/ --token \\$TOKEN --executor shell --description '${vmName}'; echo \\$INSTANCE_ID | sudo tee /opt/gitlab-runner-instance-id.txt; sudo systemctl restart gitlab-runner"`, { stdio: 'inherit' });
424
+ // Get instance ID
425
+ gcpInstanceId = (0, child_process_1.execSync)(`gcloud compute ssh ${vmName} --project=${gcpProjectId} --zone=${gcpZone} --command="cat /opt/gitlab-runner-instance-id.txt"`, { encoding: 'utf8' }).trim();
426
+ }
427
+ catch (err) {
428
+ console.log((0, kleur_1.red)('Failed to re-register runner: ' + err.message));
429
+ process.exit(1);
430
+ }
431
+ }
432
+ else {
433
+ console.log((0, kleur_1.gray)(`Creating VM ${vmName}...`));
434
+ try {
435
+ (0, child_process_1.execSync)(`gcloud compute instances create ${vmName} --project=${gcpProjectId} --zone=${gcpZone} --machine-type=${vmMachineType} --service-account=${saEmail} --scopes=https://www.googleapis.com/auth/cloud-platform --metadata=runner-token="${runnerToken}" --metadata-from-file=startup-script=${tmpFile} --image-family=debian-12 --image-project=debian-cloud`, { stdio: 'inherit' });
436
+ console.log((0, kleur_1.green)(`VM ${vmName} created. Runner will register in 2-3 minutes.`));
437
+ console.log((0, kleur_1.gray)('Waiting for runner to register...'));
438
+ // Wait for instance ID file
439
+ for (let i = 0; i < 30; i++) {
440
+ await new Promise(r => setTimeout(r, 10000));
441
+ try {
442
+ gcpInstanceId = (0, child_process_1.execSync)(`gcloud compute ssh ${vmName} --project=${gcpProjectId} --zone=${gcpZone} --command="cat /opt/gitlab-runner-instance-id.txt 2>/dev/null"`, { encoding: 'utf8' }).trim();
443
+ if (gcpInstanceId) {
444
+ console.log((0, kleur_1.green)(`Runner registered. Instance ID: ${gcpInstanceId}`));
445
+ break;
446
+ }
447
+ }
448
+ catch { }
449
+ console.log((0, kleur_1.gray)(`Waiting... (${i + 1}/30)`));
450
+ }
451
+ }
452
+ catch (err) {
453
+ console.log((0, kleur_1.red)('Failed to create VM: ' + err.message));
454
+ process.exit(1);
455
+ }
456
+ }
457
+ fs_1.default.unlinkSync(tmpFile);
458
+ console.log((0, kleur_1.green)('Runner provisioning complete!'));
459
+ }
460
+ // Step 6: GCP Instance Configuration (if using existing runner)
461
+ console.log((0, kleur_1.gray)('\nStep 6: GCP Instance Configuration'));
462
+ console.log((0, kleur_1.gray)('─'.repeat(40)));
463
+ // If we provisioned a runner, we already have the values
464
+ if (runnerSetup === 'provision-gcp' && gcpInstanceId && gcpZone && machineType) {
465
+ console.log((0, kleur_1.green)(`Using provisioned runner: ${gcpZone}, ${machineType}, instance-${gcpInstanceId}`));
466
+ }
467
+ else {
468
+ // List instances in project
469
+ console.log((0, kleur_1.gray)('Fetching instances...'));
470
+ let instances = [];
471
+ try {
472
+ const instancesJson = (0, child_process_1.execSync)(`gcloud compute instances list --project=${gcpProjectId} --format="json"`, { encoding: 'utf8' });
473
+ instances = JSON.parse(instancesJson).map((i) => ({
474
+ name: i.name,
475
+ zone: i.zone.split('/').pop(),
476
+ id: i.id,
477
+ machineType: i.machineType.split('/').pop()
478
+ }));
479
+ }
480
+ catch { }
481
+ if (instances.length > 0) {
482
+ const instanceChoices = [
483
+ ...instances.map(i => ({
484
+ title: `${i.name} (${i.zone}, ${i.machineType})`,
485
+ value: i
486
+ })),
487
+ { title: 'Enter manually', value: '_manual_' }
488
+ ];
489
+ const { selectedInstance } = await (0, prompts_1.default)({
490
+ type: 'select',
491
+ name: 'selectedInstance',
492
+ message: 'GCP Instance',
493
+ choices: instanceChoices
494
+ });
495
+ if (selectedInstance === '_manual_') {
496
+ const { manualZone } = await (0, prompts_1.default)({
497
+ type: 'text',
498
+ name: 'manualZone',
499
+ message: 'GCP Zone (e.g., us-central1-a)'
500
+ });
501
+ gcpZone = manualZone;
502
+ const { manualInstanceId } = await (0, prompts_1.default)({
503
+ type: 'text',
504
+ name: 'manualInstanceId',
505
+ message: 'GCP Instance ID (numeric)'
506
+ });
507
+ gcpInstanceId = manualInstanceId;
508
+ const { manualMachineType } = await (0, prompts_1.default)({
509
+ type: 'text',
510
+ name: 'manualMachineType',
511
+ message: 'Machine type',
512
+ initial: 'e2-standard-4'
513
+ });
514
+ machineType = manualMachineType;
515
+ }
516
+ else {
517
+ gcpZone = selectedInstance.zone;
518
+ gcpInstanceId = selectedInstance.id;
519
+ machineType = selectedInstance.machineType;
520
+ const selectedVmName = selectedInstance.name;
521
+ console.log((0, kleur_1.green)(` Selected: ${selectedVmName}`));
522
+ console.log((0, kleur_1.gray)(` Zone: ${gcpZone}`));
523
+ console.log((0, kleur_1.gray)(` Instance ID: ${gcpInstanceId}`));
524
+ console.log((0, kleur_1.gray)(` Machine: ${machineType}`));
525
+ // Ask if they want to register this VM as a runner for this project
526
+ const { registerRunner } = await (0, prompts_1.default)({
527
+ type: 'confirm',
528
+ name: 'registerRunner',
529
+ message: 'Register this VM as a runner for this GitLab project?',
530
+ initial: true
531
+ });
532
+ if (registerRunner) {
533
+ const { existingRunnerTag } = await (0, prompts_1.default)({
534
+ type: 'text',
535
+ name: 'existingRunnerTag',
536
+ message: 'Runner tag (for routing CI jobs)',
537
+ initial: 'gcp'
538
+ });
539
+ runnerTag = existingRunnerTag;
540
+ let runnerToken = '';
541
+ // Try to create runner token via glab API first
542
+ if (auth.type === 'glab') {
543
+ console.log((0, kleur_1.gray)('Creating runner token via GitLab API...'));
544
+ try {
545
+ const projectEncoded = encodeURIComponent(projectPath);
546
+ // Get project ID first
547
+ const projectInfoJson = (0, child_process_1.execSync)(`glab api "projects/${projectEncoded}"`, { encoding: 'utf8' });
548
+ const projectInfo = JSON.parse(projectInfoJson);
549
+ const projectId = projectInfo.id;
550
+ const createResult = (0, child_process_1.execSync)(`glab api --method POST "user/runners" -f runner_type=project_type -f project_id=${projectId} -f description="${selectedVmName}" -f tag_list="${runnerTag}" -f run_untagged=true`, { encoding: 'utf8' });
551
+ const runnerData = JSON.parse(createResult);
552
+ runnerToken = runnerData.token;
553
+ console.log((0, kleur_1.green)(`Runner token created (ID: ${runnerData.id})`));
554
+ }
555
+ catch (err) {
556
+ console.log((0, kleur_1.gray)('Could not create token via API, falling back to manual...'));
557
+ }
558
+ }
559
+ // Fallback: ask user for token
560
+ if (!runnerToken) {
561
+ console.log((0, kleur_1.gray)('\nCreate a runner token in GitLab (check "Run untagged jobs"):'));
562
+ console.log((0, kleur_1.green)(` https://gitlab.com/${projectPath}/-/runners/new`));
563
+ const { manualToken } = await (0, prompts_1.default)({
564
+ type: 'password',
565
+ name: 'manualToken',
566
+ message: 'GitLab runner token (glrt-...)'
567
+ });
568
+ runnerToken = manualToken;
569
+ }
570
+ if (runnerToken) {
571
+ console.log((0, kleur_1.gray)('Registering runner on VM...'));
572
+ try {
573
+ // Update runner token metadata
574
+ (0, child_process_1.execSync)(`gcloud compute instances add-metadata ${selectedVmName} --project=${gcpProjectId} --zone=${gcpZone} --metadata=runner-token="${runnerToken}"`, { stdio: 'pipe' });
575
+ // SSH and re-register
576
+ (0, child_process_1.execSync)(`gcloud compute ssh ${selectedVmName} --project=${gcpProjectId} --zone=${gcpZone} --command="sudo gitlab-runner unregister --all-runners 2>/dev/null || true; TOKEN=\\$(curl -s -H 'Metadata-Flavor: Google' http://metadata.google.internal/computeMetadata/v1/instance/attributes/runner-token); sudo gitlab-runner register --non-interactive --url https://gitlab.com/ --token \\$TOKEN --executor shell --description '${selectedVmName}'; sudo systemctl restart gitlab-runner"`, { stdio: 'inherit' });
577
+ console.log((0, kleur_1.green)('Runner registered!'));
578
+ }
579
+ catch (err) {
580
+ console.log((0, kleur_1.red)('Failed to register runner: ' + err.message));
581
+ console.log((0, kleur_1.gray)('You may need to register manually or check VM access.'));
582
+ }
583
+ }
584
+ }
585
+ }
586
+ }
587
+ else {
588
+ console.log((0, kleur_1.gray)('No instances found in project'));
589
+ const { manualZone } = await (0, prompts_1.default)({
590
+ type: 'text',
591
+ name: 'manualZone',
592
+ message: 'GCP Zone (e.g., us-central1-a)'
593
+ });
594
+ gcpZone = manualZone;
595
+ const { manualInstanceId } = await (0, prompts_1.default)({
596
+ type: 'text',
597
+ name: 'manualInstanceId',
598
+ message: 'GCP Instance ID (numeric)'
599
+ });
600
+ gcpInstanceId = manualInstanceId;
601
+ const { manualMachineType } = await (0, prompts_1.default)({
602
+ type: 'text',
603
+ name: 'manualMachineType',
604
+ message: 'Machine type',
605
+ initial: 'e2-standard-4'
606
+ });
607
+ machineType = manualMachineType;
608
+ }
609
+ } // end else (use existing runner)
610
+ // Step 7: Service Account
611
+ console.log((0, kleur_1.gray)('\nStep 5: Service Account'));
612
+ console.log((0, kleur_1.gray)('─'.repeat(40)));
613
+ let saKeyBase64;
614
+ const { keyMethod } = await (0, prompts_1.default)({
615
+ type: 'select',
616
+ name: 'keyMethod',
617
+ message: 'Service account key',
618
+ choices: [
619
+ { title: 'Create with gcloud (recommended)', value: 'gcloud' },
620
+ { title: 'Use existing key file', value: 'file' }
621
+ ]
622
+ });
623
+ if (keyMethod === 'gcloud') {
624
+ const defaultSa = `gitlab-runner@${gcpProjectId}.iam.gserviceaccount.com`;
625
+ const { saEmail } = await (0, prompts_1.default)({
626
+ type: 'text',
627
+ name: 'saEmail',
628
+ message: 'Service account email',
629
+ initial: defaultSa
630
+ });
631
+ // Check if SA exists, create if not
632
+ console.log((0, kleur_1.gray)('Checking service account...'));
633
+ try {
634
+ (0, child_process_1.execSync)(`gcloud iam service-accounts describe ${saEmail} --project=${gcpProjectId}`, { stdio: 'ignore' });
635
+ console.log((0, kleur_1.gray)('Service account exists'));
636
+ }
637
+ catch {
638
+ console.log((0, kleur_1.gray)('Creating service account...'));
639
+ const saName = saEmail.split('@')[0];
640
+ (0, child_process_1.execSync)(`gcloud iam service-accounts create ${saName} --project=${gcpProjectId} --display-name="GitGreen Runner"`, { stdio: 'inherit' });
641
+ }
642
+ // Grant monitoring viewer role
643
+ console.log((0, kleur_1.gray)('Granting Monitoring Viewer role...'));
644
+ try {
645
+ (0, child_process_1.execSync)(`gcloud projects add-iam-policy-binding ${gcpProjectId} --member="serviceAccount:${saEmail}" --role="roles/monitoring.viewer" --quiet`, { stdio: 'ignore' });
646
+ }
647
+ catch { }
648
+ // Create key
649
+ console.log((0, kleur_1.gray)('Creating key...'));
650
+ const tmpKeyPath = `/tmp/gitgreen-sa-key-${Date.now()}.json`;
651
+ (0, child_process_1.execSync)(`gcloud iam service-accounts keys create ${tmpKeyPath} --iam-account=${saEmail}`, { stdio: 'inherit' });
652
+ saKeyBase64 = fs_1.default.readFileSync(tmpKeyPath).toString('base64');
653
+ fs_1.default.unlinkSync(tmpKeyPath);
654
+ console.log((0, kleur_1.green)('Service account key created'));
655
+ }
656
+ else {
657
+ const { saKeyPath } = await (0, prompts_1.default)({
658
+ type: 'text',
659
+ name: 'saKeyPath',
660
+ message: 'Path to service account JSON key'
661
+ });
662
+ if (!fs_1.default.existsSync(saKeyPath)) {
663
+ console.log((0, kleur_1.red)('File not found: ' + saKeyPath));
664
+ process.exit(1);
665
+ }
666
+ saKeyBase64 = fs_1.default.readFileSync(saKeyPath).toString('base64');
667
+ }
668
+ // Step 6: Electricity Maps API
669
+ console.log((0, kleur_1.gray)('\nStep 6: Electricity Maps API'));
670
+ console.log((0, kleur_1.gray)('─'.repeat(40)));
671
+ console.log((0, kleur_1.gray)('Get free key: https://api-portal.electricitymaps.com'));
672
+ const { electricityMapsKey } = await (0, prompts_1.default)({
673
+ type: 'password',
674
+ name: 'electricityMapsKey',
675
+ message: 'Electricity Maps API Key'
676
+ });
677
+ if (!electricityMapsKey) {
678
+ console.log((0, kleur_1.red)('API key required'));
679
+ process.exit(1);
680
+ }
681
+ // Step 7: Optional
682
+ console.log((0, kleur_1.gray)('\nStep 7: Optional Settings'));
683
+ console.log((0, kleur_1.gray)('─'.repeat(40)));
684
+ console.log((0, kleur_1.gray)('Set a carbon budget to track emissions against a limit.'));
685
+ console.log((0, kleur_1.gray)('Example: 10 grams CO2e per job. Leave empty to skip.\n'));
686
+ const { carbonBudget } = await (0, prompts_1.default)({
687
+ type: 'number',
688
+ name: 'carbonBudget',
689
+ message: 'Carbon budget (grams CO2e)',
690
+ initial: undefined
691
+ });
692
+ let failOnBudget = false;
693
+ if (carbonBudget) {
694
+ const { shouldFail } = await (0, prompts_1.default)({
695
+ type: 'confirm',
696
+ name: 'shouldFail',
697
+ message: 'Fail CI job if over budget?',
698
+ initial: false
699
+ });
700
+ failOnBudget = shouldFail;
701
+ }
702
+ // Step 8: Set Variables
703
+ console.log((0, kleur_1.gray)('\nStep 8: Setting CI/CD Variables'));
704
+ console.log((0, kleur_1.gray)('─'.repeat(40)));
705
+ const variables = [
706
+ { key: 'GCP_PROJECT_ID', value: gcpProjectId, masked: false },
707
+ { key: 'GCP_ZONE', value: gcpZone, masked: false },
708
+ { key: 'GCP_INSTANCE_ID', value: gcpInstanceId, masked: false },
709
+ { key: 'MACHINE_TYPE', value: machineType, masked: false },
710
+ { key: 'GCP_SA_KEY_BASE64', value: saKeyBase64, masked: true },
711
+ { key: 'ELECTRICITY_MAPS_API_KEY', value: electricityMapsKey, masked: true }
712
+ ];
713
+ if (carbonBudget) {
714
+ variables.push({ key: 'CARBON_BUDGET_GRAMS', value: String(carbonBudget), masked: false });
715
+ }
716
+ if (failOnBudget) {
717
+ variables.push({ key: 'FAIL_ON_BUDGET', value: 'true', masked: false });
718
+ }
719
+ for (const v of variables) {
720
+ const ok = await setVariable(auth, projectPath, v.key, v.value, v.masked);
721
+ if (ok) {
722
+ console.log((0, kleur_1.green)(' Set ' + v.key));
723
+ }
724
+ else {
725
+ console.log((0, kleur_1.red)(' Failed: ' + v.key));
726
+ }
727
+ }
728
+ // Step 9: Generate CI job
729
+ console.log((0, kleur_1.gray)('\nStep 9: CI Configuration'));
730
+ console.log((0, kleur_1.gray)('─'.repeat(40)));
731
+ // Only prompt for runner tag if not already set from provisioning
732
+ if (!runnerTag) {
733
+ const { inputRunnerTag } = await (0, prompts_1.default)({
734
+ type: 'text',
735
+ name: 'inputRunnerTag',
736
+ message: 'Runner tag (leave empty for any runner)',
737
+ initial: ''
738
+ });
739
+ runnerTag = inputRunnerTag || '';
740
+ }
741
+ else {
742
+ console.log((0, kleur_1.gray)(`Using runner tag: ${runnerTag}`));
743
+ }
744
+ const { addCiJob } = await (0, prompts_1.default)({
745
+ type: 'confirm',
746
+ name: 'addCiJob',
747
+ message: 'Add job to .gitlab-ci.yml?',
748
+ initial: true
749
+ });
750
+ const ciJob = generateCiJob(runnerTag || undefined);
751
+ if (addCiJob) {
752
+ const ciPath = path_1.default.join(process.cwd(), '.gitlab-ci.yml');
753
+ if (fs_1.default.existsSync(ciPath)) {
754
+ // Backup existing file before modifying
755
+ const backupPath = `/tmp/gitlab-ci-backup-${Date.now()}.yml`;
756
+ const existing = fs_1.default.readFileSync(ciPath, 'utf8');
757
+ fs_1.default.writeFileSync(backupPath, existing);
758
+ console.log((0, kleur_1.gray)(`Backup saved to: ${backupPath}`));
759
+ fs_1.default.writeFileSync(ciPath, existing + '\n' + ciJob);
760
+ }
761
+ else {
762
+ fs_1.default.writeFileSync(ciPath, 'stages:\n - test\n\n' + ciJob);
763
+ }
764
+ console.log((0, kleur_1.green)('Updated .gitlab-ci.yml'));
765
+ }
766
+ else {
767
+ console.log('\nCI job snippet:\n');
768
+ console.log(ciJob);
769
+ }
770
+ console.log((0, kleur_1.bold)('\nDone'));
771
+ console.log('Commit and push to trigger the pipeline.\n');
772
+ };
773
+ exports.runInit = runInit;