runwork 0.9.4 → 0.10.1

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 (49) 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__/info-merge.test.d.ts +1 -0
  8. package/dist/commands/__tests__/info-merge.test.js +55 -0
  9. package/dist/commands/__tests__/upgrade.test.js +25 -42
  10. package/dist/commands/clone.d.ts +2 -2
  11. package/dist/commands/clone.js +39 -7
  12. package/dist/commands/dev.js +9 -1
  13. package/dist/commands/endpoints.js +2 -1
  14. package/dist/commands/files.js +3 -2
  15. package/dist/commands/info.d.ts +141 -0
  16. package/dist/commands/info.js +29 -7
  17. package/dist/commands/init.d.ts +2 -2
  18. package/dist/commands/init.js +34 -5
  19. package/dist/commands/upgrade.js +4 -3
  20. package/dist/commands/welcome.js +2 -2
  21. package/dist/devtools/registry-data.d.ts +7 -0
  22. package/dist/devtools/registry-data.js +1 -0
  23. package/dist/generated/version.d.ts +1 -1
  24. package/dist/generated/version.js +1 -1
  25. package/dist/git/__tests__/credentials.test.js +4 -4
  26. package/dist/git/__tests__/identity.test.d.ts +1 -0
  27. package/dist/git/__tests__/identity.test.js +146 -0
  28. package/dist/git/__tests__/preflight.test.d.ts +1 -0
  29. package/dist/git/__tests__/preflight.test.js +36 -0
  30. package/dist/git/auto-commit.js +8 -2
  31. package/dist/git/credentials.js +1 -1
  32. package/dist/git/identity.d.ts +44 -0
  33. package/dist/git/identity.js +133 -0
  34. package/dist/git/preflight.d.ts +28 -0
  35. package/dist/git/preflight.js +50 -0
  36. package/dist/git/sync.js +3 -1
  37. package/dist/health/__tests__/cli-distribution-checks.test.js +25 -28
  38. package/dist/health/checks.js +3 -2
  39. package/dist/index.js +32 -1
  40. package/dist/utils/__tests__/format-error.test.d.ts +1 -0
  41. package/dist/utils/__tests__/format-error.test.js +43 -0
  42. package/dist/utils/__tests__/http.test.d.ts +1 -0
  43. package/dist/utils/__tests__/http.test.js +381 -0
  44. package/dist/utils/agent-guidance.js +10 -3
  45. package/dist/utils/format-error.d.ts +10 -0
  46. package/dist/utils/format-error.js +38 -0
  47. package/dist/utils/http.d.ts +46 -0
  48. package/dist/utils/http.js +421 -0
  49. package/package.json +3 -2
@@ -3,6 +3,7 @@ import { requireAuth } from '../auth/store.js';
3
3
  import { ApiClient } from '../api/client.js';
4
4
  import { resolveWorkspace } from '../workspace/resolve.js';
5
5
  import { shouldOutputJson, jsonOut } from '../utils/output.js';
6
+ import { httpFetch } from '../utils/http.js';
6
7
  function truncate(text, max) {
7
8
  if (!text)
8
9
  return '';
@@ -137,7 +138,7 @@ const callCommand = new Command('call')
137
138
  else {
138
139
  console.log(`\n${targetMethod} ${url}\n`);
139
140
  }
140
- const response = await fetch(url, {
141
+ const response = await httpFetch(url, {
141
142
  method: targetMethod,
142
143
  headers,
143
144
  body,
@@ -6,6 +6,7 @@ import { ApiClient } from '../api/client.js';
6
6
  import { resolveWorkspace } from '../workspace/resolve.js';
7
7
  import { shouldOutputJson, jsonOut } from '../utils/output.js';
8
8
  import { promptConfirm } from '../utils/prompt.js';
9
+ import { httpFetch } from '../utils/http.js';
9
10
  function formatSize(bytes) {
10
11
  if (bytes === undefined)
11
12
  return '';
@@ -106,7 +107,7 @@ const downloadCommand = new Command('download')
106
107
  const outputPath = output || basename(key);
107
108
  try {
108
109
  const { url } = await client.getPresignedUrl(workspaceId, bucket, { action: 'read', key });
109
- const response = await fetch(url);
110
+ const response = await httpFetch(url);
110
111
  if (!response.ok) {
111
112
  throw new Error(`Download failed: ${response.status} ${response.statusText}`);
112
113
  }
@@ -137,7 +138,7 @@ const uploadCommand = new Command('upload')
137
138
  try {
138
139
  const fileBuffer = readFileSync(localPath);
139
140
  const { url } = await client.getPresignedUrl(workspaceId, bucket, { action: 'write', key: objectKey });
140
- const response = await fetch(url, { method: 'PUT', body: fileBuffer });
141
+ const response = await httpFetch(url, { method: 'PUT', body: fileBuffer });
141
142
  if (!response.ok) {
142
143
  throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
143
144
  }
@@ -1,3 +1,144 @@
1
1
  import { Command } from 'commander';
2
+ import type { WorkspaceAllData } from '../types.js';
3
+ interface BlueprintEntity {
4
+ entityName: string;
5
+ schema?: Record<string, unknown>;
6
+ }
7
+ interface BlueprintSchedule {
8
+ name: string;
9
+ schedule?: string;
10
+ description?: string;
11
+ }
12
+ interface BlueprintWorkflow {
13
+ name: string;
14
+ description?: string;
15
+ }
16
+ interface BlueprintAgent {
17
+ name: string;
18
+ type?: string;
19
+ description?: string;
20
+ }
21
+ interface BlueprintEndpoint {
22
+ path: string;
23
+ method?: string;
24
+ description?: string;
25
+ }
26
+ interface BlueprintComponent {
27
+ componentName: string;
28
+ }
29
+ interface Blueprint {
30
+ entities?: BlueprintEntity[];
31
+ schedules?: BlueprintSchedule[];
32
+ scheduledJobs?: BlueprintSchedule[];
33
+ workflows?: BlueprintWorkflow[];
34
+ agents?: BlueprintAgent[];
35
+ endpoints?: BlueprintEndpoint[];
36
+ publicEndpoints?: BlueprintEndpoint[];
37
+ components?: BlueprintComponent[];
38
+ fileStorage?: boolean | {
39
+ enabled?: boolean;
40
+ };
41
+ }
42
+ interface MergedEntity {
43
+ name: string;
44
+ sources: string[];
45
+ schema?: Record<string, unknown>;
46
+ }
47
+ interface MergedScheduledJob {
48
+ name: string;
49
+ sources: string[];
50
+ schedule?: string;
51
+ description?: string;
52
+ }
53
+ interface MergedWorkflow {
54
+ name: string;
55
+ sources: string[];
56
+ description?: string;
57
+ }
58
+ interface MergedAgent {
59
+ name: string;
60
+ sources: string[];
61
+ type?: string;
62
+ description?: string;
63
+ }
64
+ interface MergedEndpoint {
65
+ path: string;
66
+ method?: string;
67
+ sources: string[];
68
+ description?: string;
69
+ }
70
+ interface MergedComponent {
71
+ name: string;
72
+ sources: string[];
73
+ }
74
+ interface MergedFileStorage {
75
+ enabled: boolean;
76
+ sources: string[];
77
+ }
78
+ interface InfoOutput {
79
+ app: {
80
+ id: string;
81
+ name: string;
82
+ slug: string;
83
+ } | null;
84
+ workspace: {
85
+ id: string;
86
+ name: string;
87
+ };
88
+ preview: {
89
+ url: string | null;
90
+ active: boolean;
91
+ };
92
+ production: {
93
+ url: null;
94
+ deployed: boolean;
95
+ };
96
+ integrations: Array<{
97
+ id: string;
98
+ status: string;
99
+ }> | null;
100
+ registries: {
101
+ entities: MergedEntity[];
102
+ workflows: MergedWorkflow[];
103
+ scheduledJobs: MergedScheduledJob[];
104
+ agents: MergedAgent[];
105
+ publicEndpoints: MergedEndpoint[];
106
+ components: MergedComponent[];
107
+ fileStorage: MergedFileStorage;
108
+ } | null;
109
+ cli: {
110
+ version: string;
111
+ commands: Array<{
112
+ name: string;
113
+ description: string;
114
+ }>;
115
+ };
116
+ }
117
+ interface ServerRegistryItem {
118
+ appId: string;
119
+ appName: string;
120
+ deploymentMode?: 'preview' | 'production';
121
+ }
122
+ declare function mergeSources<T extends ServerRegistryItem>(blueprintItems: Array<{
123
+ name: string;
124
+ extra?: Record<string, unknown>;
125
+ }>, serverItems: T[], getServerName: (item: T) => string, appId: string): Map<string, {
126
+ sources: string[];
127
+ serverItem?: T;
128
+ blueprintExtra?: Record<string, unknown>;
129
+ }>;
130
+ declare function buildRegistries(blueprint: Blueprint | null, serverData: WorkspaceAllData | null, appId: string): InfoOutput['registries'];
131
+ /**
132
+ * Pad a possibly-missing string to a fixed width. Returns a visible
133
+ * placeholder when the value isn't a usable string so the human-readable
134
+ * printer can never crash on an unexpectedly-undefined field.
135
+ */
136
+ declare function padName(value: unknown, width: number): string;
2
137
  export declare function formatSources(sources: string[]): string;
138
+ export declare const __testing: {
139
+ mergeSources: typeof mergeSources;
140
+ padName: typeof padName;
141
+ buildRegistries: typeof buildRegistries;
142
+ };
3
143
  export declare const infoCommand: Command;
144
+ export {};
@@ -32,13 +32,22 @@ function readBlueprint(cwd) {
32
32
  }
33
33
  function mergeSources(blueprintItems, serverItems, getServerName, appId) {
34
34
  const map = new Map();
35
+ // Skip nameless entries so we never store an `undefined` key. A bad
36
+ // blueprint or a partially-populated server-side row would otherwise
37
+ // produce a registry entry with `name === undefined`, which then crashes
38
+ // the human-readable printer at `a.name.padEnd(24)` and yields ugly
39
+ // `"undefined": ...` keys in JSON output.
35
40
  for (const bp of blueprintItems) {
41
+ if (typeof bp.name !== 'string' || bp.name.length === 0)
42
+ continue;
36
43
  map.set(bp.name, { sources: ['blueprint'], blueprintExtra: bp.extra });
37
44
  }
38
45
  for (const serverItem of serverItems) {
39
46
  if (serverItem.appId !== appId)
40
47
  continue;
41
48
  const name = getServerName(serverItem);
49
+ if (typeof name !== 'string' || name.length === 0)
50
+ continue;
42
51
  const mode = serverItem.deploymentMode;
43
52
  const source = mode === 'production' ? 'production' : 'preview';
44
53
  const existing = map.get(name);
@@ -171,6 +180,16 @@ function getCliCommands(command) {
171
180
  return { name, description: cmd.description() };
172
181
  });
173
182
  }
183
+ /**
184
+ * Pad a possibly-missing string to a fixed width. Returns a visible
185
+ * placeholder when the value isn't a usable string so the human-readable
186
+ * printer can never crash on an unexpectedly-undefined field.
187
+ */
188
+ function padName(value, width) {
189
+ if (typeof value === 'string' && value.length > 0)
190
+ return value.padEnd(width);
191
+ return '(unnamed)'.padEnd(width);
192
+ }
174
193
  export function formatSources(sources) {
175
194
  return sources
176
195
  .map(s => {
@@ -240,7 +259,7 @@ function printHumanOutput(data) {
240
259
  if (reg.entities.length > 0) {
241
260
  console.log(` ${bold('Entities:')}`);
242
261
  for (const e of reg.entities) {
243
- console.log(` ${e.name.padEnd(24)} ${formatSources(e.sources)}`);
262
+ console.log(` ${padName(e.name, 24)} ${formatSources(e.sources)}`);
244
263
  }
245
264
  console.log('');
246
265
  }
@@ -248,7 +267,7 @@ function printHumanOutput(data) {
248
267
  console.log(` ${bold('Workflows:')}`);
249
268
  for (const w of reg.workflows) {
250
269
  const desc = w.description ? ` ${dim(w.description)}` : '';
251
- console.log(` ${w.name.padEnd(24)} ${formatSources(w.sources)}${desc}`);
270
+ console.log(` ${padName(w.name, 24)} ${formatSources(w.sources)}${desc}`);
252
271
  }
253
272
  console.log('');
254
273
  }
@@ -256,7 +275,7 @@ function printHumanOutput(data) {
256
275
  console.log(` ${bold('Scheduled Jobs:')}`);
257
276
  for (const s of reg.scheduledJobs) {
258
277
  const schedule = s.schedule ? ` ${gray(s.schedule)}` : '';
259
- console.log(` ${s.name.padEnd(24)} ${formatSources(s.sources)}${schedule}`);
278
+ console.log(` ${padName(s.name, 24)} ${formatSources(s.sources)}${schedule}`);
260
279
  }
261
280
  console.log('');
262
281
  }
@@ -264,22 +283,22 @@ function printHumanOutput(data) {
264
283
  console.log(` ${bold('Agents:')}`);
265
284
  for (const a of reg.agents) {
266
285
  const type = a.type ? ` ${dim(a.type)}` : '';
267
- console.log(` ${a.name.padEnd(24)} ${formatSources(a.sources)}${type}`);
286
+ console.log(` ${padName(a.name, 24)} ${formatSources(a.sources)}${type}`);
268
287
  }
269
288
  console.log('');
270
289
  }
271
290
  if (reg.publicEndpoints.length > 0) {
272
291
  console.log(` ${bold('Public Endpoints:')}`);
273
292
  for (const e of reg.publicEndpoints) {
274
- const label = e.method ? `${e.method} ${e.path}` : e.path;
275
- console.log(` ${label.padEnd(24)} ${formatSources(e.sources)}`);
293
+ const label = e.method && e.path ? `${e.method} ${e.path}` : (e.path || '');
294
+ console.log(` ${padName(label, 24)} ${formatSources(e.sources)}`);
276
295
  }
277
296
  console.log('');
278
297
  }
279
298
  if (reg.components.length > 0) {
280
299
  console.log(` ${bold('Components:')}`);
281
300
  for (const c of reg.components) {
282
- console.log(` ${c.name.padEnd(24)} ${formatSources(c.sources)}`);
301
+ console.log(` ${padName(c.name, 24)} ${formatSources(c.sources)}`);
283
302
  }
284
303
  console.log('');
285
304
  }
@@ -298,6 +317,9 @@ function printHumanOutput(data) {
298
317
  console.log(dim(` CLI version: ${data.cli.version}`));
299
318
  console.log('');
300
319
  }
320
+ // Internal helpers exposed for unit tests; do not import from anywhere
321
+ // other than `__tests__/`.
322
+ export const __testing = { mergeSources, padName, buildRegistries };
301
323
  export const infoCommand = new Command('info')
302
324
  .description('Show app context, registries, and CLI reference (use --json for agent discovery)')
303
325
  .action(async (_opts, command) => {
@@ -1,6 +1,6 @@
1
1
  import { Command } from 'commander';
2
2
  import { ApiClient } from '../api/client.js';
3
- import type { WorkspaceInfo } from '../types.js';
3
+ import type { Credentials, WorkspaceInfo } from '../types.js';
4
4
  export interface InitResult {
5
5
  appId: string;
6
6
  appName: string;
@@ -15,7 +15,7 @@ export interface ExecInitOptions {
15
15
  }
16
16
  /** Default parent directory for new apps when --here is not passed. */
17
17
  export declare const DEFAULT_APPS_DIR: string;
18
- export declare function execInit(client: ApiClient, appName: string, workspace: WorkspaceInfo, options?: ExecInitOptions): Promise<InitResult>;
18
+ export declare function execInit(client: ApiClient, appName: string, workspace: WorkspaceInfo, options?: ExecInitOptions, creds?: Credentials | null): Promise<InitResult>;
19
19
  /** Full create flow: prompt for name/workspace, init, and run agent wizard */
20
20
  export declare function runCreateFlow(name?: string, workspaceFlag?: string, options?: ExecInitOptions): Promise<void>;
21
21
  export declare const initCommand: Command;
@@ -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.1";
@@ -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.1";
@@ -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 {};