@patwoodman/dsh-sdlc-flow 0.4.0-beta.20 → 0.4.0-beta.21
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/lib/install-lib.mjs +56 -0
- package/lib/install.mjs +274 -0
- package/lib/rows/cli/package.json +1 -1
- package/lib/rows/core/package.json +1 -1
- package/lib/rows/guard/package.json +1 -1
- package/lib/rows/presets/package.json +1 -1
- package/lib/rows/tools/package.json +1 -1
- package/lib/rows/ui/package.json +1 -1
- package/package.json +5 -1
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* packages/sdlc-orchestrator/src/install-lib.mjs — pure text helpers for the
|
|
3
|
+
* registry installer. Kept separate from `install.mjs` so tests can import it
|
|
4
|
+
* without running the CLI.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Normalize `minimumReleaseAgeExclude` in a pnpm-workspace.yaml body and add
|
|
9
|
+
* exact-version entries for the suite.
|
|
10
|
+
*
|
|
11
|
+
* pnpm accepts ONE `name@version` pattern per list item. A value such as
|
|
12
|
+
* `@patwoodman/dsh-sdlc-flow@0.4.0-beta.19 || 0.4.0-beta.20` (written by an
|
|
13
|
+
* earlier install path) is not a valid pattern, so pnpm ignores the exemption
|
|
14
|
+
* and a fresh beta silently resolves to the previous one. This splits such
|
|
15
|
+
* entries into valid items, carries the package name onto a bare version, adds
|
|
16
|
+
* the requested specs, deduplicates, sorts, and leaves every other setting in
|
|
17
|
+
* the file untouched.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} text - existing pnpm-workspace.yaml (may be empty).
|
|
20
|
+
* @param {string[]} specs - exact `name@version` patterns to add.
|
|
21
|
+
* @returns {string} the rewritten YAML body with a trailing newline.
|
|
22
|
+
*/
|
|
23
|
+
export function normalizeReleaseAgeExcludes(text, specs) {
|
|
24
|
+
const lines = String(text ?? '').split('\n');
|
|
25
|
+
const start = lines.findIndex((line) => /^minimumReleaseAgeExclude:\s*$/.test(line));
|
|
26
|
+
const wanted = [];
|
|
27
|
+
if (start >= 0) {
|
|
28
|
+
let end = start + 1;
|
|
29
|
+
while (end < lines.length && (/^\s*-\s+/.test(lines[end]) || lines[end].trim() === '')) end += 1;
|
|
30
|
+
for (const line of lines.slice(start + 1, end)) {
|
|
31
|
+
const raw = line.replace(/^\s*-\s+/, '').trim();
|
|
32
|
+
if (!raw) continue;
|
|
33
|
+
const value = raw.replace(/^['"]|['"]$/g, '');
|
|
34
|
+
let packageName = null;
|
|
35
|
+
for (const part of value.split(/\s*\|\|\s*/)) {
|
|
36
|
+
const trimmed = part.trim();
|
|
37
|
+
if (!trimmed) continue;
|
|
38
|
+
const named = /^((?:@[^/]+\/)?[^@]+)@(.+)$/.exec(trimmed);
|
|
39
|
+
if (named) {
|
|
40
|
+
packageName = named[1];
|
|
41
|
+
wanted.push(trimmed);
|
|
42
|
+
} else if (packageName && /^[0-9]/.test(trimmed)) {
|
|
43
|
+
wanted.push(`${packageName}@${trimmed}`);
|
|
44
|
+
} else {
|
|
45
|
+
wanted.push(trimmed);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
lines.splice(start, end - start);
|
|
50
|
+
}
|
|
51
|
+
wanted.push(...specs);
|
|
52
|
+
const unique = [...new Set(wanted.filter(Boolean))].sort();
|
|
53
|
+
lines.push('minimumReleaseAgeExclude:');
|
|
54
|
+
for (const spec of unique) lines.push(` - '${spec}'`);
|
|
55
|
+
return `${lines.join('\n').replace(/\n+$/, '')}\n`;
|
|
56
|
+
}
|
package/lib/install.mjs
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* packages/sdlc-orchestrator/src/install.mjs — registry installer/updater.
|
|
4
|
+
*
|
|
5
|
+
* The Desktop Add-plugin field and `dsh plugin add` both go through pnpm, and
|
|
6
|
+
* pnpm 11 applies a 24-hour `minimumReleaseAge` to `latest`/range requests. A
|
|
7
|
+
* beta published minutes ago therefore resolves to the PREVIOUS beta — exactly
|
|
8
|
+
* how beta.19 (broken presets) kept installing while beta.20 was already
|
|
9
|
+
* `latest`. This command is the reliable path:
|
|
10
|
+
*
|
|
11
|
+
* 1. resolve `latest` (or `beta`) from the npm registry itself;
|
|
12
|
+
* 2. pin the EXACT version in the profile and add the matching
|
|
13
|
+
* `minimumReleaseAgeExclude` entry (pnpm's documented escape hatch);
|
|
14
|
+
* 3. create the profile from its shipped template when it is missing;
|
|
15
|
+
* 4. install with `dsh plugin add <name>@<exact>`;
|
|
16
|
+
* 5. re-add the bundle to `dsh.profile.bundles` (a failed uninstall can
|
|
17
|
+
* leave a package installed but disabled) and verify the installed
|
|
18
|
+
* package.json version.
|
|
19
|
+
*
|
|
20
|
+
* Run it without cloning:
|
|
21
|
+
*
|
|
22
|
+
* npx -y @patwoodman/dsh-sdlc-flow@latest dsh-sdlc-install
|
|
23
|
+
* npx -y @patwoodman/dsh-sdlc-flow@latest dsh-sdlc-install --only all
|
|
24
|
+
*
|
|
25
|
+
* Flags: --only lead|worker|probe|all (default lead), --tag latest|beta,
|
|
26
|
+
* --version <exact>, --dsh-home <dir>, --dry-run, --json, --help.
|
|
27
|
+
*
|
|
28
|
+
* Node built-ins only: this file is shipped in the published package and must
|
|
29
|
+
* run before any dependency is installed.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { spawnSync } from 'node:child_process';
|
|
33
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
34
|
+
import { homedir } from 'node:os';
|
|
35
|
+
import { join } from 'node:path';
|
|
36
|
+
|
|
37
|
+
import { PACKAGE, BUNDLES } from './index.js';
|
|
38
|
+
import { normalizeReleaseAgeExcludes } from './install-lib.mjs';
|
|
39
|
+
|
|
40
|
+
const REGISTRY = process.env.npm_config_registry || 'https://registry.npmjs.org';
|
|
41
|
+
const META = PACKAGE;
|
|
42
|
+
const CORE = BUNDLES.find((name) => name.endsWith('-core'));
|
|
43
|
+
const TOOLS = BUNDLES.find((name) => name.endsWith('-tools'));
|
|
44
|
+
const PRESETS = BUNDLES.find((name) => name.endsWith('-presets'));
|
|
45
|
+
const UI = BUNDLES.find((name) => name.endsWith('-ui'));
|
|
46
|
+
const GUARD = BUNDLES.find((name) => name.endsWith('-guard')) ?? `${META}-guard`;
|
|
47
|
+
|
|
48
|
+
const argv = process.argv.slice(2);
|
|
49
|
+
const has = (name) => argv.includes(name);
|
|
50
|
+
const flag = (name, fallback) => (has(name) ? argv[argv.indexOf(name) + 1] : fallback);
|
|
51
|
+
|
|
52
|
+
const ONLY = flag('--only', 'lead');
|
|
53
|
+
const TAG = flag('--tag', 'latest');
|
|
54
|
+
const EXACT = flag('--version', null);
|
|
55
|
+
const DSH_HOME = flag('--dsh-home', process.env.DSH_HOME || join(homedir(), '.dsh'));
|
|
56
|
+
const DRY_RUN = has('--dry-run');
|
|
57
|
+
const JSON_OUT = has('--json');
|
|
58
|
+
|
|
59
|
+
if (has('--help') || has('-h')) {
|
|
60
|
+
process.stdout.write(`Usage: dsh-sdlc-install [options]
|
|
61
|
+
|
|
62
|
+
Install the latest published DSH SDLC Flow bundle into a DSH profile.
|
|
63
|
+
|
|
64
|
+
--only lead|worker|probe|all which profile(s) to install (default: lead)
|
|
65
|
+
--tag latest|beta registry dist-tag to resolve (default: latest)
|
|
66
|
+
--version <exact> pin an exact version instead of a tag
|
|
67
|
+
--dsh-home <dir> DSH home (default: $DSH_HOME or ~/.dsh)
|
|
68
|
+
--dry-run print the commands without changing anything
|
|
69
|
+
--json machine-readable result
|
|
70
|
+
-h, --help this help
|
|
71
|
+
|
|
72
|
+
Examples:
|
|
73
|
+
npx -y @patwoodman/dsh-sdlc-flow@latest dsh-sdlc-install
|
|
74
|
+
npx -y @patwoodman/dsh-sdlc-flow@latest dsh-sdlc-install --only all
|
|
75
|
+
npx -y @patwoodman/dsh-sdlc-flow@latest dsh-sdlc-install --version 0.4.0-beta.21
|
|
76
|
+
`);
|
|
77
|
+
process.exit(0);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const VALID_ONLY = new Set(['lead', 'worker', 'probe', 'all']);
|
|
81
|
+
if (!VALID_ONLY.has(ONLY)) {
|
|
82
|
+
process.stderr.write(`dsh-sdlc-install: --only must be one of ${[...VALID_ONLY].join(', ')}\n`);
|
|
83
|
+
process.exit(2);
|
|
84
|
+
}
|
|
85
|
+
if (!['latest', 'beta'].includes(TAG) && EXACT === null) {
|
|
86
|
+
process.stderr.write('dsh-sdlc-install: --tag must be latest or beta\n');
|
|
87
|
+
process.exit(2);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const steps = [];
|
|
91
|
+
function record(step, detail = '') {
|
|
92
|
+
steps.push({ step, detail });
|
|
93
|
+
if (!JSON_OUT) process.stdout.write(` [ok] ${step}${detail ? ` — ${detail}` : ''}\n`);
|
|
94
|
+
}
|
|
95
|
+
function warn(message) {
|
|
96
|
+
steps.push({ step: message, warning: true });
|
|
97
|
+
if (!JSON_OUT) process.stdout.write(` [warn] ${message}\n`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function run(command, args, { cwd, timeoutMs = 900_000 } = {}) {
|
|
101
|
+
if (DRY_RUN) {
|
|
102
|
+
record(`exec ${command} ${args.join(' ')}`);
|
|
103
|
+
return { status: 0, stdout: '', stderr: '' };
|
|
104
|
+
}
|
|
105
|
+
const result = spawnSync(command, args, {
|
|
106
|
+
cwd,
|
|
107
|
+
encoding: 'utf8',
|
|
108
|
+
timeout: timeoutMs,
|
|
109
|
+
env: { ...process.env, DSH_HOME },
|
|
110
|
+
});
|
|
111
|
+
return {
|
|
112
|
+
status: result.status ?? 1,
|
|
113
|
+
stdout: result.stdout ?? '',
|
|
114
|
+
stderr: result.stderr ?? '',
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function mustRun(command, args, context) {
|
|
119
|
+
const result = run(command, args);
|
|
120
|
+
if (result.status !== 0) {
|
|
121
|
+
const detail = (result.stderr || result.stdout || '').trim().slice(-1_500);
|
|
122
|
+
throw new Error(`${context}: \`${command} ${args.join(' ')}\` exited ${result.status}${detail ? `\n${detail}` : ''}`);
|
|
123
|
+
}
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function resolveVersion() {
|
|
128
|
+
if (EXACT !== null) return { version: EXACT, source: '--version' };
|
|
129
|
+
const url = `${REGISTRY.replace(/\/+$/, '')}/${META.replace('/', '%2F')}`;
|
|
130
|
+
let response;
|
|
131
|
+
try {
|
|
132
|
+
response = await fetch(url, { signal: AbortSignal.timeout(20_000) });
|
|
133
|
+
} catch (error) {
|
|
134
|
+
throw new Error(`cannot reach the npm registry at ${REGISTRY}: ${error.message}\nPin a version with --version <exact> or check your network.`);
|
|
135
|
+
}
|
|
136
|
+
if (!response.ok) throw new Error(`registry returned HTTP ${response.status} for ${META}`);
|
|
137
|
+
const packument = await response.json();
|
|
138
|
+
const version = packument?.['dist-tags']?.[TAG];
|
|
139
|
+
if (typeof version !== 'string') {
|
|
140
|
+
throw new Error(`registry has no ${TAG} dist-tag for ${META} (tags: ${Object.keys(packument?.['dist-tags'] ?? {}).join(', ')})`);
|
|
141
|
+
}
|
|
142
|
+
return { version, source: TAG };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const profileDir = (name) => join(DSH_HOME, 'profiles', name);
|
|
146
|
+
|
|
147
|
+
/** Create a profile from its shipped template when absent. */
|
|
148
|
+
function ensureProfile(name, template) {
|
|
149
|
+
const dir = profileDir(name);
|
|
150
|
+
if (existsSync(join(dir, 'package.json'))) {
|
|
151
|
+
record(`profile ${name} already exists`);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
mustRun('dsh', ['--profile', name, '--from-default-profile', template, '--dump-config'], `create profile ${name}`);
|
|
155
|
+
record(`created profile ${name} from the ${template} template`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Add exact-version `minimumReleaseAgeExclude` entries, normalizing the
|
|
160
|
+
* malformed `pkg@a || pkg@b` form (pnpm does not understand it) into separate
|
|
161
|
+
* list items. This is the difference between installing the fresh beta and
|
|
162
|
+
* silently resolving the previous one.
|
|
163
|
+
*/
|
|
164
|
+
function ensureReleaseAgeExcludes(name, specs) {
|
|
165
|
+
const file = join(profileDir(name), 'pnpm-workspace.yaml');
|
|
166
|
+
const text = existsSync(file) ? readFileSync(file, 'utf8') : 'packages:\n - .\n';
|
|
167
|
+
const next = normalizeReleaseAgeExcludes(text, specs);
|
|
168
|
+
if (DRY_RUN) {
|
|
169
|
+
record(`would set minimumReleaseAgeExclude (${specs.join(', ')})`);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
writeFileSync(file, next);
|
|
173
|
+
record('pinned pnpm release-age exemptions', specs.join(', '));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Ensure the bundle is selected even if an earlier failed uninstall removed it. */
|
|
177
|
+
function ensureBundles(name, bundles) {
|
|
178
|
+
const file = join(profileDir(name), 'package.json');
|
|
179
|
+
if (DRY_RUN) {
|
|
180
|
+
record(`would select bundles ${bundles.join(', ')}`);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const manifest = JSON.parse(readFileSync(file, 'utf8'));
|
|
184
|
+
manifest.dsh ??= {};
|
|
185
|
+
manifest.dsh.profile ??= {};
|
|
186
|
+
const current = Array.isArray(manifest.dsh.profile.bundles) ? manifest.dsh.profile.bundles : [];
|
|
187
|
+
const missing = bundles.filter((bundle) => !current.includes(bundle));
|
|
188
|
+
if (missing.length === 0) {
|
|
189
|
+
record(`profile ${name} already selects ${bundles.length} bundle(s)`);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
manifest.dsh.profile.bundles = [...current, ...missing];
|
|
193
|
+
writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
194
|
+
record(`selected bundle(s) in ${name}`, missing.join(', '));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function installedVersion(name, pkgName) {
|
|
198
|
+
const manifest = join(profileDir(name), 'node_modules', ...pkgName.split('/'), 'package.json');
|
|
199
|
+
if (!existsSync(manifest)) return null;
|
|
200
|
+
try {
|
|
201
|
+
return JSON.parse(readFileSync(manifest, 'utf8')).version;
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const PLANS = {
|
|
208
|
+
lead: { profile: 'sdlc-web', template: 'web', packages: [META], bundles: [META] },
|
|
209
|
+
worker: { profile: 'sdlc-worker', template: 'headless', packages: [GUARD, TOOLS], bundles: [GUARD, TOOLS] },
|
|
210
|
+
probe: { profile: 'sdlc-probe', template: 'sdk', packages: [CORE, TOOLS, PRESETS, GUARD], bundles: [CORE, TOOLS, PRESETS, GUARD] },
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
function planFor(only) {
|
|
214
|
+
return only === 'all' ? ['lead', 'worker', 'probe'] : [only];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function main() {
|
|
218
|
+
if (!JSON_OUT) {
|
|
219
|
+
process.stdout.write(`\nDSH SDLC Flow — registry install${DRY_RUN ? ' (dry run)' : ''}\n`);
|
|
220
|
+
process.stdout.write('================================================\n\n');
|
|
221
|
+
process.stdout.write(` DSH_HOME : ${DSH_HOME}\n`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const { version, source } = await resolveVersion();
|
|
225
|
+
record(`resolved ${META}@${version}`, `from ${source}`);
|
|
226
|
+
|
|
227
|
+
const dsh = DRY_RUN ? { status: 0, stdout: '' } : run('dsh', ['--version']);
|
|
228
|
+
if (!DRY_RUN && dsh.status !== 0) {
|
|
229
|
+
throw new Error('dsh is not on PATH. Install it first: npm install -g @deepseek-ai/dsh@0.2.1-alpha.2 (or any 0.2.x).');
|
|
230
|
+
}
|
|
231
|
+
record('dsh CLI available', DRY_RUN ? '(dry run)' : dsh.stdout.trim());
|
|
232
|
+
|
|
233
|
+
for (const only of planFor(ONLY)) {
|
|
234
|
+
const plan = PLANS[only];
|
|
235
|
+
if (!plan || plan.packages.some((pkg) => pkg === undefined)) {
|
|
236
|
+
throw new Error(`cannot derive the ${only} package list from ${META}'s BUNDLES export`);
|
|
237
|
+
}
|
|
238
|
+
ensureProfile(plan.profile, plan.template);
|
|
239
|
+
ensureReleaseAgeExcludes(plan.profile, plan.packages.map((pkg) => `${pkg}@${version}`));
|
|
240
|
+
mustRun(
|
|
241
|
+
'dsh',
|
|
242
|
+
['plugin', '--profile', plan.profile, 'add', '--ignore-workspace-root-check', ...plan.packages.map((pkg) => `${pkg}@${version}`)],
|
|
243
|
+
`install into ${plan.profile}`,
|
|
244
|
+
);
|
|
245
|
+
record(`installed ${plan.packages.map((pkg) => `${pkg}@${version}`).join(', ')}`, plan.profile);
|
|
246
|
+
ensureBundles(plan.profile, plan.bundles);
|
|
247
|
+
if (!DRY_RUN) {
|
|
248
|
+
for (const pkg of plan.packages) {
|
|
249
|
+
const actual = installedVersion(plan.profile, pkg);
|
|
250
|
+
if (actual !== version) {
|
|
251
|
+
throw new Error(`${plan.profile}: expected ${pkg}@${version}, found ${pkg}@${actual ?? '<missing>'}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
record(`verified ${plan.profile}`, plan.packages.map((pkg) => `${pkg}@${version}`).join(', '));
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (JSON_OUT) {
|
|
259
|
+
process.stdout.write(`${JSON.stringify({ version, tag: EXACT === null ? TAG : null, only: ONLY, dshHome: DSH_HOME, steps }, null, 2)}\n`);
|
|
260
|
+
} else {
|
|
261
|
+
process.stdout.write('\nDone. Restart DeepSeek Harness (or any running lead) so the new bundle is loaded:\n');
|
|
262
|
+
process.stdout.write(' dsh --profile sdlc-web\n\n');
|
|
263
|
+
if (ONLY === 'lead' || ONLY === 'all') {
|
|
264
|
+
process.stdout.write('A new session will use the sdlc-lead preset. If you still see an "agent-preset/invalid"\n');
|
|
265
|
+
process.stdout.write('error, the app is still running the old bundle — quit it completely and reopen.\n');
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
main().catch((error) => {
|
|
271
|
+
if (JSON_OUT) process.stdout.write(`${JSON.stringify({ ok: false, error: error.message }, null, 2)}\n`);
|
|
272
|
+
else process.stderr.write(`\ndsh-sdlc-install: ${error.message}\n`);
|
|
273
|
+
process.exit(1);
|
|
274
|
+
});
|
package/lib/rows/ui/package.json
CHANGED
package/package.json
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@patwoodman/dsh-sdlc-flow",
|
|
3
|
-
"version": "0.4.0-beta.
|
|
3
|
+
"version": "0.4.0-beta.21",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "DSH SDLC Flow lead-profile bundle: task engine, tools, role presets, and web UI in one install.",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"dsh-sdlc-install": "lib/install.mjs"
|
|
9
|
+
},
|
|
7
10
|
"exports": {
|
|
8
11
|
".": "./lib/index.js",
|
|
12
|
+
"./install": "./lib/install.mjs",
|
|
9
13
|
"./package.json": "./package.json",
|
|
10
14
|
"./locale/*.json": "./locale/*.json",
|
|
11
15
|
"./icon": "./icon.svg",
|