runwork 0.9.4 → 0.10.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.
Files changed (45) hide show
  1. package/dist/agents/__tests__/intro-skill.test.js +6 -2
  2. package/dist/agents/codex.js +9 -3
  3. package/dist/api/__tests__/client.test.js +10 -2
  4. package/dist/api/client.js +5 -4
  5. package/dist/auth/__tests__/login-flow.test.js +57 -81
  6. package/dist/auth/__tests__/store.test.js +35 -6
  7. package/dist/commands/__tests__/upgrade.test.js +25 -42
  8. package/dist/commands/clone.d.ts +2 -2
  9. package/dist/commands/clone.js +39 -7
  10. package/dist/commands/dev.js +9 -1
  11. package/dist/commands/endpoints.js +2 -1
  12. package/dist/commands/files.js +3 -2
  13. package/dist/commands/init.d.ts +2 -2
  14. package/dist/commands/init.js +34 -5
  15. package/dist/commands/upgrade.js +4 -3
  16. package/dist/commands/welcome.js +2 -2
  17. package/dist/devtools/registry-data.d.ts +7 -0
  18. package/dist/devtools/registry-data.js +1 -0
  19. package/dist/generated/version.d.ts +1 -1
  20. package/dist/generated/version.js +1 -1
  21. package/dist/git/__tests__/credentials.test.js +4 -4
  22. package/dist/git/__tests__/identity.test.d.ts +1 -0
  23. package/dist/git/__tests__/identity.test.js +146 -0
  24. package/dist/git/__tests__/preflight.test.d.ts +1 -0
  25. package/dist/git/__tests__/preflight.test.js +36 -0
  26. package/dist/git/auto-commit.js +8 -2
  27. package/dist/git/credentials.js +1 -1
  28. package/dist/git/identity.d.ts +44 -0
  29. package/dist/git/identity.js +133 -0
  30. package/dist/git/preflight.d.ts +28 -0
  31. package/dist/git/preflight.js +50 -0
  32. package/dist/git/sync.js +3 -1
  33. package/dist/health/__tests__/cli-distribution-checks.test.js +25 -28
  34. package/dist/health/checks.js +3 -2
  35. package/dist/index.js +32 -1
  36. package/dist/utils/__tests__/format-error.test.d.ts +1 -0
  37. package/dist/utils/__tests__/format-error.test.js +43 -0
  38. package/dist/utils/__tests__/http.test.d.ts +1 -0
  39. package/dist/utils/__tests__/http.test.js +381 -0
  40. package/dist/utils/agent-guidance.js +10 -3
  41. package/dist/utils/format-error.d.ts +10 -0
  42. package/dist/utils/format-error.js +38 -0
  43. package/dist/utils/http.d.ts +46 -0
  44. package/dist/utils/http.js +421 -0
  45. package/package.json +3 -2
@@ -5,6 +5,9 @@ import { join, resolve } from 'path';
5
5
  import { homedir } from 'os';
6
6
  import { requireAuth } from '../auth/store.js';
7
7
  import { ApiClient } from '../api/client.js';
8
+ import { ensureGitIdentity } from '../git/identity.js';
9
+ import { requireGit } from '../git/preflight.js';
10
+ import { formatError } from '../utils/format-error.js';
8
11
  import { promptSelect, promptInput } from '../utils/prompt.js';
9
12
  import { resolveWorkspace } from '../utils/resolve.js';
10
13
  import { generateManifest, saveManifest } from '../template/manifest.js';
@@ -15,7 +18,7 @@ import { shouldOutputJson, jsonOut } from '../utils/output.js';
15
18
  import { buildInitGuide, buildErrorResponse } from '../utils/agent-guidance.js';
16
19
  /** Default parent directory for new apps when --here is not passed. */
17
20
  export const DEFAULT_APPS_DIR = join(homedir(), '.runwork', 'apps');
18
- export async function execInit(client, appName, workspace, options = {}) {
21
+ export async function execInit(client, appName, workspace, options = {}, creds) {
19
22
  const app = await client.initApp(workspace.id, appName);
20
23
  const slug = app.slug || appName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
21
24
  const parentDir = options.here ? process.cwd() : DEFAULT_APPS_DIR;
@@ -54,10 +57,29 @@ export async function execInit(client, appName, workspace, options = {}) {
54
57
  appName: app.name,
55
58
  };
56
59
  writeFileSync(join(dir, '.runwork.json'), JSON.stringify(config, null, 2));
57
- // Initialize git
60
+ // Initialize git. `stdio: 'pipe'` swallows git's stderr, so we have to
61
+ // print any failure ourselves -- otherwise this fails silently and the
62
+ // user is left with an empty directory and no clue what happened.
58
63
  if (!existsSync(join(dir, '.git'))) {
59
- execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' });
64
+ try {
65
+ execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' });
66
+ }
67
+ catch (err) {
68
+ console.error(`git init failed in ${dir}: ${formatError(err)}`);
69
+ throw err;
70
+ }
71
+ // Force the initial branch to `main` regardless of the user's
72
+ // `init.defaultBranch` config (older Git installs default to
73
+ // `master`). symbolic-ref works before any commits exist; `git
74
+ // branch -M main` would not.
75
+ try {
76
+ execFileSync('git', ['symbolic-ref', 'HEAD', 'refs/heads/main'], { cwd: dir, stdio: 'pipe' });
77
+ }
78
+ catch { /* best-effort: push uses HEAD:main so this is just cleanliness */ }
60
79
  }
80
+ // Seed a local git identity so the initial commit cannot fail on machines
81
+ // (commonly fresh Windows installs) without `git config --global user.email`.
82
+ ensureGitIdentity(dir, creds);
61
83
  // Add runwork remote
62
84
  const remoteUrl = client.getGitRemoteUrl(workspace.id, app.id);
63
85
  try {
@@ -78,7 +100,13 @@ export async function execInit(client, appName, workspace, options = {}) {
78
100
  catch {
79
101
  // Nothing to commit is fine
80
102
  }
81
- execFileSync('git', ['push', '-u', 'runwork', 'main'], { cwd: dir, stdio: 'pipe' });
103
+ try {
104
+ execFileSync('git', ['push', '-u', 'runwork', 'main'], { cwd: dir, stdio: 'pipe' });
105
+ }
106
+ catch (err) {
107
+ console.error(`git push to runwork remote failed: ${formatError(err)}`);
108
+ throw err;
109
+ }
82
110
  console.log(`\nApp "${app.name}" initialized in ${dir}`);
83
111
  return {
84
112
  appId: app.id,
@@ -92,6 +120,7 @@ export async function execInit(client, appName, workspace, options = {}) {
92
120
  /** Full create flow: prompt for name/workspace, init, and run agent wizard */
93
121
  export async function runCreateFlow(name, workspaceFlag, options = {}) {
94
122
  const useJson = shouldOutputJson(undefined);
123
+ requireGit('init');
95
124
  const creds = requireAuth();
96
125
  const client = new ApiClient(creds);
97
126
  const appName = name || await promptInput('App name');
@@ -132,7 +161,7 @@ export async function runCreateFlow(name, workspaceFlag, options = {}) {
132
161
  if (!useJson) {
133
162
  console.log(`Creating "${appName}" in workspace "${workspace.name}"...`);
134
163
  }
135
- const initResult = await execInit(client, appName, workspace, options);
164
+ const initResult = await execInit(client, appName, workspace, options, creds);
136
165
  if (useJson) {
137
166
  const response = {
138
167
  success: true,
@@ -4,13 +4,14 @@ import { platform as osPlatform, tmpdir } from 'os';
4
4
  import { mkdtempSync, writeFileSync, rmSync } from 'fs';
5
5
  import { join } from 'path';
6
6
  import { VERSION } from '../generated/version.js';
7
+ import { httpFetch } from '../utils/http.js';
7
8
  const BASE_URL = 'https://runwork.ai';
8
9
  const LATEST_JSON_URL = `${BASE_URL}/cli/latest.json`;
9
10
  const INSTALL_SH_URL = `${BASE_URL}/install.sh`;
10
11
  const INSTALL_PS1_URL = `${BASE_URL}/install.ps1`;
11
12
  async function fetchLatestVersion() {
12
13
  try {
13
- const response = await fetch(LATEST_JSON_URL, { cache: 'no-store' });
14
+ const response = await httpFetch(LATEST_JSON_URL, { cache: 'no-store' });
14
15
  if (!response.ok)
15
16
  return null;
16
17
  const data = await response.json();
@@ -57,7 +58,7 @@ async function upgradeBinary() {
57
58
  const isWindows = osPlatform() === 'win32';
58
59
  if (isWindows) {
59
60
  console.log('\nUpgrading via PowerShell installer...\n');
60
- const response = await fetch(INSTALL_PS1_URL, { cache: 'no-store' });
61
+ const response = await httpFetch(INSTALL_PS1_URL, { cache: 'no-store' });
61
62
  if (!response.ok) {
62
63
  throw new Error(`Failed to download install.ps1: HTTP ${response.status}`);
63
64
  }
@@ -83,7 +84,7 @@ async function upgradeBinary() {
83
84
  return;
84
85
  }
85
86
  console.log('\nUpgrading via install.sh...\n');
86
- const response = await fetch(INSTALL_SH_URL, { cache: 'no-store' });
87
+ const response = await httpFetch(INSTALL_SH_URL, { cache: 'no-store' });
87
88
  if (!response.ok) {
88
89
  throw new Error(`Failed to download install.sh: HTTP ${response.status}`);
89
90
  }
@@ -51,7 +51,7 @@ export async function runWelcomeWizard() {
51
51
  console.log('');
52
52
  console.log(`Creating ${cyan(appName)} in workspace ${cyan(workspace.name)}...`);
53
53
  const { execInit } = await import('./init.js');
54
- const initResult = await execInit(client, appName, workspace);
54
+ const initResult = await execInit(client, appName, workspace, {}, creds);
55
55
  appDir = initResult.directory;
56
56
  }
57
57
  else {
@@ -62,7 +62,7 @@ export async function runWelcomeWizard() {
62
62
  }
63
63
  const choice = await promptSelect('Select app to clone:', apps.map(a => ({ label: `${a.name} (${a.workspaceName})`, value: a })));
64
64
  const { execClone } = await import('./clone.js');
65
- const cloneResult = await execClone(client, choice.value);
65
+ const cloneResult = await execClone(client, choice.value, undefined, creds);
66
66
  appDir = cloneResult.directory;
67
67
  }
68
68
  // Step 4: Agent guidance
@@ -25,6 +25,13 @@ export type DevToolInstallStrategy = {
25
25
  export interface DevToolDefinition extends InstallableTool {
26
26
  /** Beginner-friendly explanation shown in the install card. */
27
27
  longDescription: string;
28
+ /**
29
+ * When true, the desktop must block onboarding "Continue" until this tool
30
+ * is detected (or explicitly skipped only after an attempted install).
31
+ * Use sparingly: this is reserved for tools whose absence guarantees the
32
+ * core CLI loop (init / clone / dev / deploy) will fail.
33
+ */
34
+ required?: boolean;
28
35
  /**
29
36
  * Per-platform install strategy. Missing platforms fall back to opening
30
37
  * `downloadUrl` in the browser.
@@ -13,6 +13,7 @@ const DEV_TOOL_REGISTRY = [
13
13
  name: 'Git',
14
14
  description: 'Version control system',
15
15
  longDescription: 'Git tracks changes to your code and is required for Runwork commands that sync your work with the platform (init, clone, dev, deploy).',
16
+ required: true,
16
17
  detection: { method: 'binary', target: 'git' },
17
18
  downloadUrl: 'https://git-scm.com/downloads',
18
19
  install: {
@@ -1 +1 @@
1
- export declare const VERSION = "0.9.4";
1
+ export declare const VERSION = "0.10.0";
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by scripts/embed-types.ts -- do not edit
2
- export const VERSION = "0.9.4";
2
+ export const VERSION = "0.10.0";
@@ -18,7 +18,7 @@ describe('git/credentials', () => {
18
18
  '--global',
19
19
  'credential.https://runwork.ai.helper',
20
20
  '!runwork git-credential-helper',
21
- ]);
21
+ ], { stdio: 'pipe' });
22
22
  });
23
23
  it('extracts origin from full URL', async () => {
24
24
  await configureGitCredentials('https://custom.runwork.dev/api/git/ws-1/app-1');
@@ -27,7 +27,7 @@ describe('git/credentials', () => {
27
27
  '--global',
28
28
  'credential.https://custom.runwork.dev.helper',
29
29
  '!runwork git-credential-helper',
30
- ]);
30
+ ], { stdio: 'pipe' });
31
31
  });
32
32
  it('handles URL with port', async () => {
33
33
  await configureGitCredentials('https://localhost:8787/api/git/ws/app');
@@ -36,7 +36,7 @@ describe('git/credentials', () => {
36
36
  '--global',
37
37
  'credential.https://localhost:8787.helper',
38
38
  '!runwork git-credential-helper',
39
- ]);
39
+ ], { stdio: 'pipe' });
40
40
  });
41
41
  });
42
42
  describe('removeGitCredentials()', () => {
@@ -47,7 +47,7 @@ describe('git/credentials', () => {
47
47
  '--global',
48
48
  '--unset',
49
49
  'credential.https://runwork.ai.helper',
50
- ]);
50
+ ], { stdio: 'pipe' });
51
51
  });
52
52
  it('ignores errors silently', async () => {
53
53
  mockExecFileSync.mockImplementation(() => {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,146 @@
1
+ import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
2
+ import { execFileSync } from 'child_process';
3
+ import { mkdtempSync, rmSync } from 'fs';
4
+ import { join } from 'path';
5
+ import { tmpdir } from 'os';
6
+ import { ensureGitIdentity, __testing } from '../identity.js';
7
+ const tempDirs = [];
8
+ // Isolate every git invocation in this file from the developer's real
9
+ // `~/.gitconfig` (and any system-level config). Without this, anyone running
10
+ // the suite locally with `git config --global user.email` already set would
11
+ // see the helper short-circuit (correctly) and the "fresh machine" assertions
12
+ // would never exercise the write path. We point GIT_CONFIG_GLOBAL/SYSTEM at
13
+ // paths that don't exist; git treats missing config files as empty.
14
+ const ISOLATION_DIR = mkdtempSync(join(tmpdir(), 'runwork-identity-isolation-'));
15
+ const NON_EXISTENT = join(ISOLATION_DIR, 'no-such-config');
16
+ const PREVIOUS_ENV = {};
17
+ beforeAll(() => {
18
+ for (const key of ['GIT_CONFIG_GLOBAL', 'GIT_CONFIG_SYSTEM', 'HOME', 'XDG_CONFIG_HOME']) {
19
+ PREVIOUS_ENV[key] = process.env[key];
20
+ }
21
+ process.env.GIT_CONFIG_GLOBAL = NON_EXISTENT;
22
+ process.env.GIT_CONFIG_SYSTEM = NON_EXISTENT;
23
+ // Some git builds still consult $HOME/.gitconfig even with GIT_CONFIG_GLOBAL
24
+ // set. Pointing HOME at the empty isolation directory closes that hole.
25
+ process.env.HOME = ISOLATION_DIR;
26
+ process.env.XDG_CONFIG_HOME = ISOLATION_DIR;
27
+ });
28
+ afterAll(() => {
29
+ for (const [key, value] of Object.entries(PREVIOUS_ENV)) {
30
+ if (value === undefined)
31
+ delete process.env[key];
32
+ else
33
+ process.env[key] = value;
34
+ }
35
+ try {
36
+ rmSync(ISOLATION_DIR, { recursive: true, force: true });
37
+ }
38
+ catch { /* best-effort */ }
39
+ });
40
+ function makeRepo() {
41
+ const dir = mkdtempSync(join(tmpdir(), 'runwork-identity-test-'));
42
+ tempDirs.push(dir);
43
+ execFileSync('git', ['init', dir]);
44
+ return dir;
45
+ }
46
+ function readConfig(cwd, key) {
47
+ try {
48
+ return execFileSync('git', ['config', '--local', '--get', key], { cwd }).toString('utf-8').trim();
49
+ }
50
+ catch {
51
+ return '';
52
+ }
53
+ }
54
+ afterEach(() => {
55
+ for (const dir of tempDirs) {
56
+ try {
57
+ rmSync(dir, { recursive: true, force: true });
58
+ }
59
+ catch { /* best-effort */ }
60
+ }
61
+ tempDirs.length = 0;
62
+ });
63
+ describe('deriveNameFromEmail', () => {
64
+ it('uppercases the local part', () => {
65
+ expect(__testing.deriveNameFromEmail('oytun@motaword.com')).toBe('Oytun');
66
+ });
67
+ it('splits separators into words', () => {
68
+ expect(__testing.deriveNameFromEmail('anna.maria@example.com')).toBe('Anna Maria');
69
+ expect(__testing.deriveNameFromEmail('jean-luc@example.com')).toBe('Jean Luc');
70
+ expect(__testing.deriveNameFromEmail('user_name@example.com')).toBe('User Name');
71
+ });
72
+ it('falls back to default when local part is empty', () => {
73
+ expect(__testing.deriveNameFromEmail('@example.com')).toBe(__testing.FALLBACK_NAME);
74
+ });
75
+ });
76
+ describe('pickIdentity', () => {
77
+ it('uses the credentials email when it looks like an email', () => {
78
+ expect(__testing.pickIdentity({ apiKey: 'k', email: 'oytun@motaword.com', baseUrl: '' }))
79
+ .toEqual({ email: 'oytun@motaword.com', name: 'Oytun' });
80
+ });
81
+ it('falls back when email is the api-key-auth placeholder', () => {
82
+ expect(__testing.pickIdentity({ apiKey: 'k', email: 'api-key-auth', baseUrl: '' }))
83
+ .toEqual({ email: __testing.FALLBACK_EMAIL, name: __testing.FALLBACK_NAME });
84
+ });
85
+ it('falls back when no credentials are passed', () => {
86
+ expect(__testing.pickIdentity(null))
87
+ .toEqual({ email: __testing.FALLBACK_EMAIL, name: __testing.FALLBACK_NAME });
88
+ });
89
+ });
90
+ describe('ensureGitIdentity', () => {
91
+ it('writes local user.email and user.name when none are configured', () => {
92
+ const dir = makeRepo();
93
+ expect(readConfig(dir, 'user.email')).toBe('');
94
+ expect(readConfig(dir, 'user.name')).toBe('');
95
+ ensureGitIdentity(dir, { apiKey: 'k', email: 'oytun@motaword.com', baseUrl: '' });
96
+ expect(readConfig(dir, 'user.email')).toBe('oytun@motaword.com');
97
+ expect(readConfig(dir, 'user.name')).toBe('Oytun');
98
+ });
99
+ it('overwrites local config with Runwork credentials when they are usable', () => {
100
+ // Commits to a Runwork remote should always be attributed to the
101
+ // authenticated Runwork user, even if the developer set a local identity
102
+ // earlier (e.g. a stale value from a previous account).
103
+ const dir = makeRepo();
104
+ execFileSync('git', ['config', '--local', 'user.email', 'preset@example.com'], { cwd: dir });
105
+ execFileSync('git', ['config', '--local', 'user.name', 'Preset User'], { cwd: dir });
106
+ ensureGitIdentity(dir, { apiKey: 'k', email: 'oytun@motaword.com', baseUrl: '' });
107
+ expect(readConfig(dir, 'user.email')).toBe('oytun@motaword.com');
108
+ expect(readConfig(dir, 'user.name')).toBe('Oytun');
109
+ });
110
+ it('respects existing local config when no Runwork credentials are available', () => {
111
+ // Without a real Runwork identity to attribute the commit to, leave any
112
+ // pre-existing local identity (or anything inherited from --global) alone.
113
+ const dir = makeRepo();
114
+ execFileSync('git', ['config', '--local', 'user.email', 'preset@example.com'], { cwd: dir });
115
+ execFileSync('git', ['config', '--local', 'user.name', 'Preset User'], { cwd: dir });
116
+ ensureGitIdentity(dir, null);
117
+ expect(readConfig(dir, 'user.email')).toBe('preset@example.com');
118
+ expect(readConfig(dir, 'user.name')).toBe('Preset User');
119
+ });
120
+ it('respects existing local config when credentials carry only a placeholder email', () => {
121
+ const dir = makeRepo();
122
+ execFileSync('git', ['config', '--local', 'user.email', 'preset@example.com'], { cwd: dir });
123
+ execFileSync('git', ['config', '--local', 'user.name', 'Preset User'], { cwd: dir });
124
+ ensureGitIdentity(dir, { apiKey: 'k', email: 'api-key-auth', baseUrl: '' });
125
+ expect(readConfig(dir, 'user.email')).toBe('preset@example.com');
126
+ expect(readConfig(dir, 'user.name')).toBe('Preset User');
127
+ });
128
+ it('falls back to a synthetic identity when credentials are missing', () => {
129
+ const dir = makeRepo();
130
+ ensureGitIdentity(dir, null);
131
+ expect(readConfig(dir, 'user.email')).toBe(__testing.FALLBACK_EMAIL);
132
+ expect(readConfig(dir, 'user.name')).toBe(__testing.FALLBACK_NAME);
133
+ });
134
+ it('lets a real commit succeed on a fresh repo (windows-style "no identity" path)', () => {
135
+ const dir = makeRepo();
136
+ ensureGitIdentity(dir, { apiKey: 'k', email: 'oytun@motaword.com', baseUrl: '' });
137
+ execFileSync('git', ['commit', '--allow-empty', '-m', 'identity probe'], { cwd: dir, stdio: 'pipe' });
138
+ const log = execFileSync('git', ['log', '--pretty=%an <%ae>'], { cwd: dir }).toString('utf-8').trim();
139
+ expect(log).toContain('Oytun <oytun@motaword.com>');
140
+ });
141
+ it('is a no-op for non-git directories', () => {
142
+ const dir = mkdtempSync(join(tmpdir(), 'runwork-identity-non-git-'));
143
+ tempDirs.push(dir);
144
+ expect(() => ensureGitIdentity(dir, null)).not.toThrow();
145
+ });
146
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,36 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { probeGit, buildMissingGitMessage } from '../preflight.js';
3
+ describe('probeGit', () => {
4
+ it('reports installed=true when git is on PATH (CI invariant)', () => {
5
+ // The CLI test suite already shells out to git in many other tests, so
6
+ // we treat git as a hard prerequisite for this package's own tests.
7
+ const probe = probeGit();
8
+ expect(probe.installed).toBe(true);
9
+ expect(probe.version).toMatch(/^git version /);
10
+ });
11
+ });
12
+ describe('buildMissingGitMessage', () => {
13
+ it('mentions the failing command name', () => {
14
+ const msg = buildMissingGitMessage('clone');
15
+ expect(msg).toContain('runwork clone');
16
+ });
17
+ it('lists install hints for all three OSes', () => {
18
+ const msg = buildMissingGitMessage('dev');
19
+ expect(msg).toMatch(/macOS/);
20
+ expect(msg).toMatch(/Windows/);
21
+ expect(msg).toMatch(/Linux/);
22
+ });
23
+ it('ends with a verification command', () => {
24
+ const msg = buildMissingGitMessage('init');
25
+ expect(msg).toContain('git --version');
26
+ });
27
+ it('only includes the Windows-shell-PATH hint when running on Windows', () => {
28
+ const msg = buildMissingGitMessage('clone');
29
+ if (process.platform === 'win32') {
30
+ expect(msg).toMatch(/cached when it opened/);
31
+ }
32
+ else {
33
+ expect(msg).not.toMatch(/cached when it opened/);
34
+ }
35
+ });
36
+ });
@@ -188,12 +188,18 @@ function commitAndPush() {
188
188
  return;
189
189
  }
190
190
  }
191
+ // Push the current HEAD to the remote's `main` branch regardless of
192
+ // what the local branch is named. This way `git init` defaults of
193
+ // `master` (older Git) or any other custom branch name still sync
194
+ // cleanly to runwork's `main`. Without `HEAD:main`, `git push runwork
195
+ // main` fails with "src refspec main does not match any" when the
196
+ // local branch isn't called `main`.
191
197
  try {
192
198
  execFileSync('git', ['rev-parse', '--abbrev-ref', '@{u}'], { stdio: 'pipe' });
193
- execFileSync('git', ['push', 'runwork', 'main'], { stdio: 'pipe' });
199
+ execFileSync('git', ['push', 'runwork', 'HEAD:main'], { stdio: 'pipe' });
194
200
  }
195
201
  catch {
196
- execFileSync('git', ['push', '-u', 'runwork', 'main'], { stdio: 'pipe' });
202
+ execFileSync('git', ['push', '-u', 'runwork', 'HEAD:main'], { stdio: 'pipe' });
197
203
  }
198
204
  console.log(dim(` Pushed ${stagedFiles.length} file(s) to git.`));
199
205
  activeCallbacks?.onGitPush?.(stagedFiles.length);
@@ -32,7 +32,7 @@ export async function removeGitCredentials(baseUrl) {
32
32
  execFileSync('git', [
33
33
  'config', '--global', '--unset',
34
34
  `credential.${origin}.helper`,
35
- ]);
35
+ ], { stdio: 'pipe' });
36
36
  }
37
37
  catch {
38
38
  // Ignore errors if the config key doesn't exist
@@ -0,0 +1,44 @@
1
+ import type { Credentials } from '../types.js';
2
+ /**
3
+ * Derive a human-friendly display name from an email address. Used when the
4
+ * credentials store only has the email (which is the common case). e.g.
5
+ * "oytun@motaword.com" -> "Oytun"; "anna.maria@example.com" -> "Anna Maria".
6
+ */
7
+ declare function deriveNameFromEmail(email: string): string;
8
+ declare function pickIdentity(creds?: Credentials | null): {
9
+ email: string;
10
+ name: string;
11
+ };
12
+ /**
13
+ * Ensure the repository at `cwd` has a usable git identity (user.email and
14
+ * user.name) and that, whenever possible, commits are attributed to the
15
+ * authenticated Runwork user.
16
+ *
17
+ * Precedence:
18
+ * 1. If we have usable Runwork credentials, always write local-scoped
19
+ * `user.email` / `user.name` from those credentials. Commits to a
20
+ * Runwork remote should be attributable to the logged-in Runwork user,
21
+ * not to whatever generic identity the developer uses for unrelated
22
+ * personal repos.
23
+ * 2. If credentials are missing or are an unusable placeholder
24
+ * (e.g. API-key login), fall back to any existing global / system
25
+ * configuration so the user's pre-existing identity still works.
26
+ * 3. If neither credentials nor any existing config are available, write a
27
+ * synthetic local identity so the inevitable `git commit` does not fail
28
+ * on a fresh Windows / minimal-container install with the classic
29
+ * `unable to auto-detect email address` error.
30
+ *
31
+ * Local scope (`git config --local`) is mandatory: we never touch the user's
32
+ * global config so we cannot disturb identities they use elsewhere.
33
+ *
34
+ * Safe to call multiple times; it short-circuits when the desired identity
35
+ * is already in place.
36
+ */
37
+ export declare function ensureGitIdentity(cwd: string, creds?: Credentials | null): void;
38
+ export declare const __testing: {
39
+ deriveNameFromEmail: typeof deriveNameFromEmail;
40
+ pickIdentity: typeof pickIdentity;
41
+ FALLBACK_EMAIL: string;
42
+ FALLBACK_NAME: string;
43
+ };
44
+ export {};
@@ -0,0 +1,133 @@
1
+ import { execFileSync } from 'child_process';
2
+ /**
3
+ * Default identity used when we cannot derive anything better from the
4
+ * authenticated credentials. Local-scoped, never written to global config,
5
+ * so it does not leak into other repositories on the user's machine.
6
+ */
7
+ const FALLBACK_EMAIL = 'runwork-cli@runwork.local';
8
+ const FALLBACK_NAME = 'Runwork User';
9
+ /**
10
+ * Derive a human-friendly display name from an email address. Used when the
11
+ * credentials store only has the email (which is the common case). e.g.
12
+ * "oytun@motaword.com" -> "Oytun"; "anna.maria@example.com" -> "Anna Maria".
13
+ */
14
+ function deriveNameFromEmail(email) {
15
+ const local = email.split('@')[0] ?? '';
16
+ if (!local)
17
+ return FALLBACK_NAME;
18
+ const cleaned = local.replace(/[._+-]+/g, ' ').trim();
19
+ if (!cleaned)
20
+ return FALLBACK_NAME;
21
+ return cleaned
22
+ .split(/\s+/)
23
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
24
+ .join(' ');
25
+ }
26
+ function isUsableEmail(value) {
27
+ if (!value)
28
+ return false;
29
+ // Reject the "api-key-auth" placeholder used by API-key login flows.
30
+ if (!value.includes('@'))
31
+ return false;
32
+ return true;
33
+ }
34
+ function pickIdentity(creds) {
35
+ const email = isUsableEmail(creds?.email) ? creds.email : FALLBACK_EMAIL;
36
+ const name = email === FALLBACK_EMAIL ? FALLBACK_NAME : deriveNameFromEmail(email);
37
+ return { email, name };
38
+ }
39
+ function readGitConfig(cwd, key) {
40
+ try {
41
+ const value = execFileSync('git', ['config', '--get', key], { cwd, stdio: ['ignore', 'pipe', 'ignore'] });
42
+ return value.toString('utf-8').trim();
43
+ }
44
+ catch {
45
+ return '';
46
+ }
47
+ }
48
+ function readLocalGitConfig(cwd, key) {
49
+ try {
50
+ const value = execFileSync('git', ['config', '--local', '--get', key], { cwd, stdio: ['ignore', 'pipe', 'ignore'] });
51
+ return value.toString('utf-8').trim();
52
+ }
53
+ catch {
54
+ return '';
55
+ }
56
+ }
57
+ /**
58
+ * Ensure the repository at `cwd` has a usable git identity (user.email and
59
+ * user.name) and that, whenever possible, commits are attributed to the
60
+ * authenticated Runwork user.
61
+ *
62
+ * Precedence:
63
+ * 1. If we have usable Runwork credentials, always write local-scoped
64
+ * `user.email` / `user.name` from those credentials. Commits to a
65
+ * Runwork remote should be attributable to the logged-in Runwork user,
66
+ * not to whatever generic identity the developer uses for unrelated
67
+ * personal repos.
68
+ * 2. If credentials are missing or are an unusable placeholder
69
+ * (e.g. API-key login), fall back to any existing global / system
70
+ * configuration so the user's pre-existing identity still works.
71
+ * 3. If neither credentials nor any existing config are available, write a
72
+ * synthetic local identity so the inevitable `git commit` does not fail
73
+ * on a fresh Windows / minimal-container install with the classic
74
+ * `unable to auto-detect email address` error.
75
+ *
76
+ * Local scope (`git config --local`) is mandatory: we never touch the user's
77
+ * global config so we cannot disturb identities they use elsewhere.
78
+ *
79
+ * Safe to call multiple times; it short-circuits when the desired identity
80
+ * is already in place.
81
+ */
82
+ export function ensureGitIdentity(cwd, creds) {
83
+ // Only act on actual git repos; refuse silently if `cwd` has no .git.
84
+ try {
85
+ execFileSync('git', ['rev-parse', '--git-dir'], { cwd, stdio: 'pipe' });
86
+ }
87
+ catch {
88
+ return;
89
+ }
90
+ const credsUsable = isUsableEmail(creds?.email);
91
+ // Path 1: real Runwork credentials -- always overwrite local config so the
92
+ // commit is attributed to the Runwork user, regardless of global state.
93
+ if (credsUsable) {
94
+ const { email, name } = pickIdentity(creds);
95
+ const existingLocalEmail = readLocalGitConfig(cwd, 'user.email');
96
+ const existingLocalName = readLocalGitConfig(cwd, 'user.name');
97
+ if (existingLocalEmail !== email) {
98
+ try {
99
+ execFileSync('git', ['config', '--local', 'user.email', email], { cwd, stdio: 'pipe' });
100
+ }
101
+ catch { /* read-only fs */ }
102
+ }
103
+ if (existingLocalName !== name) {
104
+ try {
105
+ execFileSync('git', ['config', '--local', 'user.name', name], { cwd, stdio: 'pipe' });
106
+ }
107
+ catch { /* read-only fs */ }
108
+ }
109
+ return;
110
+ }
111
+ // Path 2: no usable creds. Respect any pre-existing identity (global/local).
112
+ const existingEmail = readGitConfig(cwd, 'user.email');
113
+ const existingName = readGitConfig(cwd, 'user.name');
114
+ if (existingEmail && existingName)
115
+ return;
116
+ // Path 3: no creds AND no existing identity -- seed a synthetic fallback
117
+ // so commits do not fail on a fresh machine.
118
+ const { email, name } = pickIdentity(creds);
119
+ if (!existingEmail) {
120
+ try {
121
+ execFileSync('git', ['config', '--local', 'user.email', email], { cwd, stdio: 'pipe' });
122
+ }
123
+ catch { /* read-only fs */ }
124
+ }
125
+ if (!existingName) {
126
+ try {
127
+ execFileSync('git', ['config', '--local', 'user.name', name], { cwd, stdio: 'pipe' });
128
+ }
129
+ catch { /* read-only fs */ }
130
+ }
131
+ }
132
+ // Exported for unit tests so we do not have to re-derive expectations there.
133
+ export const __testing = { deriveNameFromEmail, pickIdentity, FALLBACK_EMAIL, FALLBACK_NAME };
@@ -0,0 +1,28 @@
1
+ export interface GitProbe {
2
+ installed: boolean;
3
+ /** Trimmed `git --version` output when detected; undefined otherwise. */
4
+ version?: string;
5
+ /** Underlying error for diagnostics (most often ENOENT on a missing git binary). */
6
+ error?: NodeJS.ErrnoException;
7
+ }
8
+ /**
9
+ * Probe whether `git` is callable from this process. We use `execFileSync`
10
+ * (not `which`/`where.exe`) so the check follows the exact PATH lookup any
11
+ * subsequent git invocation will use -- that way we never report "found"
12
+ * when the real call would fail with ENOENT, and vice versa.
13
+ */
14
+ export declare function probeGit(): GitProbe;
15
+ /**
16
+ * Build a beginner-friendly message explaining how to recover from a missing
17
+ * git binary. Includes a Windows-specific hint because the most common
18
+ * scenario there is "winget install Git.Git just succeeded but this shell's
19
+ * PATH was cached at launch" -- restarting the shell fixes it without a
20
+ * second install attempt.
21
+ */
22
+ export declare function buildMissingGitMessage(commandName: string): string;
23
+ /**
24
+ * Convenience wrapper for command entry points: probe git, and if it's
25
+ * missing, print the beginner-friendly message and exit with code 1.
26
+ * Returns void on success so callers can early-return on failure.
27
+ */
28
+ export declare function requireGit(commandName: string): void;