vexi-cli 0.5.4 → 0.8.0

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,174 @@
1
+ /**
2
+ * URL-based provider identification and model discovery.
3
+ *
4
+ * The user pastes an endpoint URL (e.g. https://openrouter.ai/api/v1).
5
+ * identifyFromUrl() inspects the hostname and path to determine which
6
+ * provider it is and how to authenticate. discoverModels() then calls
7
+ * the /models endpoint to get the live model list.
8
+ */
9
+ const OPERATION_SUFFIXES = [
10
+ '/chat/completions',
11
+ '/completions',
12
+ '/models',
13
+ '/messages',
14
+ ];
15
+ /** Strip trailing slashes and known operation path suffixes so the user can
16
+ * paste a full URL (e.g. .../chat/completions) and still get the API root. */
17
+ function normalizeBase(rawUrl) {
18
+ let url = rawUrl.trim().replace(/\/+$/, '');
19
+ for (const suffix of OPERATION_SUFFIXES) {
20
+ if (url.endsWith(suffix)) {
21
+ url = url.slice(0, url.length - suffix.length);
22
+ break;
23
+ }
24
+ }
25
+ return url;
26
+ }
27
+ function isPrivateIp(host) {
28
+ return (host === 'localhost' ||
29
+ host === '127.0.0.1' ||
30
+ host.endsWith('.local') ||
31
+ /^192\.168\.\d{1,3}\.\d{1,3}$/.test(host) ||
32
+ /^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host));
33
+ }
34
+ /**
35
+ * Identify the provider from a raw endpoint URL.
36
+ * Returns a UrlIdentity describing the provider, normalized base URL,
37
+ * API shape, and where to discover models.
38
+ */
39
+ export function identifyFromUrl(rawUrl) {
40
+ const baseUrl = normalizeBase(rawUrl);
41
+ let parsed;
42
+ try {
43
+ parsed = new URL(baseUrl);
44
+ }
45
+ catch {
46
+ throw new Error(`Invalid URL: ${rawUrl}`);
47
+ }
48
+ const host = parsed.hostname.toLowerCase();
49
+ const path = parsed.pathname;
50
+ // OpenRouter
51
+ if (host === 'openrouter.ai') {
52
+ return {
53
+ provider: 'openrouter',
54
+ displayName: 'OpenRouter',
55
+ baseUrl,
56
+ apiShape: 'openai',
57
+ modelsUrl: `${baseUrl}/models`,
58
+ };
59
+ }
60
+ // Z.ai
61
+ if (host.endsWith('z.ai')) {
62
+ const displayName = path.toLowerCase().includes('/coding/') ? 'Z.ai (Coding Plan)' : 'Z.ai';
63
+ return {
64
+ provider: 'zai',
65
+ displayName,
66
+ baseUrl,
67
+ apiShape: 'openai',
68
+ modelsUrl: `${baseUrl}/models`,
69
+ };
70
+ }
71
+ // Groq
72
+ if (host.endsWith('groq.com')) {
73
+ return {
74
+ provider: 'groq',
75
+ displayName: 'Groq',
76
+ baseUrl,
77
+ apiShape: 'openai',
78
+ modelsUrl: `${baseUrl}/models`,
79
+ };
80
+ }
81
+ // OpenAI
82
+ if (host === 'api.openai.com') {
83
+ return {
84
+ provider: 'openai',
85
+ displayName: 'OpenAI',
86
+ baseUrl,
87
+ apiShape: 'openai',
88
+ modelsUrl: `${baseUrl}/models`,
89
+ };
90
+ }
91
+ // Anthropic
92
+ if (host === 'api.anthropic.com') {
93
+ return {
94
+ provider: 'anthropic',
95
+ displayName: 'Anthropic',
96
+ baseUrl,
97
+ apiShape: 'anthropic',
98
+ modelsUrl: `${baseUrl}/models`,
99
+ };
100
+ }
101
+ // Google Gemini
102
+ if (host.endsWith('generativelanguage.googleapis.com')) {
103
+ return {
104
+ provider: 'gemini',
105
+ displayName: 'Google Gemini',
106
+ baseUrl,
107
+ apiShape: 'openai',
108
+ modelsUrl: `${baseUrl}/models`,
109
+ };
110
+ }
111
+ // Cloudflare Workers AI -- model is embedded in the path after /ai/run/
112
+ if (host.endsWith('api.cloudflare.com') && path.includes('/ai/run/')) {
113
+ const aiRunStart = path.indexOf('/ai/run/') + '/ai/run/'.length;
114
+ let modelFromPath = path.slice(aiRunStart);
115
+ if (modelFromPath.startsWith('@cf/')) {
116
+ modelFromPath = modelFromPath.slice('@cf/'.length);
117
+ }
118
+ // baseUrl is the account endpoint up to /ai/run (without the model)
119
+ const cfBase = `${parsed.protocol}//${parsed.host}${path.slice(0, aiRunStart - 1)}`;
120
+ return {
121
+ provider: 'cloudflare',
122
+ displayName: 'Cloudflare Workers AI',
123
+ baseUrl: cfBase,
124
+ apiShape: 'openai',
125
+ modelsUrl: null,
126
+ modelFromPath: modelFromPath || undefined,
127
+ };
128
+ }
129
+ // Local / private LAN servers (Ollama, LM Studio, vLLM, etc.)
130
+ if (isPrivateIp(host)) {
131
+ return {
132
+ provider: 'local',
133
+ displayName: 'Local (OpenAI-compatible)',
134
+ baseUrl,
135
+ apiShape: 'openai',
136
+ modelsUrl: `${baseUrl}/models`,
137
+ };
138
+ }
139
+ // Unknown / custom endpoint -- assume OpenAI-compatible
140
+ return {
141
+ provider: 'custom',
142
+ displayName: `Custom (${host})`,
143
+ baseUrl,
144
+ apiShape: 'openai',
145
+ modelsUrl: `${baseUrl}/models`,
146
+ };
147
+ }
148
+ /**
149
+ * Discover the available models for a given endpoint.
150
+ *
151
+ * If the URL embeds the model (Cloudflare), returns that model directly.
152
+ * If modelsUrl is null, returns an empty array.
153
+ * Otherwise calls GET /models and maps the response to a list of id strings.
154
+ *
155
+ * Accepts an optional fetchFn for testability (defaults to globalThis.fetch).
156
+ */
157
+ export async function discoverModels(identity, apiKey, fetchFn = globalThis.fetch) {
158
+ if (identity.modelFromPath) {
159
+ return [identity.modelFromPath];
160
+ }
161
+ if (!identity.modelsUrl) {
162
+ return [];
163
+ }
164
+ const headers = identity.apiShape === 'anthropic'
165
+ ? { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' }
166
+ : { Authorization: `Bearer ${apiKey}` };
167
+ const res = await fetchFn(identity.modelsUrl, { headers });
168
+ if (!res.ok) {
169
+ throw new Error(`Provider identified as "${identity.displayName}" but GET /models returned HTTP ${res.status}. ` +
170
+ `Enter the model ID manually.`);
171
+ }
172
+ const json = (await res.json());
173
+ return (json.data ?? []).map((m) => m.id).filter(Boolean);
174
+ }
@@ -0,0 +1,146 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { identifyFromUrl, discoverModels } from './endpoint.js';
3
+ // ── identifyFromUrl ──────────────────────────────────────────────────────────
4
+ describe('identifyFromUrl', () => {
5
+ it('identifies OpenRouter', () => {
6
+ const id = identifyFromUrl('https://openrouter.ai/api/v1');
7
+ expect(id.provider).toBe('openrouter');
8
+ expect(id.displayName).toBe('OpenRouter');
9
+ expect(id.baseUrl).toBe('https://openrouter.ai/api/v1');
10
+ expect(id.apiShape).toBe('openai');
11
+ expect(id.modelsUrl).toBe('https://openrouter.ai/api/v1/models');
12
+ });
13
+ it('strips /chat/completions suffix', () => {
14
+ const id = identifyFromUrl('https://openrouter.ai/api/v1/chat/completions');
15
+ expect(id.baseUrl).toBe('https://openrouter.ai/api/v1');
16
+ });
17
+ it('strips trailing slash', () => {
18
+ const id = identifyFromUrl('https://openrouter.ai/api/v1/');
19
+ expect(id.baseUrl).toBe('https://openrouter.ai/api/v1');
20
+ });
21
+ it('identifies Z.ai coding plan', () => {
22
+ const id = identifyFromUrl('https://api.z.ai/coding/v1');
23
+ expect(id.provider).toBe('zai');
24
+ expect(id.displayName).toBe('Z.ai (Coding Plan)');
25
+ expect(id.apiShape).toBe('openai');
26
+ });
27
+ it('identifies Z.ai generic', () => {
28
+ const id = identifyFromUrl('https://api.z.ai/v1');
29
+ expect(id.provider).toBe('zai');
30
+ expect(id.displayName).toBe('Z.ai');
31
+ });
32
+ it('identifies Anthropic', () => {
33
+ const id = identifyFromUrl('https://api.anthropic.com/v1');
34
+ expect(id.provider).toBe('anthropic');
35
+ expect(id.apiShape).toBe('anthropic');
36
+ expect(id.modelsUrl).toBe('https://api.anthropic.com/v1/models');
37
+ });
38
+ it('identifies OpenAI', () => {
39
+ const id = identifyFromUrl('https://api.openai.com/v1');
40
+ expect(id.provider).toBe('openai');
41
+ expect(id.apiShape).toBe('openai');
42
+ });
43
+ it('identifies Groq', () => {
44
+ const id = identifyFromUrl('https://api.groq.com/openai/v1');
45
+ expect(id.provider).toBe('groq');
46
+ expect(id.displayName).toBe('Groq');
47
+ });
48
+ it('identifies localhost as local', () => {
49
+ const id = identifyFromUrl('http://localhost:11434/v1');
50
+ expect(id.provider).toBe('local');
51
+ expect(id.displayName).toBe('Local (OpenAI-compatible)');
52
+ expect(id.modelsUrl).toBe('http://localhost:11434/v1/models');
53
+ });
54
+ it('identifies 192.168.x.x as local', () => {
55
+ const id = identifyFromUrl('http://192.168.1.100:8080/v1');
56
+ expect(id.provider).toBe('local');
57
+ });
58
+ it('identifies Cloudflare and extracts model from path', () => {
59
+ const id = identifyFromUrl('https://api.cloudflare.com/client/v4/accounts/abc123/ai/run/@cf/meta/llama-3.1-8b');
60
+ expect(id.provider).toBe('cloudflare');
61
+ expect(id.displayName).toBe('Cloudflare Workers AI');
62
+ expect(id.modelsUrl).toBeNull();
63
+ expect(id.modelFromPath).toBe('meta/llama-3.1-8b');
64
+ });
65
+ it('classifies unknown public host as custom', () => {
66
+ const id = identifyFromUrl('https://my-proxy.example.com/v1');
67
+ expect(id.provider).toBe('custom');
68
+ expect(id.displayName).toContain('my-proxy.example.com');
69
+ expect(id.apiShape).toBe('openai');
70
+ });
71
+ it('throws on invalid URL', () => {
72
+ expect(() => identifyFromUrl('not-a-url')).toThrow('Invalid URL');
73
+ });
74
+ });
75
+ // ── discoverModels ────────────────────────────────────────────────────────────
76
+ describe('discoverModels', () => {
77
+ it('returns modelFromPath directly without fetching', async () => {
78
+ const identity = {
79
+ provider: 'cloudflare',
80
+ displayName: 'Cloudflare Workers AI',
81
+ baseUrl: 'https://api.cloudflare.com/client/v4/accounts/abc/ai/run',
82
+ apiShape: 'openai',
83
+ modelsUrl: null,
84
+ modelFromPath: 'meta/llama-3.1-8b',
85
+ };
86
+ const fetchFn = vi.fn();
87
+ const models = await discoverModels(identity, 'key', fetchFn);
88
+ expect(models).toEqual(['meta/llama-3.1-8b']);
89
+ expect(fetchFn).not.toHaveBeenCalled();
90
+ });
91
+ it('returns empty array when modelsUrl is null and no modelFromPath', async () => {
92
+ const identity = {
93
+ provider: 'custom',
94
+ displayName: 'Custom',
95
+ baseUrl: 'https://example.com',
96
+ apiShape: 'openai',
97
+ modelsUrl: null,
98
+ };
99
+ const models = await discoverModels(identity, 'key', vi.fn());
100
+ expect(models).toEqual([]);
101
+ });
102
+ it('fetches /models and maps response data to ids', async () => {
103
+ const identity = {
104
+ provider: 'openrouter',
105
+ displayName: 'OpenRouter',
106
+ baseUrl: 'https://openrouter.ai/api/v1',
107
+ apiShape: 'openai',
108
+ modelsUrl: 'https://openrouter.ai/api/v1/models',
109
+ };
110
+ const mockFetch = vi.fn().mockResolvedValue({
111
+ ok: true,
112
+ json: async () => ({ data: [{ id: 'openai/gpt-4o' }, { id: 'anthropic/claude-3.5-sonnet' }] }),
113
+ });
114
+ const models = await discoverModels(identity, 'sk-test', mockFetch);
115
+ expect(models).toEqual(['openai/gpt-4o', 'anthropic/claude-3.5-sonnet']);
116
+ expect(mockFetch).toHaveBeenCalledWith('https://openrouter.ai/api/v1/models', expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer sk-test' }) }));
117
+ });
118
+ it('uses x-api-key header for Anthropic shape', async () => {
119
+ const identity = {
120
+ provider: 'anthropic',
121
+ displayName: 'Anthropic',
122
+ baseUrl: 'https://api.anthropic.com/v1',
123
+ apiShape: 'anthropic',
124
+ modelsUrl: 'https://api.anthropic.com/v1/models',
125
+ };
126
+ const mockFetch = vi.fn().mockResolvedValue({
127
+ ok: true,
128
+ json: async () => ({ data: [{ id: 'claude-3-5-sonnet-20241022' }] }),
129
+ });
130
+ await discoverModels(identity, 'sk-ant-test', mockFetch);
131
+ expect(mockFetch).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
132
+ headers: expect.objectContaining({ 'x-api-key': 'sk-ant-test' }),
133
+ }));
134
+ });
135
+ it('throws when /models returns non-OK status', async () => {
136
+ const identity = {
137
+ provider: 'groq',
138
+ displayName: 'Groq',
139
+ baseUrl: 'https://api.groq.com/openai/v1',
140
+ apiShape: 'openai',
141
+ modelsUrl: 'https://api.groq.com/openai/v1/models',
142
+ };
143
+ const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 401 });
144
+ await expect(discoverModels(identity, 'bad-key', mockFetch)).rejects.toThrow('HTTP 401');
145
+ });
146
+ });
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Git push workflow for the /push slash command.
3
+ *
4
+ * Design: fully injected — run, confirm, and provider are passed in so the
5
+ * module is testable without touching real git, the filesystem, or the network.
6
+ * All user-facing output goes through the injected log function.
7
+ * Every mutating git command is gated behind an explicit confirm call.
8
+ */
9
+ /** Wrap a string in single quotes, escaping embedded single quotes. */
10
+ function shellQuote(s) {
11
+ return "'" + s.replace(/'/g, "'\\''") + "'";
12
+ }
13
+ const AUTH_SIGNATURES = [
14
+ 'could not read',
15
+ 'authentication failed',
16
+ 'permission denied',
17
+ 'terminal prompts disabled',
18
+ 'host key verification',
19
+ 'batch mode',
20
+ ];
21
+ function isAuthError(combined) {
22
+ const lower = combined.toLowerCase();
23
+ return AUTH_SIGNATURES.some((sig) => lower.includes(sig));
24
+ }
25
+ export async function gitPush(opts) {
26
+ const log = opts.log ?? ((l) => console.log(l));
27
+ const { cwd, run, confirm, provider } = opts;
28
+ // ── 1. Repo check ────────────────────────────────────────────────────────
29
+ const repoCheck = await run('git rev-parse --is-inside-work-tree', cwd);
30
+ if (repoCheck.code !== 0) {
31
+ log('Not a git repository. Initialize one with:');
32
+ log(' git init && git remote add origin <your-repo-url>');
33
+ return { ok: false, reason: 'not-a-repo' };
34
+ }
35
+ // ── 2. Remote check ──────────────────────────────────────────────────────
36
+ const remoteResult = await run('git remote', cwd);
37
+ if (!remoteResult.stdout.trim()) {
38
+ log('No git remote configured. Add one with:');
39
+ log(' git remote add origin <your-repo-url>');
40
+ return { ok: false, reason: 'no-remote' };
41
+ }
42
+ // ── 3. Resolve branch ────────────────────────────────────────────────────
43
+ const branch = opts.branch ?? (await run('git rev-parse --abbrev-ref HEAD', cwd)).stdout.trim();
44
+ if (!branch || branch === 'HEAD') {
45
+ log('Cannot determine current branch (detached HEAD?). Checkout a named branch first.');
46
+ return { ok: false, reason: 'aborted', detail: 'detached HEAD' };
47
+ }
48
+ // ── 4. Inspect working state ─────────────────────────────────────────────
49
+ const statusResult = await run('git status --porcelain', cwd);
50
+ // Preserve leading chars (XY status columns) — do NOT trim() the full output.
51
+ const statusLines = statusResult.stdout.split('\n').filter((l) => l.trim().length > 0);
52
+ const diffStat = (await run('git diff --stat HEAD', cwd)).stdout.trim();
53
+ if (diffStat)
54
+ log(diffStat);
55
+ // ── 5. Stage + commit (skipped when pushOnly) ─────────────────────────────
56
+ if (!opts.pushOnly) {
57
+ if (statusLines.length > 0) {
58
+ // Porcelain format: XY<space>filename — Y != ' ' means unstaged/untracked
59
+ const needsAdd = statusLines.some((l) => l.length >= 2 && l[1] !== ' ');
60
+ if (needsAdd) {
61
+ const addOk = await confirm('Stage all changes? (git add -A)');
62
+ if (!addOk)
63
+ return { ok: false, reason: 'aborted' };
64
+ await run('git add -A', cwd);
65
+ }
66
+ // Draft or use the provided commit message
67
+ let commitMsg = opts.message ?? '';
68
+ if (!commitMsg) {
69
+ const stagedDiff = (await run('git diff --staged', cwd)).stdout;
70
+ const trimmedDiff = stagedDiff.slice(0, 6000);
71
+ const userContent = 'Write a single-line Conventional Commits message (type(scope): description) ' +
72
+ 'for the following diff. Reply with ONLY the commit message — no explanation, ' +
73
+ 'no markdown fences, no backticks.\n\n' +
74
+ (trimmedDiff || '(no staged diff available)');
75
+ const messages = [
76
+ { role: 'system', content: 'You draft git commit messages. Reply with only the message.' },
77
+ { role: 'user', content: userContent },
78
+ ];
79
+ try {
80
+ const raw = await provider.stream(messages, () => { });
81
+ commitMsg = raw.replace(/`/g, '').split('\n')[0]?.trim() ?? '';
82
+ }
83
+ catch {
84
+ // fall through to default below
85
+ }
86
+ if (!commitMsg)
87
+ commitMsg = 'chore: update files';
88
+ }
89
+ log(`Proposed commit message: ${commitMsg}`);
90
+ const commitOk = await confirm(`Commit with: "${commitMsg}"?`);
91
+ if (!commitOk)
92
+ return { ok: false, reason: 'aborted' };
93
+ const commitResult = await run(`git commit -m ${shellQuote(commitMsg)}`, cwd);
94
+ if (commitResult.code !== 0) {
95
+ log(`Commit failed:\n${commitResult.stderr.trim()}`);
96
+ return { ok: false, reason: 'push-failed', detail: commitResult.stderr };
97
+ }
98
+ }
99
+ else {
100
+ // Nothing staged or unstaged — check for already-committed unpushed work
101
+ const logResult = await run('git log @{u}..HEAD --oneline', cwd);
102
+ if (logResult.code === 0 && !logResult.stdout.trim()) {
103
+ log('Nothing to commit and already up to date with remote.');
104
+ return { ok: false, reason: 'nothing-to-do' };
105
+ }
106
+ if (logResult.stdout.trim()) {
107
+ log(`Unpushed commits:\n${logResult.stdout.trim()}`);
108
+ }
109
+ // code !== 0 means no upstream yet — proceed, the push step will set -u
110
+ }
111
+ }
112
+ // ── 6. Auth pre-flight ───────────────────────────────────────────────────
113
+ const safeBranch = shellQuote(branch);
114
+ // Disable terminal prompts so git never hangs waiting for input.
115
+ // On Windows, Git for Windows respects GIT_TERMINAL_PROMPT via its own env.
116
+ const dryRunCmd = process.platform === 'win32'
117
+ ? `git -c core.askPass= push --dry-run origin ${safeBranch}`
118
+ : `GIT_TERMINAL_PROMPT=0 GIT_SSH_COMMAND='ssh -o BatchMode=yes' git -c core.askPass= push --dry-run origin ${safeBranch}`;
119
+ const dryResult = await run(dryRunCmd, cwd);
120
+ if (dryResult.code !== 0 && isAuthError(dryResult.stderr + dryResult.stdout)) {
121
+ log('Authentication failed. Set up git credentials:');
122
+ log(' • SSH key: https://docs.github.com/authentication/connecting-to-github-with-ssh');
123
+ log(' • HTTPS token: personal access token or git credential helper.');
124
+ return { ok: false, reason: 'auth-failed', detail: dryResult.stderr };
125
+ }
126
+ // ── 7. Push ──────────────────────────────────────────────────────────────
127
+ const upstreamResult = await run(`git rev-parse --abbrev-ref --symbolic-full-name ${shellQuote(`${branch}@{upstream}`)}`, cwd);
128
+ const hasUpstream = upstreamResult.code === 0 && upstreamResult.stdout.trim().length > 0;
129
+ const pushCmd = hasUpstream
130
+ ? `git push origin ${safeBranch}`
131
+ : `git push -u origin ${safeBranch}`;
132
+ const pushOk = await confirm(`Run: ${pushCmd}?`);
133
+ if (!pushOk)
134
+ return { ok: false, reason: 'aborted' };
135
+ const pushResult = await run(pushCmd, cwd);
136
+ if (pushResult.code !== 0) {
137
+ log(`Push failed:\n${pushResult.stderr.trim()}`);
138
+ return { ok: false, reason: 'push-failed', detail: pushResult.stderr };
139
+ }
140
+ log(`Pushed ${branch} to origin.`);
141
+ return { ok: true };
142
+ }
@@ -0,0 +1,178 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { gitPush } from './index.js';
3
+ /** Build a mock `run` that returns canned results matched by command substring. */
4
+ function makeRun(specs = []) {
5
+ const calls = [];
6
+ const run = async (cmd, _cwd) => {
7
+ calls.push(cmd);
8
+ for (const spec of specs) {
9
+ if (cmd.includes(spec.match))
10
+ return spec.out;
11
+ }
12
+ return { stdout: '', stderr: '', code: 0 };
13
+ };
14
+ return { run, calls };
15
+ }
16
+ /** Build a mock `confirm` that returns scripted booleans in sequence. */
17
+ function makeConfirm(answers = []) {
18
+ const calls = [];
19
+ let i = 0;
20
+ const confirm = async (msg) => {
21
+ calls.push(msg);
22
+ return answers[i++] ?? true;
23
+ };
24
+ return { confirm, calls };
25
+ }
26
+ const MOCK_PROVIDER = {
27
+ id: 'openai',
28
+ model: 'gpt-4o',
29
+ stream: async () => 'feat: add new feature',
30
+ };
31
+ const OK = { stdout: '', stderr: '', code: 0 };
32
+ /** Specs shared by all happy-path scenarios. */
33
+ function happySpecs(branch = 'main', opts = {}) {
34
+ return [
35
+ { match: '--is-inside-work-tree', out: { stdout: 'true\n', stderr: '', code: 0 } },
36
+ { match: 'git remote', out: { stdout: 'origin\n', stderr: '', code: 0 } },
37
+ { match: '--abbrev-ref HEAD', out: { stdout: `${branch}\n`, stderr: '', code: 0 } },
38
+ { match: 'status --porcelain', out: OK },
39
+ { match: 'diff --stat HEAD', out: OK },
40
+ { match: 'push --dry-run', out: OK }, // must come before 'push origin'
41
+ {
42
+ match: '--symbolic-full-name',
43
+ out: opts.upstreamExists === false
44
+ ? { stdout: '', stderr: 'fatal: no upstream configured', code: 128 }
45
+ : { stdout: `origin/${branch}\n`, stderr: '', code: 0 },
46
+ },
47
+ { match: 'push origin', out: OK },
48
+ { match: 'push -u', out: OK },
49
+ ];
50
+ }
51
+ // ── Test cases ────────────────────────────────────────────────────────────────
52
+ describe('gitPush', () => {
53
+ // 1. Not a git repo
54
+ it('returns not-a-repo and runs no mutating commands', async () => {
55
+ const { run, calls } = makeRun([
56
+ { match: '--is-inside-work-tree', out: { stdout: '', stderr: '', code: 128 } },
57
+ ]);
58
+ const { confirm } = makeConfirm();
59
+ const logs = [];
60
+ const result = await gitPush({
61
+ cwd: '/proj', run, confirm, provider: MOCK_PROVIDER,
62
+ log: (l) => logs.push(l),
63
+ });
64
+ expect(result).toEqual({ ok: false, reason: 'not-a-repo' });
65
+ expect(calls.some((c) => c.includes('git add'))).toBe(false);
66
+ expect(calls.some((c) => c.includes('git commit'))).toBe(false);
67
+ expect(calls.some((c) => c.includes('git push'))).toBe(false);
68
+ expect(logs.some((l) => l.includes('git init'))).toBe(true);
69
+ });
70
+ // 2. No remote configured
71
+ it('returns no-remote and nothing is pushed', async () => {
72
+ const { run, calls } = makeRun([
73
+ { match: '--is-inside-work-tree', out: { stdout: 'true\n', stderr: '', code: 0 } },
74
+ { match: 'git remote', out: { stdout: '', stderr: '', code: 0 } },
75
+ ]);
76
+ const { confirm } = makeConfirm();
77
+ const result = await gitPush({
78
+ cwd: '/proj', run, confirm, provider: MOCK_PROVIDER,
79
+ });
80
+ expect(result).toEqual({ ok: false, reason: 'no-remote' });
81
+ expect(calls.some((c) => c.includes('git push'))).toBe(false);
82
+ });
83
+ // 3. Auth pre-flight fails → real push is NEVER run
84
+ it('returns auth-failed when dry-run shows an auth-signature error', async () => {
85
+ const { run, calls } = makeRun([
86
+ { match: '--is-inside-work-tree', out: { stdout: 'true\n', stderr: '', code: 0 } },
87
+ { match: 'git remote', out: { stdout: 'origin\n', stderr: '', code: 0 } },
88
+ { match: '--abbrev-ref HEAD', out: { stdout: 'main\n', stderr: '', code: 0 } },
89
+ { match: 'status --porcelain', out: OK },
90
+ { match: 'diff --stat HEAD', out: OK },
91
+ // dry-run fails with auth-signature stderr
92
+ {
93
+ match: 'push --dry-run',
94
+ out: { stdout: '', stderr: 'could not read Username for remote: terminal prompts disabled', code: 1 },
95
+ },
96
+ ]);
97
+ const { confirm } = makeConfirm([true]); // any confirms before push would be true
98
+ const result = await gitPush({
99
+ cwd: '/proj', run, confirm, provider: MOCK_PROVIDER, pushOnly: true,
100
+ });
101
+ expect(result.ok).toBe(false);
102
+ expect(result.reason).toBe('auth-failed');
103
+ // The real (non-dry-run) push must NOT have been called
104
+ expect(calls.some((c) => c.includes('push') && !c.includes('--dry-run'))).toBe(false);
105
+ });
106
+ // 4. Happy path: unstaged changes, no message → add, AI-draft commit, push
107
+ it('stages, AI-drafts commit message, and pushes; returns ok: true', async () => {
108
+ const { run, calls } = makeRun([
109
+ { match: '--is-inside-work-tree', out: { stdout: 'true\n', stderr: '', code: 0 } },
110
+ { match: 'git remote', out: { stdout: 'origin\n', stderr: '', code: 0 } },
111
+ { match: '--abbrev-ref HEAD', out: { stdout: 'main\n', stderr: '', code: 0 } },
112
+ // ' M' → Y='M' → unstaged modification → needsAdd = true
113
+ { match: 'status --porcelain', out: { stdout: ' M src/foo.ts\n', stderr: '', code: 0 } },
114
+ { match: 'diff --stat HEAD', out: { stdout: '1 file changed, 2 insertions(+)\n', stderr: '', code: 0 } },
115
+ { match: 'add -A', out: OK },
116
+ { match: 'diff --staged', out: { stdout: '+added a line\n', stderr: '', code: 0 } },
117
+ { match: 'commit -m', out: OK },
118
+ { match: 'push --dry-run', out: OK },
119
+ { match: '--symbolic-full-name', out: { stdout: 'origin/main\n', stderr: '', code: 0 } },
120
+ { match: 'push origin', out: OK },
121
+ ]);
122
+ // all confirms: add, commit, push
123
+ const { confirm } = makeConfirm([true, true, true]);
124
+ const logs = [];
125
+ const result = await gitPush({
126
+ cwd: '/proj', run, confirm, provider: MOCK_PROVIDER,
127
+ log: (l) => logs.push(l),
128
+ });
129
+ expect(result).toEqual({ ok: true });
130
+ expect(calls.some((c) => c.includes('add -A'))).toBe(true);
131
+ expect(calls.some((c) => c.includes('commit -m'))).toBe(true);
132
+ // Real push (non-dry-run) was called
133
+ expect(calls.some((c) => c.includes('push origin') && !c.includes('--dry-run'))).toBe(true);
134
+ // AI-drafted message appears in the log
135
+ expect(logs.some((l) => l.includes('feat: add new feature'))).toBe(true);
136
+ // Final success message
137
+ expect(logs.some((l) => l.includes('Pushed main to origin'))).toBe(true);
138
+ });
139
+ // 5. pushOnly: true → no git add or git commit
140
+ it('pushOnly skips staging and committing, only pushes', async () => {
141
+ const { run, calls } = makeRun(happySpecs());
142
+ const { confirm } = makeConfirm([true]); // only the push confirm
143
+ const result = await gitPush({
144
+ cwd: '/proj', run, confirm, provider: MOCK_PROVIDER, pushOnly: true,
145
+ });
146
+ expect(result).toEqual({ ok: true });
147
+ expect(calls.some((c) => c.includes('add -A'))).toBe(false);
148
+ expect(calls.some((c) => c.includes('commit -m'))).toBe(false);
149
+ expect(calls.some((c) => c.includes('push origin') && !c.includes('--dry-run'))).toBe(true);
150
+ });
151
+ // 6. User declines push confirm → aborted, no real push runs
152
+ it('returns aborted when user declines the push confirm', async () => {
153
+ const { run, calls } = makeRun(happySpecs());
154
+ // pushOnly so there are no add/commit confirms; only one confirm: the push
155
+ const { confirm } = makeConfirm([false]);
156
+ const result = await gitPush({
157
+ cwd: '/proj', run, confirm, provider: MOCK_PROVIDER, pushOnly: true,
158
+ });
159
+ expect(result).toEqual({ ok: false, reason: 'aborted' });
160
+ // No real push was issued after the decline
161
+ expect(calls.some((c) => c.includes('push origin') && !c.includes('--dry-run'))).toBe(false);
162
+ expect(calls.some((c) => c.includes('push -u'))).toBe(false);
163
+ });
164
+ // 7. New branch with no upstream → git push -u origin <branch>
165
+ it('uses git push -u when no upstream is configured', async () => {
166
+ const branch = 'feature/new-thing';
167
+ const { run, calls } = makeRun(happySpecs(branch, { upstreamExists: false }));
168
+ const { confirm } = makeConfirm([true]);
169
+ const result = await gitPush({
170
+ cwd: '/proj', run, confirm, provider: MOCK_PROVIDER,
171
+ pushOnly: true, branch,
172
+ });
173
+ expect(result).toEqual({ ok: true });
174
+ // Must use -u form, not the plain push
175
+ expect(calls.some((c) => c.includes('push -u'))).toBe(true);
176
+ expect(calls.some((c) => c.includes('push origin') && !c.includes('push -u') && !c.includes('--dry-run'))).toBe(false);
177
+ });
178
+ });