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.
- 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__/info-merge.test.d.ts +1 -0
- package/dist/commands/__tests__/info-merge.test.js +55 -0
- 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/info.d.ts +141 -0
- package/dist/commands/info.js +29 -7
- 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
|
@@ -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
|
|
141
|
+
const response = await httpFetch(url, {
|
|
141
142
|
method: targetMethod,
|
|
142
143
|
headers,
|
|
143
144
|
body,
|
package/dist/commands/files.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
}
|
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) => {
|
package/dist/commands/init.d.ts
CHANGED
|
@@ -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;
|
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.1";
|
|
@@ -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.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 {};
|