sdd-cli 0.1.23 → 0.1.25
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/README.md +112 -688
- package/dist/cli.js +108 -8
- package/dist/commands/ai-autopilot.d.ts +19 -0
- package/dist/commands/ai-autopilot.js +1292 -0
- package/dist/commands/ai-exec.js +14 -3
- package/dist/commands/ai-status.js +17 -5
- package/dist/commands/app-lifecycle.d.ts +25 -0
- package/dist/commands/app-lifecycle.js +505 -0
- package/dist/commands/hello.js +53 -1
- package/dist/commands/suite.d.ts +1 -0
- package/dist/commands/suite.js +82 -0
- package/dist/config/index.d.ts +23 -0
- package/dist/config/index.js +209 -0
- package/dist/context/flags.d.ts +2 -0
- package/dist/context/flags.js +9 -1
- package/dist/providers/codex.d.ts +3 -5
- package/dist/providers/codex.js +34 -2
- package/dist/providers/gemini.d.ts +5 -0
- package/dist/providers/gemini.js +82 -0
- package/dist/providers/index.d.ts +16 -0
- package/dist/providers/index.js +90 -0
- package/dist/providers/types.d.ts +13 -0
- package/dist/providers/types.js +2 -0
- package/dist/router/intent.js +34 -4
- package/dist/workspace/index.js +6 -6
- package/package.json +4 -3
|
@@ -0,0 +1,1292 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.resetToFunctionalBaseline = resetToFunctionalBaseline;
|
|
7
|
+
exports.enrichDraftWithAI = enrichDraftWithAI;
|
|
8
|
+
exports.bootstrapProjectCode = bootstrapProjectCode;
|
|
9
|
+
exports.improveGeneratedApp = improveGeneratedApp;
|
|
10
|
+
const fs_1 = __importDefault(require("fs"));
|
|
11
|
+
const path_1 = __importDefault(require("path"));
|
|
12
|
+
const providers_1 = require("../providers");
|
|
13
|
+
function extractJsonObject(text) {
|
|
14
|
+
const parseFirstBalancedObject = (raw) => {
|
|
15
|
+
const source = raw.trim();
|
|
16
|
+
const start = source.indexOf("{");
|
|
17
|
+
if (start < 0) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
let depth = 0;
|
|
21
|
+
let inString = false;
|
|
22
|
+
let escaping = false;
|
|
23
|
+
for (let i = start; i < source.length; i += 1) {
|
|
24
|
+
const char = source[i];
|
|
25
|
+
if (inString) {
|
|
26
|
+
if (escaping) {
|
|
27
|
+
escaping = false;
|
|
28
|
+
}
|
|
29
|
+
else if (char === "\\") {
|
|
30
|
+
escaping = true;
|
|
31
|
+
}
|
|
32
|
+
else if (char === "\"") {
|
|
33
|
+
inString = false;
|
|
34
|
+
}
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (char === "\"") {
|
|
38
|
+
inString = true;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (char === "{") {
|
|
42
|
+
depth += 1;
|
|
43
|
+
}
|
|
44
|
+
else if (char === "}") {
|
|
45
|
+
depth -= 1;
|
|
46
|
+
if (depth === 0) {
|
|
47
|
+
const candidate = source.slice(start, i + 1);
|
|
48
|
+
try {
|
|
49
|
+
const parsed = JSON.parse(candidate);
|
|
50
|
+
return unwrapResponse(parsed);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
};
|
|
60
|
+
const parseCandidate = (raw) => {
|
|
61
|
+
const direct = raw.trim();
|
|
62
|
+
if (!direct) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const parsed = JSON.parse(direct);
|
|
67
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// keep trying
|
|
71
|
+
}
|
|
72
|
+
const fenceMatch = /```(?:json)?\s*([\s\S]*?)\s*```/i.exec(raw);
|
|
73
|
+
if (fenceMatch && fenceMatch[1]) {
|
|
74
|
+
try {
|
|
75
|
+
const parsed = JSON.parse(fenceMatch[1].trim());
|
|
76
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// keep trying
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return parseFirstBalancedObject(raw);
|
|
83
|
+
};
|
|
84
|
+
const unwrapResponse = (value) => {
|
|
85
|
+
const nested = value.response;
|
|
86
|
+
if (typeof nested !== "string") {
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
const parsedNested = parseCandidate(nested);
|
|
90
|
+
return parsedNested ?? value;
|
|
91
|
+
};
|
|
92
|
+
const parsed = parseCandidate(text);
|
|
93
|
+
if (!parsed) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
return unwrapResponse(parsed);
|
|
97
|
+
}
|
|
98
|
+
function asText(value, fallback) {
|
|
99
|
+
if (typeof value !== "string") {
|
|
100
|
+
return fallback;
|
|
101
|
+
}
|
|
102
|
+
const clean = value.trim();
|
|
103
|
+
return clean.length > 0 ? clean : fallback;
|
|
104
|
+
}
|
|
105
|
+
function safeRelativePath(input) {
|
|
106
|
+
const clean = input.trim().replace(/\\/g, "/");
|
|
107
|
+
if (!clean || clean.startsWith("/") || /^[A-Za-z]:/.test(clean)) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
const normalized = path_1.default.posix.normalize(clean);
|
|
111
|
+
if (normalized.startsWith("../") || normalized === "..") {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
return normalized;
|
|
115
|
+
}
|
|
116
|
+
function askProviderForJson(providerExec, prompt) {
|
|
117
|
+
const first = providerExec(prompt);
|
|
118
|
+
if (!first.ok) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
const parsed = extractJsonObject(first.output);
|
|
122
|
+
if (parsed) {
|
|
123
|
+
return parsed;
|
|
124
|
+
}
|
|
125
|
+
const repairPrompt = [
|
|
126
|
+
"Convert the following response into valid JSON only.",
|
|
127
|
+
"Keep the same information.",
|
|
128
|
+
"No markdown fences, no explanations.",
|
|
129
|
+
first.output
|
|
130
|
+
].join("\n");
|
|
131
|
+
const second = providerExec(repairPrompt);
|
|
132
|
+
if (!second.ok) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
return extractJsonObject(second.output);
|
|
136
|
+
}
|
|
137
|
+
function detectBaselineKind(intent) {
|
|
138
|
+
const lower = intent.toLowerCase();
|
|
139
|
+
if (/\bnotes?\b|\bnotas?\b/.test(lower)) {
|
|
140
|
+
return "notes";
|
|
141
|
+
}
|
|
142
|
+
if ((/usuarios?|users?/.test(lower) && /novedades|news|announcements?/.test(lower)) ||
|
|
143
|
+
/gestion de usuarios/.test(lower) ||
|
|
144
|
+
/user management/.test(lower)) {
|
|
145
|
+
return "user_news";
|
|
146
|
+
}
|
|
147
|
+
return "generic";
|
|
148
|
+
}
|
|
149
|
+
function commonPackageJson(projectName) {
|
|
150
|
+
return `{
|
|
151
|
+
"name": "${projectName.toLowerCase().replace(/[^a-z0-9-]+/g, "-")}",
|
|
152
|
+
"version": "1.0.0",
|
|
153
|
+
"private": true,
|
|
154
|
+
"scripts": {
|
|
155
|
+
"test": "node --test core.test.js"
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
`;
|
|
159
|
+
}
|
|
160
|
+
function notesBaselineFiles(projectName) {
|
|
161
|
+
const readme = `# ${projectName} - Notes App
|
|
162
|
+
|
|
163
|
+
A focused notes application with persistence, pinning, search, and inline edit/delete.
|
|
164
|
+
|
|
165
|
+
## Features
|
|
166
|
+
- Create, edit, pin, and delete notes
|
|
167
|
+
- Persistent storage via localStorage
|
|
168
|
+
- Search notes by text in real time
|
|
169
|
+
- Keyboard and screen-reader friendly controls
|
|
170
|
+
- Core domain logic covered with unit tests
|
|
171
|
+
|
|
172
|
+
## Run
|
|
173
|
+
1. Open \`index.html\` in your browser.
|
|
174
|
+
2. Use the app normally; notes persist automatically.
|
|
175
|
+
|
|
176
|
+
## Test
|
|
177
|
+
- Run \`npm test\` for store-level unit tests.
|
|
178
|
+
`;
|
|
179
|
+
const core = `(function (globalScope) {
|
|
180
|
+
function nowIso() {
|
|
181
|
+
return new Date().toISOString();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function normalizeText(value) {
|
|
185
|
+
return String(value || "").trim().replace(/\\s+/g, " ");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function createNotesStore(storage, options) {
|
|
189
|
+
const key = (options && options.key) || "notes-app-state-v1";
|
|
190
|
+
const version = 1;
|
|
191
|
+
|
|
192
|
+
function emptyState() {
|
|
193
|
+
return { version, notes: [] };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function parseState(raw) {
|
|
197
|
+
if (!raw) return emptyState();
|
|
198
|
+
try {
|
|
199
|
+
const parsed = JSON.parse(raw);
|
|
200
|
+
if (!parsed || typeof parsed !== "object") return emptyState();
|
|
201
|
+
const notes = Array.isArray(parsed.notes) ? parsed.notes : [];
|
|
202
|
+
return { version, notes };
|
|
203
|
+
} catch {
|
|
204
|
+
return emptyState();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function loadState() {
|
|
209
|
+
return parseState(storage.getItem(key));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function saveState(state) {
|
|
213
|
+
storage.setItem(key, JSON.stringify(state));
|
|
214
|
+
return state;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function sorted(notes) {
|
|
218
|
+
return [...notes].sort((a, b) => {
|
|
219
|
+
if (Boolean(a.pinned) !== Boolean(b.pinned)) {
|
|
220
|
+
return a.pinned ? -1 : 1;
|
|
221
|
+
}
|
|
222
|
+
return String(b.updatedAt).localeCompare(String(a.updatedAt));
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function requireExisting(state, id) {
|
|
227
|
+
const target = state.notes.find((n) => String(n.id) === String(id));
|
|
228
|
+
if (!target) throw new Error("Note not found");
|
|
229
|
+
return target;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
list() {
|
|
234
|
+
const state = loadState();
|
|
235
|
+
return sorted(state.notes);
|
|
236
|
+
},
|
|
237
|
+
add(text) {
|
|
238
|
+
const value = normalizeText(text);
|
|
239
|
+
if (!value) throw new Error("Note text is required");
|
|
240
|
+
if (value.length > 240) throw new Error("Note text too long (max 240)");
|
|
241
|
+
const state = loadState();
|
|
242
|
+
const timestamp = nowIso();
|
|
243
|
+
const note = {
|
|
244
|
+
id: String(Date.now()) + "-" + Math.random().toString(16).slice(2, 8),
|
|
245
|
+
text: value,
|
|
246
|
+
pinned: false,
|
|
247
|
+
createdAt: timestamp,
|
|
248
|
+
updatedAt: timestamp
|
|
249
|
+
};
|
|
250
|
+
state.notes.push(note);
|
|
251
|
+
saveState(state);
|
|
252
|
+
return note;
|
|
253
|
+
},
|
|
254
|
+
update(id, text) {
|
|
255
|
+
const value = normalizeText(text);
|
|
256
|
+
if (!value) throw new Error("Note text is required");
|
|
257
|
+
if (value.length > 240) throw new Error("Note text too long (max 240)");
|
|
258
|
+
const state = loadState();
|
|
259
|
+
const note = requireExisting(state, id);
|
|
260
|
+
note.text = value;
|
|
261
|
+
note.updatedAt = nowIso();
|
|
262
|
+
saveState(state);
|
|
263
|
+
return note;
|
|
264
|
+
},
|
|
265
|
+
togglePin(id) {
|
|
266
|
+
const state = loadState();
|
|
267
|
+
const note = requireExisting(state, id);
|
|
268
|
+
note.pinned = !note.pinned;
|
|
269
|
+
note.updatedAt = nowIso();
|
|
270
|
+
saveState(state);
|
|
271
|
+
return note;
|
|
272
|
+
},
|
|
273
|
+
remove(id) {
|
|
274
|
+
const state = loadState();
|
|
275
|
+
const before = state.notes.length;
|
|
276
|
+
state.notes = state.notes.filter((n) => String(n.id) !== String(id));
|
|
277
|
+
if (state.notes.length === before) throw new Error("Note not found");
|
|
278
|
+
saveState(state);
|
|
279
|
+
return true;
|
|
280
|
+
},
|
|
281
|
+
search(query) {
|
|
282
|
+
const term = normalizeText(query).toLowerCase();
|
|
283
|
+
if (!term) return this.list();
|
|
284
|
+
return this.list().filter((n) => n.text.toLowerCase().includes(term));
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
const api = { createNotesStore };
|
|
289
|
+
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
|
290
|
+
globalScope.NotesCore = api;
|
|
291
|
+
})(typeof window !== "undefined" ? window : globalThis);
|
|
292
|
+
`;
|
|
293
|
+
const tests = `const test = require("node:test");
|
|
294
|
+
const assert = require("node:assert/strict");
|
|
295
|
+
const { createNotesStore } = require("./core");
|
|
296
|
+
|
|
297
|
+
function memoryStorage() {
|
|
298
|
+
const data = new Map();
|
|
299
|
+
return {
|
|
300
|
+
getItem(key) { return data.has(key) ? data.get(key) : null; },
|
|
301
|
+
setItem(key, value) { data.set(key, String(value)); }
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
test("add stores normalized note text", () => {
|
|
306
|
+
const store = createNotesStore(memoryStorage());
|
|
307
|
+
const note = store.add(" First note ");
|
|
308
|
+
assert.equal(note.text, "First note");
|
|
309
|
+
assert.equal(store.list().length, 1);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("add rejects empty text", () => {
|
|
313
|
+
const store = createNotesStore(memoryStorage());
|
|
314
|
+
assert.throws(() => store.add(" "), /required/);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("add rejects too long text", () => {
|
|
318
|
+
const store = createNotesStore(memoryStorage());
|
|
319
|
+
assert.throws(() => store.add("x".repeat(241)), /too long/);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("update modifies note text", () => {
|
|
323
|
+
const store = createNotesStore(memoryStorage());
|
|
324
|
+
const note = store.add("Old");
|
|
325
|
+
const updated = store.update(note.id, "New");
|
|
326
|
+
assert.equal(updated.text, "New");
|
|
327
|
+
assert.equal(store.list()[0].text, "New");
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("togglePin moves note to top", () => {
|
|
331
|
+
const store = createNotesStore(memoryStorage());
|
|
332
|
+
const a = store.add("first");
|
|
333
|
+
const b = store.add("second");
|
|
334
|
+
store.togglePin(a.id);
|
|
335
|
+
const list = store.list();
|
|
336
|
+
assert.equal(list[0].id, a.id);
|
|
337
|
+
assert.equal(list[1].id, b.id);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("search filters by term", () => {
|
|
341
|
+
const store = createNotesStore(memoryStorage());
|
|
342
|
+
store.add("Buy milk");
|
|
343
|
+
store.add("Read docs");
|
|
344
|
+
const found = store.search("milk");
|
|
345
|
+
assert.equal(found.length, 1);
|
|
346
|
+
assert.equal(found[0].text, "Buy milk");
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("remove deletes existing note", () => {
|
|
350
|
+
const store = createNotesStore(memoryStorage());
|
|
351
|
+
const note = store.add("Temp");
|
|
352
|
+
assert.equal(store.remove(note.id), true);
|
|
353
|
+
assert.equal(store.list().length, 0);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("remove throws when note is missing", () => {
|
|
357
|
+
const store = createNotesStore(memoryStorage());
|
|
358
|
+
assert.throws(() => store.remove("missing"), /not found/);
|
|
359
|
+
});
|
|
360
|
+
`;
|
|
361
|
+
const html = `<!doctype html>
|
|
362
|
+
<html lang="en">
|
|
363
|
+
<head>
|
|
364
|
+
<meta charset="UTF-8" />
|
|
365
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
366
|
+
<title>Notes App</title>
|
|
367
|
+
<link rel="stylesheet" href="styles.css" />
|
|
368
|
+
</head>
|
|
369
|
+
<body>
|
|
370
|
+
<main class="app">
|
|
371
|
+
<header class="header">
|
|
372
|
+
<h1>Notes</h1>
|
|
373
|
+
<p class="subtitle">Quick notes with persistence and search.</p>
|
|
374
|
+
</header>
|
|
375
|
+
|
|
376
|
+
<form id="note-form" class="row" aria-label="Create note">
|
|
377
|
+
<label for="note-input" class="sr-only">New note text</label>
|
|
378
|
+
<input id="note-input" type="text" maxlength="240" placeholder="Write a note..." required />
|
|
379
|
+
<button type="submit">Add</button>
|
|
380
|
+
</form>
|
|
381
|
+
|
|
382
|
+
<section class="controls" aria-label="Notes filters">
|
|
383
|
+
<label for="search-input" class="sr-only">Search notes</label>
|
|
384
|
+
<input id="search-input" type="search" placeholder="Search notes..." />
|
|
385
|
+
<button type="button" id="filter-all" data-filter="all" class="active">All</button>
|
|
386
|
+
<button type="button" id="filter-pinned" data-filter="pinned">Pinned</button>
|
|
387
|
+
</section>
|
|
388
|
+
|
|
389
|
+
<p id="status-text" class="status" aria-live="polite"></p>
|
|
390
|
+
<ul id="note-list" class="list" aria-label="Notes list"></ul>
|
|
391
|
+
</main>
|
|
392
|
+
<script src="core.js"></script>
|
|
393
|
+
<script src="app.js"></script>
|
|
394
|
+
</body>
|
|
395
|
+
</html>
|
|
396
|
+
`;
|
|
397
|
+
const css = `:root {
|
|
398
|
+
--bg: #f3f6fb;
|
|
399
|
+
--card: #ffffff;
|
|
400
|
+
--text: #1f2937;
|
|
401
|
+
--muted: #6b7280;
|
|
402
|
+
--primary: #0a66c2;
|
|
403
|
+
--border: #d1d5db;
|
|
404
|
+
}
|
|
405
|
+
* { box-sizing: border-box; }
|
|
406
|
+
body { margin: 0; font-family: "Segoe UI", Arial, sans-serif; background: radial-gradient(circle at top left, #ffffff, var(--bg)); color: var(--text); }
|
|
407
|
+
.app { max-width: 860px; margin: 40px auto; padding: 0 16px; }
|
|
408
|
+
.header h1 { margin: 0 0 4px; font-size: 32px; }
|
|
409
|
+
.subtitle { margin: 0 0 20px; color: var(--muted); }
|
|
410
|
+
.row, .controls { display: flex; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; }
|
|
411
|
+
input[type="text"], input[type="search"] { flex: 1; min-width: 220px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px; }
|
|
412
|
+
button { border: 1px solid transparent; border-radius: 10px; padding: 10px 14px; cursor: pointer; background: #e5e7eb; color: var(--text); }
|
|
413
|
+
button[type="submit"] { background: var(--primary); color: #fff; }
|
|
414
|
+
button.active { border-color: var(--primary); color: var(--primary); background: #e8f1fc; }
|
|
415
|
+
.status { color: var(--muted); margin: 8px 0 12px; min-height: 20px; }
|
|
416
|
+
.list { margin: 0; padding: 0; list-style: none; display: grid; gap: 10px; }
|
|
417
|
+
.note { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 12px; box-shadow: 0 2px 10px rgba(17, 24, 39, 0.05); }
|
|
418
|
+
.note-top { display: flex; justify-content: space-between; align-items: start; gap: 8px; }
|
|
419
|
+
.note-text { margin: 0; white-space: pre-wrap; word-break: break-word; }
|
|
420
|
+
.note-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
|
|
421
|
+
.btn-danger { background: #fee2e2; color: #b91c1c; }
|
|
422
|
+
.btn-subtle { background: #eef2ff; color: #3730a3; }
|
|
423
|
+
.pin { color: #92400e; background: #fef3c7; }
|
|
424
|
+
.sr-only { position: absolute; left: -10000px; width: 1px; height: 1px; overflow: hidden; }
|
|
425
|
+
@media (max-width: 640px) {
|
|
426
|
+
.header h1 { font-size: 28px; }
|
|
427
|
+
}
|
|
428
|
+
`;
|
|
429
|
+
const js = `const store = window.NotesCore.createNotesStore(window.localStorage);
|
|
430
|
+
const form = document.getElementById("note-form");
|
|
431
|
+
const input = document.getElementById("note-input");
|
|
432
|
+
const searchInput = document.getElementById("search-input");
|
|
433
|
+
const list = document.getElementById("note-list");
|
|
434
|
+
const statusText = document.getElementById("status-text");
|
|
435
|
+
const filterAll = document.getElementById("filter-all");
|
|
436
|
+
const filterPinned = document.getElementById("filter-pinned");
|
|
437
|
+
|
|
438
|
+
const state = {
|
|
439
|
+
filter: "all",
|
|
440
|
+
search: ""
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
function showStatus(message) {
|
|
444
|
+
statusText.textContent = message;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function currentNotes() {
|
|
448
|
+
let items = state.search ? store.search(state.search) : store.list();
|
|
449
|
+
if (state.filter === "pinned") {
|
|
450
|
+
items = items.filter((item) => item.pinned);
|
|
451
|
+
}
|
|
452
|
+
return items;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function render() {
|
|
456
|
+
const notes = currentNotes();
|
|
457
|
+
list.innerHTML = "";
|
|
458
|
+
if (notes.length === 0) {
|
|
459
|
+
const li = document.createElement("li");
|
|
460
|
+
li.className = "note";
|
|
461
|
+
li.textContent = "No notes yet. Add your first one.";
|
|
462
|
+
list.appendChild(li);
|
|
463
|
+
showStatus("0 notes");
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
notes.forEach((item) => {
|
|
468
|
+
const li = document.createElement("li");
|
|
469
|
+
li.className = "note";
|
|
470
|
+
|
|
471
|
+
const top = document.createElement("div");
|
|
472
|
+
top.className = "note-top";
|
|
473
|
+
const text = document.createElement("p");
|
|
474
|
+
text.className = "note-text";
|
|
475
|
+
text.textContent = item.text;
|
|
476
|
+
if (item.pinned) {
|
|
477
|
+
const badge = document.createElement("span");
|
|
478
|
+
badge.className = "pin";
|
|
479
|
+
badge.textContent = "Pinned";
|
|
480
|
+
top.appendChild(badge);
|
|
481
|
+
}
|
|
482
|
+
top.appendChild(text);
|
|
483
|
+
li.appendChild(top);
|
|
484
|
+
|
|
485
|
+
const actions = document.createElement("div");
|
|
486
|
+
actions.className = "note-actions";
|
|
487
|
+
|
|
488
|
+
const edit = document.createElement("button");
|
|
489
|
+
edit.type = "button";
|
|
490
|
+
edit.className = "btn-subtle";
|
|
491
|
+
edit.textContent = "Edit";
|
|
492
|
+
edit.addEventListener("click", () => {
|
|
493
|
+
const next = window.prompt("Edit note", item.text);
|
|
494
|
+
if (next === null) return;
|
|
495
|
+
try {
|
|
496
|
+
store.update(item.id, next);
|
|
497
|
+
render();
|
|
498
|
+
} catch (error) {
|
|
499
|
+
showStatus(error.message);
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
const pin = document.createElement("button");
|
|
504
|
+
pin.type = "button";
|
|
505
|
+
pin.className = "btn-subtle";
|
|
506
|
+
pin.textContent = item.pinned ? "Unpin" : "Pin";
|
|
507
|
+
pin.addEventListener("click", () => {
|
|
508
|
+
store.togglePin(item.id);
|
|
509
|
+
render();
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
const remove = document.createElement("button");
|
|
513
|
+
remove.type = "button";
|
|
514
|
+
remove.className = "btn-danger";
|
|
515
|
+
remove.textContent = "Delete";
|
|
516
|
+
remove.addEventListener("click", () => {
|
|
517
|
+
if (!window.confirm("Delete this note?")) return;
|
|
518
|
+
try {
|
|
519
|
+
store.remove(item.id);
|
|
520
|
+
render();
|
|
521
|
+
} catch (error) {
|
|
522
|
+
showStatus(error.message);
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
actions.appendChild(edit);
|
|
527
|
+
actions.appendChild(pin);
|
|
528
|
+
actions.appendChild(remove);
|
|
529
|
+
li.appendChild(actions);
|
|
530
|
+
list.appendChild(li);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
showStatus(notes.length + " note(s)");
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
form.addEventListener("submit", (event) => {
|
|
537
|
+
event.preventDefault();
|
|
538
|
+
try {
|
|
539
|
+
store.add(input.value);
|
|
540
|
+
input.value = "";
|
|
541
|
+
input.focus();
|
|
542
|
+
render();
|
|
543
|
+
} catch (error) {
|
|
544
|
+
showStatus(error.message);
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
searchInput.addEventListener("input", () => {
|
|
549
|
+
state.search = searchInput.value.trim();
|
|
550
|
+
render();
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
filterAll.addEventListener("click", () => {
|
|
554
|
+
state.filter = "all";
|
|
555
|
+
filterAll.classList.add("active");
|
|
556
|
+
filterPinned.classList.remove("active");
|
|
557
|
+
render();
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
filterPinned.addEventListener("click", () => {
|
|
561
|
+
state.filter = "pinned";
|
|
562
|
+
filterPinned.classList.add("active");
|
|
563
|
+
filterAll.classList.remove("active");
|
|
564
|
+
render();
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
render();
|
|
568
|
+
`;
|
|
569
|
+
return [
|
|
570
|
+
{ path: "package.json", content: commonPackageJson(projectName) },
|
|
571
|
+
{ path: "README.md", content: readme },
|
|
572
|
+
{ path: "core.js", content: core },
|
|
573
|
+
{ path: "core.test.js", content: tests },
|
|
574
|
+
{ path: "index.html", content: html },
|
|
575
|
+
{ path: "styles.css", content: css },
|
|
576
|
+
{ path: "app.js", content: js }
|
|
577
|
+
];
|
|
578
|
+
}
|
|
579
|
+
function userNewsBaselineFiles(projectName) {
|
|
580
|
+
const readme = `# ${projectName} - User Management and News
|
|
581
|
+
|
|
582
|
+
Web app for managing users and publishing internal news updates.
|
|
583
|
+
|
|
584
|
+
## Features
|
|
585
|
+
- Create, activate/deactivate, and remove users
|
|
586
|
+
- Publish news updates linked to an author user
|
|
587
|
+
- Filter news by author and status
|
|
588
|
+
- Persistent local storage
|
|
589
|
+
- Unit-tested domain logic
|
|
590
|
+
|
|
591
|
+
## Run
|
|
592
|
+
1. Open \`index.html\` in your browser.
|
|
593
|
+
2. Manage users in the first section and post updates in the second section.
|
|
594
|
+
|
|
595
|
+
## Test
|
|
596
|
+
- Run \`npm test\`
|
|
597
|
+
`;
|
|
598
|
+
const core = `(function (globalScope) {
|
|
599
|
+
function normalize(value) {
|
|
600
|
+
return String(value || "").trim().replace(/\\s+/g, " ");
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function createManagementStore(storage, key) {
|
|
604
|
+
const storageKey = key || "sdd-user-news-v1";
|
|
605
|
+
function emptyState() {
|
|
606
|
+
return { users: [], news: [] };
|
|
607
|
+
}
|
|
608
|
+
function load() {
|
|
609
|
+
const raw = storage.getItem(storageKey);
|
|
610
|
+
if (!raw) return emptyState();
|
|
611
|
+
try {
|
|
612
|
+
const parsed = JSON.parse(raw);
|
|
613
|
+
return {
|
|
614
|
+
users: Array.isArray(parsed.users) ? parsed.users : [],
|
|
615
|
+
news: Array.isArray(parsed.news) ? parsed.news : []
|
|
616
|
+
};
|
|
617
|
+
} catch {
|
|
618
|
+
return emptyState();
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
function save(state) {
|
|
622
|
+
storage.setItem(storageKey, JSON.stringify(state));
|
|
623
|
+
return state;
|
|
624
|
+
}
|
|
625
|
+
function requireUser(state, userId) {
|
|
626
|
+
const user = state.users.find((u) => String(u.id) === String(userId));
|
|
627
|
+
if (!user) throw new Error("User not found");
|
|
628
|
+
return user;
|
|
629
|
+
}
|
|
630
|
+
return {
|
|
631
|
+
listUsers() {
|
|
632
|
+
return load().users.slice().sort((a, b) => String(a.name).localeCompare(String(b.name)));
|
|
633
|
+
},
|
|
634
|
+
addUser(name, email) {
|
|
635
|
+
const cleanName = normalize(name);
|
|
636
|
+
const cleanEmail = normalize(email).toLowerCase();
|
|
637
|
+
if (!cleanName) throw new Error("User name is required");
|
|
638
|
+
if (!/^\\S+@\\S+\\.\\S+$/.test(cleanEmail)) throw new Error("Valid email is required");
|
|
639
|
+
const state = load();
|
|
640
|
+
if (state.users.some((u) => String(u.email).toLowerCase() === cleanEmail)) {
|
|
641
|
+
throw new Error("User email must be unique");
|
|
642
|
+
}
|
|
643
|
+
const user = { id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6), name: cleanName, email: cleanEmail, active: true };
|
|
644
|
+
state.users.push(user);
|
|
645
|
+
save(state);
|
|
646
|
+
return user;
|
|
647
|
+
},
|
|
648
|
+
setUserActive(userId, active) {
|
|
649
|
+
const state = load();
|
|
650
|
+
const user = requireUser(state, userId);
|
|
651
|
+
user.active = Boolean(active);
|
|
652
|
+
save(state);
|
|
653
|
+
return user;
|
|
654
|
+
},
|
|
655
|
+
removeUser(userId) {
|
|
656
|
+
const state = load();
|
|
657
|
+
const before = state.users.length;
|
|
658
|
+
state.users = state.users.filter((u) => String(u.id) !== String(userId));
|
|
659
|
+
if (state.users.length === before) throw new Error("User not found");
|
|
660
|
+
state.news = state.news.filter((n) => String(n.authorId) !== String(userId));
|
|
661
|
+
save(state);
|
|
662
|
+
return true;
|
|
663
|
+
},
|
|
664
|
+
listNews(filter) {
|
|
665
|
+
const state = load();
|
|
666
|
+
let items = state.news.slice();
|
|
667
|
+
if (filter && filter.authorId) {
|
|
668
|
+
items = items.filter((n) => String(n.authorId) === String(filter.authorId));
|
|
669
|
+
}
|
|
670
|
+
return items.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
|
|
671
|
+
},
|
|
672
|
+
addNews(title, content, authorId) {
|
|
673
|
+
const cleanTitle = normalize(title);
|
|
674
|
+
const cleanContent = normalize(content);
|
|
675
|
+
if (!cleanTitle) throw new Error("News title is required");
|
|
676
|
+
if (cleanTitle.length > 120) throw new Error("News title too long");
|
|
677
|
+
if (!cleanContent) throw new Error("News content is required");
|
|
678
|
+
const state = load();
|
|
679
|
+
const author = requireUser(state, authorId);
|
|
680
|
+
if (!author.active) throw new Error("Author user is inactive");
|
|
681
|
+
const entry = {
|
|
682
|
+
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
|
|
683
|
+
title: cleanTitle,
|
|
684
|
+
content: cleanContent,
|
|
685
|
+
authorId: author.id,
|
|
686
|
+
authorName: author.name,
|
|
687
|
+
createdAt: new Date().toISOString()
|
|
688
|
+
};
|
|
689
|
+
state.news.push(entry);
|
|
690
|
+
save(state);
|
|
691
|
+
return entry;
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const api = { createManagementStore };
|
|
697
|
+
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
|
698
|
+
globalScope.ManagementCore = api;
|
|
699
|
+
})(typeof window !== "undefined" ? window : globalThis);
|
|
700
|
+
`;
|
|
701
|
+
const tests = `const test = require("node:test");
|
|
702
|
+
const assert = require("node:assert/strict");
|
|
703
|
+
const { createManagementStore } = require("./core");
|
|
704
|
+
|
|
705
|
+
function memoryStorage() {
|
|
706
|
+
const map = new Map();
|
|
707
|
+
return { getItem: (k) => map.get(k) ?? null, setItem: (k, v) => map.set(k, String(v)) };
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
test("addUser creates active user", () => {
|
|
711
|
+
const store = createManagementStore(memoryStorage());
|
|
712
|
+
const user = store.addUser("Alice", "alice@example.com");
|
|
713
|
+
assert.equal(user.active, true);
|
|
714
|
+
assert.equal(store.listUsers().length, 1);
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
test("addUser enforces unique email", () => {
|
|
718
|
+
const store = createManagementStore(memoryStorage());
|
|
719
|
+
store.addUser("Alice", "alice@example.com");
|
|
720
|
+
assert.throws(() => store.addUser("Alice 2", "alice@example.com"), /unique/);
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
test("setUserActive updates status", () => {
|
|
724
|
+
const store = createManagementStore(memoryStorage());
|
|
725
|
+
const user = store.addUser("Bob", "bob@example.com");
|
|
726
|
+
store.setUserActive(user.id, false);
|
|
727
|
+
assert.equal(store.listUsers()[0].active, false);
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
test("addNews requires active author", () => {
|
|
731
|
+
const store = createManagementStore(memoryStorage());
|
|
732
|
+
const user = store.addUser("Nina", "nina@example.com");
|
|
733
|
+
store.setUserActive(user.id, false);
|
|
734
|
+
assert.throws(() => store.addNews("Update", "Hello", user.id), /inactive/);
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
test("addNews creates entry for active user", () => {
|
|
738
|
+
const store = createManagementStore(memoryStorage());
|
|
739
|
+
const user = store.addUser("Leo", "leo@example.com");
|
|
740
|
+
const news = store.addNews("Launch", "System ready", user.id);
|
|
741
|
+
assert.equal(news.authorName, "Leo");
|
|
742
|
+
assert.equal(store.listNews({}).length, 1);
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
test("listNews filters by author", () => {
|
|
746
|
+
const store = createManagementStore(memoryStorage());
|
|
747
|
+
const a = store.addUser("A", "a@example.com");
|
|
748
|
+
const b = store.addUser("B", "b@example.com");
|
|
749
|
+
store.addNews("N1", "x", a.id);
|
|
750
|
+
store.addNews("N2", "y", b.id);
|
|
751
|
+
const onlyA = store.listNews({ authorId: a.id });
|
|
752
|
+
assert.equal(onlyA.length, 1);
|
|
753
|
+
assert.equal(onlyA[0].authorId, a.id);
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
test("removeUser removes dependent news", () => {
|
|
757
|
+
const store = createManagementStore(memoryStorage());
|
|
758
|
+
const user = store.addUser("C", "c@example.com");
|
|
759
|
+
store.addNews("N1", "text", user.id);
|
|
760
|
+
store.removeUser(user.id);
|
|
761
|
+
assert.equal(store.listUsers().length, 0);
|
|
762
|
+
assert.equal(store.listNews({}).length, 0);
|
|
763
|
+
});
|
|
764
|
+
`;
|
|
765
|
+
const html = `<!doctype html>
|
|
766
|
+
<html lang="en">
|
|
767
|
+
<head>
|
|
768
|
+
<meta charset="UTF-8" />
|
|
769
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
770
|
+
<title>User and News Management</title>
|
|
771
|
+
<link rel="stylesheet" href="styles.css" />
|
|
772
|
+
</head>
|
|
773
|
+
<body>
|
|
774
|
+
<main class="app">
|
|
775
|
+
<h1>User Management and News</h1>
|
|
776
|
+
<p id="status" aria-live="polite"></p>
|
|
777
|
+
|
|
778
|
+
<section class="card">
|
|
779
|
+
<h2>Users</h2>
|
|
780
|
+
<form id="user-form" class="row">
|
|
781
|
+
<input id="user-name" placeholder="Name" required />
|
|
782
|
+
<input id="user-email" type="email" placeholder="Email" required />
|
|
783
|
+
<button type="submit">Add User</button>
|
|
784
|
+
</form>
|
|
785
|
+
<ul id="user-list"></ul>
|
|
786
|
+
</section>
|
|
787
|
+
|
|
788
|
+
<section class="card">
|
|
789
|
+
<h2>News</h2>
|
|
790
|
+
<form id="news-form" class="column">
|
|
791
|
+
<input id="news-title" placeholder="Title" maxlength="120" required />
|
|
792
|
+
<textarea id="news-content" placeholder="Content" rows="3" required></textarea>
|
|
793
|
+
<select id="news-author"></select>
|
|
794
|
+
<button type="submit">Publish News</button>
|
|
795
|
+
</form>
|
|
796
|
+
<ul id="news-list"></ul>
|
|
797
|
+
</section>
|
|
798
|
+
</main>
|
|
799
|
+
<script src="core.js"></script>
|
|
800
|
+
<script src="app.js"></script>
|
|
801
|
+
</body>
|
|
802
|
+
</html>
|
|
803
|
+
`;
|
|
804
|
+
const css = `body { margin:0; font-family:"Segoe UI", Arial, sans-serif; background:#f4f6fb; color:#1f2937; }
|
|
805
|
+
.app { max-width:960px; margin:28px auto; padding:0 16px 24px; }
|
|
806
|
+
.card { background:#fff; border:1px solid #d8dce6; border-radius:12px; padding:14px; margin-bottom:14px; box-shadow:0 3px 12px rgba(0,0,0,.04); }
|
|
807
|
+
.row { display:flex; gap:8px; flex-wrap:wrap; }
|
|
808
|
+
.column { display:flex; flex-direction:column; gap:8px; }
|
|
809
|
+
input, textarea, select, button { font:inherit; }
|
|
810
|
+
input, textarea, select { width:100%; padding:10px; border:1px solid #c7cdd9; border-radius:8px; }
|
|
811
|
+
button { padding:10px 12px; border:0; border-radius:8px; background:#0a66c2; color:#fff; cursor:pointer; }
|
|
812
|
+
ul { list-style:none; padding:0; margin:10px 0 0; }
|
|
813
|
+
li { border:1px solid #d8dce6; border-radius:8px; padding:10px; margin-bottom:8px; background:#fff; }
|
|
814
|
+
.meta { color:#6b7280; font-size:13px; }
|
|
815
|
+
.actions { display:flex; gap:8px; margin-top:6px; }
|
|
816
|
+
.muted { color:#6b7280; }
|
|
817
|
+
`;
|
|
818
|
+
const js = `const store = window.ManagementCore.createManagementStore(window.localStorage);
|
|
819
|
+
const statusEl = document.getElementById("status");
|
|
820
|
+
const userForm = document.getElementById("user-form");
|
|
821
|
+
const userName = document.getElementById("user-name");
|
|
822
|
+
const userEmail = document.getElementById("user-email");
|
|
823
|
+
const userList = document.getElementById("user-list");
|
|
824
|
+
const newsForm = document.getElementById("news-form");
|
|
825
|
+
const newsTitle = document.getElementById("news-title");
|
|
826
|
+
const newsContent = document.getElementById("news-content");
|
|
827
|
+
const newsAuthor = document.getElementById("news-author");
|
|
828
|
+
const newsList = document.getElementById("news-list");
|
|
829
|
+
|
|
830
|
+
function status(message) { statusEl.textContent = message; }
|
|
831
|
+
|
|
832
|
+
function renderUsers() {
|
|
833
|
+
const users = store.listUsers();
|
|
834
|
+
userList.innerHTML = "";
|
|
835
|
+
newsAuthor.innerHTML = "";
|
|
836
|
+
if (!users.length) {
|
|
837
|
+
const li = document.createElement("li");
|
|
838
|
+
li.className = "muted";
|
|
839
|
+
li.textContent = "No users yet";
|
|
840
|
+
userList.appendChild(li);
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
users.forEach((u) => {
|
|
844
|
+
const li = document.createElement("li");
|
|
845
|
+
li.innerHTML = "<strong>" + u.name + "</strong> <span class=\\"meta\\">" + u.email + " | " + (u.active ? "active" : "inactive") + "</span>";
|
|
846
|
+
const actions = document.createElement("div");
|
|
847
|
+
actions.className = "actions";
|
|
848
|
+
const toggle = document.createElement("button");
|
|
849
|
+
toggle.type = "button";
|
|
850
|
+
toggle.textContent = u.active ? "Deactivate" : "Activate";
|
|
851
|
+
toggle.addEventListener("click", () => { store.setUserActive(u.id, !u.active); render(); });
|
|
852
|
+
const remove = document.createElement("button");
|
|
853
|
+
remove.type = "button";
|
|
854
|
+
remove.textContent = "Remove";
|
|
855
|
+
remove.addEventListener("click", () => { store.removeUser(u.id); render(); });
|
|
856
|
+
actions.appendChild(toggle);
|
|
857
|
+
actions.appendChild(remove);
|
|
858
|
+
li.appendChild(actions);
|
|
859
|
+
userList.appendChild(li);
|
|
860
|
+
|
|
861
|
+
if (u.active) {
|
|
862
|
+
const opt = document.createElement("option");
|
|
863
|
+
opt.value = u.id;
|
|
864
|
+
opt.textContent = u.name + " (" + u.email + ")";
|
|
865
|
+
newsAuthor.appendChild(opt);
|
|
866
|
+
}
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function renderNews() {
|
|
871
|
+
const items = store.listNews({});
|
|
872
|
+
newsList.innerHTML = "";
|
|
873
|
+
if (!items.length) {
|
|
874
|
+
const li = document.createElement("li");
|
|
875
|
+
li.className = "muted";
|
|
876
|
+
li.textContent = "No news published yet";
|
|
877
|
+
newsList.appendChild(li);
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
items.forEach((n) => {
|
|
881
|
+
const li = document.createElement("li");
|
|
882
|
+
li.innerHTML = "<strong>" + n.title + "</strong><div>" + n.content + "</div><div class=\\"meta\\">By " + n.authorName + " at " + n.createdAt + "</div>";
|
|
883
|
+
newsList.appendChild(li);
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function render() {
|
|
888
|
+
renderUsers();
|
|
889
|
+
renderNews();
|
|
890
|
+
status("Users: " + store.listUsers().length + " | News: " + store.listNews({}).length);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
userForm.addEventListener("submit", (e) => {
|
|
894
|
+
e.preventDefault();
|
|
895
|
+
try {
|
|
896
|
+
store.addUser(userName.value, userEmail.value);
|
|
897
|
+
userName.value = "";
|
|
898
|
+
userEmail.value = "";
|
|
899
|
+
render();
|
|
900
|
+
} catch (err) {
|
|
901
|
+
status(err.message);
|
|
902
|
+
}
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
newsForm.addEventListener("submit", (e) => {
|
|
906
|
+
e.preventDefault();
|
|
907
|
+
try {
|
|
908
|
+
store.addNews(newsTitle.value, newsContent.value, newsAuthor.value);
|
|
909
|
+
newsTitle.value = "";
|
|
910
|
+
newsContent.value = "";
|
|
911
|
+
render();
|
|
912
|
+
} catch (err) {
|
|
913
|
+
status(err.message);
|
|
914
|
+
}
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
render();
|
|
918
|
+
`;
|
|
919
|
+
return [
|
|
920
|
+
{ path: "package.json", content: commonPackageJson(projectName) },
|
|
921
|
+
{ path: "README.md", content: readme },
|
|
922
|
+
{ path: "core.js", content: core },
|
|
923
|
+
{ path: "core.test.js", content: tests },
|
|
924
|
+
{ path: "index.html", content: html },
|
|
925
|
+
{ path: "styles.css", content: css },
|
|
926
|
+
{ path: "app.js", content: js }
|
|
927
|
+
];
|
|
928
|
+
}
|
|
929
|
+
function genericBaselineFiles(projectName) {
|
|
930
|
+
const readme = `# ${projectName} - Starter App
|
|
931
|
+
|
|
932
|
+
Features:
|
|
933
|
+
- Item capture and listing
|
|
934
|
+
- Local persistence
|
|
935
|
+
- Tested core module
|
|
936
|
+
|
|
937
|
+
Run:
|
|
938
|
+
- Open \`index.html\` in a browser
|
|
939
|
+
- Run tests with \`npm test\`
|
|
940
|
+
`;
|
|
941
|
+
const core = `(function (globalScope) {
|
|
942
|
+
function createStore(storage, key) {
|
|
943
|
+
const read = () => {
|
|
944
|
+
const raw = storage.getItem(key);
|
|
945
|
+
if (!raw) return [];
|
|
946
|
+
try { return JSON.parse(raw); } catch { return []; }
|
|
947
|
+
};
|
|
948
|
+
const write = (items) => storage.setItem(key, JSON.stringify(items));
|
|
949
|
+
return {
|
|
950
|
+
list() { return read(); },
|
|
951
|
+
add(text) {
|
|
952
|
+
const value = String(text || "").trim();
|
|
953
|
+
if (!value) throw new Error("Text is required");
|
|
954
|
+
const next = [...read(), { id: Date.now(), text: value }];
|
|
955
|
+
write(next);
|
|
956
|
+
return next;
|
|
957
|
+
}
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
const api = { createStore };
|
|
961
|
+
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
|
962
|
+
globalScope.AppCore = api;
|
|
963
|
+
})(typeof window !== "undefined" ? window : globalThis);
|
|
964
|
+
`;
|
|
965
|
+
const tests = `const test = require("node:test");
|
|
966
|
+
const assert = require("node:assert/strict");
|
|
967
|
+
const { createStore } = require("./core");
|
|
968
|
+
|
|
969
|
+
function memoryStorage() {
|
|
970
|
+
const data = new Map();
|
|
971
|
+
return { getItem: (k) => data.get(k) ?? null, setItem: (k, v) => data.set(k, String(v)) };
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
test("store adds items", () => {
|
|
975
|
+
const store = createStore(memoryStorage(), "items");
|
|
976
|
+
store.add("First");
|
|
977
|
+
assert.equal(store.list().length, 1);
|
|
978
|
+
});
|
|
979
|
+
`;
|
|
980
|
+
const html = `<!doctype html>
|
|
981
|
+
<html lang="en">
|
|
982
|
+
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>Starter App</title><link rel="stylesheet" href="styles.css" /></head>
|
|
983
|
+
<body>
|
|
984
|
+
<main class="app">
|
|
985
|
+
<h1>Starter App</h1>
|
|
986
|
+
<form id="item-form" class="row"><input id="item-input" type="text" placeholder="Add item" /><button type="submit">Add</button></form>
|
|
987
|
+
<ul id="item-list"></ul>
|
|
988
|
+
</main>
|
|
989
|
+
<script src="core.js"></script><script src="app.js"></script>
|
|
990
|
+
</body>
|
|
991
|
+
</html>
|
|
992
|
+
`;
|
|
993
|
+
const css = `body { font-family: Segoe UI, Arial, sans-serif; margin: 0; background: #f4f6fb; } .app { max-width: 720px; margin: 32px auto; padding: 0 16px; } .row { display:flex; gap:8px; } input { flex:1; padding:10px; } button { padding:10px 14px; }`;
|
|
994
|
+
const js = `const store = window.AppCore.createStore(window.localStorage, "starter-items");
|
|
995
|
+
const form = document.getElementById("item-form");
|
|
996
|
+
const input = document.getElementById("item-input");
|
|
997
|
+
const list = document.getElementById("item-list");
|
|
998
|
+
function render(){ list.innerHTML = ""; store.list().forEach((item) => { const li = document.createElement("li"); li.textContent = item.text; list.appendChild(li); }); }
|
|
999
|
+
form.addEventListener("submit", (event) => { event.preventDefault(); try { store.add(input.value); input.value = ""; render(); } catch {} });
|
|
1000
|
+
render();
|
|
1001
|
+
`;
|
|
1002
|
+
return [
|
|
1003
|
+
{ path: "package.json", content: commonPackageJson(projectName) },
|
|
1004
|
+
{ path: "README.md", content: readme },
|
|
1005
|
+
{ path: "core.js", content: core },
|
|
1006
|
+
{ path: "core.test.js", content: tests },
|
|
1007
|
+
{ path: "index.html", content: html },
|
|
1008
|
+
{ path: "styles.css", content: css },
|
|
1009
|
+
{ path: "app.js", content: js }
|
|
1010
|
+
];
|
|
1011
|
+
}
|
|
1012
|
+
function fallbackAppFiles(projectName, intent) {
|
|
1013
|
+
return detectBaselineKind(intent) === "notes" ? notesBaselineFiles(projectName) : genericBaselineFiles(projectName);
|
|
1014
|
+
}
|
|
1015
|
+
function resetToFunctionalBaseline(appDir, projectName, intent) {
|
|
1016
|
+
const files = fallbackAppFiles(projectName, intent);
|
|
1017
|
+
for (const file of files) {
|
|
1018
|
+
const full = path_1.default.join(appDir, file.path);
|
|
1019
|
+
fs_1.default.mkdirSync(path_1.default.dirname(full), { recursive: true });
|
|
1020
|
+
fs_1.default.writeFileSync(full, file.content, "utf-8");
|
|
1021
|
+
}
|
|
1022
|
+
return files.length;
|
|
1023
|
+
}
|
|
1024
|
+
function ensureQualityBaseline(files, _projectName, _intent) {
|
|
1025
|
+
return files;
|
|
1026
|
+
}
|
|
1027
|
+
function enrichDraftWithAI(input, flow, domain, baseDraft, providerRequested) {
|
|
1028
|
+
if (process.env.SDD_DISABLE_AI_AUTOPILOT === "1") {
|
|
1029
|
+
return baseDraft;
|
|
1030
|
+
}
|
|
1031
|
+
const resolution = (0, providers_1.resolveProvider)(providerRequested);
|
|
1032
|
+
if (!resolution.ok) {
|
|
1033
|
+
return baseDraft;
|
|
1034
|
+
}
|
|
1035
|
+
const prompt = [
|
|
1036
|
+
"You are an SDD requirements assistant.",
|
|
1037
|
+
"Return ONLY valid JSON with keys:",
|
|
1038
|
+
"objective, actors, scope_in, scope_out, acceptance_criteria, nfr_security, nfr_performance, nfr_availability, constraints, risks.",
|
|
1039
|
+
"No markdown. No explanation.",
|
|
1040
|
+
`Intent: ${input}`,
|
|
1041
|
+
`Flow: ${flow}`,
|
|
1042
|
+
`Domain: ${domain}`
|
|
1043
|
+
].join("\n");
|
|
1044
|
+
const parsed = askProviderForJson(resolution.provider.exec, prompt);
|
|
1045
|
+
if (!parsed) {
|
|
1046
|
+
return baseDraft;
|
|
1047
|
+
}
|
|
1048
|
+
return {
|
|
1049
|
+
...baseDraft,
|
|
1050
|
+
objective: asText(parsed.objective, baseDraft.objective ?? ""),
|
|
1051
|
+
actors: asText(parsed.actors, baseDraft.actors ?? ""),
|
|
1052
|
+
scope_in: asText(parsed.scope_in, baseDraft.scope_in ?? ""),
|
|
1053
|
+
scope_out: asText(parsed.scope_out, baseDraft.scope_out ?? ""),
|
|
1054
|
+
acceptance_criteria: asText(parsed.acceptance_criteria, baseDraft.acceptance_criteria ?? ""),
|
|
1055
|
+
nfr_security: asText(parsed.nfr_security, baseDraft.nfr_security ?? ""),
|
|
1056
|
+
nfr_performance: asText(parsed.nfr_performance, baseDraft.nfr_performance ?? ""),
|
|
1057
|
+
nfr_availability: asText(parsed.nfr_availability, baseDraft.nfr_availability ?? ""),
|
|
1058
|
+
constraints: asText(parsed.constraints, baseDraft.constraints ?? ""),
|
|
1059
|
+
risks: asText(parsed.risks, baseDraft.risks ?? "")
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
function templateFallbackAllowed() {
|
|
1063
|
+
return process.env.SDD_ALLOW_TEMPLATE_FALLBACK === "1" || process.env.SDD_DISABLE_AI_AUTOPILOT === "1";
|
|
1064
|
+
}
|
|
1065
|
+
function bootstrapProjectCode(projectRoot, projectName, intent, providerRequested) {
|
|
1066
|
+
const outputDir = path_1.default.join(projectRoot, "generated-app");
|
|
1067
|
+
fs_1.default.mkdirSync(outputDir, { recursive: true });
|
|
1068
|
+
if (process.env.SDD_DISABLE_AI_AUTOPILOT === "1") {
|
|
1069
|
+
if (templateFallbackAllowed()) {
|
|
1070
|
+
const files = fallbackAppFiles(projectName, intent);
|
|
1071
|
+
for (const file of files) {
|
|
1072
|
+
const destination = path_1.default.join(outputDir, file.path);
|
|
1073
|
+
fs_1.default.mkdirSync(path_1.default.dirname(destination), { recursive: true });
|
|
1074
|
+
fs_1.default.writeFileSync(destination, file.content, "utf-8");
|
|
1075
|
+
}
|
|
1076
|
+
return {
|
|
1077
|
+
attempted: false,
|
|
1078
|
+
generated: true,
|
|
1079
|
+
outputDir,
|
|
1080
|
+
fileCount: files.length,
|
|
1081
|
+
reason: "disabled by env, template fallback generated"
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
return { attempted: false, generated: false, outputDir, fileCount: 0, reason: "disabled by env" };
|
|
1085
|
+
}
|
|
1086
|
+
const resolution = (0, providers_1.resolveProvider)(providerRequested);
|
|
1087
|
+
let files = [];
|
|
1088
|
+
let fallbackReason;
|
|
1089
|
+
if (resolution.ok) {
|
|
1090
|
+
const prompt = [
|
|
1091
|
+
"Generate a production-lean starter app from user intent.",
|
|
1092
|
+
"The project must be executable fully in local development.",
|
|
1093
|
+
"Use DummyLocal adapters for integrations (databases, external APIs, queues) so everything runs locally.",
|
|
1094
|
+
"Add a schema document named schemas.md with entities, fields, relations, and constraints.",
|
|
1095
|
+
"Add regression tests and regression notes/documentation.",
|
|
1096
|
+
"Do not mix unrelated runtime stacks unless the intent explicitly requests a multi-tier architecture.",
|
|
1097
|
+
"Return ONLY valid JSON with this shape:",
|
|
1098
|
+
'{"files":[{"path":"relative/path","content":"file content"}],"run_command":"...","deploy_steps":["..."],"publish_steps":["..."]}',
|
|
1099
|
+
"Use only relative file paths. Keep files concise and runnable.",
|
|
1100
|
+
`Project: ${projectName}`,
|
|
1101
|
+
`Intent: ${intent}`
|
|
1102
|
+
].join("\n");
|
|
1103
|
+
const parsed = askProviderForJson(resolution.provider.exec, prompt);
|
|
1104
|
+
if (parsed) {
|
|
1105
|
+
const rawFiles = Array.isArray(parsed.files) ? parsed.files : [];
|
|
1106
|
+
for (const item of rawFiles) {
|
|
1107
|
+
if (!item || typeof item !== "object") {
|
|
1108
|
+
continue;
|
|
1109
|
+
}
|
|
1110
|
+
const maybePath = item.path;
|
|
1111
|
+
const maybeContent = item.content;
|
|
1112
|
+
if (typeof maybePath !== "string" || typeof maybeContent !== "string") {
|
|
1113
|
+
continue;
|
|
1114
|
+
}
|
|
1115
|
+
const rel = safeRelativePath(maybePath);
|
|
1116
|
+
if (!rel) {
|
|
1117
|
+
continue;
|
|
1118
|
+
}
|
|
1119
|
+
files.push({ path: rel, content: maybeContent });
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
if (files.length === 0) {
|
|
1123
|
+
const fallbackPrompt = [
|
|
1124
|
+
"Return ONLY valid JSON. No markdown.",
|
|
1125
|
+
"Schema: {\"files\":[{\"path\":\"relative/path\",\"content\":\"...\"}]}",
|
|
1126
|
+
"Generate only essential starter files to run locally with quality-first defaults.",
|
|
1127
|
+
"Must include: README.md, schemas.md, regression notes, and DummyLocal integration docs.",
|
|
1128
|
+
`Project: ${projectName}`,
|
|
1129
|
+
`Intent: ${intent}`
|
|
1130
|
+
].join("\n");
|
|
1131
|
+
const parsedFallback = askProviderForJson(resolution.provider.exec, fallbackPrompt);
|
|
1132
|
+
if (parsedFallback) {
|
|
1133
|
+
const rawFiles = Array.isArray(parsedFallback.files) ? parsedFallback.files : [];
|
|
1134
|
+
for (const item of rawFiles) {
|
|
1135
|
+
if (!item || typeof item !== "object") {
|
|
1136
|
+
continue;
|
|
1137
|
+
}
|
|
1138
|
+
const maybePath = item.path;
|
|
1139
|
+
const maybeContent = item.content;
|
|
1140
|
+
if (typeof maybePath !== "string" || typeof maybeContent !== "string") {
|
|
1141
|
+
continue;
|
|
1142
|
+
}
|
|
1143
|
+
const rel = safeRelativePath(maybePath);
|
|
1144
|
+
if (!rel) {
|
|
1145
|
+
continue;
|
|
1146
|
+
}
|
|
1147
|
+
files.push({ path: rel, content: maybeContent });
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
if (files.length === 0) {
|
|
1153
|
+
const fallbackAllowed = templateFallbackAllowed();
|
|
1154
|
+
if (fallbackAllowed) {
|
|
1155
|
+
fallbackReason = resolution.ok ? "provider response unusable, template fallback generated" : "provider unavailable, template fallback generated";
|
|
1156
|
+
files = fallbackAppFiles(projectName, intent);
|
|
1157
|
+
}
|
|
1158
|
+
else {
|
|
1159
|
+
return {
|
|
1160
|
+
attempted: resolution.ok,
|
|
1161
|
+
provider: resolution.ok ? resolution.provider.id : undefined,
|
|
1162
|
+
generated: false,
|
|
1163
|
+
outputDir,
|
|
1164
|
+
fileCount: 0,
|
|
1165
|
+
reason: resolution.ok ? "provider response unusable" : "provider unavailable"
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
files = ensureQualityBaseline(files, projectName, intent);
|
|
1170
|
+
const unique = new Map();
|
|
1171
|
+
for (const file of files) {
|
|
1172
|
+
unique.set(file.path, file.content);
|
|
1173
|
+
}
|
|
1174
|
+
for (const [rel, content] of unique.entries()) {
|
|
1175
|
+
const destination = path_1.default.join(outputDir, rel);
|
|
1176
|
+
fs_1.default.mkdirSync(path_1.default.dirname(destination), { recursive: true });
|
|
1177
|
+
fs_1.default.writeFileSync(destination, content, "utf-8");
|
|
1178
|
+
}
|
|
1179
|
+
return {
|
|
1180
|
+
attempted: resolution.ok,
|
|
1181
|
+
provider: resolution.ok ? resolution.provider.id : undefined,
|
|
1182
|
+
generated: true,
|
|
1183
|
+
outputDir,
|
|
1184
|
+
fileCount: unique.size,
|
|
1185
|
+
reason: fallbackReason
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
function compactFilesForPrompt(files) {
|
|
1189
|
+
const maxFiles = 12;
|
|
1190
|
+
const maxChars = 700;
|
|
1191
|
+
return files.slice(0, maxFiles).map((file) => ({
|
|
1192
|
+
path: file.path,
|
|
1193
|
+
content: file.content.length > maxChars ? `${file.content.slice(0, maxChars)}\n/* ...truncated... */` : file.content
|
|
1194
|
+
}));
|
|
1195
|
+
}
|
|
1196
|
+
function collectProjectFiles(appDir) {
|
|
1197
|
+
const output = [];
|
|
1198
|
+
const walk = (dir) => {
|
|
1199
|
+
const entries = fs_1.default.readdirSync(dir, { withFileTypes: true });
|
|
1200
|
+
for (const entry of entries) {
|
|
1201
|
+
const full = path_1.default.join(dir, entry.name);
|
|
1202
|
+
const rel = path_1.default.relative(appDir, full).replace(/\\/g, "/");
|
|
1203
|
+
if (entry.isDirectory()) {
|
|
1204
|
+
if (rel === "node_modules" || rel.startsWith("node_modules/") || rel === ".git" || rel.startsWith(".git/")) {
|
|
1205
|
+
continue;
|
|
1206
|
+
}
|
|
1207
|
+
walk(full);
|
|
1208
|
+
}
|
|
1209
|
+
else {
|
|
1210
|
+
const ext = path_1.default.extname(entry.name).toLowerCase();
|
|
1211
|
+
if (![".js", ".ts", ".json", ".md", ".html", ".css", ".py", ".java", ".xml", ".yml", ".yaml", ".jsx", ".tsx", ".sql", ".properties"].includes(ext)) {
|
|
1212
|
+
continue;
|
|
1213
|
+
}
|
|
1214
|
+
const content = fs_1.default.readFileSync(full, "utf-8");
|
|
1215
|
+
output.push({ path: rel, content });
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
};
|
|
1219
|
+
walk(appDir);
|
|
1220
|
+
return output;
|
|
1221
|
+
}
|
|
1222
|
+
function improveGeneratedApp(appDir, intent, providerRequested, qualityDiagnostics) {
|
|
1223
|
+
if (process.env.SDD_DISABLE_AI_AUTOPILOT === "1") {
|
|
1224
|
+
return { attempted: false, applied: false, fileCount: 0, reason: "disabled by env" };
|
|
1225
|
+
}
|
|
1226
|
+
if (!fs_1.default.existsSync(appDir)) {
|
|
1227
|
+
return { attempted: false, applied: false, fileCount: 0, reason: "app directory missing" };
|
|
1228
|
+
}
|
|
1229
|
+
const resolution = (0, providers_1.resolveProvider)(providerRequested);
|
|
1230
|
+
if (!resolution.ok) {
|
|
1231
|
+
return { attempted: false, applied: false, fileCount: 0, reason: "provider unavailable" };
|
|
1232
|
+
}
|
|
1233
|
+
const currentFiles = compactFilesForPrompt(collectProjectFiles(appDir));
|
|
1234
|
+
const prompt = [
|
|
1235
|
+
"Improve this generated app to production-lean quality.",
|
|
1236
|
+
"Requirements:",
|
|
1237
|
+
"- Keep app intent and behavior.",
|
|
1238
|
+
"- Ensure tests pass for the selected stack.",
|
|
1239
|
+
"- Ensure code is clear and maintainable.",
|
|
1240
|
+
"- Ensure schemas.md exists and documents data schemas.",
|
|
1241
|
+
"- Ensure DummyLocal integration exists and is documented.",
|
|
1242
|
+
"- Ensure regression tests (or explicit regression test documentation) exists.",
|
|
1243
|
+
"- Fix every listed quality diagnostic failure.",
|
|
1244
|
+
"Return ONLY JSON with shape:",
|
|
1245
|
+
'{"files":[{"path":"relative/path","content":"full file content"}]}',
|
|
1246
|
+
`Intent: ${intent}`,
|
|
1247
|
+
`Quality diagnostics: ${JSON.stringify(qualityDiagnostics ?? [])}`,
|
|
1248
|
+
`Current files JSON: ${JSON.stringify(currentFiles)}`
|
|
1249
|
+
].join("\n");
|
|
1250
|
+
let parsed = askProviderForJson(resolution.provider.exec, prompt);
|
|
1251
|
+
if ((!parsed || !Array.isArray(parsed.files)) && Array.isArray(qualityDiagnostics) && qualityDiagnostics.length > 0) {
|
|
1252
|
+
const fileNames = collectProjectFiles(appDir).map((f) => f.path).slice(0, 120);
|
|
1253
|
+
const targetedPrompt = [
|
|
1254
|
+
"Return ONLY valid JSON. No markdown.",
|
|
1255
|
+
'Schema: {"files":[{"path":"relative/path","content":"..."}]}',
|
|
1256
|
+
"Fix exactly the listed quality diagnostics with minimal file edits.",
|
|
1257
|
+
"If diagnostics mention missing docs/tests, generate them.",
|
|
1258
|
+
`Intent: ${intent}`,
|
|
1259
|
+
`Quality diagnostics: ${JSON.stringify(qualityDiagnostics)}`,
|
|
1260
|
+
`Current file names: ${JSON.stringify(fileNames)}`
|
|
1261
|
+
].join("\n");
|
|
1262
|
+
parsed = askProviderForJson(resolution.provider.exec, targetedPrompt);
|
|
1263
|
+
}
|
|
1264
|
+
if (!parsed || !Array.isArray(parsed.files)) {
|
|
1265
|
+
return { attempted: true, applied: false, fileCount: 0, reason: "provider response unusable" };
|
|
1266
|
+
}
|
|
1267
|
+
const updates = [];
|
|
1268
|
+
for (const item of parsed.files) {
|
|
1269
|
+
if (!item || typeof item !== "object") {
|
|
1270
|
+
continue;
|
|
1271
|
+
}
|
|
1272
|
+
const maybePath = item.path;
|
|
1273
|
+
const maybeContent = item.content;
|
|
1274
|
+
if (typeof maybePath !== "string" || typeof maybeContent !== "string") {
|
|
1275
|
+
continue;
|
|
1276
|
+
}
|
|
1277
|
+
const rel = safeRelativePath(maybePath);
|
|
1278
|
+
if (!rel) {
|
|
1279
|
+
continue;
|
|
1280
|
+
}
|
|
1281
|
+
updates.push({ path: rel, content: maybeContent });
|
|
1282
|
+
}
|
|
1283
|
+
if (updates.length === 0) {
|
|
1284
|
+
return { attempted: true, applied: false, fileCount: 0, reason: "no valid files in response" };
|
|
1285
|
+
}
|
|
1286
|
+
for (const file of updates) {
|
|
1287
|
+
const full = path_1.default.join(appDir, file.path);
|
|
1288
|
+
fs_1.default.mkdirSync(path_1.default.dirname(full), { recursive: true });
|
|
1289
|
+
fs_1.default.writeFileSync(full, file.content, "utf-8");
|
|
1290
|
+
}
|
|
1291
|
+
return { attempted: true, applied: true, fileCount: updates.length };
|
|
1292
|
+
}
|