@ghl-ai/aw 0.1.35 → 0.1.36-beta.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/cli.mjs +2 -1
- package/commands/drop.mjs +45 -47
- package/commands/init.mjs +122 -125
- package/commands/nuke.mjs +30 -10
- package/commands/pull.mjs +57 -370
- package/commands/push.mjs +297 -287
- package/commands/status.mjs +50 -80
- package/config.mjs +2 -2
- package/constants.mjs +6 -0
- package/ecc.mjs +180 -0
- package/fmt.mjs +2 -0
- package/git.mjs +233 -1
- package/integrate.mjs +8 -6
- package/package.json +3 -2
- package/apply.mjs +0 -79
- package/manifest.mjs +0 -64
- package/plan.mjs +0 -147
package/commands/push.mjs
CHANGED
|
@@ -1,32 +1,157 @@
|
|
|
1
|
-
// commands/push.mjs — Push local agents/skills to registry via PR
|
|
1
|
+
// commands/push.mjs — Push local agents/skills to registry via PR using persistent git clone
|
|
2
2
|
|
|
3
|
-
import { existsSync, statSync,
|
|
4
|
-
import {
|
|
3
|
+
import { existsSync, statSync, readFileSync, appendFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
5
|
import { execSync, execFileSync } from 'node:child_process';
|
|
6
|
-
import {
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
7
|
import * as fmt from '../fmt.mjs';
|
|
8
8
|
import { chalk } from '../fmt.mjs';
|
|
9
9
|
import { REGISTRY_REPO, REGISTRY_BASE_BRANCH, REGISTRY_DIR } from '../constants.mjs';
|
|
10
10
|
import { resolveInput } from '../paths.mjs';
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
11
|
+
import { walkRegistryTree } from '../registry.mjs';
|
|
12
|
+
import {
|
|
13
|
+
detectChanges,
|
|
14
|
+
createPushBranch,
|
|
15
|
+
checkoutMain,
|
|
16
|
+
isValidClone,
|
|
17
|
+
} from '../git.mjs';
|
|
13
18
|
|
|
14
19
|
const PUSHABLE_TYPES = ['agents', 'skills', 'commands', 'evals'];
|
|
15
20
|
|
|
16
|
-
// ──
|
|
21
|
+
// ── PR content generation ────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
function generateBranchName(files) {
|
|
24
|
+
const shortId = Date.now().toString(36).slice(-5);
|
|
25
|
+
const namespaces = [...new Set(files.map(f => f.namespace))];
|
|
26
|
+
const hasDeletes = files.some(f => f.deleted);
|
|
27
|
+
const allDeletes = files.every(f => f.deleted);
|
|
28
|
+
const prefix = allDeletes ? 'remove' : hasDeletes ? 'sync' : 'upload';
|
|
29
|
+
|
|
30
|
+
if (files.length === 1) {
|
|
31
|
+
const f = files[0];
|
|
32
|
+
const nsSlug = f.namespace.replace(/\//g, '-');
|
|
33
|
+
return `${prefix}/${nsSlug}-${f.type}-${f.slug}-${shortId}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (namespaces.length === 1) {
|
|
37
|
+
return `${prefix}/${namespaces[0].replace(/\//g, '-')}-${shortId}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return `${prefix}/batch-${shortId}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function generatePrTitle(files) {
|
|
44
|
+
if (files.length === 1) {
|
|
45
|
+
const f = files[0];
|
|
46
|
+
const verb = f.deleted ? 'Remove' : 'Add';
|
|
47
|
+
const prep = f.deleted ? 'from' : 'to';
|
|
48
|
+
return `${verb} ${f.slug} (${f.type}) ${prep} ${f.namespace}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const addCounts = {};
|
|
52
|
+
const deleteCounts = {};
|
|
53
|
+
for (const f of files) {
|
|
54
|
+
const target = f.deleted ? deleteCounts : addCounts;
|
|
55
|
+
target[f.type] = (target[f.type] || 0) + 1;
|
|
56
|
+
}
|
|
57
|
+
const parts = [];
|
|
58
|
+
for (const [type, count] of Object.entries(addCounts)) {
|
|
59
|
+
parts.push(`+${count} ${type}`);
|
|
60
|
+
}
|
|
61
|
+
for (const [type, count] of Object.entries(deleteCounts)) {
|
|
62
|
+
parts.push(`-${count} ${type}`);
|
|
63
|
+
}
|
|
64
|
+
const namespaces = [...new Set(files.map(f => f.namespace))];
|
|
65
|
+
|
|
66
|
+
if (namespaces.length === 1) {
|
|
67
|
+
return `sync: ${parts.join(', ')} in ${namespaces[0]}`;
|
|
68
|
+
}
|
|
69
|
+
return `sync: ${parts.join(', ')} across ${namespaces.join(', ')}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function generatePrBody(files, newNamespaces) {
|
|
73
|
+
if (files.length === 1) {
|
|
74
|
+
const f = files[0];
|
|
75
|
+
const action = f.deleted ? 'Registry Removal' : 'Registry Upload';
|
|
76
|
+
const bodyParts = [
|
|
77
|
+
`## ${action}`,
|
|
78
|
+
'',
|
|
79
|
+
`- **Type:** ${f.type}`,
|
|
80
|
+
`- **Slug:** ${f.slug}`,
|
|
81
|
+
`- **Namespace:** ${f.namespace}`,
|
|
82
|
+
`- **Path:** \`${f.registryTarget}\``,
|
|
83
|
+
];
|
|
84
|
+
if (f.deleted) {
|
|
85
|
+
bodyParts.push('', '> File was deleted locally and removed from registry.');
|
|
86
|
+
}
|
|
87
|
+
if (newNamespaces.length > 0) {
|
|
88
|
+
bodyParts.push('', '> **New namespace** — CODEOWNERS entry auto-added.');
|
|
89
|
+
}
|
|
90
|
+
bodyParts.push('', `${f.deleted ? 'Removed' : 'Uploaded'} via \`aw push\``);
|
|
91
|
+
return bodyParts.join('\n');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const added = files.filter(f => !f.deleted);
|
|
95
|
+
const deleted = files.filter(f => f.deleted);
|
|
96
|
+
|
|
97
|
+
const bodyParts = ['## Registry Sync', ''];
|
|
98
|
+
|
|
99
|
+
if (added.length > 0) {
|
|
100
|
+
const grouped = groupBy(added, 'type');
|
|
101
|
+
bodyParts.push('### Added / Updated');
|
|
102
|
+
for (const [type, items] of Object.entries(grouped)) {
|
|
103
|
+
bodyParts.push(`**${type}**`);
|
|
104
|
+
for (const item of items.sort((a, b) => a.slug.localeCompare(b.slug))) {
|
|
105
|
+
bodyParts.push(`- \`${item.slug}\` (${item.namespace})`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
bodyParts.push('');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (deleted.length > 0) {
|
|
112
|
+
const grouped = groupBy(deleted, 'type');
|
|
113
|
+
bodyParts.push('### Removed');
|
|
114
|
+
for (const [type, items] of Object.entries(grouped)) {
|
|
115
|
+
bodyParts.push(`**${type}**`);
|
|
116
|
+
for (const item of items.sort((a, b) => a.slug.localeCompare(b.slug))) {
|
|
117
|
+
bodyParts.push(`- ~~\`${item.slug}\`~~ (${item.namespace})`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
bodyParts.push('');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (newNamespaces.length > 0) {
|
|
124
|
+
bodyParts.push(`> **New namespace${newNamespaces.length > 1 ? 's' : ''}:** ${newNamespaces.join(', ')} — CODEOWNERS entr${newNamespaces.length > 1 ? 'ies' : 'y'} auto-added.`);
|
|
125
|
+
bodyParts.push('');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
bodyParts.push(`---`, `Synced via \`aw push\` (${files.length} files: ${added.length} added/updated, ${deleted.length} removed)`);
|
|
129
|
+
return bodyParts.join('\n');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function generateCommitMsg(files) {
|
|
133
|
+
const added = files.filter(f => !f.deleted);
|
|
134
|
+
const deleted = files.filter(f => f.deleted);
|
|
135
|
+
const addedParts = Object.entries(groupBy(added, 'type')).map(([t, items]) => `${items.length} ${t}`);
|
|
136
|
+
const deletedParts = Object.entries(groupBy(deleted, 'type')).map(([t, items]) => `${items.length} ${t} removed`);
|
|
137
|
+
const countParts = [...addedParts, ...deletedParts];
|
|
138
|
+
|
|
139
|
+
if (files.length === 1) {
|
|
140
|
+
const f = files[0];
|
|
141
|
+
return `registry: ${f.deleted ? 'remove' : 'add'} ${f.type}/${f.slug} ${f.deleted ? 'from' : 'to'} ${f.namespace}`;
|
|
142
|
+
}
|
|
143
|
+
return `registry: sync ${files.length} files (${countParts.join(', ')})`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Batch file collection from folder ────────────────────────────────
|
|
147
|
+
|
|
148
|
+
function collectBatchFiles(folderAbsPath, registrySubDir) {
|
|
149
|
+
const relPath = folderAbsPath.startsWith(registrySubDir + '/')
|
|
150
|
+
? folderAbsPath.slice(registrySubDir.length + 1)
|
|
151
|
+
: folderAbsPath;
|
|
17
152
|
|
|
18
|
-
/**
|
|
19
|
-
* Collect all pushable files under a local .aw_registry folder.
|
|
20
|
-
* Returns array of { absPath, registryTarget, type, namespace, slug, isDir }.
|
|
21
|
-
*/
|
|
22
|
-
function collectBatchFiles(folderAbsPath, workspaceDir) {
|
|
23
|
-
const relPath = relative(workspaceDir, folderAbsPath);
|
|
24
153
|
const segments = relPath.split('/');
|
|
25
154
|
|
|
26
|
-
// Detect if user pointed at or inside a type dir (agents/, skills/, etc.).
|
|
27
|
-
// e.g. "revex/courses/core/evals" → typeFilter=evals, subPathFilter=null
|
|
28
|
-
// e.g. "revex/courses/core/evals/agents" → typeFilter=evals, subPathFilter="agents"
|
|
29
|
-
// e.g. "revex/courses/core/evals/agents/architect" → typeFilter=evals, subPathFilter="agents/architect"
|
|
30
155
|
let typeFilter = null;
|
|
31
156
|
let subPathFilter = null;
|
|
32
157
|
let walkDir = folderAbsPath;
|
|
@@ -34,13 +159,10 @@ function collectBatchFiles(folderAbsPath, workspaceDir) {
|
|
|
34
159
|
|
|
35
160
|
for (let i = 0; i < segments.length; i++) {
|
|
36
161
|
if (PUSHABLE_TYPES.includes(segments[i])) {
|
|
37
|
-
// Everything before this segment is the namespace
|
|
38
162
|
const namespaceParts = segments.slice(0, i);
|
|
39
163
|
walkBaseName = namespaceParts.join('/');
|
|
40
|
-
|
|
41
|
-
walkDir = join(workspaceDir, ...namespaceParts);
|
|
164
|
+
walkDir = join(registrySubDir, ...namespaceParts);
|
|
42
165
|
typeFilter = segments[i];
|
|
43
|
-
// Anything after the type dir is a sub-path filter (e.g., "agents/architect")
|
|
44
166
|
if (i + 1 < segments.length) {
|
|
45
167
|
subPathFilter = segments.slice(i + 1).join('/');
|
|
46
168
|
}
|
|
@@ -52,12 +174,10 @@ function collectBatchFiles(folderAbsPath, workspaceDir) {
|
|
|
52
174
|
return entries
|
|
53
175
|
.filter(entry => {
|
|
54
176
|
if (typeFilter && entry.type !== typeFilter) return false;
|
|
55
|
-
// For sub-path filtering (e.g., only evals under "agents" or "agents/architect")
|
|
56
177
|
if (subPathFilter && !entry.slug.startsWith(subPathFilter)) return false;
|
|
57
178
|
return true;
|
|
58
179
|
})
|
|
59
180
|
.map(entry => {
|
|
60
|
-
// Skills and evals both have nested slug subdirs (e.g. skills/slug/file, evals/agents/developer/file)
|
|
61
181
|
const registryTarget = (entry.type === 'skills' || entry.type === 'evals')
|
|
62
182
|
? `${REGISTRY_DIR}/${entry.namespacePath}/${entry.type}/${entry.slug}/${entry.skillRelPath || entry.filename}`
|
|
63
183
|
: `${REGISTRY_DIR}/${entry.namespacePath}/${entry.type}/${entry.filename}`;
|
|
@@ -68,45 +188,15 @@ function collectBatchFiles(folderAbsPath, workspaceDir) {
|
|
|
68
188
|
namespace: entry.namespacePath,
|
|
69
189
|
slug: entry.slug,
|
|
70
190
|
isDir: false,
|
|
191
|
+
deleted: false,
|
|
71
192
|
};
|
|
72
193
|
});
|
|
73
194
|
}
|
|
74
195
|
|
|
75
|
-
|
|
76
|
-
* Collect all modified files from manifest (for no-args push).
|
|
77
|
-
* Returns array of { absPath, registryTarget, type, namespace, slug, isDir }.
|
|
78
|
-
*/
|
|
79
|
-
function collectModifiedFiles(workspaceDir) {
|
|
80
|
-
const manifest = loadManifest(workspaceDir);
|
|
81
|
-
const files = [];
|
|
82
|
-
for (const [key, entry] of Object.entries(manifest.files || {})) {
|
|
83
|
-
const filePath = join(workspaceDir, key);
|
|
84
|
-
if (!existsSync(filePath)) continue;
|
|
85
|
-
const currentHash = hashFile(filePath);
|
|
86
|
-
const isModified = currentHash !== entry.sha256;
|
|
87
|
-
const isNew = !entry.registry_sha256; // Template-derived, never pushed to remote
|
|
88
|
-
if (isModified || isNew) {
|
|
89
|
-
const meta = parseManifestKey(key);
|
|
90
|
-
if (meta) {
|
|
91
|
-
files.push({
|
|
92
|
-
absPath: filePath,
|
|
93
|
-
registryTarget: `${REGISTRY_DIR}/${key}`,
|
|
94
|
-
type: meta.type,
|
|
95
|
-
namespace: meta.namespace,
|
|
96
|
-
slug: meta.slug,
|
|
97
|
-
isDir: false,
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
return files;
|
|
103
|
-
}
|
|
196
|
+
// ── Parse type/namespace/slug from a registry-relative path ──────────
|
|
104
197
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
*/
|
|
108
|
-
function parseManifestKey(key) {
|
|
109
|
-
const parts = key.split('/');
|
|
198
|
+
function parseRegistryPath(relPath) {
|
|
199
|
+
const parts = relPath.split('/');
|
|
110
200
|
for (let i = 0; i < parts.length; i++) {
|
|
111
201
|
if (PUSHABLE_TYPES.includes(parts[i]) && i + 1 < parts.length) {
|
|
112
202
|
return {
|
|
@@ -119,244 +209,125 @@ function parseManifestKey(key) {
|
|
|
119
209
|
return null;
|
|
120
210
|
}
|
|
121
211
|
|
|
122
|
-
// ──
|
|
212
|
+
// ── CODEOWNERS helpers ────────────────────────────────────────────────
|
|
123
213
|
|
|
124
|
-
function
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (namespaces.length === 1) {
|
|
135
|
-
return `sync/${namespaces[0].replace(/\//g, '-')}-${shortId}`;
|
|
214
|
+
function getGitHubUser() {
|
|
215
|
+
try {
|
|
216
|
+
return execSync('gh api user --jq .login', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
217
|
+
} catch {
|
|
218
|
+
try {
|
|
219
|
+
return execSync('git config user.name', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
220
|
+
} catch {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
136
223
|
}
|
|
137
|
-
|
|
138
|
-
return `sync/batch-${shortId}`;
|
|
139
224
|
}
|
|
140
225
|
|
|
141
|
-
function
|
|
142
|
-
if (
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const counts = {};
|
|
148
|
-
for (const f of files) {
|
|
149
|
-
counts[f.type] = (counts[f.type] || 0) + 1;
|
|
150
|
-
}
|
|
151
|
-
const countParts = Object.entries(counts).map(([type, count]) => `${count} ${type}`);
|
|
152
|
-
const namespaces = [...new Set(files.map(f => f.namespace))];
|
|
153
|
-
|
|
154
|
-
if (namespaces.length === 1) {
|
|
155
|
-
return `sync: ${countParts.join(', ')} in ${namespaces[0]}`;
|
|
156
|
-
}
|
|
157
|
-
return `sync: ${countParts.join(', ')} across ${namespaces.join(', ')}`;
|
|
226
|
+
function isNewNamespaceInCodeowners(codeownersPath, namespace) {
|
|
227
|
+
if (!existsSync(codeownersPath)) return true;
|
|
228
|
+
const content = readFileSync(codeownersPath, 'utf8');
|
|
229
|
+
return !content.includes(`/${REGISTRY_DIR}/${namespace}/`);
|
|
158
230
|
}
|
|
159
231
|
|
|
160
|
-
|
|
161
|
-
if (files.length === 1) {
|
|
162
|
-
const f = files[0];
|
|
163
|
-
const bodyParts = [
|
|
164
|
-
'## Registry Upload',
|
|
165
|
-
'',
|
|
166
|
-
`- **Type:** ${f.type}`,
|
|
167
|
-
`- **Slug:** ${f.slug}`,
|
|
168
|
-
`- **Namespace:** ${f.namespace}`,
|
|
169
|
-
`- **Path:** \`${f.registryTarget}\``,
|
|
170
|
-
];
|
|
171
|
-
if (newNamespaces.length > 0) {
|
|
172
|
-
bodyParts.push('', '> **New namespace** — CODEOWNERS entry auto-added.');
|
|
173
|
-
}
|
|
174
|
-
bodyParts.push('', 'Uploaded via `aw push`');
|
|
175
|
-
return bodyParts.join('\n');
|
|
176
|
-
}
|
|
232
|
+
// ── Create PR via gh or fallback URL ─────────────────────────────────
|
|
177
233
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
234
|
+
function createPR(awHome, branch, prTitle, prBody) {
|
|
235
|
+
try {
|
|
236
|
+
return execFileSync('gh', [
|
|
237
|
+
'pr', 'create',
|
|
238
|
+
'--base', REGISTRY_BASE_BRANCH,
|
|
239
|
+
'--title', prTitle,
|
|
240
|
+
'--body', prBody,
|
|
241
|
+
], { cwd: awHome, encoding: 'utf8' }).trim();
|
|
242
|
+
} catch {
|
|
243
|
+
return `https://github.com/${REGISTRY_REPO}/compare/${REGISTRY_BASE_BRANCH}...${branch}?expand=1`;
|
|
183
244
|
}
|
|
245
|
+
}
|
|
184
246
|
|
|
185
|
-
|
|
186
|
-
for (const [type, items] of Object.entries(grouped)) {
|
|
187
|
-
bodyParts.push(`### ${type}`);
|
|
188
|
-
for (const item of items.sort((a, b) => a.slug.localeCompare(b.slug))) {
|
|
189
|
-
bodyParts.push(`- \`${item.slug}\` (${item.namespace})`);
|
|
190
|
-
}
|
|
191
|
-
bodyParts.push('');
|
|
192
|
-
}
|
|
247
|
+
// ── Main push pipeline ────────────────────────────────────────────────
|
|
193
248
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
249
|
+
function doPush(files, awHome, dryRun) {
|
|
250
|
+
const added = files.filter(f => !f.deleted);
|
|
251
|
+
const deleted = files.filter(f => f.deleted);
|
|
198
252
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
253
|
+
const addedParts = Object.entries(groupBy(added, 'type')).map(([t, items]) => `${items.length} ${t}`);
|
|
254
|
+
const deletedParts = Object.entries(groupBy(deleted, 'type')).map(([t, items]) => `${items.length} ${t} removed`);
|
|
255
|
+
const countParts = [...addedParts, ...deletedParts];
|
|
202
256
|
|
|
203
|
-
// ── Git pipeline (works for single file or batch) ────────────────────
|
|
204
|
-
|
|
205
|
-
function pushFiles(files, { repo, dryRun, workspaceDir }) {
|
|
206
|
-
// Summary
|
|
207
|
-
const counts = {};
|
|
208
|
-
for (const f of files) {
|
|
209
|
-
counts[f.type] = (counts[f.type] || 0) + 1;
|
|
210
|
-
}
|
|
211
|
-
const countParts = Object.entries(counts).map(([type, count]) => `${count} ${type}`);
|
|
212
257
|
fmt.logInfo(`${chalk.bold(files.length)} file${files.length > 1 ? 's' : ''} to push (${countParts.join(', ')})`);
|
|
213
258
|
|
|
214
259
|
if (dryRun) {
|
|
215
260
|
for (const f of files) {
|
|
216
261
|
const ns = chalk.dim(` [${f.namespace}]`);
|
|
217
|
-
|
|
262
|
+
const label = f.deleted ? chalk.red('DELETE') : chalk.yellow(f.type);
|
|
263
|
+
fmt.logMessage(` ${label}/${f.slug}${ns}`);
|
|
218
264
|
}
|
|
219
265
|
fmt.logWarn('No changes made (--dry-run)');
|
|
220
266
|
fmt.outro(chalk.dim('Remove --dry-run to push'));
|
|
221
267
|
return;
|
|
222
268
|
}
|
|
223
269
|
|
|
224
|
-
|
|
225
|
-
s.start('Cloning registry...');
|
|
226
|
-
|
|
227
|
-
const tempDir = mkdtempSync(join(tmpdir(), 'aw-upload-'));
|
|
228
|
-
|
|
270
|
+
// Make sure we're on main before branching
|
|
229
271
|
try {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
const branch = generateBranchName(files);
|
|
236
|
-
execSync(`git checkout -b ${branch}`, { cwd: tempDir, stdio: 'pipe' });
|
|
237
|
-
|
|
238
|
-
const s2 = fmt.spinner();
|
|
239
|
-
s2.start(`Copying ${files.length} file${files.length > 1 ? 's' : ''} to registry...`);
|
|
240
|
-
|
|
241
|
-
// Copy each file
|
|
242
|
-
let copyErrors = [];
|
|
243
|
-
for (const file of files) {
|
|
244
|
-
try {
|
|
245
|
-
const targetFull = join(tempDir, file.registryTarget);
|
|
246
|
-
mkdirSync(dirname(targetFull), { recursive: true });
|
|
247
|
-
if (file.isDir) {
|
|
248
|
-
cpSync(file.absPath, targetFull, { recursive: true });
|
|
249
|
-
} else {
|
|
250
|
-
cpSync(file.absPath, targetFull);
|
|
251
|
-
}
|
|
252
|
-
} catch (e) {
|
|
253
|
-
copyErrors.push({ file: file.registryTarget, error: e.message });
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
if (copyErrors.length > 0) {
|
|
258
|
-
for (const err of copyErrors) {
|
|
259
|
-
fmt.logWarn(`Failed to copy ${err.file}: ${err.error}`);
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// Check for new namespaces — auto-add CODEOWNERS entries
|
|
264
|
-
const topNamespaces = [...new Set(files.map(f => f.namespace.split('/')[0]))];
|
|
265
|
-
const codeownersPath = join(tempDir, 'CODEOWNERS');
|
|
266
|
-
const newNamespaces = [];
|
|
267
|
-
const ghUser = getGitHubUser();
|
|
268
|
-
for (const ns of topNamespaces) {
|
|
269
|
-
if (ghUser && isNewNamespaceInCodeowners(codeownersPath, ns)) {
|
|
270
|
-
newNamespaces.push(ns);
|
|
271
|
-
// Create CODEOWNERS if it doesn't exist yet
|
|
272
|
-
const line = `/${REGISTRY_DIR}/${ns}/ @${ghUser}\n`;
|
|
273
|
-
appendFileSync(codeownersPath, line);
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
if (newNamespaces.length > 0 && existsSync(codeownersPath)) {
|
|
277
|
-
execSync('git add CODEOWNERS', { cwd: tempDir, stdio: 'pipe' });
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// Stage all registry changes
|
|
281
|
-
execSync(`git add "${REGISTRY_DIR}"`, { cwd: tempDir, stdio: 'pipe' });
|
|
272
|
+
checkoutMain(awHome);
|
|
273
|
+
} catch (e) {
|
|
274
|
+
fmt.cancel(`Could not checkout main: ${e.message}`);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
282
277
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
278
|
+
const branch = generateBranchName(files);
|
|
279
|
+
|
|
280
|
+
// Handle CODEOWNERS for new namespaces
|
|
281
|
+
const topNamespaces = [...new Set(files.map(f => f.namespace.split('/')[0]))];
|
|
282
|
+
const codeownersPath = join(awHome, 'CODEOWNERS');
|
|
283
|
+
const newNamespaces = [];
|
|
284
|
+
const ghUser = getGitHubUser();
|
|
285
|
+
for (const ns of topNamespaces) {
|
|
286
|
+
if (ghUser && isNewNamespaceInCodeowners(codeownersPath, ns)) {
|
|
287
|
+
newNamespaces.push(ns);
|
|
288
|
+
const line = `/${REGISTRY_DIR}/${ns}/ @${ghUser}\n`;
|
|
289
|
+
appendFileSync(codeownersPath, line);
|
|
288
290
|
}
|
|
291
|
+
}
|
|
289
292
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
s2.stop('Upload prepared');
|
|
297
|
-
|
|
298
|
-
const s3 = fmt.spinner();
|
|
299
|
-
s3.start('Pushing and creating PR...');
|
|
300
|
-
|
|
301
|
-
execSync(`git push -u origin ${branch}`, { cwd: tempDir, stdio: 'pipe' });
|
|
302
|
-
|
|
303
|
-
const prBody = generatePrBody(files, newNamespaces);
|
|
304
|
-
|
|
305
|
-
// Try gh for PR creation, fall back to manual URL
|
|
306
|
-
let prUrl;
|
|
307
|
-
try {
|
|
308
|
-
prUrl = execFileSync('gh', [
|
|
309
|
-
'pr', 'create',
|
|
310
|
-
'--base', REGISTRY_BASE_BRANCH,
|
|
311
|
-
'--title', prTitle,
|
|
312
|
-
'--body', prBody,
|
|
313
|
-
], { cwd: tempDir, encoding: 'utf8' }).trim();
|
|
314
|
-
} catch {
|
|
315
|
-
const repoBase = repo.replace(/\.git$/, '');
|
|
316
|
-
prUrl = `https://github.com/${repoBase}/compare/${REGISTRY_BASE_BRANCH}...${branch}?expand=1`;
|
|
317
|
-
}
|
|
293
|
+
// Build list of repo-relative paths to stage
|
|
294
|
+
const pathsToStage = files.map(f => f.registryTarget);
|
|
295
|
+
if (newNamespaces.length > 0 && existsSync(codeownersPath)) {
|
|
296
|
+
pathsToStage.push('CODEOWNERS');
|
|
297
|
+
}
|
|
318
298
|
|
|
319
|
-
|
|
299
|
+
const commitMsg = generateCommitMsg(files);
|
|
300
|
+
const prTitle = generatePrTitle(files);
|
|
301
|
+
const prBody = generatePrBody(files, newNamespaces);
|
|
320
302
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
fmt.logInfo(`New namespace${newNamespaces.length > 1 ? 's' : ''} ${chalk.cyan(newNamespaces.join(', '))} — CODEOWNERS entr${newNamespaces.length > 1 ? 'ies' : 'y'} added`);
|
|
324
|
-
}
|
|
325
|
-
if (files.length > 1) {
|
|
326
|
-
for (const [type, items] of Object.entries(groupBy(files, 'type'))) {
|
|
327
|
-
fmt.logSuccess(`${items.length} ${type} pushed`);
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
// Update manifest — mark pushed files as synced (set registry_sha256 = sha256)
|
|
331
|
-
if (workspaceDir) {
|
|
332
|
-
const manifest = loadManifest(workspaceDir);
|
|
333
|
-
for (const file of files) {
|
|
334
|
-
// Convert registryTarget back to manifest key (strip REGISTRY_DIR/ prefix)
|
|
335
|
-
const manifestKey = file.registryTarget.replace(`${REGISTRY_DIR}/`, '');
|
|
336
|
-
if (manifest.files[manifestKey]) {
|
|
337
|
-
manifest.files[manifestKey].registry_sha256 = manifest.files[manifestKey].sha256;
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
saveManifest(workspaceDir, manifest);
|
|
341
|
-
}
|
|
303
|
+
const s = fmt.spinner();
|
|
304
|
+
s.start('Creating branch and pushing...');
|
|
342
305
|
|
|
343
|
-
|
|
344
|
-
|
|
306
|
+
let finalBranch;
|
|
307
|
+
try {
|
|
308
|
+
finalBranch = createPushBranch(awHome, branch, pathsToStage, commitMsg);
|
|
309
|
+
s.stop('Branch pushed');
|
|
345
310
|
} catch (e) {
|
|
311
|
+
s.stop(chalk.red('Push failed'));
|
|
346
312
|
fmt.cancel(`Push failed: ${e.message}`);
|
|
347
|
-
|
|
348
|
-
execSync(`rm -rf "${tempDir}"`, { stdio: 'pipe' });
|
|
313
|
+
return;
|
|
349
314
|
}
|
|
350
|
-
}
|
|
351
315
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
316
|
+
const s2 = fmt.spinner();
|
|
317
|
+
s2.start('Creating PR...');
|
|
318
|
+
const prUrl = createPR(awHome, finalBranch, prTitle, prBody);
|
|
319
|
+
s2.stop('PR created');
|
|
320
|
+
|
|
321
|
+
// Go back to main
|
|
322
|
+
try {
|
|
323
|
+
checkoutMain(awHome);
|
|
324
|
+
} catch { /* best effort */ }
|
|
325
|
+
|
|
326
|
+
if (newNamespaces.length > 0) {
|
|
327
|
+
fmt.logInfo(`New namespace${newNamespaces.length > 1 ? 's' : ''} ${chalk.cyan(newNamespaces.join(', '))} — CODEOWNERS entr${newNamespaces.length > 1 ? 'ies' : 'y'} added`);
|
|
358
328
|
}
|
|
359
|
-
|
|
329
|
+
fmt.logSuccess(`PR: ${chalk.cyan(prUrl)}`);
|
|
330
|
+
fmt.outro('Push complete — branch kept locally for iteration');
|
|
360
331
|
}
|
|
361
332
|
|
|
362
333
|
// ── Main command ─────────────────────────────────────────────────────
|
|
@@ -364,19 +335,57 @@ function groupBy(arr, key) {
|
|
|
364
335
|
export function pushCommand(args) {
|
|
365
336
|
const input = args._positional?.[0];
|
|
366
337
|
const dryRun = args['--dry-run'] === true;
|
|
367
|
-
const repo = args['--repo'] || REGISTRY_REPO;
|
|
368
338
|
const cwd = process.cwd();
|
|
339
|
+
|
|
340
|
+
const HOME = homedir();
|
|
341
|
+
const awHome = join(HOME, '.aw');
|
|
342
|
+
const registrySubDir = join(awHome, REGISTRY_DIR);
|
|
343
|
+
// workspaceDir for resolveInput: use local .aw_registry if present, otherwise global
|
|
369
344
|
const workspaceDir = join(cwd, '.aw_registry');
|
|
370
345
|
|
|
371
346
|
fmt.intro('aw push');
|
|
372
347
|
|
|
373
|
-
|
|
348
|
+
const repoUrl = `https://github.com/${REGISTRY_REPO}.git`;
|
|
349
|
+
if (!isValidClone(awHome, repoUrl)) {
|
|
350
|
+
fmt.cancel('Registry not initialized. Run: aw init');
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// No args = detect changed files via git status
|
|
374
355
|
if (!input) {
|
|
375
|
-
const
|
|
356
|
+
const changes = detectChanges(awHome, REGISTRY_DIR);
|
|
357
|
+
const allEntries = [
|
|
358
|
+
...changes.modified.map(e => ({ ...e, deleted: false })),
|
|
359
|
+
...changes.untracked.map(e => ({ ...e, deleted: false })),
|
|
360
|
+
...changes.deleted.map(e => ({ ...e, deleted: true })),
|
|
361
|
+
];
|
|
362
|
+
|
|
363
|
+
if (allEntries.length === 0) {
|
|
364
|
+
fmt.cancel('Nothing to push — no modified, new, or deleted files.\n\n Use `aw status` to see workspace state.');
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const files = allEntries
|
|
369
|
+
.map(f => {
|
|
370
|
+
const meta = parseRegistryPath(f.registryPath);
|
|
371
|
+
if (!meta) return null;
|
|
372
|
+
return {
|
|
373
|
+
absPath: join(awHome, f.path),
|
|
374
|
+
registryTarget: f.path,
|
|
375
|
+
type: meta.type,
|
|
376
|
+
namespace: meta.namespace,
|
|
377
|
+
slug: meta.slug,
|
|
378
|
+
deleted: f.deleted,
|
|
379
|
+
};
|
|
380
|
+
})
|
|
381
|
+
.filter(Boolean);
|
|
382
|
+
|
|
376
383
|
if (files.length === 0) {
|
|
377
|
-
fmt.cancel('Nothing
|
|
384
|
+
fmt.cancel('Nothing pushable found — changes must be under agents/, skills/, commands/, or evals/.');
|
|
385
|
+
return;
|
|
378
386
|
}
|
|
379
|
-
|
|
387
|
+
|
|
388
|
+
doPush(files, awHome, dryRun);
|
|
380
389
|
return;
|
|
381
390
|
}
|
|
382
391
|
|
|
@@ -388,6 +397,7 @@ export function pushCommand(args) {
|
|
|
388
397
|
? `\n\n Tip: Use the .aw_registry/ path instead:\n aw push .aw_registry/${input.split('/').slice(1).join('/')}`
|
|
389
398
|
: '';
|
|
390
399
|
fmt.cancel(`Could not resolve "${input}" to a registry path.${hint}\n\n Only files inside .aw_registry/ can be pushed.`);
|
|
400
|
+
return;
|
|
391
401
|
}
|
|
392
402
|
|
|
393
403
|
let absPath = resolved.localAbsPath;
|
|
@@ -396,20 +406,26 @@ export function pushCommand(args) {
|
|
|
396
406
|
absPath = absPath + '.md';
|
|
397
407
|
} else {
|
|
398
408
|
fmt.cancel(`Path not found: ${absPath || input}\n\n Only files inside .aw_registry/ can be pushed.\n Use ${chalk.dim('aw status')} to see modified files.`);
|
|
409
|
+
return;
|
|
399
410
|
}
|
|
400
411
|
}
|
|
401
412
|
|
|
402
|
-
// Folder/namespace input → batch push
|
|
413
|
+
// Folder/namespace input → batch push using registry content in awHome
|
|
403
414
|
if (statSync(absPath).isDirectory()) {
|
|
404
|
-
const
|
|
415
|
+
const relFromRegistry = absPath.startsWith(workspaceDir + '/')
|
|
416
|
+
? absPath.slice(workspaceDir.length + 1)
|
|
417
|
+
: resolved.registryPath;
|
|
418
|
+
const registryAbsPath = join(registrySubDir, relFromRegistry);
|
|
419
|
+
const files = collectBatchFiles(registryAbsPath, registrySubDir);
|
|
405
420
|
if (files.length === 0) {
|
|
406
421
|
fmt.cancel(`Nothing to push in ${chalk.cyan(input)} — no agents, skills, commands, or evals found.`);
|
|
422
|
+
return;
|
|
407
423
|
}
|
|
408
|
-
|
|
424
|
+
doPush(files, awHome, dryRun);
|
|
409
425
|
return;
|
|
410
426
|
}
|
|
411
427
|
|
|
412
|
-
// Single file input
|
|
428
|
+
// Single file input
|
|
413
429
|
const regParts = resolved.registryPath.split('/');
|
|
414
430
|
let typeIdx = -1;
|
|
415
431
|
for (let i = regParts.length - 1; i >= 0; i--) {
|
|
@@ -427,6 +443,7 @@ export function pushCommand(args) {
|
|
|
427
443
|
` aw push .aw_registry/platform/services/skills/development`,
|
|
428
444
|
` aw push .aw_registry/commerce/shared/commands/ship.md`,
|
|
429
445
|
].join('\n'));
|
|
446
|
+
return;
|
|
430
447
|
}
|
|
431
448
|
|
|
432
449
|
const namespaceParts = regParts.slice(0, typeIdx);
|
|
@@ -438,32 +455,25 @@ export function pushCommand(args) {
|
|
|
438
455
|
? `${REGISTRY_DIR}/${namespacePath}/${parentDir}/${slug}`
|
|
439
456
|
: `${REGISTRY_DIR}/${namespacePath}/${parentDir}/${slug}.md`;
|
|
440
457
|
|
|
441
|
-
|
|
458
|
+
doPush([{
|
|
442
459
|
absPath,
|
|
443
460
|
registryTarget,
|
|
444
461
|
type: parentDir,
|
|
445
462
|
namespace: namespacePath,
|
|
446
463
|
slug,
|
|
447
464
|
isDir,
|
|
448
|
-
|
|
465
|
+
deleted: false,
|
|
466
|
+
}], awHome, dryRun);
|
|
449
467
|
}
|
|
450
468
|
|
|
451
|
-
// ──
|
|
469
|
+
// ── Utilities ─────────────────────────────────────────────────────────
|
|
452
470
|
|
|
453
|
-
function
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
} catch {
|
|
460
|
-
return null;
|
|
461
|
-
}
|
|
471
|
+
function groupBy(arr, key) {
|
|
472
|
+
const result = {};
|
|
473
|
+
for (const item of arr) {
|
|
474
|
+
const k = item[key];
|
|
475
|
+
if (!result[k]) result[k] = [];
|
|
476
|
+
result[k].push(item);
|
|
462
477
|
}
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
function isNewNamespaceInCodeowners(codeownersPath, namespace) {
|
|
466
|
-
if (!existsSync(codeownersPath)) return true;
|
|
467
|
-
const content = readFileSync(codeownersPath, 'utf8');
|
|
468
|
-
return !content.includes(`/${REGISTRY_DIR}/${namespace}/`);
|
|
478
|
+
return result;
|
|
469
479
|
}
|