@hegemonart/get-design-done 1.28.7 → 1.28.8

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,366 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * scripts/lib/install/doctor-cursor-marketplace.cjs — Phase 28.8 (Plan B2).
5
+ *
6
+ * Cursor Marketplace doctor-mode reporter. Pure, read-only function that
7
+ * surfaces the maintainer's local Cursor Marketplace publish state to
8
+ * `scripts/install.cjs --doctor`.
9
+ *
10
+ * Phase 28.8 D-16: Cursor Marketplace is multi-step publish (submit →
11
+ * review → publish). This reporter reads `.cursor-plugin/plugin.json`
12
+ * (shipped artifact, B1) and the maintainer-local
13
+ * `.cursor-plugin/marketplace-state.json` (gitignored — local-only,
14
+ * never committed) and emits a structured status. Read-only; no writes,
15
+ * no network. Tmpdir-safe per D-10.
16
+ *
17
+ * Design pattern (for Plan 28-8-C2 + 28-8-X2 to mirror): each Tier-2
18
+ * channel ships its own pure reporter; the aggregator in install.cjs
19
+ * (Plan B2 today, X2 in the final wave) composes them. B2's reporter
20
+ * has no dependencies on other channels' state — the aggregator is the
21
+ * only knowledge boundary that needs C2 + B2 awareness.
22
+ *
23
+ * Exports:
24
+ * - `reportCursorMarketplace({ projectRoot })` — structured status.
25
+ * - `MARKETPLACE_STATES` — frozen enum of the 4 D-16 status values.
26
+ * - `formatCursorMarketplaceReport(report)` — text formatter (also used
27
+ * by install.cjs --doctor; kept here so all rendering logic stays
28
+ * adjacent to the data shape).
29
+ * - `validateManifest(parsedManifest)` — light shape validator for the
30
+ * parsed manifest. Mirrors B1's `buildManifest` defensive throws but
31
+ * in inverse direction (validate parsed → not assemble from sources).
32
+ * Separate from B1's converter because that one constructs manifests
33
+ * from canonical sources; the doctor receives a possibly-stale or
34
+ * hand-edited manifest from disk.
35
+ */
36
+
37
+ const fs = require('node:fs');
38
+ const path = require('node:path');
39
+
40
+ const MARKETPLACE_STATES = Object.freeze({
41
+ NOT_SUBMITTED: 'not-submitted',
42
+ SUBMITTED_PENDING: 'submitted-pending',
43
+ APPROVED_PUBLISHED: 'approved-published',
44
+ REJECTED: 'rejected',
45
+ });
46
+
47
+ const KNOWN_STATUS_VALUES = new Set([
48
+ MARKETPLACE_STATES.NOT_SUBMITTED,
49
+ MARKETPLACE_STATES.SUBMITTED_PENDING,
50
+ MARKETPLACE_STATES.APPROVED_PUBLISHED,
51
+ MARKETPLACE_STATES.REJECTED,
52
+ ]);
53
+
54
+ /**
55
+ * Validate a parsed `.cursor-plugin/plugin.json` object against the
56
+ * 8-field shape B1 emits. Returns `{valid, errors}` — never throws.
57
+ *
58
+ * @param {*} parsed Parsed JSON object.
59
+ * @returns {{ valid: boolean, errors: string[] }}
60
+ */
61
+ function validateManifest(parsed) {
62
+ const errors = [];
63
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
64
+ return { valid: false, errors: ['manifest is not a JSON object'] };
65
+ }
66
+
67
+ if (typeof parsed.name !== 'string' || parsed.name.length === 0) {
68
+ errors.push('name must be a non-empty string');
69
+ }
70
+ if (typeof parsed.description !== 'string' || parsed.description.length === 0) {
71
+ errors.push('description must be a non-empty string');
72
+ }
73
+ if (typeof parsed.version !== 'string' || !/^\d+\.\d+\.\d+/.test(parsed.version)) {
74
+ errors.push('version must be semver-shaped (x.y.z)');
75
+ }
76
+ if (
77
+ !parsed.author
78
+ || typeof parsed.author !== 'object'
79
+ || Array.isArray(parsed.author)
80
+ || typeof parsed.author.name !== 'string'
81
+ || parsed.author.name.length === 0
82
+ ) {
83
+ errors.push('author.name must be a non-empty string');
84
+ }
85
+ if (!Array.isArray(parsed.keywords) || parsed.keywords.length === 0) {
86
+ errors.push('keywords must be a non-empty array');
87
+ } else {
88
+ for (const k of parsed.keywords) {
89
+ if (typeof k !== 'string' || k.length === 0) {
90
+ errors.push('keywords must contain only non-empty strings');
91
+ break;
92
+ }
93
+ }
94
+ }
95
+
96
+ // Optional fields, but if present must match shape.
97
+ if (parsed.homepage !== undefined && typeof parsed.homepage !== 'string') {
98
+ errors.push('homepage must be a string if present');
99
+ }
100
+ if (parsed.repository !== undefined && typeof parsed.repository !== 'string') {
101
+ errors.push('repository must be a string if present');
102
+ }
103
+ if (parsed.license !== undefined && typeof parsed.license !== 'string') {
104
+ errors.push('license must be a string if present');
105
+ }
106
+
107
+ return { valid: errors.length === 0, errors };
108
+ }
109
+
110
+ /**
111
+ * Safely read + parse a JSON file. Returns `{exists, parsed, error}`.
112
+ * @param {string} filePath
113
+ * @returns {{ exists: boolean, parsed: *, error: string|null }}
114
+ */
115
+ function readJsonFileSafe(filePath) {
116
+ let raw;
117
+ try {
118
+ raw = fs.readFileSync(filePath, 'utf8');
119
+ } catch (e) {
120
+ if (e && e.code === 'ENOENT') {
121
+ return { exists: false, parsed: null, error: null };
122
+ }
123
+ return { exists: false, parsed: null, error: 'read failed: ' + e.message };
124
+ }
125
+ try {
126
+ return { exists: true, parsed: JSON.parse(raw), error: null };
127
+ } catch (e) {
128
+ return { exists: true, parsed: null, error: 'JSON parse failed: ' + e.message };
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Build a one-line guidance string per state.
134
+ * @param {{ state: string, marketplaceUrl: string|null, rejectionReason: string|null }} r
135
+ * @returns {string}
136
+ */
137
+ function buildGuidance(r) {
138
+ switch (r.state) {
139
+ case MARKETPLACE_STATES.NOT_SUBMITTED:
140
+ return 'submit publisher application at cursor.com/marketplace/publish; see docs/cursor-marketplace-field-test.md';
141
+ case MARKETPLACE_STATES.SUBMITTED_PENDING:
142
+ return 'await Cursor team review approval; no published SLA per D-16';
143
+ case MARKETPLACE_STATES.APPROVED_PUBLISHED:
144
+ return 'plugin is live at ' + (r.marketplaceUrl || '<marketplace-url>');
145
+ case MARKETPLACE_STATES.REJECTED:
146
+ return 'address rejection reason: ' + (r.rejectionReason || '<unspecified>')
147
+ + '; re-submit per docs/cursor-marketplace-field-test.md';
148
+ default:
149
+ return '';
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Read-only Cursor Marketplace status reporter. Reads
155
+ * `.cursor-plugin/plugin.json` and `.cursor-plugin/marketplace-state.json`
156
+ * under `projectRoot` (no writes, no network).
157
+ *
158
+ * @param {{ projectRoot: string }} opts
159
+ * @returns {{
160
+ * state: 'not-submitted'|'submitted-pending'|'approved-published'|'rejected',
161
+ * manifestPresent: boolean,
162
+ * manifestVersion: string|null,
163
+ * packageVersion: string|null,
164
+ * versionMatch: boolean,
165
+ * manifestSchemaValid: boolean,
166
+ * manifestSchemaErrors: string[],
167
+ * marketplaceUrl: string|null,
168
+ * submittedAt: string|null,
169
+ * approvedAt: string|null,
170
+ * rejectionReason: string|null,
171
+ * guidance: string,
172
+ * }}
173
+ */
174
+ function reportCursorMarketplace(opts) {
175
+ if (!opts || typeof opts !== 'object' || typeof opts.projectRoot !== 'string') {
176
+ throw new Error('reportCursorMarketplace: opts.projectRoot is required');
177
+ }
178
+ const projectRoot = opts.projectRoot;
179
+
180
+ const manifestPath = path.join(projectRoot, '.cursor-plugin', 'plugin.json');
181
+ const statePath = path.join(projectRoot, '.cursor-plugin', 'marketplace-state.json');
182
+ const pkgPath = path.join(projectRoot, 'package.json');
183
+
184
+ // 1) Manifest read
185
+ const manifestRead = readJsonFileSafe(manifestPath);
186
+ let manifestPresent = false;
187
+ let manifestVersion = null;
188
+ let manifestSchemaValid = false;
189
+ let manifestSchemaErrors = [];
190
+ if (!manifestRead.exists) {
191
+ manifestSchemaErrors = ['manifest absent'];
192
+ } else if (manifestRead.error) {
193
+ manifestPresent = true;
194
+ manifestSchemaErrors = [manifestRead.error];
195
+ } else {
196
+ manifestPresent = true;
197
+ const validation = validateManifest(manifestRead.parsed);
198
+ manifestSchemaValid = validation.valid;
199
+ manifestSchemaErrors = validation.errors;
200
+ if (typeof manifestRead.parsed.version === 'string') {
201
+ manifestVersion = manifestRead.parsed.version;
202
+ }
203
+ }
204
+
205
+ // 2) Package version read (tolerate missing).
206
+ let packageVersion = null;
207
+ const pkgRead = readJsonFileSafe(pkgPath);
208
+ if (pkgRead.exists && !pkgRead.error
209
+ && pkgRead.parsed && typeof pkgRead.parsed.version === 'string') {
210
+ packageVersion = pkgRead.parsed.version;
211
+ }
212
+
213
+ // 3) Version match (only true when both present and equal).
214
+ const versionMatch = Boolean(
215
+ manifestVersion && packageVersion && manifestVersion === packageVersion
216
+ );
217
+
218
+ // 4) State read. Maintainer-typo safety: unknown status THROWS.
219
+ const stateRead = readJsonFileSafe(statePath);
220
+ let state = MARKETPLACE_STATES.NOT_SUBMITTED;
221
+ let marketplaceUrl = null;
222
+ let submittedAt = null;
223
+ let approvedAt = null;
224
+ let rejectionReason = null;
225
+
226
+ if (stateRead.exists && stateRead.error) {
227
+ // Malformed JSON — surface loudly per T-04 in threat register.
228
+ throw new Error(
229
+ 'cursor-marketplace doctor: marketplace-state.json malformed: '
230
+ + stateRead.error
231
+ );
232
+ }
233
+ if (stateRead.exists && stateRead.parsed && typeof stateRead.parsed === 'object') {
234
+ const s = stateRead.parsed.status;
235
+ if (typeof s !== 'string') {
236
+ throw new Error(
237
+ 'cursor-marketplace doctor: marketplace-state.json is missing "status" field'
238
+ );
239
+ }
240
+ if (!KNOWN_STATUS_VALUES.has(s)) {
241
+ throw new Error(
242
+ 'cursor-marketplace doctor: unknown marketplace-state.json status: ' + s
243
+ + ' (expected one of: not-submitted, submitted-pending, approved-published, rejected)'
244
+ );
245
+ }
246
+ state = s;
247
+ if (typeof stateRead.parsed['submitted-at'] === 'string') {
248
+ submittedAt = stateRead.parsed['submitted-at'];
249
+ }
250
+ if (typeof stateRead.parsed['approved-at'] === 'string') {
251
+ approvedAt = stateRead.parsed['approved-at'];
252
+ }
253
+ if (typeof stateRead.parsed['marketplace-url'] === 'string') {
254
+ marketplaceUrl = stateRead.parsed['marketplace-url'];
255
+ }
256
+ if (typeof stateRead.parsed.reason === 'string') {
257
+ rejectionReason = stateRead.parsed.reason;
258
+ }
259
+ }
260
+
261
+ const result = {
262
+ state,
263
+ manifestPresent,
264
+ manifestVersion,
265
+ packageVersion,
266
+ versionMatch,
267
+ manifestSchemaValid,
268
+ manifestSchemaErrors,
269
+ marketplaceUrl,
270
+ submittedAt,
271
+ approvedAt,
272
+ rejectionReason,
273
+ guidance: '',
274
+ };
275
+ result.guidance = buildGuidance(result);
276
+ return result;
277
+ }
278
+
279
+ /**
280
+ * Format the doctor report as multi-line text for stdout. Pure — no IO.
281
+ *
282
+ * Output shape (per plan <interfaces>):
283
+ *
284
+ * === Cursor Marketplace status ===
285
+ * Manifest: .cursor-plugin/plugin.json (v1.28.8) ✓ matches package.json
286
+ * Schema validity: valid
287
+ * Application: submitted-pending (submitted 2026-05-22)
288
+ * Next step: await Cursor team review approval; no published SLA per D-16
289
+ *
290
+ * @param {ReturnType<typeof reportCursorMarketplace>} r
291
+ * @returns {string}
292
+ */
293
+ function formatCursorMarketplaceReport(r) {
294
+ const lines = ['=== Cursor Marketplace status ==='];
295
+
296
+ // Manifest line
297
+ let manifestLine;
298
+ if (!r.manifestPresent) {
299
+ manifestLine = ' Manifest: absent (-) ✗ create .cursor-plugin/plugin.json (B1)';
300
+ } else {
301
+ const ver = r.manifestVersion ? 'v' + r.manifestVersion : 'unknown';
302
+ let matchGlyph;
303
+ let matchText;
304
+ if (r.packageVersion === null) {
305
+ matchGlyph = '-';
306
+ matchText = 'package.json missing (no compare)';
307
+ } else if (r.versionMatch) {
308
+ matchGlyph = '✓';
309
+ matchText = 'matches package.json';
310
+ } else {
311
+ matchGlyph = '✗';
312
+ matchText = 'mismatch — package.json is v' + r.packageVersion;
313
+ }
314
+ manifestLine = ' Manifest: .cursor-plugin/plugin.json (' + ver + ') '
315
+ + matchGlyph + ' ' + matchText;
316
+ }
317
+ lines.push(manifestLine);
318
+
319
+ // Schema validity line
320
+ let schemaLine;
321
+ if (!r.manifestPresent) {
322
+ schemaLine = ' Schema validity: -';
323
+ } else if (r.manifestSchemaValid) {
324
+ schemaLine = ' Schema validity: valid';
325
+ } else {
326
+ const errs = (r.manifestSchemaErrors || []).join('; ') || 'invalid';
327
+ schemaLine = ' Schema validity: invalid: ' + errs;
328
+ }
329
+ lines.push(schemaLine);
330
+
331
+ // Application line — state + context fragment
332
+ let appContext;
333
+ switch (r.state) {
334
+ case MARKETPLACE_STATES.NOT_SUBMITTED:
335
+ appContext = '-';
336
+ break;
337
+ case MARKETPLACE_STATES.SUBMITTED_PENDING:
338
+ appContext = r.submittedAt
339
+ ? 'submitted ' + r.submittedAt.slice(0, 10)
340
+ : 'submitted-at unrecorded';
341
+ break;
342
+ case MARKETPLACE_STATES.APPROVED_PUBLISHED:
343
+ appContext = r.marketplaceUrl
344
+ ? 'live at ' + r.marketplaceUrl
345
+ : 'live (url unrecorded)';
346
+ break;
347
+ case MARKETPLACE_STATES.REJECTED:
348
+ appContext = r.rejectionReason || 'reason unrecorded';
349
+ break;
350
+ default:
351
+ appContext = '-';
352
+ }
353
+ lines.push(' Application: ' + r.state + ' (' + appContext + ')');
354
+
355
+ // Next step / guidance
356
+ lines.push(' Next step: ' + (r.guidance || '-'));
357
+
358
+ return lines.join('\n');
359
+ }
360
+
361
+ module.exports = {
362
+ reportCursorMarketplace,
363
+ formatCursorMarketplaceReport,
364
+ validateManifest,
365
+ MARKETPLACE_STATES,
366
+ };