chore: support windows OS

This commit is contained in:
insleker 2026-04-09 11:26:35 +08:00
parent 1b0daff98a
commit b121a2544b
21 changed files with 368 additions and 95 deletions

View File

@ -77,7 +77,8 @@ flowchart
s2
s2
n3 --> s3
s2 -->|"poll through API to execute job"| s4
s2
s4
s4 -->|"report execute status through API"| s2
n1@{ label: "Rectangle" }
n1["runners dashboard"]
@ -90,4 +91,34 @@ flowchart
s1["workflow dashboard"]
end
s1 --> s2
n8["github polling"]
n9["github webhook + webhook-websocket gateway"]
s2 --> n8
n8 --> s4
s2 --> n9
n9 --> s4
```
## config option
* (issue) tracker
* github(`gh`)
* polling
* `gh webhook forward`
* webhook + smee.io(`gosmee`)
* gitlab(`glab`)
* webhook + smee.io
* gitea(`tea`)
* webhook + smee.io
* openproject(`openproject-cli`)
* polling
* linear(`linear`)
* SCM
* github
* gitlab
* gitea
* notifier
* desktop
* discord
* slack
* email

View File

@ -2,9 +2,9 @@ import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'app_environment.dart';
import 'config.dart';
import 'issue_tracker_client.dart';
import 'process_launcher.dart';
class CliResponderRunner {
Future<ResponderResult> run({
@ -27,12 +27,11 @@ class CliResponderRunner {
};
final stdinPayload = renderTemplate(responder.stdinTemplate, context);
final process = await Process.start(
final process = await startExternalCommand(
responder.command,
responder.args,
workingDirectory: workspacePath,
environment: <String, String>{
...AppEnvironment.variables,
...responder.env,
'CWS_PROJECT_KEY': project.key,
'CWS_REPO': project.repo,

View File

@ -647,13 +647,51 @@ String _expandHome(String path) {
return p.normalize(p.absolute(path));
}
final home = AppEnvironment.get('HOME');
final home = _resolveHomeDirectory();
if (home == null || home.isEmpty) {
throw const FileSystemException('HOME is not set.');
}
return p.normalize(p.join(home, path.substring(2)));
}
String? _resolveHomeDirectory() {
final home = _readEnvironmentValue('HOME');
if (home != null && home.isNotEmpty) {
return home;
}
final userProfile = _readEnvironmentValue('USERPROFILE');
if (userProfile != null && userProfile.isNotEmpty) {
return userProfile;
}
final homeDrive = _readEnvironmentValue('HOMEDRIVE');
final homePath = _readEnvironmentValue('HOMEPATH');
if (homeDrive != null &&
homeDrive.isNotEmpty &&
homePath != null &&
homePath.isNotEmpty) {
return '$homeDrive$homePath';
}
return null;
}
String? _readEnvironmentValue(String key) {
final direct = AppEnvironment.get(key);
if (direct != null && direct.isNotEmpty) {
return direct;
}
for (final entry in AppEnvironment.variables.entries) {
if (entry.key.toLowerCase() == key.toLowerCase() &&
entry.value.isNotEmpty) {
return entry.value;
}
}
return null;
}
String? _expandOptionalPath(String? path) {
if (path == null || path.isEmpty) {
return null;

View File

@ -7,6 +7,7 @@ import 'package:github/github.dart';
import '../app_logger.dart';
import '../config.dart';
import '../issue_tracker_client.dart';
import '../process_launcher.dart';
import 'base.dart';
class GiteaGosmeeIssueEventSource implements IssueEventSource {
@ -107,7 +108,7 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource {
'gitea/gosmee project=${project.key} start_client '
'command="$gosmeeCommand client $publicUrl $localUrl"',
);
final process = await Process.start(gosmeeCommand, [
final process = await startExternalCommand(gosmeeCommand, [
'client',
publicUrl,
localUrl,
@ -284,7 +285,7 @@ class GiteaGosmeeIssueEventSource implements IssueEventSource {
}
Future<String> _createGosmeeChannelUrl(String localUrl) async {
final result = await Process.run(gosmeeCommand, [
final result = await runExternalCommand(gosmeeCommand, [
'--output',
'json',
'client',

View File

@ -7,6 +7,7 @@ import 'package:github/github.dart';
import '../app_logger.dart';
import '../config.dart';
import '../issue_tracker_client.dart';
import '../process_launcher.dart';
import 'base.dart';
class GitHubGosmeeIssueEventSource implements IssueEventSource {
@ -107,7 +108,7 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource {
'github/gosmee project=${project.key} start_client '
'command="$gosmeeCommand client $publicUrl $localUrl"',
);
final process = await Process.start(gosmeeCommand, [
final process = await startExternalCommand(gosmeeCommand, [
'client',
publicUrl,
localUrl,
@ -284,7 +285,7 @@ class GitHubGosmeeIssueEventSource implements IssueEventSource {
}
Future<String> _createGosmeeChannelUrl(String localUrl) async {
final result = await Process.run(gosmeeCommand, [
final result = await runExternalCommand(gosmeeCommand, [
'--output',
'json',
'client',

View File

@ -5,6 +5,7 @@ import 'dart:io';
import '../app_logger.dart';
import '../config.dart';
import '../issue_tracker_client.dart';
import '../process_launcher.dart';
import 'base.dart';
class GitHubWebhookForwardIssueEventSource implements IssueEventSource {
@ -80,7 +81,7 @@ class GitHubWebhookForwardIssueEventSource implements IssueEventSource {
'github/webhook-forward project=${project.key} start_forwarder '
'command="${issueTrackerClient.ghCommand} webhook forward --events=issues,issue_comment --repo=${project.repo} --url=$url"',
);
final process = await Process.start(issueTrackerClient.ghCommand, [
final process = await startExternalCommand(issueTrackerClient.ghCommand, [
'webhook',
'forward',
'--events=issues,issue_comment',

View File

@ -3,6 +3,8 @@ import 'dart:io';
import 'package:github/github.dart';
import 'process_launcher.dart';
import 'config_schema.dart';
class IssueTrackerClient {
@ -319,7 +321,7 @@ class IssueTrackerClient {
}
Future<Object?> _runJson(String command, List<String> arguments) async {
final result = await Process.run(command, arguments);
final result = await runExternalCommand(command, arguments);
if (result.exitCode != 0) {
throw ProcessException(
command,

View File

@ -0,0 +1,118 @@
import 'dart:io';
import 'package:path/path.dart' as p;
class _ResolvedCommand {
_ResolvedCommand({
required this.executable,
required this.arguments,
required this.usesBashShim,
});
final String executable;
final List<String> arguments;
final bool usesBashShim;
}
_ResolvedCommand _resolveCommand(String executable, List<String> arguments) {
final extension = p.extension(executable).toLowerCase();
final shouldUseBash = extension.isEmpty || extension == '.sh';
if (Platform.isWindows && shouldUseBash && File(executable).existsSync()) {
final bashScriptPath = _toBashPath(executable);
return _ResolvedCommand(
executable: 'wsl.exe',
arguments: ['--exec', 'bash', bashScriptPath, ...arguments],
usesBashShim: true,
);
}
return _ResolvedCommand(
executable: executable,
arguments: arguments,
usesBashShim: false,
);
}
String _toBashPath(String windowsPath) {
final normalized = windowsPath.replaceAll('\\', '/');
final drivePrefixMatch = RegExp(r'^([A-Za-z]):/(.*)$').firstMatch(normalized);
if (drivePrefixMatch == null) {
return normalized;
}
final driveLetter = drivePrefixMatch.group(1)!.toLowerCase();
final rest = drivePrefixMatch.group(2)!;
return '/mnt/$driveLetter/$rest';
}
Future<ProcessResult> runExternalCommand(
String executable,
List<String> arguments, {
String? workingDirectory,
Map<String, String>? environment,
bool includeParentEnvironment = true,
}) {
final resolved = _resolveCommand(executable, arguments);
final effectiveEnvironment = _effectiveEnvironmentForResolvedCommand(
environment: environment,
includeParentEnvironment: includeParentEnvironment,
usesBashShim: resolved.usesBashShim,
);
return Process.run(
resolved.executable,
resolved.arguments,
workingDirectory: workingDirectory,
environment: effectiveEnvironment,
includeParentEnvironment: includeParentEnvironment,
);
}
Future<Process> startExternalCommand(
String executable,
List<String> arguments, {
String? workingDirectory,
Map<String, String>? environment,
bool includeParentEnvironment = true,
bool runInShell = false,
ProcessStartMode mode = ProcessStartMode.normal,
}) {
final resolved = _resolveCommand(executable, arguments);
final effectiveEnvironment = _effectiveEnvironmentForResolvedCommand(
environment: environment,
includeParentEnvironment: includeParentEnvironment,
usesBashShim: resolved.usesBashShim,
);
return Process.start(
resolved.executable,
resolved.arguments,
workingDirectory: workingDirectory,
environment: effectiveEnvironment,
includeParentEnvironment: includeParentEnvironment,
runInShell: runInShell,
mode: mode,
);
}
Map<String, String>? _effectiveEnvironmentForResolvedCommand({
required Map<String, String>? environment,
required bool includeParentEnvironment,
required bool usesBashShim,
}) {
if (!usesBashShim || environment == null || environment.isEmpty) {
return environment;
}
final effectiveEnvironment = Map<String, String>.from(environment);
final existingWslenvFromProcess = includeParentEnvironment
? Platform.environment['WSLENV']
: null;
final existingWslenv =
effectiveEnvironment['WSLENV'] ?? existingWslenvFromProcess;
final forwardedKeys = <String>{
...?existingWslenv?.split(':').where((entry) => entry.isNotEmpty),
...effectiveEnvironment.keys.where((key) => key != 'WSLENV'),
};
effectiveEnvironment['WSLENV'] = forwardedKeys.join(':');
return effectiveEnvironment;
}

View File

@ -4,7 +4,7 @@ version: 1.0.0
# repository: https://github.com/my_org/my_repo
environment:
sdk: ^3.11.4
sdk: ^3.11.3
dependencies:
args: ^2.7.0
@ -30,3 +30,11 @@ dev_dependencies:
json_serializable: ">=6.11.1 <6.12.0"
lints: ^6.0.0
test: ^1.25.6
platforms:
# android:
# ios:
linux:
macos:
# web:
windows:

View File

@ -194,77 +194,77 @@
"dart-api-design": {
"source": "dart-lang/skills",
"sourceType": "github",
"computedHash": "d60beeedc91e1be7c51d7fea38acb636c0519b8140159402afcf69f31846cfba"
"computedHash": "00f2a541ca05387967b6e44afbce8b3e235a45225e06a376ec98e91116b76c09"
},
"dart-async-programming": {
"source": "dart-lang/skills",
"sourceType": "github",
"computedHash": "ebff987d2ce82a0c0eebab1b7ebc724d29bb925c56273ee55775550d634a3c72"
"computedHash": "52f989f2cb1121b8c5122d4cf6c9dbe7283523e7144e67e1f86640d33a82ce28"
},
"dart-code-generation": {
"source": "dart-lang/skills",
"sourceType": "github",
"computedHash": "259e371481a822984e666cc5214c97ac1581a0adbbdb4d7cab3997c9b1de2009"
"computedHash": "815a84815bbe6681129a6d883278fa6d251d3f4e5a6509e8e1ca74cab4422279"
},
"dart-compilation-deployment": {
"source": "dart-lang/skills",
"sourceType": "github",
"computedHash": "862ea7fd5efb9bd9760d9eab58880a1f08bb9b64125f171b778ba6918f79980f"
"computedHash": "b327d665fd5514cd0e1a193c8c51745405d7476a5b8419c5d9228324b6afa7d9"
},
"dart-concurrency-isolates": {
"source": "dart-lang/skills",
"sourceType": "github",
"computedHash": "829a819bb37342813eca318e0008d93db83aa313daa58be1b9a508d5b1674a9f"
"computedHash": "a3e7ff69a58c462d3ea94d7092d1d03e21eeb43bdbb21174400a111da4a96721"
},
"dart-documentation": {
"source": "dart-lang/skills",
"sourceType": "github",
"computedHash": "0016010518730eb2aeaabbf5046667deb724040be855a5892db4a9735608ca75"
"computedHash": "5eb2845edf402683555208ede425b217484fc1fe21f63131fefe9b249f97697a"
},
"dart-effective-style": {
"source": "dart-lang/skills",
"sourceType": "github",
"computedHash": "ceaa8cbe82090bf759d763cdc83aa80df79ac2461d0d5f223f63e72f60901ae4"
"computedHash": "3b11a0ad963151c12bbf0302cd526dcfdfa6659e194455f168d8441fae60e895"
},
"dart-idiomatic-usage": {
"source": "dart-lang/skills",
"sourceType": "github",
"computedHash": "ac383cc321ce8eeac6412eecc466e68adfcb131fd98c5b74af9d4b33c64381bf"
"computedHash": "c45023b434e63316dd209afc094f7412fd99683869b917f140918d0c065406c8"
},
"dart-language-syntax": {
"source": "dart-lang/skills",
"sourceType": "github",
"computedHash": "d1b1f4027530bb736247f62135845c06f69148cd3464fc235f942324499aee54"
"computedHash": "457294ca1c8e21c0ceb643ded8dfa1a2df1a542bea5825c95d331b99d119bd25"
},
"dart-migration-versioning": {
"source": "dart-lang/skills",
"sourceType": "github",
"computedHash": "c3fefb316b55d7794a49bc226cee17a2fafc00d75621e23aee1dd3f8c15def3a"
"computedHash": "f94fb07b64c2a4f1922504bf4de531de927e076a74b3cbd291a75763c3a7d6cb"
},
"dart-native-interop-ffi": {
"source": "dart-lang/skills",
"sourceType": "github",
"computedHash": "cac814c48f43b964471b41e99622b471203bb2900845bcc03eca02b2a3162ed5"
"computedHash": "0e29e6b5e8cb803d7644c6f13340cc1e3265d23f068be4da0f0781954737b3c6"
},
"dart-package-management": {
"source": "dart-lang/skills",
"sourceType": "github",
"computedHash": "a1fbb5422c3d9bf980a310b55f8cb938b969234eda60a27038f65833e5d0e805"
"computedHash": "569d2a0bcb4b61a15fb8e0f3406d9c54ddc9e1caf44c92302047b91730bd9320"
},
"dart-static-analysis": {
"source": "dart-lang/skills",
"sourceType": "github",
"computedHash": "f13d7c751ed2d026da741fe7b2535fb11a8d045c48ee91b48c0bf7b8147a3758"
"computedHash": "6258439460032ed8204d18abb0f09112ad7969d2497221693b302ce800f12106"
},
"dart-testing": {
"source": "dart-lang/skills",
"sourceType": "github",
"computedHash": "c6857b34f27d704eadbb770e40512693e18e04bab7345c23818386a6996dea6a"
"computedHash": "2d8a5d92cfa90dd5ad13f8f38606bdcb6edec8fc344fde02f51475a29f05d87a"
},
"dart-web-development": {
"source": "dart-lang/skills",
"sourceType": "github",
"computedHash": "2adc4b77a7d30a2c9c52fc96368df10a8242512c95c8bf2b3fc06abf208dc421"
"computedHash": "db064015bed95259c6f60ba6eb5123a97d6341e68a0925a60a3d57a31bf71d6c"
},
"datanalysis-credit-risk": {
"source": "github/awesome-copilot",
@ -839,7 +839,7 @@
"supabase-postgres-best-practices": {
"source": "supabase/agent-skills",
"sourceType": "github",
"computedHash": "9c87c315aed143ee3b34bec8117100f5035e0df09e6b23e1ecc772cff434c9ad"
"computedHash": "f4cd7570115d97af6bcb2946eb891a958c8d04a43b479871c9ccadbc670ad058"
},
"technology-stack-blueprint-generator": {
"source": "github/awesome-copilot",
@ -944,7 +944,7 @@
"worktrunk": {
"source": "max-sixty/worktrunk",
"sourceType": "github",
"computedHash": "12ed42480bc641bb017955e0884716ccf9d04389e744edad6dfcac4b01c60cca"
"computedHash": "d480468e16c7de39a121e6e760788357bb178495f8912b05f05f55977468d72e"
},
"write-coding-standards-from-file": {
"source": "github/awesome-copilot",

View File

@ -5,6 +5,40 @@ import 'package:drift/drift.dart' show driftRuntimeOptions;
import 'package:path/path.dart' as p;
import 'package:test/test.dart';
String _resolveTestHomeDirectory() {
String? getEnv(String key) {
final direct = Platform.environment[key];
if (direct != null && direct.isNotEmpty) {
return direct;
}
for (final entry in Platform.environment.entries) {
if (entry.key.toLowerCase() == key.toLowerCase() &&
entry.value.isNotEmpty) {
return entry.value;
}
}
return null;
}
final home = getEnv('HOME');
if (home != null) {
return home;
}
final userProfile = getEnv('USERPROFILE');
if (userProfile != null) {
return userProfile;
}
final homeDrive = getEnv('HOMEDRIVE');
final homePath = getEnv('HOMEPATH');
if (homeDrive != null && homePath != null) {
return '$homeDrive$homePath';
}
throw StateError('No home directory environment variables are set.');
}
void main() {
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
@ -358,16 +392,11 @@ projects:
// When loading the app config from disk.
final config = await AppConfig.load(configFile.path);
final home = _resolveTestHomeDirectory();
// Then the app exposes the configured data and worktree directories.
expect(
config.dataDir,
p.join(Platform.environment['HOME']!, '.agent-orchestrator'),
);
expect(
config.worktreeDir,
p.join(Platform.environment['HOME']!, '.worktrees'),
);
expect(config.dataDir, p.join(home, '.agent-orchestrator'));
expect(config.worktreeDir, p.join(home, '.worktrees'));
// And each project keeps the configured camelCase settings.
expect(config.projects['sample'], isNotNull);
@ -809,24 +838,19 @@ projects:
// When writing the generated starter config to disk.
await configFile.writeAsString(contents);
final config = await AppConfig.load(configFile.path);
final home = _resolveTestHomeDirectory();
// Then the file parses through the normal app config loader.
expect(config.projects['sample'], isNotNull);
// And the required starter values stay available to users.
expect(
config.dataDir,
p.join(Platform.environment['HOME']!, '.agent-orchestrator'),
);
expect(config.dataDir, p.join(home, '.agent-orchestrator'));
expect(config.projects['sample']!.provider, IssueTrackerProvider.github);
expect(
config.worktreeDir,
p.join(Platform.environment['HOME']!, '.worktrees'),
);
expect(config.worktreeDir, p.join(home, '.worktrees'));
expect(config.projects['sample']!.repo, 'owner/repo');
expect(
config.projects['sample']!.path,
p.join(Platform.environment['HOME']!, 'path/to/repo'),
p.join(home, 'path', 'to', 'repo'),
);
expect(config.projects['sample']!.defaultBranch, 'main');
expect(config.projects['sample']!.issueAssistant.mentionTriggers, [

View File

@ -76,7 +76,7 @@ void registerIssueAssistantAppRunGosmeeTests() {
final postedBody = File(p.join(sandbox.path, 'posted-body.md'));
await teaScript.writeAsString('''#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "\$*" >> "${teaLog.path}"
printf '%s\n' "\$*" >> "${bashScriptPath(teaLog.path)}"
if [[ "\$1" == "api" && "\$2" == "user" ]]; then
cat <<'JSON'
@ -102,7 +102,7 @@ fi
if [[ "\$1" == "api" && "\$4" == "repos/owner/sample/issues/12/comments" ]]; then
for arg in "\$@"; do
if [[ "\$arg" == body=* ]]; then
printf '%s' "\${arg#body=}" > "${postedBody.path}"
printf '%s' "\${arg#body=}" > "${bashScriptPath(postedBody.path)}"
fi
done
cat <<'JSON'
@ -125,7 +125,7 @@ fi
echo "unexpected tea args: \$*" >&2
exit 1
''');
await Process.run('chmod', ['+x', teaScript.path]);
await makeScriptExecutable(teaScript);
// And a responder that emits a planning reply.
final responderScript = File(p.join(sandbox.path, 'responder.sh'));
@ -226,7 +226,7 @@ projects:
final gosmeeLog = File(p.join(sandbox.path, 'gosmee-log.jsonl'));
await gosmeeScript.writeAsString('''#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "\$*" >> "${gosmeeLog.path}"
printf '%s\n' "\$*" >> "${bashScriptPath(gosmeeLog.path)}"
if [[ "\$1" == "--output" && "\$2" == "json" && "\$3" == "client" && "\$4" == "--new-url" ]]; then
printf '%s\n' 'https://gosmee.example.test/reconcile'
@ -242,7 +242,7 @@ fi
echo "unexpected gosmee args: \$*" >&2
exit 1
''');
await Process.run('chmod', ['+x', gosmeeScript.path]);
await makeScriptExecutable(gosmeeScript);
// And a tea script that returns a new issue only on a later reconciliation poll.
final teaScript = File(p.join(sandbox.path, 'tea'));
@ -272,7 +272,7 @@ exit 1
];
await teaScript.writeAsString('''#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "\$*" >> "${teaLog.path}"
printf '%s\n' "\$*" >> "${bashScriptPath(teaLog.path)}"
if [[ "\$1" == "api" && "\$2" == "user" ]]; then
cat <<'JSON'
@ -283,11 +283,11 @@ fi
if [[ "\$1" == "api" && "\$4" == repos/owner/sample/issues\\?state=open* ]]; then
count=0
if [[ -f "${issueListCount.path}" ]]; then
count="\$(cat "${issueListCount.path}")"
if [[ -f "${bashScriptPath(issueListCount.path)}" ]]; then
count="\$(cat "${bashScriptPath(issueListCount.path)}")"
fi
count=\$((count + 1))
printf '%s' "\$count" > "${issueListCount.path}"
printf '%s' "\$count" > "${bashScriptPath(issueListCount.path)}"
if [[ "\$count" -eq 1 ]]; then
cat <<'JSON'
[]
@ -310,7 +310,7 @@ fi
if [[ "\$1" == "api" && "\$4" == "repos/owner/sample/issues/18/comments" ]]; then
for arg in "\$@"; do
if [[ "\$arg" == body=* ]]; then
printf '%s' "\${arg#body=}" > "${postedBody.path}"
printf '%s' "\${arg#body=}" > "${bashScriptPath(postedBody.path)}"
fi
done
cat <<'JSON'
@ -333,7 +333,7 @@ fi
echo "unexpected tea args: \$*" >&2
exit 1
''');
await Process.run('chmod', ['+x', teaScript.path]);
await makeScriptExecutable(teaScript);
// And a responder that emits a planning reply.
final responderScript = File(p.join(sandbox.path, 'responder.sh'));

View File

@ -178,7 +178,7 @@ fi
echo "unexpected gh args: \$*" >&2
exit 1
''');
await Process.run('chmod', ['+x', ghScript.path]);
await makeScriptExecutable(ghScript);
// And an orchestrator-style config that uses gh/webhook-forward.
final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml'));
@ -284,7 +284,7 @@ projects:
];
await ghScript.writeAsString('''#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "\$*" >> "${ghLog.path}"
printf '%s\n' "\$*" >> "${bashScriptPath(ghLog.path)}"
if [[ "\$1" == "api" && "\$2" == "user" ]]; then
cat <<'JSON'
@ -295,11 +295,11 @@ fi
if [[ "\$1" == "api" && "\$4" == "search/issues" ]]; then
count=0
if [[ -f "${searchCountFile.path}" ]]; then
count="\$(cat "${searchCountFile.path}")"
if [[ -f "${bashScriptPath(searchCountFile.path)}" ]]; then
count="\$(cat "${bashScriptPath(searchCountFile.path)}")"
fi
count=\$((count + 1))
printf '%s' "\$count" > "${searchCountFile.path}"
printf '%s' "\$count" > "${bashScriptPath(searchCountFile.path)}"
if [[ "\$count" -eq 1 ]]; then
cat <<'JSON'
{"total_count":0,"incomplete_results":false,"items":[]}
@ -326,7 +326,7 @@ fi
if [[ "\$1" == "api" && "\$4" == "repos/owner/sample/issues/18/comments" ]]; then
for arg in "\$@"; do
if [[ "\$arg" == body=* ]]; then
printf '%s' "\${arg#body=}" > "${postedBody.path}"
printf '%s' "\${arg#body=}" > "${bashScriptPath(postedBody.path)}"
fi
done
cat <<'JSON'
@ -344,7 +344,7 @@ fi
echo "unexpected gh args: \$*" >&2
exit 1
''');
await Process.run('chmod', ['+x', ghScript.path]);
await makeScriptExecutable(ghScript);
// And a responder that emits a planning reply.
final responderScript = File(p.join(sandbox.path, 'responder.sh'));

View File

@ -4,6 +4,54 @@ import 'dart:io';
import 'package:git/git.dart';
import 'package:path/path.dart' as p;
Future<void> makeScriptExecutable(File script) async {
if (Platform.isWindows) {
return;
}
final result = await Process.run('chmod', ['+x', script.path]);
if (result.exitCode != 0) {
throw ProcessException(
'chmod',
['+x', script.path],
'${result.stdout}\n${result.stderr}',
result.exitCode,
);
}
}
String bashScriptPath(String hostPath) {
if (!Platform.isWindows) {
return hostPath;
}
final normalized = hostPath.replaceAll('\\', '/');
final drivePrefixMatch = RegExp(r'^([A-Za-z]):/(.*)$').firstMatch(normalized);
if (drivePrefixMatch == null) {
return normalized;
}
final driveLetter = drivePrefixMatch.group(1)!.toLowerCase();
final rest = drivePrefixMatch.group(2)!;
return '/mnt/$driveLetter/$rest';
}
String hostPathFromScript(String scriptPath) {
if (!Platform.isWindows) {
return scriptPath;
}
final trimmed = scriptPath.trim();
final mntPathMatch = RegExp(r'^/mnt/([a-zA-Z])/(.*)$').firstMatch(trimmed);
if (mntPathMatch == null) {
return trimmed;
}
final driveLetter = mntPathMatch.group(1)!.toUpperCase();
final rest = mntPathMatch.group(2)!.replaceAll('/', '\\');
return '$driveLetter:\\$rest';
}
Future<void> writeFakeGhScript({
required File ghScript,
required List<Map<String, Object?>> issueListResponse,
@ -71,7 +119,7 @@ Future<void> writeFakeGhScript({
if (ghLog != null) {
buffer.writeln(
r'''printf '%s\n' "$*" >> '''
'"${ghLog.path}"',
'"${bashScriptPath(ghLog.path)}"',
);
}
@ -154,7 +202,7 @@ Future<void> writeFakeGhScript({
..writeln(r''' if [[ "$arg" == body=* ]]; then''')
..writeln(
r''' printf '%s' "${arg#body=}" > '''
'"${postedBodyFile.path}"',
'"${bashScriptPath(postedBodyFile.path)}"',
)
..writeln(r''' fi''');
}
@ -179,7 +227,7 @@ Future<void> writeFakeGhScript({
..writeln('exit 1');
await ghScript.writeAsString(buffer.toString());
await Process.run('chmod', ['+x', ghScript.path]);
await makeScriptExecutable(ghScript);
}
Future<void> writeFlakySearchGhScript({
@ -210,7 +258,7 @@ Future<void> writeFlakySearchGhScript({
if (ghLog != null) {
buffer.writeln(
r'''printf '%s\n' "$*" >> '''
'"${ghLog.path}"',
'"${bashScriptPath(ghLog.path)}"',
);
}
@ -224,8 +272,10 @@ Future<void> writeFlakySearchGhScript({
buffer
..writeln(r'''if [[ "$1" == "api" && "$4" == "search/issues" ]]; then''')
..writeln(' if [[ ! -f "${failureStateFile.path}" ]]; then')
..writeln(' touch "${failureStateFile.path}"')
..writeln(
' if [[ ! -f "${bashScriptPath(failureStateFile.path)}" ]]; then',
)
..writeln(' touch "${bashScriptPath(failureStateFile.path)}"')
..writeln(r''' echo "error connecting to api.github.com" >&2''')
..writeln(
r''' echo "check your internet connection or https://githubstatus.com" >&2''',
@ -256,7 +306,7 @@ Future<void> writeFlakySearchGhScript({
..writeln('exit 1');
await ghScript.writeAsString(buffer.toString());
await Process.run('chmod', ['+x', ghScript.path]);
await makeScriptExecutable(ghScript);
}
Future<void> writeFakeTeaScript({
@ -287,7 +337,7 @@ Future<void> writeFakeTeaScript({
if (teaLog != null) {
buffer.writeln(
r'''printf '%s\n' "$*" >> '''
'"${teaLog.path}"',
'"${bashScriptPath(teaLog.path)}"',
);
}
@ -352,7 +402,7 @@ Future<void> writeFakeTeaScript({
..writeln(r''' if [[ "$arg" == body=* ]]; then''')
..writeln(
r''' printf '%s' "${arg#body=}" > '''
'"${postedBodyFile.path}"',
'"${bashScriptPath(postedBodyFile.path)}"',
)
..writeln(r''' fi''');
}
@ -377,7 +427,7 @@ Future<void> writeFakeTeaScript({
..writeln('exit 1');
await teaScript.writeAsString(buffer.toString());
await Process.run('chmod', ['+x', teaScript.path]);
await makeScriptExecutable(teaScript);
}
Future<void> writeResponderScript(
@ -395,7 +445,7 @@ ${jsonEncode(response)}
JSON
exit $exitCode
''');
await Process.run('chmod', ['+x', responderScript.path]);
await makeScriptExecutable(responderScript);
}
Future<void> writeJsonlResponderScript(
@ -412,7 +462,7 @@ cat <<'JSON'
{"type":"turn.completed"}
JSON
''');
await Process.run('chmod', ['+x', responderScript.path]);
await makeScriptExecutable(responderScript);
}
Future<void> writeDelayedLoggingResponderScript(
@ -429,15 +479,15 @@ import time
print(int(time.time() * 1000))
PY
}
printf 'start:%s:%s\n' "\$CWS_ISSUE_NUMBER" "\$(timestamp_ms)" >> "${eventLog.path}"
printf 'start:%s:%s\n' "\$CWS_ISSUE_NUMBER" "\$(timestamp_ms)" >> "${bashScriptPath(eventLog.path)}"
cat >/dev/null
sleep ${delay.inSeconds}
printf 'end:%s:%s\n' "\$CWS_ISSUE_NUMBER" "\$(timestamp_ms)" >> "${eventLog.path}"
printf 'end:%s:%s\n' "\$CWS_ISSUE_NUMBER" "\$(timestamp_ms)" >> "${bashScriptPath(eventLog.path)}"
cat <<'JSON'
${jsonEncode(response)}
JSON
''');
await Process.run('chmod', ['+x', responderScript.path]);
await makeScriptExecutable(responderScript);
}
Future<void> writeWebhookForwardGhScript({
@ -478,7 +528,7 @@ Future<void> writeWebhookForwardGhScript({
if (ghLog != null) {
buffer.writeln(
r'''printf '%s\n' "$*" >> '''
'"${ghLog.path}"',
'"${bashScriptPath(ghLog.path)}"',
);
}
@ -521,7 +571,7 @@ Future<void> writeWebhookForwardGhScript({
..writeln(r''' if [[ "$arg" == body=* ]]; then''')
..writeln(
r''' printf '%s' "${arg#body=}" > '''
'"${postedBodyFile.path}"',
'"${bashScriptPath(postedBodyFile.path)}"',
)
..writeln(r''' fi''');
}
@ -567,7 +617,7 @@ Future<void> writeWebhookForwardGhScript({
..writeln('exit 1');
await ghScript.writeAsString(buffer.toString());
await Process.run('chmod', ['+x', ghScript.path]);
await makeScriptExecutable(ghScript);
}
Future<void> writeGitHubGosmeeGhScript({
@ -601,7 +651,7 @@ Future<void> writeGitHubGosmeeGhScript({
if (ghLog != null) {
buffer.writeln(
r'''printf '%s\n' "$*" >> '''
'"${ghLog.path}"',
'"${bashScriptPath(ghLog.path)}"',
);
}
@ -668,7 +718,7 @@ Future<void> writeGitHubGosmeeGhScript({
..writeln(r''' if [[ "$arg" == body=* ]]; then''')
..writeln(
r''' printf '%s' "${arg#body=}" > '''
'"${postedBodyFile.path}"',
'"${bashScriptPath(postedBodyFile.path)}"',
)
..writeln(r''' fi''');
}
@ -698,7 +748,7 @@ Future<void> writeGitHubGosmeeGhScript({
..writeln('exit 1');
await ghScript.writeAsString(buffer.toString());
await Process.run('chmod', ['+x', ghScript.path]);
await makeScriptExecutable(ghScript);
}
Future<void> writeGosmeeForwardScript({
@ -714,7 +764,7 @@ Future<void> writeGosmeeForwardScript({
if (gosmeeLog != null) {
buffer.writeln(
r'''printf '%s\n' "$*" >> '''
'"${gosmeeLog.path}"',
'"${bashScriptPath(gosmeeLog.path)}"',
);
}
@ -757,7 +807,7 @@ Future<void> writeGosmeeForwardScript({
..writeln('exit 1');
await gosmeeScript.writeAsString(buffer.toString());
await Process.run('chmod', ['+x', gosmeeScript.path]);
await makeScriptExecutable(gosmeeScript);
}
Future<void> initGitRepo(Directory directory) async {

View File

@ -78,13 +78,13 @@ void registerIssueAssistantAppRunOnceWorktreeTests() {
);
await responderScript.writeAsString('''#!/usr/bin/env bash
set -euo pipefail
pwd > "${cwdCapture.path}"
cat > "${stdinCapture.path}"
pwd > "${bashScriptPath(cwdCapture.path)}"
cat > "${bashScriptPath(stdinCapture.path)}"
cat <<'JSON'
{"decision":"no_reply","mode":"planning","summary":"captured mention context"}
JSON
''');
await Process.run('chmod', ['+x', responderScript.path]);
await makeScriptExecutable(responderScript);
// And an orchestrator-style config pointing to the fake repo and responder.
final configFile = File(
@ -115,7 +115,7 @@ projects:
await app.close();
// Then the responder runs from an ephemeral worktree instead of the source checkout.
final capturedCwd = await cwdCapture.readAsString();
final capturedCwd = hostPathFromScript(await cwdCapture.readAsString());
expect(
p.normalize(capturedCwd.trim()),
isNot(p.normalize(repoDir.path)),
@ -201,13 +201,13 @@ projects:
);
await responderScript.writeAsString('''#!/usr/bin/env bash
set -euo pipefail
pwd > "${cwdCapture.path}"
pwd > "${bashScriptPath(cwdCapture.path)}"
cat >/dev/null
cat <<'JSON'
{"decision":"reply","mode":"planning","markdown":"planning reply","summary":"used project path"}
JSON
''');
await Process.run('chmod', ['+x', responderScript.path]);
await makeScriptExecutable(responderScript);
// And an orchestrator-style config pointing to the fake repo and responder.
final configFile = File(p.join(sandbox.path, 'agent-orchestrator.yaml'));
@ -237,7 +237,7 @@ projects:
await app.close();
// Then the responder runs from an ephemeral worktree instead of the source checkout.
final capturedCwd = await cwdCapture.readAsString();
final capturedCwd = hostPathFromScript(await cwdCapture.readAsString());
expect(p.normalize(capturedCwd.trim()), isNot(p.normalize(repoDir.path)));
expect(
p.isWithin(worktreeRoot.path, p.normalize(capturedCwd.trim())),