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.
- package/dist/agents/__tests__/intro-skill.test.js +6 -2
- package/dist/agents/codex.js +9 -3
- package/dist/api/__tests__/client.test.js +10 -2
- package/dist/api/client.js +5 -4
- package/dist/auth/__tests__/login-flow.test.js +57 -81
- package/dist/auth/__tests__/store.test.js +35 -6
- package/dist/commands/__tests__/upgrade.test.js +25 -42
- package/dist/commands/clone.d.ts +2 -2
- package/dist/commands/clone.js +39 -7
- package/dist/commands/dev.js +9 -1
- package/dist/commands/endpoints.js +2 -1
- package/dist/commands/files.js +3 -2
- package/dist/commands/init.d.ts +2 -2
- package/dist/commands/init.js +34 -5
- package/dist/commands/upgrade.js +4 -3
- package/dist/commands/welcome.js +2 -2
- package/dist/devtools/registry-data.d.ts +7 -0
- package/dist/devtools/registry-data.js +1 -0
- package/dist/generated/version.d.ts +1 -1
- package/dist/generated/version.js +1 -1
- package/dist/git/__tests__/credentials.test.js +4 -4
- package/dist/git/__tests__/identity.test.d.ts +1 -0
- package/dist/git/__tests__/identity.test.js +146 -0
- package/dist/git/__tests__/preflight.test.d.ts +1 -0
- package/dist/git/__tests__/preflight.test.js +36 -0
- package/dist/git/auto-commit.js +8 -2
- package/dist/git/credentials.js +1 -1
- package/dist/git/identity.d.ts +44 -0
- package/dist/git/identity.js +133 -0
- package/dist/git/preflight.d.ts +28 -0
- package/dist/git/preflight.js +50 -0
- package/dist/git/sync.js +3 -1
- package/dist/health/__tests__/cli-distribution-checks.test.js +25 -28
- package/dist/health/checks.js +3 -2
- package/dist/index.js +32 -1
- package/dist/utils/__tests__/format-error.test.d.ts +1 -0
- package/dist/utils/__tests__/format-error.test.js +43 -0
- package/dist/utils/__tests__/http.test.d.ts +1 -0
- package/dist/utils/__tests__/http.test.js +381 -0
- package/dist/utils/agent-guidance.js +10 -3
- package/dist/utils/format-error.d.ts +10 -0
- package/dist/utils/format-error.js +38 -0
- package/dist/utils/http.d.ts +46 -0
- package/dist/utils/http.js +421 -0
- package/package.json +3 -2
package/dist/commands/init.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
package/dist/commands/upgrade.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
}
|
package/dist/commands/welcome.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
+
});
|
package/dist/git/auto-commit.js
CHANGED
|
@@ -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);
|
package/dist/git/credentials.js
CHANGED
|
@@ -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;
|