runwork 0.9.0 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agents/__tests__/claude-code-stats.test.js +173 -2
- package/dist/agents/__tests__/codex-stats.test.js +93 -0
- package/dist/agents/claude-code.js +140 -19
- package/dist/agents/claude-desktop.d.ts +2 -1
- package/dist/agents/claude-desktop.js +57 -0
- package/dist/agents/codex.d.ts +17 -1
- package/dist/agents/codex.js +144 -1
- package/dist/agents/detect.js +2 -1
- package/dist/agents/detection.d.ts +17 -0
- package/dist/agents/detection.js +89 -0
- package/dist/agents/generic-adapter.js +3 -13
- package/dist/agents/registry-data.d.ts +126 -0
- package/dist/agents/registry-data.js +436 -0
- package/dist/agents/registry.d.ts +8 -48
- package/dist/agents/registry.js +9 -192
- package/dist/commands/dev.js +78 -0
- package/dist/generated/version.d.ts +1 -1
- package/dist/generated/version.js +1 -1
- package/dist/git/__tests__/manifest.test.js +69 -0
- package/dist/git/auto-commit.d.ts +5 -0
- package/dist/git/auto-commit.js +15 -14
- package/dist/template/manifest.js +30 -7
- package/dist/utils/__tests__/ignore-matcher.test.d.ts +1 -0
- package/dist/utils/__tests__/ignore-matcher.test.js +188 -0
- package/dist/utils/ignore-matcher.d.ts +38 -0
- package/dist/utils/ignore-matcher.js +116 -0
- package/package.json +1 -1
package/dist/agents/registry.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* CLI-side agent registry entry point.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* The data and browser-safe types / lookups live in `./registry-data.ts` so
|
|
5
|
+
* that the desktop package can import them without pulling in Node-only
|
|
6
|
+
* modules. This file keeps the existing public surface for CLI callers by
|
|
7
|
+
* re-exporting everything from the data module and adding the Node-only path
|
|
8
|
+
* resolvers used by the generic adapter and `sync` command.
|
|
9
9
|
*/
|
|
10
10
|
import { platform, homedir } from 'os';
|
|
11
11
|
import { join } from 'path';
|
|
12
|
+
// Re-export types, data, and browser-safe lookups so every existing CLI
|
|
13
|
+
// caller keeps working unchanged (`import { getRegistryAgent, ... } from './registry.js'`).
|
|
14
|
+
export * from './registry-data.js';
|
|
12
15
|
function getNodePlatform() {
|
|
13
16
|
const p = platform();
|
|
14
17
|
if (p === 'darwin')
|
|
@@ -31,189 +34,3 @@ export function resolveToAbsolute(ps, scope) {
|
|
|
31
34
|
? join(homedir(), resolved)
|
|
32
35
|
: join(process.cwd(), resolved);
|
|
33
36
|
}
|
|
34
|
-
// ---------------------------------------------------------------------------
|
|
35
|
-
// Registry
|
|
36
|
-
// ---------------------------------------------------------------------------
|
|
37
|
-
const AGENT_REGISTRY = [
|
|
38
|
-
// === First-class agents (have custom adapters in CLI) ===
|
|
39
|
-
{
|
|
40
|
-
slug: 'claude-code',
|
|
41
|
-
name: 'Claude Code',
|
|
42
|
-
description: "Anthropic's AI coding agent for the terminal",
|
|
43
|
-
category: 'cli',
|
|
44
|
-
detection: { method: 'binary', target: 'claude' },
|
|
45
|
-
skillsPaths: { global: '.claude/skills', project: '.claude/skills' },
|
|
46
|
-
mcpConfigPath: '.claude/settings.json',
|
|
47
|
-
instructionFile: { global: '.claude/CLAUDE.md', project: '.claude/CLAUDE.md' },
|
|
48
|
-
mcpConfigKey: 'mcpServers',
|
|
49
|
-
firstClass: true,
|
|
50
|
-
},
|
|
51
|
-
{
|
|
52
|
-
slug: 'claude-desktop',
|
|
53
|
-
name: 'Claude Desktop',
|
|
54
|
-
aliases: ['Claude Desktop (Cowork)', 'Cowork'],
|
|
55
|
-
description: 'Claude as a desktop application',
|
|
56
|
-
category: 'desktop',
|
|
57
|
-
detection: {
|
|
58
|
-
method: 'path',
|
|
59
|
-
target: {
|
|
60
|
-
macos: 'Library/Application Support/Claude/claude_desktop_config.json',
|
|
61
|
-
windows: 'AppData/Roaming/Claude/claude_desktop_config.json',
|
|
62
|
-
linux: '.config/Claude/claude_desktop_config.json',
|
|
63
|
-
},
|
|
64
|
-
},
|
|
65
|
-
skillsPaths: { global: '.claude/skills', project: '.claude/skills' },
|
|
66
|
-
mcpConfigPath: {
|
|
67
|
-
macos: 'Library/Application Support/Claude/claude_desktop_config.json',
|
|
68
|
-
windows: 'AppData/Roaming/Claude/claude_desktop_config.json',
|
|
69
|
-
linux: '.config/Claude/claude_desktop_config.json',
|
|
70
|
-
},
|
|
71
|
-
mcpConfigKey: 'mcpServers',
|
|
72
|
-
firstClass: true,
|
|
73
|
-
},
|
|
74
|
-
{
|
|
75
|
-
slug: 'cursor',
|
|
76
|
-
name: 'Cursor',
|
|
77
|
-
description: 'AI-powered code editor',
|
|
78
|
-
category: 'ide',
|
|
79
|
-
detection: { method: 'binary', target: 'cursor' },
|
|
80
|
-
skillsPaths: { global: '.cursor/skills', project: '.cursor/skills' },
|
|
81
|
-
mcpConfigPath: '.cursor/mcp.json',
|
|
82
|
-
instructionFile: { project: '.cursor/rules/runwork.mdc' },
|
|
83
|
-
mcpConfigKey: 'mcpServers',
|
|
84
|
-
skillFormat: 'mdc',
|
|
85
|
-
firstClass: true,
|
|
86
|
-
},
|
|
87
|
-
{
|
|
88
|
-
slug: 'codex',
|
|
89
|
-
name: 'Codex CLI',
|
|
90
|
-
description: "OpenAI's AI coding agent for the terminal",
|
|
91
|
-
category: 'cli',
|
|
92
|
-
detection: { method: 'binary', target: 'codex' },
|
|
93
|
-
skillsPaths: { global: '.codex/skills', project: '.agents/skills' },
|
|
94
|
-
instructionFile: { global: '.codex/instructions.md', project: 'AGENTS.md' },
|
|
95
|
-
firstClass: true,
|
|
96
|
-
},
|
|
97
|
-
{
|
|
98
|
-
slug: 'windsurf',
|
|
99
|
-
name: 'Windsurf',
|
|
100
|
-
description: 'AI-powered code editor by Codeium',
|
|
101
|
-
category: 'ide',
|
|
102
|
-
detection: { method: 'binary', target: 'windsurf' },
|
|
103
|
-
skillsPaths: { global: '.codeium/windsurf/skills', project: '.windsurf/skills' },
|
|
104
|
-
mcpConfigPath: '.codeium/windsurf/mcp_config.json',
|
|
105
|
-
instructionFile: { global: '.codeium/windsurf/rules/runwork.md', project: '.windsurf/rules/runwork.md' },
|
|
106
|
-
mcpConfigKey: 'mcpServers',
|
|
107
|
-
skillFormat: 'windsurf-md',
|
|
108
|
-
firstClass: true,
|
|
109
|
-
},
|
|
110
|
-
{
|
|
111
|
-
slug: 'gemini',
|
|
112
|
-
name: 'Gemini CLI',
|
|
113
|
-
description: "Google's AI coding agent for the terminal",
|
|
114
|
-
category: 'cli',
|
|
115
|
-
detection: { method: 'binary', target: 'gemini' },
|
|
116
|
-
skillsPaths: { global: '.gemini/skills', project: '.gemini/skills' },
|
|
117
|
-
mcpConfigPath: '.gemini/settings.json',
|
|
118
|
-
instructionFile: { global: '.gemini/GEMINI.md', project: 'GEMINI.md' },
|
|
119
|
-
mcpConfigKey: 'mcpServers',
|
|
120
|
-
firstClass: true,
|
|
121
|
-
},
|
|
122
|
-
// === Additional agents with some custom handling ===
|
|
123
|
-
{
|
|
124
|
-
slug: 'copilot-vscode',
|
|
125
|
-
name: 'GitHub Copilot (VS Code)',
|
|
126
|
-
description: "GitHub's AI pair programmer in VS Code",
|
|
127
|
-
category: 'extension',
|
|
128
|
-
detection: { method: 'binary', target: 'code' },
|
|
129
|
-
skillsPaths: { global: '.copilot/skills', project: '.github/skills' },
|
|
130
|
-
mcpConfigPath: '.vscode/mcp.json',
|
|
131
|
-
mcpConfigKey: 'servers',
|
|
132
|
-
},
|
|
133
|
-
{
|
|
134
|
-
slug: 'cline',
|
|
135
|
-
name: 'Cline',
|
|
136
|
-
description: 'Autonomous AI coding agent for VS Code',
|
|
137
|
-
category: 'extension',
|
|
138
|
-
detection: { method: 'binary', target: 'cline' },
|
|
139
|
-
skillsPaths: { global: '.agents/skills', project: '.agents/skills' },
|
|
140
|
-
},
|
|
141
|
-
{
|
|
142
|
-
slug: 'copilot-cli',
|
|
143
|
-
name: 'GitHub Copilot CLI',
|
|
144
|
-
description: 'GitHub Copilot in the terminal',
|
|
145
|
-
category: 'cli',
|
|
146
|
-
detection: { method: 'binary', target: 'gh' },
|
|
147
|
-
skillsPaths: { global: '.copilot/skills', project: '.github/skills' },
|
|
148
|
-
mcpConfigPath: '.copilot/mcp-config.json',
|
|
149
|
-
mcpConfigKey: 'mcpServers',
|
|
150
|
-
},
|
|
151
|
-
// === Community agents (from skillshare targets.yaml) ===
|
|
152
|
-
{ slug: 'antigravity', name: 'Antigravity', description: "Google's Antigravity AI agent", category: 'cli', detection: { method: 'binary', target: 'antigravity' }, skillsPaths: { global: '.gemini/antigravity/skills', project: '.agent/skills' } },
|
|
153
|
-
{ slug: 'amp', name: 'Amp', description: 'AI coding agent by Sourcegraph', category: 'cli', detection: { method: 'binary', target: 'amp' }, skillsPaths: { global: '.config/agents/skills', project: '.agents/skills' } },
|
|
154
|
-
{ slug: 'adal', name: 'AdaL', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'adal' }, skillsPaths: { global: '.adal/skills', project: '.adal/skills' } },
|
|
155
|
-
{ slug: 'astrbot', name: 'AstrBot', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'astrbot' }, skillsPaths: { global: '.astrbot/data/skills', project: 'data/skills' } },
|
|
156
|
-
{ slug: 'augment', name: 'Augment', description: 'AI coding assistant', category: 'ide', detection: { method: 'binary', target: 'augment' }, skillsPaths: { global: '.augment/skills', project: '.augment/skills' } },
|
|
157
|
-
{ slug: 'bob', name: 'Bob', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'bob' }, skillsPaths: { global: '.bob/skills', project: '.bob/skills' } },
|
|
158
|
-
{ slug: 'codebuddy', name: 'CodeBuddy', description: 'AI coding assistant', category: 'cli', detection: { method: 'binary', target: 'codebuddy' }, skillsPaths: { global: '.codebuddy/skills', project: '.codebuddy/skills' } },
|
|
159
|
-
{ slug: 'comate', name: 'Comate', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'comate' }, skillsPaths: { global: '.comate/skills', project: '.comate/skills' } },
|
|
160
|
-
{ slug: 'commandcode', name: 'Command Code', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'commandcode' }, skillsPaths: { global: '.commandcode/skills', project: '.commandcode/skills' } },
|
|
161
|
-
{ slug: 'continue', name: 'Continue', description: 'Open-source AI code assistant', category: 'extension', detection: { method: 'binary', target: 'continue' }, skillsPaths: { global: '.continue/skills', project: '.continue/skills' } },
|
|
162
|
-
{ slug: 'cortex', name: 'Cortex', description: "Snowflake's AI coding agent", category: 'cli', detection: { method: 'binary', target: 'cortex' }, skillsPaths: { global: '.snowflake/cortex/skills', project: '.cortex/skills' } },
|
|
163
|
-
{ slug: 'crush', name: 'Crush', description: 'AI agent by Charm', category: 'cli', detection: { method: 'binary', target: 'crush' }, skillsPaths: { global: '.config/crush/skills', project: '.crush/skills' } },
|
|
164
|
-
{ slug: 'deepagents', name: 'Deep Agents', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'deepagents' }, skillsPaths: { global: '.deepagents/agent/skills', project: '.deepagents/skills' } },
|
|
165
|
-
{ slug: 'droid', name: 'Droid', description: "Factory AI's coding agent", category: 'cli', detection: { method: 'binary', target: 'droid' }, skillsPaths: { global: '.factory/skills', project: '.factory/skills' } },
|
|
166
|
-
{ slug: 'firebender', name: 'Firebender', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'firebender' }, skillsPaths: { global: '.firebender/skills', project: '.firebender/skills' } },
|
|
167
|
-
{ slug: 'goose', name: 'Goose', description: 'AI coding agent by Block', category: 'cli', detection: { method: 'binary', target: 'goose' }, skillsPaths: { global: '.config/goose/skills', project: '.goose/skills' } },
|
|
168
|
-
{ slug: 'hermes', name: 'Hermes', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'hermes' }, skillsPaths: { global: '.hermes/skills', project: '.hermes/skills' } },
|
|
169
|
-
{ slug: 'iflow', name: 'iFlow CLI', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'iflow' }, skillsPaths: { global: '.iflow/skills', project: '.iflow/skills' } },
|
|
170
|
-
{ slug: 'junie', name: 'Junie', description: 'JetBrains AI coding agent', category: 'ide', detection: { method: 'binary', target: 'junie' }, skillsPaths: { global: '.junie/skills', project: '.junie/skills' } },
|
|
171
|
-
{ slug: 'kilocode', name: 'Kilo Code', description: 'AI coding agent', category: 'extension', detection: { method: 'binary', target: 'kilocode' }, skillsPaths: { global: '.kilocode/skills', project: '.kilocode/skills' } },
|
|
172
|
-
{ slug: 'kimi', name: 'Kimi Code CLI', description: "Moonshot AI's coding agent", category: 'cli', detection: { method: 'binary', target: 'kimi' }, skillsPaths: { global: '.config/agents/skills', project: '.agents/skills' } },
|
|
173
|
-
{ slug: 'kiro', name: 'Kiro', description: "AWS's AI coding agent", category: 'ide', detection: { method: 'binary', target: 'kiro' }, skillsPaths: { global: '.kiro/skills', project: '.kiro/skills' } },
|
|
174
|
-
{ slug: 'kode', name: 'Kode', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'kode' }, skillsPaths: { global: '.kode/skills', project: '.kode/skills' } },
|
|
175
|
-
{ slug: 'letta', name: 'Letta', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'letta' }, skillsPaths: { global: '.letta/skills', project: '.skills' } },
|
|
176
|
-
{ slug: 'lingma', name: 'Lingma', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'lingma' }, skillsPaths: { global: '.lingma/skills', project: '.lingma/skills' } },
|
|
177
|
-
{ slug: 'mcpjam', name: 'MCPJam', description: 'MCP-based AI agent', category: 'cli', detection: { method: 'binary', target: 'mcpjam' }, skillsPaths: { global: '.mcpjam/skills', project: '.mcpjam/skills' } },
|
|
178
|
-
{ slug: 'mux', name: 'Mux', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'mux' }, skillsPaths: { global: '.mux/skills', project: '.mux/skills' } },
|
|
179
|
-
{ slug: 'neovate', name: 'Neovate', description: 'AI coding agent for Neovim', category: 'extension', detection: { method: 'binary', target: 'neovate' }, skillsPaths: { global: '.neovate/skills', project: '.neovate/skills' } },
|
|
180
|
-
{ slug: 'omp', name: 'Oh My Pi', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'omp' }, skillsPaths: { global: '.omp/agent/skills', project: '.omp/skills' } },
|
|
181
|
-
{ slug: 'openclaw', name: 'OpenClaw', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'openclaw' }, skillsPaths: { global: '.openclaw/skills', project: 'skills' } },
|
|
182
|
-
{ slug: 'opencode', name: 'OpenCode', description: 'Open-source AI coding agent', category: 'cli', detection: { method: 'binary', target: 'opencode' }, skillsPaths: { global: '.config/opencode/skills', project: '.opencode/skills' } },
|
|
183
|
-
{ slug: 'openhands', name: 'OpenHands', description: 'Open-source AI coding agent', category: 'cli', detection: { method: 'binary', target: 'openhands' }, skillsPaths: { global: '.openhands/skills', project: '.openhands/skills' } },
|
|
184
|
-
{ slug: 'pi', name: 'Pi', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'pi' }, skillsPaths: { global: '.pi/agent/skills', project: '.pi/skills' } },
|
|
185
|
-
{ slug: 'pochi', name: 'Pochi', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'pochi' }, skillsPaths: { global: '.pochi/skills', project: '.pochi/skills' } },
|
|
186
|
-
{ slug: 'purecode', name: 'PureCode AI', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'purecode' }, skillsPaths: { global: '.purecode/skills', project: '.agents/skills' } },
|
|
187
|
-
{ slug: 'qoder', name: 'Qoder', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'qoder' }, skillsPaths: { global: '.qoder/skills', project: '.qoder/skills' } },
|
|
188
|
-
{ slug: 'qwen', name: 'Qwen Code', description: "Alibaba's AI coding agent", category: 'cli', detection: { method: 'binary', target: 'qwen' }, skillsPaths: { global: '.qwen/skills', project: '.qwen/skills' } },
|
|
189
|
-
{ slug: 'roo', name: 'Roo Code', description: 'AI coding agent', category: 'extension', detection: { method: 'binary', target: 'roo' }, skillsPaths: { global: '.roo/skills', project: '.roo/skills' } },
|
|
190
|
-
{ slug: 'trae', name: 'Trae', description: 'ByteDance AI coding IDE', category: 'ide', detection: { method: 'binary', target: 'trae' }, skillsPaths: { global: '.trae/skills', project: '.trae/skills' } },
|
|
191
|
-
{ slug: 'mistral-vibe', name: 'Mistral Vibe', description: "Mistral's AI coding agent", category: 'cli', detection: { method: 'binary', target: 'vibe' }, skillsPaths: { global: '.vibe/skills', project: '.vibe/skills' } },
|
|
192
|
-
{ slug: 'verdent', name: 'Verdent', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'verdent' }, skillsPaths: { global: '.verdent/skills', project: '.verdent/skills' } },
|
|
193
|
-
{ slug: 'warp', name: 'Warp AI', description: 'AI-powered terminal', category: 'cli', detection: { method: 'path', target: { macos: '/Applications/Warp.app', linux: '/usr/bin/warp-terminal' } }, skillsPaths: { global: '.agents/skills', project: '.agents/skills' } },
|
|
194
|
-
{ slug: 'witsy', name: 'Witsy', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'witsy' }, skillsPaths: { global: '.agents/skills', project: '.agents/skills' } },
|
|
195
|
-
{ slug: 'zencoder', name: 'Zencoder', description: 'AI coding agent', category: 'cli', detection: { method: 'binary', target: 'zencoder' }, skillsPaths: { global: '.zencoder/skills', project: '.zencoder/skills' } },
|
|
196
|
-
{ slug: 'replit', name: 'Replit', description: 'AI-powered coding platform', category: 'ide', detection: { method: 'binary', target: 'replit' }, skillsPaths: { global: '.config/agents/skills', project: '.agents/skills' } },
|
|
197
|
-
];
|
|
198
|
-
// ---------------------------------------------------------------------------
|
|
199
|
-
// Index (built once)
|
|
200
|
-
// ---------------------------------------------------------------------------
|
|
201
|
-
const slugIndex = new Map();
|
|
202
|
-
for (const agent of AGENT_REGISTRY) {
|
|
203
|
-
slugIndex.set(agent.slug, agent);
|
|
204
|
-
}
|
|
205
|
-
// ---------------------------------------------------------------------------
|
|
206
|
-
// Public API
|
|
207
|
-
// ---------------------------------------------------------------------------
|
|
208
|
-
export function getRegistryAgent(slug) {
|
|
209
|
-
return slugIndex.get(slug);
|
|
210
|
-
}
|
|
211
|
-
export function getRegistryAgents() {
|
|
212
|
-
return AGENT_REGISTRY;
|
|
213
|
-
}
|
|
214
|
-
export function getFirstClassSlugs() {
|
|
215
|
-
return AGENT_REGISTRY.filter(a => a.firstClass).map(a => a.slug);
|
|
216
|
-
}
|
|
217
|
-
export function getCustomAdapterSlugs() {
|
|
218
|
-
return new Set(['claude-code', 'claude-desktop', 'cursor', 'windsurf', 'codex', 'cline', 'gemini']);
|
|
219
|
-
}
|
package/dist/commands/dev.js
CHANGED
|
@@ -35,6 +35,65 @@ function readConfig() {
|
|
|
35
35
|
}
|
|
36
36
|
return JSON.parse(readFileSync('.runwork.json', 'utf-8'));
|
|
37
37
|
}
|
|
38
|
+
// Files the CLI cannot function without and which the user did not author.
|
|
39
|
+
// `.runwork.json` is the local app identity; `blueprint.json` is the app's
|
|
40
|
+
// canonical feature definition; `.gitignore` keeps caches out of git. If a
|
|
41
|
+
// sync from the remote silently removes any of these (which has happened
|
|
42
|
+
// when a server-side agent commits with a stale index — see
|
|
43
|
+
// worker/agents/git/git.ts), restoring from a pre-sync snapshot keeps the
|
|
44
|
+
// project usable and unblocks `runwork deploy`.
|
|
45
|
+
const CRITICAL_FILES = ['.runwork.json', 'blueprint.json', '.gitignore'];
|
|
46
|
+
function snapshotCriticalFiles(cwd) {
|
|
47
|
+
const snapshots = [];
|
|
48
|
+
for (const rel of CRITICAL_FILES) {
|
|
49
|
+
const abs = join(cwd, rel);
|
|
50
|
+
if (!existsSync(abs))
|
|
51
|
+
continue;
|
|
52
|
+
try {
|
|
53
|
+
snapshots.push({ path: rel, contents: readFileSync(abs, 'utf-8') });
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// Best-effort: skip unreadable files.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return snapshots;
|
|
60
|
+
}
|
|
61
|
+
function restoreMissingCriticalFiles(cwd, snapshots) {
|
|
62
|
+
const restored = [];
|
|
63
|
+
for (const snap of snapshots) {
|
|
64
|
+
const abs = join(cwd, snap.path);
|
|
65
|
+
if (existsSync(abs))
|
|
66
|
+
continue;
|
|
67
|
+
try {
|
|
68
|
+
writeFileSync(abs, snap.contents, 'utf-8');
|
|
69
|
+
restored.push(snap.path);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// Best-effort: skip files we cannot write back.
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return restored;
|
|
76
|
+
}
|
|
77
|
+
function commitAndPushRestoredFiles(cwd, files) {
|
|
78
|
+
if (files.length === 0)
|
|
79
|
+
return false;
|
|
80
|
+
try {
|
|
81
|
+
execFileSync('git', ['add', '--', ...files], { cwd, stdio: 'pipe' });
|
|
82
|
+
execFileSync('git', ['commit', '-m', 'chore: restore critical files removed by sync'], { cwd, stdio: 'pipe' });
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
execFileSync('git', ['push', 'runwork', 'main'], { cwd, stdio: 'pipe' });
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// Push failures are non-fatal: the local copy is restored, and the
|
|
93
|
+
// restoration commit will be pushed with the next auto-sync cycle.
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
38
97
|
export async function execDev(options) {
|
|
39
98
|
const useJson = options?.json ?? false;
|
|
40
99
|
const config = readConfig();
|
|
@@ -108,7 +167,26 @@ export async function execDev(options) {
|
|
|
108
167
|
// Sync
|
|
109
168
|
if (!useJson)
|
|
110
169
|
console.log(dim('Syncing...'));
|
|
170
|
+
const criticalSnapshot = snapshotCriticalFiles(cwd);
|
|
111
171
|
const syncResult = syncWithRemote(cwd);
|
|
172
|
+
const restoredCriticalFiles = restoreMissingCriticalFiles(cwd, criticalSnapshot);
|
|
173
|
+
if (restoredCriticalFiles.length > 0) {
|
|
174
|
+
const pushed = commitAndPushRestoredFiles(cwd, restoredCriticalFiles);
|
|
175
|
+
if (useJson) {
|
|
176
|
+
jsonLine({
|
|
177
|
+
event: 'sync_restored_critical_files',
|
|
178
|
+
files: restoredCriticalFiles,
|
|
179
|
+
pushed,
|
|
180
|
+
timestamp: ts(),
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
console.warn(yellow(` Sync removed critical file(s); restored: ${restoredCriticalFiles.join(', ')}`));
|
|
185
|
+
if (!pushed) {
|
|
186
|
+
console.warn(dim(' Restoration committed locally but not pushed; will retry on next auto-sync.'));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
112
190
|
if (useJson) {
|
|
113
191
|
jsonLine({ event: 'startup', phase: 'sync', status: syncResult.status, pushed: syncResult.pushed, timestamp: ts() });
|
|
114
192
|
if (syncResult.status === 'sync-failed') {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.9.
|
|
1
|
+
export declare const VERSION = "0.9.2";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Auto-generated by scripts/embed-types.ts -- do not edit
|
|
2
|
-
export const VERSION = "0.9.
|
|
2
|
+
export const VERSION = "0.9.2";
|
|
@@ -67,6 +67,47 @@ describe('generateManifest()', () => {
|
|
|
67
67
|
expect(Object.keys(manifest.files)).toHaveLength(1);
|
|
68
68
|
expect(manifest.files['a.txt']).toBeDefined();
|
|
69
69
|
});
|
|
70
|
+
it('skips .bun-cache directory by default (regression: hung dev startup)', async () => {
|
|
71
|
+
// Bun's per-project cache used to be walked recursively here. When it
|
|
72
|
+
// grew to ~1 GB / 25k+ files, generateManifest spent minutes hashing
|
|
73
|
+
// every file and starved the rest of `runwork dev`. The default
|
|
74
|
+
// ignore-matcher now skips .bun-cache/ even without a .gitignore.
|
|
75
|
+
const dir = makeTempDir('bun-cache');
|
|
76
|
+
writeFileSync(join(dir, 'a.txt'), 'hello');
|
|
77
|
+
mkdirSync(join(dir, '.bun-cache'));
|
|
78
|
+
writeFileSync(join(dir, '.bun-cache', 'pkg.tgz'), 'tarball');
|
|
79
|
+
mkdirSync(join(dir, '.bun-cache', 'react@19.0.0'));
|
|
80
|
+
writeFileSync(join(dir, '.bun-cache', 'react@19.0.0', 'index.js'), 'r');
|
|
81
|
+
const manifest = await generateManifest(dir);
|
|
82
|
+
expect(Object.keys(manifest.files)).toEqual(['a.txt']);
|
|
83
|
+
});
|
|
84
|
+
it('honors simple .gitignore directory entries (e.g. dist/, .next/, coverage)', async () => {
|
|
85
|
+
const dir = makeTempDir('gitignored-dirs');
|
|
86
|
+
writeFileSync(join(dir, '.gitignore'), 'dist/\n.next/\ncoverage\n');
|
|
87
|
+
writeFileSync(join(dir, 'a.txt'), 'hello');
|
|
88
|
+
mkdirSync(join(dir, 'dist'));
|
|
89
|
+
writeFileSync(join(dir, 'dist', 'bundle.js'), 'compiled');
|
|
90
|
+
mkdirSync(join(dir, '.next'));
|
|
91
|
+
writeFileSync(join(dir, '.next', 'build.json'), '{}');
|
|
92
|
+
mkdirSync(join(dir, 'coverage'));
|
|
93
|
+
writeFileSync(join(dir, 'coverage', 'lcov.info'), 'data');
|
|
94
|
+
const manifest = await generateManifest(dir);
|
|
95
|
+
const keys = Object.keys(manifest.files).sort();
|
|
96
|
+
expect(keys).toEqual(['.gitignore', 'a.txt']);
|
|
97
|
+
});
|
|
98
|
+
it('falls through to defaults when .gitignore patterns are too complex', async () => {
|
|
99
|
+
// The walker only honors simple directory/basename entries. Globbed
|
|
100
|
+
// patterns are skipped, so files matching them still appear in the
|
|
101
|
+
// manifest. This is acceptable: the goal is to avoid the runaway
|
|
102
|
+
// cases (huge gitignored caches), not to be a full gitignore matcher.
|
|
103
|
+
const dir = makeTempDir('complex-gitignore');
|
|
104
|
+
writeFileSync(join(dir, '.gitignore'), '*.local\nsrc/generated/\n');
|
|
105
|
+
writeFileSync(join(dir, 'a.txt'), 'hello');
|
|
106
|
+
writeFileSync(join(dir, 'config.local'), 'should-still-appear');
|
|
107
|
+
const manifest = await generateManifest(dir);
|
|
108
|
+
expect(manifest.files['a.txt']).toBeDefined();
|
|
109
|
+
expect(manifest.files['config.local']).toBeDefined();
|
|
110
|
+
});
|
|
70
111
|
it('handles files with spaces in names', async () => {
|
|
71
112
|
const dir = makeTempDir('spaces');
|
|
72
113
|
writeFileSync(join(dir, 'hello world.txt'), 'content');
|
|
@@ -374,4 +415,32 @@ describe('detectUserEdits() edge cases', () => {
|
|
|
374
415
|
const edits = await detectUserEdits(dir, manifest);
|
|
375
416
|
expect(edits).not.toContain('config.txt');
|
|
376
417
|
});
|
|
418
|
+
it('does not flag tracked file whose committed content differs from template manifest', async () => {
|
|
419
|
+
// Reproduces the post-`runwork clone` scenario: the manifest was
|
|
420
|
+
// generated from the pristine template, then the AI-customized
|
|
421
|
+
// version was overlaid via `git fetch` + `git checkout .` and is now
|
|
422
|
+
// committed at HEAD with no local edits. Method 3's scan would
|
|
423
|
+
// false-flag every such file on every clone if it didn't skip
|
|
424
|
+
// tracked files.
|
|
425
|
+
const dir = makeTempDir('clone-overlay');
|
|
426
|
+
initGitRepo(dir);
|
|
427
|
+
// Simulate: AI-customized version is committed at HEAD.
|
|
428
|
+
const overlaidContent = 'AI-customized content';
|
|
429
|
+
writeFileSync(join(dir, 'worker/agents.ts'.replace('/', '-')), 'placeholder');
|
|
430
|
+
const aiFilePath = 'agents.ts';
|
|
431
|
+
writeFileSync(join(dir, aiFilePath), overlaidContent);
|
|
432
|
+
execFileSync('git', ['add', '.'], { cwd: dir });
|
|
433
|
+
execFileSync('git', ['commit', '-m', 'overlay'], { cwd: dir });
|
|
434
|
+
// Manifest captured the PRISTINE template hash (different from the
|
|
435
|
+
// overlaid HEAD content). detectUserEdits must not treat this as a
|
|
436
|
+
// user edit.
|
|
437
|
+
const manifest = {
|
|
438
|
+
version: 1,
|
|
439
|
+
files: {
|
|
440
|
+
[aiFilePath]: 'sha256:pristine_template_hash_unrelated_to_disk',
|
|
441
|
+
},
|
|
442
|
+
};
|
|
443
|
+
const edits = await detectUserEdits(dir, manifest);
|
|
444
|
+
expect(edits).not.toContain(aiFilePath);
|
|
445
|
+
});
|
|
377
446
|
});
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import type { ApiClient } from '../api/client.js';
|
|
2
|
+
/**
|
|
3
|
+
* Basename-level ignore predicate using the static defaults only.
|
|
4
|
+
* Exposed for unit testing of the static defaults; the runtime watcher uses
|
|
5
|
+
* a richer matcher built from `.gitignore` (see watchAndAutoCommit).
|
|
6
|
+
*/
|
|
2
7
|
export declare function isIgnored(filePath: string): boolean;
|
|
3
8
|
export declare function watchAndAutoCommit(directory: string, client: ApiClient, appId: string, callbacks?: {
|
|
4
9
|
onFileChange?: (relPath: string, pendingCount: number) => void;
|
package/dist/git/auto-commit.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { execFileSync } from 'child_process';
|
|
2
2
|
import { readFileSync } from 'fs';
|
|
3
3
|
import { watch } from 'chokidar';
|
|
4
|
-
import {
|
|
4
|
+
import { join, relative } from 'path';
|
|
5
5
|
import { dim, cyan, yellow } from '../ui/colors.js';
|
|
6
|
+
import { buildIgnoreSets, defaultIgnoreSets, isPathIgnored } from '../utils/ignore-matcher.js';
|
|
6
7
|
let watcher = null;
|
|
7
8
|
let fastSyncTimer = null;
|
|
8
9
|
let gitTimer = null;
|
|
@@ -13,9 +14,6 @@ let activeCallbacks;
|
|
|
13
14
|
const pendingFastSync = new Map();
|
|
14
15
|
// Track files the user actually touched during this session (for git)
|
|
15
16
|
const changedFiles = new Set();
|
|
16
|
-
const SKIP_DIRS = new Set(['node_modules', '.git', '.runwork']);
|
|
17
|
-
const SKIP_FILES = new Set(['.dev.vars', '.env']);
|
|
18
|
-
const SKIP_EXTENSIONS = new Set(['.log']);
|
|
19
17
|
// Binary extensions to skip in fast sync (git handles them fine)
|
|
20
18
|
const BINARY_EXTENSIONS = new Set([
|
|
21
19
|
'.png', '.jpg', '.jpeg', '.gif', '.ico', '.webp', '.avif', '.svg',
|
|
@@ -24,21 +22,24 @@ const BINARY_EXTENSIONS = new Set([
|
|
|
24
22
|
'.zip', '.tar', '.gz', '.br',
|
|
25
23
|
'.pdf', '.wasm',
|
|
26
24
|
]);
|
|
25
|
+
const STATIC_IGNORE_SETS = defaultIgnoreSets();
|
|
26
|
+
/**
|
|
27
|
+
* Basename-level ignore predicate using the static defaults only.
|
|
28
|
+
* Exposed for unit testing of the static defaults; the runtime watcher uses
|
|
29
|
+
* a richer matcher built from `.gitignore` (see watchAndAutoCommit).
|
|
30
|
+
*/
|
|
27
31
|
export function isIgnored(filePath) {
|
|
28
|
-
|
|
29
|
-
if (SKIP_DIRS.has(name))
|
|
30
|
-
return true;
|
|
31
|
-
if (SKIP_FILES.has(name))
|
|
32
|
-
return true;
|
|
33
|
-
const ext = name.lastIndexOf('.') >= 0 ? name.slice(name.lastIndexOf('.')) : '';
|
|
34
|
-
if (SKIP_EXTENSIONS.has(ext))
|
|
35
|
-
return true;
|
|
36
|
-
return false;
|
|
32
|
+
return isPathIgnored(filePath, STATIC_IGNORE_SETS);
|
|
37
33
|
}
|
|
38
34
|
export async function watchAndAutoCommit(directory, client, appId, callbacks) {
|
|
39
35
|
activeCallbacks = callbacks;
|
|
36
|
+
// Build a project-scoped ignore matcher that includes simple patterns from
|
|
37
|
+
// the project's .gitignore in addition to the static defaults. Without
|
|
38
|
+
// this the watcher tries to descend into large gitignored caches such as
|
|
39
|
+
// `.bun-cache/` or `dist/` and bogs the CLI down on startup.
|
|
40
|
+
const ignoreSets = buildIgnoreSets(directory);
|
|
40
41
|
watcher = watch(directory, {
|
|
41
|
-
ignored:
|
|
42
|
+
ignored: (p) => isPathIgnored(p, ignoreSets),
|
|
42
43
|
persistent: true,
|
|
43
44
|
ignoreInitial: true,
|
|
44
45
|
awaitWriteFinish: {
|
|
@@ -2,22 +2,26 @@ import { createHash } from 'crypto';
|
|
|
2
2
|
import { execFileSync } from 'child_process';
|
|
3
3
|
import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from 'fs';
|
|
4
4
|
import { join, relative } from 'path';
|
|
5
|
-
|
|
6
|
-
const SKIP_FILES = new Set(['.dev.vars', '.env']);
|
|
5
|
+
import { buildIgnoreSets } from '../utils/ignore-matcher.js';
|
|
7
6
|
function sha256(data) {
|
|
8
7
|
return 'sha256:' + createHash('sha256').update(data).digest('hex');
|
|
9
8
|
}
|
|
10
|
-
function walkDir(dir, base) {
|
|
9
|
+
function walkDir(dir, base, sets) {
|
|
11
10
|
const results = [];
|
|
12
11
|
const entries = readdirSync(dir, { withFileTypes: true });
|
|
13
12
|
for (const entry of entries) {
|
|
14
|
-
if (
|
|
13
|
+
if (sets.dirs.has(entry.name))
|
|
15
14
|
continue;
|
|
16
|
-
if (
|
|
15
|
+
if (sets.files.has(entry.name))
|
|
17
16
|
continue;
|
|
17
|
+
if (entry.isFile()) {
|
|
18
|
+
const dotIndex = entry.name.lastIndexOf('.');
|
|
19
|
+
if (dotIndex >= 0 && sets.extensions.has(entry.name.slice(dotIndex)))
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
18
22
|
const fullPath = join(dir, entry.name);
|
|
19
23
|
if (entry.isDirectory()) {
|
|
20
|
-
results.push(...walkDir(fullPath, base));
|
|
24
|
+
results.push(...walkDir(fullPath, base, sets));
|
|
21
25
|
}
|
|
22
26
|
else if (entry.isFile()) {
|
|
23
27
|
results.push(relative(base, fullPath));
|
|
@@ -27,7 +31,8 @@ function walkDir(dir, base) {
|
|
|
27
31
|
}
|
|
28
32
|
export async function generateManifest(dir) {
|
|
29
33
|
const files = {};
|
|
30
|
-
const
|
|
34
|
+
const sets = buildIgnoreSets(dir);
|
|
35
|
+
const allFiles = walkDir(dir, dir, sets);
|
|
31
36
|
for (const relPath of allFiles) {
|
|
32
37
|
const content = readFileSync(join(dir, relPath));
|
|
33
38
|
files[relPath] = sha256(content);
|
|
@@ -104,9 +109,27 @@ export async function detectUserEdits(dir, manifest) {
|
|
|
104
109
|
// 3. Scan manifest files on disk directly — catches gitignored files that
|
|
105
110
|
// git ls-files --exclude-standard would miss. If a template file was added
|
|
106
111
|
// to .gitignore by the user and then modified, only this check detects it.
|
|
112
|
+
//
|
|
113
|
+
// Tracked files are excluded here because their state is fully described
|
|
114
|
+
// by Method 1 (`git diff HEAD`). After `runwork clone`, AI-customized
|
|
115
|
+
// versions of template files are committed at HEAD and would otherwise be
|
|
116
|
+
// false-flagged on every fresh clone.
|
|
117
|
+
const trackedFiles = new Set();
|
|
118
|
+
try {
|
|
119
|
+
const tracked = execFileSync('git', ['-c', 'core.quotePath=false', 'ls-files'], { cwd: dir, encoding: 'utf-8' }).trim();
|
|
120
|
+
for (const relPath of tracked.split('\n')) {
|
|
121
|
+
if (relPath)
|
|
122
|
+
trackedFiles.add(relPath);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// No git repo — every manifest file remains a candidate for Method 3.
|
|
127
|
+
}
|
|
107
128
|
for (const [relPath, expectedHash] of Object.entries(manifest.files)) {
|
|
108
129
|
if (edits.includes(relPath))
|
|
109
130
|
continue;
|
|
131
|
+
if (trackedFiles.has(relPath))
|
|
132
|
+
continue;
|
|
110
133
|
const filePath = join(dir, relPath);
|
|
111
134
|
try {
|
|
112
135
|
const content = readFileSync(filePath);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|