@aion0/forge 0.10.85 → 0.10.86
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/RELEASE_NOTES.md +4 -4
- package/app/api/schedules/[id]/route.ts +27 -20
- package/app/api/schedules/extract/route.ts +23 -16
- package/components/SchedulesView.tsx +7 -1
- package/lib/pipeline.ts +43 -1
- package/lib/projects.ts +154 -1
- package/package.json +1 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.86
|
|
2
2
|
|
|
3
3
|
Released: 2026-06-16
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
5
|
+
## Changes since v0.10.85
|
|
6
6
|
|
|
7
7
|
### Other
|
|
8
|
-
-
|
|
8
|
+
- fix(schedules): pause/resume + extractor openai support
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.
|
|
11
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.85...v0.10.86
|
|
@@ -116,29 +116,36 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
|
|
116
116
|
}
|
|
117
117
|
patch.action_config = body.action_config;
|
|
118
118
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (
|
|
131
|
-
|
|
119
|
+
// Cross-field validation runs ONLY for the section being patched.
|
|
120
|
+
// Toggling unrelated fields (e.g. `enabled` from the Pause/Resume button)
|
|
121
|
+
// must not re-validate sections the user isn't touching — a schedule with a
|
|
122
|
+
// pre-existing quirk in its action_config would otherwise refuse to pause.
|
|
123
|
+
if (patch.action_kind !== undefined || patch.action_config !== undefined) {
|
|
124
|
+
const effectiveActionKind = patch.action_kind ?? existing.action_kind;
|
|
125
|
+
const effectiveActionConfig = patch.action_config ?? existing.action_config ?? {};
|
|
126
|
+
if (effectiveActionKind === 'chat') {
|
|
127
|
+
const sid = typeof effectiveActionConfig.session_id === 'string' ? effectiveActionConfig.session_id.trim() : '';
|
|
128
|
+
if (!sid) return NextResponse.json({ error: 'action_kind=chat requires action_config.session_id' }, { status: 400 });
|
|
129
|
+
}
|
|
130
|
+
if (effectiveActionKind === 'email') {
|
|
131
|
+
const toRaw = effectiveActionConfig.to;
|
|
132
|
+
const to = Array.isArray(toRaw)
|
|
133
|
+
? toRaw.filter((x) => typeof x === 'string' && x.trim())
|
|
134
|
+
: (typeof toRaw === 'string' && toRaw.trim() ? [toRaw] : []);
|
|
135
|
+
if (to.length === 0) {
|
|
136
|
+
return NextResponse.json({ error: 'action_kind=email requires action_config.to (recipient address or array)' }, { status: 400 });
|
|
137
|
+
}
|
|
132
138
|
}
|
|
133
139
|
}
|
|
134
140
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
141
|
+
if (patch.schedule_kind !== undefined || patch.schedule_cron !== undefined || patch.schedule_at !== undefined) {
|
|
142
|
+
const effectiveKind = patch.schedule_kind ?? existing.schedule_kind;
|
|
143
|
+
if (effectiveKind === 'cron' && !(patch.schedule_cron ?? existing.schedule_cron)) {
|
|
144
|
+
return NextResponse.json({ error: 'schedule_cron is required when schedule_kind=cron' }, { status: 400 });
|
|
145
|
+
}
|
|
146
|
+
if (effectiveKind === 'once' && !(patch.schedule_at ?? existing.schedule_at)) {
|
|
147
|
+
return NextResponse.json({ error: 'schedule_at is required when schedule_kind=once' }, { status: 400 });
|
|
148
|
+
}
|
|
142
149
|
}
|
|
143
150
|
|
|
144
151
|
updateSchedule(id, patch);
|
|
@@ -19,6 +19,8 @@ import { inferAdapter, pickApiKey, pickBaseUrl } from '@/lib/chat/agent-loop';
|
|
|
19
19
|
import { makeAnthropicClient } from '@/lib/chat/llm/anthropic';
|
|
20
20
|
import { listAgents } from '@/lib/agents';
|
|
21
21
|
import { scanProjects } from '@/lib/projects';
|
|
22
|
+
import { createOpenAI } from '@ai-sdk/openai';
|
|
23
|
+
import { generateText } from 'ai';
|
|
22
24
|
|
|
23
25
|
interface Extracted {
|
|
24
26
|
name: string;
|
|
@@ -116,15 +118,9 @@ export async function POST(req: Request) {
|
|
|
116
118
|
? profiles[preferredId]
|
|
117
119
|
: candidates[0]![1];
|
|
118
120
|
const adapter = inferAdapter(profile.provider);
|
|
119
|
-
if (adapter !== 'anthropic') {
|
|
120
|
-
return NextResponse.json({
|
|
121
|
-
ok: false,
|
|
122
|
-
error: 'extractor currently supports anthropic profiles only (openai is a follow-up)',
|
|
123
|
-
}, { status: 400 });
|
|
124
|
-
}
|
|
125
121
|
const apiKey = pickApiKey(profile, adapter);
|
|
126
122
|
const baseUrl = pickBaseUrl(profile, adapter);
|
|
127
|
-
const model = profile.model || 'claude-sonnet-4-6';
|
|
123
|
+
const model = profile.model || (adapter === 'anthropic' ? 'claude-sonnet-4-6' : 'gpt-4o-mini');
|
|
128
124
|
|
|
129
125
|
const cliAgents = listAgents().filter((a: any) => a.backendType !== 'api');
|
|
130
126
|
const availableAgentIds = cliAgents.map((a: any) => a.id);
|
|
@@ -134,15 +130,26 @@ export async function POST(req: Request) {
|
|
|
134
130
|
|
|
135
131
|
let raw = '';
|
|
136
132
|
try {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
133
|
+
if (adapter === 'anthropic') {
|
|
134
|
+
const client = makeAnthropicClient(apiKey, baseUrl);
|
|
135
|
+
const resp = await client.messages.create({
|
|
136
|
+
model,
|
|
137
|
+
max_tokens: 1500,
|
|
138
|
+
system,
|
|
139
|
+
messages: [{ role: 'user', content: text }],
|
|
140
|
+
});
|
|
141
|
+
for (const block of resp.content) {
|
|
142
|
+
if (block.type === 'text') raw += block.text;
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
// OpenAI / OpenAI-compatible — non-streaming one-shot via @ai-sdk/openai.
|
|
146
|
+
const provider = createOpenAI({ apiKey, baseURL: baseUrl });
|
|
147
|
+
const resp = await generateText({
|
|
148
|
+
model: provider.chat(model),
|
|
149
|
+
system,
|
|
150
|
+
messages: [{ role: 'user', content: text }],
|
|
151
|
+
});
|
|
152
|
+
raw = resp.text;
|
|
146
153
|
}
|
|
147
154
|
} catch (e: any) {
|
|
148
155
|
return NextResponse.json({
|
|
@@ -145,11 +145,17 @@ export default function SchedulesView({ onViewPipeline }: Props) {
|
|
|
145
145
|
|
|
146
146
|
async function togglePaused(s: Schedule) {
|
|
147
147
|
try {
|
|
148
|
-
await fetch(`/api/schedules/${encodeURIComponent(s.id)}`, {
|
|
148
|
+
const res = await fetch(`/api/schedules/${encodeURIComponent(s.id)}`, {
|
|
149
149
|
method: 'PATCH',
|
|
150
150
|
headers: { 'Content-Type': 'application/json' },
|
|
151
151
|
body: JSON.stringify({ enabled: !s.enabled }),
|
|
152
152
|
});
|
|
153
|
+
if (!res.ok) {
|
|
154
|
+
// Surface backend validation errors — otherwise the button looks dead.
|
|
155
|
+
const body = await res.json().catch(() => ({}));
|
|
156
|
+
setErr(`Toggle failed (${res.status}): ${body?.error || res.statusText}`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
153
159
|
void refresh();
|
|
154
160
|
} catch (e) { setErr(`Toggle failed: ${e instanceof Error ? e.message : String(e)}`); }
|
|
155
161
|
}
|
package/lib/pipeline.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { execSync } from 'node:child_process';
|
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import YAML from 'yaml';
|
|
13
13
|
import { createTask, getTask, onTaskEvent, taskModelOverrides, taskAppendSystemPromptOverrides, cancelTask } from './task-manager';
|
|
14
|
-
import { getProjectInfo, resolveOrCloneProject, getProjectWorktreeRoot, ensureScratchProject } from './projects';
|
|
14
|
+
import { getProjectInfo, resolveOrCloneProject, resolveProjectStrict, getProjectWorktreeRoot, ensureScratchProject } from './projects';
|
|
15
15
|
import { loadSettings } from './settings';
|
|
16
16
|
import { getAgent, listAgents } from './agents';
|
|
17
17
|
import type { Task } from '../src/types';
|
|
@@ -172,6 +172,16 @@ export interface Workflow {
|
|
|
172
172
|
/** Declares the pipeline pushes to git. Enables an up-front OTP/2FA preflight
|
|
173
173
|
* so a 2fa_verify wall aborts BEFORE any code work instead of at push time. */
|
|
174
174
|
git_push?: boolean;
|
|
175
|
+
/** Declares the pipeline needs a real git checkout to operate (e.g. it will
|
|
176
|
+
* cd into the project and run `git`, `glab`, build/test commands). When
|
|
177
|
+
* true, startPipeline runs the strict resolver (resolveProjectStrict) and
|
|
178
|
+
* aborts the whole run with a clear error if no project can be resolved —
|
|
179
|
+
* instead of silently falling back to scratch, which causes downstream
|
|
180
|
+
* shell nodes to crash with "fatal: not a git repository".
|
|
181
|
+
*
|
|
182
|
+
* Default: false (preserves the scratch fallback for connector-only / chat
|
|
183
|
+
* pipelines that don't touch a repo). */
|
|
184
|
+
requires_real_project?: boolean;
|
|
175
185
|
/** Default execution backend for every node's task. 'tmux' runs interactive
|
|
176
186
|
* claude in a per-node tmux session (subscription billing); 'headless' /
|
|
177
187
|
* omitted uses default `claude -p`. Per-node `backend:` overrides this. */
|
|
@@ -517,6 +527,14 @@ export function parseWorkflow(raw: string): Workflow {
|
|
|
517
527
|
for_each,
|
|
518
528
|
conversation,
|
|
519
529
|
backend: parsed.backend === 'tmux' || parsed.backend === 'headless' ? parsed.backend : undefined,
|
|
530
|
+
// Top-level boolean flag — must be explicitly passed through; the return is
|
|
531
|
+
// a literal, so any field omitted here is silently dropped from the parsed
|
|
532
|
+
// workflow. NOTE: `git_push` has the SAME latent issue (it's declared in
|
|
533
|
+
// the interface + read at startPipeline but never parsed here, so its OTP
|
|
534
|
+
// preflight has never actually fired). Left as-is on purpose: wiring it up
|
|
535
|
+
// would newly enable the preflight on existing fortinet pipelines, an
|
|
536
|
+
// unrelated behavior change that belongs in its own commit.
|
|
537
|
+
requires_real_project: parsed.requires_real_project === true,
|
|
520
538
|
};
|
|
521
539
|
}
|
|
522
540
|
|
|
@@ -1081,6 +1099,30 @@ export function startPipeline(
|
|
|
1081
1099
|
forEach: forEachState,
|
|
1082
1100
|
};
|
|
1083
1101
|
|
|
1102
|
+
// Strict project resolution — only for pipelines that declared they need a
|
|
1103
|
+
// real git checkout. MUST run BEFORE ensurePipelineTmpDir: the lenient
|
|
1104
|
+
// resolver inside that helper falls back to scratch (building tmp_dir in the
|
|
1105
|
+
// wrong place) and downstream `git` / `glab` commands then crash with
|
|
1106
|
+
// "fatal: not a git repository". The strict resolver instead clones/locates
|
|
1107
|
+
// the real repo (or returns a structured error we surface here), and seeds
|
|
1108
|
+
// input.project so the tmp_dir lands inside that repo.
|
|
1109
|
+
if (workflow.requires_real_project) {
|
|
1110
|
+
const r = resolveProjectStrict(input.project || '');
|
|
1111
|
+
if ('error' in r) {
|
|
1112
|
+
pipeline.status = 'failed';
|
|
1113
|
+
pipeline.error = r.message;
|
|
1114
|
+
pipeline.completedAt = new Date().toISOString();
|
|
1115
|
+
savePipeline(pipeline);
|
|
1116
|
+
finalizePipeline(pipeline);
|
|
1117
|
+
return pipeline;
|
|
1118
|
+
}
|
|
1119
|
+
// Seed the resolved project name into input so ensurePipelineTmpDir and
|
|
1120
|
+
// every node's lenient resolveOrCloneProject hit it immediately as
|
|
1121
|
+
// 'existing' (no redundant scan/clone). pipeline.input === input here
|
|
1122
|
+
// (same ref from construction), so an in-place mutation updates both.
|
|
1123
|
+
if (r.project.name) input.project = r.project.name;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1084
1126
|
ensurePipelineTmpDir(pipeline);
|
|
1085
1127
|
|
|
1086
1128
|
// Git OTP/2FA preflight — abort the whole run up front so a push wall never
|
package/lib/projects.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readdirSync, existsSync, statSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { readdirSync, existsSync, statSync, readFileSync, mkdirSync, writeFileSync, accessSync, constants as fsConstants } from 'node:fs';
|
|
2
2
|
import { execSync } from 'node:child_process';
|
|
3
3
|
import { join, resolve, isAbsolute, parse } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
@@ -231,6 +231,159 @@ export interface ResolveResult {
|
|
|
231
231
|
clone_url?: string;
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
+
// ─── Container mode + auto-root ───────────────────────────────────
|
|
235
|
+
|
|
236
|
+
const CONTAINER_DEFAULT_ROOT = '/data/project';
|
|
237
|
+
|
|
238
|
+
/** True when Forge is running inside a Docker/container deployment.
|
|
239
|
+
* Two-channel detection (per user choice C):
|
|
240
|
+
* 1. FORGE_CONTAINER=1 env var (set by entrypoint)
|
|
241
|
+
* 2. /data/project exists AND is writable (probe; covers ad-hoc mounts)
|
|
242
|
+
* Either signal is enough. */
|
|
243
|
+
export function detectContainerMode(): boolean {
|
|
244
|
+
if (process.env.FORGE_CONTAINER === '1') return true;
|
|
245
|
+
try {
|
|
246
|
+
accessSync(CONTAINER_DEFAULT_ROOT, fsConstants.W_OK);
|
|
247
|
+
return true;
|
|
248
|
+
} catch { return false; }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** In container mode, register `/data/project` as a project root if no roots
|
|
252
|
+
* are configured yet. Idempotent: returns the path that was added (or
|
|
253
|
+
* already present), or null when we're not in container mode / the path
|
|
254
|
+
* isn't writable. Auto-add (user choice 3) so subsequent runs use it without
|
|
255
|
+
* re-triggering this path.
|
|
256
|
+
*
|
|
257
|
+
* Persists to settings so the path also appears in Settings → Projects. */
|
|
258
|
+
export function ensureContainerRoot(): string | null {
|
|
259
|
+
if (!detectContainerMode()) return null;
|
|
260
|
+
try {
|
|
261
|
+
mkdirSync(CONTAINER_DEFAULT_ROOT, { recursive: true });
|
|
262
|
+
accessSync(CONTAINER_DEFAULT_ROOT, fsConstants.W_OK);
|
|
263
|
+
} catch { return null; }
|
|
264
|
+
const settings = loadSettings();
|
|
265
|
+
const roots = settings.projectRoots || [];
|
|
266
|
+
if (roots.includes(CONTAINER_DEFAULT_ROOT)) return CONTAINER_DEFAULT_ROOT;
|
|
267
|
+
settings.projectRoots = [...roots, CONTAINER_DEFAULT_ROOT];
|
|
268
|
+
saveSettings(settings);
|
|
269
|
+
invalidateProjectScan();
|
|
270
|
+
return CONTAINER_DEFAULT_ROOT;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ─── Strict resolver (for pipelines that declare requires_real_project) ───
|
|
274
|
+
|
|
275
|
+
export type StrictResolveError =
|
|
276
|
+
| 'no_repo_specified'
|
|
277
|
+
| 'clone_failed';
|
|
278
|
+
|
|
279
|
+
export interface StrictResolveResult {
|
|
280
|
+
project: LocalProject;
|
|
281
|
+
source: 'existing' | 'gitlab-cloned';
|
|
282
|
+
clone_url?: string;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Strict project resolver — used by pipelines that declare
|
|
287
|
+
* `requires_real_project: true`. Differs from resolveOrCloneProject in that
|
|
288
|
+
* it NEVER falls back to scratch; on any failure it returns a structured
|
|
289
|
+
* error so the caller (startPipeline) can abort the run with a clear message
|
|
290
|
+
* instead of letting git commands inside the task crash with
|
|
291
|
+
* "fatal: not a git repository".
|
|
292
|
+
*
|
|
293
|
+
* Flow (matches the user's spec):
|
|
294
|
+
* 1. Existing project by name / owner-repo / fuzzy → use it
|
|
295
|
+
* 2. Otherwise figure out the target repo URL:
|
|
296
|
+
* - input name contains '/' → treat as owner/repo
|
|
297
|
+
* - else gitlab connector default_project_path
|
|
298
|
+
* - else → error('no_repo_specified')
|
|
299
|
+
* 3. Re-scan locally for a checkout whose origin matches the target → use it
|
|
300
|
+
* 4. Clone via tryGitlabClone — writes to the Forge-managed cache
|
|
301
|
+
* (<dataDir>/cloned-projects), which is ALWAYS writable and does NOT
|
|
302
|
+
* need a configured projectRoot. We best-effort register the container
|
|
303
|
+
* root (/data/project) so manually-placed checkouts there get scanned,
|
|
304
|
+
* but never block the clone on it.
|
|
305
|
+
* - gitlab connector missing → error('no_repo_specified')
|
|
306
|
+
* - clone failed → error('clone_failed')
|
|
307
|
+
*/
|
|
308
|
+
export function resolveProjectStrict(name: string | undefined): StrictResolveResult | { error: StrictResolveError; message: string } {
|
|
309
|
+
const trimmed = (name || '').trim();
|
|
310
|
+
|
|
311
|
+
// Step 1: existing match — reuse the lenient resolver's match logic by
|
|
312
|
+
// delegating, but discard its scratch fallback.
|
|
313
|
+
if (trimmed) {
|
|
314
|
+
const hit = getProjectInfo(trimmed);
|
|
315
|
+
if (hit) return { project: hit, source: 'existing' };
|
|
316
|
+
|
|
317
|
+
const projects = scanProjects();
|
|
318
|
+
if (trimmed.includes('/')) {
|
|
319
|
+
const want = normalizeRepoPath(trimmed) || trimmed.toLowerCase().replace(/^\/+|\/+$/g, '');
|
|
320
|
+
const wantBase = want.split('/').pop()!;
|
|
321
|
+
const byRepo = projects.filter((p) => p.repo && p.repo === want);
|
|
322
|
+
if (byRepo.length === 1) return { project: byRepo[0], source: 'existing' };
|
|
323
|
+
const byRepoBase = projects.filter((p) => p.repo && p.repo.split('/').pop() === wantBase);
|
|
324
|
+
if (byRepoBase.length === 1) return { project: byRepoBase[0], source: 'existing' };
|
|
325
|
+
}
|
|
326
|
+
const base = trimmed.split('/').pop()!.trim();
|
|
327
|
+
const ownerRepo = trimmed.replace(/\//g, '-');
|
|
328
|
+
const cands = projects.filter((p) =>
|
|
329
|
+
p.name === base || p.name === ownerRepo || p.name.toLowerCase() === base.toLowerCase());
|
|
330
|
+
if (cands.length === 1) return { project: cands[0], source: 'existing' };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Step 2: derive target repo path
|
|
334
|
+
const gl = readGitlabConnector();
|
|
335
|
+
const targetPath = trimmed && trimmed.includes('/')
|
|
336
|
+
? trimmed
|
|
337
|
+
: (gl?.default_project_path || '').trim();
|
|
338
|
+
if (!targetPath) {
|
|
339
|
+
return {
|
|
340
|
+
error: 'no_repo_specified',
|
|
341
|
+
message: trimmed
|
|
342
|
+
? `Could not resolve project "${trimmed}". Pass an owner/repo path, or set GitLab connector's default_project_path.`
|
|
343
|
+
: 'No repository specified. Set `project: owner/repo` in pipeline input, or set GitLab connector\'s default_project_path.',
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Step 3: scan locally by origin remote
|
|
348
|
+
if (gl) {
|
|
349
|
+
const projects = scanProjects();
|
|
350
|
+
const want = normalizeRepoPath(targetPath) || targetPath.toLowerCase().replace(/^\/+|\/+$/g, '');
|
|
351
|
+
const wantBase = want.split('/').pop()!;
|
|
352
|
+
const localHit =
|
|
353
|
+
projects.find((p) => p.repo && p.repo === want) ||
|
|
354
|
+
projects.find((p) => p.repo && p.repo.split('/').pop() === wantBase) ||
|
|
355
|
+
projects.find((p) => p.name.toLowerCase() === wantBase.toLowerCase());
|
|
356
|
+
if (localHit) return { project: localHit, source: 'existing' };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Step 4: clone. tryGitlabClone writes to <dataDir>/cloned-projects (always
|
|
360
|
+
// writable) — it does NOT use projectRoots, so there's no "no project root"
|
|
361
|
+
// failure here: as long as the gitlab connector is configured, a clone has
|
|
362
|
+
// somewhere to land. Best-effort register the container root so manual
|
|
363
|
+
// checkouts under /data/project are discoverable on the next scan; ignore
|
|
364
|
+
// its result — it must never block the clone.
|
|
365
|
+
ensureContainerRoot();
|
|
366
|
+
|
|
367
|
+
if (!gl) {
|
|
368
|
+
return {
|
|
369
|
+
error: 'no_repo_specified',
|
|
370
|
+
message: `GitLab connector is not configured; cannot clone "${targetPath}". Install + configure the GitLab connector first.`,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
const cloned = tryGitlabClone(targetPath);
|
|
374
|
+
if (!cloned) {
|
|
375
|
+
return {
|
|
376
|
+
error: 'clone_failed',
|
|
377
|
+
message: `Failed to clone ${gl.base_url}/${targetPath}. Check GitLab connectivity, credentials, and that the path exists.`,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
return {
|
|
381
|
+
project: cloned,
|
|
382
|
+
source: 'gitlab-cloned',
|
|
383
|
+
clone_url: `${gl.base_url}/${targetPath.replace(/^\/+|\/+$/g, '')}.git`,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
234
387
|
/**
|
|
235
388
|
* Pick a writable directory to clone into, in priority order:
|
|
236
389
|
* 1. settings.projectRoots[0] — what the user already trusts
|
package/package.json
CHANGED