opensdd 0.1.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.
- package/LICENSE +21 -0
- package/bin/opensdd.js +2 -0
- package/opensdd/cli.md +633 -0
- package/opensdd/sdd-generate.md +209 -0
- package/opensdd/sdd-manager.md +134 -0
- package/opensdd/spec-format.md +494 -0
- package/package.json +31 -0
- package/src/commands/init.js +184 -0
- package/src/commands/install.js +161 -0
- package/src/commands/list.js +40 -0
- package/src/commands/publish.js +235 -0
- package/src/commands/status.js +91 -0
- package/src/commands/update.js +227 -0
- package/src/commands/updateApply.js +149 -0
- package/src/commands/validate.js +95 -0
- package/src/index.js +126 -0
- package/src/lib/manifest.js +38 -0
- package/src/lib/registry.js +176 -0
- package/src/lib/skills.js +204 -0
- package/src/lib/validation.js +122 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import readline from 'node:readline';
|
|
4
|
+
import { installSkills } from '../lib/skills.js';
|
|
5
|
+
|
|
6
|
+
const PROJECT_MARKERS = [
|
|
7
|
+
'package.json',
|
|
8
|
+
'pyproject.toml',
|
|
9
|
+
'Cargo.toml',
|
|
10
|
+
'go.mod',
|
|
11
|
+
'.git',
|
|
12
|
+
'opensdd.json',
|
|
13
|
+
'pom.xml',
|
|
14
|
+
'build.gradle',
|
|
15
|
+
'build.gradle.kts',
|
|
16
|
+
'Makefile',
|
|
17
|
+
'CMakeLists.txt',
|
|
18
|
+
'composer.json',
|
|
19
|
+
'Gemfile',
|
|
20
|
+
'setup.py',
|
|
21
|
+
'setup.cfg',
|
|
22
|
+
'mix.exs',
|
|
23
|
+
'deno.json',
|
|
24
|
+
'bun.lockb',
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
function hasProjectMarker(dir) {
|
|
28
|
+
return PROJECT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getProjectName(dir) {
|
|
32
|
+
// Try package.json
|
|
33
|
+
const pkgPath = path.join(dir, 'package.json');
|
|
34
|
+
if (fs.existsSync(pkgPath)) {
|
|
35
|
+
try {
|
|
36
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
37
|
+
if (pkg.name) return pkg.name;
|
|
38
|
+
} catch {
|
|
39
|
+
// ignore
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// Try pyproject.toml
|
|
43
|
+
const pyprojectPath = path.join(dir, 'pyproject.toml');
|
|
44
|
+
if (fs.existsSync(pyprojectPath)) {
|
|
45
|
+
try {
|
|
46
|
+
const content = fs.readFileSync(pyprojectPath, 'utf-8');
|
|
47
|
+
const match = content.match(/name\s*=\s*"([^"]+)"/);
|
|
48
|
+
if (match) return match[1];
|
|
49
|
+
} catch {
|
|
50
|
+
// ignore
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// Try Cargo.toml
|
|
54
|
+
const cargoPath = path.join(dir, 'Cargo.toml');
|
|
55
|
+
if (fs.existsSync(cargoPath)) {
|
|
56
|
+
try {
|
|
57
|
+
const content = fs.readFileSync(cargoPath, 'utf-8');
|
|
58
|
+
const match = content.match(/name\s*=\s*"([^"]+)"/);
|
|
59
|
+
if (match) return match[1];
|
|
60
|
+
} catch {
|
|
61
|
+
// ignore
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Default to directory name
|
|
65
|
+
return path.basename(dir);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function promptYN(question) {
|
|
69
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
70
|
+
return new Promise((resolve) => {
|
|
71
|
+
rl.question(question, (answer) => {
|
|
72
|
+
rl.close();
|
|
73
|
+
resolve(answer.toLowerCase() === 'y');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function initCommand() {
|
|
79
|
+
const cwd = process.cwd();
|
|
80
|
+
|
|
81
|
+
// Step 1: Check for project markers
|
|
82
|
+
if (!hasProjectMarker(cwd)) {
|
|
83
|
+
const proceed = await promptYN(
|
|
84
|
+
'Warning: No project markers found in the current directory. Continue? (y/n) '
|
|
85
|
+
);
|
|
86
|
+
if (!proceed) {
|
|
87
|
+
process.exit(0);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Step 2: Install skills to all agent formats
|
|
92
|
+
let warnings;
|
|
93
|
+
try {
|
|
94
|
+
warnings = installSkills(cwd);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.error(`Error: Could not install skills: ${err.message}`);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const w of warnings) {
|
|
101
|
+
console.warn(`Warning: ${w}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Step 3: Read or create opensdd.json
|
|
105
|
+
const manifestPath = path.join(cwd, 'opensdd.json');
|
|
106
|
+
let manifest = null;
|
|
107
|
+
let manifestCreated = false;
|
|
108
|
+
|
|
109
|
+
if (fs.existsSync(manifestPath)) {
|
|
110
|
+
try {
|
|
111
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
112
|
+
} catch (err) {
|
|
113
|
+
console.error(`Error: opensdd.json is malformed JSON: ${err.message}`);
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!manifest) {
|
|
119
|
+
manifest = {
|
|
120
|
+
opensdd: '0.1.0',
|
|
121
|
+
specs_dir: 'opensdd',
|
|
122
|
+
deps_dir: '.opensdd.deps',
|
|
123
|
+
};
|
|
124
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
125
|
+
manifestCreated = true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const specsDir = manifest.specs_dir || 'opensdd';
|
|
129
|
+
const depsDir = manifest.deps_dir || '.opensdd.deps';
|
|
130
|
+
const specsDirPath = path.join(cwd, specsDir);
|
|
131
|
+
const depsDirPath = path.join(cwd, depsDir);
|
|
132
|
+
|
|
133
|
+
// Step 4: Create specs directory
|
|
134
|
+
const specsDirCreated = !fs.existsSync(specsDirPath);
|
|
135
|
+
fs.mkdirSync(specsDirPath, { recursive: true });
|
|
136
|
+
|
|
137
|
+
// Step 5: Create skeleton spec.md
|
|
138
|
+
const specMdPath = path.join(specsDirPath, 'spec.md');
|
|
139
|
+
let specMdCreated = false;
|
|
140
|
+
if (!fs.existsSync(specMdPath)) {
|
|
141
|
+
const projectName = getProjectName(cwd);
|
|
142
|
+
const skeleton = `# ${projectName}
|
|
143
|
+
|
|
144
|
+
> TODO: One-line description of what this software does.
|
|
145
|
+
|
|
146
|
+
## Behavioral Contract
|
|
147
|
+
|
|
148
|
+
<!-- Define behaviors here. -->
|
|
149
|
+
|
|
150
|
+
## NOT Specified (Implementation Freedom)
|
|
151
|
+
|
|
152
|
+
<!-- List aspects left to the implementer's discretion. -->
|
|
153
|
+
|
|
154
|
+
## Invariants
|
|
155
|
+
|
|
156
|
+
<!-- List properties that must hold true across all inputs and states. -->
|
|
157
|
+
`;
|
|
158
|
+
fs.writeFileSync(specMdPath, skeleton);
|
|
159
|
+
specMdCreated = true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Step 6: Create deps directory
|
|
163
|
+
const depsDirCreated = !fs.existsSync(depsDirPath);
|
|
164
|
+
fs.mkdirSync(depsDirPath, { recursive: true });
|
|
165
|
+
|
|
166
|
+
// Step 7: Print output
|
|
167
|
+
const isReInit = !manifestCreated;
|
|
168
|
+
const skillVerb = isReInit ? 'updated' : 'installed';
|
|
169
|
+
|
|
170
|
+
console.log('Initialized OpenSDD:');
|
|
171
|
+
console.log(
|
|
172
|
+
' Skills installed for: Claude Code, Codex CLI, Cursor, GitHub Copilot, Gemini CLI, Amp'
|
|
173
|
+
);
|
|
174
|
+
console.log(` sdd-manager ${skillVerb} (6 agent formats)`);
|
|
175
|
+
console.log(` sdd-generate ${skillVerb} (6 agent formats)`);
|
|
176
|
+
console.log(
|
|
177
|
+
` opensdd.json ${manifestCreated ? 'created' : 'already exists (preserved)'}`
|
|
178
|
+
);
|
|
179
|
+
console.log(` ${specsDir}/ ${specsDirCreated ? 'created' : 'already exists'}`);
|
|
180
|
+
console.log(
|
|
181
|
+
` ${specsDir}/spec.md ${specMdCreated ? 'created (skeleton)' : 'already exists (preserved)'}`
|
|
182
|
+
);
|
|
183
|
+
console.log(` ${depsDir}/ ${depsDirCreated ? 'created' : 'already exists'}`);
|
|
184
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
findManifestPath,
|
|
5
|
+
readManifest,
|
|
6
|
+
writeManifest,
|
|
7
|
+
getDepsDir,
|
|
8
|
+
} from '../lib/manifest.js';
|
|
9
|
+
import {
|
|
10
|
+
resolveRegistry,
|
|
11
|
+
fetchSpecIndex,
|
|
12
|
+
fetchSpecManifest,
|
|
13
|
+
fetchSpecFiles,
|
|
14
|
+
listRegistrySpecs,
|
|
15
|
+
} from '../lib/registry.js';
|
|
16
|
+
|
|
17
|
+
function isValidSpecName(name) {
|
|
18
|
+
return /^[a-z0-9]+(-[a-z0-9]+)*$/.test(name);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function installCommand(name, version, options) {
|
|
22
|
+
// Step 1: Verify opensdd.json exists
|
|
23
|
+
const manifestPath = findManifestPath(process.cwd());
|
|
24
|
+
if (!manifestPath) {
|
|
25
|
+
console.error('Error: OpenSDD not initialized. Run `opensdd init` to get started.');
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const manifest = readManifest(manifestPath);
|
|
30
|
+
const projectRoot = path.dirname(manifestPath);
|
|
31
|
+
const depsDir = getDepsDir(manifest);
|
|
32
|
+
const depsDirPath = path.join(projectRoot, depsDir);
|
|
33
|
+
const registrySource = resolveRegistry(options, manifest);
|
|
34
|
+
|
|
35
|
+
// Step 2: Check if already installed
|
|
36
|
+
const deps = manifest.dependencies || {};
|
|
37
|
+
const specDirPath = path.join(depsDirPath, name);
|
|
38
|
+
|
|
39
|
+
let useVersion = version;
|
|
40
|
+
|
|
41
|
+
if (deps[name] && fs.existsSync(specDirPath)) {
|
|
42
|
+
console.error(`Error: ${name} is already installed (v${deps[name].version}).`);
|
|
43
|
+
console.error(`Use \`opensdd update ${name}\` to update to the latest version.`);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Handle stale entry (entry exists but directory missing)
|
|
48
|
+
if (deps[name] && !fs.existsSync(specDirPath)) {
|
|
49
|
+
console.log(
|
|
50
|
+
`Note: Found stale entry for ${name} in opensdd.json (directory missing). Re-installing.`
|
|
51
|
+
);
|
|
52
|
+
if (!useVersion) {
|
|
53
|
+
useVersion = deps[name].version;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Step 3: Validate spec name
|
|
58
|
+
if (!isValidSpecName(name)) {
|
|
59
|
+
console.error(
|
|
60
|
+
`Error: Invalid spec name "${name}". Allowed characters: lowercase alphanumeric and hyphens only.`
|
|
61
|
+
);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Step 4: Fetch index.json
|
|
66
|
+
let index;
|
|
67
|
+
try {
|
|
68
|
+
index = await fetchSpecIndex(registrySource, name);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
console.error(`Error: Could not reach registry at ${registrySource}`);
|
|
71
|
+
console.error(err.message);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!index) {
|
|
76
|
+
console.error(`Error: Spec "${name}" not found in registry.`);
|
|
77
|
+
try {
|
|
78
|
+
const available = await listRegistrySpecs(registrySource);
|
|
79
|
+
if (available.length > 0) {
|
|
80
|
+
console.error('\nAvailable specs:');
|
|
81
|
+
for (const s of available) {
|
|
82
|
+
console.error(` ${s.name}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
// ignore listing errors
|
|
87
|
+
}
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Resolve version
|
|
92
|
+
const resolvedVersion = useVersion || index.latest;
|
|
93
|
+
|
|
94
|
+
// Check version exists
|
|
95
|
+
if (!index.versions || !index.versions[resolvedVersion]) {
|
|
96
|
+
console.error(`Error: Version ${resolvedVersion} not found for ${name}.`);
|
|
97
|
+
const availableVersions = Object.keys(index.versions || {});
|
|
98
|
+
if (availableVersions.length > 0) {
|
|
99
|
+
console.error(`Available versions: ${availableVersions.join(', ')}`);
|
|
100
|
+
}
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Step 5: Fetch manifest for the version
|
|
105
|
+
const specManifest = await fetchSpecManifest(registrySource, name, resolvedVersion);
|
|
106
|
+
if (!specManifest) {
|
|
107
|
+
console.error(`Error: Could not fetch manifest for ${name}@${resolvedVersion}`);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Step 6: Fetch all spec files and write to deps dir
|
|
112
|
+
const files = await fetchSpecFiles(registrySource, name, resolvedVersion);
|
|
113
|
+
if (!files) {
|
|
114
|
+
console.error(`Error: Could not fetch spec files for ${name}@${resolvedVersion}`);
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
fs.mkdirSync(specDirPath, { recursive: true });
|
|
119
|
+
for (const [fileName, content] of Object.entries(files)) {
|
|
120
|
+
// Invariant: opensdd install MUST NOT create a deviations.md file
|
|
121
|
+
if (fileName === 'deviations.md') continue;
|
|
122
|
+
fs.writeFileSync(path.join(specDirPath, fileName), content);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Step 7: Add entry to opensdd.json
|
|
126
|
+
if (!manifest.dependencies) {
|
|
127
|
+
manifest.dependencies = {};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Preserve consumer-managed fields from stale entry if re-installing
|
|
131
|
+
const existingEntry = deps[name];
|
|
132
|
+
manifest.dependencies[name] = {
|
|
133
|
+
version: resolvedVersion,
|
|
134
|
+
source: registrySource,
|
|
135
|
+
spec_format: specManifest.spec_format || '0.1.0',
|
|
136
|
+
implementation: existingEntry?.implementation ?? null,
|
|
137
|
+
tests: existingEntry?.tests ?? null,
|
|
138
|
+
has_deviations: existingEntry?.has_deviations ?? false,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
writeManifest(manifestPath, manifest);
|
|
142
|
+
|
|
143
|
+
// Step 8: Check for missing dependencies
|
|
144
|
+
if (specManifest.dependencies && specManifest.dependencies.length > 0) {
|
|
145
|
+
const missing = specManifest.dependencies.filter(
|
|
146
|
+
(dep) => !manifest.dependencies[dep]
|
|
147
|
+
);
|
|
148
|
+
if (missing.length > 0) {
|
|
149
|
+
console.log('\nWarning: This spec has uninstalled dependencies:');
|
|
150
|
+
for (const dep of missing) {
|
|
151
|
+
console.log(` Run \`opensdd install ${dep}\` to install ${dep}.`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Step 9: Print success
|
|
157
|
+
console.log(`Installed ${name} v${resolvedVersion} to ${depsDir}/${name}/`);
|
|
158
|
+
console.log(
|
|
159
|
+
`\nRun "implement the ${name} spec" in your agent to generate an implementation.`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { findManifestPath, readManifest } from '../lib/manifest.js';
|
|
2
|
+
import { resolveRegistry, listRegistrySpecs } from '../lib/registry.js';
|
|
3
|
+
|
|
4
|
+
export async function listCommand(options) {
|
|
5
|
+
// list doesn't require opensdd.json, but uses it for registry resolution if available
|
|
6
|
+
let manifest = null;
|
|
7
|
+
const manifestPath = findManifestPath(process.cwd());
|
|
8
|
+
if (manifestPath) {
|
|
9
|
+
try {
|
|
10
|
+
manifest = readManifest(manifestPath);
|
|
11
|
+
} catch {
|
|
12
|
+
// ignore — list works without opensdd.json
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const registrySource = resolveRegistry(options, manifest);
|
|
17
|
+
|
|
18
|
+
let specs;
|
|
19
|
+
try {
|
|
20
|
+
specs = await listRegistrySpecs(registrySource);
|
|
21
|
+
} catch (err) {
|
|
22
|
+
console.error(`Error: Could not reach registry at ${registrySource}`);
|
|
23
|
+
console.error(err.message);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (specs.length === 0) {
|
|
28
|
+
console.log('No specs available in the registry.');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
console.log('Available specs:\n');
|
|
33
|
+
|
|
34
|
+
for (const spec of specs) {
|
|
35
|
+
const name = (spec.name || 'unknown').padEnd(17);
|
|
36
|
+
const version = `v${spec.latest || '0.0.0'}`.padEnd(8);
|
|
37
|
+
const desc = spec.description || '';
|
|
38
|
+
console.log(` ${name}${version}${desc}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { execSync } from 'node:child_process';
|
|
5
|
+
import readline from 'node:readline';
|
|
6
|
+
import {
|
|
7
|
+
findManifestPath,
|
|
8
|
+
readManifest,
|
|
9
|
+
getSpecsDir,
|
|
10
|
+
} from '../lib/manifest.js';
|
|
11
|
+
import {
|
|
12
|
+
resolveRegistry,
|
|
13
|
+
isGitHubUrl,
|
|
14
|
+
parseGitHubUrl,
|
|
15
|
+
fetchSpecIndex,
|
|
16
|
+
} from '../lib/registry.js';
|
|
17
|
+
import { validateSpec } from '../lib/validation.js';
|
|
18
|
+
|
|
19
|
+
function prompt(question) {
|
|
20
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
rl.question(question, (answer) => {
|
|
23
|
+
rl.close();
|
|
24
|
+
resolve(answer);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function commandExists(cmd) {
|
|
30
|
+
try {
|
|
31
|
+
execSync(`which ${cmd}`, { stdio: 'ignore' });
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function copyDirRecursive(src, dest) {
|
|
39
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
40
|
+
const entries = fs.readdirSync(src, { withFileTypes: true });
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
const srcPath = path.join(src, entry.name);
|
|
43
|
+
const destPath = path.join(dest, entry.name);
|
|
44
|
+
if (entry.isDirectory()) {
|
|
45
|
+
copyDirRecursive(srcPath, destPath);
|
|
46
|
+
} else {
|
|
47
|
+
fs.copyFileSync(srcPath, destPath);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function publishCommand(options) {
|
|
53
|
+
// Step 1: Verify opensdd.json
|
|
54
|
+
const manifestPath = findManifestPath(process.cwd());
|
|
55
|
+
if (!manifestPath) {
|
|
56
|
+
console.error('Error: OpenSDD not initialized. Run `opensdd init` to get started.');
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const manifest = readManifest(manifestPath);
|
|
61
|
+
const projectRoot = path.dirname(manifestPath);
|
|
62
|
+
const registrySource = resolveRegistry(options, manifest);
|
|
63
|
+
|
|
64
|
+
// Step 2: Verify publish section
|
|
65
|
+
if (!manifest.publish) {
|
|
66
|
+
console.error('Error: No `publish` section in opensdd.json.');
|
|
67
|
+
console.error(
|
|
68
|
+
'Add a `publish` object with name, version, description, and spec_format to publish your spec.'
|
|
69
|
+
);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const { name, version, description, spec_format, dependencies } = manifest.publish;
|
|
74
|
+
|
|
75
|
+
if (!name || !version || !description || !spec_format) {
|
|
76
|
+
console.error(
|
|
77
|
+
'Error: `publish` section must include name, version, description, and spec_format.'
|
|
78
|
+
);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Step 4: Verify spec.md exists
|
|
83
|
+
const specsDir = getSpecsDir(manifest);
|
|
84
|
+
const specsDirPath = path.join(projectRoot, specsDir);
|
|
85
|
+
|
|
86
|
+
if (!fs.existsSync(path.join(specsDirPath, 'spec.md'))) {
|
|
87
|
+
console.error(`Error: ${specsDir}/spec.md not found. Create your spec before publishing.`);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Step 5: Validate spec
|
|
92
|
+
console.log(`Publishing ${name} v${version} to registry...\n`);
|
|
93
|
+
|
|
94
|
+
const validation = validateSpec(specsDirPath);
|
|
95
|
+
if (validation.errors.length > 0) {
|
|
96
|
+
console.error(' Validated spec FAILED\n');
|
|
97
|
+
for (const err of validation.errors) {
|
|
98
|
+
console.error(` - ${err}`);
|
|
99
|
+
}
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
console.log(' Validated spec ok');
|
|
103
|
+
|
|
104
|
+
// Step 6: Verify registry is GitHub
|
|
105
|
+
if (!isGitHubUrl(registrySource)) {
|
|
106
|
+
console.error('Error: Publishing requires a GitHub registry URL.');
|
|
107
|
+
console.error(`Current registry: ${registrySource}`);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Check git and gh CLI
|
|
112
|
+
if (!commandExists('git')) {
|
|
113
|
+
console.error('Error: git is not installed. Install git to publish specs.');
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
if (!commandExists('gh')) {
|
|
117
|
+
console.error('Error: GitHub CLI (gh) is not installed.');
|
|
118
|
+
console.error(
|
|
119
|
+
'Install it from https://cli.github.com/ and authenticate with `gh auth login`.'
|
|
120
|
+
);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Step 7: Check if version already exists
|
|
125
|
+
try {
|
|
126
|
+
const index = await fetchSpecIndex(registrySource, name);
|
|
127
|
+
if (index && index.versions && index.versions[version]) {
|
|
128
|
+
console.error(
|
|
129
|
+
`\nError: Version ${version} already exists in the registry for ${name}.`
|
|
130
|
+
);
|
|
131
|
+
console.error('Bump the version in opensdd.json before publishing.');
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
} catch {
|
|
135
|
+
// Spec doesn't exist in registry yet — that's fine
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Step 9: Determine branch name
|
|
139
|
+
let branchName = options.branch;
|
|
140
|
+
if (!branchName) {
|
|
141
|
+
const answer = await prompt(
|
|
142
|
+
`Enter branch name for the registry PR (default: opensdd/${name}-v${version}): `
|
|
143
|
+
);
|
|
144
|
+
branchName = answer.trim() || `opensdd/${name}-v${version}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const { owner, repo } = parseGitHubUrl(registrySource);
|
|
148
|
+
const repoUrl = `https://github.com/${owner}/${repo}.git`;
|
|
149
|
+
|
|
150
|
+
// Step 10: Clone, create branch, add files, push, create PR
|
|
151
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opensdd-publish-'));
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
// Clone
|
|
155
|
+
execSync(`git clone --depth 1 "${repoUrl}" "${tmpDir}"`, { stdio: 'pipe' });
|
|
156
|
+
|
|
157
|
+
// Create branch
|
|
158
|
+
execSync(`git checkout -b "${branchName}"`, { cwd: tmpDir, stdio: 'pipe' });
|
|
159
|
+
console.log(` Created branch ${branchName}`);
|
|
160
|
+
|
|
161
|
+
// Create registry entry directory
|
|
162
|
+
const registryDir = path.join(tmpDir, 'registry', name, version);
|
|
163
|
+
fs.mkdirSync(registryDir, { recursive: true });
|
|
164
|
+
|
|
165
|
+
// Copy all spec files first
|
|
166
|
+
copyDirRecursive(specsDirPath, registryDir);
|
|
167
|
+
|
|
168
|
+
// Build manifest.json from publish fields (written after copy so it takes precedence
|
|
169
|
+
// over any manifest.json that might exist in the specs directory)
|
|
170
|
+
const publishManifest = {
|
|
171
|
+
name,
|
|
172
|
+
version,
|
|
173
|
+
spec_format,
|
|
174
|
+
description,
|
|
175
|
+
dependencies: dependencies || [],
|
|
176
|
+
};
|
|
177
|
+
fs.writeFileSync(
|
|
178
|
+
path.join(registryDir, 'manifest.json'),
|
|
179
|
+
JSON.stringify(publishManifest, null, 2) + '\n'
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
console.log(` Created registry entry registry/${name}/${version}/`);
|
|
183
|
+
|
|
184
|
+
// Update (or create) index.json
|
|
185
|
+
const indexDir = path.join(tmpDir, 'registry', name);
|
|
186
|
+
fs.mkdirSync(indexDir, { recursive: true });
|
|
187
|
+
const indexPath = path.join(indexDir, 'index.json');
|
|
188
|
+
|
|
189
|
+
let indexData;
|
|
190
|
+
if (fs.existsSync(indexPath)) {
|
|
191
|
+
indexData = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
|
|
192
|
+
} else {
|
|
193
|
+
indexData = { name, description, latest: version, versions: {} };
|
|
194
|
+
}
|
|
195
|
+
indexData.latest = version;
|
|
196
|
+
indexData.versions[version] = { spec_format };
|
|
197
|
+
fs.writeFileSync(indexPath, JSON.stringify(indexData, null, 2) + '\n');
|
|
198
|
+
console.log(` Updated index.json latest: ${version}`);
|
|
199
|
+
|
|
200
|
+
// Commit and push
|
|
201
|
+
execSync('git add -A', { cwd: tmpDir, stdio: 'pipe' });
|
|
202
|
+
execSync(`git commit -m "Add ${name} v${version}"`, { cwd: tmpDir, stdio: 'pipe' });
|
|
203
|
+
execSync(`git push -u origin "${branchName}"`, { cwd: tmpDir, stdio: 'pipe' });
|
|
204
|
+
|
|
205
|
+
// Create PR
|
|
206
|
+
const prTitle = `Add ${name} v${version}`;
|
|
207
|
+
const prBody = `Adds ${name} v${version} to the OpenSDD registry.\n\n${description}`;
|
|
208
|
+
const prOutput = execSync(
|
|
209
|
+
`gh pr create --repo "${owner}/${repo}" --title "${prTitle}" --body "${prBody}" --head "${branchName}"`,
|
|
210
|
+
{ cwd: tmpDir, encoding: 'utf-8' }
|
|
211
|
+
).trim();
|
|
212
|
+
|
|
213
|
+
console.log(` Opened pull request ${prOutput}`);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
if (err.message.includes('Authentication') || err.message.includes('auth')) {
|
|
216
|
+
console.error('\nError: Git authentication failed.');
|
|
217
|
+
console.error('Run `gh auth login` to authenticate with GitHub.');
|
|
218
|
+
} else {
|
|
219
|
+
console.error(`\nError during publish: ${err.message}`);
|
|
220
|
+
if (err.stderr) {
|
|
221
|
+
console.error(err.stderr.toString());
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
process.exit(1);
|
|
225
|
+
} finally {
|
|
226
|
+
// Clean up temp directory
|
|
227
|
+
try {
|
|
228
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
229
|
+
} catch {
|
|
230
|
+
// ignore cleanup errors
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
console.log('\nPublished. Spec will be available after PR is merged.');
|
|
235
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
findManifestPath,
|
|
5
|
+
readManifest,
|
|
6
|
+
getSpecsDir,
|
|
7
|
+
getDepsDir,
|
|
8
|
+
} from '../lib/manifest.js';
|
|
9
|
+
|
|
10
|
+
export async function statusCommand() {
|
|
11
|
+
const manifestPath = findManifestPath(process.cwd());
|
|
12
|
+
if (!manifestPath) {
|
|
13
|
+
console.log('OpenSDD not initialized. Run `opensdd init` to get started.');
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const manifest = readManifest(manifestPath);
|
|
18
|
+
const projectRoot = path.dirname(manifestPath);
|
|
19
|
+
const specsDir = getSpecsDir(manifest);
|
|
20
|
+
const depsDir = getDepsDir(manifest);
|
|
21
|
+
|
|
22
|
+
let hasContent = false;
|
|
23
|
+
|
|
24
|
+
// Authored spec
|
|
25
|
+
if (manifest.publish) {
|
|
26
|
+
hasContent = true;
|
|
27
|
+
const pub = manifest.publish;
|
|
28
|
+
console.log('Authored spec:\n');
|
|
29
|
+
console.log(` ${pub.name} v${pub.version} ${specsDir}/`);
|
|
30
|
+
console.log('');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Dependencies
|
|
34
|
+
const deps = manifest.dependencies || {};
|
|
35
|
+
const depKeys = Object.keys(deps);
|
|
36
|
+
|
|
37
|
+
if (depKeys.length > 0) {
|
|
38
|
+
hasContent = true;
|
|
39
|
+
console.log('Installed dependencies:\n');
|
|
40
|
+
|
|
41
|
+
for (const depName of depKeys) {
|
|
42
|
+
const entry = deps[depName];
|
|
43
|
+
const namePad = depName.padEnd(16);
|
|
44
|
+
const version = `v${entry.version}`.padEnd(8);
|
|
45
|
+
|
|
46
|
+
let status;
|
|
47
|
+
if (entry.implementation) {
|
|
48
|
+
status = `implemented ${entry.implementation}`;
|
|
49
|
+
} else {
|
|
50
|
+
status = 'not implemented';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Check for deviations
|
|
54
|
+
let deviationInfo = '';
|
|
55
|
+
const deviationsPath = path.join(projectRoot, depsDir, depName, 'deviations.md');
|
|
56
|
+
if (entry.has_deviations || fs.existsSync(deviationsPath)) {
|
|
57
|
+
if (fs.existsSync(deviationsPath)) {
|
|
58
|
+
const content = fs.readFileSync(deviationsPath, 'utf-8');
|
|
59
|
+
const deviationCount = (content.match(/^## /gm) || []).length;
|
|
60
|
+
if (deviationCount > 0) {
|
|
61
|
+
deviationInfo = ` ${deviationCount} deviation${deviationCount > 1 ? 's' : ''}`;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
console.log(` ${namePad}${version}${status}${deviationInfo}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Check for untracked spec directories
|
|
71
|
+
const depsDirPath = path.join(projectRoot, depsDir);
|
|
72
|
+
if (fs.existsSync(depsDirPath)) {
|
|
73
|
+
const dirEntries = fs.readdirSync(depsDirPath, { withFileTypes: true });
|
|
74
|
+
const untracked = dirEntries
|
|
75
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.') && !deps[e.name])
|
|
76
|
+
.map((e) => e.name);
|
|
77
|
+
|
|
78
|
+
if (untracked.length > 0) {
|
|
79
|
+
console.log('\nWarning: Untracked spec directories:');
|
|
80
|
+
for (const u of untracked) {
|
|
81
|
+
console.log(` ${depsDir}/${u}/`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!hasContent) {
|
|
87
|
+
console.log(
|
|
88
|
+
'No specs found. Run `opensdd install <name>` to install a dependency or add a `publish` entry to opensdd.json.'
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|