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
542 changes: 5 additions & 537 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@
"chalk": "^5.3.0",
"fs-extra": "^11.2.0",
"glob": "^11.0.0",
"inquirer": "^12.4.1",
"js-yaml": "^4.1.0",
"mime-types": "^3.0.2",
"semver": "^7.6.3",
Expand Down
133 changes: 28 additions & 105 deletions src/classes/ManagerLocal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,33 +26,25 @@ import {
isAdmin,
runCliAsAdmin,
} from '../helpers/file.js';
import {
isValidGithubRepo,
isValidSlug,
isValidVersion,
pathGetSlug,
pathGetVersion,
toSlug,
} from '../helpers/utils.js';
import { isValidGithubRepo, isValidSlug, isValidVersion, pathGetSlug, pathGetVersion } from '../helpers/utils.js';
import { commandExists, getArchitecture, getSystem, isTests } from '../helpers/utilsLocal.js';
import { apiBuffer } from '../helpers/api.js';
import { CreateQuestion, createPackageQuestions, createPackageVersionQuestions } from '../helpers/createQuestions.js';
import { FileInterface } from '../types/File.js';
import { FileType } from '../types/FileType.js';
import { RegistryType } from '../types/Registry.js';
import { PluginFormat, pluginFormatDir } from '../types/PluginFormat.js';
import { ConfigInterface } from '../types/Config.js';
import { ConfigLocal } from './ConfigLocal.js';
import { packageCompatibleFiles, packageErrors, packageRecommendations } from '../helpers/package.js';
import { PresetInterface } from '../types/Preset.js';
import { presetFormatDir } from '../types/PresetFormat.js';
import { ProjectInterface } from '../types/Project.js';
import { projectFormatDir } from '../types/ProjectFormat.js';
import { FileFormat } from '../types/FileFormat.js';
import { licenses } from '../types/License.js';
import { PluginType, PluginTypeOption, pluginTypes } from '../types/PluginType.js';
import { PresetTypeOption, presetTypes } from '../types/PresetType.js';
import { ProjectTypeOption, projectTypes } from '../types/ProjectType.js';
import { PluginType } from '../types/PluginType.js';
import { SystemType } from '../types/SystemType.js';
import { packageLoadFile, packageSaveFile } from '../helpers/packageLocal.js';
import inquirer from 'inquirer';

export class ManagerLocal extends Manager {
protected typeDir: string;
Expand Down Expand Up @@ -138,102 +130,33 @@ export class ManagerLocal extends Manager {
return dirTarget;
}

async create(dirPath?: string) {
// TODO Rewrite this code after prototype is proven.
const pkgQuestions = [
{
name: 'org',
type: 'input',
message: 'Org id',
default: 'org-name',
validate: (value: string) => value === toSlug(value),
},
{
name: 'package',
type: 'input',
message: 'Package id',
default: 'package-name',
validate: (value: string) => value === toSlug(value),
},
{
name: 'version',
type: 'input',
message: 'Package version',
default: '1.0.0',
validate: (value: string) => isValidVersion(value),
},
];
const pkgAnswers = await inquirer.prompt(pkgQuestions as any);
let types: PluginTypeOption[] | PresetTypeOption[] | ProjectTypeOption[] = pluginTypes;
if (this.type === RegistryType.Apps) {
types = pluginTypes;
} else if (this.type === RegistryType.Presets) {
types = presetTypes;
} else if (this.type === RegistryType.Projects) {
types = projectTypes;
}
const pkgVersionQuestions = [
{ name: 'name', type: 'input', message: 'Package name' },
{ name: 'author', type: 'input', message: 'Author name' },
{ name: 'description', type: 'input', message: 'Description' },
{ name: 'license', type: 'list', message: 'License', choices: licenses },
{ name: 'type', type: 'list', message: 'Type', choices: types },
{
name: 'tags',
type: 'input',
message: 'Tags (comma-separated)',
filter: (input: string) =>
input
.split(',')
.map(tag => tag.trim())
.filter(tag => tag.length > 0),
},
{
name: 'url',
type: 'input',
message: 'Website url',
default: `https://github.com/${pkgAnswers.org}/${pkgAnswers.package}`,
},
{
name: 'donate',
type: 'input',
message: 'Donation url',
},
{
name: 'audio',
type: 'input',
message: 'Audio preview url',
default: `https://open-audio-stack.github.io/open-audio-stack-registry/${this.type}/${pkgAnswers.org}/${pkgAnswers.package}/${pkgAnswers.package}.flac`,
},
{
name: 'image',
type: 'input',
message: 'Image preview url',
default: `https://open-audio-stack.github.io/open-audio-stack-registry/${this.type}/${pkgAnswers.org}/${pkgAnswers.package}/${pkgAnswers.package}.jpg`,
},
{ name: 'date', type: 'input', message: 'Date released', default: new Date().toISOString() },
{ name: 'changes', type: 'input', message: 'List of changes' },
];

const pkgVersionAnswers = await inquirer.prompt(pkgVersionQuestions as any);
// TODO prompt for each file. Left empty here deliberately - a freshly created package has no
// built/published release yet, so there's nothing to fill `files` with. createSave() reports
// this as a recommendation rather than treating it as a fatal validation error.
pkgVersionAnswers.files = [];
if (this.type === RegistryType.Presets || this.type === RegistryType.Projects) {
pkgVersionAnswers.plugins = [];
}
return this.createSave(`${pkgAnswers.org}/${pkgAnswers.package}`, pkgVersionAnswers as PackageVersion, dirPath);
// Interactive prompting (previously driven by the `inquirer` package directly from this
// method) doesn't belong in an isomorphic browser/server library - see review.md item 5. The
// question metadata below is what a CLI needs to drive its own prompt library; createSave()
// then persists whatever it collects. createQuestions() first, to obtain org/package (needed
// to compute createVersionQuestions()'s own defaults), then createVersionQuestions(org, pkg).

createQuestions(): CreateQuestion[] {
return createPackageQuestions();
}

// Split out from create() so package persistence is testable without driving inquirer's
// interactive prompts. A freshly created package is expected to be incomplete (e.g. `files`
// stays empty until a release is built and published) so, unlike Package.addVersion() which
// throws on any validation error, this only logs errors/recommendations as a report and always
// persists - the point of `create` is to scaffold the metadata file for a developer to fill in
// over time, not to produce a fully valid, publishable package on the first pass.
createVersionQuestions(org: string, pkg: string): CreateQuestion[] {
return createPackageVersionQuestions(this.type, org, pkg);
}

// A freshly created package is expected to be incomplete - there is no built/published release
// yet, so `files` (and, for Presets/Projects, `plugins`) default to empty rather than requiring
// the caller to remember to set them - so, unlike Package.addVersion() which throws on any
// validation error, this only logs errors/recommendations as a report and always persists - the
// point of `create` is to scaffold the metadata file for a developer to fill in over time, not
// to produce a fully valid, publishable package on the first pass.
createSave(slug: string, pkgVersion: PackageVersion, dirPath?: string) {
if (!isValidSlug(slug)) throw new Error(`Invalid package slug: ${slug}`);
if (!pkgVersion.files) pkgVersion.files = [];
if (this.type === RegistryType.Presets || this.type === RegistryType.Projects) {
const pkgVersionWithPlugins = pkgVersion as PresetInterface | ProjectInterface;
if (!pkgVersionWithPlugins.plugins) pkgVersionWithPlugins.plugins = {};
}
const errors = packageErrors(pkgVersion);
const recs = packageRecommendations(pkgVersion);
this.logReport(slug, errors, recs);
Expand Down
103 changes: 103 additions & 0 deletions src/helpers/createQuestions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { isValidVersion, toSlug } from './utils.js';
import { licenses } from '../types/License.js';
import { PluginTypeOption, pluginTypes } from '../types/PluginType.js';
import { PresetTypeOption, presetTypes } from '../types/PresetType.js';
import { ProjectTypeOption, projectTypes } from '../types/ProjectType.js';
import { RegistryType } from '../types/Registry.js';

// Deliberately not the `inquirer` package's own Question type - core has no interactive-prompt
// dependency (previously it depended on `inquirer` directly, which pulled a CLI/UX concern into
// this isomorphic browser/server library - see review.md item 5). This shape happens to line up
// with what most JS prompt libraries (inquirer included) expect for a single question, so a CLI
// can typically pass these straight through, but it's defined here independently.
export interface CreateQuestion {
name: string;
type: 'input' | 'list';
message: string;
default?: string;
choices?: readonly { name: string; value: string; description?: string }[];
validate?: (value: string) => boolean;
filter?: (value: string) => unknown;
}

// The org/package/version questions needed to identify a new package before anything else can be
// asked (e.g. `packageVersionQuestions()` below needs `org`/`pkg` to compute its own defaults).
export function createPackageQuestions(): CreateQuestion[] {
return [
{
name: 'org',
type: 'input',
message: 'Org id',
default: 'org-name',
validate: (value: string) => value === toSlug(value),
},
{
name: 'package',
type: 'input',
message: 'Package id',
default: 'package-name',
validate: (value: string) => value === toSlug(value),
},
{
name: 'version',
type: 'input',
message: 'Package version',
default: '1.0.0',
validate: (value: string) => isValidVersion(value),
},
];
}

// The remaining package version fields (see specification.md "Packages fields to populate") -
// parameterized by registry type and the org/package answered via createPackageQuestions() above,
// since several defaults (url/audio/image) are derived from them.
export function createPackageVersionQuestions(type: RegistryType, org: string, pkg: string): CreateQuestion[] {
let types: PluginTypeOption[] | PresetTypeOption[] | ProjectTypeOption[] = pluginTypes;
if (type === RegistryType.Presets) {
types = presetTypes;
} else if (type === RegistryType.Projects) {
types = projectTypes;
}
return [
{ name: 'name', type: 'input', message: 'Package name' },
{ name: 'author', type: 'input', message: 'Author name' },
{ name: 'description', type: 'input', message: 'Description' },
{ name: 'license', type: 'list', message: 'License', choices: licenses },
{ name: 'type', type: 'list', message: 'Type', choices: types },
{
name: 'tags',
type: 'input',
message: 'Tags (comma-separated)',
filter: (input: string) =>
input
.split(',')
.map(tag => tag.trim())
.filter(tag => tag.length > 0),
},
{
name: 'url',
type: 'input',
message: 'Website url',
default: `https://github.com/${org}/${pkg}`,
},
{
name: 'donate',
type: 'input',
message: 'Donation url',
},
{
name: 'audio',
type: 'input',
message: 'Audio preview url',
default: `https://open-audio-stack.github.io/open-audio-stack-registry/${type}/${org}/${pkg}/${pkg}.flac`,
},
{
name: 'image',
type: 'input',
message: 'Image preview url',
default: `https://open-audio-stack.github.io/open-audio-stack-registry/${type}/${org}/${pkg}/${pkg}.jpg`,
},
{ name: 'date', type: 'input', message: 'Date released', default: new Date().toISOString() },
{ name: 'changes', type: 'input', message: 'List of changes' },
];
}
1 change: 1 addition & 0 deletions src/index-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export * from './classes/Registry.js';
export * from './helpers/api.js';
export * from './helpers/config.js';
// export * from './helpers/configLocal.js';
export * from './helpers/createQuestions.js';
// export * from './helpers/file.js';
export * from './helpers/package.js';
// export * from './helpers/packageLocal.js';
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export * from './classes/RegistryLocal.js';
export * from './helpers/api.js';
export * from './helpers/config.js';
export * from './helpers/configLocal.js';
export * from './helpers/createQuestions.js';
export * from './helpers/file.js';
export * from './helpers/package.js';
export * from './helpers/packageLocal.js';
Expand Down
39 changes: 39 additions & 0 deletions tests/classes/ManagerLocal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,45 @@ test('Project sync, install project, add new dependency, remove new dependency',
expect(omitDownloads(pkgNoDepsAgain)).toEqual(omitDownloads(PROJECT_NO_DEPS));
});

test('createQuestions returns the org/package/version questions needed before createVersionQuestions', () => {
const manager = new ManagerLocal(RegistryType.Plugins, CONFIG);
const questions = manager.createQuestions();
expect(questions.map(q => q.name)).toEqual(['org', 'package', 'version']);
});

test('createVersionQuestions returns type-appropriate choices and org/package-derived defaults', () => {
const pluginManager = new ManagerLocal(RegistryType.Plugins, CONFIG);
const pluginQuestions = pluginManager.createVersionQuestions('test-org', 'test-plugin');
const pluginUrlQuestion = pluginQuestions.find(q => q.name === 'url');
expect(pluginUrlQuestion?.default).toEqual('https://github.com/test-org/test-plugin');
const pluginTypeQuestion = pluginQuestions.find(q => q.name === 'type');
expect(pluginTypeQuestion?.choices?.length).toBeGreaterThan(0);

const presetManager = new ManagerLocal(RegistryType.Presets, CONFIG);
const presetQuestions = presetManager.createVersionQuestions('test-org', 'test-preset');
const presetTypeQuestion = presetQuestions.find(q => q.name === 'type');
// Preset and Plugin types are different enums - a Presets manager must offer preset-specific
// choices, not fall through to the Plugins default.
expect(presetTypeQuestion?.choices).not.toEqual(pluginTypeQuestion?.choices);
});

test('Create save defaults files to an empty array and, for Presets/Projects, plugins to an empty object', () => {
const presetManager = new ManagerLocal(RegistryType.Presets, CONFIG);
const dirTarget: string = path.join(APP_DIR, 'create', 'test-org', 'test-preset-defaults');
// Deliberately omit `files`/`plugins` entirely, as a CLI assembling answers from
// createQuestions()/createVersionQuestions() would - createSave() must fill both in rather
// than requiring every caller to remember this.
const pkgVersion = { ...PRESET } as Partial<PackageVersion> as PackageVersion;
delete (pkgVersion as any).files;
delete (pkgVersion as any).plugins;

const filePath: string = presetManager.createSave('test-org/test-preset-defaults', pkgVersion, dirTarget);

const saved = fileReadJson(filePath);
expect(saved.files).toEqual([]);
expect(saved.plugins).toEqual({});
});

test('Create save persists an incomplete package without throwing', () => {
const manager = new ManagerLocal(RegistryType.Plugins, CONFIG);
const pkgVersion: PackageVersion = { ...PLUGIN, files: [] };
Expand Down
59 changes: 59 additions & 0 deletions tests/helpers/createQuestions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { expect, test } from 'vitest';
import { createPackageQuestions, createPackageVersionQuestions } from '../../src/helpers/createQuestions';
import { RegistryType } from '../../src/types/Registry';
import { toSlug, isValidVersion } from '../../src/helpers/utils';

test('createPackageQuestions validates org/package as slugs and version as semver', () => {
const questions = createPackageQuestions();
const org = questions.find(q => q.name === 'org');
const pkg = questions.find(q => q.name === 'package');
const version = questions.find(q => q.name === 'version');

expect(org?.validate?.('my-org')).toEqual(true);
expect(org?.validate?.('My Org')).toEqual(false);
expect(pkg?.validate?.('my-package')).toEqual(true);
expect(pkg?.validate?.('My Package')).toEqual(false);
expect(version?.validate?.('1.0.0')).toEqual(true);
expect(version?.validate?.('not-a-version')).toEqual(false);

// Sanity check against the underlying helpers directly, so this test fails if their behavior
// ever diverges from what these questions assume.
expect(org?.validate?.('my-org')).toEqual('my-org' === toSlug('my-org'));
expect(version?.validate?.('1.0.0')).toEqual(isValidVersion('1.0.0'));
});

test('createPackageQuestions tags filter splits and trims a comma-separated string', () => {
// Tags live on createPackageVersionQuestions, not createPackageQuestions - covered below.
const questions = createPackageVersionQuestions(RegistryType.Plugins, 'test-org', 'test-plugin');
const tags = questions.find(q => q.name === 'tags');
expect(tags?.filter?.('Synth, Modulation, Effect ,')).toEqual(['Synth', 'Modulation', 'Effect']);
});

test('createPackageVersionQuestions derives url/audio/image defaults from type/org/package', () => {
const questions = createPackageVersionQuestions(RegistryType.Plugins, 'surge-synthesizer', 'surge');
expect(questions.find(q => q.name === 'url')?.default).toEqual('https://github.com/surge-synthesizer/surge');
expect(questions.find(q => q.name === 'audio')?.default).toEqual(
'https://open-audio-stack.github.io/open-audio-stack-registry/plugins/surge-synthesizer/surge/surge.flac',
);
expect(questions.find(q => q.name === 'image')?.default).toEqual(
'https://open-audio-stack.github.io/open-audio-stack-registry/plugins/surge-synthesizer/surge/surge.jpg',
);
});

test('createPackageVersionQuestions offers type-specific choices per registry type', () => {
const pluginChoices = createPackageVersionQuestions(RegistryType.Plugins, 'org', 'pkg').find(
q => q.name === 'type',
)?.choices;
const presetChoices = createPackageVersionQuestions(RegistryType.Presets, 'org', 'pkg').find(
q => q.name === 'type',
)?.choices;
const projectChoices = createPackageVersionQuestions(RegistryType.Projects, 'org', 'pkg').find(
q => q.name === 'type',
)?.choices;

expect(pluginChoices?.length).toBeGreaterThan(0);
expect(presetChoices?.length).toBeGreaterThan(0);
expect(projectChoices?.length).toBeGreaterThan(0);
expect(pluginChoices).not.toEqual(presetChoices);
expect(presetChoices).not.toEqual(projectChoices);
});
Loading