runwork 0.14.0 → 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/dist/__tests__/install-scripts.test.js +33 -0
  2. package/dist/agents/__tests__/chatgpt-registry.test.d.ts +1 -0
  3. package/dist/agents/__tests__/chatgpt-registry.test.js +61 -0
  4. package/dist/agents/__tests__/claude-code-managed-block.test.js +52 -44
  5. package/dist/agents/__tests__/detection.test.js +3 -0
  6. package/dist/agents/claude-code.d.ts +11 -5
  7. package/dist/agents/claude-code.js +50 -51
  8. package/dist/agents/cursor.d.ts +2 -15
  9. package/dist/agents/cursor.js +3 -33
  10. package/dist/agents/defaults-merge.d.ts +15 -0
  11. package/dist/agents/defaults-merge.js +19 -0
  12. package/dist/agents/detection.js +3 -0
  13. package/dist/agents/intro-skill.js +5 -3
  14. package/dist/agents/registry-data.d.ts +59 -2
  15. package/dist/agents/registry-data.js +176 -0
  16. package/dist/commands/__tests__/clone-args.test.js +8 -1
  17. package/dist/commands/__tests__/clone-source-result.test.d.ts +1 -0
  18. package/dist/commands/__tests__/clone-source-result.test.js +45 -0
  19. package/dist/commands/clone.d.ts +33 -0
  20. package/dist/commands/clone.js +105 -10
  21. package/dist/commands/deploy.js +128 -11
  22. package/dist/commands/dev.js +72 -32
  23. package/dist/commands/doctor.js +64 -3
  24. package/dist/commands/info.d.ts +3 -0
  25. package/dist/commands/info.js +11 -0
  26. package/dist/commands/sync.js +14 -1
  27. package/dist/commands/validate.d.ts +2 -0
  28. package/dist/commands/validate.js +89 -0
  29. package/dist/deploy/__tests__/deploy-state.test.d.ts +1 -0
  30. package/dist/deploy/__tests__/deploy-state.test.js +80 -0
  31. package/dist/deploy/__tests__/deploy-status.test.d.ts +1 -0
  32. package/dist/deploy/__tests__/deploy-status.test.js +41 -0
  33. package/dist/deploy/__tests__/detach-args.test.d.ts +1 -0
  34. package/dist/deploy/__tests__/detach-args.test.js +30 -0
  35. package/dist/deploy/deploy-state.d.ts +36 -0
  36. package/dist/deploy/deploy-state.js +63 -0
  37. package/dist/deploy/deploy-status.d.ts +21 -0
  38. package/dist/deploy/deploy-status.js +36 -0
  39. package/dist/deploy/detach.d.ts +32 -0
  40. package/dist/deploy/detach.js +71 -0
  41. package/dist/dev/__tests__/session.test.js +39 -0
  42. package/dist/dev/__tests__/startup-sync.test.d.ts +1 -0
  43. package/dist/dev/__tests__/startup-sync.test.js +32 -0
  44. package/dist/dev/session.d.ts +9 -0
  45. package/dist/dev/session.js +4 -1
  46. package/dist/dev/startup-sync.d.ts +23 -0
  47. package/dist/dev/startup-sync.js +14 -0
  48. package/dist/generated/version.d.ts +1 -1
  49. package/dist/generated/version.js +1 -1
  50. package/dist/git/__tests__/classify-sync-error.test.d.ts +1 -0
  51. package/dist/git/__tests__/classify-sync-error.test.js +57 -0
  52. package/dist/git/__tests__/ensure-git-credential-helper.test.d.ts +1 -0
  53. package/dist/git/__tests__/ensure-git-credential-helper.test.js +133 -0
  54. package/dist/git/__tests__/ensure-runwork-remote.test.d.ts +1 -0
  55. package/dist/git/__tests__/ensure-runwork-remote.test.js +109 -0
  56. package/dist/git/__tests__/repo-config.test.d.ts +1 -0
  57. package/dist/git/__tests__/repo-config.test.js +26 -0
  58. package/dist/git/classify-sync-error.d.ts +21 -0
  59. package/dist/git/classify-sync-error.js +111 -0
  60. package/dist/git/credentials.d.ts +43 -0
  61. package/dist/git/credentials.js +67 -0
  62. package/dist/git/remote.d.ts +37 -0
  63. package/dist/git/remote.js +75 -0
  64. package/dist/git/repo-config.d.ts +8 -0
  65. package/dist/git/repo-config.js +29 -0
  66. package/dist/health/__tests__/checks.test.js +2 -2
  67. package/dist/health/__tests__/fix.test.d.ts +1 -0
  68. package/dist/health/__tests__/fix.test.js +72 -0
  69. package/dist/health/__tests__/runner-filter.test.d.ts +1 -0
  70. package/dist/health/__tests__/runner-filter.test.js +61 -0
  71. package/dist/health/checks.d.ts +7 -0
  72. package/dist/health/checks.js +50 -36
  73. package/dist/health/fix.d.ts +22 -0
  74. package/dist/health/fix.js +62 -0
  75. package/dist/health/runner.d.ts +7 -1
  76. package/dist/health/runner.js +40 -27
  77. package/dist/index.js +2 -0
  78. package/dist/tools/types.d.ts +5 -0
  79. package/dist/utils/__tests__/help-json.test.d.ts +1 -0
  80. package/dist/utils/__tests__/help-json.test.js +39 -0
  81. package/dist/utils/__tests__/ignore-matcher.test.js +18 -1
  82. package/dist/utils/help-json.d.ts +14 -0
  83. package/dist/utils/help-json.js +4 -1
  84. package/dist/validate/__tests__/validate.test.d.ts +1 -0
  85. package/dist/validate/__tests__/validate.test.js +42 -0
  86. package/dist/validate/freshness.d.ts +28 -0
  87. package/dist/validate/freshness.js +48 -0
  88. package/dist/validate/preview.d.ts +21 -0
  89. package/dist/validate/preview.js +39 -0
  90. package/package.json +1 -1
@@ -8,7 +8,7 @@
8
8
  * Keep this file browser-safe: no `os`, `path`, `fs`, or other Node-only
9
9
  * imports. Node-only path resolvers live next to this file in `./registry.ts`.
10
10
  */
11
- export type AgentCategory = 'cli' | 'ide' | 'desktop' | 'extension';
11
+ export type AgentCategory = 'cli' | 'ide' | 'desktop' | 'extension' | 'web';
12
12
  export type { PlatformString, Detection, InstallableTool } from '../tools/types.js';
13
13
  import type { InstallableTool, Detection, PlatformString } from '../tools/types.js';
14
14
  /** @deprecated use `Detection` from ../tools/types — kept as alias for back-compat. */
@@ -21,6 +21,13 @@ export interface AgentLaunch {
21
21
  };
22
22
  cli?: string;
23
23
  cliAcceptsPrompt?: boolean;
24
+ /**
25
+ * Web/URL launch target opened in the default browser. `{prompt}` is
26
+ * substituted with a URL-encoded prompt (e.g. ChatGPT's `?prompt=`), or
27
+ * stripped when no prompt is supplied. Used by web-only agents and as the
28
+ * fallback for desktop agents whose app is not installed.
29
+ */
30
+ url?: string;
24
31
  }
25
32
  export interface AgentQuickStart {
26
33
  launchHint?: string;
@@ -44,6 +51,28 @@ export interface AgentManualSetupArtifact {
44
51
  }
45
52
  /** Where a manual-setup step is inserted in the onboarding journey. */
46
53
  export type ManualSetupSlot = 'installation' | 'connecting' | 'try-it';
54
+ /**
55
+ * A plan-conditional variant of a manual-setup checklist. When an agent's
56
+ * connector setup differs by account plan (e.g. ChatGPT Team vs Personal),
57
+ * each variant carries its own steps; the onboarding UI selects the variant
58
+ * matching the workspace's stored plan. Agents without plan branching omit
59
+ * `variants` and use the flat `steps`.
60
+ */
61
+ export interface AgentManualSetupVariant {
62
+ key: 'team' | 'personal';
63
+ label: string;
64
+ title?: string;
65
+ steps: ManualSetupStep[];
66
+ }
67
+ /**
68
+ * A copy-to-clipboard value surfaced alongside the manual-setup steps. The
69
+ * onboarding UI substitutes `token` with a real value (e.g. `{mcpUrl}` →
70
+ * the workspace MCP URL) before rendering the copy control.
71
+ */
72
+ export interface AgentManualSetupCopyValue {
73
+ label: string;
74
+ token: '{mcpUrl}';
75
+ }
47
76
  export interface AgentManualSetup {
48
77
  title: string;
49
78
  steps: ManualSetupStep[];
@@ -51,6 +80,10 @@ export interface AgentManualSetup {
51
80
  /** Where in the journey to show this step. Defaults to 'try-it'. */
52
81
  showAfter?: ManualSetupSlot;
53
82
  downloadArtifact?: AgentManualSetupArtifact;
83
+ /** Plan-conditional step lists. When present, the UI renders the variant matching the workspace plan instead of `steps`. */
84
+ variants?: AgentManualSetupVariant[];
85
+ /** A value the user copies into the agent during setup (e.g. the workspace MCP URL). */
86
+ copyValue?: AgentManualSetupCopyValue;
54
87
  }
55
88
  /**
56
89
  * How a recipient can resume a shared conversation locally in this agent.
@@ -72,12 +105,18 @@ export interface AgentManualSetup {
72
105
  * paste-prompt path. Used for walled-garden agents like
73
106
  * Microsoft Copilot or Claude Desktop Chat mode.
74
107
  *
108
+ * - 'url-prompt' Open the agent's `launch.url` (or app) with a prefilled
109
+ * prompt that instructs the agent to fetch and continue
110
+ * the shared conversation via Runwork's MCP tools. Used by
111
+ * connect-only agents (ChatGPT) that have MCP access but no
112
+ * local session files to drop into.
113
+ *
75
114
  * Bundle placement logic (which directory, which filename) is per-agent and
76
115
  * lives in the resume command implementation, not here. This data field stays
77
116
  * declarative.
78
117
  */
79
118
  export interface AgentResumeCapability {
80
- mode: 'cli-resume' | 'file-drop-only' | 'unsupported';
119
+ mode: 'cli-resume' | 'file-drop-only' | 'unsupported' | 'url-prompt';
81
120
  /** Bundle format this agent reads natively (must match a bundle's `format`). */
82
121
  nativeBundleFormat?: 'claude-jsonl' | 'codex-rollout' | string;
83
122
  /** Template for the resume command (cli-resume only). `{uuid}` is substituted with the share's session id. */
@@ -88,6 +127,13 @@ export interface AgentResumeCapability {
88
127
  export interface AgentDefinition extends InstallableTool {
89
128
  aliases?: string[];
90
129
  category: AgentCategory;
130
+ /**
131
+ * Family identifier grouping multiple entries that share one account/
132
+ * connector/OAuth identity (e.g. `chatgpt` + `chatgpt-app` → 'chatgpt').
133
+ * Drives the shared plan toggle, connect-step dedup, and family-level
134
+ * adoption. Undefined for standalone agents.
135
+ */
136
+ family?: string;
91
137
  /** Desktop launch hints (GUI app name, CLI command) */
92
138
  launch?: AgentLaunch;
93
139
  /** Whether the CLI knows how to auto-install this agent */
@@ -127,6 +173,17 @@ export declare function getAgentsByCategory(category: AgentCategory): AgentDefin
127
173
  export declare function isCLIAgent(slug: string): boolean;
128
174
  export declare function isIDEAgent(slug: string): boolean;
129
175
  export declare function isDesktopAgent(slug: string): boolean;
176
+ export declare function isWebAgent(slug: string): boolean;
177
+ /**
178
+ * Connect-only agents have no local footprint to write: no skills directory,
179
+ * no MCP config file, no instruction file. The connector lives in the user's
180
+ * account (e.g. ChatGPT). `runwork sync` skips these for all local writes.
181
+ */
182
+ export declare function isConnectOnlyAgent(agent: AgentDefinition | undefined): boolean;
183
+ /** Family identifier for an agent (e.g. 'chatgpt' for both ChatGPT entries), or its slug when standalone. */
184
+ export declare function getAgentFamily(slug: string): string | undefined;
185
+ /** All agents sharing the given family identifier. */
186
+ export declare function getFamilyAgents(family: string): AgentDefinition[];
130
187
  /** Alias of {@link getAgent} kept for backward compatibility with CLI callers. */
131
188
  export declare const getRegistryAgent: typeof getAgent;
132
189
  /** Alias of {@link getAgents} kept for backward compatibility with CLI callers. */
@@ -246,6 +246,158 @@ const AGENT_REGISTRY = [
246
246
  ],
247
247
  },
248
248
  },
249
+ {
250
+ // ChatGPT Web. Connect-only: no local files. Shares one account/connector/
251
+ // OAuth identity with `chatgpt-app` (family: 'chatgpt'). Launches in the
252
+ // browser; chatgpt.com supports `?prompt=` for open + resume.
253
+ slug: 'chatgpt',
254
+ name: 'ChatGPT (Web)',
255
+ aliases: ['ChatGPT'],
256
+ description: "OpenAI's ChatGPT in the browser, connected to your workspace via MCP",
257
+ category: 'web',
258
+ family: 'chatgpt',
259
+ detection: { method: 'always' },
260
+ launch: { url: 'https://chatgpt.com/?prompt={prompt}' },
261
+ logo: 'openai',
262
+ downloadUrl: 'https://chatgpt.com',
263
+ firstClass: true,
264
+ resumeCapability: {
265
+ mode: 'url-prompt',
266
+ manualOpenHint: 'Opens ChatGPT with a prompt that fetches and continues the shared conversation via your Runwork workspace tools.',
267
+ },
268
+ quickStart: {
269
+ launchHint: 'Open chatgpt.com',
270
+ examplePrompts: [
271
+ "What can my team's Runwork workspace do?",
272
+ "Check my team's recent activity",
273
+ ],
274
+ },
275
+ manualSetup: {
276
+ title: 'Connect Runwork in ChatGPT',
277
+ showAfter: 'connecting',
278
+ copyValue: { label: 'Workspace MCP URL', token: '{mcpUrl}' },
279
+ // Flat steps mirror the 'personal' variant as a safe fallback when no
280
+ // plan is resolved. The onboarding UI prefers `variants` by plan.
281
+ steps: [
282
+ { id: 'open', label: 'Open chatgpt.com and sign in' },
283
+ { id: 'settings-apps', label: 'Open the profile menu (bottom-left), then Settings, then Apps' },
284
+ { id: 'dev-mode', label: 'Click "Advanced settings" and turn on "Developer mode" (skip if it is already on)' },
285
+ { id: 'create-app', label: 'Go back to Apps and click "Create app"' },
286
+ { id: 'fill', label: 'In the New App dialog, set Name to "Runwork", keep Connection on "Server URL", and paste the Workspace MCP URL above into the Server URL field' },
287
+ { id: 'oauth', label: 'Leave Authentication set to "OAuth", check "I understand and want to continue", then click Create' },
288
+ { id: 'authorize', label: 'Complete the OAuth sign-in to authorize Runwork for your account' },
289
+ { id: 'confirm', label: 'In a new chat, ask "Do you have access to Runwork tools?" to confirm (or check Settings, then Apps, for Runwork)' },
290
+ ],
291
+ variants: [
292
+ {
293
+ key: 'team',
294
+ label: 'Team / Enterprise',
295
+ title: 'Connect the Runwork app in ChatGPT',
296
+ steps: [
297
+ { id: 'open', label: 'Open chatgpt.com and sign in' },
298
+ { id: 'settings-apps', label: 'Open the profile menu (bottom-left), then Settings, then Apps' },
299
+ { id: 'find', label: 'Find the Runwork app published by your workspace admin and open it' },
300
+ { id: 'connect', label: 'Click Connect and complete the OAuth sign-in to authorize Runwork' },
301
+ { id: 'confirm', label: 'In a new chat, ask "Do you have access to Runwork tools?" to confirm (or check Settings, then Apps, for Runwork)' },
302
+ ],
303
+ },
304
+ {
305
+ key: 'personal',
306
+ label: 'Personal',
307
+ title: 'Create the Runwork connector in ChatGPT',
308
+ steps: [
309
+ { id: 'open', label: 'Open chatgpt.com and sign in' },
310
+ { id: 'settings-apps', label: 'Open the profile menu (bottom-left), then Settings, then Apps' },
311
+ { id: 'dev-mode', label: 'Click "Advanced settings" and turn on "Developer mode" (skip if it is already on)' },
312
+ { id: 'create-app', label: 'Go back to Apps and click "Create app"' },
313
+ { id: 'fill', label: 'In the New App dialog, set Name to "Runwork", keep Connection on "Server URL", and paste the Workspace MCP URL above into the Server URL field' },
314
+ { id: 'oauth', label: 'Leave Authentication set to "OAuth", check "I understand and want to continue", then click Create' },
315
+ { id: 'authorize', label: 'Complete the OAuth sign-in to authorize Runwork for your account' },
316
+ { id: 'confirm', label: 'In a new chat, ask "Do you have access to Runwork tools?" to confirm (or check Settings, then Apps, for Runwork)' },
317
+ ],
318
+ },
319
+ ],
320
+ },
321
+ },
322
+ {
323
+ // ChatGPT Desktop app. Connect-only (no local files); shares the same
324
+ // account/connector/OAuth identity as `chatgpt` Web. Launches the app,
325
+ // falling back to the web URL when the app is not installed.
326
+ slug: 'chatgpt-app',
327
+ name: 'ChatGPT Desktop',
328
+ description: "OpenAI's ChatGPT desktop app, connected to your workspace via MCP",
329
+ category: 'desktop',
330
+ family: 'chatgpt',
331
+ detection: {
332
+ method: 'any',
333
+ target: [
334
+ { method: 'path', target: { macos: '/Applications/ChatGPT.app' } },
335
+ { method: 'windows-appx', target: 'OpenAI.ChatGPT' },
336
+ ],
337
+ },
338
+ launch: { app: { macos: 'ChatGPT', windows: 'ChatGPT' }, url: 'https://chatgpt.com/?prompt={prompt}' },
339
+ logo: 'openai',
340
+ downloadUrl: 'https://chatgpt.com/download',
341
+ firstClass: true,
342
+ resumeCapability: {
343
+ mode: 'url-prompt',
344
+ manualOpenHint: 'Opens ChatGPT with a prompt that fetches and continues the shared conversation via your Runwork workspace tools.',
345
+ },
346
+ quickStart: {
347
+ launchHint: 'Open the ChatGPT desktop app',
348
+ examplePrompts: [
349
+ "What can my team's Runwork workspace do?",
350
+ "Check my team's recent activity",
351
+ ],
352
+ },
353
+ manualSetup: {
354
+ title: 'Connect Runwork in ChatGPT',
355
+ showAfter: 'connecting',
356
+ copyValue: { label: 'Workspace MCP URL', token: '{mcpUrl}' },
357
+ steps: [
358
+ { id: 'install', label: 'Install the ChatGPT desktop app from chatgpt.com/download if you have not already' },
359
+ { id: 'open-web', label: 'Connectors are created on chatgpt.com: open it in your browser and sign in' },
360
+ { id: 'settings-apps', label: 'Open the profile menu (bottom-left), then Settings, then Apps' },
361
+ { id: 'dev-mode', label: 'Click "Advanced settings" and turn on "Developer mode" (skip if it is already on)' },
362
+ { id: 'create-app', label: 'Go back to Apps and click "Create app"' },
363
+ { id: 'fill', label: 'In the New App dialog, set Name to "Runwork", keep Connection on "Server URL", and paste the Workspace MCP URL above into the Server URL field' },
364
+ { id: 'oauth', label: 'Leave Authentication set to "OAuth", check "I understand and want to continue", then click Create' },
365
+ { id: 'authorize', label: 'Complete the OAuth sign-in to authorize Runwork for your account' },
366
+ { id: 'confirm', label: 'Open the ChatGPT desktop app and, in a new chat, ask "Do you have access to Runwork tools?" to confirm' },
367
+ ],
368
+ variants: [
369
+ {
370
+ key: 'team',
371
+ label: 'Team / Enterprise',
372
+ title: 'Connect the Runwork app in ChatGPT',
373
+ steps: [
374
+ { id: 'install', label: 'Install the ChatGPT desktop app from chatgpt.com/download if you have not already' },
375
+ { id: 'open', label: 'Open the ChatGPT desktop app and sign in' },
376
+ { id: 'settings-apps', label: 'Open the profile menu (bottom-left), then Settings, then Apps' },
377
+ { id: 'find', label: 'Find the Runwork app published by your workspace admin and open it' },
378
+ { id: 'connect', label: 'Click Connect and complete the OAuth sign-in to authorize Runwork' },
379
+ { id: 'confirm', label: 'In a new chat, ask "Do you have access to Runwork tools?" to confirm (or check Settings, then Apps, for Runwork)' },
380
+ ],
381
+ },
382
+ {
383
+ key: 'personal',
384
+ label: 'Personal',
385
+ title: 'Create the Runwork connector in ChatGPT',
386
+ steps: [
387
+ { id: 'install', label: 'Install the ChatGPT desktop app from chatgpt.com/download if you have not already' },
388
+ { id: 'open-web', label: 'Connectors are created on chatgpt.com: open it in your browser and sign in' },
389
+ { id: 'settings-apps', label: 'Open the profile menu (bottom-left), then Settings, then Apps' },
390
+ { id: 'dev-mode', label: 'Click "Advanced settings" and turn on "Developer mode" (skip if it is already on)' },
391
+ { id: 'create-app', label: 'Go back to Apps and click "Create app"' },
392
+ { id: 'fill', label: 'In the New App dialog, set Name to "Runwork", keep Connection on "Server URL", and paste the Workspace MCP URL above into the Server URL field' },
393
+ { id: 'oauth', label: 'Leave Authentication set to "OAuth", check "I understand and want to continue", then click Create' },
394
+ { id: 'authorize', label: 'Complete the OAuth sign-in to authorize Runwork for your account' },
395
+ { id: 'confirm', label: 'Open the ChatGPT desktop app and, in a new chat, ask "Do you have access to Runwork tools?" to confirm' },
396
+ ],
397
+ },
398
+ ],
399
+ },
400
+ },
249
401
  {
250
402
  slug: 'windsurf',
251
403
  name: 'Windsurf',
@@ -481,6 +633,30 @@ export function isIDEAgent(slug) {
481
633
  export function isDesktopAgent(slug) {
482
634
  return getAgent(slug)?.category === 'desktop';
483
635
  }
636
+ export function isWebAgent(slug) {
637
+ return getAgent(slug)?.category === 'web';
638
+ }
639
+ /**
640
+ * Connect-only agents have no local footprint to write: no skills directory,
641
+ * no MCP config file, no instruction file. The connector lives in the user's
642
+ * account (e.g. ChatGPT). `runwork sync` skips these for all local writes.
643
+ */
644
+ export function isConnectOnlyAgent(agent) {
645
+ if (!agent)
646
+ return false;
647
+ return !agent.skillsPaths && !agent.mcpConfigPath && !agent.instructionFile;
648
+ }
649
+ /** Family identifier for an agent (e.g. 'chatgpt' for both ChatGPT entries), or its slug when standalone. */
650
+ export function getAgentFamily(slug) {
651
+ const agent = getAgent(slug);
652
+ if (!agent)
653
+ return undefined;
654
+ return agent.family ?? agent.slug;
655
+ }
656
+ /** All agents sharing the given family identifier. */
657
+ export function getFamilyAgents(family) {
658
+ return AGENT_REGISTRY.filter((a) => (a.family ?? a.slug) === family);
659
+ }
484
660
  /** Alias of {@link getAgent} kept for backward compatibility with CLI callers. */
485
661
  export const getRegistryAgent = getAgent;
486
662
  /** Alias of {@link getAgents} kept for backward compatibility with CLI callers. */
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { normalizeCloneArgs } from '../clone.js';
2
+ import { normalizeCloneArgs, RESTRICTED_FS_HELP } from '../clone.js';
3
3
  describe('normalizeCloneArgs', () => {
4
4
  it('passes through both args unchanged when --app is not provided', () => {
5
5
  expect(normalizeCloneArgs('app-id', 'C:/dir', undefined)).toEqual({
@@ -42,3 +42,10 @@ describe('normalizeCloneArgs', () => {
42
42
  });
43
43
  });
44
44
  });
45
+ describe('clone --help restricted-FS guidance', () => {
46
+ it('warns agents about restricted "outputs" mounts and the remedy', () => {
47
+ expect(RESTRICTED_FS_HELP).toContain('Operation not permitted');
48
+ expect(RESTRICTED_FS_HELP).toContain('outputs');
49
+ expect(RESTRICTED_FS_HELP).toContain('home directory');
50
+ });
51
+ });
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { pullOutcomeFromError, isCloneSourceFailure } from '../clone.js';
3
+ /** Fake an execFileSync error carrying git's stderr. */
4
+ function gitError(stderr) {
5
+ const err = new Error('git failed');
6
+ err.stderr = Buffer.from(stderr);
7
+ return err;
8
+ }
9
+ describe('clone source outcome', () => {
10
+ describe('pullOutcomeFromError()', () => {
11
+ it('classifies an empty remote (brand-new app) as benign', () => {
12
+ const src = pullOutcomeFromError(gitError("fatal: couldn't find remote ref main"));
13
+ expect(src.pulled).toBe(false);
14
+ expect(src.reason).toBe('empty-remote');
15
+ });
16
+ it('classifies an auth failure', () => {
17
+ const src = pullOutcomeFromError(gitError('fatal: Authentication failed for https://runwork.ai'));
18
+ expect(src.reason).toBe('auth');
19
+ });
20
+ it('classifies a network failure', () => {
21
+ const src = pullOutcomeFromError(gitError('fatal: unable to access: Could not resolve host: runwork.ai'));
22
+ expect(src.reason).toBe('network');
23
+ });
24
+ it('classifies a missing remote', () => {
25
+ const src = pullOutcomeFromError(gitError("fatal: 'runwork' does not appear to be a git repository"));
26
+ expect(src.reason).toBe('no-remote');
27
+ });
28
+ it('preserves the raw stderr as detail', () => {
29
+ const src = pullOutcomeFromError(gitError('fatal: Authentication failed'));
30
+ expect(src.detail).toContain('Authentication failed');
31
+ });
32
+ });
33
+ describe('isCloneSourceFailure()', () => {
34
+ it('is false for a successful pull', () => {
35
+ expect(isCloneSourceFailure({ pulled: true })).toBe(false);
36
+ });
37
+ it('is false for an empty remote (expected for new apps)', () => {
38
+ expect(isCloneSourceFailure({ pulled: false, reason: 'empty-remote' })).toBe(false);
39
+ });
40
+ it.each(['auth', 'network', 'no-remote', 'unknown'])('is true for a real %s failure (template-only scaffold)', (reason) => {
41
+ const src = { pulled: false, reason };
42
+ expect(isCloneSourceFailure(src)).toBe(true);
43
+ });
44
+ });
45
+ });
@@ -1,6 +1,19 @@
1
1
  import { Command } from 'commander';
2
2
  import { ApiClient } from '../api/client.js';
3
3
  import type { Credentials, AppInfo } from '../types.js';
4
+ import { type SyncErrorReason } from '../git/classify-sync-error.js';
5
+ /**
6
+ * Outcome of overlaying the app's git-tracked source on top of the template.
7
+ * `pulled: false` with `reason: 'empty-remote'` is the expected, benign state
8
+ * for a brand-new app (nothing pushed yet). Any other `reason` is a real
9
+ * failure that left a template-only scaffold -- the caller must NOT report
10
+ * success, since editing it would edit the wrong code (friction log #1).
11
+ */
12
+ export interface CloneSource {
13
+ pulled: boolean;
14
+ reason?: 'empty-remote' | SyncErrorReason;
15
+ detail?: string;
16
+ }
4
17
  export interface CloneResult {
5
18
  appId: string;
6
19
  appName: string;
@@ -8,8 +21,21 @@ export interface CloneResult {
8
21
  directory: string;
9
22
  workspaceId: string;
10
23
  workspaceName: string;
24
+ source: CloneSource;
11
25
  }
12
26
  export declare function execClone(client: ApiClient, app: AppInfo, directory?: string, creds?: Credentials | null): Promise<CloneResult>;
27
+ /**
28
+ * Turn a failed-pull error into a CloneSource: an empty remote (benign,
29
+ * brand-new app) vs a classified real failure (auth/network/no-remote) that
30
+ * leaves a template-only scaffold.
31
+ */
32
+ export declare function pullOutcomeFromError(err: unknown): CloneSource;
33
+ /**
34
+ * A clone is a real failure -- a template-only scaffold rather than the app's
35
+ * code -- when the source pull didn't happen for any reason other than an
36
+ * empty remote. Callers must not report success in that case.
37
+ */
38
+ export declare function isCloneSourceFailure(source: CloneSource): boolean;
13
39
  /**
14
40
  * Reconcile positional args with --app. When --app is provided, the first
15
41
  * positional argument is intended as the *directory* -- not another appId
@@ -24,4 +50,11 @@ export declare function normalizeCloneArgs(appId: string | undefined, directory:
24
50
  appId: string | undefined;
25
51
  directory: string | undefined;
26
52
  };
53
+ /**
54
+ * Help epilogue shown under `runwork clone --help`. Agents tend to read help
55
+ * before running, so we surface the restricted-mount caveat (friction log #9)
56
+ * here. Exported so it can be asserted on directly (commander's help-text
57
+ * hooks aren't reflected by `helpInformation()`).
58
+ */
59
+ export declare const RESTRICTED_FS_HELP = "\nNote for AI agents: clone into a normal working directory. Some sandboxed\n\"outputs\" mounts (e.g. certain agent environments) disallow the unlink\noperations git uses for pack files, which can corrupt the repo with errors\nlike \"unable to unlink '.git/objects/pack/...': Operation not permitted\". If\nyou hit that, clone into your home directory or another standard path instead.";
27
60
  export declare const cloneCommand: Command;
@@ -13,6 +13,10 @@ import { runAgentWizard } from '../ui/banner.js';
13
13
  import { shouldOutputJson, jsonOut } from '../utils/output.js';
14
14
  import { buildCloneGuide, buildErrorResponse } from '../utils/agent-guidance.js';
15
15
  import { ensureGitIdentity } from '../git/identity.js';
16
+ import { ensureGitCredentialHelper } from '../git/credentials.js';
17
+ import { ensureRunworkRemote } from '../git/remote.js';
18
+ import { hardenRepoForRestrictedFs } from '../git/repo-config.js';
19
+ import { classifySyncError } from '../git/classify-sync-error.js';
16
20
  import { requireGit } from '../git/preflight.js';
17
21
  import { formatError } from '../utils/format-error.js';
18
22
  export async function execClone(client, app, directory, creds) {
@@ -58,16 +62,24 @@ export async function execClone(client, app, directory, creds) {
58
62
  execFileSync('git', ['symbolic-ref', 'HEAD', 'refs/heads/main'], { cwd: dir, stdio: 'pipe' });
59
63
  }
60
64
  catch { /* best-effort: push uses HEAD:main so this is just cleanliness */ }
65
+ // Disable background gc/maintenance so pack rewrites don't fail on
66
+ // restricted filesystems (e.g. some agent "outputs" mounts). Harmless on
67
+ // normal filesystems. See git/repo-config.ts.
68
+ hardenRepoForRestrictedFs(dir);
61
69
  }
62
70
  // Seed a local git identity so subsequent commits do not fail on machines
63
71
  // (commonly fresh Windows installs) without `git config --global user.email`.
64
72
  ensureGitIdentity(dir, creds);
65
- try {
66
- execFileSync('git', ['remote', 'set-url', 'runwork', remoteUrl], { cwd: dir, stdio: 'pipe' });
67
- }
68
- catch {
69
- execFileSync('git', ['remote', 'add', 'runwork', remoteUrl], { cwd: dir, stdio: 'pipe' });
73
+ // Make sure git can authenticate to the runwork remote before we fetch.
74
+ // Headless auth paths (the get_cli_setup MCP tool, or a hand-written
75
+ // ~/.runwork/.credentials) register API creds but not the git helper, so
76
+ // without this the fetch below silently fails and we fall back to a
77
+ // template-only scaffold. See git/credentials.ts:ensureGitCredentialHelper.
78
+ if (creds?.baseUrl) {
79
+ await ensureGitCredentialHelper(creds.baseUrl);
70
80
  }
81
+ ensureRunworkRemote(dir, remoteUrl);
82
+ let source = { pulled: true };
71
83
  try {
72
84
  execFileSync('git', ['fetch', 'runwork', 'main'], { cwd: dir, stdio: 'pipe' });
73
85
  // Reset to remote HEAD - overlays git-tracked files on top of template
@@ -76,11 +88,19 @@ export async function execClone(client, app, directory, creds) {
76
88
  execFileSync('git', ['checkout', '.'], { cwd: dir, stdio: 'pipe' });
77
89
  }
78
90
  catch (err) {
79
- // Distinguish "remote is empty" (expected for brand-new apps) from
80
- // real failures. We print both the generic next-step hint and the
81
- // underlying error so authentication / network failures aren't lost.
82
- console.warn('Could not pull from runwork remote. Starting with template only.');
83
- console.warn(` ${formatError(err)}`);
91
+ source = pullOutcomeFromError(err);
92
+ if (source.reason === 'empty-remote') {
93
+ // Brand-new app: the remote has no `main` branch yet. Expected and
94
+ // benign -- the template scaffold IS the starting point.
95
+ console.warn('No source pushed for this app yet. Starting from the template scaffold.');
96
+ }
97
+ else {
98
+ // A real failure (auth, missing remote, network). The scaffold here is
99
+ // NOT the app's code -- surface it loudly so the caller fails instead of
100
+ // silently editing the wrong files.
101
+ console.warn('Could not pull the app source from the runwork remote.');
102
+ console.warn(` ${formatError(err)}`);
103
+ }
84
104
  }
85
105
  // Write .runwork.json config
86
106
  const config = {
@@ -107,8 +127,45 @@ export async function execClone(client, app, directory, creds) {
107
127
  directory: resolve(dir),
108
128
  workspaceId: app.workspaceId,
109
129
  workspaceName: app.workspaceName,
130
+ source,
110
131
  };
111
132
  }
133
+ /** Pull the raw git stderr off an execFileSync error for classification. */
134
+ function extractStderr(err) {
135
+ const e = err;
136
+ if (e?.stderr)
137
+ return e.stderr.toString().trim();
138
+ return formatError(err);
139
+ }
140
+ /**
141
+ * Whether a failed `git fetch runwork main` means the remote simply has no
142
+ * `main` branch yet (a brand-new app), as opposed to an auth/network/remote
143
+ * failure. Git reports this as "couldn't find remote ref main".
144
+ */
145
+ function isEmptyRemote(stderr) {
146
+ const s = stderr.toLowerCase();
147
+ return s.includes("couldn't find remote ref") || s.includes('could not find remote ref');
148
+ }
149
+ /**
150
+ * Turn a failed-pull error into a CloneSource: an empty remote (benign,
151
+ * brand-new app) vs a classified real failure (auth/network/no-remote) that
152
+ * leaves a template-only scaffold.
153
+ */
154
+ export function pullOutcomeFromError(err) {
155
+ const detail = extractStderr(err);
156
+ if (isEmptyRemote(detail)) {
157
+ return { pulled: false, reason: 'empty-remote', detail };
158
+ }
159
+ return { pulled: false, reason: classifySyncError(detail), detail };
160
+ }
161
+ /**
162
+ * A clone is a real failure -- a template-only scaffold rather than the app's
163
+ * code -- when the source pull didn't happen for any reason other than an
164
+ * empty remote. Callers must not report success in that case.
165
+ */
166
+ export function isCloneSourceFailure(source) {
167
+ return !source.pulled && source.reason !== 'empty-remote';
168
+ }
112
169
  /**
113
170
  * Reconcile positional args with --app. When --app is provided, the first
114
171
  * positional argument is intended as the *directory* -- not another appId
@@ -123,11 +180,24 @@ export function normalizeCloneArgs(appId, directory, options) {
123
180
  }
124
181
  return { appId, directory };
125
182
  }
183
+ /**
184
+ * Help epilogue shown under `runwork clone --help`. Agents tend to read help
185
+ * before running, so we surface the restricted-mount caveat (friction log #9)
186
+ * here. Exported so it can be asserted on directly (commander's help-text
187
+ * hooks aren't reflected by `helpInformation()`).
188
+ */
189
+ export const RESTRICTED_FS_HELP = `
190
+ Note for AI agents: clone into a normal working directory. Some sandboxed
191
+ "outputs" mounts (e.g. certain agent environments) disallow the unlink
192
+ operations git uses for pack files, which can corrupt the repo with errors
193
+ like "unable to unlink '.git/objects/pack/...': Operation not permitted". If
194
+ you hit that, clone into your home directory or another standard path instead.`;
126
195
  export const cloneCommand = new Command('clone')
127
196
  .description('Clone a Runwork app to local development')
128
197
  .argument('[appId]', 'App ID to clone (interactive if omitted)')
129
198
  .argument('[directory]', 'Target directory')
130
199
  .option('--app <name-or-id>', 'App name or ID (skips interactive selection)')
200
+ .addHelpText('after', RESTRICTED_FS_HELP)
131
201
  .action(async (rawAppId, rawDirectory, options) => {
132
202
  requireGit('clone');
133
203
  const { appId, directory } = normalizeCloneArgs(rawAppId, rawDirectory, options);
@@ -156,20 +226,45 @@ export const cloneCommand = new Command('clone')
156
226
  console.log(`Cloning "${app.name}"...`);
157
227
  }
158
228
  const cloneResult = await execClone(client, app, directory, creds);
229
+ // A real pull failure (auth/network/missing-remote) left a template-only
230
+ // scaffold that is NOT the app's deployed code. Report it as a failure so
231
+ // an agent doesn't start editing the wrong files (friction log #1).
232
+ const pullFailed = isCloneSourceFailure(cloneResult.source);
159
233
  if (useJson) {
234
+ if (pullFailed) {
235
+ jsonOut(buildErrorResponse('clone', 'Cloned the scaffold but could not pull the app source', `git could not fetch the app's source (${cloneResult.source.reason}), so the directory holds only the template scaffold, not the deployed code. ${cloneResult.source.detail ?? ''}`.trim(), [
236
+ 'Run `runwork doctor --fix` to repair git auth and the runwork remote',
237
+ `Then re-clone: runwork clone --app ${cloneResult.appName}`,
238
+ ]));
239
+ process.exit(1);
240
+ }
160
241
  const response = {
161
242
  success: true,
162
243
  command: 'clone',
163
244
  result: cloneResult,
245
+ warning: cloneResult.source.reason === 'empty-remote'
246
+ ? 'This app has no source pushed yet. The directory is the starting template scaffold.'
247
+ : undefined,
164
248
  guide: buildCloneGuide(cloneResult.appName, cloneResult.slug),
165
249
  };
166
250
  jsonOut(response);
167
251
  return;
168
252
  }
253
+ if (pullFailed) {
254
+ console.error(`\nCloned the scaffold for "${cloneResult.appName}", but could NOT pull the app source (${cloneResult.source.reason}).`);
255
+ console.error('The directory holds only the template scaffold, not the deployed code.');
256
+ console.error('Fix git access, then re-clone:');
257
+ console.error(' runwork doctor --fix');
258
+ console.error(` runwork clone --app ${cloneResult.appName}`);
259
+ process.exit(1);
260
+ }
169
261
  // No trailing separator: cloneResult.directory is already an absolute
170
262
  // path. Appending '/' here mixed with Windows '\' separators produced
171
263
  // confusing output like `C:\Users\...\app/` on Windows.
172
264
  console.log(`\nApp "${cloneResult.appName}" cloned to ${cloneResult.directory}`);
265
+ if (cloneResult.source.reason === 'empty-remote') {
266
+ console.log('Note: no source pushed for this app yet. Starting from the template scaffold.');
267
+ }
173
268
  console.log(`Remote: ${client.getGitRemoteUrl(cloneResult.workspaceId, cloneResult.appId)}`);
174
269
  await runAgentWizard(cloneResult.directory);
175
270
  console.log(`Next: cd ${cloneResult.slug} && runwork dev`);