create-interview-cockpit 0.22.0 → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -335,7 +335,12 @@ export default function WorkspaceSwitcher() {
335
335
  <>
336
336
  <div className="space-y-1 max-h-48 overflow-y-auto">
337
337
  <button
338
- onClick={() => runExport(folderPicker.ws)}
338
+ onClick={() =>
339
+ runExport(
340
+ folderPicker.ws,
341
+ folderPicker.ws.driveConfig?.folderId,
342
+ )
343
+ }
339
344
  className="w-full text-left px-3 py-2 rounded-lg text-xs text-slate-300 hover:bg-slate-800 flex items-center gap-2"
340
345
  >
341
346
  <FolderOpen size={13} className="text-slate-500 shrink-0" />
@@ -0,0 +1,216 @@
1
+ // ─── Shared GitHub Actions concurrency helpers ─────────────────────────
2
+ // Used by the GitHub Actions Lab to evaluate `concurrency:` blocks against
3
+ // a simulated github context AND to enforce GitHub's queue/cancel rules on
4
+ // the actual `act` runs triggered from the lab UI.
5
+
6
+ import { parse as parseYaml } from "yaml";
7
+
8
+ // ─── Types ───────────────────────────────────────────────────────────────
9
+
10
+ /**
11
+ * Subset of `github.*` context fields used when evaluating a concurrency
12
+ * group expression. Kept narrow on purpose so we don't oversell what the
13
+ * tiny expression engine below can resolve.
14
+ */
15
+ export interface GhaConcurrencyContext {
16
+ event_name: string;
17
+ ref: string;
18
+ head_ref: string;
19
+ workflow: string;
20
+ }
21
+
22
+ export interface ParsedConcurrency {
23
+ groupExpr: string;
24
+ cancelExpr: string;
25
+ }
26
+
27
+ export type GhaConcurrencyRunStatus =
28
+ | "pending"
29
+ | "running"
30
+ | "completed"
31
+ | "cancelled";
32
+
33
+ /**
34
+ * One record per Run button click. Records survive in memory for the
35
+ * lifetime of the modal so the user can see the full timeline of how
36
+ * GitHub's concurrency rules played out.
37
+ */
38
+ export interface GhaConcurrencyRun {
39
+ id: string;
40
+ seq: number;
41
+ command: string;
42
+ /** github.event_name when the run was triggered. */
43
+ eventName: string;
44
+ /** Workflow file path used for the run. */
45
+ workflowPath: string;
46
+ /** Evaluated group key — empty string when no concurrency block exists. */
47
+ groupKey: string;
48
+ /** Evaluated cancel-in-progress flag — false when not declared. */
49
+ cancelInProgress: boolean;
50
+ /** Snapshot of the github.* context the run was evaluated against. */
51
+ context: GhaConcurrencyContext;
52
+ status: GhaConcurrencyRunStatus;
53
+ startedAt?: number;
54
+ endedAt?: number;
55
+ exitCode?: number;
56
+ cancelReason?: string;
57
+ }
58
+
59
+ // ─── Parsing ────────────────────────────────────────────────────────────
60
+
61
+ /**
62
+ * Extract the workflow-level `concurrency` block. Tolerates both shapes
63
+ * GitHub accepts (`concurrency: my-group` and the long `group:` form).
64
+ * Returns null when the workflow doesn't declare concurrency.
65
+ */
66
+ export function parseConcurrencyBlock(
67
+ yaml: string | undefined,
68
+ ): ParsedConcurrency | null {
69
+ if (!yaml) return null;
70
+ let doc: unknown;
71
+ try {
72
+ doc = parseYaml(yaml);
73
+ } catch {
74
+ return null;
75
+ }
76
+ if (!doc || typeof doc !== "object") return null;
77
+ const raw = (doc as Record<string, unknown>).concurrency;
78
+ if (raw == null) return null;
79
+ if (typeof raw === "string") {
80
+ return { groupExpr: raw, cancelExpr: "false" };
81
+ }
82
+ if (typeof raw === "object") {
83
+ const obj = raw as Record<string, unknown>;
84
+ const group = typeof obj.group === "string" ? obj.group : "";
85
+ const cancel = obj["cancel-in-progress"];
86
+ const cancelExpr =
87
+ typeof cancel === "boolean"
88
+ ? String(cancel)
89
+ : typeof cancel === "string"
90
+ ? cancel
91
+ : "false";
92
+ return { groupExpr: group, cancelExpr };
93
+ }
94
+ return null;
95
+ }
96
+
97
+ // ─── Expression engine ─────────────────────────────────────────────────
98
+
99
+ /**
100
+ * Resolve `github.X` against the simulated context. Returns undefined for
101
+ * any other namespace so unknown references render as empty (matching
102
+ * GitHub's behaviour for undefined expressions inside `${{ ... }}`).
103
+ */
104
+ function resolveGithubPath(
105
+ path: string,
106
+ ctx: GhaConcurrencyContext,
107
+ ): string | undefined {
108
+ if (!path.startsWith("github.")) return undefined;
109
+ const key = path.slice("github.".length) as keyof GhaConcurrencyContext;
110
+ const value = ctx[key];
111
+ return typeof value === "string" ? value : undefined;
112
+ }
113
+
114
+ /**
115
+ * Evaluate one `${{ … }}` expression. Supports the subset real workflows
116
+ * use when defining concurrency keys:
117
+ * - github.X
118
+ * - 'string literal'
119
+ * - true / false / integer literals
120
+ * - X || Y (coalesce — first non-empty wins, GitHub semantics)
121
+ * - X == Y (string equality → 'true'/'false')
122
+ */
123
+ function evalConcurrencyExpr(expr: string, ctx: GhaConcurrencyContext): string {
124
+ const trimmed = expr.trim();
125
+ if (!trimmed) return "";
126
+
127
+ // Equality binds tighter than `||` in our tiny grammar (matches the way
128
+ // real workflows are normally written: `event_name == 'pull_request'`).
129
+ if (trimmed.includes("==")) {
130
+ const [lhs, rhs] = trimmed.split("==").map((s) => s.trim());
131
+ const l = evalConcurrencyExpr(lhs, ctx);
132
+ const r = evalConcurrencyExpr(rhs, ctx);
133
+ return l === r ? "true" : "false";
134
+ }
135
+
136
+ // Coalesce: walk left-to-right, return the first non-empty value.
137
+ if (trimmed.includes("||")) {
138
+ for (const part of trimmed.split("||")) {
139
+ const value = evalConcurrencyExpr(part, ctx);
140
+ if (value) return value;
141
+ }
142
+ return "";
143
+ }
144
+
145
+ const literal = trimmed.match(/^'([^']*)'$/);
146
+ if (literal) return literal[1];
147
+
148
+ if (trimmed === "true" || trimmed === "false") return trimmed;
149
+ if (/^-?\d+$/.test(trimmed)) return trimmed;
150
+
151
+ const resolved = resolveGithubPath(trimmed, ctx);
152
+ return resolved ?? "";
153
+ }
154
+
155
+ /**
156
+ * Replace every `${{ … }}` placeholder in `template` with the value its
157
+ * inner expression evaluates to against `ctx`.
158
+ */
159
+ export function renderConcurrencyTemplate(
160
+ template: string,
161
+ ctx: GhaConcurrencyContext,
162
+ ): string {
163
+ if (!template) return "";
164
+ return template.replace(/\$\{\{\s*([\s\S]*?)\s*\}\}/g, (_match, inner) =>
165
+ evalConcurrencyExpr(String(inner), ctx),
166
+ );
167
+ }
168
+
169
+ /**
170
+ * Compute the (groupKey, cancelInProgress) pair for a hypothetical run.
171
+ * When the workflow doesn't declare a concurrency block, returns
172
+ * (empty key, false) which means "no scheduling constraints — treat as
173
+ * its own group with no cancellation".
174
+ */
175
+ export function evaluateConcurrencyFor(
176
+ parsed: ParsedConcurrency | null,
177
+ ctx: GhaConcurrencyContext,
178
+ ): { groupKey: string; cancelInProgress: boolean } {
179
+ if (!parsed) return { groupKey: "", cancelInProgress: false };
180
+ const groupKey = renderConcurrencyTemplate(parsed.groupExpr, ctx);
181
+ const cancelRaw = renderConcurrencyTemplate(parsed.cancelExpr, ctx)
182
+ .trim()
183
+ .toLowerCase();
184
+ return { groupKey, cancelInProgress: cancelRaw === "true" };
185
+ }
186
+
187
+ /**
188
+ * Derive a plausible default github.* context from a selected `act` event
189
+ * name. We don't have the real one act injects, but these defaults are
190
+ * close enough that the evaluated group keys feel realistic.
191
+ */
192
+ export function defaultContextForEvent(
193
+ eventName: string,
194
+ workflowPath: string,
195
+ branch: string,
196
+ prNumber: number,
197
+ headRef: string,
198
+ ): GhaConcurrencyContext {
199
+ const workflowName = workflowPath
200
+ .replace(/^\.github\/workflows\//, "")
201
+ .replace(/\.ya?ml$/, "");
202
+ if (eventName === "pull_request") {
203
+ return {
204
+ event_name: eventName,
205
+ ref: `refs/pull/${prNumber}/merge`,
206
+ head_ref: headRef,
207
+ workflow: workflowName,
208
+ };
209
+ }
210
+ return {
211
+ event_name: eventName,
212
+ ref: `refs/heads/${branch}`,
213
+ head_ref: "",
214
+ workflow: workflowName,
215
+ };
216
+ }
@@ -1,4 +1,5 @@
1
1
  import type { GithubActionsLabWorkspace } from "./types";
2
+ import type { GithubActionsLabEnvironment } from "./types";
2
3
 
3
4
  // ─── Default Lab Template ────────────────────────────────────────────────
4
5
  //
@@ -401,6 +402,41 @@ export const REACT_VITE_TYPESCRIPT_GHA_LAB: GithubActionsLabWorkspace = {
401
402
 
402
403
  // ─── Helpers (mirror infraLab.ts API surface) ────────────────────────────
403
404
 
405
+ function cloneGhaLabEnvironment(
406
+ environment?: GithubActionsLabEnvironment,
407
+ ): GithubActionsLabEnvironment | undefined {
408
+ if (!environment || typeof environment !== "object") return undefined;
409
+
410
+ const cloneEntries = (entries: GithubActionsLabEnvironment["variables"]) =>
411
+ Array.isArray(entries)
412
+ ? entries
413
+ .filter(
414
+ (entry) =>
415
+ entry &&
416
+ typeof entry.name === "string" &&
417
+ typeof entry.value === "string",
418
+ )
419
+ .map((entry) => ({
420
+ name: entry.name,
421
+ value: entry.value,
422
+ ...(entry.enabled === false ? { enabled: false } : {}),
423
+ }))
424
+ : [];
425
+
426
+ const variables = cloneEntries(environment.variables);
427
+ const secrets = cloneEntries(environment.secrets);
428
+ const env = cloneEntries(environment.env);
429
+ if (variables.length === 0 && secrets.length === 0 && env.length === 0) {
430
+ return undefined;
431
+ }
432
+
433
+ return {
434
+ ...(variables.length ? { variables } : {}),
435
+ ...(secrets.length ? { secrets } : {}),
436
+ ...(env.length ? { env } : {}),
437
+ };
438
+ }
439
+
404
440
  export function cloneGhaLabWorkspace(
405
441
  workspace?: GithubActionsLabWorkspace | null,
406
442
  ): GithubActionsLabWorkspace {
@@ -415,6 +451,7 @@ export function cloneGhaLabWorkspace(
415
451
  )
416
452
  ? source.activeFile
417
453
  : (Object.keys(sourceFiles)[0] ?? ".github/workflows/ci.yml");
454
+ const environment = cloneGhaLabEnvironment(source.environment);
418
455
 
419
456
  return {
420
457
  version: 1,
@@ -433,6 +470,7 @@ export function cloneGhaLabWorkspace(
433
470
  ...(source.includeRunHistoryInContext
434
471
  ? { includeRunHistoryInContext: true }
435
472
  : {}),
473
+ ...(environment ? { environment } : {}),
436
474
  };
437
475
  }
438
476
 
@@ -517,6 +555,9 @@ export function parseGhaLabWorkspace(
517
555
  ...(parsed.includeRunHistoryInContext === true
518
556
  ? { includeRunHistoryInContext: true }
519
557
  : {}),
558
+ ...(parsed.environment && typeof parsed.environment === "object"
559
+ ? { environment: parsed.environment as GithubActionsLabEnvironment }
560
+ : {}),
520
561
  });
521
562
  } catch {
522
563
  return null;
@@ -49,6 +49,21 @@ export interface InfraLabWorkspace {
49
49
  files: Record<string, string>;
50
50
  }
51
51
 
52
+ export interface GithubActionsLabEnvironmentEntry {
53
+ name: string;
54
+ value: string;
55
+ enabled?: boolean;
56
+ }
57
+
58
+ export interface GithubActionsLabEnvironment {
59
+ /** Values available to workflows as `${{ vars.NAME }}` through act's --var-file. */
60
+ variables?: GithubActionsLabEnvironmentEntry[];
61
+ /** Values available to workflows as `${{ secrets.NAME }}` through act's --secret-file. */
62
+ secrets?: GithubActionsLabEnvironmentEntry[];
63
+ /** Runner/container environment values available to shell steps as `$NAME`. */
64
+ env?: GithubActionsLabEnvironmentEntry[];
65
+ }
66
+
52
67
  export interface GithubActionsLabWorkspace {
53
68
  version: 1;
54
69
  label: string;
@@ -64,6 +79,8 @@ export interface GithubActionsLabWorkspace {
64
79
  * (job statuses, durations, exit codes) instead of just the YAML.
65
80
  */
66
81
  includeRunHistoryInContext?: boolean;
82
+ /** Local act inputs mirroring GitHub repository variables, secrets, and runner env. */
83
+ environment?: GithubActionsLabEnvironment;
67
84
  }
68
85
 
69
86
  export interface WorkspaceMeta {
@@ -1 +1 @@
1
- {"root":["./src/app.tsx","./src/api.ts","./src/browsersecuritytemplates.ts","./src/enterpriselocallab.ts","./src/githubactionslab.ts","./src/infralab.ts","./src/main.tsx","./src/reactlab.ts","./src/store.ts","./src/types.ts","./src/vite-env.d.ts","./src/components/aisettingsmodal.tsx","./src/components/annotationdialog.tsx","./src/components/browsersecuritylabmodal.tsx","./src/components/canvaslabmodal.tsx","./src/components/chatmessage.tsx","./src/components/chatview.tsx","./src/components/codecontextpanel.tsx","./src/components/codelineannotationpopup.tsx","./src/components/coderunnermodal.tsx","./src/components/deploymentlabmodal.tsx","./src/components/docrefmodal.tsx","./src/components/fileattachments.tsx","./src/components/filepickermodal.tsx","./src/components/fileviewermodal.tsx","./src/components/ghahistorypanel.tsx","./src/components/ghajobspanel.tsx","./src/components/gitdiffpanel.tsx","./src/components/gitdiffviewermodal.tsx","./src/components/githubactionslabmodal.tsx","./src/components/infralabmodal.tsx","./src/components/labspanel.tsx","./src/components/linkedconvospicker.tsx","./src/components/markdownrenderer.tsx","./src/components/mermaiddiagram.tsx","./src/components/notesmodal.tsx","./src/components/plotembed.tsx","./src/components/sidebar.tsx","./src/components/textannotator.tsx","./src/components/vizcraftembed.tsx","./src/components/workspaceswitcher.tsx"],"version":"5.9.3"}
1
+ {"root":["./src/app.tsx","./src/api.ts","./src/browsersecuritytemplates.ts","./src/enterpriselocallab.ts","./src/ghaconcurrency.ts","./src/githubactionslab.ts","./src/infralab.ts","./src/main.tsx","./src/reactlab.ts","./src/store.ts","./src/types.ts","./src/vite-env.d.ts","./src/components/aisettingsmodal.tsx","./src/components/annotationdialog.tsx","./src/components/browsersecuritylabmodal.tsx","./src/components/canvaslabmodal.tsx","./src/components/chatmessage.tsx","./src/components/chatview.tsx","./src/components/codecontextpanel.tsx","./src/components/codelineannotationpopup.tsx","./src/components/coderunnermodal.tsx","./src/components/deploymentlabmodal.tsx","./src/components/docrefmodal.tsx","./src/components/fileattachments.tsx","./src/components/filepickermodal.tsx","./src/components/fileviewermodal.tsx","./src/components/ghaconcurrencypanel.tsx","./src/components/ghahistorypanel.tsx","./src/components/ghajobspanel.tsx","./src/components/gitdiffpanel.tsx","./src/components/gitdiffviewermodal.tsx","./src/components/githubactionslabmodal.tsx","./src/components/infralabmodal.tsx","./src/components/labspanel.tsx","./src/components/linkedconvospicker.tsx","./src/components/markdownrenderer.tsx","./src/components/mermaiddiagram.tsx","./src/components/notesmodal.tsx","./src/components/plotembed.tsx","./src/components/sidebar.tsx","./src/components/textannotator.tsx","./src/components/vizcraftembed.tsx","./src/components/workspaceswitcher.tsx"],"version":"5.9.3"}
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.20.0"
2
+ "version": "0.22.0"
3
3
  }
@@ -13,6 +13,19 @@ interface GithubActionsLabWorkspace {
13
13
  files: Record<string, string>;
14
14
  defaultEvent?: string;
15
15
  defaultWorkflow?: string;
16
+ environment?: GithubActionsLabEnvironment;
17
+ }
18
+
19
+ interface GithubActionsLabEnvironmentEntry {
20
+ name: string;
21
+ value: string;
22
+ enabled?: boolean;
23
+ }
24
+
25
+ interface GithubActionsLabEnvironment {
26
+ variables?: GithubActionsLabEnvironmentEntry[];
27
+ secrets?: GithubActionsLabEnvironmentEntry[];
28
+ env?: GithubActionsLabEnvironmentEntry[];
16
29
  }
17
30
 
18
31
  type OutputKind = "stdout" | "stderr" | "info";
@@ -69,8 +82,11 @@ export type GhaStreamMessage =
69
82
 
70
83
  const MAX_FILE_COUNT = 60;
71
84
  const MAX_TOTAL_SOURCE_BYTES = 1_000_000;
85
+ const MAX_GHA_ENV_ENTRY_COUNT = 80;
86
+ const MAX_GHA_ENV_TOTAL_BYTES = 100_000;
72
87
  const MAX_LOG_CHARS = 400_000;
73
88
  const SOURCE_MANIFEST = ".gha-source-files.json";
89
+ const GHA_ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
74
90
 
75
91
  // `act` accepts these events; we restrict to common ones so the console
76
92
  // can't be used to run arbitrary subcommands.
@@ -105,6 +121,21 @@ const ALLOWED_ACT_FLAGS = new Set([
105
121
  "-v",
106
122
  ]);
107
123
 
124
+ // act asks an interactive first-run question for runner image size unless it
125
+ // already has platform mappings. The lab console cannot answer arrow-key
126
+ // prompts cleanly, so we inject the Medium image mappings by default. Users can
127
+ // still override with -P / --platform in the command.
128
+ const DEFAULT_ACT_PLATFORM_ARGS = [
129
+ "-P",
130
+ "ubuntu-latest=catthehacker/ubuntu:act-latest",
131
+ "-P",
132
+ "ubuntu-24.04=catthehacker/ubuntu:act-24.04",
133
+ "-P",
134
+ "ubuntu-22.04=catthehacker/ubuntu:act-22.04",
135
+ "-P",
136
+ "ubuntu-20.04=catthehacker/ubuntu:act-20.04",
137
+ ];
138
+
108
139
  // ─── Utilities ───────────────────────────────────────────────────────────
109
140
 
110
141
  function getGhaRunsDir(): string {
@@ -140,6 +171,69 @@ function assertSafeRelativePath(filePath: string, label: string): void {
140
171
  }
141
172
  }
142
173
 
174
+ function parseEnvironmentEntries(
175
+ input: unknown,
176
+ label: string,
177
+ ): GithubActionsLabEnvironmentEntry[] {
178
+ if (!Array.isArray(input)) return [];
179
+ if (input.length > MAX_GHA_ENV_ENTRY_COUNT) {
180
+ throw new Error(
181
+ `${label} exceeds the ${MAX_GHA_ENV_ENTRY_COUNT} item limit`,
182
+ );
183
+ }
184
+
185
+ let totalBytes = 0;
186
+ return input
187
+ .filter((entry): entry is Record<string, unknown> => {
188
+ return !!entry && typeof entry === "object";
189
+ })
190
+ .map((entry) => {
191
+ const name = typeof entry.name === "string" ? entry.name.trim() : "";
192
+ if (!name) return null;
193
+ if (!GHA_ENV_NAME_RE.test(name)) {
194
+ throw new Error(
195
+ `${label} name '${name}' is invalid. Use letters, numbers, and underscores; do not start with a number.`,
196
+ );
197
+ }
198
+ const value = typeof entry.value === "string" ? entry.value : "";
199
+ totalBytes += Buffer.byteLength(name, "utf8");
200
+ totalBytes += Buffer.byteLength(value, "utf8");
201
+ if (totalBytes > MAX_GHA_ENV_TOTAL_BYTES) {
202
+ throw new Error(`${label} values exceed the allowed size limit`);
203
+ }
204
+ return {
205
+ name,
206
+ value,
207
+ ...(entry.enabled === false ? { enabled: false } : {}),
208
+ };
209
+ })
210
+ .filter((entry): entry is GithubActionsLabEnvironmentEntry => !!entry);
211
+ }
212
+
213
+ function parseWorkspaceEnvironment(
214
+ input: unknown,
215
+ ): GithubActionsLabEnvironment | undefined {
216
+ if (!input || typeof input !== "object") return undefined;
217
+ const candidate = input as Record<string, unknown>;
218
+ const variables = parseEnvironmentEntries(
219
+ candidate.variables,
220
+ "GitHub Actions variable",
221
+ );
222
+ const secrets = parseEnvironmentEntries(
223
+ candidate.secrets,
224
+ "GitHub Actions secret",
225
+ );
226
+ const env = parseEnvironmentEntries(candidate.env, "GitHub Actions env");
227
+ if (variables.length === 0 && secrets.length === 0 && env.length === 0) {
228
+ return undefined;
229
+ }
230
+ return {
231
+ ...(variables.length ? { variables } : {}),
232
+ ...(secrets.length ? { secrets } : {}),
233
+ ...(env.length ? { env } : {}),
234
+ };
235
+ }
236
+
143
237
  function parseWorkspace(input: unknown): GithubActionsLabWorkspace {
144
238
  if (!input || typeof input !== "object") {
145
239
  throw new Error("workspace payload is required");
@@ -173,6 +267,7 @@ function parseWorkspace(input: unknown): GithubActionsLabWorkspace {
173
267
  if (totalBytes > MAX_TOTAL_SOURCE_BYTES) {
174
268
  throw new Error("workspace source exceeds the allowed size limit");
175
269
  }
270
+ const environment = parseWorkspaceEnvironment(candidate.environment);
176
271
  return {
177
272
  version: 1,
178
273
  label:
@@ -192,6 +287,7 @@ function parseWorkspace(input: unknown): GithubActionsLabWorkspace {
192
287
  typeof candidate.defaultWorkflow === "string"
193
288
  ? candidate.defaultWorkflow
194
289
  : undefined,
290
+ ...(environment ? { environment } : {}),
195
291
  files,
196
292
  };
197
293
  }
@@ -258,6 +354,72 @@ async function syncWorkspaceToSession(
258
354
  return workspaceDir;
259
355
  }
260
356
 
357
+ function escapeDotenvValue(value: string): string {
358
+ return `"${value
359
+ .replace(/\\/g, "\\\\")
360
+ .replace(/\r/g, "")
361
+ .replace(/\n/g, "\\n")
362
+ .replace(/"/g, '\\"')}"`;
363
+ }
364
+
365
+ function formatEnvironmentFile(
366
+ entries: GithubActionsLabEnvironmentEntry[],
367
+ ): string {
368
+ return `${entries
369
+ .filter((entry) => entry.enabled !== false)
370
+ .map((entry) => `${entry.name}=${escapeDotenvValue(entry.value)}`)
371
+ .join("\n")}\n`;
372
+ }
373
+
374
+ async function buildActEnvironmentArgs(
375
+ runDir: string,
376
+ workspace: GithubActionsLabWorkspace,
377
+ ): Promise<{ args: string[]; summary: string[] }> {
378
+ const args: string[] = [];
379
+ const summary: string[] = [];
380
+ const specs: Array<{
381
+ key: keyof GithubActionsLabEnvironment;
382
+ flag: string;
383
+ fileName: string;
384
+ label: string;
385
+ }> = [
386
+ {
387
+ key: "variables",
388
+ flag: "--var-file",
389
+ fileName: "act-vars.env",
390
+ label: "variable",
391
+ },
392
+ {
393
+ key: "secrets",
394
+ flag: "--secret-file",
395
+ fileName: "act-secrets.env",
396
+ label: "secret",
397
+ },
398
+ {
399
+ key: "env",
400
+ flag: "--env-file",
401
+ fileName: "act-env.env",
402
+ label: "runner env",
403
+ },
404
+ ];
405
+
406
+ for (const spec of specs) {
407
+ const entries = (workspace.environment?.[spec.key] ?? []).filter(
408
+ (entry) => entry.enabled !== false,
409
+ );
410
+ if (entries.length === 0) continue;
411
+
412
+ const filePath = path.join(runDir, spec.fileName);
413
+ await fs.writeFile(filePath, formatEnvironmentFile(entries), "utf8");
414
+ args.push(spec.flag, filePath);
415
+ summary.push(
416
+ `${entries.length} ${spec.label}${entries.length === 1 ? "" : "s"}`,
417
+ );
418
+ }
419
+
420
+ return { args, summary };
421
+ }
422
+
261
423
  // ─── Command parsing ────────────────────────────────────────────────────
262
424
 
263
425
  function splitCommand(command: string): string[] {
@@ -304,6 +466,24 @@ interface ParsedActCommand {
304
466
  displayCommand: string;
305
467
  }
306
468
 
469
+ function hasPlatformOverride(args: string[]): boolean {
470
+ return args.some(
471
+ (arg) =>
472
+ arg === "-P" ||
473
+ arg === "--platform" ||
474
+ arg.startsWith("-P=") ||
475
+ arg.startsWith("--platform="),
476
+ );
477
+ }
478
+
479
+ function withDefaultActPlatforms(args: string[]): {
480
+ args: string[];
481
+ injected: boolean;
482
+ } {
483
+ if (hasPlatformOverride(args)) return { args, injected: false };
484
+ return { args: [...args, ...DEFAULT_ACT_PLATFORM_ARGS], injected: true };
485
+ }
486
+
307
487
  function parseActCommand(command: string): ParsedActCommand {
308
488
  const tokens = splitCommand(command);
309
489
  if (tokens.length === 0) throw new Error("Type a command to run");
@@ -681,6 +861,7 @@ export async function streamGhaCommand(
681
861
  const workspace = parseWorkspace(input.workspace);
682
862
 
683
863
  const parsed = parseActCommand(input.command);
864
+ const platformArgs = withDefaultActPlatforms(parsed.args);
684
865
  const sessionKey =
685
866
  input.fileId ?? input.questionId ?? `draft-${randomUUID()}`;
686
867
  const runId = randomUUID();
@@ -688,6 +869,8 @@ export async function streamGhaCommand(
688
869
  const runDir = path.join(getGhaRunsDir(), runId);
689
870
  const workspaceDir = await syncWorkspaceToSession(sessionKey, workspace);
690
871
  await fs.mkdir(runDir, { recursive: true });
872
+ const environmentArgs = await buildActEnvironmentArgs(runDir, workspace);
873
+ const actArgs = [...platformArgs.args, ...environmentArgs.args];
691
874
 
692
875
  let logs = "";
693
876
  let status: "completed" | "failed" = "completed";
@@ -696,12 +879,26 @@ export async function streamGhaCommand(
696
879
 
697
880
  const emit = (msg: GhaStreamMessage) => input.onMessage?.(msg);
698
881
  emit({ type: "output", kind: "info", text: parsed.displayCommand });
882
+ if (platformArgs.injected) {
883
+ emit({
884
+ type: "output",
885
+ kind: "info",
886
+ text: "[info] Using default Medium act runner image mappings. Pass -P/--platform to override.\n",
887
+ });
888
+ }
889
+ if (environmentArgs.summary.length > 0) {
890
+ emit({
891
+ type: "output",
892
+ kind: "info",
893
+ text: `[info] Injected ${environmentArgs.summary.join(", ")} into act via temporary files.\n`,
894
+ });
895
+ }
699
896
 
700
897
  // Track per-job status from act's prefixed stdout/stderr lines so the
701
898
  // client can render a live DAG in addition to the raw console.
702
899
  const tracker = new JobTracker((job) => emit({ type: "job", job }));
703
900
 
704
- const child = spawn("act", parsed.args, {
901
+ const child = spawn("act", actArgs, {
705
902
  cwd: workspaceDir,
706
903
  env: {
707
904
  ...process.env,
@@ -1163,6 +1163,27 @@ async function getOrCreateFolder(
1163
1163
  return folderId;
1164
1164
  }
1165
1165
 
1166
+ async function resolveExportFolderId(
1167
+ drive: drive_v3.Drive,
1168
+ ws: storage.WorkspaceMeta,
1169
+ targetFolderId?: string,
1170
+ ): Promise<string> {
1171
+ const requestedTarget = targetFolderId?.trim();
1172
+ if (requestedTarget) return requestedTarget;
1173
+
1174
+ // If the workspace is currently scoped to a Drive subfolder (for example
1175
+ // Shared Questions/SBD), push back into that same folder. Falling back to
1176
+ // _export here made topic-level pushes from a selected subfolder land in
1177
+ // Shared Questions/_export instead of beside the original topic folders.
1178
+ const selectedSubfolder = ws.driveConfig?.subFolderId?.trim();
1179
+ if (selectedSubfolder) return selectedSubfolder;
1180
+
1181
+ if (!ws.driveConfig?.folderId) {
1182
+ throw new Error("No Drive folder linked to this workspace");
1183
+ }
1184
+ return getOrCreateFolder(drive, ws.driveConfig.folderId, EXPORT_FOLDER_NAME);
1185
+ }
1186
+
1166
1187
  async function uploadFileToFolder(
1167
1188
  drive: drive_v3.Drive,
1168
1189
  folderId: string,
@@ -1342,7 +1363,6 @@ export async function exportWorkspace(
1342
1363
  }
1343
1364
 
1344
1365
  const drive = await getExportDriveClient();
1345
- const { folderId } = ws.driveConfig;
1346
1366
  const result: ExportResult = {
1347
1367
  topicsExported: 0,
1348
1368
  questionsExported: 0,
@@ -1350,10 +1370,9 @@ export async function exportWorkspace(
1350
1370
  errors: [],
1351
1371
  };
1352
1372
 
1353
- // Use the chosen subfolder directly, or fall back to an "_export" subfolder
1354
- const exportFolderId =
1355
- targetFolderId ??
1356
- (await getOrCreateFolder(drive, folderId, EXPORT_FOLDER_NAME));
1373
+ // Use the chosen subfolder directly. If none was passed, use the workspace's
1374
+ // selected Drive subfolder before falling back to root/_export.
1375
+ const exportFolderId = await resolveExportFolderId(drive, ws, targetFolderId);
1357
1376
  const topics = await storage.getTopicsForWorkspace(workspaceId);
1358
1377
  const ctxDir = storage.getContextFilesDirForWorkspace(workspaceId);
1359
1378
 
@@ -1415,10 +1434,7 @@ export async function exportTopic(
1415
1434
  }
1416
1435
 
1417
1436
  const drive = await getExportDriveClient();
1418
- const { folderId } = ws.driveConfig;
1419
- const exportFolderId =
1420
- targetFolderId ??
1421
- (await getOrCreateFolder(drive, folderId, EXPORT_FOLDER_NAME));
1437
+ const exportFolderId = await resolveExportFolderId(drive, ws, targetFolderId);
1422
1438
  const topic = (await storage.getTopicsForWorkspace(workspaceId)).find(
1423
1439
  (t) => t.id === topicId,
1424
1440
  );