runwork 0.13.4 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundled-types/core-endpoints.d.ts +16 -4
- package/dist/__tests__/install-scripts.test.js +33 -0
- package/dist/agents/__tests__/chatgpt-registry.test.d.ts +1 -0
- package/dist/agents/__tests__/chatgpt-registry.test.js +61 -0
- package/dist/agents/__tests__/detection.test.js +3 -0
- package/dist/agents/claude-desktop-plugin-tree.js +23 -8
- package/dist/agents/claude-desktop.js +6 -3
- package/dist/agents/codex.js +15 -7
- package/dist/agents/detection.js +3 -0
- package/dist/agents/intro-skill.js +5 -3
- package/dist/agents/registry-data.d.ts +59 -2
- package/dist/agents/registry-data.js +176 -0
- package/dist/api/client.d.ts +5 -0
- package/dist/api/client.js +8 -0
- package/dist/commands/__tests__/clone-args.test.js +8 -1
- package/dist/commands/__tests__/clone-source-result.test.d.ts +1 -0
- package/dist/commands/__tests__/clone-source-result.test.js +45 -0
- package/dist/commands/__tests__/mcp-entries.test.d.ts +1 -0
- package/dist/commands/__tests__/mcp-entries.test.js +48 -0
- package/dist/commands/clone.d.ts +33 -0
- package/dist/commands/clone.js +105 -10
- package/dist/commands/deploy.js +128 -11
- package/dist/commands/dev.js +72 -32
- package/dist/commands/doctor.js +64 -3
- package/dist/commands/info.d.ts +3 -0
- package/dist/commands/info.js +11 -0
- package/dist/commands/mcp-entries.d.ts +13 -0
- package/dist/commands/mcp-entries.js +26 -0
- package/dist/commands/sync.js +20 -11
- package/dist/commands/validate.d.ts +2 -0
- package/dist/commands/validate.js +89 -0
- package/dist/deploy/__tests__/deploy-state.test.d.ts +1 -0
- package/dist/deploy/__tests__/deploy-state.test.js +80 -0
- package/dist/deploy/__tests__/deploy-status.test.d.ts +1 -0
- package/dist/deploy/__tests__/deploy-status.test.js +41 -0
- package/dist/deploy/__tests__/detach-args.test.d.ts +1 -0
- package/dist/deploy/__tests__/detach-args.test.js +30 -0
- package/dist/deploy/deploy-state.d.ts +36 -0
- package/dist/deploy/deploy-state.js +63 -0
- package/dist/deploy/deploy-status.d.ts +21 -0
- package/dist/deploy/deploy-status.js +36 -0
- package/dist/deploy/detach.d.ts +32 -0
- package/dist/deploy/detach.js +71 -0
- package/dist/dev/__tests__/session.test.js +39 -0
- package/dist/dev/__tests__/startup-sync.test.d.ts +1 -0
- package/dist/dev/__tests__/startup-sync.test.js +32 -0
- package/dist/dev/session.d.ts +9 -0
- package/dist/dev/session.js +4 -1
- package/dist/dev/startup-sync.d.ts +23 -0
- package/dist/dev/startup-sync.js +14 -0
- package/dist/generated/bundled-types.js +1 -1
- package/dist/generated/version.d.ts +1 -1
- package/dist/generated/version.js +1 -1
- package/dist/git/__tests__/classify-sync-error.test.d.ts +1 -0
- package/dist/git/__tests__/classify-sync-error.test.js +57 -0
- package/dist/git/__tests__/ensure-git-credential-helper.test.d.ts +1 -0
- package/dist/git/__tests__/ensure-git-credential-helper.test.js +133 -0
- package/dist/git/__tests__/ensure-runwork-remote.test.d.ts +1 -0
- package/dist/git/__tests__/ensure-runwork-remote.test.js +109 -0
- package/dist/git/__tests__/repo-config.test.d.ts +1 -0
- package/dist/git/__tests__/repo-config.test.js +26 -0
- package/dist/git/classify-sync-error.d.ts +21 -0
- package/dist/git/classify-sync-error.js +111 -0
- package/dist/git/credentials.d.ts +43 -0
- package/dist/git/credentials.js +67 -0
- package/dist/git/remote.d.ts +37 -0
- package/dist/git/remote.js +75 -0
- package/dist/git/repo-config.d.ts +8 -0
- package/dist/git/repo-config.js +29 -0
- package/dist/health/__tests__/checks.test.js +2 -2
- package/dist/health/__tests__/fix.test.d.ts +1 -0
- package/dist/health/__tests__/fix.test.js +72 -0
- package/dist/health/__tests__/runner-filter.test.d.ts +1 -0
- package/dist/health/__tests__/runner-filter.test.js +61 -0
- package/dist/health/checks.d.ts +7 -0
- package/dist/health/checks.js +50 -36
- package/dist/health/fix.d.ts +22 -0
- package/dist/health/fix.js +62 -0
- package/dist/health/runner.d.ts +7 -1
- package/dist/health/runner.js +40 -27
- package/dist/index.js +2 -0
- package/dist/tools/types.d.ts +5 -0
- package/dist/types.d.ts +3 -0
- package/dist/utils/__tests__/help-json.test.d.ts +1 -0
- package/dist/utils/__tests__/help-json.test.js +39 -0
- package/dist/utils/__tests__/ignore-matcher.test.js +18 -1
- package/dist/utils/help-json.d.ts +14 -0
- package/dist/utils/help-json.js +4 -1
- package/dist/validate/__tests__/validate.test.d.ts +1 -0
- package/dist/validate/__tests__/validate.test.js +42 -0
- package/dist/validate/freshness.d.ts +28 -0
- package/dist/validate/freshness.js +48 -0
- package/dist/validate/preview.d.ts +21 -0
- package/dist/validate/preview.js +39 -0
- package/package.json +1 -1
package/dist/commands/dev.js
CHANGED
|
@@ -8,6 +8,10 @@ import { watchAndAutoCommit, stopAutoCommit } from '../git/auto-commit.js';
|
|
|
8
8
|
import { syncWithRemote } from '../git/sync.js';
|
|
9
9
|
import { snapshotCriticalFiles, restoreMissingCriticalFiles, commitAndPushRestoredFiles } from '../git/critical-files.js';
|
|
10
10
|
import { ensureGitIdentity } from '../git/identity.js';
|
|
11
|
+
import { ensureGitCredentialHelper } from '../git/credentials.js';
|
|
12
|
+
import { ensureRunworkRemote } from '../git/remote.js';
|
|
13
|
+
import { diagnoseSyncError } from '../git/classify-sync-error.js';
|
|
14
|
+
import { buildStartupSyncSummary } from '../dev/startup-sync.js';
|
|
11
15
|
import { requireGit } from '../git/preflight.js';
|
|
12
16
|
import { startLogTailer } from '../logs/tailer.js';
|
|
13
17
|
import { startPreviewUrlPoller } from '../dev/preview-url-poller.js';
|
|
@@ -110,6 +114,17 @@ export async function execDev(options) {
|
|
|
110
114
|
// fresh machine where `git config --global user.email/.name` has never
|
|
111
115
|
// been set (extremely common on Windows after a clean install).
|
|
112
116
|
ensureGitIdentity(cwd, creds);
|
|
117
|
+
// Ensure git can authenticate to the runwork remote before the sync loop
|
|
118
|
+
// fetches/pushes. Headless auth (get_cli_setup / hand-written credentials)
|
|
119
|
+
// registers API creds but not the git helper. See
|
|
120
|
+
// git/credentials.ts:ensureGitCredentialHelper.
|
|
121
|
+
if (creds?.baseUrl) {
|
|
122
|
+
await ensureGitCredentialHelper(creds.baseUrl);
|
|
123
|
+
}
|
|
124
|
+
// Ensure the `runwork` remote exists and points at the canonical URL. A
|
|
125
|
+
// manual `git clone <url>` names the remote `origin`, which otherwise makes
|
|
126
|
+
// the sync loop fail with "'runwork' does not appear to be a git repository".
|
|
127
|
+
ensureRunworkRemote(cwd, client.getGitRemoteUrl(config.workspaceId, config.appId));
|
|
113
128
|
// Detect user edits made outside of `runwork dev`. When this fires
|
|
114
129
|
// for an AI agent, it almost always means the agent edited code
|
|
115
130
|
// before starting dev -- the wrong order. We surface this loudly so
|
|
@@ -189,6 +204,10 @@ export async function execDev(options) {
|
|
|
189
204
|
await populateSkill(cwd, client, config.appId);
|
|
190
205
|
if (useJson)
|
|
191
206
|
jsonLine({ event: 'startup', phase: 'skill_fetched', timestamp: ts() });
|
|
207
|
+
// Track the startup sync outcome so `session_started` can tell agents
|
|
208
|
+
// whether the preview reflects their latest edits or is serving stale code
|
|
209
|
+
// (friction log #2: a happy session_started masked a failed initial sync).
|
|
210
|
+
let startupSync = buildStartupSyncSummary({ noSync });
|
|
192
211
|
// Sync. In no-sync mode we never mutate the working tree: instead of
|
|
193
212
|
// syncWithRemote (rebase/merge/commit/push) we do a best-effort,
|
|
194
213
|
// read-only `git fetch` so remote-tracking refs stay current and
|
|
@@ -212,6 +231,7 @@ export async function execDev(options) {
|
|
|
212
231
|
console.log(dim('Syncing...'));
|
|
213
232
|
const criticalSnapshot = snapshotCriticalFiles(cwd);
|
|
214
233
|
const syncResult = syncWithRemote(cwd);
|
|
234
|
+
startupSync = buildStartupSyncSummary({ noSync: false, status: syncResult.status });
|
|
215
235
|
const restoredCriticalFiles = restoreMissingCriticalFiles(cwd, criticalSnapshot);
|
|
216
236
|
if (restoredCriticalFiles.length > 0) {
|
|
217
237
|
const pushed = commitAndPushRestoredFiles(cwd, restoredCriticalFiles);
|
|
@@ -233,37 +253,24 @@ export async function execDev(options) {
|
|
|
233
253
|
if (useJson) {
|
|
234
254
|
jsonLine({ event: 'startup', phase: 'sync', status: syncResult.status, pushed: syncResult.pushed, timestamp: ts() });
|
|
235
255
|
if (syncResult.status === 'sync-failed') {
|
|
236
|
-
|
|
237
|
-
jsonLine({
|
|
238
|
-
event: 'error',
|
|
239
|
-
phase: 'sync',
|
|
240
|
-
timestamp: ts(),
|
|
241
|
-
error: {
|
|
242
|
-
message: 'Merge conflicts detected after stash pop',
|
|
243
|
-
diagnosis: 'Local changes could not be automatically merged with the remote state. The stash pop resulted in conflicts that require manual resolution.',
|
|
244
|
-
suggestions: [
|
|
245
|
-
'Resolve the merge conflicts in the affected files',
|
|
246
|
-
'Run git add on resolved files, then git commit',
|
|
247
|
-
'Run runwork dev again after resolving conflicts',
|
|
248
|
-
],
|
|
249
|
-
},
|
|
250
|
-
});
|
|
251
|
-
process.exit(1);
|
|
252
|
-
}
|
|
256
|
+
const diag = diagnoseSyncError(syncResult.error);
|
|
253
257
|
jsonLine({
|
|
254
258
|
event: 'error',
|
|
255
259
|
phase: 'sync',
|
|
256
260
|
timestamp: ts(),
|
|
257
261
|
error: {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
'Check your network connection',
|
|
263
|
-
'Run runwork doctor to diagnose environment issues',
|
|
264
|
-
],
|
|
262
|
+
reason: diag.reason,
|
|
263
|
+
message: diag.message,
|
|
264
|
+
diagnosis: diag.diagnosis,
|
|
265
|
+
suggestions: diag.suggestions,
|
|
265
266
|
},
|
|
266
267
|
});
|
|
268
|
+
// A stash-pop conflict leaves the working tree in a state the user
|
|
269
|
+
// must resolve, so we stop. Other sync failures are non-fatal -- the
|
|
270
|
+
// preview keeps serving the last good state -- so we continue.
|
|
271
|
+
if (syncResult.error === 'stash-conflict') {
|
|
272
|
+
process.exit(1);
|
|
273
|
+
}
|
|
267
274
|
}
|
|
268
275
|
}
|
|
269
276
|
else {
|
|
@@ -277,16 +284,20 @@ export async function execDev(options) {
|
|
|
277
284
|
case 'merged':
|
|
278
285
|
console.log(yellow(' Merged with Runwork (histories diverged).'));
|
|
279
286
|
break;
|
|
280
|
-
case 'sync-failed':
|
|
287
|
+
case 'sync-failed': {
|
|
288
|
+
const diag = diagnoseSyncError(syncResult.error);
|
|
281
289
|
if (syncResult.error === 'stash-conflict') {
|
|
282
|
-
console.error(
|
|
290
|
+
console.error(`${diag.message}. ${diag.diagnosis}`);
|
|
291
|
+
for (const s of diag.suggestions)
|
|
292
|
+
console.error(dim(` - ${s}`));
|
|
283
293
|
process.exit(1);
|
|
284
294
|
}
|
|
285
|
-
console.warn(yellow(`Sync failed. Continuing...`));
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
295
|
+
console.warn(yellow(`Sync failed: ${diag.message}. Continuing...`));
|
|
296
|
+
console.warn(dim(` ${diag.diagnosis}`));
|
|
297
|
+
for (const s of diag.suggestions)
|
|
298
|
+
console.warn(dim(` - ${s}`));
|
|
289
299
|
break;
|
|
300
|
+
}
|
|
290
301
|
}
|
|
291
302
|
if (!syncResult.pushed && syncResult.status !== 'skipped' && syncResult.status !== 'sync-failed') {
|
|
292
303
|
console.warn(yellow('Push failed. Continuing with current state...'));
|
|
@@ -311,13 +322,33 @@ export async function execDev(options) {
|
|
|
311
322
|
cliVersion: VERSION,
|
|
312
323
|
mode,
|
|
313
324
|
noSync,
|
|
325
|
+
// Persist the startup sync outcome so the detached parent can relay
|
|
326
|
+
// stale-code warnings -- not just the child's own log (friction log #2).
|
|
327
|
+
startupSync,
|
|
314
328
|
}));
|
|
315
|
-
// Emit session_started (JSON) or show banner (human)
|
|
329
|
+
// Emit session_started (JSON) or show banner (human). We always emit
|
|
330
|
+
// session_started -- it's the rendezvous signal `runwork dev --detach`
|
|
331
|
+
// polls for -- but carry the startup sync outcome so agents don't mistake a
|
|
332
|
+
// stale preview for one reflecting their latest edits. `staleCode: true`
|
|
333
|
+
// means the initial sync failed and the preview is serving the last good
|
|
334
|
+
// state, not the current working tree.
|
|
316
335
|
if (useJson) {
|
|
317
|
-
jsonLine({
|
|
336
|
+
jsonLine({
|
|
337
|
+
event: 'session_started',
|
|
338
|
+
previewUrl: currentPreviewUrl,
|
|
339
|
+
appName: config.appName,
|
|
340
|
+
syncStatus: startupSync.syncStatus,
|
|
341
|
+
syncFailed: startupSync.syncFailed,
|
|
342
|
+
staleCode: startupSync.staleCode,
|
|
343
|
+
timestamp: ts(),
|
|
344
|
+
guide: buildDevSessionGuide(),
|
|
345
|
+
});
|
|
318
346
|
}
|
|
319
347
|
else {
|
|
320
348
|
console.log(getDevBanner({ appName: config.appName, previewUrl: currentPreviewUrl, workspaceName: config.workspaceName }));
|
|
349
|
+
if (startupSync.syncFailed) {
|
|
350
|
+
console.log(yellow(' Warning: initial sync failed. The preview shows the last deployed code, NOT your latest changes.'));
|
|
351
|
+
}
|
|
321
352
|
console.log(getKeyboardHints());
|
|
322
353
|
console.log('');
|
|
323
354
|
console.log(dim(' ─────────────────────────────────────────────'));
|
|
@@ -568,6 +599,9 @@ async function runDevDetachParent(opts) {
|
|
|
568
599
|
switch (outcome.result) {
|
|
569
600
|
case 'started': {
|
|
570
601
|
const f = outcome.file;
|
|
602
|
+
// Relay the child's startup sync outcome so a detached caller learns the
|
|
603
|
+
// preview may be serving stale code (the child only logs it otherwise).
|
|
604
|
+
const syncFailed = f.startupSync?.syncFailed ?? false;
|
|
571
605
|
if (opts.json) {
|
|
572
606
|
jsonLine({
|
|
573
607
|
event: 'session_started',
|
|
@@ -575,6 +609,9 @@ async function runDevDetachParent(opts) {
|
|
|
575
609
|
pid: f.pid,
|
|
576
610
|
sessionId: f.sessionId,
|
|
577
611
|
mode: f.mode,
|
|
612
|
+
syncStatus: f.startupSync?.syncStatus,
|
|
613
|
+
syncFailed,
|
|
614
|
+
staleCode: f.startupSync?.staleCode ?? false,
|
|
578
615
|
timestamp: ts(),
|
|
579
616
|
guide: buildDevSessionGuide(),
|
|
580
617
|
});
|
|
@@ -583,6 +620,9 @@ async function runDevDetachParent(opts) {
|
|
|
583
620
|
console.log('');
|
|
584
621
|
console.log(`Dev session running in background (PID ${f.pid}).`);
|
|
585
622
|
console.log(` Preview: ${green(f.previewUrl)}`);
|
|
623
|
+
if (syncFailed) {
|
|
624
|
+
console.log(yellow(' Warning: initial sync failed. The preview shows the last deployed code, NOT your latest changes.'));
|
|
625
|
+
}
|
|
586
626
|
console.log(dim(` Stop with: runwork dev stop`));
|
|
587
627
|
console.log(dim(` Logs: tail -f .runwork/dev-stdout.log`));
|
|
588
628
|
console.log('');
|
package/dist/commands/doctor.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import { shouldOutputJson, jsonOut } from '../utils/output.js';
|
|
3
|
-
import { bold, dim, green, yellow, cyan } from '../ui/colors.js';
|
|
4
|
-
import { runAllChecks } from '../health/runner.js';
|
|
3
|
+
import { bold, dim, green, yellow, cyan, red } from '../ui/colors.js';
|
|
4
|
+
import { runAllChecks, ALL_CHECK_NAMES } from '../health/runner.js';
|
|
5
|
+
import { buildContext } from '../health/checks.js';
|
|
6
|
+
import { applyDoctorFixes } from '../health/fix.js';
|
|
5
7
|
import { detectCurrentAgent } from '../agents/runtime-detection.js';
|
|
6
8
|
/**
|
|
7
9
|
* Env vars worth surfacing in --verbose for AI agents debugging their own
|
|
@@ -98,6 +100,7 @@ const CHECK_LABELS = {
|
|
|
98
100
|
'project-config': 'Project config',
|
|
99
101
|
'app-exists': 'App exists',
|
|
100
102
|
'git-remote': 'Git remote',
|
|
103
|
+
'deploy-freshness': 'Deploy freshness',
|
|
101
104
|
'agent-setup': 'Agent setup',
|
|
102
105
|
};
|
|
103
106
|
function printHumanReport(report) {
|
|
@@ -147,12 +150,70 @@ function printHumanReport(report) {
|
|
|
147
150
|
}
|
|
148
151
|
console.log('');
|
|
149
152
|
}
|
|
153
|
+
function printFixOutcomes(outcomes) {
|
|
154
|
+
console.log('');
|
|
155
|
+
console.log(bold('Applying fixes'));
|
|
156
|
+
if (outcomes.length === 0) {
|
|
157
|
+
console.log(` ${dim('Nothing to fix (no auto-remediable failures).')}`);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
for (const o of outcomes) {
|
|
161
|
+
const marker = o.applied ? green('[fixed]') : yellow('[skip]');
|
|
162
|
+
console.log(` ${marker} ${o.name}: ${dim(o.message)}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** Parse and validate `--check a,b,c` against the known check names. */
|
|
166
|
+
function parseCheckNames(raw) {
|
|
167
|
+
const requested = raw.split(',').map(s => s.trim()).filter(Boolean);
|
|
168
|
+
const known = new Set(ALL_CHECK_NAMES);
|
|
169
|
+
const only = requested.filter(n => known.has(n));
|
|
170
|
+
const unknown = requested.filter(n => !known.has(n));
|
|
171
|
+
return { only, unknown };
|
|
172
|
+
}
|
|
150
173
|
export const doctorCommand = new Command('doctor')
|
|
151
174
|
.description('Check system health: auth, network, project config, agent setup')
|
|
152
175
|
.option('-v, --verbose', 'Include host-agent detection results, runtime info, and allowlisted env vars (useful for AI agents debugging their own environment)')
|
|
176
|
+
.option('--check <names>', `Run only the named checks (comma-separated). Available: ${ALL_CHECK_NAMES.join(', ')}`)
|
|
177
|
+
.option('--fix', 'Auto-remediate fixable failures (git credential helper, runwork remote), then re-check')
|
|
153
178
|
.action(async (opts, command) => {
|
|
154
179
|
const asJson = shouldOutputJson(command.optsWithGlobals().json);
|
|
155
|
-
|
|
180
|
+
// Resolve `--check` scope (if any) up front so both fix and report honor it.
|
|
181
|
+
let only;
|
|
182
|
+
if (opts.check) {
|
|
183
|
+
const parsed = parseCheckNames(opts.check);
|
|
184
|
+
if (parsed.unknown.length > 0) {
|
|
185
|
+
const msg = `Unknown check(s): ${parsed.unknown.join(', ')}. Available: ${ALL_CHECK_NAMES.join(', ')}`;
|
|
186
|
+
if (asJson) {
|
|
187
|
+
jsonOut({ success: false, command: 'doctor', error: msg });
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
console.error(red(msg));
|
|
191
|
+
}
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
only = parsed.only;
|
|
195
|
+
}
|
|
196
|
+
// --- Fix flow: report, remediate fixable failures, re-check ---
|
|
197
|
+
if (opts.fix) {
|
|
198
|
+
const before = await runAllChecks({ only });
|
|
199
|
+
const failingNames = before.checks.filter(c => c.status === 'fail').map(c => c.name);
|
|
200
|
+
const ctx = buildContext();
|
|
201
|
+
const outcomes = await applyDoctorFixes(ctx, failingNames);
|
|
202
|
+
const after = await runAllChecks({ only });
|
|
203
|
+
if (asJson) {
|
|
204
|
+
jsonOut({ success: after.status !== 'fail', command: 'doctor', fixes: outcomes, report: after });
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
printFixOutcomes(outcomes);
|
|
208
|
+
printHumanReport(after);
|
|
209
|
+
}
|
|
210
|
+
if (after.checks.some(c => c.status === 'fail')) {
|
|
211
|
+
process.exit(1);
|
|
212
|
+
}
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
// --- Normal (optionally scoped) report ---
|
|
216
|
+
const report = await runAllChecks({ only });
|
|
156
217
|
const verboseInfo = opts.verbose ? collectVerboseInfo() : null;
|
|
157
218
|
if (asJson) {
|
|
158
219
|
jsonOut(verboseInfo ? { ...report, verbose: verboseInfo } : report);
|
package/dist/commands/info.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import { type SessionMode, type SessionStaleReason } from '../dev/session.js';
|
|
3
|
+
import { type DeploySummary } from '../deploy/deploy-state.js';
|
|
3
4
|
import type { WorkspaceAllData } from '../types.js';
|
|
4
5
|
interface BlueprintEntity {
|
|
5
6
|
entityName: string;
|
|
@@ -124,6 +125,8 @@ interface InfoOutput {
|
|
|
124
125
|
url: string | null;
|
|
125
126
|
deployed: boolean;
|
|
126
127
|
};
|
|
128
|
+
/** Last deploy from this machine vs the current local HEAD (friction log #8). */
|
|
129
|
+
deploy: DeploySummary;
|
|
127
130
|
integrations: Array<{
|
|
128
131
|
id: string;
|
|
129
132
|
status: string;
|
package/dist/commands/info.js
CHANGED
|
@@ -10,6 +10,7 @@ import { requireAuth } from '../auth/store.js';
|
|
|
10
10
|
import { ApiClient } from '../api/client.js';
|
|
11
11
|
import { resolveWorkspace } from '../workspace/resolve.js';
|
|
12
12
|
import { getSessionState } from '../dev/session.js';
|
|
13
|
+
import { getDeploySummary } from '../deploy/deploy-state.js';
|
|
13
14
|
function readLocalDevSession(appDir, appId) {
|
|
14
15
|
if (!appId)
|
|
15
16
|
return { state: 'none' };
|
|
@@ -245,6 +246,15 @@ function printHumanOutput(data) {
|
|
|
245
246
|
else {
|
|
246
247
|
console.log(` ${dim(pad('Production:'))}${dim('(not deployed)')}`);
|
|
247
248
|
}
|
|
249
|
+
if (data.deploy.deployedShortSha) {
|
|
250
|
+
const localPart = data.deploy.localShortSha ? `, local ${data.deploy.localShortSha}` : '';
|
|
251
|
+
const sync = data.deploy.inSync === true
|
|
252
|
+
? green('in sync')
|
|
253
|
+
: data.deploy.inSync === false
|
|
254
|
+
? yellow('local has undeployed commits')
|
|
255
|
+
: dim('unknown');
|
|
256
|
+
console.log(` ${dim(pad('Last deploy:'))}${data.deploy.deployedShortSha}${dim(localPart)} ${sync}`);
|
|
257
|
+
}
|
|
248
258
|
// Local dev session view: what's running on THIS machine, from the
|
|
249
259
|
// session file. This may disagree with the server's "preview"
|
|
250
260
|
// status above; that's diagnostic, not a bug.
|
|
@@ -428,6 +438,7 @@ export const infoCommand = new Command('info')
|
|
|
428
438
|
preview,
|
|
429
439
|
localDevSession,
|
|
430
440
|
production: appInfo?.production ?? { url: null, deployed: false },
|
|
441
|
+
deploy: getDeploySummary(process.cwd()),
|
|
431
442
|
integrations,
|
|
432
443
|
registries,
|
|
433
444
|
cli: {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { McpServerConfig } from '../types.js';
|
|
2
|
+
import type { McpServerEntry } from '../agents/types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Build local-agent MCP entries for external (third-party) servers added to the
|
|
5
|
+
* workspace. Auth handling by `authType`:
|
|
6
|
+
* - 'header': inject the user's resolved per-server headers (decrypted server-side).
|
|
7
|
+
* - 'oauth': emit no auth header so the local agent runs its own browser flow.
|
|
8
|
+
* - 'none'/unset: no auth header.
|
|
9
|
+
*
|
|
10
|
+
* Critically, external entries never receive the Runwork platform API key. That key
|
|
11
|
+
* belongs only on the Runwork workspace-tools entry, which is added separately.
|
|
12
|
+
*/
|
|
13
|
+
export declare function buildExternalMcpEntries(mcpServers: McpServerConfig[], resolvedCredentials: Record<string, Record<string, string>>): McpServerEntry[];
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { RUNWORK_MCP_PREFIX } from '../agents/types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Build local-agent MCP entries for external (third-party) servers added to the
|
|
4
|
+
* workspace. Auth handling by `authType`:
|
|
5
|
+
* - 'header': inject the user's resolved per-server headers (decrypted server-side).
|
|
6
|
+
* - 'oauth': emit no auth header so the local agent runs its own browser flow.
|
|
7
|
+
* - 'none'/unset: no auth header.
|
|
8
|
+
*
|
|
9
|
+
* Critically, external entries never receive the Runwork platform API key. That key
|
|
10
|
+
* belongs only on the Runwork workspace-tools entry, which is added separately.
|
|
11
|
+
*/
|
|
12
|
+
export function buildExternalMcpEntries(mcpServers, resolvedCredentials) {
|
|
13
|
+
return mcpServers
|
|
14
|
+
.filter(s => s.enabled)
|
|
15
|
+
.map(s => {
|
|
16
|
+
const resolved = s.authType === 'header' ? resolvedCredentials[s.id] : undefined;
|
|
17
|
+
const headers = resolved && Object.keys(resolved).length > 0 ? resolved : undefined;
|
|
18
|
+
return {
|
|
19
|
+
name: `${RUNWORK_MCP_PREFIX}${s.name}`,
|
|
20
|
+
url: s.url,
|
|
21
|
+
transport: s.transport,
|
|
22
|
+
...(headers ? { headers } : {}),
|
|
23
|
+
description: `Runwork MCP server: ${s.name}. Provides tools for interacting with this workspace resource.`,
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
}
|
package/dist/commands/sync.js
CHANGED
|
@@ -7,6 +7,7 @@ import { ApiClient } from '../api/client.js';
|
|
|
7
7
|
import { getAdapterBySlug, detectAgents } from '../agents/detect.js';
|
|
8
8
|
import { CodexAdapter } from '../agents/codex.js';
|
|
9
9
|
import { RUNWORK_MCP_PREFIX, RUNWORK_WORKSPACE_MCP_NAME } from '../agents/types.js';
|
|
10
|
+
import { buildExternalMcpEntries } from './mcp-entries.js';
|
|
10
11
|
import { RUNWORK_AGENT_DEFAULTS, AGENT_DEFAULTS_SCHEMA_VERSION } from '../agents/default-config.js';
|
|
11
12
|
import { resolveAgentDefaults } from '../agents/defaults-merge.js';
|
|
12
13
|
import { collectTelemetryEvents, printTelemetryVerbose, summarizeTelemetryForDryRun, } from './sync-telemetry.js';
|
|
@@ -15,7 +16,7 @@ import { buildShareConversationSkill, buildSaveConversationSkill, BUILT_IN_SKILL
|
|
|
15
16
|
import { computeSyncPlan } from '../sync/change-detect.js';
|
|
16
17
|
import { printSyncSummary, resolveConflict, resolveConflictNonInteractive } from '../sync/conflict-ui.js';
|
|
17
18
|
import { executeSyncPlan } from '../sync/executor.js';
|
|
18
|
-
import { getRegistryAgent, resolveToAbsolute } from '../agents/registry.js';
|
|
19
|
+
import { getRegistryAgent, resolveToAbsolute, isConnectOnlyAgent } from '../agents/registry.js';
|
|
19
20
|
function loadSetupState(filePath) {
|
|
20
21
|
if (!existsSync(filePath))
|
|
21
22
|
return null;
|
|
@@ -135,7 +136,7 @@ export async function syncFromState(state, statePath, credentials, opts) {
|
|
|
135
136
|
}
|
|
136
137
|
console.log(' Fetching workspace data...');
|
|
137
138
|
// Fetch latest data (includeContent=true gets all skill content in one request)
|
|
138
|
-
const [allSkills, mcpServers, externalSkills, registries, connectedIntegrations] = await Promise.all([
|
|
139
|
+
const [allSkills, mcpServers, externalSkills, registries, connectedIntegrations, resolvedMcpCredentials] = await Promise.all([
|
|
139
140
|
client.listWorkspaceSkills(state.workspaceId, true),
|
|
140
141
|
client.listMcpServers(state.workspaceId),
|
|
141
142
|
client.listExternalSkills(state.workspaceId),
|
|
@@ -143,6 +144,9 @@ export async function syncFromState(state, statePath, credentials, opts) {
|
|
|
143
144
|
client.listConnectedIntegrations(state.workspaceId)
|
|
144
145
|
.then(list => list.map(i => i.canonicalId ?? i.integrationId))
|
|
145
146
|
.catch(() => []),
|
|
147
|
+
// Decrypted auth headers for header-auth servers, scoped to this user.
|
|
148
|
+
// Tolerate older backends that lack the endpoint.
|
|
149
|
+
client.resolveMcpCredentials(state.workspaceId).catch(() => ({})),
|
|
146
150
|
]);
|
|
147
151
|
const appCount = allSkills.filter(s => s.type === 'app').length;
|
|
148
152
|
const parts = [];
|
|
@@ -167,15 +171,7 @@ export async function syncFromState(state, statePath, credentials, opts) {
|
|
|
167
171
|
}
|
|
168
172
|
// Build MCP entries (pull-only)
|
|
169
173
|
const baseUrl = credentials.baseUrl || 'https://runwork.ai';
|
|
170
|
-
const mcpEntries = mcpServers
|
|
171
|
-
.filter(s => s.enabled)
|
|
172
|
-
.map(s => ({
|
|
173
|
-
name: `${RUNWORK_MCP_PREFIX}${s.name}`,
|
|
174
|
-
url: s.url,
|
|
175
|
-
transport: s.transport,
|
|
176
|
-
headers: { Authorization: `Bearer ${credentials.apiKey}` },
|
|
177
|
-
description: `Runwork MCP server: ${s.name}. Provides tools for interacting with this workspace resource.`,
|
|
178
|
-
}));
|
|
174
|
+
const mcpEntries = buildExternalMcpEntries(mcpServers, resolvedMcpCredentials);
|
|
179
175
|
mcpEntries.push({
|
|
180
176
|
name: RUNWORK_WORKSPACE_MCP_NAME,
|
|
181
177
|
url: `${baseUrl}/api/workspaces/${state.workspaceId}/mcp`,
|
|
@@ -344,6 +340,13 @@ export async function syncFromState(state, statePath, credentials, opts) {
|
|
|
344
340
|
hookInstallCalls: 0,
|
|
345
341
|
};
|
|
346
342
|
for (const adapter of adapters) {
|
|
343
|
+
// Connect-only agents (e.g. ChatGPT) have no local files to write; the
|
|
344
|
+
// connector lives in the user's account. Skip all local-write phases.
|
|
345
|
+
if (isConnectOnlyAgent(getRegistryAgent(adapter.slug))) {
|
|
346
|
+
console.log(` [${adapter.name}] Connect-only agent: no local files to sync`);
|
|
347
|
+
summary.adaptersProcessed++;
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
347
350
|
let adapterFailedAnyScope = false;
|
|
348
351
|
for (const scope of scopes) {
|
|
349
352
|
try {
|
|
@@ -437,6 +440,8 @@ export async function syncFromState(state, statePath, credentials, opts) {
|
|
|
437
440
|
}
|
|
438
441
|
// Write team instructions and/or per-agent extra instructions.
|
|
439
442
|
for (const adapter of adapters) {
|
|
443
|
+
if (isConnectOnlyAgent(getRegistryAgent(adapter.slug)))
|
|
444
|
+
continue;
|
|
440
445
|
if (!adapter.writeTeamInstructions)
|
|
441
446
|
continue;
|
|
442
447
|
const agentExtra = agentConfigs?.[adapter.slug]?.extraInstructions;
|
|
@@ -459,6 +464,8 @@ export async function syncFromState(state, statePath, credentials, opts) {
|
|
|
459
464
|
// User scope is handled below in the unified defaults+network+minimum block.
|
|
460
465
|
if (agentConfigs) {
|
|
461
466
|
for (const adapter of adapters) {
|
|
467
|
+
if (isConnectOnlyAgent(getRegistryAgent(adapter.slug)))
|
|
468
|
+
continue;
|
|
462
469
|
const agentConfig = agentConfigs[adapter.slug];
|
|
463
470
|
if (!agentConfig || !adapter.writeAgentConfig)
|
|
464
471
|
continue;
|
|
@@ -511,6 +518,8 @@ export async function syncFromState(state, statePath, credentials, opts) {
|
|
|
511
518
|
if (!state.agentDefaults)
|
|
512
519
|
state.agentDefaults = {};
|
|
513
520
|
for (const adapter of adapters) {
|
|
521
|
+
if (isConnectOnlyAgent(getRegistryAgent(adapter.slug)))
|
|
522
|
+
continue;
|
|
514
523
|
if (!adapter.writeAgentConfig)
|
|
515
524
|
continue;
|
|
516
525
|
const slug = adapter.slug;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { readFileSync, existsSync } from 'fs';
|
|
3
|
+
import { requireAuth } from '../auth/store.js';
|
|
4
|
+
import { ApiClient } from '../api/client.js';
|
|
5
|
+
import { shouldOutputJson, jsonOut } from '../utils/output.js';
|
|
6
|
+
import { bold, dim, green, yellow, red } from '../ui/colors.js';
|
|
7
|
+
import { requireGit } from '../git/preflight.js';
|
|
8
|
+
import { getGitFreshness } from '../validate/freshness.js';
|
|
9
|
+
import { checkPreview } from '../validate/preview.js';
|
|
10
|
+
const BUILD_FOLLOWUP = 'In-sandbox typecheck/lint requires a worker exec endpoint (not yet available). The preview error-overlay check is used as a build proxy.';
|
|
11
|
+
export const validateCommand = new Command('validate')
|
|
12
|
+
.description('Validate the app before deploy: git sync state + preview liveness (no local deps needed)')
|
|
13
|
+
.action(async (_opts, command) => {
|
|
14
|
+
const useJson = shouldOutputJson(command.optsWithGlobals().json);
|
|
15
|
+
const cwd = process.cwd();
|
|
16
|
+
if (!existsSync('.runwork.json')) {
|
|
17
|
+
if (useJson) {
|
|
18
|
+
jsonOut({ success: false, command: 'validate', error: 'No .runwork.json found. Run inside a Runwork app.' });
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
console.error('No .runwork.json found. Run `runwork validate` inside a Runwork app.');
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
requireGit('validate');
|
|
25
|
+
const config = JSON.parse(readFileSync('.runwork.json', 'utf-8'));
|
|
26
|
+
const creds = requireAuth();
|
|
27
|
+
const client = new ApiClient(creds);
|
|
28
|
+
const sync = getGitFreshness(cwd);
|
|
29
|
+
// Best-effort: ask the server for the current preview URL. No running
|
|
30
|
+
// preview is not a failure -- the preview check is simply skipped.
|
|
31
|
+
let previewUrl = null;
|
|
32
|
+
try {
|
|
33
|
+
const status = await client.getDevStatus(config.appId);
|
|
34
|
+
previewUrl = status.previewUrl || null;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
/* no active session -- preview check skipped */
|
|
38
|
+
}
|
|
39
|
+
const preview = await checkPreview(previewUrl);
|
|
40
|
+
// Overall: fail only on a concrete problem -- the preview is down or
|
|
41
|
+
// serving a broken build. Local commits not yet deployed are informational.
|
|
42
|
+
const ok = preview.url === null ? true : preview.responds && !preview.errorDetected;
|
|
43
|
+
const result = { sync, preview, build: { checked: false, reason: BUILD_FOLLOWUP }, ok };
|
|
44
|
+
if (useJson) {
|
|
45
|
+
jsonOut({ success: ok, command: 'validate', result });
|
|
46
|
+
if (!ok)
|
|
47
|
+
process.exit(1);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
console.log('');
|
|
51
|
+
console.log(bold('runwork validate'));
|
|
52
|
+
console.log('');
|
|
53
|
+
// Sync
|
|
54
|
+
if (sync.inSync) {
|
|
55
|
+
console.log(` ${green('[ok]')} Sync ${dim('local matches the deploy remote, working tree clean')}`);
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
const bits = [];
|
|
59
|
+
if (sync.uncommitted)
|
|
60
|
+
bits.push('uncommitted changes');
|
|
61
|
+
if (sync.ahead > 0)
|
|
62
|
+
bits.push(`${sync.ahead} commit(s) not yet pushed/deployed`);
|
|
63
|
+
if (sync.behind > 0)
|
|
64
|
+
bits.push(`${sync.behind} commit(s) behind the remote`);
|
|
65
|
+
if (!sync.remoteSha)
|
|
66
|
+
bits.push('no runwork/main ref (push or run dev first)');
|
|
67
|
+
console.log(` ${yellow('[info]')} Sync ${dim(bits.join('; ') || 'not in sync')}`);
|
|
68
|
+
}
|
|
69
|
+
// Preview
|
|
70
|
+
if (!preview.url) {
|
|
71
|
+
console.log(` ${dim('[skip]')} Preview ${dim('no preview running (start `runwork dev`)')}`);
|
|
72
|
+
}
|
|
73
|
+
else if (!preview.responds) {
|
|
74
|
+
console.log(` ${red('[FAIL]')} Preview ${dim(`not responding${preview.error ? `: ${preview.error}` : ` (status ${preview.status})`}`)}`);
|
|
75
|
+
}
|
|
76
|
+
else if (preview.errorDetected) {
|
|
77
|
+
console.log(` ${red('[FAIL]')} Preview ${dim('serving a build error overlay -- the app does not compile')}`);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
console.log(` ${green('[ok]')} Preview ${dim(`live and serving a working build (status ${preview.status})`)}`);
|
|
81
|
+
}
|
|
82
|
+
// Build (proxy)
|
|
83
|
+
console.log(` ${dim('[note]')} Build ${dim(BUILD_FOLLOWUP)}`);
|
|
84
|
+
console.log('');
|
|
85
|
+
console.log(ok ? green(' validate: ok') : red(' validate: FAILED'));
|
|
86
|
+
console.log('');
|
|
87
|
+
if (!ok)
|
|
88
|
+
process.exit(1);
|
|
89
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
vi.mock('child_process');
|
|
3
|
+
vi.mock('fs', () => ({
|
|
4
|
+
existsSync: vi.fn(),
|
|
5
|
+
mkdirSync: vi.fn(),
|
|
6
|
+
readFileSync: vi.fn(),
|
|
7
|
+
writeFileSync: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
const mockExecFileSync = (await import('child_process')).execFileSync;
|
|
10
|
+
const fs = await import('fs');
|
|
11
|
+
const { summarizeDeploy, getHeadSha, writeDeployState, readDeployState, } = await import('../deploy-state.js');
|
|
12
|
+
const SHA = 'a1b2c3d4e5f600000000000000000000deadbeef';
|
|
13
|
+
describe('deploy/deploy-state', () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
vi.clearAllMocks();
|
|
16
|
+
});
|
|
17
|
+
describe('summarizeDeploy()', () => {
|
|
18
|
+
it('reports in-sync when local HEAD matches the deployed SHA', () => {
|
|
19
|
+
const s = summarizeDeploy(SHA, { sha: SHA, deployedAt: '2026-06-05T00:00:00Z', url: 'https://app' });
|
|
20
|
+
expect(s.inSync).toBe(true);
|
|
21
|
+
expect(s.deployedShortSha).toBe('a1b2c3d');
|
|
22
|
+
expect(s.localShortSha).toBe('a1b2c3d');
|
|
23
|
+
expect(s.deployedAt).toBe('2026-06-05T00:00:00Z');
|
|
24
|
+
expect(s.url).toBe('https://app');
|
|
25
|
+
});
|
|
26
|
+
it('reports out-of-sync when local HEAD has moved past the deploy', () => {
|
|
27
|
+
const s = summarizeDeploy('ffffffffffffffffffffffffffffffffffffffff', {
|
|
28
|
+
sha: SHA, deployedAt: '2026-06-05T00:00:00Z', url: 'https://app',
|
|
29
|
+
});
|
|
30
|
+
expect(s.inSync).toBe(false);
|
|
31
|
+
expect(s.deployedShortSha).toBe('a1b2c3d');
|
|
32
|
+
expect(s.localShortSha).toBe('fffffff');
|
|
33
|
+
});
|
|
34
|
+
it('returns inSync=null when there is no recorded deploy', () => {
|
|
35
|
+
const s = summarizeDeploy(SHA, null);
|
|
36
|
+
expect(s.inSync).toBeNull();
|
|
37
|
+
expect(s.deployedSha).toBeNull();
|
|
38
|
+
expect(s.localShortSha).toBe('a1b2c3d');
|
|
39
|
+
});
|
|
40
|
+
it('returns inSync=null when local HEAD is unknown (not a repo)', () => {
|
|
41
|
+
const s = summarizeDeploy(null, { sha: SHA, deployedAt: 't', url: '' });
|
|
42
|
+
expect(s.inSync).toBeNull();
|
|
43
|
+
expect(s.localSha).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
describe('getHeadSha()', () => {
|
|
47
|
+
it('returns the trimmed HEAD sha', () => {
|
|
48
|
+
mockExecFileSync.mockReturnValue(Buffer.from(SHA + '\n'));
|
|
49
|
+
expect(getHeadSha('/repo')).toBe(SHA);
|
|
50
|
+
});
|
|
51
|
+
it('returns null when git fails (no commits / not a repo)', () => {
|
|
52
|
+
mockExecFileSync.mockImplementation(() => { throw new Error('no HEAD'); });
|
|
53
|
+
expect(getHeadSha('/repo')).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
describe('writeDeployState() / readDeployState()', () => {
|
|
57
|
+
it('writes JSON, creating .runwork when missing', () => {
|
|
58
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
59
|
+
writeDeployState('/repo', { sha: SHA, deployedAt: 't', url: 'https://app' });
|
|
60
|
+
expect(fs.mkdirSync).toHaveBeenCalled();
|
|
61
|
+
expect(fs.writeFileSync).toHaveBeenCalledOnce();
|
|
62
|
+
const written = vi.mocked(fs.writeFileSync).mock.calls[0][1];
|
|
63
|
+
expect(JSON.parse(written)).toEqual({ sha: SHA, deployedAt: 't', url: 'https://app' });
|
|
64
|
+
});
|
|
65
|
+
it('reads back a valid state', () => {
|
|
66
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
67
|
+
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ sha: SHA, deployedAt: 't', url: 'https://app' }));
|
|
68
|
+
expect(readDeployState('/repo')).toEqual({ sha: SHA, deployedAt: 't', url: 'https://app' });
|
|
69
|
+
});
|
|
70
|
+
it('returns null when the file is missing', () => {
|
|
71
|
+
vi.mocked(fs.existsSync).mockReturnValue(false);
|
|
72
|
+
expect(readDeployState('/repo')).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
it('returns null when the file is malformed', () => {
|
|
75
|
+
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
76
|
+
vi.mocked(fs.readFileSync).mockReturnValue('{ not json');
|
|
77
|
+
expect(readDeployState('/repo')).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|