@ghl-ai/aw 0.1.44-beta.1 → 0.1.44-beta.2
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/c4/claudePluginRegistry.mjs +142 -0
- package/c4/codexConfig.mjs +148 -0
- package/c4/codexPromptInjector.mjs +187 -0
- package/c4/commandSurface.mjs +286 -0
- package/c4/detect.mjs +62 -0
- package/c4/diagnostics.mjs +536 -0
- package/c4/eccRegistryBridge.mjs +184 -0
- package/c4/ghCli.mjs +94 -0
- package/c4/gitAuth.mjs +384 -0
- package/c4/index.mjs +64 -0
- package/c4/jsonMerge.mjs +229 -0
- package/c4/mcpServer.mjs +160 -0
- package/c4/mcpSmokeProbe.mjs +201 -0
- package/c4/preflight.mjs +254 -0
- package/c4/repoLocalClaudeSettings.mjs +54 -0
- package/c4/repoLocalIgnore.mjs +157 -0
- package/c4/repoRootInstructions.mjs +166 -0
- package/c4/secrets.mjs +55 -0
- package/c4/slimRouter.mjs +472 -0
- package/cli.mjs +7 -0
- package/commands/c4.mjs +387 -0
- package/ecc.mjs +7 -72
- package/integrate.mjs +6 -6
- package/mcp.mjs +0 -1
- package/package.json +5 -3
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* c4/eccRegistryBridge.mjs — Codex-only Bug A workaround.
|
|
3
|
+
*
|
|
4
|
+
* Codex Web's upstream `aw-session-start.sh` enumerator scans a fixed
|
|
5
|
+
* registry path:
|
|
6
|
+
*
|
|
7
|
+
* ~/.aw/.aw_registry/platform/core/skills/<name>/SKILL.md
|
|
8
|
+
*
|
|
9
|
+
* ECC ships skills under a different path:
|
|
10
|
+
*
|
|
11
|
+
* ~/.aw-ecc/skills/<name>/SKILL.md
|
|
12
|
+
*
|
|
13
|
+
* On Codex Web, neither Claude's plugin marketplace nor Cursor's direct
|
|
14
|
+
* `~/.cursor/skills/...` resolution applies. The only way Codex can see
|
|
15
|
+
* ECC skills is if the registry path is populated. We bridge by symlinking
|
|
16
|
+
* each ECC skill's SKILL.md into the registry layout, idempotently, without
|
|
17
|
+
* clobbering any user-authored content already present at the target.
|
|
18
|
+
*
|
|
19
|
+
* Also installs a fallback symlink at `<homeOf(awHome)>/.aw_registry` →
|
|
20
|
+
* `<awHome>/.aw_registry` because some legacy hooks read from that path.
|
|
21
|
+
*
|
|
22
|
+
* **Scope**: Codex Web only. The orchestrator is responsible for *not*
|
|
23
|
+
* invoking this on claude-web or cursor-cloud; the module itself is
|
|
24
|
+
* harness-agnostic and just performs the bridge.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
existsSync,
|
|
29
|
+
lstatSync,
|
|
30
|
+
readlinkSync,
|
|
31
|
+
readdirSync,
|
|
32
|
+
mkdirSync,
|
|
33
|
+
symlinkSync,
|
|
34
|
+
unlinkSync,
|
|
35
|
+
} from 'node:fs';
|
|
36
|
+
import { dirname, join } from 'node:path';
|
|
37
|
+
|
|
38
|
+
/** True iff `path` is a symlink that points to a nonexistent target. */
|
|
39
|
+
function isBrokenSymlink(path) {
|
|
40
|
+
try {
|
|
41
|
+
const st = lstatSync(path);
|
|
42
|
+
if (!st.isSymbolicLink()) return false;
|
|
43
|
+
return !existsSync(path);
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Install the fallback symlink `~/.aw_registry → ~/.aw/.aw_registry` if not
|
|
51
|
+
* present and not blocked by an existing real directory. Best-effort.
|
|
52
|
+
*/
|
|
53
|
+
function installRegistryFallbackLink(awHome) {
|
|
54
|
+
const home = dirname(awHome);
|
|
55
|
+
const fallback = join(home, '.aw_registry');
|
|
56
|
+
const target = join(awHome, '.aw_registry');
|
|
57
|
+
|
|
58
|
+
if (!existsSync(target)) {
|
|
59
|
+
// Nothing to point at yet — skip silently.
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let existsAtFallback = false;
|
|
64
|
+
try {
|
|
65
|
+
lstatSync(fallback);
|
|
66
|
+
existsAtFallback = true;
|
|
67
|
+
} catch {
|
|
68
|
+
existsAtFallback = false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (existsAtFallback) return; // do not clobber existing dir/symlink
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
symlinkSync(target, fallback);
|
|
75
|
+
} catch {
|
|
76
|
+
// best-effort
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Symlink every ECC skill into the registry layout. Idempotent.
|
|
82
|
+
*
|
|
83
|
+
* Result shape:
|
|
84
|
+
* - linked — skill names freshly bridged this call
|
|
85
|
+
* - skipped — skill names whose registry target already existed (file or healthy symlink)
|
|
86
|
+
* - broken — skill directories that exist in eccHome/skills/ but have no SKILL.md
|
|
87
|
+
* (broken ECC layout — distinct from already-bridged so the orchestrator
|
|
88
|
+
* can surface a real diagnostic rather than treating it as success)
|
|
89
|
+
* - reason — only set when the result is fully diagnostic:
|
|
90
|
+
* 'ecc-missing' — eccHome/skills/ does not exist
|
|
91
|
+
* 'already-bridged' — every well-formed skill was already linked
|
|
92
|
+
* and there were no broken skills
|
|
93
|
+
*
|
|
94
|
+
* @param {object} opts
|
|
95
|
+
* @param {string} opts.awHome Path of the AW home (e.g. ~/.aw).
|
|
96
|
+
* @param {string} opts.eccHome Path of the ECC home (e.g. ~/.aw-ecc).
|
|
97
|
+
* @returns {{
|
|
98
|
+
* linked: string[],
|
|
99
|
+
* skipped: string[],
|
|
100
|
+
* broken: string[],
|
|
101
|
+
* reason?: 'already-bridged' | 'ecc-missing'
|
|
102
|
+
* }}
|
|
103
|
+
*/
|
|
104
|
+
export function applyEccRegistryBridge({ awHome, eccHome } = {}) {
|
|
105
|
+
if (!awHome || !eccHome) {
|
|
106
|
+
throw new Error('applyEccRegistryBridge: awHome and eccHome are required');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const eccSkillsDir = join(eccHome, 'skills');
|
|
110
|
+
if (!existsSync(eccSkillsDir)) {
|
|
111
|
+
return { linked: [], skipped: [], broken: [], reason: 'ecc-missing' };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const linked = [];
|
|
115
|
+
const skipped = [];
|
|
116
|
+
const broken = [];
|
|
117
|
+
|
|
118
|
+
let entries;
|
|
119
|
+
try {
|
|
120
|
+
entries = readdirSync(eccSkillsDir, { withFileTypes: true });
|
|
121
|
+
} catch {
|
|
122
|
+
return { linked: [], skipped: [], broken: [], reason: 'ecc-missing' };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let skillDirCount = 0;
|
|
126
|
+
for (const entry of entries) {
|
|
127
|
+
if (!entry.isDirectory()) continue;
|
|
128
|
+
skillDirCount += 1;
|
|
129
|
+
const name = entry.name;
|
|
130
|
+
const source = join(eccSkillsDir, name, 'SKILL.md');
|
|
131
|
+
|
|
132
|
+
// ECC skill directory exists but lacks SKILL.md → broken layout.
|
|
133
|
+
if (!existsSync(source)) {
|
|
134
|
+
broken.push(name);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const targetDir = join(awHome, '.aw_registry/platform/core/skills', name);
|
|
139
|
+
const target = join(targetDir, 'SKILL.md');
|
|
140
|
+
|
|
141
|
+
// If target already exists and is a healthy file/symlink, leave it alone.
|
|
142
|
+
let targetExists = false;
|
|
143
|
+
try {
|
|
144
|
+
lstatSync(target);
|
|
145
|
+
targetExists = true;
|
|
146
|
+
} catch {
|
|
147
|
+
targetExists = false;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (targetExists && !isBrokenSymlink(target)) {
|
|
151
|
+
skipped.push(name);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Either absent or a broken symlink — replace.
|
|
156
|
+
mkdirSync(targetDir, { recursive: true });
|
|
157
|
+
if (targetExists) {
|
|
158
|
+
try { unlinkSync(target); } catch { /* best-effort */ }
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
symlinkSync(source, target);
|
|
162
|
+
linked.push(name);
|
|
163
|
+
} catch {
|
|
164
|
+
// Filesystem may forbid symlinks (rare on Codex Web). Treat as broken
|
|
165
|
+
// so the orchestrator can surface the real diagnostic.
|
|
166
|
+
broken.push(name);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
installRegistryFallbackLink(awHome);
|
|
171
|
+
|
|
172
|
+
const result = { linked, skipped, broken };
|
|
173
|
+
// Only flag 'already-bridged' when every well-formed skill was already linked
|
|
174
|
+
// AND no broken skills exist — i.e. this run was a true no-op.
|
|
175
|
+
if (
|
|
176
|
+
skillDirCount > 0 &&
|
|
177
|
+
linked.length === 0 &&
|
|
178
|
+
broken.length === 0 &&
|
|
179
|
+
skipped.length === skillDirCount
|
|
180
|
+
) {
|
|
181
|
+
result.reason = 'already-bridged';
|
|
182
|
+
}
|
|
183
|
+
return result;
|
|
184
|
+
}
|
package/c4/ghCli.mjs
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* c4/ghCli.mjs — gh CLI install with apt-keyring fallback.
|
|
3
|
+
*
|
|
4
|
+
* Decision tree (mirrors pilot's `ensure_github_cli`):
|
|
5
|
+
* 1. gh on PATH → 'preinstalled'
|
|
6
|
+
* 2. apt-get not on PATH (non-Debian image) → 'unavailable'
|
|
7
|
+
* 3. `apt-get install -y -qq gh` direct succeeds → 'apt-default'
|
|
8
|
+
* 4. Keyring fallback (download keyring + add apt source + install) → 'apt-keyring'
|
|
9
|
+
* 5. Both apt paths fail → 'unavailable'
|
|
10
|
+
*
|
|
11
|
+
* Best-effort: never throws. The orchestrator continues even on
|
|
12
|
+
* { installed: false } because the insteadOf rewrite path alone usually
|
|
13
|
+
* suffices for git operations; gh is redundancy.
|
|
14
|
+
*
|
|
15
|
+
* The `shell` injection point makes this fully unit-testable without
|
|
16
|
+
* spawning real apt-get subprocesses or touching /etc/apt.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { spawnSync } from 'node:child_process';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {object} ShellResult
|
|
23
|
+
* @property {boolean} ok
|
|
24
|
+
* @property {string} [stdout]
|
|
25
|
+
* @property {string} [stderr]
|
|
26
|
+
* @property {number} [exitCode]
|
|
27
|
+
*
|
|
28
|
+
* @typedef {(cmd: string) => ShellResult} ShellRunner
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** Default real shell — captures stdout/stderr to avoid polluting parent output. */
|
|
32
|
+
function defaultShell(cmd) {
|
|
33
|
+
const result = spawnSync(cmd, [], {
|
|
34
|
+
shell: true,
|
|
35
|
+
encoding: 'utf8',
|
|
36
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
37
|
+
});
|
|
38
|
+
return {
|
|
39
|
+
ok: result.status === 0,
|
|
40
|
+
stdout: result.stdout ?? '',
|
|
41
|
+
stderr: result.stderr ?? '',
|
|
42
|
+
exitCode: result.status ?? -1,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Run a shell command and never throw — failures show up as `{ ok: false }`. */
|
|
47
|
+
function runSafe(shell, cmd) {
|
|
48
|
+
try {
|
|
49
|
+
const result = shell(cmd);
|
|
50
|
+
return result ?? { ok: false, stdout: '', stderr: '', exitCode: -1 };
|
|
51
|
+
} catch (err) {
|
|
52
|
+
return { ok: false, stdout: '', stderr: String(err?.message ?? err), exitCode: -1 };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const KEYRING_STEPS = [
|
|
57
|
+
'apt-get install -y -qq curl ca-certificates gnupg',
|
|
58
|
+
'mkdir -p /etc/apt/keyrings',
|
|
59
|
+
'curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg ' +
|
|
60
|
+
'| tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null',
|
|
61
|
+
'chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg',
|
|
62
|
+
'echo "deb [arch=$(dpkg --print-architecture) ' +
|
|
63
|
+
'signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] ' +
|
|
64
|
+
'https://cli.github.com/packages stable main" ' +
|
|
65
|
+
'| tee /etc/apt/sources.list.d/github-cli.list > /dev/null',
|
|
66
|
+
'apt-get update -y -qq',
|
|
67
|
+
'apt-get install -y -qq gh',
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {object} [opts]
|
|
72
|
+
* @param {ShellRunner} [opts.shell] Injection point for testing.
|
|
73
|
+
* @returns {{ installed: boolean, source: 'preinstalled' | 'apt-default' | 'apt-keyring' | 'unavailable' }}
|
|
74
|
+
*/
|
|
75
|
+
export function ensureGhCli({ shell = defaultShell } = {}) {
|
|
76
|
+
if (runSafe(shell, 'command -v gh').ok) {
|
|
77
|
+
return { installed: true, source: 'preinstalled' };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!runSafe(shell, 'command -v apt-get').ok) {
|
|
81
|
+
return { installed: false, source: 'unavailable' };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (runSafe(shell, 'apt-get install -y -qq gh').ok) {
|
|
85
|
+
return { installed: true, source: 'apt-default' };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const step of KEYRING_STEPS) {
|
|
89
|
+
if (!runSafe(shell, step).ok) {
|
|
90
|
+
return { installed: false, source: 'unavailable' };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { installed: true, source: 'apt-keyring' };
|
|
94
|
+
}
|
package/c4/gitAuth.mjs
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* c4/gitAuth.mjs — 6-step pilot auth sequence + 3-way preflight verification.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `cursor-web-bootstrap.sh::clear_existing_github_auth +
|
|
5
|
+
* configure_github_access + preflight_platform_docs_access`. The aggressive
|
|
6
|
+
* scrub-and-rebuild model (vs. surgical edit) is empirically required —
|
|
7
|
+
* Cursor's pre-installed `cursor` gh account writes url.*.insteadOf rules
|
|
8
|
+
* with its own non-org-authorized token, and a surgical edit loses the race
|
|
9
|
+
* against `gh auth setup-git`'s re-emission.
|
|
10
|
+
*
|
|
11
|
+
* Every external dependency (shell, fs, fetch) is parameterized so the module
|
|
12
|
+
* is fully unit-testable without spawning real git/gh subprocesses or doing
|
|
13
|
+
* real HTTPS calls.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
spawnSync,
|
|
18
|
+
} from 'node:child_process';
|
|
19
|
+
import {
|
|
20
|
+
writeFileSync,
|
|
21
|
+
chmodSync,
|
|
22
|
+
rmSync,
|
|
23
|
+
existsSync,
|
|
24
|
+
} from 'node:fs';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
|
|
27
|
+
const PLATFORM_DOCS_REPO = 'GoHighLevel/platform-docs';
|
|
28
|
+
|
|
29
|
+
/* ─────────────────────────────────────────────────────────────────────────
|
|
30
|
+
* Default shell runner — captures stdout/stderr; supports stdin via opts.input
|
|
31
|
+
* ───────────────────────────────────────────────────────────────────────── */
|
|
32
|
+
|
|
33
|
+
function defaultShell(cmd, opts = {}) {
|
|
34
|
+
const result = spawnSync(cmd, [], {
|
|
35
|
+
shell: true,
|
|
36
|
+
encoding: 'utf8',
|
|
37
|
+
stdio: opts.input ? ['pipe', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe'],
|
|
38
|
+
input: opts.input,
|
|
39
|
+
env: opts.env ?? process.env,
|
|
40
|
+
});
|
|
41
|
+
return {
|
|
42
|
+
ok: result.status === 0,
|
|
43
|
+
stdout: result.stdout ?? '',
|
|
44
|
+
stderr: result.stderr ?? '',
|
|
45
|
+
exitCode: result.status ?? -1,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function runSafe(shell, cmd, opts) {
|
|
50
|
+
try {
|
|
51
|
+
return shell(cmd, opts) ?? { ok: false, stdout: '', stderr: '', exitCode: -1 };
|
|
52
|
+
} catch (err) {
|
|
53
|
+
return { ok: false, stdout: '', stderr: String(err?.message ?? err), exitCode: -1 };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* ─────────────────────────────────────────────────────────────────────────
|
|
58
|
+
* Step 1 — clearAllGitHubAuthState
|
|
59
|
+
* ───────────────────────────────────────────────────────────────────────── */
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Parse `git config --global --get-regexp '^url\.'` output and return
|
|
63
|
+
* unique section names (everything before the final `.<key>` part).
|
|
64
|
+
*
|
|
65
|
+
* url.https://x-access-token:ABC@github.com/.insteadOf https://github.com/
|
|
66
|
+
* → url.https://x-access-token:ABC@github.com/
|
|
67
|
+
*/
|
|
68
|
+
function extractUrlSections(stdout) {
|
|
69
|
+
const sections = new Set();
|
|
70
|
+
for (const rawLine of stdout.split('\n')) {
|
|
71
|
+
const line = rawLine.trim();
|
|
72
|
+
if (!line) continue;
|
|
73
|
+
const fullKey = line.split(/\s+/)[0]; // "url.<section>.<keyName>"
|
|
74
|
+
const section = fullKey.replace(/\.[^.]*$/, '');
|
|
75
|
+
if (section.startsWith('url.')) sections.add(section);
|
|
76
|
+
}
|
|
77
|
+
return [...sections];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Parse `gh auth status` output (stdout AND stderr — gh versions differ).
|
|
82
|
+
* Looks for "Logged in to github.com account <name>" patterns.
|
|
83
|
+
*
|
|
84
|
+
* Defensively filters extracted names to GitHub's allowed username charset
|
|
85
|
+
* (`[A-Za-z0-9-]`) — defense-in-depth against a tampered `gh` output that
|
|
86
|
+
* could otherwise inject shell metacharacters into our `gh auth logout`
|
|
87
|
+
* call below. GitHub usernames are 1–39 chars per the platform; we accept
|
|
88
|
+
* the same range but cap with sanity bounds.
|
|
89
|
+
*/
|
|
90
|
+
const GH_USERNAME_PATTERN = /^[A-Za-z0-9-]{1,39}$/;
|
|
91
|
+
|
|
92
|
+
function extractGhAccounts(stdout, stderr) {
|
|
93
|
+
const haystack = `${stdout}\n${stderr}`;
|
|
94
|
+
const pattern = /Logged in to github\.com account (\S+)/g;
|
|
95
|
+
const accounts = new Set();
|
|
96
|
+
let match;
|
|
97
|
+
while ((match = pattern.exec(haystack)) !== null) {
|
|
98
|
+
const name = match[1];
|
|
99
|
+
if (GH_USERNAME_PATTERN.test(name)) {
|
|
100
|
+
accounts.add(name);
|
|
101
|
+
}
|
|
102
|
+
// Silently drop names that don't match the allowed charset — they are
|
|
103
|
+
// either unparseable cruft from gh's output or evidence of tampering;
|
|
104
|
+
// either way, we will not interpolate them into a shell command.
|
|
105
|
+
}
|
|
106
|
+
return [...accounts];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Aggressively scrub all pre-existing GitHub auth state. Returns a
|
|
111
|
+
* diagnostic record so callers can log what was wiped.
|
|
112
|
+
*
|
|
113
|
+
* @param {object} opts
|
|
114
|
+
* @param {Function} [opts.shell]
|
|
115
|
+
* @param {string} [opts.home]
|
|
116
|
+
*/
|
|
117
|
+
export function clearAllGitHubAuthState({ shell = defaultShell, home = process.env.HOME ?? '~' } = {}) {
|
|
118
|
+
const removedSections = [];
|
|
119
|
+
const loggedOutAccounts = [];
|
|
120
|
+
const wipedFiles = [];
|
|
121
|
+
|
|
122
|
+
// 1. Enumerate + remove url.* sections (wholesale).
|
|
123
|
+
const urlList = runSafe(shell, "git config --global --get-regexp '^url\\.'");
|
|
124
|
+
if (urlList.ok && urlList.stdout) {
|
|
125
|
+
for (const section of extractUrlSections(urlList.stdout)) {
|
|
126
|
+
runSafe(shell, `git config --global --remove-section "${section}"`);
|
|
127
|
+
removedSections.push(section);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 2. Unset both credential.helper variants.
|
|
132
|
+
runSafe(shell, 'git config --global --unset-all credential.helper');
|
|
133
|
+
runSafe(shell, 'git config --global --unset-all credential.https://github.com.helper');
|
|
134
|
+
|
|
135
|
+
// 3. Log out every gh account on github.com (dynamic enumeration).
|
|
136
|
+
if (runSafe(shell, 'command -v gh').ok) {
|
|
137
|
+
const status = runSafe(shell, 'gh auth status --hostname github.com');
|
|
138
|
+
for (const acct of extractGhAccounts(status.stdout, status.stderr)) {
|
|
139
|
+
runSafe(shell, `gh auth logout --hostname github.com --user ${acct}`);
|
|
140
|
+
loggedOutAccounts.push(acct);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 4. Remove ~/.git-credentials.
|
|
145
|
+
const credPath = join(home, '.git-credentials');
|
|
146
|
+
if (existsSync(credPath)) {
|
|
147
|
+
try {
|
|
148
|
+
rmSync(credPath, { force: true });
|
|
149
|
+
wipedFiles.push(credPath);
|
|
150
|
+
} catch {
|
|
151
|
+
// Best-effort: leave a stale file rather than crash the bootstrap.
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { removedSections, loggedOutAccounts, wipedFiles };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* ─────────────────────────────────────────────────────────────────────────
|
|
159
|
+
* Step 2 — installPatInsteadOf + installCredentialHelperStore
|
|
160
|
+
* ───────────────────────────────────────────────────────────────────────── */
|
|
161
|
+
|
|
162
|
+
function assertToken(token) {
|
|
163
|
+
if (typeof token !== 'string' || token.length === 0) {
|
|
164
|
+
throw new Error('gitAuth: token is required (got empty/non-string)');
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Write the global insteadOf rule that rewrites https://github.com/ to use
|
|
170
|
+
* x-access-token:<token>@github.com.
|
|
171
|
+
*
|
|
172
|
+
* @param {string} token
|
|
173
|
+
* @param {object} [opts]
|
|
174
|
+
*/
|
|
175
|
+
export function installPatInsteadOf(token, { shell = defaultShell } = {}) {
|
|
176
|
+
assertToken(token);
|
|
177
|
+
runSafe(
|
|
178
|
+
shell,
|
|
179
|
+
`git config --global url."https://x-access-token:${token}@github.com/".insteadOf "https://github.com/"`
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Install ~/.git-credentials with the token entry (chmod 600) and configure
|
|
185
|
+
* git to read from it via the `store` helper. Belt-and-suspenders alongside
|
|
186
|
+
* insteadOf for tools that bypass URL rewriting.
|
|
187
|
+
*
|
|
188
|
+
* @param {string} token
|
|
189
|
+
* @param {string} home
|
|
190
|
+
* @param {object} [opts]
|
|
191
|
+
*/
|
|
192
|
+
export function installCredentialHelperStore(token, home, { shell = defaultShell } = {}) {
|
|
193
|
+
assertToken(token);
|
|
194
|
+
if (!home || typeof home !== 'string') {
|
|
195
|
+
throw new Error('gitAuth: home is required');
|
|
196
|
+
}
|
|
197
|
+
const credPath = join(home, '.git-credentials');
|
|
198
|
+
writeFileSync(credPath, `https://x-access-token:${token}@github.com\n`, { mode: 0o600 });
|
|
199
|
+
try { chmodSync(credPath, 0o600); } catch { /* best-effort */ }
|
|
200
|
+
runSafe(shell, `git config --global credential.helper "store --file=${credPath}"`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/* ─────────────────────────────────────────────────────────────────────────
|
|
204
|
+
* Step 3 — gh login + setup-git
|
|
205
|
+
* ───────────────────────────────────────────────────────────────────────── */
|
|
206
|
+
|
|
207
|
+
/** Spawn env without GITHUB_TOKEN / GH_TOKEN — gh refuses --with-token if either is set. */
|
|
208
|
+
function envWithoutGhTokens() {
|
|
209
|
+
const env = { ...process.env };
|
|
210
|
+
delete env.GITHUB_TOKEN;
|
|
211
|
+
delete env.GH_TOKEN;
|
|
212
|
+
return env;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Pipe the PAT into `gh auth login --with-token`. Best-effort: returns
|
|
217
|
+
* `{ ok: false }` rather than throwing if gh is missing or login fails.
|
|
218
|
+
*
|
|
219
|
+
* @param {string} token
|
|
220
|
+
* @param {object} [opts]
|
|
221
|
+
*/
|
|
222
|
+
export function ghAuthLoginWithToken(token, { shell = defaultShell } = {}) {
|
|
223
|
+
assertToken(token);
|
|
224
|
+
if (!runSafe(shell, 'command -v gh').ok) {
|
|
225
|
+
return { ok: false, error: 'gh CLI not installed' };
|
|
226
|
+
}
|
|
227
|
+
const result = runSafe(
|
|
228
|
+
shell,
|
|
229
|
+
'gh auth login --hostname github.com --with-token',
|
|
230
|
+
{ input: `${token}\n`, env: envWithoutGhTokens() }
|
|
231
|
+
);
|
|
232
|
+
if (result.ok) return { ok: true };
|
|
233
|
+
return { ok: false, error: (result.stderr || result.stdout || 'gh auth login failed').trim() };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Run `gh auth setup-git` — re-writes a richer set of url.*.insteadOf rules
|
|
238
|
+
* using OUR token (covering git@, ssh://, https:// forms).
|
|
239
|
+
*
|
|
240
|
+
* @param {object} [opts]
|
|
241
|
+
*/
|
|
242
|
+
export function ghAuthSetupGit({ shell = defaultShell } = {}) {
|
|
243
|
+
if (!runSafe(shell, 'command -v gh').ok) return { ok: false };
|
|
244
|
+
const result = runSafe(shell, 'gh auth setup-git');
|
|
245
|
+
return { ok: result.ok };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/* ─────────────────────────────────────────────────────────────────────────
|
|
249
|
+
* Step 4 — ensureOriginRemote
|
|
250
|
+
* ───────────────────────────────────────────────────────────────────────── */
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* If <cwd>/.git exists and no origin matches the desired URL, set/add origin.
|
|
254
|
+
* No-op when there is no .git dir (we may be in a non-git workspace).
|
|
255
|
+
*
|
|
256
|
+
* @param {object} opts
|
|
257
|
+
* @param {string} opts.cwd
|
|
258
|
+
* @param {string} [opts.repoFullName]
|
|
259
|
+
* @param {Function} [opts.shell]
|
|
260
|
+
*/
|
|
261
|
+
export function ensureOriginRemote({ cwd, repoFullName, shell = defaultShell } = {}) {
|
|
262
|
+
if (!cwd || !existsSync(join(cwd, '.git'))) {
|
|
263
|
+
return { changed: false, action: 'no-git-dir' };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const existing = runSafe(shell, `git -C "${cwd}" remote get-url origin`);
|
|
267
|
+
let resolvedRepo = repoFullName;
|
|
268
|
+
if (!resolvedRepo) {
|
|
269
|
+
if (existing.ok) {
|
|
270
|
+
const m = existing.stdout.match(/github\.com[:/]([^/]+\/[^/.]+)(?:\.git)?/);
|
|
271
|
+
if (m) resolvedRepo = m[1];
|
|
272
|
+
}
|
|
273
|
+
if (!resolvedRepo) {
|
|
274
|
+
resolvedRepo = process.env.REPO_FULL_NAME ?? '';
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (!resolvedRepo) {
|
|
278
|
+
return { changed: false, action: 'no-git-dir' };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const desiredUrl = `https://github.com/${resolvedRepo}.git`;
|
|
282
|
+
|
|
283
|
+
if (existing.ok) {
|
|
284
|
+
const current = existing.stdout.trim();
|
|
285
|
+
if (current === desiredUrl) return { changed: false, action: 'noop' };
|
|
286
|
+
runSafe(shell, `git -C "${cwd}" remote set-url origin "${desiredUrl}"`);
|
|
287
|
+
return { changed: true, action: 'set-url' };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
runSafe(shell, `git -C "${cwd}" remote add origin "${desiredUrl}"`);
|
|
291
|
+
return { changed: true, action: 'add' };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/* ─────────────────────────────────────────────────────────────────────────
|
|
295
|
+
* Step 5 — verifyAuth (3 modes) + preflightPlatformDocs
|
|
296
|
+
* ───────────────────────────────────────────────────────────────────────── */
|
|
297
|
+
|
|
298
|
+
const VERIFY_MODES = ['rest-api', 'ls-remote-with-auth', 'ls-remote-via-rewrite'];
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* @typedef {'rest-api' | 'ls-remote-with-auth' | 'ls-remote-via-rewrite'} AuthVerifyMode
|
|
302
|
+
*
|
|
303
|
+
* @param {AuthVerifyMode} mode
|
|
304
|
+
* @param {string} token
|
|
305
|
+
* @param {object} [opts]
|
|
306
|
+
* @param {Function} [opts.shell]
|
|
307
|
+
* @param {Function} [opts.fetchImpl]
|
|
308
|
+
* @returns {Promise<{ ok: boolean, status?: number, error?: string }>}
|
|
309
|
+
*/
|
|
310
|
+
export async function verifyAuth(mode, token, { shell = defaultShell, fetchImpl = globalThis.fetch } = {}) {
|
|
311
|
+
if (!VERIFY_MODES.includes(mode)) {
|
|
312
|
+
throw new Error(`verifyAuth: unknown mode "${mode}" (expected one of ${VERIFY_MODES.join(', ')})`);
|
|
313
|
+
}
|
|
314
|
+
assertToken(token);
|
|
315
|
+
|
|
316
|
+
if (mode === 'rest-api') {
|
|
317
|
+
try {
|
|
318
|
+
const res = await fetchImpl(`https://api.github.com/repos/${PLATFORM_DOCS_REPO}`, {
|
|
319
|
+
headers: {
|
|
320
|
+
Authorization: `token ${token}`,
|
|
321
|
+
Accept: 'application/vnd.github+json',
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
const status = res.status;
|
|
325
|
+
if (status === 200) return { ok: true, status };
|
|
326
|
+
return { ok: false, status };
|
|
327
|
+
} catch (err) {
|
|
328
|
+
return { ok: false, error: String(err?.message ?? err) };
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const url = mode === 'ls-remote-with-auth'
|
|
333
|
+
? `https://x-access-token:${token}@github.com/${PLATFORM_DOCS_REPO}.git`
|
|
334
|
+
: `https://github.com/${PLATFORM_DOCS_REPO}.git`;
|
|
335
|
+
|
|
336
|
+
const result = runSafe(shell, `git ls-remote "${url}" HEAD`);
|
|
337
|
+
if (result.ok) return { ok: true };
|
|
338
|
+
const errMsg = (result.stderr || result.stdout || `git ls-remote exit ${result.exitCode}`).trim();
|
|
339
|
+
return { ok: false, error: errMsg };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Run all three verifyAuth modes and reduce to a single diagnosis token.
|
|
344
|
+
*
|
|
345
|
+
* Diagnosis priority (top to bottom):
|
|
346
|
+
* all FAIL → 'network' (none of the paths work — likely network/PAT unusable)
|
|
347
|
+
* a FAIL → 'pat-invalid' (REST API rejected; org/SAML/scope problem)
|
|
348
|
+
* b FAIL → 'helper-mismatch' (auth-in-URL fails but REST passed — odd)
|
|
349
|
+
* c FAIL → 'rewrite-conflict' (the Cursor scenario)
|
|
350
|
+
* else → 'ok'
|
|
351
|
+
*
|
|
352
|
+
* @param {string} token
|
|
353
|
+
* @param {object} [opts]
|
|
354
|
+
* @returns {Promise<{
|
|
355
|
+
* apiOk: boolean,
|
|
356
|
+
* lsRemoteWithAuthOk: boolean,
|
|
357
|
+
* lsRemoteViaRewriteOk: boolean,
|
|
358
|
+
* diagnosis: 'ok' | 'pat-invalid' | 'rewrite-conflict' | 'helper-mismatch' | 'network'
|
|
359
|
+
* }>}
|
|
360
|
+
*/
|
|
361
|
+
export async function preflightPlatformDocs(token, opts = {}) {
|
|
362
|
+
const a = await verifyAuth('rest-api', token, opts);
|
|
363
|
+
const b = await verifyAuth('ls-remote-with-auth', token, opts);
|
|
364
|
+
const c = await verifyAuth('ls-remote-via-rewrite', token, opts);
|
|
365
|
+
|
|
366
|
+
const apiOk = a.ok;
|
|
367
|
+
const lsRemoteWithAuthOk = b.ok;
|
|
368
|
+
const lsRemoteViaRewriteOk = c.ok;
|
|
369
|
+
|
|
370
|
+
let diagnosis;
|
|
371
|
+
if (!apiOk && !lsRemoteWithAuthOk && !lsRemoteViaRewriteOk) {
|
|
372
|
+
diagnosis = 'network';
|
|
373
|
+
} else if (!apiOk) {
|
|
374
|
+
diagnosis = 'pat-invalid';
|
|
375
|
+
} else if (!lsRemoteWithAuthOk) {
|
|
376
|
+
diagnosis = 'helper-mismatch';
|
|
377
|
+
} else if (!lsRemoteViaRewriteOk) {
|
|
378
|
+
diagnosis = 'rewrite-conflict';
|
|
379
|
+
} else {
|
|
380
|
+
diagnosis = 'ok';
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return { apiOk, lsRemoteWithAuthOk, lsRemoteViaRewriteOk, diagnosis };
|
|
384
|
+
}
|