-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathbundler.ts
More file actions
285 lines (255 loc) · 10.5 KB
/
bundler.ts
File metadata and controls
285 lines (255 loc) · 10.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
import * as core from '@actions/core';
import * as exec from '@actions/exec';
import * as glob from '@actions/glob';
import * as fs from 'fs';
import * as path from 'path';
export type BundleFormat = 'apm' | 'plugin';
export interface ExtractResult {
files: number;
verified: boolean;
format: BundleFormat;
}
/**
* Resolve a local bundle path (may contain glob patterns) to a single file.
* Errors if zero or multiple files match.
*/
export async function resolveLocalBundle(pattern: string, workspaceDir: string): Promise<string> {
const resolvedWorkspace = path.resolve(workspaceDir);
// If the pattern is an absolute path without globs, use it directly
const resolvedPattern = path.isAbsolute(pattern) ? pattern : path.join(resolvedWorkspace, pattern);
const globber = await glob.create(resolvedPattern, { followSymbolicLinks: false });
const matches = await globber.glob();
if (matches.length === 0) {
throw new Error(`No bundle found matching: ${pattern}`);
}
if (matches.length > 1) {
const list = matches.map(m => path.relative(resolvedWorkspace, m)).join(', ');
throw new Error(`Multiple bundles match '${pattern}': ${list}. Use an exact path.`);
}
const resolvedBundle = path.resolve(matches[0]);
// Path traversal protection for relative patterns: ensure resolved path stays
// within the workspace. Absolute patterns are user-explicit and not checked —
// the user intentionally specified a location (e.g. /tmp/gh-aw/apm-bundle/).
if (!path.isAbsolute(pattern)) {
const relative = path.relative(resolvedWorkspace, resolvedBundle);
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error(`Bundle path "${pattern}" resolves outside the workspace`);
}
}
return resolvedBundle;
}
/**
* Inspect a bundle archive to determine its format without extracting it.
*
* Reads the tar table-of-contents (`tar tzf`) and looks for the format
* markers:
* - APM bundle: `apm.lock.yaml` (lockfile-driven, .github/.claude trees)
* - Plugin bundle: `plugin.json` at the bundle root (Claude Code marketplace
* layout, flat agents/skills/commands/instructions/ dirs, no lockfile)
*
* Returns the detected format. Throws if neither marker is present, or if
* BOTH are present (ambiguous archive -- almost certainly a build error).
*
* Bundles always have a single top-level wrapper directory (the package
* versioned dir, e.g. `pack-test-1.0.0/`). We accept the marker at any depth
* inside the wrapper to stay tolerant of archive shape changes.
*/
export async function detectBundleFormat(bundlePath: string): Promise<BundleFormat> {
const list = await exec.getExecOutput('tar', ['tzf', bundlePath], {
ignoreReturnCode: true,
silent: true,
});
if (list.exitCode !== 0) {
throw new Error(
`Failed to list bundle contents (tar tzf exit ${list.exitCode}): `
+ (list.stderr.trim() || 'unknown error'),
);
}
const entries = list.stdout.split('\n').map(l => l.trim()).filter(Boolean);
// APM and plugin bundles always wrap their contents in a single top-level
// directory named after the package (e.g. `roundtrip-1.0.0/`). Match the
// format markers ONLY at that depth to avoid false positives from a nested
// file that happens to be named `plugin.json` or `apm.lock.yaml` inside a
// dependency's payload (e.g. a plugin that ships its own example fixtures).
const hasLockfile = entries.some(e => /^[^/]+\/apm\.lock\.yaml$/.test(e));
const hasPluginJson = entries.some(e => /^[^/]+\/plugin\.json$/.test(e));
if (hasLockfile && hasPluginJson) {
throw new Error(
`Bundle ${path.basename(bundlePath)} contains both apm.lock.yaml and plugin.json -- `
+ `ambiguous format. Re-pack with a single --format value.`,
);
}
if (hasLockfile) return 'apm';
if (hasPluginJson) return 'plugin';
throw new Error(
`Bundle ${path.basename(bundlePath)} contains neither apm.lock.yaml nor plugin.json. `
+ `Cannot determine bundle format -- the archive may be corrupt or produced by an `
+ `unsupported tool.`,
);
}
export async function extractBundle(bundlePath: string, outputDir: string): Promise<ExtractResult> {
const resolvedBundle = path.resolve(bundlePath);
const resolvedOutput = path.resolve(outputDir);
if (!fs.existsSync(resolvedBundle)) {
throw new Error(`Bundle not found: ${bundlePath}`);
}
// Detect the bundle format up-front. Plugin-format restore is rejected with
// a clear message: deploying plugin bundles into a workspace is a different
// contract (no lockfile to drive deployed_files, files land at workspace
// root, plugin.json may collide with project files). That belongs in
// `apm unpack` upstream, not here. See PR description for the deferred RFC.
const format = await detectBundleFormat(resolvedBundle);
if (format === 'plugin') {
throw new Error(
`Plugin-format bundle restore is not supported by this action. `
+ `The bundle at ${path.basename(bundlePath)} was packed with --format plugin `
+ `(no apm.lock.yaml, flat plugin layout). Note: 'apm unpack' itself also `
+ `rejects plugin-format bundles -- this is an upstream limitation, not just `
+ `an action constraint. To fix:\n`
+ ` 1. Re-pack the upstream bundle in apm format. If you control the pack step, `
+ `set 'bundle-format: apm' on apm-action (this is the action's default), or run `
+ `'apm pack --format apm --archive' directly.\n`
+ ` 2. If the bundle was published by a third party, restore it with your `
+ `plugin tooling (e.g. Claude Code plugin install) instead of this action.`,
);
}
// APM-format path: prefer `apm unpack` (provides verification),
// fall back to `tar xzf` if APM is unavailable.
const apmAvailable = await exec.exec('apm', ['--version'], {
ignoreReturnCode: true,
silent: true,
}).catch(() => 1) === 0;
if (apmAvailable) {
core.info('Using apm unpack (with verification)...');
const rc = await exec.exec('apm', ['unpack', resolvedBundle, '-o', resolvedOutput], {
ignoreReturnCode: true,
});
if (rc !== 0) {
throw new Error(`apm unpack failed with exit code ${rc}`);
}
const files = countDeployedFiles(resolvedOutput);
return { files, verified: true, format };
}
// Fallback: tar extraction.
//
// Defense-in-depth: even if this path ever runs again (e.g. if a future
// change reintroduces a "skip apm install" mode, or apm install transiently
// fails), exclude the lockfile + manifest. They are bundle metadata, not
// deployable output -- the same files that `apm unpack` (the primary path)
// intentionally never copies. Leaking them into a git checkout dirties the
// workspace and breaks downstream `git checkout` steps. See microsoft/apm-action#26.
core.info('APM not available -- extracting with tar (no verification)...');
const rc = await exec.exec('tar', [
'xzf', resolvedBundle,
'-C', resolvedOutput,
'--strip-components=1',
'--exclude=apm.lock.yaml',
'--exclude=apm.lock',
'--exclude=apm.yml',
], {
ignoreReturnCode: true,
});
if (rc !== 0) {
throw new Error(`tar extraction failed with exit code ${rc}`);
}
const files = countDeployedFiles(resolvedOutput);
return { files, verified: false, format };
}
/**
* Run `apm pack` after install and return the path to the produced bundle
* along with the format that was used.
*/
export async function runPackStep(
workingDir: string,
opts: { target?: string; archive: boolean; format: BundleFormat },
): Promise<{ bundlePath: string; format: BundleFormat }> {
const resolvedDir = path.resolve(workingDir);
const buildDir = path.join(resolvedDir, 'build');
// Always pass --format explicitly so this action's behavior is robust to
// any future change in the apm CLI's default. The action's contract is
// the action's, not the CLI's.
const args = ['pack', '-o', buildDir, '--format', opts.format];
if (opts.target) {
args.push('--target', opts.target);
}
if (opts.archive) {
args.push('--archive');
}
core.info(`Running: apm ${args.join(' ')}`);
const rc = await exec.exec('apm', args, {
cwd: resolvedDir,
ignoreReturnCode: true,
env: { ...process.env as Record<string, string> },
});
if (rc !== 0) {
throw new Error(`apm pack failed with exit code ${rc}`);
}
// Find the produced bundle in build/
const bundlePath = findBundle(buildDir, opts.archive);
core.info(`Bundle produced: ${bundlePath}`);
return { bundlePath, format: opts.format };
}
/**
* Find the bundle output in the build directory.
* For archives: look for .tar.gz files.
* For directories: look for non-hidden directories.
*/
function findBundle(buildDir: string, archive: boolean): string {
if (!fs.existsSync(buildDir)) {
throw new Error(`Build directory not found: ${buildDir}`);
}
const entries = fs.readdirSync(buildDir);
if (archive) {
const archives = entries.filter(e => e.endsWith('.tar.gz')).sort();
if (archives.length === 0) {
throw new Error('No .tar.gz archive found in build directory after apm pack');
}
if (archives.length > 1) {
throw new Error(
`Multiple .tar.gz archives found in build directory after apm pack: ${archives.join(', ')}`,
);
}
return path.join(buildDir, archives[0]);
}
// Directory mode: find the first non-hidden directory
const dirs = entries.filter(e => {
if (e.startsWith('.')) return false;
return fs.statSync(path.join(buildDir, e)).isDirectory();
}).sort();
if (dirs.length === 0) {
throw new Error('No bundle directory found in build directory after apm pack');
}
if (dirs.length > 1) {
throw new Error(
`Multiple bundle directories found in build directory after apm pack: ${dirs.join(', ')}`,
);
}
return path.join(buildDir, dirs[0]);
}
/**
* Count deployed primitive files under .github/ for reporting.
*/
function countDeployedFiles(rootDir: string): number {
const githubDir = path.join(rootDir, '.github');
const claudeDir = path.join(rootDir, '.claude');
let count = 0;
for (const dir of [githubDir, claudeDir]) {
if (fs.existsSync(dir)) {
count += countFilesRecursive(dir);
}
}
return count;
}
function countFilesRecursive(dir: string): number {
let count = 0;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name.startsWith('.')) continue;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
count += countFilesRecursive(fullPath);
} else {
count++;
}
}
return count;
}