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.
@@ -0,0 +1,227 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createPatch } from 'diff';
4
+ import {
5
+ findManifestPath,
6
+ readManifest,
7
+ getDepsDir,
8
+ } from '../lib/manifest.js';
9
+ import {
10
+ resolveRegistry,
11
+ fetchSpecIndex,
12
+ fetchSpecManifest,
13
+ fetchSpecFiles,
14
+ } from '../lib/registry.js';
15
+
16
+ export async function updateCommand(name, options) {
17
+ const manifestPath = findManifestPath(process.cwd());
18
+ if (!manifestPath) {
19
+ console.error('Error: OpenSDD not initialized. Run `opensdd init` to get started.');
20
+ process.exit(1);
21
+ }
22
+
23
+ const manifest = readManifest(manifestPath);
24
+ const projectRoot = path.dirname(manifestPath);
25
+ const depsDir = getDepsDir(manifest);
26
+ const depsDirPath = path.join(projectRoot, depsDir);
27
+ const registrySource = resolveRegistry(options, manifest);
28
+ const deps = manifest.dependencies || {};
29
+
30
+ // Determine which specs to update
31
+ let specsToUpdate;
32
+ if (name) {
33
+ if (!deps[name]) {
34
+ console.error(`Error: ${name} is not installed. Run \`opensdd install ${name}\` first.`);
35
+ process.exit(1);
36
+ }
37
+ specsToUpdate = [name];
38
+ } else {
39
+ specsToUpdate = Object.keys(deps);
40
+ if (specsToUpdate.length === 0) {
41
+ console.log('No dependencies installed.');
42
+ return;
43
+ }
44
+ }
45
+
46
+ const results = [];
47
+
48
+ for (const specName of specsToUpdate) {
49
+ const entry = deps[specName];
50
+ const specDir = path.join(depsDirPath, specName);
51
+
52
+ // Fetch latest from registry
53
+ let index;
54
+ try {
55
+ index = await fetchSpecIndex(registrySource, specName);
56
+ } catch (err) {
57
+ console.error(`Error: Could not reach registry for ${specName}: ${err.message}`);
58
+ process.exit(1);
59
+ }
60
+
61
+ if (!index) {
62
+ console.warn(
63
+ `Warning: ${specName} is no longer available in the registry. Local files preserved.`
64
+ );
65
+ results.push({ name: specName, status: 'not_found' });
66
+ continue;
67
+ }
68
+
69
+ const latestVersion = index.latest;
70
+
71
+ if (latestVersion === entry.version) {
72
+ results.push({ name: specName, status: 'up_to_date', version: entry.version });
73
+ continue;
74
+ }
75
+
76
+ // Fetch new version files
77
+ const newManifest = await fetchSpecManifest(registrySource, specName, latestVersion);
78
+ const newFiles = await fetchSpecFiles(registrySource, specName, latestVersion);
79
+
80
+ if (!newManifest || !newFiles) {
81
+ console.error(`Error: Could not fetch ${specName}@${latestVersion} from registry.`);
82
+ process.exit(1);
83
+ }
84
+
85
+ // Step d: Compute unified diffs of all spec-owned files before overwriting
86
+ const diffs = {};
87
+ const changedFiles = [];
88
+
89
+ for (const [fileName, newContent] of Object.entries(newFiles)) {
90
+ // deviations.md is consumer-owned — skip it in diffs and overwrites
91
+ if (fileName === 'deviations.md') continue;
92
+
93
+ const existingPath = path.join(specDir, fileName);
94
+ const oldContent = fs.existsSync(existingPath)
95
+ ? fs.readFileSync(existingPath, 'utf-8')
96
+ : '';
97
+
98
+ if (oldContent !== newContent) {
99
+ diffs[fileName] = createPatch(fileName, oldContent, newContent);
100
+ changedFiles.push(fileName);
101
+ }
102
+ }
103
+
104
+ // Step e: Overwrite all spec-owned files
105
+ fs.mkdirSync(specDir, { recursive: true });
106
+ for (const [fileName, content] of Object.entries(newFiles)) {
107
+ // Step f: MUST NOT overwrite deviations.md
108
+ if (fileName === 'deviations.md') continue;
109
+ fs.writeFileSync(path.join(specDir, fileName), content);
110
+ }
111
+
112
+ // Step g: Create staging directory
113
+ const updatesDir = path.join(depsDirPath, '.updates', specName);
114
+ const wasReplaced = fs.existsSync(updatesDir);
115
+ fs.mkdirSync(updatesDir, { recursive: true });
116
+
117
+ // Write changeset.md
118
+ const specFormatOld = entry.spec_format || '0.1.0';
119
+ const specFormatNew = newManifest.spec_format || '0.1.0';
120
+ const specFormatChange =
121
+ specFormatOld === specFormatNew
122
+ ? 'unchanged'
123
+ : `${specFormatOld} \u2192 ${specFormatNew}`;
124
+
125
+ let changeset = `# Changeset: ${specName}\n\n`;
126
+ changeset += `**Previous version:** ${entry.version}\n`;
127
+ changeset += `**New version:** ${latestVersion}\n`;
128
+ changeset += `**Spec-format:** ${specFormatChange}\n`;
129
+ changeset += `**Date:** ${new Date().toISOString().split('T')[0]}\n\n`;
130
+ changeset += '## Changed Files\n';
131
+
132
+ for (const [fileName, diff] of Object.entries(diffs)) {
133
+ changeset += `\n### ${fileName}\n\n\`\`\`diff\n${diff}\`\`\`\n`;
134
+ }
135
+
136
+ if (changedFiles.length === 0) {
137
+ changeset += '\nNo file content changes (metadata only).\n';
138
+ }
139
+
140
+ fs.writeFileSync(path.join(updatesDir, 'changeset.md'), changeset);
141
+
142
+ // Write manifest.json for staging
143
+ const stageManifest = {
144
+ name: specName,
145
+ previous_version: entry.version,
146
+ version: latestVersion,
147
+ source: registrySource,
148
+ spec_format: newManifest.spec_format || '0.1.0',
149
+ };
150
+
151
+ fs.writeFileSync(
152
+ path.join(updatesDir, 'manifest.json'),
153
+ JSON.stringify(stageManifest, null, 2) + '\n'
154
+ );
155
+
156
+ results.push({
157
+ name: specName,
158
+ status: 'updated',
159
+ oldVersion: entry.version,
160
+ newVersion: latestVersion,
161
+ changedFiles,
162
+ replaced: wasReplaced,
163
+ });
164
+ }
165
+
166
+ // Print output
167
+ if (name) {
168
+ // Single spec output
169
+ const result = results[0];
170
+ if (result.status === 'up_to_date') {
171
+ console.log(`${result.name} v${result.version} is already up to date.`);
172
+ return;
173
+ }
174
+ if (result.status === 'not_found') {
175
+ return; // Warning already printed
176
+ }
177
+
178
+ console.log(`Updated ${result.name}: v${result.oldVersion} -> v${result.newVersion}`);
179
+ if (result.replaced) {
180
+ console.log(' (replaced existing pending update)');
181
+ }
182
+
183
+ if (result.changedFiles.length > 0) {
184
+ console.log('\nChanged files:');
185
+ for (const f of result.changedFiles) {
186
+ console.log(` ${f.padEnd(16)}updated`);
187
+ }
188
+ }
189
+
190
+ // Check for deviations.md
191
+ const deviationsPath = path.join(depsDirPath, result.name, 'deviations.md');
192
+ if (fs.existsSync(deviationsPath)) {
193
+ console.log('\nPreserved:');
194
+ console.log(' deviations.md (consumer-owned, not modified)');
195
+ }
196
+
197
+ console.log('\nStaged update:');
198
+ console.log(` ${depsDir}/.updates/${result.name}/changeset.md`);
199
+ console.log(` ${depsDir}/.updates/${result.name}/manifest.json`);
200
+ console.log(`\nRun "process the ${result.name} spec update" in your agent.`);
201
+ console.log(`After confirming, run: opensdd update apply ${result.name}`);
202
+ } else {
203
+ // All specs output
204
+ const updated = results.filter((r) => r.status === 'updated');
205
+
206
+ console.log(`Updated ${updated.length} of ${results.length} installed specs:\n`);
207
+
208
+ for (const r of results) {
209
+ const namePad = r.name.padEnd(16);
210
+ if (r.status === 'updated') {
211
+ console.log(` ${namePad}v${r.oldVersion} -> v${r.newVersion} staged`);
212
+ } else if (r.status === 'up_to_date') {
213
+ console.log(` ${namePad}v${r.version} already up to date`);
214
+ } else if (r.status === 'not_found') {
215
+ console.log(` ${namePad}not found in registry`);
216
+ }
217
+ }
218
+
219
+ if (updated.length > 0) {
220
+ console.log('\nRun "process spec updates" in your agent.');
221
+ console.log('After confirming each update, run:');
222
+ for (const r of updated) {
223
+ console.log(` opensdd update apply ${r.name}`);
224
+ }
225
+ }
226
+ }
227
+ }
@@ -0,0 +1,149 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import readline from 'node:readline';
4
+ import {
5
+ findManifestPath,
6
+ readManifest,
7
+ writeManifest,
8
+ getDepsDir,
9
+ } from '../lib/manifest.js';
10
+
11
+ function prompt(question) {
12
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
13
+ return new Promise((resolve) => {
14
+ rl.question(question, (answer) => {
15
+ rl.close();
16
+ resolve(answer);
17
+ });
18
+ });
19
+ }
20
+
21
+ export async function updateApplyCommand(name) {
22
+ const manifestPath = findManifestPath(process.cwd());
23
+ if (!manifestPath) {
24
+ console.error('Error: OpenSDD not initialized. Run `opensdd init` to get started.');
25
+ process.exit(1);
26
+ }
27
+
28
+ const manifest = readManifest(manifestPath);
29
+ const projectRoot = path.dirname(manifestPath);
30
+ const depsDir = getDepsDir(manifest);
31
+ const updatesDir = path.join(projectRoot, depsDir, '.updates');
32
+
33
+ // Determine which updates to apply
34
+ let pendingNames;
35
+ if (name) {
36
+ const updateDir = path.join(updatesDir, name);
37
+ if (!fs.existsSync(updateDir)) {
38
+ console.error(`Error: No pending update for ${name}.`);
39
+ process.exit(1);
40
+ }
41
+ pendingNames = [name];
42
+ } else {
43
+ if (!fs.existsSync(updatesDir)) {
44
+ console.log('No pending updates.');
45
+ process.exit(0);
46
+ }
47
+ const entries = fs.readdirSync(updatesDir, { withFileTypes: true });
48
+ pendingNames = entries
49
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.'))
50
+ .map((e) => e.name);
51
+ if (pendingNames.length === 0) {
52
+ console.log('No pending updates.');
53
+ process.exit(0);
54
+ }
55
+ }
56
+
57
+ // Print warning
58
+ console.log('\u26A0 This will finalize the update in opensdd.json.');
59
+ console.log(' Only proceed if you have confirmed that all spec changes');
60
+ console.log(' have been implemented and tests pass.\n');
61
+
62
+ // Prompt for confirmation
63
+ let confirmMsg;
64
+ if (pendingNames.length === 1) {
65
+ const updateManifestPath = path.join(updatesDir, pendingNames[0], 'manifest.json');
66
+ const updateManifest = JSON.parse(fs.readFileSync(updateManifestPath, 'utf-8'));
67
+ confirmMsg = `Apply update for ${pendingNames[0]} v${updateManifest.previous_version} -> v${updateManifest.version}? (y/n) `;
68
+ } else {
69
+ confirmMsg = `Apply ${pendingNames.length} pending updates? (y/n) `;
70
+ }
71
+
72
+ const answer = await prompt(confirmMsg);
73
+ if (answer.toLowerCase() !== 'y') {
74
+ process.exit(0);
75
+ }
76
+
77
+ console.log('');
78
+
79
+ // Apply each update
80
+ const applied = [];
81
+ for (const specName of pendingNames) {
82
+ const updateDir = path.join(updatesDir, specName);
83
+ const manifestJsonPath = path.join(updateDir, 'manifest.json');
84
+
85
+ if (!fs.existsSync(manifestJsonPath)) {
86
+ console.error(`Error: Missing manifest.json for ${specName} update.`);
87
+ process.exit(1);
88
+ }
89
+
90
+ let updateManifest;
91
+ try {
92
+ updateManifest = JSON.parse(fs.readFileSync(manifestJsonPath, 'utf-8'));
93
+ } catch (err) {
94
+ console.error(`Error: Malformed manifest.json for ${specName}: ${err.message}`);
95
+ process.exit(1);
96
+ }
97
+
98
+ // Update opensdd.json dependency entry, preserving consumer-managed fields
99
+ if (!manifest.dependencies) manifest.dependencies = {};
100
+ const existing = manifest.dependencies[specName] || {};
101
+
102
+ manifest.dependencies[specName] = {
103
+ ...existing,
104
+ version: updateManifest.version,
105
+ source: updateManifest.source,
106
+ spec_format: updateManifest.spec_format,
107
+ // Preserve consumer-managed fields
108
+ implementation:
109
+ existing.implementation !== undefined ? existing.implementation : null,
110
+ tests: existing.tests !== undefined ? existing.tests : null,
111
+ has_deviations:
112
+ existing.has_deviations !== undefined ? existing.has_deviations : false,
113
+ };
114
+
115
+ // Delete staging directory
116
+ fs.rmSync(updateDir, { recursive: true, force: true });
117
+
118
+ applied.push({
119
+ name: specName,
120
+ oldVersion: updateManifest.previous_version,
121
+ newVersion: updateManifest.version,
122
+ });
123
+ }
124
+
125
+ // Write updated manifest
126
+ writeManifest(manifestPath, manifest);
127
+
128
+ // Clean up .updates/ directory if empty
129
+ if (fs.existsSync(updatesDir)) {
130
+ const remaining = fs.readdirSync(updatesDir);
131
+ if (remaining.length === 0) {
132
+ fs.rmSync(updatesDir, { recursive: true, force: true });
133
+ }
134
+ }
135
+
136
+ // Print output
137
+ if (applied.length === 1) {
138
+ const a = applied[0];
139
+ console.log(`Applied update for ${a.name}: v${a.oldVersion} -> v${a.newVersion}\n`);
140
+ console.log(' opensdd.json updated');
141
+ console.log(' staged files cleaned up');
142
+ } else {
143
+ console.log(`Applied ${applied.length} updates:\n`);
144
+ for (const a of applied) {
145
+ console.log(` ${a.name.padEnd(16)}v${a.oldVersion} -> v${a.newVersion} applied`);
146
+ }
147
+ console.log('\nopensdd.json updated.');
148
+ }
149
+ }
@@ -0,0 +1,95 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { validateSpec } from '../lib/validation.js';
4
+
5
+ export async function validateCommand(specPath) {
6
+ let targetDir;
7
+
8
+ if (specPath) {
9
+ targetDir = path.resolve(specPath);
10
+ } else {
11
+ targetDir = path.join(process.cwd(), 'opensdd');
12
+ }
13
+
14
+ if (!fs.existsSync(targetDir)) {
15
+ if (!specPath) {
16
+ console.error(
17
+ 'No spec directory found. Provide a path or run from a directory containing `opensdd/`.'
18
+ );
19
+ } else {
20
+ console.error(`Error: Path does not exist: ${specPath}`);
21
+ }
22
+ process.exit(1);
23
+ }
24
+
25
+ if (!fs.statSync(targetDir).isDirectory()) {
26
+ console.error(`Error: ${specPath} is not a directory.`);
27
+ process.exit(1);
28
+ }
29
+
30
+ const result = validateSpec(targetDir);
31
+
32
+ // Determine name and version for display
33
+ let displayName = path.basename(targetDir);
34
+ let displayVersion = '';
35
+
36
+ if (result.manifest) {
37
+ displayName = result.manifest.name || displayName;
38
+ displayVersion = result.manifest.version ? ` v${result.manifest.version}` : '';
39
+ }
40
+
41
+ if (result.errors.length > 0) {
42
+ console.log(`Validation failed for ${displayName}\n`);
43
+ } else {
44
+ console.log(`Validated ${displayName}${displayVersion}\n`);
45
+ }
46
+
47
+ // spec.md structure
48
+ if (result.specErrors.length > 0) {
49
+ console.log(' spec.md structure error');
50
+ for (const err of result.specErrors) {
51
+ console.log(` - ${err}`);
52
+ }
53
+ } else if (result.specWarnings.length > 0) {
54
+ console.log(
55
+ ` spec.md structure ${result.specWarnings.length} warning${result.specWarnings.length > 1 ? 's' : ''}`
56
+ );
57
+ for (const w of result.specWarnings) {
58
+ console.log(` - ${w}`);
59
+ }
60
+ } else {
61
+ console.log(' spec.md structure ok');
62
+ }
63
+
64
+ // manifest.json
65
+ if (result.manifestExists) {
66
+ if (result.manifestErrors.length > 0) {
67
+ console.log(' manifest.json error');
68
+ for (const err of result.manifestErrors) {
69
+ console.log(` - ${err}`);
70
+ }
71
+ } else {
72
+ console.log(' manifest.json ok');
73
+ }
74
+ }
75
+
76
+ // deviations.md check
77
+ if (result.hasDeviations) {
78
+ console.log(' deviations.md found (should not be in publishable spec)');
79
+ } else {
80
+ console.log(' no deviations.md ok');
81
+ }
82
+
83
+ // Summary
84
+ console.log('');
85
+ if (result.errors.length > 0) {
86
+ console.log(
87
+ `${result.errors.length} error${result.errors.length > 1 ? 's' : ''}. Fix errors before publishing.`
88
+ );
89
+ process.exit(1);
90
+ } else if (result.warnings.length > 0) {
91
+ console.log('Valid with warnings. Review warnings before publishing.');
92
+ } else {
93
+ console.log('Valid. Ready for publishing to registry.');
94
+ }
95
+ }
package/src/index.js ADDED
@@ -0,0 +1,126 @@
1
+ import { initCommand } from './commands/init.js';
2
+ import { listCommand } from './commands/list.js';
3
+ import { installCommand } from './commands/install.js';
4
+ import { updateCommand } from './commands/update.js';
5
+ import { updateApplyCommand } from './commands/updateApply.js';
6
+ import { publishCommand } from './commands/publish.js';
7
+ import { statusCommand } from './commands/status.js';
8
+ import { validateCommand } from './commands/validate.js';
9
+
10
+ function parseArgs(argv) {
11
+ const flags = {};
12
+ const positional = [];
13
+ for (let i = 0; i < argv.length; i++) {
14
+ if (argv[i].startsWith('--')) {
15
+ const key = argv[i].slice(2);
16
+ if (i + 1 < argv.length && !argv[i + 1].startsWith('--')) {
17
+ flags[key] = argv[i + 1];
18
+ i++;
19
+ } else {
20
+ flags[key] = true;
21
+ }
22
+ } else {
23
+ positional.push(argv[i]);
24
+ }
25
+ }
26
+ return { flags, positional };
27
+ }
28
+
29
+ function printHelp() {
30
+ console.log(`opensdd v0.1.0 - Open Spec-Driven Development CLI
31
+
32
+ Usage: opensdd <command> [options]
33
+
34
+ Commands:
35
+ init Initialize OpenSDD in the current project
36
+ list List specs available in the registry
37
+ install <name> [ver] Install a spec from the registry
38
+ update [name] Fetch latest version of dependency specs
39
+ update apply [name] Apply a staged update to opensdd.json
40
+ publish Publish an authored spec to the registry
41
+ status Show status of authored and installed specs
42
+ validate [path] Validate a spec directory
43
+
44
+ Options:
45
+ --registry <url> Alternative registry source
46
+ --branch <name> Branch name for publish PR
47
+ --version Show version
48
+ --help Show help`);
49
+ }
50
+
51
+ async function main() {
52
+ const rawArgs = process.argv.slice(2);
53
+ const command = rawArgs[0];
54
+
55
+ try {
56
+ switch (command) {
57
+ case 'init':
58
+ await initCommand();
59
+ break;
60
+
61
+ case 'list': {
62
+ const { flags } = parseArgs(rawArgs.slice(1));
63
+ await listCommand({ registry: flags.registry });
64
+ break;
65
+ }
66
+
67
+ case 'install': {
68
+ const { flags, positional } = parseArgs(rawArgs.slice(1));
69
+ if (!positional[0]) {
70
+ console.error('Usage: opensdd install <name> [version]');
71
+ process.exit(1);
72
+ }
73
+ await installCommand(positional[0], positional[1], { registry: flags.registry });
74
+ break;
75
+ }
76
+
77
+ case 'update': {
78
+ if (rawArgs[1] === 'apply') {
79
+ const { positional } = parseArgs(rawArgs.slice(2));
80
+ await updateApplyCommand(positional[0]);
81
+ } else {
82
+ const { flags, positional } = parseArgs(rawArgs.slice(1));
83
+ await updateCommand(positional[0], { registry: flags.registry });
84
+ }
85
+ break;
86
+ }
87
+
88
+ case 'publish': {
89
+ const { flags } = parseArgs(rawArgs.slice(1));
90
+ await publishCommand({ branch: flags.branch, registry: flags.registry });
91
+ break;
92
+ }
93
+
94
+ case 'status':
95
+ await statusCommand();
96
+ break;
97
+
98
+ case 'validate': {
99
+ const { positional } = parseArgs(rawArgs.slice(1));
100
+ await validateCommand(positional[0]);
101
+ break;
102
+ }
103
+
104
+ case '--version':
105
+ case '-v':
106
+ console.log('0.1.0');
107
+ break;
108
+
109
+ case '--help':
110
+ case '-h':
111
+ case undefined:
112
+ printHelp();
113
+ break;
114
+
115
+ default:
116
+ console.error(`Unknown command: ${command}`);
117
+ printHelp();
118
+ process.exit(1);
119
+ }
120
+ } catch (err) {
121
+ console.error(`Error: ${err.message}`);
122
+ process.exit(1);
123
+ }
124
+ }
125
+
126
+ main();
@@ -0,0 +1,38 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * Search upward from startDir for opensdd.json, stopping at filesystem root.
6
+ * Returns the absolute path to opensdd.json, or null if not found.
7
+ */
8
+ export function findManifestPath(startDir) {
9
+ let dir = path.resolve(startDir);
10
+ while (true) {
11
+ const candidate = path.join(dir, 'opensdd.json');
12
+ if (fs.existsSync(candidate)) return candidate;
13
+ const parent = path.dirname(dir);
14
+ if (parent === dir) return null;
15
+ dir = parent;
16
+ }
17
+ }
18
+
19
+ export function readManifest(manifestPath) {
20
+ const content = fs.readFileSync(manifestPath, 'utf-8');
21
+ try {
22
+ return JSON.parse(content);
23
+ } catch (err) {
24
+ throw new Error(`opensdd.json is malformed JSON: ${err.message}`);
25
+ }
26
+ }
27
+
28
+ export function writeManifest(manifestPath, data) {
29
+ fs.writeFileSync(manifestPath, JSON.stringify(data, null, 2) + '\n');
30
+ }
31
+
32
+ export function getSpecsDir(manifest) {
33
+ return manifest.specs_dir || 'opensdd';
34
+ }
35
+
36
+ export function getDepsDir(manifest) {
37
+ return manifest.deps_dir || '.opensdd.deps';
38
+ }