runwork 0.10.0 → 0.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/__tests__/clone-args.test.d.ts +1 -0
- package/dist/commands/__tests__/clone-args.test.js +44 -0
- package/dist/commands/__tests__/info-merge.test.d.ts +1 -0
- package/dist/commands/__tests__/info-merge.test.js +55 -0
- package/dist/commands/clone.d.ts +14 -0
- package/dist/commands/clone.js +20 -2
- package/dist/commands/info.d.ts +141 -0
- package/dist/commands/info.js +29 -7
- package/dist/generated/version.d.ts +1 -1
- package/dist/generated/version.js +1 -1
- package/dist/git/__tests__/credential-helper-e2e.test.d.ts +21 -0
- package/dist/git/__tests__/credential-helper-e2e.test.js +195 -0
- package/dist/git/__tests__/credentials.test.js +33 -20
- package/dist/git/__tests__/preflight-resolution.test.d.ts +1 -0
- package/dist/git/__tests__/preflight-resolution.test.js +366 -0
- package/dist/git/credentials.d.ts +17 -0
- package/dist/git/credentials.js +22 -1
- package/dist/git/preflight.d.ts +34 -5
- package/dist/git/preflight.js +237 -11
- package/dist/health/__tests__/checks.test.js +134 -0
- package/dist/health/checks.d.ts +13 -0
- package/dist/health/checks.js +130 -14
- package/dist/health/runner.js +5 -1
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { normalizeCloneArgs } from '../clone.js';
|
|
3
|
+
describe('normalizeCloneArgs', () => {
|
|
4
|
+
it('passes through both args unchanged when --app is not provided', () => {
|
|
5
|
+
expect(normalizeCloneArgs('app-id', 'C:/dir', undefined)).toEqual({
|
|
6
|
+
appId: 'app-id',
|
|
7
|
+
directory: 'C:/dir',
|
|
8
|
+
});
|
|
9
|
+
expect(normalizeCloneArgs('app-id', 'C:/dir', {})).toEqual({
|
|
10
|
+
appId: 'app-id',
|
|
11
|
+
directory: 'C:/dir',
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
it('passes through both args unchanged when --app is set but directory is also explicit', () => {
|
|
15
|
+
expect(normalizeCloneArgs('app-id', 'C:/dir', { app: 'foo' })).toEqual({ appId: 'app-id', directory: 'C:/dir' });
|
|
16
|
+
});
|
|
17
|
+
it('shifts the first positional to directory when --app is set and only one positional arg is present', () => {
|
|
18
|
+
// The reproducer from the field:
|
|
19
|
+
// runwork clone --app academic-grade-conversion C:\Users\Oytun\Desktop\agc-test
|
|
20
|
+
// commander binds rawAppId = "C:\Users\..." and rawDirectory = undefined.
|
|
21
|
+
expect(normalizeCloneArgs('C:\\Users\\Oytun\\Desktop\\agc-test', undefined, {
|
|
22
|
+
app: 'academic-grade-conversion',
|
|
23
|
+
})).toEqual({
|
|
24
|
+
appId: undefined,
|
|
25
|
+
directory: 'C:\\Users\\Oytun\\Desktop\\agc-test',
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
it('does not shift when --app is set and no positional args are provided', () => {
|
|
29
|
+
expect(normalizeCloneArgs(undefined, undefined, { app: 'foo' })).toEqual({
|
|
30
|
+
appId: undefined,
|
|
31
|
+
directory: undefined,
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
it('does not shift when --app is set with only the directory positional (rawAppId already undefined)', () => {
|
|
35
|
+
// commander binds positional arguments left-to-right; if only one is
|
|
36
|
+
// given, it's always rawAppId. So this case shouldn't arise in practice
|
|
37
|
+
// -- but if a future commander upgrade changes that, we should leave
|
|
38
|
+
// already-correct args alone.
|
|
39
|
+
expect(normalizeCloneArgs(undefined, 'C:/dir', { app: 'foo' })).toEqual({
|
|
40
|
+
appId: undefined,
|
|
41
|
+
directory: 'C:/dir',
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { __testing } from '../info.js';
|
|
3
|
+
const { mergeSources, padName } = __testing;
|
|
4
|
+
describe('mergeSources — bad data resilience', () => {
|
|
5
|
+
it('drops blueprint entries with missing name', () => {
|
|
6
|
+
const map = mergeSources([
|
|
7
|
+
{ name: 'good', extra: {} },
|
|
8
|
+
{ name: undefined, extra: {} },
|
|
9
|
+
{ name: '', extra: {} },
|
|
10
|
+
], [], (item) => item.name ?? '', 'app-1');
|
|
11
|
+
expect(Array.from(map.keys())).toEqual(['good']);
|
|
12
|
+
});
|
|
13
|
+
it('drops server items whose name resolver returns undefined or empty', () => {
|
|
14
|
+
const map = mergeSources([], [
|
|
15
|
+
{ name: 'good', appId: 'app-1', appName: 'My App', deploymentMode: 'preview' },
|
|
16
|
+
{ name: undefined, appId: 'app-1', appName: 'My App', deploymentMode: 'preview' },
|
|
17
|
+
{ name: '', appId: 'app-1', appName: 'My App', deploymentMode: 'preview' },
|
|
18
|
+
], (item) => item.name ?? '', 'app-1');
|
|
19
|
+
expect(Array.from(map.keys())).toEqual(['good']);
|
|
20
|
+
});
|
|
21
|
+
it('skips server items belonging to other apps', () => {
|
|
22
|
+
const map = mergeSources([], [
|
|
23
|
+
{ name: 'mine', appId: 'app-1', appName: 'A', deploymentMode: 'preview' },
|
|
24
|
+
{ name: 'theirs', appId: 'app-2', appName: 'B', deploymentMode: 'preview' },
|
|
25
|
+
], (item) => item.name ?? '', 'app-1');
|
|
26
|
+
expect(Array.from(map.keys())).toEqual(['mine']);
|
|
27
|
+
});
|
|
28
|
+
it('merges blueprint and server entries by name', () => {
|
|
29
|
+
const map = mergeSources([{ name: 'agent-x', extra: { type: 'task' } }], [
|
|
30
|
+
{ name: 'agent-x', appId: 'app-1', appName: 'A', deploymentMode: 'production' },
|
|
31
|
+
{ name: 'agent-y', appId: 'app-1', appName: 'A', deploymentMode: 'preview' },
|
|
32
|
+
], (item) => item.name ?? '', 'app-1');
|
|
33
|
+
const x = map.get('agent-x');
|
|
34
|
+
const y = map.get('agent-y');
|
|
35
|
+
expect(x?.sources).toEqual(['blueprint', 'production']);
|
|
36
|
+
expect(x?.blueprintExtra).toEqual({ type: 'task' });
|
|
37
|
+
expect(y?.sources).toEqual(['preview']);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
describe('padName — defensive printer', () => {
|
|
41
|
+
it('pads valid names to the requested width', () => {
|
|
42
|
+
expect(padName('foo', 8)).toBe('foo ');
|
|
43
|
+
});
|
|
44
|
+
it('returns a placeholder for undefined / null / empty inputs', () => {
|
|
45
|
+
expect(padName(undefined, 12)).toContain('(unnamed)');
|
|
46
|
+
expect(padName(null, 12)).toContain('(unnamed)');
|
|
47
|
+
expect(padName('', 12)).toContain('(unnamed)');
|
|
48
|
+
// Placeholder is also padded to the requested width.
|
|
49
|
+
expect(padName(undefined, 12).length).toBe(12);
|
|
50
|
+
});
|
|
51
|
+
it('does not throw on non-string inputs', () => {
|
|
52
|
+
expect(() => padName(42, 8)).not.toThrow();
|
|
53
|
+
expect(() => padName({}, 8)).not.toThrow();
|
|
54
|
+
});
|
|
55
|
+
});
|
package/dist/commands/clone.d.ts
CHANGED
|
@@ -10,4 +10,18 @@ export interface CloneResult {
|
|
|
10
10
|
workspaceName: string;
|
|
11
11
|
}
|
|
12
12
|
export declare function execClone(client: ApiClient, app: AppInfo, directory?: string, creds?: Credentials | null): Promise<CloneResult>;
|
|
13
|
+
/**
|
|
14
|
+
* Reconcile positional args with --app. When --app is provided, the first
|
|
15
|
+
* positional argument is intended as the *directory* -- not another appId
|
|
16
|
+
* -- because the app is already disambiguated by the option. Without this,
|
|
17
|
+
* `runwork clone --app foo C:\path\to\dir` ends up parsing `C:\path\to\dir`
|
|
18
|
+
* as `appId` (which is then ignored because `--app` wins), leaving
|
|
19
|
+
* `directory` undefined and silently cloning into the slug under cwd.
|
|
20
|
+
*/
|
|
21
|
+
export declare function normalizeCloneArgs(appId: string | undefined, directory: string | undefined, options: {
|
|
22
|
+
app?: string;
|
|
23
|
+
} | undefined): {
|
|
24
|
+
appId: string | undefined;
|
|
25
|
+
directory: string | undefined;
|
|
26
|
+
};
|
|
13
27
|
export declare const cloneCommand: Command;
|
package/dist/commands/clone.js
CHANGED
|
@@ -109,13 +109,28 @@ export async function execClone(client, app, directory, creds) {
|
|
|
109
109
|
workspaceName: app.workspaceName,
|
|
110
110
|
};
|
|
111
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* Reconcile positional args with --app. When --app is provided, the first
|
|
114
|
+
* positional argument is intended as the *directory* -- not another appId
|
|
115
|
+
* -- because the app is already disambiguated by the option. Without this,
|
|
116
|
+
* `runwork clone --app foo C:\path\to\dir` ends up parsing `C:\path\to\dir`
|
|
117
|
+
* as `appId` (which is then ignored because `--app` wins), leaving
|
|
118
|
+
* `directory` undefined and silently cloning into the slug under cwd.
|
|
119
|
+
*/
|
|
120
|
+
export function normalizeCloneArgs(appId, directory, options) {
|
|
121
|
+
if (options?.app && appId && !directory) {
|
|
122
|
+
return { appId: undefined, directory: appId };
|
|
123
|
+
}
|
|
124
|
+
return { appId, directory };
|
|
125
|
+
}
|
|
112
126
|
export const cloneCommand = new Command('clone')
|
|
113
127
|
.description('Clone a Runwork app to local development')
|
|
114
128
|
.argument('[appId]', 'App ID to clone (interactive if omitted)')
|
|
115
129
|
.argument('[directory]', 'Target directory')
|
|
116
130
|
.option('--app <name-or-id>', 'App name or ID (skips interactive selection)')
|
|
117
|
-
.action(async (
|
|
131
|
+
.action(async (rawAppId, rawDirectory, options) => {
|
|
118
132
|
requireGit('clone');
|
|
133
|
+
const { appId, directory } = normalizeCloneArgs(rawAppId, rawDirectory, options);
|
|
119
134
|
const creds = requireAuth();
|
|
120
135
|
const client = new ApiClient(creds);
|
|
121
136
|
const useJson = shouldOutputJson(undefined);
|
|
@@ -151,7 +166,10 @@ export const cloneCommand = new Command('clone')
|
|
|
151
166
|
jsonOut(response);
|
|
152
167
|
return;
|
|
153
168
|
}
|
|
154
|
-
|
|
169
|
+
// No trailing separator: cloneResult.directory is already an absolute
|
|
170
|
+
// path. Appending '/' here mixed with Windows '\' separators produced
|
|
171
|
+
// confusing output like `C:\Users\...\app/` on Windows.
|
|
172
|
+
console.log(`\nApp "${cloneResult.appName}" cloned to ${cloneResult.directory}`);
|
|
155
173
|
console.log(`Remote: ${client.getGitRemoteUrl(cloneResult.workspaceId, cloneResult.appId)}`);
|
|
156
174
|
await runAgentWizard(cloneResult.directory);
|
|
157
175
|
console.log(`Next: cd ${cloneResult.slug} && runwork dev`);
|
package/dist/commands/info.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/commands/info.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.10.
|
|
1
|
+
export declare const VERSION = "0.10.2";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Auto-generated by scripts/embed-types.ts -- do not edit
|
|
2
|
-
export const VERSION = "0.10.
|
|
2
|
+
export const VERSION = "0.10.2";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end auth test.
|
|
3
|
+
*
|
|
4
|
+
* Spins up a tiny HTTP server that mimics the runwork.ai git endpoint's
|
|
5
|
+
* auth contract (HTTP Basic, any username, password = API key) and runs a
|
|
6
|
+
* real `git ls-remote` against it through a credential-helper script. This
|
|
7
|
+
* verifies the entire chain we depend on:
|
|
8
|
+
*
|
|
9
|
+
* git -> credential.<origin>.helper -> our helper script -> our helper's
|
|
10
|
+
* protocol output -> git constructs Basic auth -> server accepts it.
|
|
11
|
+
*
|
|
12
|
+
* Catches the class of bugs we've burned hours on (Pass 1's PATH issues,
|
|
13
|
+
* Pass 2's "logged in but no helper registered") *before* a user hits them.
|
|
14
|
+
*
|
|
15
|
+
* Note: this test exercises the contract our credential helper depends on,
|
|
16
|
+
* not the helper function itself -- credentials.test.ts already covers
|
|
17
|
+
* `handleGitCredentialRequest()` in isolation. Together they prove that
|
|
18
|
+
* (a) the helper produces the right protocol output and (b) git+server
|
|
19
|
+
* accept that output as Basic auth.
|
|
20
|
+
*/
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end auth test.
|
|
3
|
+
*
|
|
4
|
+
* Spins up a tiny HTTP server that mimics the runwork.ai git endpoint's
|
|
5
|
+
* auth contract (HTTP Basic, any username, password = API key) and runs a
|
|
6
|
+
* real `git ls-remote` against it through a credential-helper script. This
|
|
7
|
+
* verifies the entire chain we depend on:
|
|
8
|
+
*
|
|
9
|
+
* git -> credential.<origin>.helper -> our helper script -> our helper's
|
|
10
|
+
* protocol output -> git constructs Basic auth -> server accepts it.
|
|
11
|
+
*
|
|
12
|
+
* Catches the class of bugs we've burned hours on (Pass 1's PATH issues,
|
|
13
|
+
* Pass 2's "logged in but no helper registered") *before* a user hits them.
|
|
14
|
+
*
|
|
15
|
+
* Note: this test exercises the contract our credential helper depends on,
|
|
16
|
+
* not the helper function itself -- credentials.test.ts already covers
|
|
17
|
+
* `handleGitCredentialRequest()` in isolation. Together they prove that
|
|
18
|
+
* (a) the helper produces the right protocol output and (b) git+server
|
|
19
|
+
* accept that output as Basic auth.
|
|
20
|
+
*/
|
|
21
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
22
|
+
import { execFile } from 'child_process';
|
|
23
|
+
import { promisify } from 'util';
|
|
24
|
+
import { mkdtempSync, writeFileSync, rmSync, chmodSync, existsSync } from 'fs';
|
|
25
|
+
import { join } from 'path';
|
|
26
|
+
import { tmpdir } from 'os';
|
|
27
|
+
import { createServer } from 'http';
|
|
28
|
+
import { probeGit } from '../preflight.js';
|
|
29
|
+
const execFileAsync = promisify(execFile);
|
|
30
|
+
const EXPECTED_API_KEY = 'test-api-key-abcd1234';
|
|
31
|
+
/**
|
|
32
|
+
* Minimal HTTP server that:
|
|
33
|
+
* - Returns 401 + WWW-Authenticate when no Basic auth is supplied
|
|
34
|
+
* - Returns 401 when password (the API key) is wrong; username is ignored
|
|
35
|
+
* - Returns 200 + a valid empty-refs pkt-line response when auth is correct
|
|
36
|
+
*
|
|
37
|
+
* The pkt-line response is the smallest valid output for `info/refs?
|
|
38
|
+
* service=git-upload-pack` -- the service announcement plus a flush packet.
|
|
39
|
+
* That's enough for `git ls-remote` to terminate successfully.
|
|
40
|
+
*/
|
|
41
|
+
function createBasicAuthGitServer(attempts) {
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
const server = createServer((req, res) => {
|
|
44
|
+
const authHeader = req.headers['authorization'];
|
|
45
|
+
if (!authHeader || !authHeader.startsWith('Basic ')) {
|
|
46
|
+
attempts.push({ hasAuthHeader: false });
|
|
47
|
+
res.writeHead(401, {
|
|
48
|
+
'WWW-Authenticate': 'Basic realm="Test"',
|
|
49
|
+
'Content-Type': 'text/plain',
|
|
50
|
+
});
|
|
51
|
+
res.end('Authentication required');
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf-8');
|
|
55
|
+
const colonIdx = decoded.indexOf(':');
|
|
56
|
+
const username = colonIdx >= 0 ? decoded.slice(0, colonIdx) : decoded;
|
|
57
|
+
const password = colonIdx >= 0 ? decoded.slice(colonIdx + 1) : '';
|
|
58
|
+
attempts.push({ hasAuthHeader: true, username, password });
|
|
59
|
+
if (password !== EXPECTED_API_KEY) {
|
|
60
|
+
res.writeHead(401, {
|
|
61
|
+
'WWW-Authenticate': 'Basic realm="Test"',
|
|
62
|
+
'Content-Type': 'text/plain',
|
|
63
|
+
});
|
|
64
|
+
res.end('Invalid API key');
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
// Minimal valid info/refs response for git-upload-pack with no refs.
|
|
68
|
+
// Shape:
|
|
69
|
+
// 001e# service=git-upload-pack\n
|
|
70
|
+
// 0000
|
|
71
|
+
// 0000 (no refs)
|
|
72
|
+
const service = '# service=git-upload-pack\n';
|
|
73
|
+
const pktServiceLength = (service.length + 4).toString(16).padStart(4, '0');
|
|
74
|
+
const flush = '0000';
|
|
75
|
+
const body = `${pktServiceLength}${service}${flush}${flush}`;
|
|
76
|
+
res.writeHead(200, {
|
|
77
|
+
'Content-Type': 'application/x-git-upload-pack-advertisement',
|
|
78
|
+
'Cache-Control': 'no-cache',
|
|
79
|
+
});
|
|
80
|
+
res.end(body);
|
|
81
|
+
});
|
|
82
|
+
server.listen(0, '127.0.0.1', () => resolve(server));
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Write a credential helper script into `dir` and return its path. The
|
|
87
|
+
* script ignores stdin (test fixtures don't need to honour git's
|
|
88
|
+
* action/host inputs) and prints the test's API key. Bash on macOS/Linux
|
|
89
|
+
* and Git Bash on Windows both run the resulting `!"<path>"` invocation
|
|
90
|
+
* the same way.
|
|
91
|
+
*/
|
|
92
|
+
function writeHelperScript(dir, apiKey) {
|
|
93
|
+
const scriptPath = join(dir, 'fake-helper.sh');
|
|
94
|
+
const body = [
|
|
95
|
+
'#!/usr/bin/env bash',
|
|
96
|
+
'# Test fixture: emits the git credential helper "get" protocol output',
|
|
97
|
+
'# with a hardcoded API key. The "get" arg is the only one git invokes',
|
|
98
|
+
'# during a fetch; "store"/"erase" are no-ops.',
|
|
99
|
+
'if [ "$1" != "get" ]; then exit 0; fi',
|
|
100
|
+
'# Drain stdin so git\'s pipe doesn\'t stall.',
|
|
101
|
+
'cat > /dev/null',
|
|
102
|
+
'echo "username=runwork"',
|
|
103
|
+
`echo "password=${apiKey}"`,
|
|
104
|
+
'',
|
|
105
|
+
].join('\n');
|
|
106
|
+
writeFileSync(scriptPath, body, { mode: 0o755 });
|
|
107
|
+
// chmod is no-op on Windows but won't error.
|
|
108
|
+
chmodSync(scriptPath, 0o755);
|
|
109
|
+
return scriptPath;
|
|
110
|
+
}
|
|
111
|
+
describe('git credential helper e2e (Basic auth contract)', () => {
|
|
112
|
+
const gitProbe = probeGit();
|
|
113
|
+
const skipReason = !gitProbe.installed ? 'git not installed' : null;
|
|
114
|
+
let tmpDir;
|
|
115
|
+
let homeDir;
|
|
116
|
+
let xdgConfigHome;
|
|
117
|
+
let scriptPath;
|
|
118
|
+
let server;
|
|
119
|
+
let serverUrl;
|
|
120
|
+
let attempts = [];
|
|
121
|
+
beforeAll(async () => {
|
|
122
|
+
if (skipReason)
|
|
123
|
+
return;
|
|
124
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'runwork-helper-e2e-'));
|
|
125
|
+
homeDir = join(tmpDir, 'home');
|
|
126
|
+
xdgConfigHome = join(homeDir, '.config');
|
|
127
|
+
// git -c gc.auto=0 needs ~/.gitconfig to exist when GIT_CONFIG_GLOBAL is
|
|
128
|
+
// pointed at a path that doesn't yet exist; pre-create the dir and
|
|
129
|
+
// empty file.
|
|
130
|
+
const gitConfigGlobal = join(homeDir, '.gitconfig');
|
|
131
|
+
require('fs').mkdirSync(homeDir, { recursive: true });
|
|
132
|
+
writeFileSync(gitConfigGlobal, '');
|
|
133
|
+
scriptPath = writeHelperScript(tmpDir, EXPECTED_API_KEY);
|
|
134
|
+
server = await createBasicAuthGitServer(attempts);
|
|
135
|
+
const addr = server.address();
|
|
136
|
+
serverUrl = `http://127.0.0.1:${addr.port}`;
|
|
137
|
+
// Register the credential helper globally in the isolated git config.
|
|
138
|
+
// We use the unscoped `credential.helper` (not `credential.<url>.helper`)
|
|
139
|
+
// because (a) the test config is fully isolated so we don't risk
|
|
140
|
+
// bleeding into other host auth flows and (b) git's URL matching for
|
|
141
|
+
// scoped helpers has subtleties that aren't what we're testing here --
|
|
142
|
+
// we're testing the helper *contract*, not git's URL matcher.
|
|
143
|
+
const gitBin = gitProbe.path ?? 'git';
|
|
144
|
+
await execFileAsync(gitBin, [
|
|
145
|
+
'config', '--file', gitConfigGlobal,
|
|
146
|
+
'credential.helper', `!"${scriptPath}"`,
|
|
147
|
+
]);
|
|
148
|
+
});
|
|
149
|
+
afterAll(() => {
|
|
150
|
+
if (server)
|
|
151
|
+
server.close();
|
|
152
|
+
if (tmpDir && existsSync(tmpDir))
|
|
153
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
154
|
+
});
|
|
155
|
+
it.skipIf(skipReason)('git ls-remote authenticates via the credential helper end-to-end', async () => {
|
|
156
|
+
const gitBin = gitProbe.path ?? 'git';
|
|
157
|
+
// Use GIT_CONFIG_GLOBAL to isolate from the user's real ~/.gitconfig
|
|
158
|
+
// (which may have an auth helper registered for some other host).
|
|
159
|
+
const env = {
|
|
160
|
+
...process.env,
|
|
161
|
+
GIT_CONFIG_GLOBAL: join(homeDir, '.gitconfig'),
|
|
162
|
+
GIT_CONFIG_SYSTEM: '/dev/null',
|
|
163
|
+
XDG_CONFIG_HOME: xdgConfigHome,
|
|
164
|
+
HOME: homeDir,
|
|
165
|
+
// Force git to never prompt -- if the helper fails, we want the
|
|
166
|
+
// command to exit non-zero, not hang.
|
|
167
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
168
|
+
};
|
|
169
|
+
const path = '/test-workspace/test-app';
|
|
170
|
+
const url = `${serverUrl}${path}`;
|
|
171
|
+
// ls-remote against the fake server. Should exit 0 (server accepted
|
|
172
|
+
// auth and returned a valid -- empty -- ref advertisement).
|
|
173
|
+
const result = await execFileAsync(gitBin, ['ls-remote', url], { env });
|
|
174
|
+
expect(result.stdout).toBe('');
|
|
175
|
+
// No refs in the test response, so stdout is empty.
|
|
176
|
+
// Verify the server actually saw a Basic-auth attempt with our key.
|
|
177
|
+
const successAttempt = attempts.find((a) => a.hasAuthHeader && a.password === EXPECTED_API_KEY);
|
|
178
|
+
expect(successAttempt).toBeDefined();
|
|
179
|
+
expect(successAttempt.username).toBe('runwork');
|
|
180
|
+
}, 20_000);
|
|
181
|
+
it.skipIf(skipReason)('server rejects requests with no auth (sanity: contract is enforced)', async () => {
|
|
182
|
+
// Direct fetch with no Authorization header should 401.
|
|
183
|
+
const response = await fetch(`${serverUrl}/whatever`);
|
|
184
|
+
expect(response.status).toBe(401);
|
|
185
|
+
expect(response.headers.get('www-authenticate')).toContain('Basic');
|
|
186
|
+
});
|
|
187
|
+
it.skipIf(skipReason)('server rejects requests with the wrong API key (sanity: contract is enforced)', async () => {
|
|
188
|
+
const response = await fetch(`${serverUrl}/whatever`, {
|
|
189
|
+
headers: {
|
|
190
|
+
authorization: `Basic ${Buffer.from('runwork:wrong-key').toString('base64')}`,
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
expect(response.status).toBe(401);
|
|
194
|
+
});
|
|
195
|
+
});
|