amxx-builder 1.6.0 → 1.7.0

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.
Files changed (47) hide show
  1. package/AGENTS.md +13 -3
  2. package/README.md +276 -20
  3. package/action.yml +1 -1
  4. package/defaults/amxbuild.defaults.yml +7 -1
  5. package/mcp/handlers.js +342 -152
  6. package/mcp/registry.js +264 -25
  7. package/package.json +1 -1
  8. package/skills/amxb-migration/SKILL.md +126 -27
  9. package/skills/amxx-pawn-style/SKILL.md +422 -0
  10. package/src/agent-assets.js +206 -0
  11. package/src/asset-fetcher.js +33 -48
  12. package/src/build-plan.js +22 -5
  13. package/src/build-service.js +54 -11
  14. package/src/cli.js +38 -4
  15. package/src/collector.js +9 -1
  16. package/src/commands/build.js +5 -4
  17. package/src/commands/dry-run.js +28 -1
  18. package/src/commands/init.js +125 -11
  19. package/src/commands/opencode-skills.js +47 -0
  20. package/src/commands/serve.js +309 -111
  21. package/src/commands/skills-dir.js +21 -0
  22. package/src/commands/watch.js +66 -23
  23. package/src/compile-utils.js +119 -4
  24. package/src/compiler-fetcher.js +196 -41
  25. package/src/compiler.js +68 -29
  26. package/src/dep-graph.js +27 -0
  27. package/src/deployer.js +39 -15
  28. package/src/deps-resolver.js +151 -11
  29. package/src/deps-tree.js +20 -9
  30. package/src/download.js +132 -0
  31. package/src/fs-utils.js +35 -1
  32. package/src/fungun-fetcher.js +28 -8
  33. package/src/github-api.js +32 -0
  34. package/src/include-tree.js +30 -61
  35. package/src/ini-builder.js +1 -1
  36. package/src/jsonrpc-transport.js +53 -5
  37. package/src/local-sources.js +363 -0
  38. package/src/manifest-path.js +18 -1
  39. package/src/manifest.js +398 -40
  40. package/src/opencode-skills.js +262 -0
  41. package/src/release-fetcher.js +26 -33
  42. package/src/repo-fetcher.js +388 -37
  43. package/src/retry.js +3 -1
  44. package/src/schema.js +1 -1
  45. package/templates/init-deploy.env +9 -0
  46. package/templates/init-gitignore +22 -0
  47. package/src/dep-docs.js +0 -115
@@ -3,16 +3,13 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const crypto = require('crypto');
6
- const axios = require('axios');
7
- // Default for API calls; download sites pass their own longer timeout.
8
- axios.defaults.timeout = 30000;
9
6
  const AdmZip = require('adm-zip');
10
7
 
11
8
  const chalk = require('chalk');
12
- const { safeExtractTar } = require('./fs-utils');
9
+ const { safeExtractTar, makeSiblingTmpDir, publishDir } = require('./fs-utils');
10
+ const { downloadToFile } = require('./download');
13
11
  const logger = require('./logger');
14
12
  const { getCacheDir } = require('./cache-dir');
15
- const { withRetry } = require('./retry');
16
13
  const { getAmxmodxFullDir, getHostPlatform } = require('./compiler-fetcher');
17
14
  const { getReleaseCacheDir } = require('./release-fetcher');
18
15
  const { resolveGithubToken } = require('./manifest');
@@ -91,47 +88,34 @@ async function resolveUrlSource(source, manifestDir, buildDir, noFetch) {
91
88
 
92
89
  const filename = getFilenameFromUrl(source.url);
93
90
  logger.step(`Assets: downloading ${filename}...`);
94
- fs.mkdirSync(cacheDir, { recursive: true });
95
-
96
- const bar = require('./progress').createBar(100, ` ${chalk.cyan('Downloading')} ${(filename || 'file').padEnd(30)}`);
97
91
 
92
+ // Download + extract into a unique sibling temp dir, then rename into place:
93
+ // an interrupted run leaves only the temp dir (never a sentinel-marked cache)
94
+ // and concurrent fetches of the same URL cannot interleave writes.
95
+ const tmpDir = makeSiblingTmpDir(cacheDir);
98
96
  try {
99
- const response = await withRetry(
100
- () => axios.get(source.url, {
101
- responseType: 'arraybuffer',
102
- maxRedirects: 5,
103
- timeout: 600000, // large archives — allow slow links, still bound hangs
104
- onDownloadProgress: (e) => {
105
- if (bar && e.total) {
106
- bar.update(Math.round(e.loaded / e.total * 100));
107
- }
108
- },
109
- }),
110
- { label: filename }
111
- );
112
- if (bar) bar.stop();
113
- const contentType = response.headers['content-type'] || '';
114
- const data = Buffer.from(response.data);
97
+ const downloadPath = path.join(tmpDir, filename);
98
+ const { headers } = await downloadToFile(source.url, downloadPath, {
99
+ progressLabel: ` ${chalk.cyan('Downloading')} ${(filename || 'file').padEnd(30)}`,
100
+ });
101
+ const contentType = headers['content-type'] || '';
115
102
 
116
103
  if (isArchive(filename, contentType)) {
117
- extractArchive(data, filename, cacheDir);
118
- } else {
119
- const filePath = path.join(cacheDir, filename);
120
- const part = filePath + '.part';
121
- fs.writeFileSync(part, data);
122
- fs.renameSync(part, filePath);
104
+ extractArchiveFile(downloadPath, tmpDir);
123
105
  }
124
106
 
125
- const sentinelTmp = sentinel + '.tmp';
107
+ // Sentinel written into tmpDir first: only complete dirs become the cache.
108
+ const sentinelTmp = path.join(tmpDir, '.cached.tmp');
126
109
  fs.writeFileSync(sentinelTmp, JSON.stringify({ url: source.url, cached_at: new Date().toISOString() }));
127
- fs.renameSync(sentinelTmp, sentinel);
110
+ fs.renameSync(sentinelTmp, path.join(tmpDir, '.cached'));
111
+ publishDir(tmpDir, cacheDir, () => fs.existsSync(sentinel));
128
112
  logger.info(`Assets: ${filename} ready`);
129
113
  return cacheDir;
130
114
  } catch (err) {
115
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch (_) {}
131
116
  // Never rmSync the whole cache dir: it may be a shared 'global' entry used
132
- // by parallel sources or other projects. Invalidate the sentinel only and
133
- // leave the content to be overwritten on the next fetch.
134
- try { fs.rmSync(sentinel, { force: true }); } catch (_) {}
117
+ // by parallel sources or other projects. Only the unique temp dir above is
118
+ // ours to remove; a stale marker-less cache dir is replaced on next fetch.
135
119
  throw new Error(`Failed to fetch asset ${source.url}: ${err.message}`);
136
120
  }
137
121
  }
@@ -157,25 +141,26 @@ function isArchive(filename, contentType) {
157
141
  return /zip|tar|gzip|x-compressed/.test(contentType);
158
142
  }
159
143
 
160
- function extractArchive(data, filename, destDir) {
161
- const isZip = /\.zip$/i.test(filename) || isZipMagic(data);
144
+ function extractArchiveFile(archivePath, destDir) {
145
+ const isZip = /\.zip$/i.test(archivePath) || isZipMagicFile(archivePath);
162
146
  if (isZip) {
163
- new AdmZip(data).extractAllTo(destDir, true);
164
- return;
165
- }
166
- const tmpFile = path.join(destDir, filename);
167
- fs.writeFileSync(tmpFile, data);
168
- try {
169
- safeExtractTar(tmpFile, destDir);
170
- } finally {
171
- fs.rmSync(tmpFile, { force: true });
147
+ new AdmZip(archivePath).extractAllTo(destDir, true);
148
+ } else {
149
+ safeExtractTar(archivePath, destDir);
172
150
  }
151
+ fs.rmSync(archivePath, { force: true });
173
152
  }
174
153
 
175
154
  // ZIP archives start with "PK" — filename extensions are unreliable for
176
155
  // CDN/redirect URLs, so sniff the actual bytes when the name is inconclusive.
177
- function isZipMagic(buf) {
178
- return buf.length >= 2 && buf[0] === 0x50 && buf[1] === 0x4b;
156
+ function isZipMagicFile(filePath) {
157
+ try {
158
+ const fd = fs.openSync(filePath, 'r');
159
+ const buf = Buffer.alloc(2);
160
+ fs.readSync(fd, buf, 0, 2, 0);
161
+ fs.closeSync(fd);
162
+ return buf[0] === 0x50 && buf[1] === 0x4b;
163
+ } catch { return false; }
179
164
  }
180
165
 
181
166
  // ─── map application ──────────────────────────────────────────────────────────
package/src/build-plan.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const glob = require('fast-glob');
6
+ const { isLocal } = require('./local-sources');
6
7
 
7
8
  /**
8
9
  * Structured build plan — mirrors what printDryRun shows, but as data.
@@ -35,18 +36,23 @@ function buildPlanData(manifest, options = {}) {
35
36
  },
36
37
  repos: manifest.repos.map((r) => ({
37
38
  repo: r.repo,
38
- ref: r.ref || 'default branch',
39
+ source: isLocal(r) ? 'local' : (r.source || 'git'),
40
+ ref: isLocal(r) ? null : (r.ref || 'default branch'),
39
41
  amxmodx_dir: r.amxmodx_dir,
40
42
  deps_override: r.deps_override || null,
43
+ local_dir: r._localDir || null,
44
+ ...(r.ref_ttl !== undefined ? { ref_ttl: r.ref_ttl } : {}),
41
45
  })),
42
46
  globalDeps: manifest.globalDeps.map((d) => ({
43
- source: d.source,
44
- repo: d.source === 'fungun' ? null : d.repo,
45
- ref: d.ref,
47
+ source: isLocal(d) ? 'local' : d.source,
48
+ repo: isLocal(d) ? d.repo : (d.source === 'fungun' ? null : d.repo),
49
+ ref: isLocal(d) ? null : d.ref,
46
50
  id: d.id ?? null,
47
51
  url: d.url ?? null,
48
52
  include_path: d.include_path || null,
49
53
  asset: d.asset ?? null,
54
+ local_dir: d._localDir || null,
55
+ ...(d.ref_ttl !== undefined ? { ref_ttl: d.ref_ttl } : {}),
50
56
  })),
51
57
  assets: manifest.assets.sources.map((s) => {
52
58
  if (detailed) {
@@ -94,7 +100,18 @@ function buildPlanData(manifest, options = {}) {
94
100
  : path.join(path.resolve(out.dir), expand(out.archive_name)),
95
101
  amxmodx_path: expand(out.amxmodx_path) + '/',
96
102
  assets_path: out.assets_path ? expand(out.assets_path) + '/' : null,
97
- generate_ini: out.generate_ini,
103
+ // `pluginIni` is the resolved source of truth; hand-built fixtures
104
+ // (tests, external callers) may not carry it → fall back to the legacy
105
+ // raw output flag.
106
+ generate_ini: manifest.pluginIni ? manifest.pluginIni.enabled : Boolean(out.generate_ini),
107
+ plugins_ini: manifest.pluginIni
108
+ ? {
109
+ enabled: manifest.pluginIni.enabled,
110
+ default: manifest.pluginIni.defaultIni,
111
+ debug: manifest.pluginIni.defaultDebug,
112
+ force_debug: manifest.pluginIni.forceDebug,
113
+ }
114
+ : null,
98
115
  on_conflict: out.on_conflict,
99
116
  },
100
117
  };
@@ -37,14 +37,40 @@ const logger = require('./logger');
37
37
  const { emit, EVENTS } = require('./events');
38
38
  const { resolveGithubToken } = require('./manifest');
39
39
  const { fetchCompiler } = require('./compiler-fetcher');
40
- const { fetchRepo, resolveRepoRefs } = require('./repo-fetcher');
41
- const { resolveDeps, repoKey } = require('./deps-resolver');
40
+ const { resolveRepoRefs } = require('./repo-fetcher');
41
+ const { ensureRepoDir } = require('./local-sources');
42
+ const { resolveDeps, repoKey, normalizeRepo } = require('./deps-resolver');
42
43
  const { compilePlugins } = require('./compiler');
43
44
  const { collectAll } = require('./collector');
44
45
  const { fetchAssets } = require('./asset-fetcher');
45
46
  const { buildIniFiles } = require('./ini-builder');
46
47
  const { createArchive, copyOutput } = require('./archiver');
47
48
 
49
+ // Legacy manifest knobs (output.generate_ini, *.plugins_ini_postfix) still work
50
+ // but warn. Watch/serve call runBuild repeatedly with the same manifest, so the
51
+ // same warning is emitted only once per process to avoid rebuild-time noise.
52
+ const loggedDeprecations = new Set();
53
+
54
+ function logDeprecations(manifest) {
55
+ for (const message of manifest._deprecations || []) {
56
+ if (loggedDeprecations.has(message)) continue;
57
+ loggedDeprecations.add(message);
58
+ logger.warn(message);
59
+ }
60
+ }
61
+
62
+ // Same once-per-process dedupe as logDeprecations: watch/serve rebuild often.
63
+ const loggedForcedDebug = new Set();
64
+
65
+ function logForcedDebug(manifest) {
66
+ const forceDebug = manifest.pluginIni ? manifest.pluginIni.forceDebug : null;
67
+ if (forceDebug == null) return;
68
+ const message = `AMXB_PLUGINS_DEBUG — plugin debug forced ${forceDebug ? 'on' : 'off'} for this build`;
69
+ if (loggedForcedDebug.has(message)) return;
70
+ loggedForcedDebug.add(message);
71
+ logger.dim(message);
72
+ }
73
+
48
74
  /**
49
75
  * Run the full build pipeline for an already-resolved manifest.
50
76
  *
@@ -86,6 +112,9 @@ async function runBuild(manifest, options = {}) {
86
112
  } catch (_) { /* no subscriber — keep the original error surface */ }
87
113
  };
88
114
 
115
+ logDeprecations(manifest);
116
+ logForcedDebug(manifest);
117
+
89
118
  // Cancellation is checked between stages. Compilation itself is atomic per
90
119
  // plugin and runs to completion — cancellation only takes effect at the next
91
120
  // stage boundary (documented in the module docstring).
@@ -118,19 +147,32 @@ async function runBuild(manifest, options = {}) {
118
147
  await resolveRepoRefs(manifest.repos, (repo) => resolveGithubToken(manifest, repo));
119
148
  checkCancelled();
120
149
 
121
- const cloneJobs = new Map();
150
+ const cloneJobs = new Map(); // normalized repo identity → fetch promise
122
151
  for (const repoConfig of manifest.repos) {
123
- const key = repoKey(repoConfig);
124
- if (!cloneJobs.has(key)) {
125
- cloneJobs.set(key,
126
- fetchRepo(repoConfig.repo, repoConfig._resolvedRef, resolveGithubToken(manifest, repoConfig.repo), noFetch, manifest.github.ssh)
152
+ const identity = normalizeRepo(repoConfig);
153
+ if (!cloneJobs.has(identity)) {
154
+ // ensureRepoDir returns `_localDir` for local entries (never fetches).
155
+ cloneJobs.set(identity,
156
+ ensureRepoDir(repoConfig, {
157
+ token: resolveGithubToken(manifest, repoConfig.repo),
158
+ noFetch,
159
+ ssh: manifest.github.ssh,
160
+ })
127
161
  );
128
162
  }
129
163
  }
130
- const cloned = await Promise.all(
131
- [...cloneJobs.entries()].map(async ([key, p]) => ({ key, dir: await p }))
164
+ const cloned = new Map();
165
+ await Promise.all(
166
+ [...cloneJobs.entries()].map(async ([identity, p]) => {
167
+ const dir = await p;
168
+ cloned.set(identity, dir);
169
+ })
132
170
  );
133
- for (const { key, dir } of cloned) repoLocalDirs[key] = dir;
171
+ // Map every manifest entry (raw spelling) to its clone dir; duplicate
172
+ // spellings of the same repo share the clone but each repoKey stays valid.
173
+ for (const repoConfig of manifest.repos) {
174
+ repoLocalDirs[repoKey(repoConfig)] = cloned.get(normalizeRepo(repoConfig));
175
+ }
134
176
  checkCancelled();
135
177
  }
136
178
 
@@ -156,7 +198,7 @@ async function runBuild(manifest, options = {}) {
156
198
  );
157
199
 
158
200
  // ── 7. Generate plugins-*.ini ───────────────────────────────────────────
159
- if (manifest.output.generate_ini) {
201
+ if (manifest.pluginIni.enabled) {
160
202
  emitEvent(EVENTS.STAGE, { stage: 'ini', message: 'Generating plugins ini files' });
161
203
  buildIniFiles(compiledPlugins, buildDir);
162
204
  }
@@ -166,6 +208,7 @@ async function runBuild(manifest, options = {}) {
166
208
  if (noArchive) {
167
209
  logger.info('--no-archive: skipping zip creation');
168
210
  const elapsed = ((Date.now() - buildStart) / 1000).toFixed(1);
211
+ emitEvent(EVENTS.DONE, { ok: true, elapsed, noArchive: true, message: `Done in ${elapsed}s` });
169
212
  return { ok: true, elapsed, noArchive: true };
170
213
  }
171
214
 
package/src/cli.js CHANGED
@@ -19,6 +19,8 @@ const { runReleases } = require('./commands/releases');
19
19
  const { runInit, runInitInteractive } = require('./commands/init');
20
20
  const { runMcp } = require('./commands/mcp');
21
21
  const { runServe } = require('./commands/serve');
22
+ const { runSkillsDir } = require('./commands/skills-dir');
23
+ const { runOpencodeSkills } = require('./commands/opencode-skills');
22
24
 
23
25
  program
24
26
  .name('amxx-builder')
@@ -28,9 +30,13 @@ program
28
30
  // ─── Update check ──────────────────────────────────────────────────────────────
29
31
 
30
32
  program.hook('preAction', async () => {
31
- // The MCP server and the serve JSON-RPC server own stdout; an update notice
32
- // would corrupt the protocol stream.
33
- if (program.args[0] === 'mcp' || program.args[0] === 'serve') return;
33
+ // The MCP server, the serve JSON-RPC server, skills-dir and opencode-skills
34
+ // own stdout; an update notice would corrupt the protocol/parseable stream.
35
+ // The GitHub Action already pins a version and shouldn't make an extra
36
+ // network call.
37
+ if (program.args[0] === 'mcp' || program.args[0] === 'serve'
38
+ || program.args[0] === 'skills-dir' || program.args[0] === 'opencode-skills') return;
39
+ if (process.env.GITHUB_ACTIONS === 'true') return;
34
40
  try {
35
41
  const latest = await checkForUpdate();
36
42
  if (latest) {
@@ -245,10 +251,13 @@ program
245
251
  .option('--ci', 'Alias for --workflow')
246
252
  .option('--plugin <name>', 'Create amxmodx/scripting/<name>.sma')
247
253
  .option('--gitignore', 'Create .gitignore')
248
- .option('--opencode', 'Create .opencode/opencode.json with MCP config (amxb mcp)')
254
+ .option('--opencode', 'Create .opencode/opencode.json (MCP config) + skills bridge plugin (amxb mcp)')
249
255
  .option('--deploy', 'Create .env with deploy stubs (AMXB_DEPLOY_*)')
250
256
  .option('--script', 'Create build.bat and build.sh quick-build scripts')
257
+ .option('--vscode', 'Create .vscode/extensions.json with recommended extensions')
258
+ .option('--vsc', 'Alias for --vscode')
251
259
  .option('-f, --force', 'Overwrite existing files instead of skipping them')
260
+ .option('--with-manifest', 'With --force, also overwrite an existing amxbuild.yml')
252
261
  .option('-i, --interactive', 'Interactive mode with prompts')
253
262
  .action(async (options) => {
254
263
  try {
@@ -291,6 +300,31 @@ program
291
300
  }
292
301
  });
293
302
 
303
+ // ─── skills-dir ────────────────────────────────────────────────────────────────
304
+
305
+ program
306
+ .command('skills-dir')
307
+ .description('Print the absolute path to the bundled skills directory (used by the opencode skills bridge plugin)')
308
+ .action(() => {
309
+ runSkillsDir();
310
+ });
311
+
312
+ // ─── opencode-skills ─────────────────────────────────────────────────────────
313
+
314
+ program
315
+ .command('opencode-skills')
316
+ .description('Materialize project + dependency skills for the opencode bridge (stdout: container paths)')
317
+ .option('--manifest <path>', 'Path to manifest file')
318
+ .option('--no-fetch', 'Use cached repos without re-cloning')
319
+ .action(async (options) => {
320
+ try {
321
+ await runOpencodeSkills(options);
322
+ } catch (err) {
323
+ logger.error(err.message);
324
+ process.exit(1);
325
+ }
326
+ });
327
+
294
328
  // ─── version ───────────────────────────────────────────────────────────────────
295
329
 
296
330
  program
package/src/collector.js CHANGED
@@ -2,7 +2,7 @@ const fs = require('fs');
2
2
  const path = require('path');
3
3
  const glob = require('fast-glob');
4
4
  const logger = require('./logger');
5
- const { repoKey } = require('./deps-resolver');
5
+ const { repoKey, normalizeRepo } = require('./deps-resolver');
6
6
 
7
7
  /**
8
8
  * Copies everything from each repo's amxmodx_dir into build/amxmodx/,
@@ -25,9 +25,17 @@ async function collectAll(manifest, repoLocalDirs, buildDir) {
25
25
  fs.mkdirSync(amxmodxBuildDir, { recursive: true });
26
26
 
27
27
  const origins = new Map(); // rel path → repo label (conflict tracking)
28
+ const seenRepos = new Set(); // case-insensitive repo identity (dedupe)
28
29
 
29
30
  // Copy from each remote repo
30
31
  for (const repoConfig of manifest.repos) {
32
+ const repoIdentity = normalizeRepo(repoConfig);
33
+ if (seenRepos.has(repoIdentity)) {
34
+ logger.dim(` ${repoConfig.repo}: duplicate entry, skipping`);
35
+ continue;
36
+ }
37
+ seenRepos.add(repoIdentity);
38
+
31
39
  const repoDir = repoLocalDirs[repoKey(repoConfig)];
32
40
  const srcDir = path.join(repoDir, repoConfig.amxmodx_dir);
33
41
 
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const logger = require('../logger');
4
- const { parseManifest, applyOverrides } = require('../manifest');
4
+ const { resolveManifest } = require('../manifest');
5
5
  const { resolveManifestPath, loadEnv } = require('./shared');
6
6
  const { printDryRun } = require('./dry-run');
7
7
  const { subscribeCompiledRendering } = require('./compile-renderer');
@@ -29,9 +29,10 @@ async function runBuildCLI(options) {
29
29
  const manifestPath = resolveManifestPath(options.manifest);
30
30
  loadEnv(manifestPath);
31
31
 
32
- const manifest = parseManifest(manifestPath);
33
- if (options.set?.length) applyOverrides(manifest, options.set);
34
- if (options.define?.length) manifest.amxmodx.defines.push(...options.define);
32
+ const manifest = resolveManifest(manifestPath, {
33
+ set: options.set,
34
+ define: options.define,
35
+ });
35
36
  logger.info(`Manifest: ${manifest.name} v${manifest.version}`);
36
37
 
37
38
  if (options.dryRun) {
@@ -3,6 +3,7 @@
3
3
  const path = require('path');
4
4
  const logger = require('../logger');
5
5
  const { buildPlanData } = require('../build-plan');
6
+ const { isLocal } = require('../local-sources');
6
7
 
7
8
  function printDryRun(manifest) {
8
9
  const out = manifest.output;
@@ -20,6 +21,10 @@ function printDryRun(manifest) {
20
21
  if (manifest.repos.length) {
21
22
  logger.info(`\nRepos (${manifest.repos.length}):`);
22
23
  for (const r of manifest.repos) {
24
+ if (isLocal(r)) {
25
+ logger.dim(` [local] ${r.repo} -> ${r._localDir} [dir: ${r.amxmodx_dir}]`);
26
+ continue;
27
+ }
23
28
  const ref = r.ref || 'default branch';
24
29
  logger.dim(` ${r.repo} @ ${ref} [dir: ${r.amxmodx_dir}]`);
25
30
  }
@@ -28,6 +33,10 @@ function printDryRun(manifest) {
28
33
  if (manifest.globalDeps.length) {
29
34
  logger.info(`\nGlobal deps (${manifest.globalDeps.length}):`);
30
35
  for (const d of manifest.globalDeps) {
36
+ if (isLocal(d)) {
37
+ logger.dim(` [local] ${d.repo} -> ${d._localDir}${d.include_path ? ':' + d.include_path : ''}`);
38
+ continue;
39
+ }
31
40
  if (d.source === 'fungun') {
32
41
  logger.dim(` [fungun] plugin #${d.id} (${d.url})`);
33
42
  continue;
@@ -60,7 +69,25 @@ function printDryRun(manifest) {
60
69
  logger.dim(` amxmodx path in archive: ${expand(out.amxmodx_path)}/`);
61
70
  }
62
71
  if (out.assets_path) logger.dim(` assets path: ${expand(out.assets_path)}/`);
63
- logger.dim(` generate_ini: ${out.generate_ini} | on_conflict: ${out.on_conflict}`);
72
+
73
+ if (manifest.pluginIni) {
74
+ const { enabled, defaultIni, defaultDebug, forceDebug } = manifest.pluginIni;
75
+ const base = defaultIni === false
76
+ ? 'none'
77
+ : (defaultIni === '' ? 'plugins.ini' : `plugins-${defaultIni}.ini`);
78
+ const debug = forceDebug != null
79
+ ? `${forceDebug} (forced by AMXB_PLUGINS_DEBUG)`
80
+ : defaultDebug;
81
+ logger.dim(` plugins ini: ${enabled ? 'enabled' : 'disabled'} | default: ${base} | debug: ${debug}`);
82
+ logger.dim(` on_conflict: ${out.on_conflict}`);
83
+ } else {
84
+ logger.dim(` generate_ini: ${out.generate_ini} | on_conflict: ${out.on_conflict}`);
85
+ }
86
+
87
+ if (manifest._deprecations && manifest._deprecations.length) {
88
+ logger.info(`\nDeprecations (${manifest._deprecations.length}):`);
89
+ for (const d of manifest._deprecations) logger.warn(` ${d}`);
90
+ }
64
91
 
65
92
  logger.info(`\n=== END DRY RUN ===`);
66
93
  }