Compare commits
3 Commits
88b9a1c842
...
be85e5b4a0
| Author | SHA1 | Date |
|---|---|---|
|
|
be85e5b4a0 | |
|
|
04cfde1454 | |
|
|
7baa30c567 |
|
|
@ -7,6 +7,7 @@ on:
|
||||||
- "src/**"
|
- "src/**"
|
||||||
- "tools/installer/**"
|
- "tools/installer/**"
|
||||||
- "package.json"
|
- "package.json"
|
||||||
|
- "removals.txt"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
channel:
|
channel:
|
||||||
|
|
@ -135,6 +136,22 @@ jobs:
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Advance @next dist-tag to stable
|
||||||
|
if: github.event_name == 'workflow_dispatch' && inputs.channel == 'latest'
|
||||||
|
# Failure here leaves @next stale until the next push-driven prerelease
|
||||||
|
# republishes — annoying but not release-breaking. Don't fail the job
|
||||||
|
# after a successful stable publish + tag + GH release.
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
# Without this, @latest can leapfrog @next (e.g. latest=6.5.0 while
|
||||||
|
# next=6.4.1-next.0) and `npx bmad-method@next install` silently
|
||||||
|
# downgrades users. Point @next at the just-published stable so
|
||||||
|
# @next >= @latest always holds; the next push-driven prerelease will
|
||||||
|
# bump from this base via the existing derive step above.
|
||||||
|
VERSION=$(node -p 'require("./package.json").version')
|
||||||
|
npm dist-tag add "bmad-method@${VERSION}" next
|
||||||
|
echo "Advanced @next dist-tag to ${VERSION}"
|
||||||
|
|
||||||
- name: Notify Discord
|
- name: Notify Discord
|
||||||
if: github.event_name == 'workflow_dispatch' && inputs.channel == 'latest'
|
if: github.event_name == 'workflow_dispatch' && inputs.channel == 'latest'
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
|
||||||
|
|
@ -23,13 +23,10 @@ checkForUpdate().catch(() => {
|
||||||
|
|
||||||
async function checkForUpdate() {
|
async function checkForUpdate() {
|
||||||
try {
|
try {
|
||||||
// For beta versions, check the beta tag; otherwise check latest
|
// Prereleases (e.g. 6.5.1-next.0) live on the `next` dist-tag; stable
|
||||||
const isBeta =
|
// releases live on `latest`. semver.prerelease() returns null for stable,
|
||||||
packageJson.version.includes('Beta') ||
|
// so this correctly routes pre-1.0-next/rc/etc. without string matching.
|
||||||
packageJson.version.includes('beta') ||
|
const tag = semver.prerelease(packageJson.version) ? 'next' : 'latest';
|
||||||
packageJson.version.includes('alpha') ||
|
|
||||||
packageJson.version.includes('rc');
|
|
||||||
const tag = isBeta ? 'beta' : 'latest';
|
|
||||||
|
|
||||||
const result = execSync(`npm view ${packageName}@${tag} version`, {
|
const result = execSync(`npm view ${packageName}@${tag} version`, {
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
|
|
|
||||||
|
|
@ -435,6 +435,9 @@ class ManifestGenerator {
|
||||||
// this means user-scoped keys (e.g. user_name) could mis-file into the
|
// this means user-scoped keys (e.g. user_name) could mis-file into the
|
||||||
// team config, so the operator should notice.
|
// team config, so the operator should notice.
|
||||||
const scopeByModuleKey = {};
|
const scopeByModuleKey = {};
|
||||||
|
// Maps installer moduleName (may be full display name) → module code field
|
||||||
|
// from module.yaml, so TOML sections use [modules.<code>] not [modules.<name>].
|
||||||
|
const codeByModuleName = {};
|
||||||
for (const moduleName of this.updatedModules) {
|
for (const moduleName of this.updatedModules) {
|
||||||
const moduleYamlPath = await resolveInstalledModuleYaml(moduleName);
|
const moduleYamlPath = await resolveInstalledModuleYaml(moduleName);
|
||||||
if (!moduleYamlPath) {
|
if (!moduleYamlPath) {
|
||||||
|
|
@ -447,6 +450,7 @@ class ManifestGenerator {
|
||||||
try {
|
try {
|
||||||
const parsed = yaml.parse(await fs.readFile(moduleYamlPath, 'utf8'));
|
const parsed = yaml.parse(await fs.readFile(moduleYamlPath, 'utf8'));
|
||||||
if (!parsed || typeof parsed !== 'object') continue;
|
if (!parsed || typeof parsed !== 'object') continue;
|
||||||
|
if (parsed.code) codeByModuleName[moduleName] = parsed.code;
|
||||||
scopeByModuleKey[moduleName] = {};
|
scopeByModuleKey[moduleName] = {};
|
||||||
for (const [key, value] of Object.entries(parsed)) {
|
for (const [key, value] of Object.entries(parsed)) {
|
||||||
if (value && typeof value === 'object' && 'prompt' in value) {
|
if (value && typeof value === 'object' && 'prompt' in value) {
|
||||||
|
|
@ -545,6 +549,9 @@ class ManifestGenerator {
|
||||||
if (moduleName === 'core') continue;
|
if (moduleName === 'core') continue;
|
||||||
const cfg = moduleConfigs[moduleName];
|
const cfg = moduleConfigs[moduleName];
|
||||||
if (!cfg || Object.keys(cfg).length === 0) continue;
|
if (!cfg || Object.keys(cfg).length === 0) continue;
|
||||||
|
// Use the module's code field from module.yaml as the TOML key so the
|
||||||
|
// section is [modules.mdo] not [modules.MDO: Maxio DevOps Operations].
|
||||||
|
const sectionKey = codeByModuleName[moduleName] || moduleName;
|
||||||
// Only filter out spread-from-core pollution when we actually know
|
// Only filter out spread-from-core pollution when we actually know
|
||||||
// this module's prompt schema. For external/marketplace modules whose
|
// this module's prompt schema. For external/marketplace modules whose
|
||||||
// module.yaml isn't in the src tree, fall through as all-team so we
|
// module.yaml isn't in the src tree, fall through as all-team so we
|
||||||
|
|
@ -552,14 +559,14 @@ class ManifestGenerator {
|
||||||
const haveSchema = Object.keys(scopeByModuleKey[moduleName] || {}).length > 0;
|
const haveSchema = Object.keys(scopeByModuleKey[moduleName] || {}).length > 0;
|
||||||
const { team: modTeam, user: modUser } = partition(moduleName, cfg, haveSchema);
|
const { team: modTeam, user: modUser } = partition(moduleName, cfg, haveSchema);
|
||||||
if (Object.keys(modTeam).length > 0) {
|
if (Object.keys(modTeam).length > 0) {
|
||||||
teamLines.push(`[modules.${moduleName}]`);
|
teamLines.push(`[modules.${sectionKey}]`);
|
||||||
for (const [key, value] of Object.entries(modTeam)) {
|
for (const [key, value] of Object.entries(modTeam)) {
|
||||||
teamLines.push(`${key} = ${formatTomlValue(value)}`);
|
teamLines.push(`${key} = ${formatTomlValue(value)}`);
|
||||||
}
|
}
|
||||||
teamLines.push('');
|
teamLines.push('');
|
||||||
}
|
}
|
||||||
if (Object.keys(modUser).length > 0) {
|
if (Object.keys(modUser).length > 0) {
|
||||||
userLines.push(`[modules.${moduleName}]`);
|
userLines.push(`[modules.${sectionKey}]`);
|
||||||
for (const [key, value] of Object.entries(modUser)) {
|
for (const [key, value] of Object.entries(modUser)) {
|
||||||
userLines.push(`${key} = ${formatTomlValue(value)}`);
|
userLines.push(`${key} = ${formatTomlValue(value)}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,8 @@ function getExternalModuleCachePath(moduleName, ...segments) {
|
||||||
* Built-in modules (core, bmm) live under <src>. External official modules are
|
* Built-in modules (core, bmm) live under <src>. External official modules are
|
||||||
* cloned into ~/.bmad/cache/external-modules/<name>/ with varying internal
|
* cloned into ~/.bmad/cache/external-modules/<name>/ with varying internal
|
||||||
* layouts (some at src/module.yaml, some at skills/module.yaml, some nested).
|
* layouts (some at src/module.yaml, some at skills/module.yaml, some nested).
|
||||||
|
* Local custom-source modules are not cached; their path is read from the
|
||||||
|
* CustomModuleManager resolution cache set during the same install run.
|
||||||
* This mirrors the candidate-path search in
|
* This mirrors the candidate-path search in
|
||||||
* ExternalModuleManager.findExternalModuleSource but performs no git/network
|
* ExternalModuleManager.findExternalModuleSource but performs no git/network
|
||||||
* work, which keeps it safe to call during manifest writing.
|
* work, which keeps it safe to call during manifest writing.
|
||||||
|
|
@ -97,14 +99,13 @@ async function resolveInstalledModuleYaml(moduleName) {
|
||||||
const builtIn = path.join(getModulePath(moduleName), 'module.yaml');
|
const builtIn = path.join(getModulePath(moduleName), 'module.yaml');
|
||||||
if (await fs.pathExists(builtIn)) return builtIn;
|
if (await fs.pathExists(builtIn)) return builtIn;
|
||||||
|
|
||||||
const cacheRoot = getExternalModuleCachePath(moduleName);
|
// Search a resolved root directory using the same candidate-path pattern.
|
||||||
if (!(await fs.pathExists(cacheRoot))) return null;
|
async function searchRoot(root) {
|
||||||
|
|
||||||
for (const dir of ['skills', 'src']) {
|
for (const dir of ['skills', 'src']) {
|
||||||
const direct = path.join(cacheRoot, dir, 'module.yaml');
|
const direct = path.join(root, dir, 'module.yaml');
|
||||||
if (await fs.pathExists(direct)) return direct;
|
if (await fs.pathExists(direct)) return direct;
|
||||||
|
|
||||||
const dirPath = path.join(cacheRoot, dir);
|
const dirPath = path.join(root, dir);
|
||||||
if (await fs.pathExists(dirPath)) {
|
if (await fs.pathExists(dirPath)) {
|
||||||
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
|
|
@ -115,8 +116,39 @@ async function resolveInstalledModuleYaml(moduleName) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const atRoot = path.join(cacheRoot, 'module.yaml');
|
// BMB standard: {setup-skill}/assets/module.yaml (setup skill is any *-setup directory)
|
||||||
|
const rootEntries = await fs.readdir(root, { withFileTypes: true });
|
||||||
|
for (const entry of rootEntries) {
|
||||||
|
if (!entry.isDirectory() || !entry.name.endsWith('-setup')) continue;
|
||||||
|
const setupAssets = path.join(root, entry.name, 'assets', 'module.yaml');
|
||||||
|
if (await fs.pathExists(setupAssets)) return setupAssets;
|
||||||
|
}
|
||||||
|
|
||||||
|
const atRoot = path.join(root, 'module.yaml');
|
||||||
if (await fs.pathExists(atRoot)) return atRoot;
|
if (await fs.pathExists(atRoot)) return atRoot;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheRoot = getExternalModuleCachePath(moduleName);
|
||||||
|
if (await fs.pathExists(cacheRoot)) {
|
||||||
|
const found = await searchRoot(cacheRoot);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: local custom-source modules store their source path in the
|
||||||
|
// CustomModuleManager resolution cache populated during the same install run.
|
||||||
|
// Match by code OR name since callers may use either form.
|
||||||
|
try {
|
||||||
|
const { CustomModuleManager } = require('./modules/custom-module-manager');
|
||||||
|
for (const [, mod] of CustomModuleManager._resolutionCache) {
|
||||||
|
if ((mod.code === moduleName || mod.name === moduleName) && mod.localPath) {
|
||||||
|
const found = await searchRoot(mod.localPath);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Resolution cache unavailable — continue
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ const path = require('node:path');
|
||||||
const os = require('node:os');
|
const os = require('node:os');
|
||||||
const semver = require('semver');
|
const semver = require('semver');
|
||||||
const fs = require('./fs-native');
|
const fs = require('./fs-native');
|
||||||
|
const installerPackageJson = require('../../package.json');
|
||||||
const { CLIUtils } = require('./cli-utils');
|
const { CLIUtils } = require('./cli-utils');
|
||||||
const { ExternalModuleManager } = require('./modules/external-manager');
|
const { ExternalModuleManager } = require('./modules/external-manager');
|
||||||
const { resolveModuleVersion } = require('./modules/version-resolver');
|
const { resolveModuleVersion } = require('./modules/version-resolver');
|
||||||
|
|
@ -128,6 +129,24 @@ class UI {
|
||||||
await prompts.log.warn(warning);
|
await prompts.log.warn(warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When the user launched the installer from a prerelease (npx bmad-method@next),
|
||||||
|
// mirror that intent for external modules: seed the global channel to 'next' so
|
||||||
|
// the module picker's version labels resolve from main HEAD (matching what
|
||||||
|
// actually gets installed) and the interactive channel gate skips — the user
|
||||||
|
// already declared "next" intent by typing @next. Explicit channel flags
|
||||||
|
// override this seed.
|
||||||
|
if (
|
||||||
|
semver.prerelease(installerPackageJson.version) !== null &&
|
||||||
|
!channelOptions.global &&
|
||||||
|
channelOptions.nextSet.size === 0 &&
|
||||||
|
channelOptions.pins.size === 0
|
||||||
|
) {
|
||||||
|
channelOptions.global = 'next';
|
||||||
|
await prompts.log.info(
|
||||||
|
'Launched from a prerelease — installing all external modules from main HEAD (next channel). Pass --all-stable or --pin to override.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Get directory from options or prompt
|
// Get directory from options or prompt
|
||||||
let confirmedDirectory;
|
let confirmedDirectory;
|
||||||
if (options.directory) {
|
if (options.directory) {
|
||||||
|
|
@ -332,8 +351,10 @@ class UI {
|
||||||
|
|
||||||
// Interactive channel gate: "Ready to install (all stable)? [Y/n]"
|
// Interactive channel gate: "Ready to install (all stable)? [Y/n]"
|
||||||
// Only shown for fresh installs with no channel flags and an external module
|
// Only shown for fresh installs with no channel flags and an external module
|
||||||
// selected. Non-interactive installs skip this and fall through to the
|
// selected. Skipped for prerelease launches because channelOptions.global
|
||||||
// registry default (stable) or whatever flags were supplied.
|
// was already seeded to 'next' upstream. Non-interactive installs skip this
|
||||||
|
// and fall through to the registry default (stable) or whatever flags were
|
||||||
|
// supplied.
|
||||||
await this._interactiveChannelGate({ options, channelOptions, selectedModules });
|
await this._interactiveChannelGate({ options, channelOptions, selectedModules });
|
||||||
|
|
||||||
let toolSelection = await this.promptToolSelection(confirmedDirectory, options);
|
let toolSelection = await this.promptToolSelection(confirmedDirectory, options);
|
||||||
|
|
@ -1783,7 +1804,9 @@ class UI {
|
||||||
*
|
*
|
||||||
* Skipped when:
|
* Skipped when:
|
||||||
* - running non-interactively (--yes)
|
* - running non-interactively (--yes)
|
||||||
* - the user already passed channel flags (--channel / --pin / --next)
|
* - the user already passed channel flags (--channel / --pin / --next), OR
|
||||||
|
* the installer was launched from a prerelease (which seeds
|
||||||
|
* channelOptions.global = 'next' upstream in promptInstall)
|
||||||
* - no externals/community modules are selected
|
* - no externals/community modules are selected
|
||||||
*
|
*
|
||||||
* Mutates channelOptions.pins and channelOptions.nextSet to reflect picker choices.
|
* Mutates channelOptions.pins and channelOptions.nextSet to reflect picker choices.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue