合并远程主分支客户端收口
This commit is contained in:
@@ -6,7 +6,6 @@ import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
@@ -16,10 +15,9 @@ import type { ChildProcess, SpawnOptionsWithoutStdio } from 'node:child_process'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { OpencodeManager } from '@electron/opencode/manager';
|
||||
import {
|
||||
ensureBundledSuperpowersPlugin,
|
||||
BUNDLED_COURSE_SKILL_IDS,
|
||||
resolveBundledAgentBrowserPluginPath,
|
||||
resolveBundledSuperpowersDir,
|
||||
} from '@electron/opencode/superpowers';
|
||||
} from '@electron/opencode/course-skills';
|
||||
import { logger } from '@electron/utils/logger';
|
||||
|
||||
type SpawnCall = {
|
||||
@@ -50,41 +48,12 @@ function createSpawnHarness() {
|
||||
return { children, calls, spawn };
|
||||
}
|
||||
|
||||
function createBundledSuperpowersFixture(): string {
|
||||
const sourceDir = mkdtempSync(join(tmpdir(), 'niancode-superpowers-source-'));
|
||||
writeFileSync(
|
||||
join(sourceDir, 'package.json'),
|
||||
JSON.stringify({ name: 'superpowers', version: '5.0.7' }),
|
||||
);
|
||||
writeFileSync(join(sourceDir, 'LICENSE'), 'source-license');
|
||||
const pluginDir = join(sourceDir, '.opencode', 'plugins');
|
||||
const skillDir = join(sourceDir, 'skills', 'using-superpowers');
|
||||
mkdirSync(pluginDir, { recursive: true });
|
||||
mkdirSync(skillDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(pluginDir, 'superpowers.js'),
|
||||
'export const SuperpowersPlugin = async () => ({});\n',
|
||||
);
|
||||
writeFileSync(join(skillDir, 'SKILL.md'), '---\nname: using-superpowers\n---\n');
|
||||
return sourceDir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('OpencodeManager', () => {
|
||||
it('resolves the packaged Superpowers source without depending on the managed destination name', () => {
|
||||
const resourcesPath = 'C:\\Program Files\\NianCode\\resources';
|
||||
|
||||
expect(resolveBundledSuperpowersDir({
|
||||
isPackaged: true,
|
||||
resourcesPath,
|
||||
appPath: 'C:\\Program Files\\NianCode\\resources\\app.asar',
|
||||
})).toBe(join(resourcesPath, 'resources', 'skills', 'superpowers'));
|
||||
});
|
||||
|
||||
it('resolves the bundled Agent Browser plugin from the course skills bundle', () => {
|
||||
const resourcesPath = 'C:\\Program Files\\Makelore\\resources';
|
||||
|
||||
@@ -762,216 +731,6 @@ describe('OpencodeManager', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('installs bundled Superpowers into the managed opencode config directory before spawning', async () => {
|
||||
const userDataDir = mkdtempSync(join(tmpdir(), 'niancode-opencode-manager-'));
|
||||
const bundledSuperpowersDir = createBundledSuperpowersFixture();
|
||||
try {
|
||||
const { children, calls, spawn } = createSpawnHarness();
|
||||
const manager = new OpencodeManager({
|
||||
port: 4328,
|
||||
binPath: '/opt/opencode',
|
||||
userDataDir,
|
||||
bundledSuperpowersDir,
|
||||
runtimeConfigProvider: () => ({ config: {}, env: {} }),
|
||||
spawn,
|
||||
});
|
||||
|
||||
const startPromise = manager.start();
|
||||
children[0].stdout.emit(
|
||||
'data',
|
||||
Buffer.from('opencode server listening on http://127.0.0.1:4328\n'),
|
||||
);
|
||||
await startPromise;
|
||||
|
||||
const opencodeDir = join(userDataDir, 'opencode');
|
||||
const managedConfigDir = join(opencodeDir, 'niancode-config');
|
||||
const installedRepoDir = join(managedConfigDir, 'superpowers-bundles', '5.0.7');
|
||||
const wrapperPath = join(managedConfigDir, 'plugins', 'superpowers-niancode.js');
|
||||
|
||||
expect(calls[0].options.env).toMatchObject({
|
||||
OPENCODE_CONFIG_DIR: managedConfigDir,
|
||||
});
|
||||
expect(existsSync(join(installedRepoDir, 'skills', 'using-superpowers', 'SKILL.md'))).toBe(true);
|
||||
expect(existsSync(join(installedRepoDir, '.opencode', 'plugins', 'superpowers.js'))).toBe(true);
|
||||
expect(readFileSync(wrapperPath, 'utf8')).toContain(
|
||||
"export { SuperpowersPlugin } from '../superpowers-bundles/5.0.7/.opencode/plugins/superpowers.js';",
|
||||
);
|
||||
expect(JSON.parse(readFileSync(
|
||||
join(managedConfigDir, 'superpowers-active.json'),
|
||||
'utf8',
|
||||
))).toEqual({ version: '5.0.7', directory: '5.0.7' });
|
||||
} finally {
|
||||
rmSync(userDataDir, { recursive: true, force: true });
|
||||
rmSync(bundledSuperpowersDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('migrates the wrapper without touching an unusable legacy destination under a non-ASCII user path', () => {
|
||||
const managedRoot = mkdtempSync(join(tmpdir(), 'niancode-managed-config-'));
|
||||
const managedConfigDir = join(
|
||||
managedRoot,
|
||||
'卢欢',
|
||||
'AppData',
|
||||
'Roaming',
|
||||
'niancode',
|
||||
'opencode',
|
||||
'niancode-config',
|
||||
);
|
||||
mkdirSync(managedConfigDir, { recursive: true });
|
||||
const bundledSuperpowersDir = createBundledSuperpowersFixture();
|
||||
try {
|
||||
const legacyPath = join(managedConfigDir, 'superpowers');
|
||||
writeFileSync(legacyPath, 'legacy-path-must-remain-untouched');
|
||||
const wrapperPath = join(managedConfigDir, 'plugins', 'superpowers-niancode.js');
|
||||
mkdirSync(join(managedConfigDir, 'plugins'), { recursive: true });
|
||||
writeFileSync(
|
||||
wrapperPath,
|
||||
"export { SuperpowersPlugin } from '../superpowers/.opencode/plugins/superpowers.js';\n",
|
||||
);
|
||||
|
||||
expect(ensureBundledSuperpowersPlugin({
|
||||
managedConfigDir,
|
||||
sourceDir: bundledSuperpowersDir,
|
||||
})).toBe(true);
|
||||
|
||||
expect(readFileSync(legacyPath, 'utf8')).toBe('legacy-path-must-remain-untouched');
|
||||
expect(readFileSync(wrapperPath, 'utf8')).not.toContain('../superpowers/.opencode');
|
||||
expect(readFileSync(wrapperPath, 'utf8')).toContain('../superpowers-bundles/5.0.7/.opencode');
|
||||
expect(existsSync(join(
|
||||
managedConfigDir,
|
||||
'superpowers-bundles',
|
||||
'5.0.7',
|
||||
'skills',
|
||||
'using-superpowers',
|
||||
'SKILL.md',
|
||||
))).toBe(true);
|
||||
} finally {
|
||||
rmSync(managedRoot, { recursive: true, force: true });
|
||||
rmSync(bundledSuperpowersDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('reuses an installed Superpowers version without overwriting its files', () => {
|
||||
const managedConfigDir = mkdtempSync(join(tmpdir(), 'niancode-managed-config-'));
|
||||
const bundledSuperpowersDir = createBundledSuperpowersFixture();
|
||||
try {
|
||||
expect(ensureBundledSuperpowersPlugin({
|
||||
managedConfigDir,
|
||||
sourceDir: bundledSuperpowersDir,
|
||||
})).toBe(true);
|
||||
const installedLicense = join(
|
||||
managedConfigDir,
|
||||
'superpowers-bundles',
|
||||
'5.0.7',
|
||||
'LICENSE',
|
||||
);
|
||||
writeFileSync(installedLicense, 'installed-copy-must-not-be-overwritten');
|
||||
|
||||
expect(ensureBundledSuperpowersPlugin({
|
||||
managedConfigDir,
|
||||
sourceDir: bundledSuperpowersDir,
|
||||
})).toBe(true);
|
||||
expect(readFileSync(installedLicense, 'utf8')).toBe('installed-copy-must-not-be-overwritten');
|
||||
} finally {
|
||||
rmSync(managedConfigDir, { recursive: true, force: true });
|
||||
rmSync(bundledSuperpowersDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('cleans an incomplete Superpowers staging directory', () => {
|
||||
const managedConfigDir = mkdtempSync(join(tmpdir(), 'niancode-managed-config-'));
|
||||
const bundledSuperpowersDir = createBundledSuperpowersFixture();
|
||||
try {
|
||||
rmSync(join(
|
||||
bundledSuperpowersDir,
|
||||
'skills',
|
||||
'using-superpowers',
|
||||
'SKILL.md',
|
||||
));
|
||||
|
||||
expect(() => ensureBundledSuperpowersPlugin({
|
||||
managedConfigDir,
|
||||
sourceDir: bundledSuperpowersDir,
|
||||
})).toThrow('Bundled Superpowers staging copy is incomplete');
|
||||
|
||||
const bundlesDir = join(managedConfigDir, 'superpowers-bundles');
|
||||
expect(readdirSync(bundlesDir).some((entry) => entry.startsWith('.staging-'))).toBe(false);
|
||||
} finally {
|
||||
rmSync(managedConfigDir, { recursive: true, force: true });
|
||||
rmSync(bundledSuperpowersDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the active bundle when a newer Superpowers bundle is incomplete', () => {
|
||||
const managedConfigDir = mkdtempSync(join(tmpdir(), 'niancode-managed-config-'));
|
||||
const installedSourceDir = createBundledSuperpowersFixture();
|
||||
const brokenSourceDir = createBundledSuperpowersFixture();
|
||||
try {
|
||||
expect(ensureBundledSuperpowersPlugin({
|
||||
managedConfigDir,
|
||||
sourceDir: installedSourceDir,
|
||||
})).toBe(true);
|
||||
const manifestPath = join(managedConfigDir, 'superpowers-active.json');
|
||||
const originalManifest = readFileSync(manifestPath, 'utf8');
|
||||
|
||||
writeFileSync(
|
||||
join(brokenSourceDir, 'package.json'),
|
||||
JSON.stringify({ name: 'superpowers', version: '5.0.8' }),
|
||||
);
|
||||
rmSync(join(brokenSourceDir, 'skills', 'using-superpowers', 'SKILL.md'));
|
||||
|
||||
expect(() => ensureBundledSuperpowersPlugin({
|
||||
managedConfigDir,
|
||||
sourceDir: brokenSourceDir,
|
||||
})).toThrow('Bundled Superpowers staging copy is incomplete');
|
||||
expect(readFileSync(manifestPath, 'utf8')).toBe(originalManifest);
|
||||
expect(existsSync(join(
|
||||
managedConfigDir,
|
||||
'superpowers-bundles',
|
||||
'5.0.7',
|
||||
'.opencode',
|
||||
'plugins',
|
||||
'superpowers.js',
|
||||
))).toBe(true);
|
||||
} finally {
|
||||
rmSync(managedConfigDir, { recursive: true, force: true });
|
||||
rmSync(installedSourceDir, { recursive: true, force: true });
|
||||
rmSync(brokenSourceDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('uses a fresh bundle directory when the preferred version path is unusable', () => {
|
||||
const managedConfigDir = mkdtempSync(join(tmpdir(), 'niancode-managed-config-'));
|
||||
const bundledSuperpowersDir = createBundledSuperpowersFixture();
|
||||
try {
|
||||
const bundlesDir = join(managedConfigDir, 'superpowers-bundles');
|
||||
mkdirSync(bundlesDir, { recursive: true });
|
||||
writeFileSync(join(bundlesDir, '5.0.7'), 'unusable-preferred-path');
|
||||
|
||||
expect(ensureBundledSuperpowersPlugin({
|
||||
managedConfigDir,
|
||||
sourceDir: bundledSuperpowersDir,
|
||||
})).toBe(true);
|
||||
|
||||
const manifest = JSON.parse(readFileSync(
|
||||
join(managedConfigDir, 'superpowers-active.json'),
|
||||
'utf8',
|
||||
)) as { version: string; directory: string };
|
||||
expect(manifest.version).toBe('5.0.7');
|
||||
expect(manifest.directory).toMatch(/^5\.0\.7-/);
|
||||
expect(existsSync(join(
|
||||
bundlesDir,
|
||||
manifest.directory,
|
||||
'.opencode',
|
||||
'plugins',
|
||||
'superpowers.js',
|
||||
))).toBe(true);
|
||||
} finally {
|
||||
rmSync(managedConfigDir, { recursive: true, force: true });
|
||||
rmSync(bundledSuperpowersDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('installs bundled course skills into the managed opencode config directory before spawning', async () => {
|
||||
const userDataDir = mkdtempSync(join(tmpdir(), 'niancode-opencode-manager-'));
|
||||
const bundledCourseSkillsDir = mkdtempSync(join(tmpdir(), 'niancode-course-skills-source-'));
|
||||
@@ -1059,7 +818,48 @@ describe('OpencodeManager', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('removes legacy Superpowers runtime artifacts before spawning', async () => {
|
||||
const userDataDir = mkdtempSync(join(tmpdir(), 'niancode-opencode-manager-'));
|
||||
const managedConfigDir = join(userDataDir, 'opencode', 'niancode-config');
|
||||
const legacyPluginPath = join(managedConfigDir, 'plugins', 'superpowers-niancode.js');
|
||||
const legacyPluginAliasPath = join(managedConfigDir, 'plugins', 'superpowers.js');
|
||||
const legacyManifestPath = join(managedConfigDir, 'superpowers-active.json');
|
||||
const legacyBundlePath = join(managedConfigDir, 'superpowers-bundles', '5.0.7', 'skills');
|
||||
try {
|
||||
mkdirSync(join(managedConfigDir, 'plugins'), { recursive: true });
|
||||
mkdirSync(legacyBundlePath, { recursive: true });
|
||||
writeFileSync(legacyPluginPath, 'legacy plugin');
|
||||
writeFileSync(legacyPluginAliasPath, 'legacy plugin alias');
|
||||
writeFileSync(legacyManifestPath, '{}');
|
||||
|
||||
const { children, spawn } = createSpawnHarness();
|
||||
const manager = new OpencodeManager({
|
||||
port: 4338,
|
||||
binPath: '/opt/opencode',
|
||||
userDataDir,
|
||||
runtimeConfigProvider: () => ({ config: {}, env: {} }),
|
||||
spawn,
|
||||
});
|
||||
|
||||
const startPromise = manager.start();
|
||||
children[0].stdout.emit(
|
||||
'data',
|
||||
Buffer.from('opencode server listening on http://127.0.0.1:4338\n'),
|
||||
);
|
||||
await startPromise;
|
||||
|
||||
expect(existsSync(legacyPluginPath)).toBe(false);
|
||||
expect(existsSync(legacyPluginAliasPath)).toBe(false);
|
||||
expect(existsSync(legacyManifestPath)).toBe(false);
|
||||
expect(existsSync(join(managedConfigDir, 'superpowers-bundles'))).toBe(false);
|
||||
} finally {
|
||||
rmSync(userDataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('removes retired bundled skills from the managed config before spawning', async () => {
|
||||
expect(BUNDLED_COURSE_SKILL_IDS).not.toContain('deploy-publish-check');
|
||||
|
||||
const userDataDir = mkdtempSync(join(tmpdir(), 'niancode-opencode-manager-'));
|
||||
const bundledCourseSkillsDir = mkdtempSync(join(tmpdir(), 'niancode-course-skills-source-'));
|
||||
const retiredSkillDir = join(userDataDir, 'opencode', 'niancode-config', 'skills', 'student-growth-logger');
|
||||
@@ -1308,7 +1108,7 @@ describe('OpencodeManager', () => {
|
||||
listener.close((error) => error ? reject(error) : resolve());
|
||||
});
|
||||
}
|
||||
});
|
||||
}, 10_000);
|
||||
|
||||
it('does not overwrite a timed-out start process that failed to exit', async () => {
|
||||
const { children, spawn } = createSpawnHarness();
|
||||
@@ -1665,6 +1465,7 @@ describe('OpencodeManager', () => {
|
||||
const manager = new OpencodeManager({
|
||||
port: 4350,
|
||||
binPath: '/opt/opencode',
|
||||
findPortOwner: vi.fn().mockResolvedValue(null),
|
||||
runtimeConfigProvider: () => {
|
||||
configCalls += 1;
|
||||
return configCalls === 1 ? firstConfig : { config: {}, env: {} };
|
||||
|
||||
Reference in New Issue
Block a user