@open-product-primer/cli 1.2.0 → 2.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,299 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.isGitSource = isGitSource;
37
+ exports.isPathSource = isPathSource;
38
+ exports.readRemoteContextConfig = readRemoteContextConfig;
39
+ exports.writeRemoteContextConfig = writeRemoteContextConfig;
40
+ exports.findSourceByName = findSourceByName;
41
+ exports.identityFilePath = identityFilePath;
42
+ exports.readIdentity = readIdentity;
43
+ exports.writeIdentity = writeIdentity;
44
+ exports.resolveIdentityOnly = resolveIdentityOnly;
45
+ exports.hasEverFullyResolved = hasEverFullyResolved;
46
+ exports.resolveFull = resolveFull;
47
+ exports.assembleOprimWorkspaceContent = assembleOprimWorkspaceContent;
48
+ const path = __importStar(require("path"));
49
+ const fs = __importStar(require("fs"));
50
+ const os = __importStar(require("os"));
51
+ const crypto = __importStar(require("crypto"));
52
+ const child_process_1 = require("child_process");
53
+ const yaml = __importStar(require("js-yaml"));
54
+ function isGitSource(source) {
55
+ return 'git' in source && typeof source.git === 'string';
56
+ }
57
+ function isPathSource(source) {
58
+ return 'path' in source && typeof source.path === 'string';
59
+ }
60
+ // ─── oprim/config.yaml — remote_context block ──────────────────────────────
61
+ const REMOTE_CONTEXT_KEY = 'remote_context';
62
+ function configPath(projectRoot) {
63
+ return path.join(projectRoot, 'oprim', 'config.yaml');
64
+ }
65
+ function readRemoteContextConfig(projectRoot) {
66
+ const p = configPath(projectRoot);
67
+ if (!fs.existsSync(p))
68
+ return { enabled: false, sources: [] };
69
+ const parsed = yaml.load(fs.readFileSync(p, 'utf-8'));
70
+ const raw = parsed?.[REMOTE_CONTEXT_KEY];
71
+ if (!raw)
72
+ return { enabled: false, sources: [] };
73
+ return {
74
+ enabled: raw.enabled ?? false,
75
+ sources: Array.isArray(raw.sources) ? raw.sources : [],
76
+ };
77
+ }
78
+ // Surgical replace of just the `remote_context:` top-level block, leaving every other
79
+ // line in oprim/config.yaml byte-for-byte untouched — mirrors the non-destructive approach
80
+ // in config-merge.ts (never parse+re-dump the whole file).
81
+ function replaceTopLevelBlock(content, key, blockContent) {
82
+ const lines = content.split('\n');
83
+ const startIdx = lines.findIndex((l) => new RegExp(`^${key}:`).test(l));
84
+ const blockLines = blockContent.replace(/\n$/, '').split('\n');
85
+ if (startIdx === -1) {
86
+ const separator = content.endsWith('\n') || content === '' ? '' : '\n';
87
+ return content + separator + blockLines.join('\n') + '\n';
88
+ }
89
+ let endIdx = lines.length;
90
+ for (let i = startIdx + 1; i < lines.length; i++) {
91
+ if (/^[A-Za-z_]/.test(lines[i])) {
92
+ endIdx = i;
93
+ break;
94
+ }
95
+ }
96
+ return [...lines.slice(0, startIdx), ...blockLines, ...lines.slice(endIdx)].join('\n');
97
+ }
98
+ function writeRemoteContextConfig(projectRoot, config) {
99
+ const p = configPath(projectRoot);
100
+ const existing = fs.existsSync(p) ? fs.readFileSync(p, 'utf-8') : '';
101
+ const dumped = yaml.dump({ [REMOTE_CONTEXT_KEY]: config }, { lineWidth: -1 });
102
+ const updated = replaceTopLevelBlock(existing, REMOTE_CONTEXT_KEY, dumped);
103
+ fs.mkdirSync(path.dirname(p), { recursive: true });
104
+ fs.writeFileSync(p, updated, 'utf-8');
105
+ }
106
+ function findSourceByName(config, name) {
107
+ return config.sources.find((s) => s.name === name);
108
+ }
109
+ // ─── Remote context identity (.oprim-context/context.yaml) ─────────────────
110
+ function identityFilePath(rootDir) {
111
+ return path.join(rootDir, '.oprim-context', 'context.yaml');
112
+ }
113
+ function readIdentity(rootDir) {
114
+ const p = identityFilePath(rootDir);
115
+ if (!fs.existsSync(p))
116
+ return null;
117
+ try {
118
+ const parsed = yaml.load(fs.readFileSync(p, 'utf-8'));
119
+ if (!parsed?.name || !parsed?.version)
120
+ return null;
121
+ return { name: parsed.name, version: parsed.version, description: parsed.description };
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ }
127
+ function writeIdentity(rootDir, identity) {
128
+ const p = identityFilePath(rootDir);
129
+ fs.mkdirSync(path.dirname(p), { recursive: true });
130
+ fs.writeFileSync(p, yaml.dump(identity, { lineWidth: -1 }), 'utf-8');
131
+ }
132
+ // ─── Cache directory layout (git sources only) ─────────────────────────────
133
+ // 5 minutes — see design.md open question. Overridable for tests that need to exercise
134
+ // post-throttle-window behavior without actually waiting.
135
+ function throttleMs() {
136
+ const override = process.env['OPRIM_REMOTE_CONTEXT_THROTTLE_MS'];
137
+ return override ? Number(override) : 5 * 60 * 1000;
138
+ }
139
+ // Overridable so tests never write into the real user's home directory.
140
+ function cacheRoot() {
141
+ return process.env['OPRIM_REMOTE_CONTEXT_CACHE_DIR'] ?? path.join(os.homedir(), '.oprim', 'remote-context-cache');
142
+ }
143
+ function sourceCacheKey(source) {
144
+ const hash = crypto.createHash('sha1').update(source.git).digest('hex').slice(0, 12);
145
+ return `${source.name}-${hash}`;
146
+ }
147
+ function fullCacheDir(source) {
148
+ return path.join(cacheRoot(), sourceCacheKey(source), 'full');
149
+ }
150
+ function identityCacheDir(source) {
151
+ return path.join(cacheRoot(), sourceCacheKey(source), 'identity');
152
+ }
153
+ function markerPath(dir) {
154
+ return path.join(dir, '.oprim-last-fetch');
155
+ }
156
+ function isWithinThrottle(dir) {
157
+ const marker = markerPath(dir);
158
+ if (!fs.existsSync(marker))
159
+ return false;
160
+ const last = Number(fs.readFileSync(marker, 'utf-8').trim() || '0');
161
+ return Date.now() - last < throttleMs();
162
+ }
163
+ function touchMarker(dir) {
164
+ fs.writeFileSync(markerPath(dir), String(Date.now()), 'utf-8');
165
+ }
166
+ function git(args, cwd) {
167
+ (0, child_process_1.execFileSync)('git', args, { cwd, stdio: 'ignore' });
168
+ }
169
+ function withNameMismatch(source, identity) {
170
+ if (!identity)
171
+ return undefined;
172
+ if (identity.name === source.name)
173
+ return undefined;
174
+ return { declared: source.name, resolved: identity.name };
175
+ }
176
+ // ─── Git source resolution ─────────────────────────────────────────────────
177
+ function ensureFullGitClone(source) {
178
+ const dir = fullCacheDir(source);
179
+ const exists = fs.existsSync(path.join(dir, '.git'));
180
+ if (exists && isWithinThrottle(dir)) {
181
+ return { dir, stale: false };
182
+ }
183
+ try {
184
+ if (!exists) {
185
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
186
+ git(['clone', '--depth', '1', '--quiet', source.git, dir]);
187
+ }
188
+ else {
189
+ git(['fetch', '--depth', '1', '--quiet', 'origin', 'HEAD'], dir);
190
+ git(['reset', '--hard', '--quiet', 'FETCH_HEAD'], dir);
191
+ }
192
+ touchMarker(dir);
193
+ return { dir, stale: false };
194
+ }
195
+ catch (err) {
196
+ if (exists) {
197
+ // Last-known-good fallback — resolution degrades to stale rather than failing outright.
198
+ return { dir, stale: true };
199
+ }
200
+ return { dir, stale: false, error: err instanceof Error ? err.message : String(err) };
201
+ }
202
+ }
203
+ function ensureIdentityOnlyGitClone(source) {
204
+ const dir = identityCacheDir(source);
205
+ const exists = fs.existsSync(path.join(dir, '.git'));
206
+ if (exists && isWithinThrottle(dir)) {
207
+ return { dir };
208
+ }
209
+ try {
210
+ if (!exists) {
211
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
212
+ // Partial clone (blob:none) + cone sparse-checkout scoped to .oprim-context/ — this is
213
+ // the "targeted single-file fetch" from design.md decision 5, implemented as an
214
+ // equivalent narrow fetch: git only downloads the one blob it needs to check out,
215
+ // not the whole workspace. `git archive --remote` was considered and rejected — GitHub
216
+ // and most managed git hosts disable the upload-archive service, so it isn't portable.
217
+ git(['clone', '--filter=blob:none', '--no-checkout', '--depth', '1', '--quiet', source.git, dir]);
218
+ git(['sparse-checkout', 'init', '--cone'], dir);
219
+ git(['sparse-checkout', 'set', '.oprim-context'], dir);
220
+ git(['checkout', '--quiet'], dir);
221
+ }
222
+ else {
223
+ git(['fetch', '--depth', '1', '--quiet', 'origin', 'HEAD'], dir);
224
+ git(['reset', '--hard', '--quiet', 'FETCH_HEAD'], dir);
225
+ }
226
+ touchMarker(dir);
227
+ return { dir };
228
+ }
229
+ catch (err) {
230
+ return { dir, error: err instanceof Error ? err.message : String(err) };
231
+ }
232
+ }
233
+ function resolveIdentityOnly(source) {
234
+ if (isPathSource(source)) {
235
+ if (!fs.existsSync(source.path)) {
236
+ return { identity: null, stale: false, error: `path not found: ${source.path}` };
237
+ }
238
+ const identity = readIdentity(source.path);
239
+ return { identity, stale: false, nameMismatch: withNameMismatch(source, identity) };
240
+ }
241
+ const { dir, error } = ensureIdentityOnlyGitClone(source);
242
+ if (error)
243
+ return { identity: null, stale: false, error };
244
+ const identity = readIdentity(dir);
245
+ return { identity, stale: false, nameMismatch: withNameMismatch(source, identity) };
246
+ }
247
+ // Local-path sources are always read live (no persistent cache), so "has this ever been
248
+ // resolved" is only a meaningful question for git sources, where full resolution populates
249
+ // a durable cache directory that outlives any single command invocation.
250
+ function hasEverFullyResolved(source) {
251
+ if (isPathSource(source))
252
+ return true;
253
+ return fs.existsSync(path.join(fullCacheDir(source), '.git'));
254
+ }
255
+ function resolveFull(source) {
256
+ if (isPathSource(source)) {
257
+ if (!fs.existsSync(source.path)) {
258
+ return { workspaceRoot: source.path, stale: false, error: `path not found: ${source.path}` };
259
+ }
260
+ const identity = readIdentity(source.path);
261
+ return {
262
+ workspaceRoot: source.path,
263
+ stale: false,
264
+ nameMismatch: withNameMismatch(source, identity),
265
+ };
266
+ }
267
+ const { dir, stale, error } = ensureFullGitClone(source);
268
+ if (error)
269
+ return { workspaceRoot: dir, stale: false, error };
270
+ const identity = readIdentity(dir);
271
+ return { workspaceRoot: dir, stale, nameMismatch: withNameMismatch(source, identity) };
272
+ }
273
+ // ─── Assembling oprim/ workspace content for printing ──────────────────────
274
+ function walkTextFiles(dir, base, out) {
275
+ if (!fs.existsSync(dir))
276
+ return;
277
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
278
+ const full = path.join(dir, entry.name);
279
+ const rel = path.join(base, entry.name);
280
+ if (entry.isDirectory()) {
281
+ walkTextFiles(full, rel, out);
282
+ }
283
+ else if (entry.isFile() && /\.(md|yaml|yml)$/.test(entry.name)) {
284
+ out.push(rel);
285
+ }
286
+ }
287
+ }
288
+ function assembleOprimWorkspaceContent(workspaceRoot) {
289
+ const oprimDir = path.join(workspaceRoot, 'oprim');
290
+ const files = [];
291
+ walkTextFiles(oprimDir, 'oprim', files);
292
+ files.sort();
293
+ return files
294
+ .map((rel) => {
295
+ const content = fs.readFileSync(path.join(workspaceRoot, rel), 'utf-8');
296
+ return `─── ${rel} ───\n${content}`;
297
+ })
298
+ .join('\n\n');
299
+ }
@@ -1,4 +1,4 @@
1
- export declare function configTemplate(projectName: string, openspecEnabled: boolean, graphifyEnabled: boolean, okfEnabled: boolean): string;
1
+ export declare function configTemplate(projectName: string, openspecEnabled: boolean, graphifyEnabled: boolean, okfEnabled: boolean, specFramework?: string): string;
2
2
  export declare function okfFrontmatter(type: string, titleHint: string): string;
3
3
  export declare function noteMinimalFrontmatter(titleHint: string): string;
4
4
  export declare function indexTemplate(projectName: string): string;
@@ -5,7 +5,7 @@ exports.configTemplate = configTemplate;
5
5
  exports.okfFrontmatter = okfFrontmatter;
6
6
  exports.noteMinimalFrontmatter = noteMinimalFrontmatter;
7
7
  exports.indexTemplate = indexTemplate;
8
- function configTemplate(projectName, openspecEnabled, graphifyEnabled, okfEnabled) {
8
+ function configTemplate(projectName, openspecEnabled, graphifyEnabled, okfEnabled, specFramework = openspecEnabled ? 'openspec' : 'none') {
9
9
  return `version: 1
10
10
  project:
11
11
  name: "${projectName}"
@@ -17,6 +17,7 @@ integrations:
17
17
  graphify:
18
18
  enabled: ${graphifyEnabled}
19
19
  graph_dir: graphify-out
20
+ spec_framework: ${specFramework}
20
21
  okf:
21
22
  enabled: ${okfEnabled}
22
23
  measurement:
@@ -28,6 +29,11 @@ measurement:
28
29
  sequencing:
29
30
  wip_limits:
30
31
  now: 2
32
+ context: ""
33
+ rules: {}
34
+ remote_context:
35
+ enabled: false
36
+ sources: []
31
37
  `;
32
38
  }
33
39
  // OKF (Open Knowledge Format) — https://github.com/GoogleCloudPlatform/okf
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-product-primer/cli",
3
- "version": "1.2.0",
3
+ "version": "2.1.0",
4
4
  "description": "Open Product Primer CLI — product decisions, sequencing, and KPI tracking for repositories",
5
5
  "keywords": [
6
6
  "product",