Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 100 additions & 2 deletions bin/multiagent-safety.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
const TOOL_NAME = 'guardex';
const SHORT_TOOL_NAME = 'gx';
const LEGACY_NAMES = ['musafety', 'multiagent-safety'];
const OPENSPEC_PACKAGE = '@fission-ai/openspec';
const GLOBAL_TOOLCHAIN_PACKAGES = [
'oh-my-codex',
'@fission-ai/openspec',
OPENSPEC_PACKAGE,
'@imdeadpool/codex-account-switcher',
];
const GH_BIN = process.env.MUSAFETY_GH_BIN || 'gh';
Expand All @@ -28,6 +29,7 @@ const MAINTAINER_RELEASE_REPO = path.resolve(
process.env.MUSAFETY_RELEASE_REPO || '/tmp/multiagent-safety',
);
const NPM_BIN = process.env.MUSAFETY_NPM_BIN || 'npm';
const OPENSPEC_BIN = process.env.MUSAFETY_OPENSPEC_BIN || 'openspec';
const SCORECARD_BIN = process.env.MUSAFETY_SCORECARD_BIN || 'scorecard';
const GIT_PROTECTED_BRANCHES_KEY = 'multiagent.protectedBranches';
const GIT_BASE_BRANCH_KEY = 'multiagent.baseBranch';
Expand Down Expand Up @@ -3112,6 +3114,95 @@ function maybeSelfUpdateBeforeStatus() {
console.log(`[${TOOL_NAME}] ✅ Updated to latest published version.`);
}

function checkForOpenSpecPackageUpdate() {
if (envFlagEnabled('MUSAFETY_SKIP_OPENSPEC_UPDATE_CHECK')) {
return { checked: false, reason: 'disabled' };
}

const forceCheck = envFlagEnabled('MUSAFETY_FORCE_OPENSPEC_UPDATE_CHECK');
if (!forceCheck && !isInteractiveTerminal()) {
return { checked: false, reason: 'non-interactive' };
}

const detection = detectGlobalToolchainPackages();
if (!detection.ok) {
return { checked: false, reason: 'package-detect-failed' };
}

const current = String((detection.installedVersions || {})[OPENSPEC_PACKAGE] || '').trim();
if (!current) {
return { checked: false, reason: 'not-installed' };
}

const latestResult = run(NPM_BIN, ['view', OPENSPEC_PACKAGE, 'version', '--json'], { timeout: 5000 });
if (latestResult.status !== 0) {
return { checked: false, reason: 'lookup-failed' };
}

const latest = parseNpmVersionOutput(latestResult.stdout);
if (!latest) {
return { checked: false, reason: 'invalid-latest-version' };
}

return {
checked: true,
current,
latest,
updateAvailable: isNewerVersion(latest, current),
};
}

function printOpenSpecUpdateAvailableBanner(current, latest) {
const title = colorize('OPENSPEC UPDATE AVAILABLE', '1;33');
console.log(`[${TOOL_NAME}] ${title}`);
console.log(`[${TOOL_NAME}] Current: ${current}`);
console.log(`[${TOOL_NAME}] Latest : ${latest}`);
console.log(`[${TOOL_NAME}] Command: ${NPM_BIN} i -g ${OPENSPEC_PACKAGE}@latest`);
console.log(`[${TOOL_NAME}] Then : ${OPENSPEC_BIN} update`);
}

function maybeOpenSpecUpdateBeforeStatus() {
const check = checkForOpenSpecPackageUpdate();
if (!check.checked || !check.updateAvailable) {
return;
}

printOpenSpecUpdateAvailableBanner(check.current, check.latest);

const autoApproval = parseAutoApproval('MUSAFETY_AUTO_OPENSPEC_UPDATE_APPROVAL');
const interactive = isInteractiveTerminal();

if (!interactive && autoApproval == null) {
console.log(`[${TOOL_NAME}] Non-interactive shell; skipping OpenSpec update prompt.`);
return;
}

const shouldUpdate = interactive
? promptYesNoStrict(
`Update OpenSpec now? (${NPM_BIN} i -g ${OPENSPEC_PACKAGE}@latest && ${OPENSPEC_BIN} update)`,
)
: autoApproval;

if (!shouldUpdate) {
console.log(`[${TOOL_NAME}] Skipped OpenSpec update.`);
return;
}

const installResult = run(NPM_BIN, ['i', '-g', `${OPENSPEC_PACKAGE}@latest`], { stdio: 'inherit' });
if (installResult.status !== 0) {
console.log(`[${TOOL_NAME}] ⚠️ OpenSpec npm install failed. You can retry manually.`);
return;
}

const toolUpdateResult = run(OPENSPEC_BIN, ['update'], { stdio: 'inherit' });
if (toolUpdateResult.status !== 0) {
console.log(`[${TOOL_NAME}] ⚠️ OpenSpec tool update failed. Run '${OPENSPEC_BIN} update' manually.`);
return;
}

console.log(`[${TOOL_NAME}] ✅ OpenSpec updated to latest package and tool plugins refreshed.`);
}

function promptYesNoStrict(question) {
while (true) {
process.stdout.write(`${question} [y/n] `);
Expand Down Expand Up @@ -3176,15 +3267,21 @@ function detectGlobalToolchainPackages() {

const installed = [];
const missing = [];
const installedVersions = {};
for (const pkg of GLOBAL_TOOLCHAIN_PACKAGES) {
if (installedSet.has(pkg)) {
installed.push(pkg);
const rawVersion = dependencyMap[pkg] && dependencyMap[pkg].version;
const version = String(rawVersion || '').trim();
if (version) {
installedVersions[pkg] = version;
}
} else {
missing.push(pkg);
}
}

return { ok: true, installed, missing };
return { ok: true, installed, missing, installedVersions };
}

function detectRequiredSystemTools() {
Expand Down Expand Up @@ -5064,6 +5161,7 @@ function main() {

if (args.length === 0) {
maybeSelfUpdateBeforeStatus();
maybeOpenSpecUpdateBeforeStatus();
status([]);
return;
}
Expand Down
87 changes: 87 additions & 0 deletions scripts/openspec/init-change-workspace.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
set -euo pipefail

if [[ $# -lt 1 || $# -gt 2 ]]; then
echo "Usage: $0 <change-slug> [capability-slug]"
echo "Example: $0 add-dashboard-live-usage runtime-migration"
exit 1
fi

CHANGE_SLUG="$1"
CAPABILITY_SLUG="${2:-$CHANGE_SLUG}"

if [[ "$CHANGE_SLUG" =~ [^a-z0-9-] ]]; then
echo "Error: change slug must be kebab-case (lowercase letters, numbers, hyphens)."
exit 1
fi

if [[ "$CAPABILITY_SLUG" =~ [^a-z0-9-] ]]; then
echo "Error: capability slug must be kebab-case (lowercase letters, numbers, hyphens)."
exit 1
fi

CHANGE_DIR="openspec/changes/${CHANGE_SLUG}"
SPEC_DIR="${CHANGE_DIR}/specs/${CAPABILITY_SLUG}"
TODAY="$(date -u +%Y-%m-%d)"

mkdir -p "$SPEC_DIR"

if [[ ! -f "${CHANGE_DIR}/.openspec.yaml" ]]; then
cat > "${CHANGE_DIR}/.openspec.yaml" <<YAMLEOF
schema: spec-driven
created: ${TODAY}
YAMLEOF
fi

if [[ ! -f "${CHANGE_DIR}/proposal.md" ]]; then
cat > "${CHANGE_DIR}/proposal.md" <<PROPOSALEOF
## Why

- TODO: describe the user/problem outcome this change addresses.

## What Changes

- TODO: summarize the intended behavior and scope.

## Impact

- TODO: call out risks, rollout notes, and affected surfaces.
PROPOSALEOF
fi

if [[ ! -f "${CHANGE_DIR}/tasks.md" ]]; then
cat > "${CHANGE_DIR}/tasks.md" <<TASKSEOF
## 1. Specification

- [ ] 1.1 Finalize proposal scope and acceptance criteria for \`${CHANGE_SLUG}\`.
- [ ] 1.2 Define normative requirements in \`specs/${CAPABILITY_SLUG}/spec.md\`.

## 2. Implementation

- [ ] 2.1 Implement scoped behavior changes.
- [ ] 2.2 Add/update focused regression coverage.

## 3. Verification

- [ ] 3.1 Run targeted project verification commands.
- [ ] 3.2 Run \`openspec validate ${CHANGE_SLUG} --type change --strict\`.
- [ ] 3.3 Run \`openspec validate --specs\`.
TASKSEOF
fi

if [[ ! -f "${SPEC_DIR}/spec.md" ]]; then
cat > "${SPEC_DIR}/spec.md" <<SPECEOF
## ADDED Requirements

### Requirement: ${CAPABILITY_SLUG} behavior
The system SHALL enforce ${CAPABILITY_SLUG} behavior as defined by this change.

#### Scenario: Baseline acceptance
- **WHEN** ${CAPABILITY_SLUG} behavior is exercised
- **THEN** the expected outcome is produced
- **AND** regressions are covered by tests.
SPECEOF
fi

echo "[guardex] OpenSpec change workspace ready: ${CHANGE_DIR}"
echo "[guardex] OpenSpec change spec scaffold: ${SPEC_DIR}/spec.md"
62 changes: 62 additions & 0 deletions test/install.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ function createFakeNpmScript(scriptBody) {
return fakeNpmPath;
}

function createFakeOpenSpecScript(scriptBody) {
const fakeBin = fs.mkdtempSync(path.join(os.tmpdir(), 'musafety-fake-openspec-'));
const fakeOpenSpecPath = path.join(fakeBin, 'openspec');
fs.writeFileSync(fakeOpenSpecPath, `#!/usr/bin/env bash\nset -e\n${scriptBody}\n`, 'utf8');
fs.chmodSync(fakeOpenSpecPath, 0o755);
return fakeOpenSpecPath;
}

function createFakeScorecardScript(scriptBody) {
const fakeBin = fs.mkdtempSync(path.join(os.tmpdir(), 'musafety-fake-scorecard-'));
const fakePath = path.join(fakeBin, 'scorecard');
Expand Down Expand Up @@ -1677,6 +1685,60 @@ test('self-update prompt requires explicit y/n when approval is not preconfigure
);
});

test('default invocation checks for openspec package updates and runs openspec update', () => {
const repoDir = initRepo();
const npmMarkerPath = path.join(repoDir, '.openspec-npm-update-called');
const toolMarkerPath = path.join(repoDir, '.openspec-tool-update-called');
const fakeNpm = createFakeNpmScript(`
if [[ "$1" == "list" && "$2" == "-g" ]]; then
echo '{"dependencies":{"@fission-ai/openspec":{"version":"1.2.0"}}}'
exit 0
fi
if [[ "$1" == "view" && "$2" == "@fission-ai/openspec" && "$3" == "version" ]]; then
echo '"1.3.0"'
exit 0
fi
if [[ "$1" == "i" && "$2" == "-g" && "$3" == "@fission-ai/openspec@latest" ]]; then
echo "updated" > "${npmMarkerPath}"
exit 0
fi
echo "unexpected npm args: $*" >&2
exit 1
`);
const fakeOpenSpec = createFakeOpenSpecScript(`
if [[ "$1" == "update" ]]; then
echo "updated" > "${toolMarkerPath}"
exit 0
fi
echo "unexpected openspec args: $*" >&2
exit 1
`);

const result = runNodeWithEnv([], repoDir, {
MUSAFETY_NPM_BIN: fakeNpm,
MUSAFETY_OPENSPEC_BIN: fakeOpenSpec,
MUSAFETY_SKIP_UPDATE_CHECK: '1',
MUSAFETY_FORCE_OPENSPEC_UPDATE_CHECK: '1',
MUSAFETY_AUTO_OPENSPEC_UPDATE_APPROVAL: 'yes',
});

assert.equal(result.status, 0, result.stderr || result.stdout);
assert.match(result.stdout, /OPENSPEC UPDATE AVAILABLE/);
assert.match(result.stdout, /Current:\s+1\.2\.0/);
assert.match(result.stdout, /Latest\s+:\s+1\.3\.0/);
assert.match(result.stdout, /OpenSpec updated to latest package and tool plugins refreshed/);
assert.equal(fs.existsSync(npmMarkerPath), true, 'expected openspec npm install to run');
assert.equal(fs.existsSync(toolMarkerPath), true, 'expected openspec update command to run');
});

test('openspec update prompt requires explicit y/n when approval is not preconfigured', () => {
const source = fs.readFileSync(cliPath, 'utf8');
assert.match(
source,
/const shouldUpdate = interactive\s*\?\s*promptYesNoStrict\(\s*`Update OpenSpec now\?\s*\(\$\{NPM_BIN\} i -g \$\{OPENSPEC_PACKAGE\}@latest && \$\{OPENSPEC_BIN\} update\)`\s*,?\s*\)\s*:\s*autoApproval;/s,
);
});

test('status --json returns cli, services, and repo summary', () => {
const repoDir = initRepo();

Expand Down