@ghl-ai/aw 0.1.37-beta.8 → 0.1.37
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/cli.mjs +12 -2
- package/commands/init.mjs +27 -7
- package/commands/link-project.mjs +3 -0
- package/commands/nuke.mjs +19 -14
- package/commands/pull.mjs +74 -32
- package/commands/push-rules.mjs +212 -0
- package/commands/push.mjs +27 -3
- package/commands/slack-sim.mjs +128 -0
- package/config.mjs +1 -1
- package/constants.mjs +8 -1
- package/ecc.mjs +80 -4
- package/file-tree.mjs +76 -0
- package/fmt.mjs +14 -0
- package/git.mjs +2 -1
- package/hooks.mjs +101 -0
- package/integrate.mjs +49 -11
- package/package.json +10 -4
- package/render-rules.mjs +483 -0
- package/slack-sim/fake-slack.mjs +200 -0
- package/slack-sim/http.mjs +170 -0
- package/slack-sim/in-process.mjs +263 -0
- package/slack-sim/render.mjs +42 -0
- package/slack-sim/scenario.mjs +64 -0
- package/slack-sim/scenarios/checkpoint-approve.json +21 -0
- package/slack-sim/scenarios/image-thread.json +27 -0
- package/slack-sim/scenarios/implementation-basic.json +18 -0
- package/slack-sim/scenarios/poll-webhook-race.json +18 -0
- package/slack-sim/scenarios/review-pr.json +14 -0
- package/telemetry.mjs +5 -3
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { existsSync, mkdtempSync, statSync } from 'node:fs';
|
|
2
|
+
import { execFileSync, execSync } from 'node:child_process';
|
|
3
|
+
import { join, normalize, relative } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
|
|
6
|
+
import * as fmt from '../fmt.mjs';
|
|
7
|
+
import { chalk } from '../fmt.mjs';
|
|
8
|
+
import { REGISTRY_BASE_BRANCH, REGISTRY_REPO, RULES_SOURCE_DIR, AW_CO_AUTHOR } from '../constants.mjs';
|
|
9
|
+
import { syncFileTree } from '../file-tree.mjs';
|
|
10
|
+
|
|
11
|
+
function normalizeSlashes(path) {
|
|
12
|
+
return path.replace(/\\/g, '/');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isRulesPushInput(input) {
|
|
16
|
+
if (!input) return false;
|
|
17
|
+
const normalized = normalizeSlashes(input).replace(/\/+$/, '');
|
|
18
|
+
return normalized === RULES_SOURCE_DIR
|
|
19
|
+
|| normalized.startsWith(`${RULES_SOURCE_DIR}/`)
|
|
20
|
+
|| normalized === `.aw_registry/${RULES_SOURCE_DIR}`
|
|
21
|
+
|| normalized.startsWith(`.aw_registry/${RULES_SOURCE_DIR}/`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function resolveRulesPushSource(input, cwd = process.cwd()) {
|
|
25
|
+
const localRulesRoot = join(cwd, RULES_SOURCE_DIR);
|
|
26
|
+
const syncedRulesRoot = join(cwd, '.aw_registry', RULES_SOURCE_DIR);
|
|
27
|
+
|
|
28
|
+
if (!input) {
|
|
29
|
+
if (existsSync(localRulesRoot)) return { sourceRoot: localRulesRoot, sourceType: 'local' };
|
|
30
|
+
if (existsSync(syncedRulesRoot)) return { sourceRoot: syncedRulesRoot, sourceType: 'synced' };
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const normalizedInput = normalizeSlashes(input).replace(/\/+$/, '');
|
|
35
|
+
|
|
36
|
+
if (normalizedInput === RULES_SOURCE_DIR || normalizedInput.startsWith(`${RULES_SOURCE_DIR}/`)) {
|
|
37
|
+
if (!existsSync(localRulesRoot)) return null;
|
|
38
|
+
return { sourceRoot: localRulesRoot, sourceType: 'local' };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (normalizedInput === `.aw_registry/${RULES_SOURCE_DIR}` || normalizedInput.startsWith(`.aw_registry/${RULES_SOURCE_DIR}/`)) {
|
|
42
|
+
if (!existsSync(syncedRulesRoot)) return null;
|
|
43
|
+
|
|
44
|
+
const relativeRulesPath = normalizedInput.slice(`.aw_registry/${RULES_SOURCE_DIR}`.length).replace(/^\/+/, '');
|
|
45
|
+
const localOverridePath = relativeRulesPath ? join(localRulesRoot, relativeRulesPath) : localRulesRoot;
|
|
46
|
+
if (existsSync(localOverridePath)) {
|
|
47
|
+
return { sourceRoot: localRulesRoot, sourceType: 'local' };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { sourceRoot: syncedRulesRoot, sourceType: 'synced' };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function hasRulesChanges(cwd = process.cwd()) {
|
|
57
|
+
const candidateDirs = [RULES_SOURCE_DIR, `.aw_registry/${RULES_SOURCE_DIR}`]
|
|
58
|
+
.filter(rel => existsSync(join(cwd, rel)));
|
|
59
|
+
|
|
60
|
+
if (candidateDirs.length === 0) return false;
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const output = execSync(
|
|
64
|
+
`git status --short --untracked-files=all -- ${candidateDirs.map(dir => `"${dir}"`).join(' ')}`,
|
|
65
|
+
{ cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
66
|
+
).trim();
|
|
67
|
+
return output.length > 0;
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function buildRulesPushFile(input, cwd = process.cwd()) {
|
|
74
|
+
const resolved = resolveRulesPushSource(input, cwd);
|
|
75
|
+
if (!resolved) return null;
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
absPath: resolved.sourceRoot,
|
|
79
|
+
registryTarget: RULES_SOURCE_DIR,
|
|
80
|
+
type: 'rules',
|
|
81
|
+
namespace: 'platform',
|
|
82
|
+
slug: 'rules',
|
|
83
|
+
isDir: true,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function buildRulesPrTitle(sourceRoot, cwd) {
|
|
88
|
+
const rel = normalizeSlashes(relative(cwd, sourceRoot));
|
|
89
|
+
return rel === RULES_SOURCE_DIR
|
|
90
|
+
? 'sync: update platform rules'
|
|
91
|
+
: `sync: update platform rules from ${rel}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function buildRulesPrBody(sourceRoot, sourceType, cwd) {
|
|
95
|
+
const rel = normalizeSlashes(relative(cwd, sourceRoot)) || RULES_SOURCE_DIR;
|
|
96
|
+
return [
|
|
97
|
+
'## Platform Rules Sync',
|
|
98
|
+
'',
|
|
99
|
+
`- **Source:** \`${rel}\``,
|
|
100
|
+
`- **Mode:** ${sourceType === 'local' ? 'canonical local rules' : 'synced rules copy'}`,
|
|
101
|
+
'',
|
|
102
|
+
'Uploaded via `aw push-rules`',
|
|
103
|
+
].join('\n');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function pushRulesTree(sourceRoot, { repo, dryRun, cwd }) {
|
|
107
|
+
if (!existsSync(sourceRoot) || !statSync(sourceRoot).isDirectory()) {
|
|
108
|
+
fmt.cancel(`Rules path not found: ${sourceRoot}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (dryRun) {
|
|
112
|
+
fmt.logInfo(`Would push rules from ${chalk.cyan(normalizeSlashes(relative(cwd, sourceRoot)) || RULES_SOURCE_DIR)}`);
|
|
113
|
+
fmt.logWarn('No changes made (--dry-run)');
|
|
114
|
+
fmt.outro(chalk.dim('Remove --dry-run to push'));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const s = fmt.spinner();
|
|
119
|
+
s.start('Cloning registry...');
|
|
120
|
+
|
|
121
|
+
const tempDir = mkdtempSync(join(tmpdir(), 'aw-push-rules-'));
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const repoUrl = repo.startsWith('http') ? repo : `https://github.com/${repo}.git`;
|
|
125
|
+
execSync(`git clone --filter=blob:none --no-checkout "${repoUrl}" "${tempDir}"`, { stdio: 'pipe' });
|
|
126
|
+
execSync(`git checkout ${REGISTRY_BASE_BRANCH}`, { cwd: tempDir, stdio: 'pipe' });
|
|
127
|
+
s.stop('Repository cloned');
|
|
128
|
+
|
|
129
|
+
const branch = `sync/rules-${Date.now().toString(36).slice(-5)}`;
|
|
130
|
+
execSync(`git checkout -b ${branch}`, { cwd: tempDir, stdio: 'pipe' });
|
|
131
|
+
|
|
132
|
+
const s2 = fmt.spinner();
|
|
133
|
+
s2.start('Copying platform rules...');
|
|
134
|
+
|
|
135
|
+
syncFileTree(sourceRoot, join(tempDir, RULES_SOURCE_DIR));
|
|
136
|
+
execSync(`git add -A "${RULES_SOURCE_DIR}"`, { cwd: tempDir, stdio: 'pipe' });
|
|
137
|
+
|
|
138
|
+
const diffStatus = execSync('git diff --cached --name-only', { cwd: tempDir, encoding: 'utf8' }).trim();
|
|
139
|
+
if (!diffStatus) {
|
|
140
|
+
s2.stop('No changes');
|
|
141
|
+
fmt.cancel('Nothing to push — remote rules already match local content.');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const sourceType = normalize(sourceRoot).includes(normalize(join('.aw_registry', RULES_SOURCE_DIR)))
|
|
145
|
+
? 'synced'
|
|
146
|
+
: 'local';
|
|
147
|
+
const prTitle = buildRulesPrTitle(sourceRoot, cwd);
|
|
148
|
+
const prBody = buildRulesPrBody(sourceRoot, sourceType, cwd);
|
|
149
|
+
|
|
150
|
+
execSync(`git commit -m "registry: sync platform rules\n\n${AW_CO_AUTHOR}"`, { cwd: tempDir, stdio: 'pipe' });
|
|
151
|
+
s2.stop('Rules sync prepared');
|
|
152
|
+
|
|
153
|
+
const s3 = fmt.spinner();
|
|
154
|
+
s3.start('Pushing and creating PR...');
|
|
155
|
+
|
|
156
|
+
execSync(`git push -u origin ${branch}`, { cwd: tempDir, stdio: 'pipe' });
|
|
157
|
+
|
|
158
|
+
let prUrl;
|
|
159
|
+
try {
|
|
160
|
+
prUrl = execFileSync('gh', [
|
|
161
|
+
'pr', 'create',
|
|
162
|
+
'--base', REGISTRY_BASE_BRANCH,
|
|
163
|
+
'--title', prTitle,
|
|
164
|
+
'--body', prBody,
|
|
165
|
+
], { cwd: tempDir, encoding: 'utf8' }).trim();
|
|
166
|
+
} catch {
|
|
167
|
+
const repoBase = repo.replace(/\.git$/, '');
|
|
168
|
+
prUrl = `https://github.com/${repoBase}/compare/${REGISTRY_BASE_BRANCH}...${branch}?expand=1`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
s3.stop('Branch pushed');
|
|
172
|
+
fmt.logSuccess(`PR: ${chalk.cyan(prUrl)}`);
|
|
173
|
+
fmt.outro('Rules push complete');
|
|
174
|
+
} catch (e) {
|
|
175
|
+
fmt.cancel(`Rules push failed: ${e.message}`);
|
|
176
|
+
} finally {
|
|
177
|
+
execSync(`rm -rf "${tempDir}"`, { stdio: 'pipe' });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function pushRulesCommand(args) {
|
|
182
|
+
const input = args._positional?.[0];
|
|
183
|
+
const dryRun = args['--dry-run'] === true;
|
|
184
|
+
const repo = args['--repo'] || REGISTRY_REPO;
|
|
185
|
+
const cwd = process.cwd();
|
|
186
|
+
|
|
187
|
+
fmt.intro('aw push-rules');
|
|
188
|
+
|
|
189
|
+
const resolved = resolveRulesPushSource(input, cwd);
|
|
190
|
+
if (!resolved) {
|
|
191
|
+
fmt.cancel([
|
|
192
|
+
'Could not find a rules source to push.',
|
|
193
|
+
'',
|
|
194
|
+
` Checked ${chalk.cyan('.aw_rules/')} and ${chalk.cyan('.aw_registry/.aw_rules/')}.`,
|
|
195
|
+
'',
|
|
196
|
+
' Use `aw pull platform` first or create a local `.aw_rules/` authoring tree.',
|
|
197
|
+
].join('\n'));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (resolved.sourceType === 'synced') {
|
|
201
|
+
fmt.logWarn('Pushing from synced `.aw_registry/.aw_rules/`. Local `.aw_rules/` is safer for authoring.');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
pushRulesTree(resolved.sourceRoot, { repo, dryRun, cwd });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export const __test__ = {
|
|
208
|
+
buildRulesPushFile,
|
|
209
|
+
isRulesPushInput,
|
|
210
|
+
resolveRulesPushSource,
|
|
211
|
+
hasRulesChanges,
|
|
212
|
+
};
|
package/commands/push.mjs
CHANGED
|
@@ -11,7 +11,7 @@ const exec = promisify(execCb);
|
|
|
11
11
|
const execFile = promisify(execFileCb);
|
|
12
12
|
import * as fmt from '../fmt.mjs';
|
|
13
13
|
import { chalk } from '../fmt.mjs';
|
|
14
|
-
import { REGISTRY_REPO, REGISTRY_URL, REGISTRY_BASE_BRANCH, REGISTRY_DIR } from '../constants.mjs';
|
|
14
|
+
import { REGISTRY_REPO, REGISTRY_URL, REGISTRY_BASE_BRANCH, REGISTRY_DIR, AW_CO_AUTHOR } from '../constants.mjs';
|
|
15
15
|
import { resolveInput } from '../paths.mjs';
|
|
16
16
|
import { walkRegistryTree } from '../registry.mjs';
|
|
17
17
|
import {
|
|
@@ -20,11 +20,11 @@ import {
|
|
|
20
20
|
createPushBranch,
|
|
21
21
|
checkoutMain,
|
|
22
22
|
isValidClone,
|
|
23
|
-
findNearestWorktree,
|
|
24
23
|
getLocalRegistryDir,
|
|
25
24
|
commitsAheadOfMain,
|
|
26
25
|
logAheadOfMain,
|
|
27
26
|
} from '../git.mjs';
|
|
27
|
+
import { hasRulesChanges, isRulesPushInput, pushRulesCommand } from './push-rules.mjs';
|
|
28
28
|
|
|
29
29
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
30
30
|
const VERSION = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')).version;
|
|
@@ -192,7 +192,7 @@ function generateCommitMsg(files) {
|
|
|
192
192
|
const countParts = [...addedParts, ...deletedParts];
|
|
193
193
|
|
|
194
194
|
const version = VERSION;
|
|
195
|
-
const trailer = `\n\nGenerated-By: aw/${version}`;
|
|
195
|
+
const trailer = `\n\nGenerated-By: aw/${version}\n${AW_CO_AUTHOR}`;
|
|
196
196
|
|
|
197
197
|
if (files.length === 1) {
|
|
198
198
|
const f = files[0];
|
|
@@ -452,14 +452,27 @@ export async function pushCommand(args) {
|
|
|
452
452
|
|
|
453
453
|
fmt.intro('aw push');
|
|
454
454
|
|
|
455
|
+
if (isRulesPushInput(input)) {
|
|
456
|
+
fmt.logInfo('Detected platform rules path — redirecting to `aw push-rules`.');
|
|
457
|
+
pushRulesCommand(args);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
455
461
|
const repoUrl = REGISTRY_URL;
|
|
456
462
|
if (!isValidClone(awHome, repoUrl)) {
|
|
463
|
+
if (!input && hasRulesChanges(cwd)) {
|
|
464
|
+
fmt.logInfo('Detected changes under platform rules — redirecting to `aw push-rules`.');
|
|
465
|
+
pushRulesCommand(args);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
457
468
|
fmt.cancel('Registry not initialized. Run: aw init');
|
|
458
469
|
return;
|
|
459
470
|
}
|
|
460
471
|
|
|
461
472
|
// No args = staged files first (git commit behaviour), else auto-detect all changes
|
|
462
473
|
if (!input) {
|
|
474
|
+
const rulesChanged = hasRulesChanges(cwd);
|
|
475
|
+
|
|
463
476
|
// Extra paths outside .aw_registry/ that aw also manages: content/ and CODEOWNERS.
|
|
464
477
|
// Detect staged variants for staged-mode and unstaged variants for auto-mode.
|
|
465
478
|
const getExtraStagedPaths = async () => {
|
|
@@ -498,6 +511,9 @@ export async function pushCommand(args) {
|
|
|
498
511
|
};
|
|
499
512
|
});
|
|
500
513
|
const totalCount = files.length + extraStaged.length;
|
|
514
|
+
if (rulesChanged) {
|
|
515
|
+
fmt.logWarn('Detected .aw_rules changes — push them separately with `aw push-rules`.');
|
|
516
|
+
}
|
|
501
517
|
fmt.logInfo(`${chalk.dim('mode:')} staged (${totalCount} file${totalCount > 1 ? 's' : ''})`);
|
|
502
518
|
await doPush(files, awHome, dryRun, worktreeFlow, true, extraStaged);
|
|
503
519
|
return;
|
|
@@ -519,6 +535,11 @@ export async function pushCommand(args) {
|
|
|
519
535
|
}
|
|
520
536
|
|
|
521
537
|
if (allEntries.length === 0 && extraChanged.length === 0) {
|
|
538
|
+
if (rulesChanged) {
|
|
539
|
+
fmt.logInfo('Detected changes under platform rules — redirecting to `aw push-rules`.');
|
|
540
|
+
pushRulesCommand(args);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
522
543
|
fmt.cancel('Nothing to push — no staged or modified files.\n\n Stage files in your IDE or use `aw status` to see changes.');
|
|
523
544
|
return;
|
|
524
545
|
}
|
|
@@ -548,6 +569,9 @@ export async function pushCommand(args) {
|
|
|
548
569
|
}
|
|
549
570
|
|
|
550
571
|
const totalCount = files.length + extraChanged.length;
|
|
572
|
+
if (rulesChanged) {
|
|
573
|
+
fmt.logWarn('Detected .aw_rules changes — push them separately with `aw push-rules`.');
|
|
574
|
+
}
|
|
551
575
|
fmt.logInfo(`${chalk.dim('mode:')} auto (${totalCount} file${totalCount > 1 ? 's' : ''} — stage specific files to push a subset)`);
|
|
552
576
|
await doPush(files, awHome, dryRun, worktreeFlow, false, extraChanged);
|
|
553
577
|
return;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import * as fmt from '../fmt.mjs';
|
|
2
|
+
import { loadScenario, listBuiltInScenarios } from '../slack-sim/scenario.mjs';
|
|
3
|
+
import { printDryRun, printScenarioIntro, printState } from '../slack-sim/render.mjs';
|
|
4
|
+
|
|
5
|
+
function sleep(ms) {
|
|
6
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function slackSimCommand(args) {
|
|
10
|
+
const [subcommand = 'list-scenarios', scenarioRef] = args._positional;
|
|
11
|
+
|
|
12
|
+
if (args['--help']) {
|
|
13
|
+
printHelp();
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (subcommand === 'list-scenarios') {
|
|
18
|
+
fmt.intro('aw slack-sim list-scenarios');
|
|
19
|
+
const scenarios = listBuiltInScenarios();
|
|
20
|
+
fmt.note(
|
|
21
|
+
scenarios.map((s) => `${s.name} ${fmt.chalk.dim(s.path)}`).join('\n'),
|
|
22
|
+
'Built-in scenarios',
|
|
23
|
+
);
|
|
24
|
+
fmt.outro('Done');
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!['run', 'replay'].includes(subcommand)) {
|
|
29
|
+
fmt.cancel(`Unknown slack-sim subcommand: ${subcommand}`);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const scenario = loadScenario(scenarioRef);
|
|
34
|
+
const useHttp = Boolean(args['--api-base']) && !args['--in-process'];
|
|
35
|
+
|
|
36
|
+
printScenarioIntro(scenario, useHttp ? 'http' : 'in-process');
|
|
37
|
+
fmt.note(
|
|
38
|
+
[
|
|
39
|
+
`source: ${scenario.__file}`,
|
|
40
|
+
`actions: ${scenario.actions.length}`,
|
|
41
|
+
`org: ${scenario.session.orgId}`,
|
|
42
|
+
`team: ${scenario.session.teamId}`,
|
|
43
|
+
].join('\n'),
|
|
44
|
+
'Scenario',
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
if (args['--dry-run']) {
|
|
48
|
+
printDryRun(scenario);
|
|
49
|
+
fmt.outro('Dry run complete');
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const transport = useHttp
|
|
54
|
+
? await (await import('../slack-sim/http.mjs')).createHttpTransport({
|
|
55
|
+
apiBase: args['--api-base'],
|
|
56
|
+
orgId: scenario.session.orgId,
|
|
57
|
+
teamId: scenario.session.teamId,
|
|
58
|
+
})
|
|
59
|
+
: await (await import('../slack-sim/in-process.mjs')).createInProcessTransport();
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
await transport.start?.();
|
|
63
|
+
let currentChannel = scenario.defaults.channel;
|
|
64
|
+
let currentThreadTs = scenario.defaults.threadTs;
|
|
65
|
+
for (let i = 0; i < scenario.actions.length; i++) {
|
|
66
|
+
const action = scenario.actions[i];
|
|
67
|
+
const actionResult = await transport.runAction(action, scenario);
|
|
68
|
+
currentChannel = actionResult?.channel || action.channel || currentChannel;
|
|
69
|
+
currentThreadTs = actionResult?.threadTs || action.threadTs || action.thread_ts || (action.type === 'app_mention_top_level' ? actionResult?.ts : currentThreadTs);
|
|
70
|
+
const state = await transport.getState({
|
|
71
|
+
channel: currentChannel,
|
|
72
|
+
threadTs: currentThreadTs,
|
|
73
|
+
});
|
|
74
|
+
if (action.type === 'assert') {
|
|
75
|
+
assertState(state, action);
|
|
76
|
+
fmt.logSuccess(`Assertion ${i + 1} passed`);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
printState(i, action, state);
|
|
80
|
+
if (args['--wait-for-finish'] && state.status === 'running' && action.type !== 'wait') {
|
|
81
|
+
await sleep(250);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
fmt.outro('Scenario complete');
|
|
85
|
+
} finally {
|
|
86
|
+
await transport.close?.();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function assertState(state, action) {
|
|
91
|
+
const failures = [];
|
|
92
|
+
if (action.status && state.status !== action.status) failures.push(`expected status=${action.status}, got ${state.status}`);
|
|
93
|
+
if (action.prRole && state.prRole !== action.prRole) failures.push(`expected prRole=${action.prRole}, got ${state.prRole}`);
|
|
94
|
+
if (action.pollerActive !== undefined && state.pollerActive !== action.pollerActive) {
|
|
95
|
+
failures.push(`expected pollerActive=${action.pollerActive}, got ${state.pollerActive}`);
|
|
96
|
+
}
|
|
97
|
+
if (action.checkpointActive !== undefined && state.checkpointActive !== action.checkpointActive) {
|
|
98
|
+
failures.push(`expected checkpointActive=${action.checkpointActive}, got ${state.checkpointActive}`);
|
|
99
|
+
}
|
|
100
|
+
if (action.prUrlIncludes && !String(state.prUrl || '').includes(action.prUrlIncludes)) {
|
|
101
|
+
failures.push(`expected prUrl to include "${action.prUrlIncludes}", got ${state.prUrl || '<none>'}`);
|
|
102
|
+
}
|
|
103
|
+
if (action.threadContains) {
|
|
104
|
+
const haystack = (state.messages || []).map((m) => m.text).join('\n');
|
|
105
|
+
if (!haystack.includes(action.threadContains)) failures.push(`expected thread to contain "${action.threadContains}"`);
|
|
106
|
+
}
|
|
107
|
+
if (action.threadNotContains) {
|
|
108
|
+
const haystack = (state.messages || []).map((m) => m.text).join('\n');
|
|
109
|
+
if (haystack.includes(action.threadNotContains)) failures.push(`expected thread to not contain "${action.threadNotContains}"`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (failures.length) {
|
|
113
|
+
throw new Error(failures.join('; '));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function printHelp() {
|
|
118
|
+
fmt.intro('aw slack-sim');
|
|
119
|
+
fmt.note(
|
|
120
|
+
[
|
|
121
|
+
'aw slack-sim list-scenarios',
|
|
122
|
+
'aw slack-sim run <scenario-file-or-name> [--dry-run] [--in-process] [--api-base <url>]',
|
|
123
|
+
'aw slack-sim replay <scenario-file-or-name> [--dry-run] [--in-process] [--api-base <url>]',
|
|
124
|
+
].join('\n'),
|
|
125
|
+
'Usage',
|
|
126
|
+
);
|
|
127
|
+
fmt.outro('Done');
|
|
128
|
+
}
|
package/config.mjs
CHANGED
package/constants.mjs
CHANGED
|
@@ -25,5 +25,12 @@ export const DOCS_SOURCE_DIR = 'content';
|
|
|
25
25
|
/** Persistent git clone root — ~/.aw/ */
|
|
26
26
|
export const AW_HOME = join(homedir(), '.aw');
|
|
27
27
|
|
|
28
|
+
/** Directory in platform-docs repo containing platform rules (pulled into .aw_registry/.aw_rules/) */
|
|
29
|
+
export const RULES_SOURCE_DIR = '.aw_rules';
|
|
28
30
|
/** Telemetry endpoint — override with AW_TELEMETRY_URL env var */
|
|
29
|
-
export const TELEMETRY_URL = process.env.AW_TELEMETRY_URL || 'https://services.leadconnectorhq.com/
|
|
31
|
+
export const TELEMETRY_URL = process.env.AW_TELEMETRY_URL || 'https://services.leadconnectorhq.com/agentic-workspace/api/telemetry/events';
|
|
32
|
+
|
|
33
|
+
/** AW bot identity for Co-Authored-By trailers */
|
|
34
|
+
export const AW_BOT_NAME = 'AW';
|
|
35
|
+
export const AW_BOT_EMAIL = process.env.AW_BOT_EMAIL || '273421570+ghl-aw@users.noreply.github.com';
|
|
36
|
+
export const AW_CO_AUTHOR = `Co-Authored-By: ${AW_BOT_NAME} <${AW_BOT_EMAIL}>`;
|
package/ecc.mjs
CHANGED
|
@@ -9,7 +9,7 @@ import * as fmt from "./fmt.mjs";
|
|
|
9
9
|
|
|
10
10
|
const AW_ECC_REPO_SSH = "git@github.com:shreyansh-ghl/aw-ecc.git";
|
|
11
11
|
const AW_ECC_REPO_HTTPS = "https://github.com/shreyansh-ghl/aw-ecc.git";
|
|
12
|
-
const AW_ECC_TAG = "v1.2.
|
|
12
|
+
const AW_ECC_TAG = "v1.2.9";
|
|
13
13
|
|
|
14
14
|
const MARKETPLACE_NAME = "aw-marketplace";
|
|
15
15
|
const PLUGIN_KEY = `aw@${MARKETPLACE_NAME}`;
|
|
@@ -28,10 +28,71 @@ const TARGET_STATE = {
|
|
|
28
28
|
codex: { state: ".codex/ecc-install-state.json" },
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
+
// User-owned config files that must never be clobbered by aw-ecc install.
|
|
32
|
+
// If these files already exist before `aw init`, we preserve them exactly.
|
|
33
|
+
const PROTECTED_CONFIG_BY_TARGET = {
|
|
34
|
+
claude: [".claude/settings.json", ".claude.json"],
|
|
35
|
+
cursor: [".cursor/settings.json", ".cursor/mcp.json"],
|
|
36
|
+
codex: [".codex/config.toml"],
|
|
37
|
+
};
|
|
38
|
+
|
|
31
39
|
function run(cmd, opts = {}) {
|
|
32
40
|
return execSync(cmd, { stdio: "pipe", ...opts });
|
|
33
41
|
}
|
|
34
42
|
|
|
43
|
+
function readIfExists(path) {
|
|
44
|
+
try { return existsSync(path) ? readFileSync(path, "utf8") : null; } catch { return null; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function snapshotProtectedConfigs(home, target) {
|
|
48
|
+
const relPaths = PROTECTED_CONFIG_BY_TARGET[target] || [];
|
|
49
|
+
return relPaths.map((relPath) => {
|
|
50
|
+
const absPath = join(home, relPath);
|
|
51
|
+
const content = readIfExists(absPath);
|
|
52
|
+
return {
|
|
53
|
+
relPath,
|
|
54
|
+
absPath,
|
|
55
|
+
existedBeforeInstall: content !== null,
|
|
56
|
+
contentBeforeInstall: content,
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function restoreProtectedConfigs(snapshot) {
|
|
62
|
+
let restored = 0;
|
|
63
|
+
for (const entry of snapshot) {
|
|
64
|
+
if (entry.existedBeforeInstall) {
|
|
65
|
+
const current = readIfExists(entry.absPath);
|
|
66
|
+
if (current === entry.contentBeforeInstall) continue;
|
|
67
|
+
try {
|
|
68
|
+
mkdirSync(dirname(entry.absPath), { recursive: true });
|
|
69
|
+
writeFileSync(entry.absPath, entry.contentBeforeInstall ?? "");
|
|
70
|
+
restored++;
|
|
71
|
+
} catch { /* best effort */ }
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// If aw-ecc created a protected settings file from scratch, remove it.
|
|
76
|
+
// AW should not own these user config files.
|
|
77
|
+
try {
|
|
78
|
+
if (existsSync(entry.absPath)) {
|
|
79
|
+
rmSync(entry.absPath, { force: true });
|
|
80
|
+
restored++;
|
|
81
|
+
}
|
|
82
|
+
} catch { /* best effort */ }
|
|
83
|
+
}
|
|
84
|
+
return restored;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function relProtectedPath(absPath, home) {
|
|
88
|
+
const normalized = String(absPath || "").replace(/\\/g, "/");
|
|
89
|
+
for (const rel of Object.values(PROTECTED_CONFIG_BY_TARGET).flat()) {
|
|
90
|
+
const full = join(home, rel).replace(/\\/g, "/");
|
|
91
|
+
if (normalized === full) return rel;
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
35
96
|
function cloneOrUpdate(tag, dest) {
|
|
36
97
|
// AW_ECC_CLONE_URL overrides the remote (used in tests to point at a local fake repo)
|
|
37
98
|
const overrideUrl = process.env.AW_ECC_CLONE_URL;
|
|
@@ -132,6 +193,7 @@ export async function installAwEcc(
|
|
|
132
193
|
if (!silent) fmt.logStep("Installing aw-ecc engine...");
|
|
133
194
|
|
|
134
195
|
const repoDir = eccDir();
|
|
196
|
+
const home = homedir();
|
|
135
197
|
|
|
136
198
|
try {
|
|
137
199
|
cloneOrUpdate(AW_ECC_TAG, repoDir);
|
|
@@ -153,6 +215,8 @@ export async function installAwEcc(
|
|
|
153
215
|
});
|
|
154
216
|
for (const target of fileCopyTargets) {
|
|
155
217
|
try {
|
|
218
|
+
const snapshot = snapshotProtectedConfigs(home, target);
|
|
219
|
+
|
|
156
220
|
// Always use HOME as cwd so files land in ~/.<target>/ globally.
|
|
157
221
|
const runCwd = homedir();
|
|
158
222
|
// For claude: skip commands (plugin handles them as /aw:tdd) but
|
|
@@ -173,6 +237,10 @@ export async function installAwEcc(
|
|
|
173
237
|
if (target === "codex") {
|
|
174
238
|
syncEccToCodex(repoDir);
|
|
175
239
|
}
|
|
240
|
+
|
|
241
|
+
// Critical: preserve user-owned config files if they existed before
|
|
242
|
+
// running aw-ecc (aw should only add, never replace user settings).
|
|
243
|
+
restoreProtectedConfigs(snapshot);
|
|
176
244
|
} catch { /* target not supported — skip */ }
|
|
177
245
|
}
|
|
178
246
|
}
|
|
@@ -201,10 +269,18 @@ export function uninstallAwEcc({ silent = false } = {}) {
|
|
|
201
269
|
try {
|
|
202
270
|
const data = JSON.parse(readFileSync(statePath, "utf8"));
|
|
203
271
|
for (const op of data.operations || []) {
|
|
204
|
-
if (op.destinationPath
|
|
205
|
-
|
|
272
|
+
if (!op.destinationPath) continue;
|
|
273
|
+
const absPath = op.destinationPath.startsWith("/") ? op.destinationPath : join(HOME, op.destinationPath);
|
|
274
|
+
const relProtected = relProtectedPath(absPath, HOME);
|
|
275
|
+
if (relProtected) {
|
|
276
|
+
// Never delete user settings files as part of aw-ecc uninstall.
|
|
277
|
+
// AW-specific MCP removal is handled separately in removeMcpConfig().
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (existsSync(absPath)) {
|
|
281
|
+
rmSync(absPath, { recursive: true, force: true });
|
|
206
282
|
removed++;
|
|
207
|
-
pruneEmptyParents(
|
|
283
|
+
pruneEmptyParents(absPath, join(HOME, cfg.state.split("/")[0]));
|
|
208
284
|
}
|
|
209
285
|
}
|
|
210
286
|
rmSync(statePath, { force: true });
|
package/file-tree.mjs
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdirSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
readdirSync,
|
|
5
|
+
copyFileSync,
|
|
6
|
+
unlinkSync,
|
|
7
|
+
rmdirSync,
|
|
8
|
+
} from 'node:fs';
|
|
9
|
+
import { join, relative, dirname } from 'node:path';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Collect all file paths (relative) in a directory tree.
|
|
13
|
+
*/
|
|
14
|
+
export function collectAllPaths(dir, base) {
|
|
15
|
+
const paths = new Set();
|
|
16
|
+
if (!existsSync(dir)) return paths;
|
|
17
|
+
|
|
18
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
19
|
+
if (entry.name.startsWith('.')) continue;
|
|
20
|
+
|
|
21
|
+
const full = join(dir, entry.name);
|
|
22
|
+
if (entry.isDirectory()) {
|
|
23
|
+
for (const relPath of collectAllPaths(full, base)) paths.add(relPath);
|
|
24
|
+
} else {
|
|
25
|
+
paths.add(relative(base, full));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return paths;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Mirror all files from src to dest.
|
|
34
|
+
* By default, local-only files are removed so dest stays in sync with src.
|
|
35
|
+
*/
|
|
36
|
+
export function syncFileTree(src, dest, options = {}) {
|
|
37
|
+
const { deleteMissing = true } = options;
|
|
38
|
+
mkdirSync(dest, { recursive: true });
|
|
39
|
+
|
|
40
|
+
const remotePaths = collectAllPaths(src, src);
|
|
41
|
+
const localPaths = collectAllPaths(dest, dest);
|
|
42
|
+
|
|
43
|
+
for (const relPath of remotePaths) {
|
|
44
|
+
const srcPath = join(src, relPath);
|
|
45
|
+
const destPath = join(dest, relPath);
|
|
46
|
+
mkdirSync(dirname(destPath), { recursive: true });
|
|
47
|
+
copyFileSync(srcPath, destPath);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (deleteMissing) {
|
|
51
|
+
for (const relPath of localPaths) {
|
|
52
|
+
if (!remotePaths.has(relPath)) {
|
|
53
|
+
const destPath = join(dest, relPath);
|
|
54
|
+
try {
|
|
55
|
+
unlinkSync(destPath);
|
|
56
|
+
} catch {
|
|
57
|
+
// best effort
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function pruneEmpty(dir) {
|
|
64
|
+
if (!existsSync(dir)) return;
|
|
65
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
66
|
+
if (entry.isDirectory()) pruneEmpty(join(dir, entry.name));
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
if (readdirSync(dir).length === 0 && dir !== dest) rmdirSync(dir);
|
|
70
|
+
} catch {
|
|
71
|
+
// best effort
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
pruneEmpty(dest);
|
|
76
|
+
}
|
package/fmt.mjs
CHANGED
|
@@ -72,7 +72,21 @@ export const isCancel = p.isCancel;
|
|
|
72
72
|
|
|
73
73
|
export const spinner = () => p.spinner();
|
|
74
74
|
|
|
75
|
+
export class CancelError extends Error {
|
|
76
|
+
constructor(message, { exitCode = 1 } = {}) {
|
|
77
|
+
super(message);
|
|
78
|
+
this.name = 'CancelError';
|
|
79
|
+
this.exitCode = exitCode;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
75
83
|
export function cancel(msg) {
|
|
84
|
+
p.cancel(msg);
|
|
85
|
+
throw new CancelError(msg);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Hard exit — for use in process exception handlers where throwing is unsafe */
|
|
89
|
+
export function cancelAndExit(msg) {
|
|
76
90
|
p.cancel(msg);
|
|
77
91
|
process.exit(1);
|
|
78
92
|
}
|