@chrysb/alphaclaw 0.4.6-beta.7 → 0.4.6-beta.9
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/bin/alphaclaw.js +2 -32
- package/lib/public/css/theme.css +19 -0
- package/lib/public/js/app.js +1 -1
- package/lib/public/js/components/doctor/helpers.js +71 -5
- package/lib/public/js/components/doctor/index.js +89 -28
- package/lib/public/js/components/envars.js +0 -1
- package/lib/public/js/components/onboarding/welcome-config.js +39 -17
- package/lib/public/js/components/onboarding/welcome-form-step.js +142 -47
- package/lib/public/js/components/onboarding/welcome-import-step.js +306 -0
- package/lib/public/js/components/onboarding/welcome-placeholder-review-step.js +99 -0
- package/lib/public/js/components/onboarding/welcome-secret-review-step.js +191 -0
- package/lib/public/js/components/segmented-control.js +7 -1
- package/lib/public/js/components/welcome/index.js +112 -0
- package/lib/public/js/components/welcome/use-welcome.js +561 -0
- package/lib/public/js/lib/api.js +221 -161
- package/lib/server/commands.js +1 -0
- package/lib/server/constants.js +0 -1
- package/lib/server/doctor/bootstrap-context.js +191 -0
- package/lib/server/doctor/prompt.js +20 -4
- package/lib/server/doctor/service.js +18 -4
- package/lib/server/gateway.js +15 -40
- package/lib/server/onboarding/github.js +120 -19
- package/lib/server/onboarding/import/import-applier.js +321 -0
- package/lib/server/onboarding/import/import-config.js +69 -0
- package/lib/server/onboarding/import/import-scanner.js +469 -0
- package/lib/server/onboarding/import/import-temp.js +63 -0
- package/lib/server/onboarding/import/secret-detector.js +289 -0
- package/lib/server/onboarding/index.js +256 -29
- package/lib/server/onboarding/workspace.js +38 -6
- package/lib/server/routes/onboarding.js +281 -12
- package/lib/server.js +12 -3
- package/package.json +1 -1
- package/lib/public/js/components/welcome.js +0 -318
package/lib/server/commands.js
CHANGED
package/lib/server/constants.js
CHANGED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
|
|
4
|
+
const kDoctorBootstrapMaxChars = 20000;
|
|
5
|
+
const kDoctorBootstrapTotalMaxChars = 150000;
|
|
6
|
+
const kDoctorBootstrapNearLimitRatio = 0.9;
|
|
7
|
+
const kDoctorContextTruncationGuidance =
|
|
8
|
+
"OpenClaw trims oversized injected files by keeping the first 70%, keeping the last 20%, and cutting the middle 10% without a warning.";
|
|
9
|
+
|
|
10
|
+
const kDoctorRootContextFiles = [
|
|
11
|
+
{ path: "AGENTS.md", injectMode: "always" },
|
|
12
|
+
{ path: "SOUL.md", injectMode: "always" },
|
|
13
|
+
{ path: "TOOLS.md", injectMode: "always" },
|
|
14
|
+
{ path: "IDENTITY.md", injectMode: "always" },
|
|
15
|
+
{ path: "USER.md", injectMode: "always" },
|
|
16
|
+
{ path: "HEARTBEAT.md", injectMode: "always" },
|
|
17
|
+
{ path: "BOOTSTRAP.md", injectMode: "first_run_only" },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const kDoctorBootstrapExtraFiles = [
|
|
21
|
+
{ path: "hooks/bootstrap/AGENTS.md", injectMode: "always" },
|
|
22
|
+
{ path: "hooks/bootstrap/TOOLS.md", injectMode: "always" },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const kDoctorBootstrapContextFiles = [...kDoctorRootContextFiles, ...kDoctorBootstrapExtraFiles];
|
|
26
|
+
|
|
27
|
+
const readWorkspaceFileChars = (workspaceRoot, relativePath) => {
|
|
28
|
+
const fullPath = path.join(workspaceRoot, relativePath);
|
|
29
|
+
try {
|
|
30
|
+
const content = fs.readFileSync(fullPath, "utf8");
|
|
31
|
+
return {
|
|
32
|
+
exists: true,
|
|
33
|
+
chars: content.length,
|
|
34
|
+
};
|
|
35
|
+
} catch {
|
|
36
|
+
return {
|
|
37
|
+
exists: false,
|
|
38
|
+
chars: 0,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const analyzeBootstrapContext = ({
|
|
44
|
+
workspaceRoot = "",
|
|
45
|
+
bootstrapMaxChars = kDoctorBootstrapMaxChars,
|
|
46
|
+
bootstrapTotalMaxChars = kDoctorBootstrapTotalMaxChars,
|
|
47
|
+
} = {}) => {
|
|
48
|
+
const files = kDoctorBootstrapContextFiles.map((spec) => {
|
|
49
|
+
const fileState = readWorkspaceFileChars(workspaceRoot, spec.path);
|
|
50
|
+
const rawChars = fileState.chars;
|
|
51
|
+
const fileLimitChars = Math.min(rawChars, bootstrapMaxChars);
|
|
52
|
+
const nearFileLimit = rawChars > 0 && rawChars >= Math.floor(bootstrapMaxChars * kDoctorBootstrapNearLimitRatio);
|
|
53
|
+
return {
|
|
54
|
+
...spec,
|
|
55
|
+
exists: fileState.exists,
|
|
56
|
+
rawChars,
|
|
57
|
+
fileLimitChars,
|
|
58
|
+
injectedChars: 0,
|
|
59
|
+
truncatedByFileLimit: rawChars > bootstrapMaxChars,
|
|
60
|
+
truncatedByTotalLimit: false,
|
|
61
|
+
truncated: rawChars > bootstrapMaxChars,
|
|
62
|
+
nearFileLimit: nearFileLimit && rawChars <= bootstrapMaxChars,
|
|
63
|
+
active: spec.injectMode === "always",
|
|
64
|
+
reason: rawChars > bootstrapMaxChars ? "file_limit" : "",
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
let injectedTotalChars = 0;
|
|
69
|
+
for (const file of files) {
|
|
70
|
+
if (!file.active || !file.exists) continue;
|
|
71
|
+
const remainingChars = Math.max(0, bootstrapTotalMaxChars - injectedTotalChars);
|
|
72
|
+
file.injectedChars = Math.min(file.fileLimitChars, remainingChars);
|
|
73
|
+
file.truncatedByTotalLimit = file.fileLimitChars > file.injectedChars;
|
|
74
|
+
file.truncated = file.truncatedByFileLimit || file.truncatedByTotalLimit;
|
|
75
|
+
if (file.truncatedByFileLimit && file.truncatedByTotalLimit) {
|
|
76
|
+
file.reason = "file_and_total_limit";
|
|
77
|
+
} else if (file.truncatedByFileLimit) {
|
|
78
|
+
file.reason = "file_limit";
|
|
79
|
+
} else if (file.truncatedByTotalLimit) {
|
|
80
|
+
file.reason = "total_limit";
|
|
81
|
+
}
|
|
82
|
+
injectedTotalChars += file.injectedChars;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const activeFiles = files.filter((file) => file.active && file.exists);
|
|
86
|
+
const activeTruncatedFiles = activeFiles.filter((file) => file.truncated);
|
|
87
|
+
const activeNearLimitFiles = activeFiles.filter((file) => file.nearFileLimit && !file.truncated);
|
|
88
|
+
const inactiveTruncatedFiles = files.filter((file) => !file.active && file.exists && file.truncated);
|
|
89
|
+
const hasTotalLimitTruncation = activeTruncatedFiles.some(
|
|
90
|
+
(file) => file.reason === "total_limit" || file.reason === "file_and_total_limit",
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
bootstrapMaxChars,
|
|
95
|
+
bootstrapTotalMaxChars,
|
|
96
|
+
truncationGuidance: kDoctorContextTruncationGuidance,
|
|
97
|
+
files,
|
|
98
|
+
activeFiles,
|
|
99
|
+
activeRawChars: activeFiles.reduce((sum, file) => sum + file.rawChars, 0),
|
|
100
|
+
activeInjectedChars: activeFiles.reduce((sum, file) => sum + file.injectedChars, 0),
|
|
101
|
+
hasActiveTruncation: activeTruncatedFiles.length > 0,
|
|
102
|
+
hasActiveNearLimitFiles: activeNearLimitFiles.length > 0,
|
|
103
|
+
hasActiveWarnings: activeTruncatedFiles.length > 0 || activeNearLimitFiles.length > 0,
|
|
104
|
+
hasAnyTruncation: activeTruncatedFiles.length > 0 || inactiveTruncatedFiles.length > 0,
|
|
105
|
+
activeTruncatedFiles,
|
|
106
|
+
activeNearLimitFiles,
|
|
107
|
+
inactiveTruncatedFiles,
|
|
108
|
+
hasTotalLimitTruncation,
|
|
109
|
+
totalLimitReached: injectedTotalChars >= bootstrapTotalMaxChars,
|
|
110
|
+
};
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const formatChars = (value = 0) => `${Number(value || 0).toLocaleString()} chars`;
|
|
114
|
+
|
|
115
|
+
const buildBootstrapTruncationCards = (bootstrapContext = null) => {
|
|
116
|
+
if (!bootstrapContext?.hasActiveTruncation) return [];
|
|
117
|
+
|
|
118
|
+
const cards = bootstrapContext.activeTruncatedFiles
|
|
119
|
+
.filter((file) => file.reason === "file_limit")
|
|
120
|
+
.map((file) => ({
|
|
121
|
+
priority: "P0",
|
|
122
|
+
category: "project context",
|
|
123
|
+
title: `${file.path} is being truncated in Project Context`,
|
|
124
|
+
summary:
|
|
125
|
+
`${file.path} is ${formatChars(file.rawChars)}, above the per-file Project Context limit ` +
|
|
126
|
+
`of ${formatChars(bootstrapContext.bootstrapMaxChars)}. The agent is not seeing the full file.`,
|
|
127
|
+
recommendation:
|
|
128
|
+
`Move the most important rules to the top of ${file.path}, shorten or split low-priority content, ` +
|
|
129
|
+
`and increase OpenClaw's bootstrap limits if this file legitimately needs more room. ` +
|
|
130
|
+
kDoctorContextTruncationGuidance,
|
|
131
|
+
evidence: [
|
|
132
|
+
{ type: "path", path: file.path },
|
|
133
|
+
{
|
|
134
|
+
type: "text",
|
|
135
|
+
text:
|
|
136
|
+
`Raw size: ${formatChars(file.rawChars)}. ` +
|
|
137
|
+
`Per-file limit: ${formatChars(bootstrapContext.bootstrapMaxChars)}.`,
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
targetPaths: [{ path: file.path }],
|
|
141
|
+
fixPrompt:
|
|
142
|
+
`Reorganize ${file.path} so the most important instructions appear at the top and reduce unnecessary length. ` +
|
|
143
|
+
`Do not change unrelated behavior.`,
|
|
144
|
+
status: "open",
|
|
145
|
+
}));
|
|
146
|
+
|
|
147
|
+
const totalLimitedFiles = bootstrapContext.activeTruncatedFiles.filter(
|
|
148
|
+
(file) => file.reason === "total_limit" || file.reason === "file_and_total_limit",
|
|
149
|
+
);
|
|
150
|
+
if (totalLimitedFiles.length > 0) {
|
|
151
|
+
cards.unshift({
|
|
152
|
+
priority: "P0",
|
|
153
|
+
category: "project context",
|
|
154
|
+
title: "Project Context total bootstrap limit is truncating injected files",
|
|
155
|
+
summary:
|
|
156
|
+
`Injected workspace guidance needs ${formatChars(bootstrapContext.activeRawChars)} raw across active ` +
|
|
157
|
+
`Project Context files, exceeding the total bootstrap budget of ` +
|
|
158
|
+
`${formatChars(bootstrapContext.bootstrapTotalMaxChars)}.`,
|
|
159
|
+
recommendation:
|
|
160
|
+
`Reduce total Project Context size across injected guidance files, keep critical instructions near the top, ` +
|
|
161
|
+
`and raise OpenClaw's total bootstrap budget if the workspace legitimately needs more injected guidance. ` +
|
|
162
|
+
kDoctorContextTruncationGuidance,
|
|
163
|
+
evidence: totalLimitedFiles.map((file) => ({
|
|
164
|
+
type: "text",
|
|
165
|
+
text:
|
|
166
|
+
`${file.path}: raw ${formatChars(file.rawChars)}, injected ${formatChars(file.injectedChars)} ` +
|
|
167
|
+
`before the total limit stopped more content from being included.`,
|
|
168
|
+
})),
|
|
169
|
+
targetPaths: totalLimitedFiles.map((file) => ({ path: file.path })),
|
|
170
|
+
fixPrompt:
|
|
171
|
+
`Reduce the combined size of the affected Project Context files and keep the most important instructions near the top. ` +
|
|
172
|
+
`Only edit the files listed in the finding.`,
|
|
173
|
+
status: "open",
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return cards;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
module.exports = {
|
|
181
|
+
analyzeBootstrapContext,
|
|
182
|
+
buildBootstrapTruncationCards,
|
|
183
|
+
formatChars,
|
|
184
|
+
kDoctorBootstrapContextFiles,
|
|
185
|
+
kDoctorBootstrapExtraFiles,
|
|
186
|
+
kDoctorBootstrapMaxChars,
|
|
187
|
+
kDoctorBootstrapNearLimitRatio,
|
|
188
|
+
kDoctorBootstrapTotalMaxChars,
|
|
189
|
+
kDoctorContextTruncationGuidance,
|
|
190
|
+
kDoctorRootContextFiles,
|
|
191
|
+
};
|
|
@@ -1,6 +1,17 @@
|
|
|
1
|
+
const {
|
|
2
|
+
kDoctorBootstrapExtraFiles,
|
|
3
|
+
kDoctorBootstrapMaxChars,
|
|
4
|
+
kDoctorBootstrapTotalMaxChars,
|
|
5
|
+
kDoctorContextTruncationGuidance,
|
|
6
|
+
kDoctorRootContextFiles,
|
|
7
|
+
} = require("./bootstrap-context");
|
|
8
|
+
|
|
1
9
|
const renderList = (items = []) =>
|
|
2
10
|
items.length ? items.map((item) => `- ${item}`).join("\n") : "- (none)";
|
|
3
11
|
|
|
12
|
+
const renderContextFileList = (files = []) =>
|
|
13
|
+
files.map((file) => `\`${file.path}\``).join(", ");
|
|
14
|
+
|
|
4
15
|
const renderResolvedCards = (cards = []) => {
|
|
5
16
|
if (!cards.length) return "";
|
|
6
17
|
const lines = cards.map(
|
|
@@ -37,10 +48,15 @@ Important:
|
|
|
37
48
|
- Return ONLY valid JSON. No markdown fences. No extra prose.
|
|
38
49
|
|
|
39
50
|
OpenClaw context injection:
|
|
40
|
-
- OpenClaw automatically injects
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
-
|
|
51
|
+
- OpenClaw automatically injects these workspace files into the agent's Project Context: ${renderContextFileList(
|
|
52
|
+
kDoctorRootContextFiles,
|
|
53
|
+
)}.
|
|
54
|
+
- \`BOOTSTRAP.md\` is first-run only; the others above are injected on normal turns when present.
|
|
55
|
+
- Additionally, AlphaClaw injects these extra bootstrap files on normal turns when present: ${renderContextFileList(
|
|
56
|
+
kDoctorBootstrapExtraFiles,
|
|
57
|
+
)}.
|
|
58
|
+
- Large injected files are truncated per-file at ${kDoctorBootstrapMaxChars} chars by default, and total bootstrap injection across files is capped at ${kDoctorBootstrapTotalMaxChars} chars by default.
|
|
59
|
+
- ${kDoctorContextTruncationGuidance}
|
|
44
60
|
|
|
45
61
|
OpenClaw default context:
|
|
46
62
|
- \`AGENTS.md\` is the workspace home file in the default OpenClaw template. It may intentionally include first-run instructions, session-startup guidance, memory conventions, safety rules, tool pointers, and optional behavioral guidance.
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
const fs = require("fs");
|
|
2
2
|
const path = require("path");
|
|
3
|
+
const {
|
|
4
|
+
analyzeBootstrapContext,
|
|
5
|
+
buildBootstrapTruncationCards,
|
|
6
|
+
} = require("./bootstrap-context");
|
|
3
7
|
const { buildDoctorPrompt } = require("./prompt");
|
|
4
8
|
const { normalizeDoctorResult } = require("./normalize");
|
|
5
9
|
const { calculateWorkspaceDelta, computeWorkspaceSnapshot } = require("./workspace-fingerprint");
|
|
@@ -137,6 +141,7 @@ const createDoctorService = ({
|
|
|
137
141
|
};
|
|
138
142
|
|
|
139
143
|
const buildStatus = () => {
|
|
144
|
+
const bootstrapContext = analyzeBootstrapContext({ workspaceRoot });
|
|
140
145
|
const recentRuns = listDoctorRuns({ limit: 10 });
|
|
141
146
|
const latestRun = recentRuns[0] || null;
|
|
142
147
|
const latestCompletedRun =
|
|
@@ -179,6 +184,7 @@ const createDoctorService = ({
|
|
|
179
184
|
lastRunAgeMs,
|
|
180
185
|
needsInitialRun: !latestCompletedRun,
|
|
181
186
|
stale,
|
|
187
|
+
bootstrapContext,
|
|
182
188
|
changeSummary: {
|
|
183
189
|
...delta,
|
|
184
190
|
hasBaseline: hasManifestBaseline,
|
|
@@ -245,10 +251,14 @@ const createDoctorService = ({
|
|
|
245
251
|
console.error(`[doctor] run ${runId} stderr end`);
|
|
246
252
|
throw error;
|
|
247
253
|
}
|
|
248
|
-
|
|
254
|
+
const bootstrapTruncationCards = buildBootstrapTruncationCards(
|
|
255
|
+
analyzeBootstrapContext({ workspaceRoot }),
|
|
256
|
+
);
|
|
257
|
+
const cards = [...bootstrapTruncationCards, ...normalizedResult.cards];
|
|
258
|
+
captureEvidenceSnippets(cards, workspaceRoot);
|
|
249
259
|
insertDoctorCards({
|
|
250
260
|
runId,
|
|
251
|
-
cards
|
|
261
|
+
cards,
|
|
252
262
|
});
|
|
253
263
|
completeDoctorRun({
|
|
254
264
|
id: runId,
|
|
@@ -342,7 +352,11 @@ const createDoctorService = ({
|
|
|
342
352
|
throw new Error("Doctor import requires raw output");
|
|
343
353
|
}
|
|
344
354
|
const normalizedResult = normalizeDoctorResult(normalizedRawOutput);
|
|
345
|
-
|
|
355
|
+
const bootstrapTruncationCards = buildBootstrapTruncationCards(
|
|
356
|
+
analyzeBootstrapContext({ workspaceRoot }),
|
|
357
|
+
);
|
|
358
|
+
const cards = [...bootstrapTruncationCards, ...normalizedResult.cards];
|
|
359
|
+
captureEvidenceSnippets(cards, workspaceRoot);
|
|
346
360
|
const workspaceSnapshot = getCurrentWorkspaceSnapshot();
|
|
347
361
|
const runId = createDoctorRun({
|
|
348
362
|
status: kDoctorRunStatus.completed,
|
|
@@ -354,7 +368,7 @@ const createDoctorService = ({
|
|
|
354
368
|
});
|
|
355
369
|
insertDoctorCards({
|
|
356
370
|
runId,
|
|
357
|
-
cards
|
|
371
|
+
cards,
|
|
358
372
|
});
|
|
359
373
|
completeDoctorRun({
|
|
360
374
|
id: runId,
|
package/lib/server/gateway.js
CHANGED
|
@@ -3,6 +3,7 @@ const { spawn, execSync } = require("child_process");
|
|
|
3
3
|
const fs = require("fs");
|
|
4
4
|
const net = require("net");
|
|
5
5
|
const {
|
|
6
|
+
ALPHACLAW_DIR,
|
|
6
7
|
OPENCLAW_DIR,
|
|
7
8
|
GATEWAY_HOST,
|
|
8
9
|
GATEWAY_PORT,
|
|
@@ -48,51 +49,25 @@ const gatewayEnv = () => ({
|
|
|
48
49
|
XDG_CONFIG_HOME: OPENCLAW_DIR,
|
|
49
50
|
});
|
|
50
51
|
|
|
51
|
-
const hasOnboardingModelConfig = () => {
|
|
52
|
-
const configPath = `${OPENCLAW_DIR}/openclaw.json`;
|
|
53
|
-
if (!fs.existsSync(configPath)) return false;
|
|
54
|
-
try {
|
|
55
|
-
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
56
|
-
const primaryModel = String(
|
|
57
|
-
config?.agents?.defaults?.model?.primary || "",
|
|
58
|
-
).trim();
|
|
59
|
-
return primaryModel.includes("/");
|
|
60
|
-
} catch {
|
|
61
|
-
return false;
|
|
62
|
-
}
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
const hasLegacyOnboardingArtifacts = () => fs.existsSync(kControlUiSkillPath);
|
|
66
|
-
|
|
67
52
|
const writeOnboardingMarker = (reason) => {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
);
|
|
82
|
-
return true;
|
|
83
|
-
} catch (err) {
|
|
84
|
-
console.error(`[alphaclaw] Failed to write onboarding marker: ${err.message}`);
|
|
85
|
-
return false;
|
|
86
|
-
}
|
|
53
|
+
fs.mkdirSync(ALPHACLAW_DIR, { recursive: true });
|
|
54
|
+
fs.writeFileSync(
|
|
55
|
+
kOnboardingMarkerPath,
|
|
56
|
+
JSON.stringify(
|
|
57
|
+
{
|
|
58
|
+
onboarded: true,
|
|
59
|
+
reason,
|
|
60
|
+
markedAt: new Date().toISOString(),
|
|
61
|
+
},
|
|
62
|
+
null,
|
|
63
|
+
2,
|
|
64
|
+
),
|
|
65
|
+
);
|
|
87
66
|
};
|
|
88
67
|
|
|
89
68
|
const isOnboarded = () => {
|
|
90
69
|
if (fs.existsSync(kOnboardingMarkerPath)) return true;
|
|
91
|
-
if (
|
|
92
|
-
writeOnboardingMarker("config_primary_model");
|
|
93
|
-
return true;
|
|
94
|
-
}
|
|
95
|
-
if (hasLegacyOnboardingArtifacts()) {
|
|
70
|
+
if (fs.existsSync(kControlUiSkillPath)) {
|
|
96
71
|
writeOnboardingMarker("legacy_artifact_backfill");
|
|
97
72
|
return true;
|
|
98
73
|
}
|
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const os = require("os");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const crypto = require("crypto");
|
|
5
|
+
const {
|
|
6
|
+
kImportTempPrefix,
|
|
7
|
+
isValidImportTempDir,
|
|
8
|
+
} = require("./import/import-temp");
|
|
9
|
+
|
|
1
10
|
const buildGithubHeaders = (githubToken) => ({
|
|
2
11
|
Authorization: `token ${githubToken}`,
|
|
3
12
|
"User-Agent": "openclaw-railway",
|
|
@@ -14,9 +23,18 @@ const parseGithubErrorMessage = async (response) => {
|
|
|
14
23
|
return response.statusText || `HTTP ${response.status}`;
|
|
15
24
|
};
|
|
16
25
|
|
|
17
|
-
const
|
|
26
|
+
const isClassicPat = (token) => String(token || "").startsWith("ghp_");
|
|
27
|
+
const isFineGrainedPat = (token) =>
|
|
28
|
+
String(token || "").startsWith("github_pat_");
|
|
29
|
+
|
|
30
|
+
const verifyGithubRepoForOnboarding = async ({
|
|
31
|
+
repoUrl,
|
|
32
|
+
githubToken,
|
|
33
|
+
mode = "new",
|
|
34
|
+
}) => {
|
|
18
35
|
const ghHeaders = buildGithubHeaders(githubToken);
|
|
19
36
|
const [repoOwner] = String(repoUrl || "").split("/", 1);
|
|
37
|
+
const isExisting = mode === "existing";
|
|
20
38
|
|
|
21
39
|
try {
|
|
22
40
|
const userRes = await fetch("https://api.github.com/user", {
|
|
@@ -30,25 +48,28 @@ const verifyGithubRepoForOnboarding = async ({ repoUrl, githubToken }) => {
|
|
|
30
48
|
error: `Cannot verify GitHub token: ${details}`,
|
|
31
49
|
};
|
|
32
50
|
}
|
|
33
|
-
|
|
34
|
-
.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
51
|
+
if (isClassicPat(githubToken)) {
|
|
52
|
+
const oauthScopes = (userRes.headers?.get?.("x-oauth-scopes") || "")
|
|
53
|
+
.toLowerCase()
|
|
54
|
+
.split(",")
|
|
55
|
+
.map((s) => s.trim())
|
|
56
|
+
.filter(Boolean);
|
|
57
|
+
if (
|
|
58
|
+
oauthScopes.length > 0 &&
|
|
59
|
+
!oauthScopes.includes("repo") &&
|
|
60
|
+
!oauthScopes.includes("public_repo")
|
|
61
|
+
) {
|
|
62
|
+
return {
|
|
63
|
+
ok: false,
|
|
64
|
+
status: 400,
|
|
65
|
+
error: `Your token needs the "repo" scope. Current scopes: ${oauthScopes.join(", ")}`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
48
68
|
}
|
|
49
69
|
const authedUser = await userRes.json().catch(() => ({}));
|
|
50
70
|
const authedLogin = String(authedUser?.login || "").trim();
|
|
51
71
|
if (
|
|
72
|
+
!isExisting &&
|
|
52
73
|
repoOwner &&
|
|
53
74
|
authedLogin &&
|
|
54
75
|
repoOwner.toLowerCase() !== authedLogin.toLowerCase()
|
|
@@ -56,7 +77,7 @@ const verifyGithubRepoForOnboarding = async ({ repoUrl, githubToken }) => {
|
|
|
56
77
|
return {
|
|
57
78
|
ok: false,
|
|
58
79
|
status: 400,
|
|
59
|
-
error: `
|
|
80
|
+
error: `New workspace repo owner must match your token user "${authedLogin}"`,
|
|
60
81
|
};
|
|
61
82
|
}
|
|
62
83
|
|
|
@@ -64,6 +85,13 @@ const verifyGithubRepoForOnboarding = async ({ repoUrl, githubToken }) => {
|
|
|
64
85
|
headers: ghHeaders,
|
|
65
86
|
});
|
|
66
87
|
if (checkRes.status === 404) {
|
|
88
|
+
if (isExisting) {
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
status: 400,
|
|
92
|
+
error: `Repository "${repoUrl}" not found. Check the repo name and token permissions.`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
67
95
|
return { ok: true, repoExists: false, repoIsEmpty: false };
|
|
68
96
|
}
|
|
69
97
|
if (checkRes.ok) {
|
|
@@ -75,10 +103,13 @@ const verifyGithubRepoForOnboarding = async ({ repoUrl, githubToken }) => {
|
|
|
75
103
|
return { ok: true, repoExists: true, repoIsEmpty: true };
|
|
76
104
|
}
|
|
77
105
|
if (commitsRes.ok) {
|
|
106
|
+
if (isExisting) {
|
|
107
|
+
return { ok: true, repoExists: true, repoIsEmpty: false };
|
|
108
|
+
}
|
|
78
109
|
return {
|
|
79
110
|
ok: false,
|
|
80
111
|
status: 400,
|
|
81
|
-
error: `Repository "${repoUrl}" already exists and is not empty
|
|
112
|
+
error: `Repository "${repoUrl}" already exists and is not empty. Did you mean to use "Import existing setup"?`,
|
|
82
113
|
};
|
|
83
114
|
}
|
|
84
115
|
const commitCheckDetails = await parseGithubErrorMessage(commitsRes);
|
|
@@ -90,6 +121,13 @@ const verifyGithubRepoForOnboarding = async ({ repoUrl, githubToken }) => {
|
|
|
90
121
|
}
|
|
91
122
|
|
|
92
123
|
const details = await parseGithubErrorMessage(checkRes);
|
|
124
|
+
if (isFineGrainedPat(githubToken) && checkRes.status === 403) {
|
|
125
|
+
return {
|
|
126
|
+
ok: false,
|
|
127
|
+
status: 400,
|
|
128
|
+
error: `Your fine-grained token needs Contents (read/write) and Metadata (read) permissions for "${repoUrl}".`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
93
131
|
return {
|
|
94
132
|
ok: false,
|
|
95
133
|
status: 400,
|
|
@@ -150,4 +188,67 @@ const ensureGithubRepoAccessible = async ({
|
|
|
150
188
|
}
|
|
151
189
|
};
|
|
152
190
|
|
|
153
|
-
|
|
191
|
+
const cloneRepoToTemp = async ({ repoUrl, githubToken, shellCmd }) => {
|
|
192
|
+
const tempId = crypto.randomUUID().slice(0, 8);
|
|
193
|
+
const tempDir = path.join(os.tmpdir(), `${kImportTempPrefix}${tempId}`);
|
|
194
|
+
const askPassPath = path.join(
|
|
195
|
+
os.tmpdir(),
|
|
196
|
+
`alphaclaw-import-askpass-${tempId}.sh`,
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
fs.writeFileSync(
|
|
201
|
+
askPassPath,
|
|
202
|
+
[
|
|
203
|
+
"#!/bin/sh",
|
|
204
|
+
'case "$1" in',
|
|
205
|
+
' *Username*) printf "%s\\n" "x-access-token" ;;',
|
|
206
|
+
' *) printf "%s\\n" "$ALPHACLAW_GITHUB_TOKEN" ;;',
|
|
207
|
+
"esac",
|
|
208
|
+
"",
|
|
209
|
+
].join("\n"),
|
|
210
|
+
{ mode: 0o700 },
|
|
211
|
+
);
|
|
212
|
+
await shellCmd(
|
|
213
|
+
`git clone --depth=1 "https://github.com/${repoUrl}.git" "${tempDir}"`,
|
|
214
|
+
{
|
|
215
|
+
timeout: 60000,
|
|
216
|
+
env: {
|
|
217
|
+
...process.env,
|
|
218
|
+
GIT_ASKPASS: askPassPath,
|
|
219
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
220
|
+
ALPHACLAW_GITHUB_TOKEN: githubToken,
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
);
|
|
224
|
+
console.log(`[onboard] Cloned ${repoUrl} to ${tempDir}`);
|
|
225
|
+
return { ok: true, tempDir };
|
|
226
|
+
} catch (e) {
|
|
227
|
+
return {
|
|
228
|
+
ok: false,
|
|
229
|
+
error: `Failed to clone repo: ${e.message}`,
|
|
230
|
+
};
|
|
231
|
+
} finally {
|
|
232
|
+
try {
|
|
233
|
+
fs.rmSync(askPassPath, { force: true });
|
|
234
|
+
} catch {}
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const cleanupTempClone = (tempDir) => {
|
|
239
|
+
try {
|
|
240
|
+
if (isValidImportTempDir(tempDir)) {
|
|
241
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
242
|
+
console.log(`[onboard] Cleaned up temp clone ${tempDir}`);
|
|
243
|
+
}
|
|
244
|
+
} catch (e) {
|
|
245
|
+
console.error(`[onboard] Temp cleanup error: ${e.message}`);
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
module.exports = {
|
|
250
|
+
ensureGithubRepoAccessible,
|
|
251
|
+
verifyGithubRepoForOnboarding,
|
|
252
|
+
cloneRepoToTemp,
|
|
253
|
+
cleanupTempClone,
|
|
254
|
+
};
|