@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.
@@ -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
+ }