@stackwright-pro/mcp 0.2.0-alpha.8 → 0.2.0-alpha.80
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/LICENSE +21 -0
- package/dist/integrity.d.mts +29 -1
- package/dist/integrity.d.ts +29 -1
- package/dist/integrity.js +69 -10
- package/dist/integrity.js.map +1 -1
- package/dist/integrity.mjs +68 -10
- package/dist/integrity.mjs.map +1 -1
- package/dist/server.js +2980 -397
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +3012 -404
- package/dist/server.mjs.map +1 -1
- package/dist/tools/type-schemas.d.mts +51 -0
- package/dist/tools/type-schemas.d.ts +51 -0
- package/dist/tools/type-schemas.js +120 -0
- package/dist/tools/type-schemas.js.map +1 -0
- package/dist/tools/type-schemas.mjs +94 -0
- package/dist/tools/type-schemas.mjs.map +1 -0
- package/package.json +17 -8
package/dist/server.mjs
CHANGED
|
@@ -3,15 +3,44 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
|
|
5
5
|
// src/tools/data-explorer.ts
|
|
6
|
-
import { z } from "zod";
|
|
6
|
+
import { z as z2 } from "zod";
|
|
7
7
|
import { listEntities, generateFilter } from "@stackwright-pro/cli-data-explorer";
|
|
8
|
+
|
|
9
|
+
// src/coerce.ts
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
function jsonCoerce(schema) {
|
|
12
|
+
return z.preprocess((v) => {
|
|
13
|
+
if (typeof v === "string") {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(v);
|
|
16
|
+
} catch {
|
|
17
|
+
return v;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return v;
|
|
21
|
+
}, schema);
|
|
22
|
+
}
|
|
23
|
+
function boolCoerce(schema) {
|
|
24
|
+
return z.preprocess((v) => v === "true" ? true : v === "false" ? false : v, schema);
|
|
25
|
+
}
|
|
26
|
+
function numCoerce(schema) {
|
|
27
|
+
return z.preprocess((v) => {
|
|
28
|
+
if (typeof v === "string" && v.trim() !== "") {
|
|
29
|
+
const n = Number(v);
|
|
30
|
+
if (!Number.isNaN(n)) return n;
|
|
31
|
+
}
|
|
32
|
+
return v;
|
|
33
|
+
}, schema);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// src/tools/data-explorer.ts
|
|
8
37
|
function registerDataExplorerTools(server2) {
|
|
9
38
|
server2.tool(
|
|
10
39
|
"stackwright_pro_list_entities",
|
|
11
40
|
"List all available API entities from OpenAPI specs or generated Zod schemas. Use this to discover what entities are available before generating endpoint filters. Returns entity names, endpoints, and field counts. Part of the Pro Otter Raft for building API-integrated Stackwright applications.",
|
|
12
41
|
{
|
|
13
|
-
specPath:
|
|
14
|
-
projectRoot:
|
|
42
|
+
specPath: z2.string().optional().describe("Path to OpenAPI spec file (YAML or JSON)"),
|
|
43
|
+
projectRoot: z2.string().optional().describe("Project root directory (auto-detected if omitted)")
|
|
15
44
|
},
|
|
16
45
|
async ({ specPath, projectRoot }) => {
|
|
17
46
|
const result = listEntities({
|
|
@@ -59,9 +88,13 @@ function registerDataExplorerTools(server2) {
|
|
|
59
88
|
"stackwright_pro_generate_filter",
|
|
60
89
|
"Generate endpoint filter configuration from selected entities. Creates include/exclude patterns for stackwright.yml OpenAPI integration. Use this after stackwright_pro_list_entities to select which API endpoints the application needs. Only selected endpoints will generate client code, reducing bundle size and improving security.",
|
|
61
90
|
{
|
|
62
|
-
selectedEntities:
|
|
63
|
-
|
|
64
|
-
|
|
91
|
+
selectedEntities: jsonCoerce(z2.array(z2.string())).describe(
|
|
92
|
+
'Entity slugs to include (e.g., ["equipment", "supplies"])'
|
|
93
|
+
),
|
|
94
|
+
excludePatterns: jsonCoerce(z2.array(z2.string()).optional()).describe(
|
|
95
|
+
'Glob patterns to exclude (e.g., ["/admin/**", "/reports/**"])'
|
|
96
|
+
),
|
|
97
|
+
projectRoot: z2.string().optional().describe("Project root directory")
|
|
65
98
|
},
|
|
66
99
|
async ({ selectedEntities, excludePatterns, projectRoot }) => {
|
|
67
100
|
const result = generateFilter({
|
|
@@ -117,7 +150,7 @@ function registerDataExplorerTools(server2) {
|
|
|
117
150
|
}
|
|
118
151
|
|
|
119
152
|
// src/tools/security.ts
|
|
120
|
-
import { z as
|
|
153
|
+
import { z as z3 } from "zod";
|
|
121
154
|
import { createHash } from "crypto";
|
|
122
155
|
import fs from "fs";
|
|
123
156
|
import path from "path";
|
|
@@ -126,8 +159,8 @@ function registerSecurityTools(server2) {
|
|
|
126
159
|
"stackwright_pro_validate_spec",
|
|
127
160
|
"Validate an OpenAPI spec against the enterprise approved-specs configuration. Checks if the spec URL is on the allowlist and verifies SHA-256 hash integrity. Use this in enterprise environments where only pre-approved API specs are allowed. Fails build if spec is not approved or has been modified.",
|
|
128
161
|
{
|
|
129
|
-
specPath:
|
|
130
|
-
configPath:
|
|
162
|
+
specPath: z3.string().describe("URL or file path to the OpenAPI spec to validate"),
|
|
163
|
+
configPath: z3.string().optional().describe("Path to stackwright.yml with prebuild.security config")
|
|
131
164
|
},
|
|
132
165
|
async ({ specPath, configPath }) => {
|
|
133
166
|
let securityEnabled = false;
|
|
@@ -235,16 +268,15 @@ Status: Valid (${allowlist.length} specs on allowlist)`
|
|
|
235
268
|
"stackwright_pro_add_approved_spec",
|
|
236
269
|
"Add an OpenAPI spec to the approved-specs allowlist in stackwright.yml. Computes the SHA-256 hash of the spec and adds it to the security configuration. Use this when onboarding a new API in enterprise environments.",
|
|
237
270
|
{
|
|
238
|
-
name:
|
|
239
|
-
url:
|
|
240
|
-
configPath:
|
|
271
|
+
name: z3.string().describe("Human-readable name for the spec"),
|
|
272
|
+
url: z3.string().describe("URL or file path to the OpenAPI spec"),
|
|
273
|
+
configPath: z3.string().optional().describe("Path to stackwright.yml")
|
|
241
274
|
},
|
|
242
275
|
async ({ name, url, configPath }) => {
|
|
243
276
|
const configFile = configPath || path.join(process.cwd(), "stackwright.yml");
|
|
244
277
|
let sha256 = "<computed-at-build>";
|
|
245
278
|
try {
|
|
246
279
|
if (url.startsWith("http://") || url.startsWith("https://")) {
|
|
247
|
-
sha256 = "<computed-at-build>";
|
|
248
280
|
} else if (fs.existsSync(url)) {
|
|
249
281
|
const specContent = fs.readFileSync(url, "utf8");
|
|
250
282
|
sha256 = createHash("sha256").update(specContent).digest("hex");
|
|
@@ -291,7 +323,7 @@ SHA-256 computed: ${sha256}`
|
|
|
291
323
|
"stackwright_pro_list_approved_specs",
|
|
292
324
|
"List all specs currently on the approved-specs allowlist. Shows spec names, URLs, and hash prefixes. Use this to audit what APIs are approved in the project.",
|
|
293
325
|
{
|
|
294
|
-
configPath:
|
|
326
|
+
configPath: z3.string().optional().describe("Path to stackwright.yml")
|
|
295
327
|
},
|
|
296
328
|
async ({ configPath }) => {
|
|
297
329
|
const configFile = configPath || path.join(process.cwd(), "stackwright.yml");
|
|
@@ -360,16 +392,18 @@ SHA-256 computed: ${sha256}`
|
|
|
360
392
|
}
|
|
361
393
|
|
|
362
394
|
// src/tools/isr.ts
|
|
363
|
-
import { z as
|
|
395
|
+
import { z as z4 } from "zod";
|
|
364
396
|
function registerIsrTools(server2) {
|
|
365
397
|
server2.tool(
|
|
366
398
|
"stackwright_pro_configure_isr",
|
|
367
399
|
"Configure Incremental Static Regeneration (ISR) for an API-backed collection. ISR allows API data to be cached and refreshed on a schedule, providing real-time data with the performance of static generation. Use this after stackwright_pro_generate_filter to set revalidation intervals.",
|
|
368
400
|
{
|
|
369
|
-
collection:
|
|
370
|
-
revalidateSeconds:
|
|
371
|
-
|
|
372
|
-
|
|
401
|
+
collection: z4.string().describe('Collection name (e.g., "equipment", "supplies")'),
|
|
402
|
+
revalidateSeconds: numCoerce(z4.number().optional()).describe(
|
|
403
|
+
"Revalidation interval in seconds (default: 60)"
|
|
404
|
+
),
|
|
405
|
+
fallback: z4.enum(["blocking", "true", "false"]).optional().describe("Fallback behavior for new pages"),
|
|
406
|
+
configPath: z4.string().optional().describe("Path to stackwright.yml")
|
|
373
407
|
},
|
|
374
408
|
async ({ collection, revalidateSeconds = 60, fallback = "blocking", configPath }) => {
|
|
375
409
|
const revalidate = revalidateSeconds;
|
|
@@ -380,7 +414,7 @@ function registerIsrTools(server2) {
|
|
|
380
414
|
isr:
|
|
381
415
|
revalidate: ${revalidate}
|
|
382
416
|
fallback: ${fallback}`;
|
|
383
|
-
let description
|
|
417
|
+
let description;
|
|
384
418
|
if (revalidate < 60) {
|
|
385
419
|
description = "\u26A1 Very fresh data (revalidate every " + revalidate + "s)";
|
|
386
420
|
} else if (revalidate < 300) {
|
|
@@ -412,14 +446,16 @@ ${yamlSnippet}
|
|
|
412
446
|
"stackwright_pro_configure_isr_batch",
|
|
413
447
|
"Configure ISR for multiple collections at once. More efficient than calling stackwright_pro_configure_isr multiple times. Provide different revalidation intervals based on data freshness requirements.",
|
|
414
448
|
{
|
|
415
|
-
collections:
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
449
|
+
collections: jsonCoerce(
|
|
450
|
+
z4.array(
|
|
451
|
+
z4.object({
|
|
452
|
+
name: z4.string().describe("Collection name"),
|
|
453
|
+
revalidateSeconds: numCoerce(z4.number().optional()).describe("Revalidation interval")
|
|
454
|
+
})
|
|
455
|
+
)
|
|
420
456
|
).describe("Array of collection configurations"),
|
|
421
|
-
defaultFallback:
|
|
422
|
-
configPath:
|
|
457
|
+
defaultFallback: z4.enum(["blocking", "true", "false"]).optional().describe("Default fallback behavior"),
|
|
458
|
+
configPath: z4.string().optional().describe("Path to stackwright.yml")
|
|
423
459
|
},
|
|
424
460
|
async ({ collections, defaultFallback = "blocking", configPath }) => {
|
|
425
461
|
const lines = [`\u2699\uFE0F Batch ISR Configuration:
|
|
@@ -447,17 +483,17 @@ Fallback: ${defaultFallback}`);
|
|
|
447
483
|
}
|
|
448
484
|
|
|
449
485
|
// src/tools/dashboard.ts
|
|
450
|
-
import { z as
|
|
486
|
+
import { z as z5 } from "zod";
|
|
451
487
|
import { listEntities as listEntities2 } from "@stackwright-pro/cli-data-explorer";
|
|
452
488
|
function registerDashboardTools(server2) {
|
|
453
489
|
server2.tool(
|
|
454
490
|
"stackwright_pro_generate_dashboard",
|
|
455
491
|
"Generate a dashboard page configuration for displaying API data. Creates YAML content for a Stackwright page with grid, metric_card, data_table, and collection_list content types. Use this after stackwright_pro_generate_filter to create pages for your API collections.",
|
|
456
492
|
{
|
|
457
|
-
entities:
|
|
458
|
-
layout:
|
|
459
|
-
pageTitle:
|
|
460
|
-
specPath:
|
|
493
|
+
entities: jsonCoerce(z5.array(z5.string())).describe("Entity slugs to include in dashboard"),
|
|
494
|
+
layout: z5.enum(["grid", "table", "mixed"]).optional().describe("Dashboard layout style"),
|
|
495
|
+
pageTitle: z5.string().optional().describe("Page title"),
|
|
496
|
+
specPath: z5.string().optional().describe("Path to OpenAPI spec for entity details")
|
|
461
497
|
},
|
|
462
498
|
async ({ entities, layout = "mixed", pageTitle, specPath }) => {
|
|
463
499
|
let entityDetails = [];
|
|
@@ -543,9 +579,9 @@ ${yaml}
|
|
|
543
579
|
"stackwright_pro_generate_detail_page",
|
|
544
580
|
"Generate a detail view page for a single API entity. Creates YAML content for displaying all fields of an individual record. Use this to create detail pages that complement collection listing pages.",
|
|
545
581
|
{
|
|
546
|
-
entity:
|
|
547
|
-
slugField:
|
|
548
|
-
specPath:
|
|
582
|
+
entity: z5.string().describe('Entity slug (e.g., "equipment")'),
|
|
583
|
+
slugField: z5.string().optional().describe('Field to use as URL slug (default: "id")'),
|
|
584
|
+
specPath: z5.string().optional().describe("Path to OpenAPI spec for field details")
|
|
549
585
|
},
|
|
550
586
|
async ({ entity, slugField = "id", specPath }) => {
|
|
551
587
|
let fields = [];
|
|
@@ -568,39 +604,49 @@ ${yaml}
|
|
|
568
604
|
` title: "${entityName} Details | {{ ${entity}.${slugField} }}"`,
|
|
569
605
|
"",
|
|
570
606
|
" content_items:",
|
|
571
|
-
" -
|
|
572
|
-
'
|
|
573
|
-
"
|
|
574
|
-
`
|
|
575
|
-
'
|
|
576
|
-
"
|
|
577
|
-
`
|
|
578
|
-
'
|
|
579
|
-
' background: "primary"',
|
|
580
|
-
' color: "text"',
|
|
607
|
+
" - type: text_block",
|
|
608
|
+
' label: "detail-header"',
|
|
609
|
+
" heading:",
|
|
610
|
+
` text: "{{ ${entity}.${slugField} }}"`,
|
|
611
|
+
' textSize: "h1"',
|
|
612
|
+
" textBlocks:",
|
|
613
|
+
` - text: "Details for this ${entity}"`,
|
|
614
|
+
' textSize: "body1"',
|
|
581
615
|
"",
|
|
582
|
-
" - grid
|
|
583
|
-
'
|
|
584
|
-
"
|
|
585
|
-
" items:"
|
|
616
|
+
" - type: grid",
|
|
617
|
+
' label: "detail-fields"',
|
|
618
|
+
" columns:"
|
|
586
619
|
];
|
|
587
620
|
const displayFields = fields.length > 0 ? fields.slice(0, 8) : [
|
|
588
621
|
{ name: slugField, type: "string" },
|
|
589
622
|
{ name: "created_at", type: "datetime" },
|
|
590
623
|
{ name: "updated_at", type: "datetime" }
|
|
591
624
|
];
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
yamlLines.push(
|
|
597
|
-
yamlLines.push(
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
625
|
+
const mid = Math.ceil(displayFields.length / 2);
|
|
626
|
+
const col1 = displayFields.slice(0, mid);
|
|
627
|
+
const col2 = displayFields.slice(mid);
|
|
628
|
+
for (const [colIndex, colFields] of [col1, col2].entries()) {
|
|
629
|
+
yamlLines.push(" - width: 1");
|
|
630
|
+
yamlLines.push(" content_items:");
|
|
631
|
+
for (const field of colFields) {
|
|
632
|
+
const label = field.name.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
|
|
633
|
+
yamlLines.push(" - type: text_block");
|
|
634
|
+
yamlLines.push(` label: "${field.name}-field"`);
|
|
635
|
+
yamlLines.push(" heading:");
|
|
636
|
+
yamlLines.push(` text: "${label}"`);
|
|
637
|
+
yamlLines.push(' textSize: "h4"');
|
|
638
|
+
yamlLines.push(" textBlocks:");
|
|
639
|
+
yamlLines.push(` - text: "{{ ${entity}.${field.name} }}"`);
|
|
640
|
+
yamlLines.push(' textSize: "body1"');
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
yamlLines.push("");
|
|
644
|
+
yamlLines.push(" - type: action_bar");
|
|
645
|
+
yamlLines.push(" actions:");
|
|
646
|
+
yamlLines.push(` - label: "<- Back to ${entityName}"`);
|
|
647
|
+
yamlLines.push(" action: navigate");
|
|
648
|
+
yamlLines.push(` href: "/${entity}"`);
|
|
649
|
+
yamlLines.push(" style: secondary");
|
|
604
650
|
const yaml = yamlLines.join("\n");
|
|
605
651
|
return {
|
|
606
652
|
content: [
|
|
@@ -621,7 +667,7 @@ ${yaml}
|
|
|
621
667
|
}
|
|
622
668
|
|
|
623
669
|
// src/tools/clarification.ts
|
|
624
|
-
import { z as
|
|
670
|
+
import { z as z6 } from "zod";
|
|
625
671
|
var CONTRADICTION_PATTERNS = [
|
|
626
672
|
{
|
|
627
673
|
keywords: ["minimal", "clean", "simple"],
|
|
@@ -709,12 +755,14 @@ function registerClarificationTools(server2) {
|
|
|
709
755
|
"stackwright_pro_clarify",
|
|
710
756
|
"Ask the user for clarification when a specialist otter encounters ambiguity. This is for MID-EXECUTION questions, NOT upfront question collection (use the Question Manifest Protocol for that). Returns a structured response for the foreman to present to the user via ask_user_question (closed_choice) or directly (open_text).",
|
|
711
757
|
{
|
|
712
|
-
context:
|
|
713
|
-
question_type:
|
|
714
|
-
question:
|
|
715
|
-
choices:
|
|
716
|
-
|
|
717
|
-
|
|
758
|
+
context: z6.string().optional().describe("Context about what the otter is trying to do"),
|
|
759
|
+
question_type: z6.enum(["closed_choice", "open_text"]).describe("Type of question being asked"),
|
|
760
|
+
question: z6.string().describe("The clarification question to ask the user"),
|
|
761
|
+
choices: jsonCoerce(z6.array(z6.string()).optional()).describe(
|
|
762
|
+
"Options for closed_choice questions"
|
|
763
|
+
),
|
|
764
|
+
priority: z6.enum(["blocking", "preferred", "optional"]).optional().default("preferred").describe("How critical is this clarification? Default: preferred"),
|
|
765
|
+
target_field: z6.string().optional().describe("What field/config does this clarify?")
|
|
718
766
|
},
|
|
719
767
|
async ({ context, question_type, question, choices, priority, target_field }) => {
|
|
720
768
|
const result = handleClarify({
|
|
@@ -743,8 +791,10 @@ function registerClarificationTools(server2) {
|
|
|
743
791
|
"stackwright_pro_detect_conflict",
|
|
744
792
|
"Detect when a user's stated preference conflicts with their selected choices. Uses keyword heuristics against known contradiction patterns (minimal vs vibrant, dark vs light, etc). Returns conflict details and resolution options.",
|
|
745
793
|
{
|
|
746
|
-
stated_preference:
|
|
747
|
-
selected_values:
|
|
794
|
+
stated_preference: z6.string().describe("What the user said they wanted"),
|
|
795
|
+
selected_values: jsonCoerce(z6.record(z6.string(), z6.string())).describe(
|
|
796
|
+
"What the user actually selected (field \u2192 value)"
|
|
797
|
+
)
|
|
748
798
|
},
|
|
749
799
|
async ({ stated_preference, selected_values }) => {
|
|
750
800
|
const result = handleDetectConflict({ stated_preference, selected_values });
|
|
@@ -789,7 +839,7 @@ ${result.resolution_options?.map((o) => ` \u2022 ${o}`).join("\n")}
|
|
|
789
839
|
}
|
|
790
840
|
|
|
791
841
|
// src/tools/packages.ts
|
|
792
|
-
import { z as
|
|
842
|
+
import { z as z7 } from "zod";
|
|
793
843
|
import { readFileSync, writeFileSync, existsSync, realpathSync, lstatSync } from "fs";
|
|
794
844
|
import { execSync } from "child_process";
|
|
795
845
|
import path2 from "path";
|
|
@@ -810,17 +860,23 @@ function registerPackageTools(server2) {
|
|
|
810
860
|
"Ensures pro packages are present in a project's package.json. Safe to call multiple times \u2014 never overwrites existing version pins. Use this to bootstrap dependencies before specialist otters run. Pass includeBaseline: true to automatically include all required @stackwright-pro/* baseline dependencies. Safe to call on existing projects \u2014 never overwrites pinned versions.",
|
|
811
861
|
{
|
|
812
862
|
// FIX 3 (B-new-1): Zod v4 requires two-arg z.record(keySchema, valueSchema)
|
|
813
|
-
packages:
|
|
814
|
-
'Dependencies to add. Record<packageName, version>. e.g. { "@stackwright-pro/auth": "latest" }'
|
|
863
|
+
packages: jsonCoerce(z7.record(z7.string(), z7.string()).optional().default({})).describe(
|
|
864
|
+
'Dependencies to add. Record<packageName, version>. e.g. { "@stackwright-pro/auth": "latest" }. Omit or pass {} when using includeBaseline: true.'
|
|
865
|
+
),
|
|
866
|
+
devPackages: jsonCoerce(z7.record(z7.string(), z7.string()).optional()).describe(
|
|
867
|
+
"devDependencies to add. Same format as packages."
|
|
815
868
|
),
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
869
|
+
scripts: jsonCoerce(z7.record(z7.string(), z7.string()).optional()).describe(
|
|
870
|
+
"npm scripts to add. Only adds if key does not already exist."
|
|
871
|
+
),
|
|
872
|
+
targetDir: z7.string().optional().describe(
|
|
819
873
|
"Project directory containing package.json. Defaults to process.cwd(). Must be an absolute path within the current working directory."
|
|
820
874
|
),
|
|
821
|
-
runInstall:
|
|
822
|
-
|
|
823
|
-
|
|
875
|
+
runInstall: boolCoerce(z7.boolean().optional().default(true)).describe(
|
|
876
|
+
"Run pnpm install after writing package.json. Defaults to true. Pass boolean true/false."
|
|
877
|
+
),
|
|
878
|
+
includeBaseline: boolCoerce(z7.boolean().optional().default(false)).describe(
|
|
879
|
+
"When true, automatically merges BASELINE_DEPS and BASELINE_DEV_DEPS before applying packages/devPackages args. Safe to call on existing projects \u2014 never overwrites pinned versions. Pass boolean true/false."
|
|
824
880
|
)
|
|
825
881
|
},
|
|
826
882
|
async ({ packages, devPackages, scripts, targetDir, runInstall, includeBaseline }) => {
|
|
@@ -978,11 +1034,11 @@ function setupPackages(opts) {
|
|
|
978
1034
|
}
|
|
979
1035
|
}
|
|
980
1036
|
const raw = readFileSync(realPackageJsonPath, "utf8");
|
|
981
|
-
const PackageJsonSchema =
|
|
1037
|
+
const PackageJsonSchema = z7.object({
|
|
982
1038
|
// Zod v4: z.record(keySchema, valueSchema) — two-arg form required
|
|
983
|
-
dependencies:
|
|
984
|
-
devDependencies:
|
|
985
|
-
scripts:
|
|
1039
|
+
dependencies: z7.record(z7.string(), z7.string()).optional(),
|
|
1040
|
+
devDependencies: z7.record(z7.string(), z7.string()).optional(),
|
|
1041
|
+
scripts: z7.record(z7.string(), z7.string()).optional()
|
|
986
1042
|
}).passthrough();
|
|
987
1043
|
const schemaResult = PackageJsonSchema.safeParse(JSON.parse(raw));
|
|
988
1044
|
if (!schemaResult.success) {
|
|
@@ -1063,9 +1119,10 @@ function setupPackages(opts) {
|
|
|
1063
1119
|
}
|
|
1064
1120
|
|
|
1065
1121
|
// src/tools/questions.ts
|
|
1066
|
-
import { readFile } from "fs/promises";
|
|
1122
|
+
import { readFile, writeFile } from "fs/promises";
|
|
1123
|
+
import { existsSync as existsSync2, lstatSync as lstatSync2, mkdirSync, renameSync } from "fs";
|
|
1067
1124
|
import { join } from "path";
|
|
1068
|
-
import { z as
|
|
1125
|
+
import { z as z8 } from "zod";
|
|
1069
1126
|
|
|
1070
1127
|
// src/question-adapter.ts
|
|
1071
1128
|
function truncate(str, maxLength) {
|
|
@@ -1250,23 +1307,30 @@ function answersToManifestFormat(answers, questions) {
|
|
|
1250
1307
|
return result;
|
|
1251
1308
|
}
|
|
1252
1309
|
|
|
1310
|
+
// src/validation.ts
|
|
1311
|
+
var SAFE_PHASE = /^[a-z][a-z0-9-]{0,30}$/;
|
|
1312
|
+
var SAFE_QUESTION_ID = /^[a-z0-9][a-z0-9-]{0,63}$/i;
|
|
1313
|
+
function isValidPhase(phase) {
|
|
1314
|
+
return SAFE_PHASE.test(phase);
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1253
1317
|
// src/tools/questions.ts
|
|
1254
|
-
var ManifestQuestionSchema =
|
|
1255
|
-
id:
|
|
1256
|
-
question:
|
|
1257
|
-
type:
|
|
1258
|
-
required:
|
|
1259
|
-
options:
|
|
1260
|
-
|
|
1261
|
-
label:
|
|
1262
|
-
value:
|
|
1318
|
+
var ManifestQuestionSchema = z8.object({
|
|
1319
|
+
id: z8.string(),
|
|
1320
|
+
question: z8.string(),
|
|
1321
|
+
type: z8.enum(["text", "select", "multi-select", "confirm"]),
|
|
1322
|
+
required: z8.boolean().optional(),
|
|
1323
|
+
options: z8.array(
|
|
1324
|
+
z8.object({
|
|
1325
|
+
label: z8.string(),
|
|
1326
|
+
value: z8.string()
|
|
1263
1327
|
})
|
|
1264
1328
|
).optional(),
|
|
1265
|
-
default:
|
|
1266
|
-
help:
|
|
1267
|
-
dependsOn:
|
|
1268
|
-
questionId:
|
|
1269
|
-
value:
|
|
1329
|
+
default: z8.union([z8.string(), z8.boolean(), z8.array(z8.string())]).optional(),
|
|
1330
|
+
help: z8.string().optional(),
|
|
1331
|
+
dependsOn: z8.object({
|
|
1332
|
+
questionId: z8.string(),
|
|
1333
|
+
value: z8.union([z8.string(), z8.array(z8.string())])
|
|
1270
1334
|
}).optional()
|
|
1271
1335
|
});
|
|
1272
1336
|
function registerQuestionTools(server2) {
|
|
@@ -1274,15 +1338,16 @@ function registerQuestionTools(server2) {
|
|
|
1274
1338
|
"stackwright_pro_present_phase_questions",
|
|
1275
1339
|
"Adapt manifest-format questions from a specialist otter and present them to the user via ask_user_question. Pass only the phase name \u2014 this tool reads questions from .stackwright/question-manifest.json automatically. The questions parameter is optional and only needed if the manifest has not been written yet. Use this instead of calling ask_user_question directly \u2014 it handles label truncation (50-char limit), header generation, confirm/text defaults, and correct array formatting automatically. IMPORTANT: This is the ONLY approved way to prepare questions before calling ask_user_question. Never call ask_user_question with raw manifest questions. Never retry ask_user_question validation errors by calling it directly \u2014 always re-call this tool.",
|
|
1276
1340
|
{
|
|
1277
|
-
phase:
|
|
1278
|
-
questions:
|
|
1341
|
+
phase: z8.string().describe('Phase name for display context, e.g. "designer", "api", "auth"'),
|
|
1342
|
+
questions: jsonCoerce(z8.array(ManifestQuestionSchema).optional()).describe(
|
|
1279
1343
|
"Questions in Question Manifest format. If omitted, questions are read from .stackwright/question-manifest.json using the phase name."
|
|
1280
1344
|
),
|
|
1281
|
-
answers:
|
|
1345
|
+
answers: jsonCoerce(
|
|
1346
|
+
z8.record(z8.string(), z8.union([z8.string(), z8.array(z8.string()), z8.boolean()])).optional()
|
|
1347
|
+
).describe("Previously collected answers used to resolve dependsOn conditions")
|
|
1282
1348
|
},
|
|
1283
1349
|
async ({ phase, questions, answers }) => {
|
|
1284
1350
|
let resolvedQuestions;
|
|
1285
|
-
const SAFE_PHASE = /^[a-z][a-z0-9-]{0,30}$/;
|
|
1286
1351
|
if (!SAFE_PHASE.test(phase)) {
|
|
1287
1352
|
return {
|
|
1288
1353
|
content: [
|
|
@@ -1329,17 +1394,36 @@ function registerQuestionTools(server2) {
|
|
|
1329
1394
|
}
|
|
1330
1395
|
}
|
|
1331
1396
|
const adapted = adaptQuestions(resolvedQuestions, answers ?? {});
|
|
1397
|
+
const labelSchema = z8.string().max(50, "Value should have at most 50 characters");
|
|
1398
|
+
for (const q of adapted) {
|
|
1399
|
+
for (const opt of q.options) {
|
|
1400
|
+
const check = labelSchema.safeParse(opt.label);
|
|
1401
|
+
if (!check.success) {
|
|
1402
|
+
return {
|
|
1403
|
+
content: [
|
|
1404
|
+
{
|
|
1405
|
+
type: "text",
|
|
1406
|
+
text: JSON.stringify({
|
|
1407
|
+
error: `Option label for phase "${phase}" exceeds 50 characters: ${check.error.issues[0]?.message ?? "label too long"}. Truncate option labels to \u226450 characters before calling this tool.`
|
|
1408
|
+
})
|
|
1409
|
+
}
|
|
1410
|
+
],
|
|
1411
|
+
isError: true
|
|
1412
|
+
};
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1332
1416
|
if (adapted.length === 0) {
|
|
1333
1417
|
return {
|
|
1334
1418
|
content: [
|
|
1335
1419
|
{
|
|
1336
1420
|
type: "text",
|
|
1337
|
-
text:
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1421
|
+
text: `Phase "${phase}" has no questions to present. Do NOT call ask_user_question. Call stackwright_pro_save_phase_answers({ phase: "${phase}", rawAnswers: [] }) directly, then proceed to the execution step for this phase.`
|
|
1422
|
+
},
|
|
1423
|
+
{
|
|
1424
|
+
type: "text",
|
|
1425
|
+
// Empty array — second block always present so the foreman's two-block contract holds
|
|
1426
|
+
text: JSON.stringify([])
|
|
1343
1427
|
}
|
|
1344
1428
|
],
|
|
1345
1429
|
isError: false
|
|
@@ -1360,11 +1444,147 @@ function registerQuestionTools(server2) {
|
|
|
1360
1444
|
};
|
|
1361
1445
|
}
|
|
1362
1446
|
);
|
|
1447
|
+
server2.tool(
|
|
1448
|
+
"stackwright_pro_get_next_question",
|
|
1449
|
+
"Returns the next unanswered question for a phase as a plain JSON object \u2014 one question at a time. Returns { done: true } when all questions are answered or the phase has no questions. Call this in a loop: present the question in plain chat, get user reply, call record_answer, repeat.",
|
|
1450
|
+
{ phase: z8.string().describe('Phase name e.g. "designer", "api", "auth"') },
|
|
1451
|
+
async ({ phase }) => {
|
|
1452
|
+
if (!SAFE_PHASE.test(phase)) {
|
|
1453
|
+
return {
|
|
1454
|
+
content: [
|
|
1455
|
+
{
|
|
1456
|
+
type: "text",
|
|
1457
|
+
text: JSON.stringify({ error: `Invalid phase name: "${phase.slice(0, 50)}"` })
|
|
1458
|
+
}
|
|
1459
|
+
],
|
|
1460
|
+
isError: true
|
|
1461
|
+
};
|
|
1462
|
+
}
|
|
1463
|
+
const cwd = process.cwd();
|
|
1464
|
+
const questionsPath = join(cwd, ".stackwright", "questions", `${phase}.json`);
|
|
1465
|
+
let questions = [];
|
|
1466
|
+
try {
|
|
1467
|
+
const raw = await readFile(questionsPath, "utf-8");
|
|
1468
|
+
const parsed = JSON.parse(raw);
|
|
1469
|
+
questions = parsed.questions ?? [];
|
|
1470
|
+
} catch {
|
|
1471
|
+
return { content: [{ type: "text", text: JSON.stringify({ done: true }) }] };
|
|
1472
|
+
}
|
|
1473
|
+
if (questions.length === 0) {
|
|
1474
|
+
return { content: [{ type: "text", text: JSON.stringify({ done: true }) }] };
|
|
1475
|
+
}
|
|
1476
|
+
const answersPath = join(cwd, ".stackwright", "answers", `${phase}.json`);
|
|
1477
|
+
let answeredIds = /* @__PURE__ */ new Set();
|
|
1478
|
+
try {
|
|
1479
|
+
const raw = await readFile(answersPath, "utf-8");
|
|
1480
|
+
const parsed = JSON.parse(raw);
|
|
1481
|
+
answeredIds = new Set(Object.keys(parsed.answers ?? {}));
|
|
1482
|
+
} catch {
|
|
1483
|
+
}
|
|
1484
|
+
const next = questions.find((q) => !answeredIds.has(q.id));
|
|
1485
|
+
if (!next) {
|
|
1486
|
+
return { content: [{ type: "text", text: JSON.stringify({ done: true }) }] };
|
|
1487
|
+
}
|
|
1488
|
+
return {
|
|
1489
|
+
content: [
|
|
1490
|
+
{
|
|
1491
|
+
type: "text",
|
|
1492
|
+
text: JSON.stringify({
|
|
1493
|
+
done: false,
|
|
1494
|
+
questionId: next.id,
|
|
1495
|
+
question: next.question,
|
|
1496
|
+
type: next.type,
|
|
1497
|
+
options: next.options ?? null,
|
|
1498
|
+
help: next.help ?? null,
|
|
1499
|
+
index: questions.indexOf(next) + 1,
|
|
1500
|
+
total: questions.length
|
|
1501
|
+
})
|
|
1502
|
+
}
|
|
1503
|
+
]
|
|
1504
|
+
};
|
|
1505
|
+
}
|
|
1506
|
+
);
|
|
1507
|
+
server2.tool(
|
|
1508
|
+
"stackwright_pro_record_answer",
|
|
1509
|
+
"Records a single answer to a phase question. Appends to .stackwright/answers/{phase}.json incrementally. Idempotent \u2014 calling twice for the same questionId overwrites. Call after receiving each user reply in the conversational question loop.",
|
|
1510
|
+
{
|
|
1511
|
+
phase: z8.string().describe('Phase name e.g. "designer"'),
|
|
1512
|
+
questionId: z8.string().describe('The question ID from get_next_question, e.g. "designer-1"'),
|
|
1513
|
+
answer: z8.string().describe("The user's free-text answer")
|
|
1514
|
+
},
|
|
1515
|
+
async ({ phase, questionId, answer }) => {
|
|
1516
|
+
if (!SAFE_PHASE.test(phase)) {
|
|
1517
|
+
return {
|
|
1518
|
+
content: [
|
|
1519
|
+
{ type: "text", text: JSON.stringify({ error: "Invalid phase name" }) }
|
|
1520
|
+
],
|
|
1521
|
+
isError: true
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
if (!SAFE_QUESTION_ID.test(questionId)) {
|
|
1525
|
+
return {
|
|
1526
|
+
content: [
|
|
1527
|
+
{ type: "text", text: JSON.stringify({ error: "Invalid questionId" }) }
|
|
1528
|
+
],
|
|
1529
|
+
isError: true
|
|
1530
|
+
};
|
|
1531
|
+
}
|
|
1532
|
+
const safeAnswer = answer.slice(0, 2e3);
|
|
1533
|
+
const cwd = process.cwd();
|
|
1534
|
+
const answersDir = join(cwd, ".stackwright", "answers");
|
|
1535
|
+
const answersPath = join(answersDir, `${phase}.json`);
|
|
1536
|
+
if (existsSync2(answersDir) && lstatSync2(answersDir).isSymbolicLink()) {
|
|
1537
|
+
return {
|
|
1538
|
+
content: [
|
|
1539
|
+
{
|
|
1540
|
+
type: "text",
|
|
1541
|
+
text: JSON.stringify({ error: "answers dir is a symlink \u2014 refusing to write" })
|
|
1542
|
+
}
|
|
1543
|
+
],
|
|
1544
|
+
isError: true
|
|
1545
|
+
};
|
|
1546
|
+
}
|
|
1547
|
+
mkdirSync(answersDir, { recursive: true });
|
|
1548
|
+
let existing = {
|
|
1549
|
+
version: "1.0",
|
|
1550
|
+
phase,
|
|
1551
|
+
answers: {}
|
|
1552
|
+
};
|
|
1553
|
+
try {
|
|
1554
|
+
if (existsSync2(answersPath)) {
|
|
1555
|
+
if (lstatSync2(answersPath).isSymbolicLink()) {
|
|
1556
|
+
return {
|
|
1557
|
+
content: [
|
|
1558
|
+
{
|
|
1559
|
+
type: "text",
|
|
1560
|
+
text: JSON.stringify({ error: "answers file is a symlink \u2014 refusing to write" })
|
|
1561
|
+
}
|
|
1562
|
+
],
|
|
1563
|
+
isError: true
|
|
1564
|
+
};
|
|
1565
|
+
}
|
|
1566
|
+
const raw = await readFile(answersPath, "utf-8");
|
|
1567
|
+
existing = JSON.parse(raw);
|
|
1568
|
+
}
|
|
1569
|
+
} catch {
|
|
1570
|
+
}
|
|
1571
|
+
existing.answers[questionId] = safeAnswer;
|
|
1572
|
+
existing.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1573
|
+
const tmp = `${answersPath}.tmp`;
|
|
1574
|
+
await writeFile(tmp, JSON.stringify(existing, null, 2), "utf-8");
|
|
1575
|
+
renameSync(tmp, answersPath);
|
|
1576
|
+
return {
|
|
1577
|
+
content: [
|
|
1578
|
+
{ type: "text", text: JSON.stringify({ recorded: true, phase, questionId }) }
|
|
1579
|
+
]
|
|
1580
|
+
};
|
|
1581
|
+
}
|
|
1582
|
+
);
|
|
1363
1583
|
}
|
|
1364
1584
|
|
|
1365
1585
|
// src/tools/orchestration.ts
|
|
1366
|
-
import { z as
|
|
1367
|
-
import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as
|
|
1586
|
+
import { z as z9 } from "zod";
|
|
1587
|
+
import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync3, mkdirSync as mkdirSync2, lstatSync as lstatSync3 } from "fs";
|
|
1368
1588
|
import { join as join2 } from "path";
|
|
1369
1589
|
var OTTER_NAME_TO_PHASE = [
|
|
1370
1590
|
["designer", "designer"],
|
|
@@ -1374,7 +1594,9 @@ var OTTER_NAME_TO_PHASE = [
|
|
|
1374
1594
|
["dashboard", "dashboard"],
|
|
1375
1595
|
["data", "data"],
|
|
1376
1596
|
["page", "pages"],
|
|
1377
|
-
["workflow", "workflow"]
|
|
1597
|
+
["workflow", "workflow"],
|
|
1598
|
+
["polish", "polish"],
|
|
1599
|
+
["geo", "geo"]
|
|
1378
1600
|
];
|
|
1379
1601
|
var PHASE_TO_OTTER = {
|
|
1380
1602
|
designer: "stackwright-pro-designer-otter",
|
|
@@ -1384,7 +1606,9 @@ var PHASE_TO_OTTER = {
|
|
|
1384
1606
|
pages: "stackwright-pro-page-otter",
|
|
1385
1607
|
dashboard: "stackwright-pro-dashboard-otter",
|
|
1386
1608
|
data: "stackwright-pro-data-otter",
|
|
1387
|
-
workflow: "stackwright-pro-workflow-otter"
|
|
1609
|
+
workflow: "stackwright-pro-workflow-otter",
|
|
1610
|
+
polish: "stackwright-pro-polish-otter",
|
|
1611
|
+
geo: "stackwright-pro-geo-otter"
|
|
1388
1612
|
};
|
|
1389
1613
|
function detectPhase(otterName) {
|
|
1390
1614
|
const lower = otterName.toLowerCase();
|
|
@@ -1420,9 +1644,9 @@ function handleSaveManifest(input) {
|
|
|
1420
1644
|
const dir = join2(cwd, ".stackwright");
|
|
1421
1645
|
const filePath = join2(dir, "question-manifest.json");
|
|
1422
1646
|
try {
|
|
1423
|
-
|
|
1424
|
-
if (
|
|
1425
|
-
const stat =
|
|
1647
|
+
mkdirSync2(dir, { recursive: true });
|
|
1648
|
+
if (existsSync3(filePath)) {
|
|
1649
|
+
const stat = lstatSync3(filePath);
|
|
1426
1650
|
if (stat.isSymbolicLink()) {
|
|
1427
1651
|
const message = `Refusing to write to symlink: ${filePath}`;
|
|
1428
1652
|
return {
|
|
@@ -1450,14 +1674,78 @@ function handleSaveManifest(input) {
|
|
|
1450
1674
|
}
|
|
1451
1675
|
}
|
|
1452
1676
|
function handleSavePhaseAnswers(input) {
|
|
1677
|
+
if (!isValidPhase(input.phase)) {
|
|
1678
|
+
return {
|
|
1679
|
+
text: JSON.stringify({
|
|
1680
|
+
success: false,
|
|
1681
|
+
error: `Invalid phase name: "${input.phase.slice(0, 50)}". Must be lowercase alphanumeric with hyphens, max 31 chars.`
|
|
1682
|
+
}),
|
|
1683
|
+
isError: true
|
|
1684
|
+
};
|
|
1685
|
+
}
|
|
1686
|
+
for (let i = 0; i < input.rawAnswers.length; i++) {
|
|
1687
|
+
const a = input.rawAnswers[i];
|
|
1688
|
+
if (!a.question_header || a.question_header.trim().length === 0) {
|
|
1689
|
+
return {
|
|
1690
|
+
text: JSON.stringify({
|
|
1691
|
+
success: false,
|
|
1692
|
+
error: `rawAnswers[${i}].question_header is empty \u2014 every answer must have a non-empty question_header.`
|
|
1693
|
+
}),
|
|
1694
|
+
isError: true
|
|
1695
|
+
};
|
|
1696
|
+
}
|
|
1697
|
+
if (!a.selected_options || a.selected_options.length === 0) {
|
|
1698
|
+
return {
|
|
1699
|
+
text: JSON.stringify({
|
|
1700
|
+
success: false,
|
|
1701
|
+
error: `rawAnswers[${i}].selected_options is empty for question_header "${a.question_header}" \u2014 every answer must have at least one selected option.`
|
|
1702
|
+
}),
|
|
1703
|
+
isError: true
|
|
1704
|
+
};
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
if (input.questions && input.questions.length > 0 && input.rawAnswers.length === 0) {
|
|
1708
|
+
return {
|
|
1709
|
+
text: JSON.stringify({
|
|
1710
|
+
success: false,
|
|
1711
|
+
error: `${input.questions.length} questions provided but rawAnswers is empty \u2014 this is a malformed payload. If the phase truly has no questions, omit the questions parameter.`
|
|
1712
|
+
}),
|
|
1713
|
+
isError: true
|
|
1714
|
+
};
|
|
1715
|
+
}
|
|
1453
1716
|
const cwd = input._cwd ?? process.cwd();
|
|
1454
1717
|
const dir = join2(cwd, ".stackwright", "answers");
|
|
1455
1718
|
const filePath = join2(dir, `${input.phase}.json`);
|
|
1456
1719
|
try {
|
|
1457
|
-
|
|
1720
|
+
mkdirSync2(dir, { recursive: true });
|
|
1721
|
+
const warnings = [];
|
|
1458
1722
|
let answers;
|
|
1459
1723
|
if (input.questions && input.questions.length > 0) {
|
|
1460
|
-
|
|
1724
|
+
try {
|
|
1725
|
+
answers = answersToManifestFormat(input.rawAnswers, input.questions);
|
|
1726
|
+
} catch (mapErr) {
|
|
1727
|
+
answers = {};
|
|
1728
|
+
warnings.push(
|
|
1729
|
+
`Reverse-mapping threw (${mapErr instanceof Error ? mapErr.message : String(mapErr)}) \u2014 falling back to direct key mapping.`
|
|
1730
|
+
);
|
|
1731
|
+
}
|
|
1732
|
+
if (Object.keys(answers).length === 0 && input.rawAnswers.length > 0) {
|
|
1733
|
+
answers = Object.fromEntries(
|
|
1734
|
+
input.rawAnswers.map((a) => [
|
|
1735
|
+
a.question_header,
|
|
1736
|
+
a.selected_options.length > 1 ? a.selected_options : a.selected_options[0] ?? ""
|
|
1737
|
+
])
|
|
1738
|
+
);
|
|
1739
|
+
warnings.push(
|
|
1740
|
+
`Reverse-mapping produced empty answers despite ${input.rawAnswers.length} rawAnswers \u2014 used direct key mapping as fallback. Ensure question_header values match the manifest header format (e.g. "DESI-1", not descriptive keys).`
|
|
1741
|
+
);
|
|
1742
|
+
}
|
|
1743
|
+
const requiredCount = input.questions.filter((q) => q.required !== false).length;
|
|
1744
|
+
if (input.rawAnswers.length < requiredCount) {
|
|
1745
|
+
warnings.push(
|
|
1746
|
+
`Only ${input.rawAnswers.length} answers provided for ${requiredCount} required questions (${input.questions.length} total) \u2014 answers may be incomplete.`
|
|
1747
|
+
);
|
|
1748
|
+
}
|
|
1461
1749
|
} else {
|
|
1462
1750
|
answers = Object.fromEntries(
|
|
1463
1751
|
input.rawAnswers.map((a) => [a.question_header, a.selected_options[0] ?? ""])
|
|
@@ -1469,8 +1757,8 @@ function handleSavePhaseAnswers(input) {
|
|
|
1469
1757
|
completedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1470
1758
|
answers
|
|
1471
1759
|
};
|
|
1472
|
-
if (
|
|
1473
|
-
const stat =
|
|
1760
|
+
if (existsSync3(filePath)) {
|
|
1761
|
+
const stat = lstatSync3(filePath);
|
|
1474
1762
|
if (stat.isSymbolicLink()) {
|
|
1475
1763
|
const message = `Refusing to write to symlink: ${filePath}`;
|
|
1476
1764
|
return {
|
|
@@ -1484,7 +1772,8 @@ function handleSavePhaseAnswers(input) {
|
|
|
1484
1772
|
text: JSON.stringify({
|
|
1485
1773
|
success: true,
|
|
1486
1774
|
path: filePath,
|
|
1487
|
-
answersCount: Object.keys(answers).length
|
|
1775
|
+
answersCount: Object.keys(answers).length,
|
|
1776
|
+
...warnings.length > 0 ? { warnings } : {}
|
|
1488
1777
|
}),
|
|
1489
1778
|
isError: false
|
|
1490
1779
|
};
|
|
@@ -1499,7 +1788,7 @@ function handleSavePhaseAnswers(input) {
|
|
|
1499
1788
|
function handleReadPhaseAnswers(input) {
|
|
1500
1789
|
const cwd = input._cwd ?? process.cwd();
|
|
1501
1790
|
const filePath = join2(cwd, ".stackwright", "answers", `${input.phase}.json`);
|
|
1502
|
-
if (!
|
|
1791
|
+
if (!existsSync3(filePath)) {
|
|
1503
1792
|
return {
|
|
1504
1793
|
text: JSON.stringify({ missing: true, phase: input.phase }),
|
|
1505
1794
|
isError: false
|
|
@@ -1531,13 +1820,63 @@ function handleGetOtterName(input) {
|
|
|
1531
1820
|
isError: false
|
|
1532
1821
|
};
|
|
1533
1822
|
}
|
|
1823
|
+
function handleSaveBuildContext(input) {
|
|
1824
|
+
const cwd = input._cwd ?? process.cwd();
|
|
1825
|
+
const dir = join2(cwd, ".stackwright");
|
|
1826
|
+
const filePath = join2(dir, "build-context.json");
|
|
1827
|
+
try {
|
|
1828
|
+
mkdirSync2(dir, { recursive: true });
|
|
1829
|
+
if (existsSync3(filePath)) {
|
|
1830
|
+
const stat = lstatSync3(filePath);
|
|
1831
|
+
if (stat.isSymbolicLink()) {
|
|
1832
|
+
return {
|
|
1833
|
+
text: JSON.stringify({
|
|
1834
|
+
success: false,
|
|
1835
|
+
error: `Refusing to write to symlink: ${filePath}`
|
|
1836
|
+
}),
|
|
1837
|
+
isError: true
|
|
1838
|
+
};
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
const payload = {
|
|
1842
|
+
version: "1.0",
|
|
1843
|
+
savedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1844
|
+
buildContext: input.buildContext
|
|
1845
|
+
};
|
|
1846
|
+
writeFileSync2(filePath, JSON.stringify(payload, null, 2) + "\n");
|
|
1847
|
+
return {
|
|
1848
|
+
text: JSON.stringify({ success: true, path: filePath }),
|
|
1849
|
+
isError: false
|
|
1850
|
+
};
|
|
1851
|
+
} catch (err) {
|
|
1852
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1853
|
+
return {
|
|
1854
|
+
text: JSON.stringify({ success: false, error: message }),
|
|
1855
|
+
isError: true
|
|
1856
|
+
};
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1534
1859
|
function registerOrchestrationTools(server2) {
|
|
1860
|
+
server2.tool(
|
|
1861
|
+
"stackwright_pro_save_build_context",
|
|
1862
|
+
`Save the user's initial build description to .stackwright/build-context.json. Call this once at startup after the user answers the opening "what are you building" question. The saved context is automatically prepended to specialist prompts by stackwright_pro_build_specialist_prompt.`,
|
|
1863
|
+
{
|
|
1864
|
+
buildContext: z9.string().describe("Free-text description of what the user wants to build")
|
|
1865
|
+
},
|
|
1866
|
+
async ({ buildContext }) => {
|
|
1867
|
+
const { text, isError } = handleSaveBuildContext({ buildContext });
|
|
1868
|
+
return {
|
|
1869
|
+
content: [{ type: "text", text }],
|
|
1870
|
+
isError
|
|
1871
|
+
};
|
|
1872
|
+
}
|
|
1873
|
+
);
|
|
1535
1874
|
server2.tool(
|
|
1536
1875
|
"stackwright_pro_parse_otter_response",
|
|
1537
1876
|
"Parse and validate a specialist otter's QUESTION_COLLECTION_MODE JSON response. Handles JSON extraction from LLM responses (strips markdown, fixes single quotes, trailing commas). Detects the phase from the otter name. Use this immediately after invoke_agent() to get a validated manifest phase object.",
|
|
1538
1877
|
{
|
|
1539
|
-
otterName:
|
|
1540
|
-
responseText:
|
|
1878
|
+
otterName: z9.string().describe('The agent name, e.g. "stackwright-pro-api-otter"'),
|
|
1879
|
+
responseText: z9.string().describe("Raw text response from the otter's QUESTION_COLLECTION_MODE invocation")
|
|
1541
1880
|
},
|
|
1542
1881
|
async ({ otterName, responseText }) => {
|
|
1543
1882
|
const { result, isError } = handleParseOtterResponse({ otterName, responseText });
|
|
@@ -1551,16 +1890,18 @@ function registerOrchestrationTools(server2) {
|
|
|
1551
1890
|
"stackwright_pro_save_manifest",
|
|
1552
1891
|
"Write the question manifest to .stackwright/question-manifest.json. Call this after collecting and parsing questions from all otters via stackwright_pro_parse_otter_response.",
|
|
1553
1892
|
{
|
|
1554
|
-
phases:
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1893
|
+
phases: jsonCoerce(
|
|
1894
|
+
z9.array(
|
|
1895
|
+
z9.object({
|
|
1896
|
+
phase: z9.string(),
|
|
1897
|
+
otter: z9.string(),
|
|
1898
|
+
questions: z9.array(z9.any()),
|
|
1899
|
+
requiredPackages: z9.object({
|
|
1900
|
+
dependencies: z9.record(z9.string(), z9.string()).optional(),
|
|
1901
|
+
devPackages: z9.record(z9.string(), z9.string()).optional()
|
|
1902
|
+
}).optional()
|
|
1903
|
+
})
|
|
1904
|
+
)
|
|
1564
1905
|
).describe("Array of parsed phase objects from stackwright_pro_parse_otter_response")
|
|
1565
1906
|
},
|
|
1566
1907
|
async ({ phases }) => {
|
|
@@ -1575,15 +1916,37 @@ function registerOrchestrationTools(server2) {
|
|
|
1575
1916
|
"stackwright_pro_save_phase_answers",
|
|
1576
1917
|
"Save user answers for a phase to .stackwright/answers/{phase}.json. Pass rawAnswers directly from ask_user_question and the original manifest questions for label-to-value reverse mapping.",
|
|
1577
1918
|
{
|
|
1578
|
-
phase:
|
|
1579
|
-
rawAnswers:
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1919
|
+
phase: z9.string().describe('Phase name, e.g. "designer"'),
|
|
1920
|
+
rawAnswers: jsonCoerce(
|
|
1921
|
+
z9.array(
|
|
1922
|
+
z9.object({
|
|
1923
|
+
question_header: z9.string().min(1, "question_header must not be empty"),
|
|
1924
|
+
selected_options: z9.array(z9.string()).min(1, "selected_options must have at least one entry"),
|
|
1925
|
+
other_text: z9.string().nullable().optional()
|
|
1926
|
+
})
|
|
1927
|
+
)
|
|
1928
|
+
).describe(
|
|
1929
|
+
"Answers as returned by ask_user_question \u2014 pass the native array, not a JSON string"
|
|
1930
|
+
),
|
|
1931
|
+
questions: jsonCoerce(
|
|
1932
|
+
z9.array(
|
|
1933
|
+
z9.object({
|
|
1934
|
+
id: z9.string(),
|
|
1935
|
+
question: z9.string(),
|
|
1936
|
+
type: z9.string(),
|
|
1937
|
+
options: z9.array(z9.object({ label: z9.string(), value: z9.string() })).optional(),
|
|
1938
|
+
required: z9.boolean().optional(),
|
|
1939
|
+
default: z9.union([z9.string(), z9.boolean(), z9.array(z9.string())]).optional(),
|
|
1940
|
+
help: z9.string().optional(),
|
|
1941
|
+
dependsOn: z9.object({
|
|
1942
|
+
questionId: z9.string(),
|
|
1943
|
+
value: z9.union([z9.string(), z9.array(z9.string())])
|
|
1944
|
+
}).optional()
|
|
1945
|
+
}).passthrough()
|
|
1946
|
+
).optional()
|
|
1947
|
+
).describe(
|
|
1948
|
+
"Original manifest questions for label\u2192value reverse-mapping \u2014 pass the native array, not a JSON string"
|
|
1949
|
+
)
|
|
1587
1950
|
},
|
|
1588
1951
|
async ({ phase, rawAnswers, questions }) => {
|
|
1589
1952
|
const { text, isError } = handleSavePhaseAnswers({
|
|
@@ -1601,7 +1964,7 @@ function registerOrchestrationTools(server2) {
|
|
|
1601
1964
|
"stackwright_pro_read_phase_answers",
|
|
1602
1965
|
"Read saved answers for a phase from .stackwright/answers/{phase}.json. Returns { missing: true } when no answers exist yet \u2014 use this to skip phases safely in the execution loop.",
|
|
1603
1966
|
{
|
|
1604
|
-
phase:
|
|
1967
|
+
phase: z9.string().describe('Phase name, e.g. "designer"')
|
|
1605
1968
|
},
|
|
1606
1969
|
async ({ phase }) => {
|
|
1607
1970
|
const { text, isError } = handleReadPhaseAnswers({ phase });
|
|
@@ -1615,7 +1978,7 @@ function registerOrchestrationTools(server2) {
|
|
|
1615
1978
|
"stackwright_pro_get_otter_name",
|
|
1616
1979
|
"Get the agent name for a phase (e.g. 'designer' \u2192 'stackwright-pro-designer-otter'). Use this in the execution loop to invoke the correct specialist otter without hardcoding names in the prompt.",
|
|
1617
1980
|
{
|
|
1618
|
-
phase:
|
|
1981
|
+
phase: z9.string().describe('Phase name, e.g. "designer", "api", "pages"')
|
|
1619
1982
|
},
|
|
1620
1983
|
async ({ phase }) => {
|
|
1621
1984
|
const { text, isError } = handleGetOtterName({ phase });
|
|
@@ -1628,52 +1991,428 @@ function registerOrchestrationTools(server2) {
|
|
|
1628
1991
|
}
|
|
1629
1992
|
|
|
1630
1993
|
// src/tools/pipeline.ts
|
|
1631
|
-
import { z as
|
|
1632
|
-
import { readFileSync as
|
|
1994
|
+
import { z as z11 } from "zod";
|
|
1995
|
+
import { readFileSync as readFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync5, mkdirSync as mkdirSync4, lstatSync as lstatSync5 } from "fs";
|
|
1996
|
+
import { join as join4 } from "path";
|
|
1997
|
+
import { createHash as createHash3 } from "crypto";
|
|
1998
|
+
|
|
1999
|
+
// src/artifact-signing.ts
|
|
2000
|
+
import {
|
|
2001
|
+
createHash as createHash2,
|
|
2002
|
+
generateKeyPairSync,
|
|
2003
|
+
createPublicKey,
|
|
2004
|
+
createPrivateKey,
|
|
2005
|
+
sign,
|
|
2006
|
+
verify,
|
|
2007
|
+
timingSafeEqual
|
|
2008
|
+
} from "crypto";
|
|
2009
|
+
import {
|
|
2010
|
+
readFileSync as readFileSync3,
|
|
2011
|
+
writeFileSync as writeFileSync3,
|
|
2012
|
+
existsSync as existsSync4,
|
|
2013
|
+
mkdirSync as mkdirSync3,
|
|
2014
|
+
lstatSync as lstatSync4,
|
|
2015
|
+
unlinkSync,
|
|
2016
|
+
readdirSync
|
|
2017
|
+
} from "fs";
|
|
1633
2018
|
import { join as join3 } from "path";
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
}
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
2019
|
+
import { z as z10 } from "zod";
|
|
2020
|
+
var ALGORITHM = "ECDSA-P384-SHA384";
|
|
2021
|
+
var KEY_FILE = "pipeline-keys.json";
|
|
2022
|
+
var KEY_DIR = ".stackwright";
|
|
2023
|
+
var SIGNATURE_MANIFEST = "signatures.json";
|
|
2024
|
+
var ARTIFACTS_DIR = ".stackwright/artifacts";
|
|
2025
|
+
function rejectSymlink(filePath, context) {
|
|
2026
|
+
if (!existsSync4(filePath)) return;
|
|
2027
|
+
const stat = lstatSync4(filePath);
|
|
2028
|
+
if (stat.isSymbolicLink()) {
|
|
2029
|
+
throw new Error(`Security: refusing to follow symlink at ${context}: ${filePath}`);
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
function computeSha384(data) {
|
|
2033
|
+
return createHash2("sha384").update(data).digest("hex");
|
|
2034
|
+
}
|
|
2035
|
+
function safeDigestEqual(a, b) {
|
|
2036
|
+
if (a.length !== b.length) return false;
|
|
2037
|
+
return timingSafeEqual(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
|
|
2038
|
+
}
|
|
2039
|
+
function emptyManifest() {
|
|
2040
|
+
return {
|
|
2041
|
+
version: "1.0",
|
|
2042
|
+
algorithm: ALGORITHM,
|
|
2043
|
+
signatures: {}
|
|
2044
|
+
};
|
|
2045
|
+
}
|
|
2046
|
+
function initPipelineKeys(cwd) {
|
|
2047
|
+
const keyDir = join3(cwd, KEY_DIR);
|
|
2048
|
+
const keyPath = join3(keyDir, KEY_FILE);
|
|
2049
|
+
rejectSymlink(keyPath, "pipeline-keys.json");
|
|
2050
|
+
mkdirSync3(keyDir, { recursive: true });
|
|
2051
|
+
const { publicKey, privateKey } = generateKeyPairSync("ec", {
|
|
2052
|
+
namedCurve: "P-384"
|
|
2053
|
+
});
|
|
2054
|
+
const publicKeyPem = publicKey.export({ type: "spki", format: "pem" });
|
|
2055
|
+
const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" });
|
|
2056
|
+
const fingerprint = createHash2("sha256").update(publicKeyPem).digest("hex");
|
|
2057
|
+
const keyFile = {
|
|
2058
|
+
version: "1.0",
|
|
2059
|
+
algorithm: ALGORITHM,
|
|
2060
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2061
|
+
publicKeyPem,
|
|
2062
|
+
privateKeyPem
|
|
2063
|
+
};
|
|
2064
|
+
writeFileSync3(keyPath, JSON.stringify(keyFile, null, 2), { encoding: "utf-8" });
|
|
2065
|
+
return { publicKeyPem, fingerprint };
|
|
2066
|
+
}
|
|
2067
|
+
function loadPipelineKeys(cwd) {
|
|
2068
|
+
const keyPath = join3(cwd, KEY_DIR, KEY_FILE);
|
|
2069
|
+
rejectSymlink(keyPath, "pipeline-keys.json");
|
|
2070
|
+
if (!existsSync4(keyPath)) {
|
|
2071
|
+
throw new Error("Pipeline keys not found \u2014 call initPipelineKeys() first");
|
|
2072
|
+
}
|
|
2073
|
+
let raw;
|
|
2074
|
+
try {
|
|
2075
|
+
raw = readFileSync3(keyPath, "utf-8");
|
|
2076
|
+
} catch (err) {
|
|
2077
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2078
|
+
throw new Error(`Cannot read pipeline keys: ${msg}`, { cause: err });
|
|
2079
|
+
}
|
|
2080
|
+
let parsed;
|
|
2081
|
+
try {
|
|
2082
|
+
parsed = JSON.parse(raw);
|
|
2083
|
+
} catch {
|
|
2084
|
+
throw new Error("Pipeline keys file is not valid JSON");
|
|
2085
|
+
}
|
|
2086
|
+
if (typeof parsed.publicKeyPem !== "string" || !parsed.publicKeyPem.includes("-----BEGIN PUBLIC KEY-----")) {
|
|
2087
|
+
throw new Error("Invalid public key PEM in pipeline keys file");
|
|
2088
|
+
}
|
|
2089
|
+
if (typeof parsed.privateKeyPem !== "string" || !parsed.privateKeyPem.includes("-----BEGIN")) {
|
|
2090
|
+
throw new Error("Invalid private key PEM in pipeline keys file");
|
|
2091
|
+
}
|
|
2092
|
+
const publicKey = createPublicKey(parsed.publicKeyPem);
|
|
2093
|
+
const privateKey = createPrivateKey(parsed.privateKeyPem);
|
|
2094
|
+
return { privateKey, publicKey };
|
|
2095
|
+
}
|
|
2096
|
+
function signArtifact(artifactBytes, privateKey) {
|
|
2097
|
+
const digest = computeSha384(artifactBytes);
|
|
2098
|
+
const sig = sign("SHA384", artifactBytes, privateKey);
|
|
2099
|
+
return {
|
|
2100
|
+
digest,
|
|
2101
|
+
signature: sig.toString("base64"),
|
|
2102
|
+
algorithm: ALGORITHM,
|
|
2103
|
+
signedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2104
|
+
};
|
|
2105
|
+
}
|
|
2106
|
+
function verifyArtifact(artifactBytes, signature, publicKey) {
|
|
2107
|
+
const sigValid = verify(
|
|
2108
|
+
"SHA384",
|
|
2109
|
+
artifactBytes,
|
|
2110
|
+
publicKey,
|
|
2111
|
+
Buffer.from(signature.signature, "base64")
|
|
2112
|
+
);
|
|
2113
|
+
if (!sigValid) return false;
|
|
2114
|
+
const actualDigest = computeSha384(artifactBytes);
|
|
2115
|
+
return safeDigestEqual(actualDigest, signature.digest);
|
|
2116
|
+
}
|
|
2117
|
+
function loadSignatureManifest(cwd) {
|
|
2118
|
+
const manifestPath = join3(cwd, ARTIFACTS_DIR, SIGNATURE_MANIFEST);
|
|
2119
|
+
rejectSymlink(manifestPath, "signatures.json");
|
|
2120
|
+
if (!existsSync4(manifestPath)) return emptyManifest();
|
|
2121
|
+
let raw;
|
|
2122
|
+
try {
|
|
2123
|
+
raw = readFileSync3(manifestPath, "utf-8");
|
|
2124
|
+
} catch (err) {
|
|
2125
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2126
|
+
throw new Error(`Cannot read signature manifest: ${msg}`, { cause: err });
|
|
2127
|
+
}
|
|
2128
|
+
try {
|
|
2129
|
+
const parsed = JSON.parse(raw);
|
|
2130
|
+
if (parsed.version !== "1.0" || typeof parsed.signatures !== "object") {
|
|
2131
|
+
throw new Error("Malformed signature manifest: invalid version or missing signatures object");
|
|
2132
|
+
}
|
|
2133
|
+
return parsed;
|
|
2134
|
+
} catch (err) {
|
|
2135
|
+
if (err instanceof Error && err.message.startsWith("Malformed")) throw err;
|
|
2136
|
+
throw new Error("Signature manifest is not valid JSON", { cause: err });
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
function saveArtifactSignature(cwd, artifactFilename, sig, signerOtter) {
|
|
2140
|
+
const artifactsDir = join3(cwd, ARTIFACTS_DIR);
|
|
2141
|
+
const manifestPath = join3(artifactsDir, SIGNATURE_MANIFEST);
|
|
2142
|
+
rejectSymlink(manifestPath, "signatures.json (save)");
|
|
2143
|
+
mkdirSync3(artifactsDir, { recursive: true });
|
|
2144
|
+
const manifest = loadSignatureManifest(cwd);
|
|
2145
|
+
manifest.signatures[artifactFilename] = {
|
|
2146
|
+
...sig,
|
|
2147
|
+
signedBy: signerOtter
|
|
2148
|
+
};
|
|
2149
|
+
writeFileSync3(manifestPath, JSON.stringify(manifest, null, 2), { encoding: "utf-8" });
|
|
2150
|
+
}
|
|
2151
|
+
function getArtifactSignature(cwd, artifactFilename) {
|
|
2152
|
+
const manifest = loadSignatureManifest(cwd);
|
|
2153
|
+
const entry = manifest.signatures[artifactFilename];
|
|
2154
|
+
if (!entry) return null;
|
|
2155
|
+
return {
|
|
2156
|
+
digest: entry.digest,
|
|
2157
|
+
signature: entry.signature,
|
|
2158
|
+
algorithm: entry.algorithm,
|
|
2159
|
+
signedAt: entry.signedAt
|
|
2160
|
+
};
|
|
2161
|
+
}
|
|
2162
|
+
function emitSignatureAuditEvent(params) {
|
|
2163
|
+
const record = JSON.stringify({
|
|
2164
|
+
level: "AUDIT",
|
|
2165
|
+
event: "ARTIFACT_SIGNATURE_FAIL",
|
|
2166
|
+
timestamp: params.timestamp,
|
|
2167
|
+
source: params.source,
|
|
2168
|
+
artifactFilename: params.artifactFilename,
|
|
2169
|
+
expectedDigest: params.expectedDigest,
|
|
2170
|
+
actualDigest: params.actualDigest,
|
|
2171
|
+
phase: params.phase
|
|
2172
|
+
});
|
|
2173
|
+
process.stderr.write(`ARTIFACT_SIGNATURE_FAIL ${record}
|
|
2174
|
+
`);
|
|
2175
|
+
}
|
|
2176
|
+
function registerArtifactSigningTools(server2) {
|
|
2177
|
+
server2.tool(
|
|
2178
|
+
"stackwright_pro_verify_artifact_signatures",
|
|
2179
|
+
"Verify ECDSA P-384 signatures for all pipeline artifacts in .stackwright/artifacts/. Auto-discovers keys from .stackwright/pipeline-keys.json. Returns per-artifact verification status.",
|
|
2180
|
+
{
|
|
2181
|
+
cwd: z10.string().optional().describe("Project root directory. Defaults to process.cwd().")
|
|
2182
|
+
},
|
|
2183
|
+
async ({ cwd: cwdParam }) => {
|
|
2184
|
+
const cwd = cwdParam ?? process.cwd();
|
|
2185
|
+
let publicKey;
|
|
2186
|
+
try {
|
|
2187
|
+
const keys = loadPipelineKeys(cwd);
|
|
2188
|
+
publicKey = keys.publicKey;
|
|
2189
|
+
} catch (err) {
|
|
2190
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2191
|
+
return {
|
|
2192
|
+
content: [
|
|
2193
|
+
{
|
|
2194
|
+
type: "text",
|
|
2195
|
+
text: JSON.stringify({
|
|
2196
|
+
error: true,
|
|
2197
|
+
message: `Cannot load pipeline keys: ${msg}`
|
|
2198
|
+
})
|
|
2199
|
+
}
|
|
2200
|
+
],
|
|
2201
|
+
isError: true
|
|
2202
|
+
};
|
|
2203
|
+
}
|
|
2204
|
+
let manifest;
|
|
2205
|
+
try {
|
|
2206
|
+
manifest = loadSignatureManifest(cwd);
|
|
2207
|
+
} catch (err) {
|
|
2208
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2209
|
+
return {
|
|
2210
|
+
content: [
|
|
2211
|
+
{
|
|
2212
|
+
type: "text",
|
|
2213
|
+
text: JSON.stringify({
|
|
2214
|
+
error: true,
|
|
2215
|
+
message: `Cannot load signature manifest: ${msg}`
|
|
2216
|
+
})
|
|
2217
|
+
}
|
|
2218
|
+
],
|
|
2219
|
+
isError: true
|
|
2220
|
+
};
|
|
2221
|
+
}
|
|
2222
|
+
const artifactsPath = join3(cwd, ARTIFACTS_DIR);
|
|
2223
|
+
let artifactFiles = [];
|
|
2224
|
+
try {
|
|
2225
|
+
if (existsSync4(artifactsPath)) {
|
|
2226
|
+
artifactFiles = readdirSync(artifactsPath).filter(
|
|
2227
|
+
(f) => f.endsWith(".json") && f !== SIGNATURE_MANIFEST
|
|
2228
|
+
);
|
|
2229
|
+
}
|
|
2230
|
+
} catch {
|
|
2231
|
+
}
|
|
2232
|
+
const results = [];
|
|
2233
|
+
let hasFailure = false;
|
|
2234
|
+
for (const filename of artifactFiles) {
|
|
2235
|
+
const filePath = join3(artifactsPath, filename);
|
|
2236
|
+
try {
|
|
2237
|
+
rejectSymlink(filePath, `artifact ${filename}`);
|
|
2238
|
+
} catch {
|
|
2239
|
+
results.push({
|
|
2240
|
+
filename,
|
|
2241
|
+
verified: false,
|
|
2242
|
+
error: "Refusing to verify symlink"
|
|
2243
|
+
});
|
|
2244
|
+
hasFailure = true;
|
|
2245
|
+
continue;
|
|
2246
|
+
}
|
|
2247
|
+
let artifactBytes;
|
|
2248
|
+
try {
|
|
2249
|
+
artifactBytes = readFileSync3(filePath);
|
|
2250
|
+
} catch (err) {
|
|
2251
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2252
|
+
results.push({
|
|
2253
|
+
filename,
|
|
2254
|
+
verified: false,
|
|
2255
|
+
error: `Cannot read artifact: ${msg}`
|
|
2256
|
+
});
|
|
2257
|
+
hasFailure = true;
|
|
2258
|
+
continue;
|
|
2259
|
+
}
|
|
2260
|
+
const entry = manifest.signatures[filename];
|
|
2261
|
+
if (!entry) {
|
|
2262
|
+
results.push({
|
|
2263
|
+
filename,
|
|
2264
|
+
verified: false,
|
|
2265
|
+
error: "No signature found in manifest"
|
|
2266
|
+
});
|
|
2267
|
+
hasFailure = true;
|
|
2268
|
+
continue;
|
|
2269
|
+
}
|
|
2270
|
+
const sig = {
|
|
2271
|
+
digest: entry.digest,
|
|
2272
|
+
signature: entry.signature,
|
|
2273
|
+
algorithm: entry.algorithm,
|
|
2274
|
+
signedAt: entry.signedAt
|
|
2275
|
+
};
|
|
2276
|
+
const verified = verifyArtifact(artifactBytes, sig, publicKey);
|
|
2277
|
+
if (!verified) {
|
|
2278
|
+
const actualDigest = computeSha384(artifactBytes);
|
|
2279
|
+
emitSignatureAuditEvent({
|
|
2280
|
+
artifactFilename: filename,
|
|
2281
|
+
expectedDigest: sig.digest,
|
|
2282
|
+
actualDigest,
|
|
2283
|
+
phase: entry.signedBy ?? "unknown",
|
|
2284
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2285
|
+
source: "stackwright_pro_verify_artifact_signatures"
|
|
2286
|
+
});
|
|
2287
|
+
results.push({
|
|
2288
|
+
filename,
|
|
2289
|
+
verified: false,
|
|
2290
|
+
error: `Signature verification failed \u2014 artifact may have been tampered with`,
|
|
2291
|
+
signedBy: entry.signedBy,
|
|
2292
|
+
signedAt: entry.signedAt
|
|
2293
|
+
});
|
|
2294
|
+
hasFailure = true;
|
|
2295
|
+
} else {
|
|
2296
|
+
results.push({
|
|
2297
|
+
filename,
|
|
2298
|
+
verified: true,
|
|
2299
|
+
signedBy: entry.signedBy,
|
|
2300
|
+
signedAt: entry.signedAt
|
|
2301
|
+
});
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
for (const manifestFilename of Object.keys(manifest.signatures)) {
|
|
2305
|
+
if (!artifactFiles.includes(manifestFilename)) {
|
|
2306
|
+
results.push({
|
|
2307
|
+
filename: manifestFilename,
|
|
2308
|
+
verified: false,
|
|
2309
|
+
error: "Artifact referenced in manifest but missing from disk"
|
|
2310
|
+
});
|
|
2311
|
+
hasFailure = true;
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
const verifiedCount = results.filter((r) => r.verified).length;
|
|
2315
|
+
const failedCount = results.filter((r) => !r.verified).length;
|
|
2316
|
+
return {
|
|
2317
|
+
content: [
|
|
2318
|
+
{
|
|
2319
|
+
type: "text",
|
|
2320
|
+
text: JSON.stringify({
|
|
2321
|
+
totalArtifacts: artifactFiles.length,
|
|
2322
|
+
verifiedCount,
|
|
2323
|
+
failedCount,
|
|
2324
|
+
results,
|
|
2325
|
+
...hasFailure ? {
|
|
2326
|
+
error: "SIGNATURE VERIFICATION FAILED: One or more artifact signatures are invalid. Do not proceed \u2014 artifacts may have been tampered with."
|
|
2327
|
+
} : {}
|
|
2328
|
+
})
|
|
2329
|
+
}
|
|
2330
|
+
],
|
|
2331
|
+
isError: hasFailure
|
|
2332
|
+
};
|
|
2333
|
+
}
|
|
2334
|
+
);
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
// src/tools/pipeline.ts
|
|
2338
|
+
import { WorkflowFileSchema, authConfigSchema } from "@stackwright-pro/types";
|
|
2339
|
+
var PHASE_ORDER = [
|
|
2340
|
+
"designer",
|
|
2341
|
+
"theme",
|
|
2342
|
+
"scaffold",
|
|
2343
|
+
// generates app/ directory from config
|
|
2344
|
+
"api",
|
|
2345
|
+
"data",
|
|
2346
|
+
"geo",
|
|
2347
|
+
"workflow",
|
|
2348
|
+
"services",
|
|
2349
|
+
"pages",
|
|
2350
|
+
"dashboard",
|
|
2351
|
+
"auth",
|
|
2352
|
+
"polish"
|
|
2353
|
+
];
|
|
2354
|
+
var PHASE_DEPENDENCIES = {
|
|
2355
|
+
designer: [],
|
|
2356
|
+
theme: ["designer"],
|
|
2357
|
+
scaffold: ["designer", "theme"],
|
|
2358
|
+
// needs stackwright.yml + theme-tokens
|
|
2359
|
+
api: [],
|
|
2360
|
+
data: ["api"],
|
|
2361
|
+
geo: ["data"],
|
|
2362
|
+
// workflow: no hard deps — uses service: references with Prism mock fallback
|
|
2363
|
+
// when API artifacts aren't available; roles come from user answers
|
|
2364
|
+
workflow: [],
|
|
2365
|
+
// services: needs API endpoints to know what to wire, and optionally
|
|
2366
|
+
// workflow states as trigger sources. Produces OpenAPI specs consumed
|
|
2367
|
+
// by pages and dashboard for typed data wiring.
|
|
2368
|
+
services: ["api", "workflow"],
|
|
2369
|
+
// pages/dashboard: depend on services for typed endpoint wiring (OpenAPI
|
|
2370
|
+
// specs) and on geo so the specialist prompt includes geo-manifest.json
|
|
2371
|
+
// — page/dashboard otters see which page slugs are already claimed and
|
|
2372
|
+
// won't attempt to overwrite them (swp-73c). 'api' is still transitive
|
|
2373
|
+
// through 'data'; auth removed — page-otter has graceful fallback.
|
|
2374
|
+
pages: ["designer", "theme", "data", "services", "geo", "scaffold"],
|
|
2375
|
+
dashboard: ["designer", "theme", "data", "services", "geo", "scaffold"],
|
|
2376
|
+
// auth is the penultimate phase — runs after all content-producing phases
|
|
2377
|
+
// so it can read pages, dashboard, workflow, and geo artifacts for route
|
|
2378
|
+
// protection and RBAC wiring. Skipped upstream phases still satisfy deps
|
|
2379
|
+
// (the foreman marks them executed=true), so auth always runs.
|
|
2380
|
+
auth: ["pages", "dashboard", "workflow", "geo"],
|
|
2381
|
+
// polish is the terminal phase — runs after auth so the landing page and
|
|
2382
|
+
// nav reflect the final set of protected routes and all generated pages.
|
|
2383
|
+
polish: ["auth"]
|
|
2384
|
+
};
|
|
2385
|
+
var PHASE_ARTIFACT = {
|
|
2386
|
+
designer: "design-language.json",
|
|
2387
|
+
theme: "theme-tokens.json",
|
|
2388
|
+
scaffold: "scaffold-manifest.json",
|
|
2389
|
+
api: "api-config.json",
|
|
2390
|
+
auth: "auth-config.json",
|
|
2391
|
+
data: "data-config.json",
|
|
2392
|
+
pages: "pages-manifest.json",
|
|
2393
|
+
dashboard: "dashboard-manifest.json",
|
|
2394
|
+
workflow: "workflow-config.json",
|
|
2395
|
+
services: "services-config.json",
|
|
2396
|
+
polish: "polish-manifest.json",
|
|
2397
|
+
geo: "geo-manifest.json"
|
|
2398
|
+
};
|
|
2399
|
+
var PHASE_TO_OTTER2 = {
|
|
2400
|
+
designer: "stackwright-pro-designer-otter",
|
|
2401
|
+
theme: "stackwright-pro-theme-otter",
|
|
2402
|
+
scaffold: "stackwright-pro-scaffold-otter",
|
|
2403
|
+
api: "stackwright-pro-api-otter",
|
|
2404
|
+
auth: "stackwright-pro-auth-otter",
|
|
2405
|
+
data: "stackwright-pro-data-otter",
|
|
2406
|
+
pages: "stackwright-pro-page-otter",
|
|
2407
|
+
dashboard: "stackwright-pro-dashboard-otter",
|
|
2408
|
+
workflow: "stackwright-pro-workflow-otter",
|
|
2409
|
+
services: "stackwright-services-otter",
|
|
2410
|
+
polish: "stackwright-pro-polish-otter",
|
|
2411
|
+
geo: "stackwright-pro-geo-otter"
|
|
2412
|
+
};
|
|
2413
|
+
function isValidPhase2(phase) {
|
|
2414
|
+
return PHASE_ORDER.includes(phase);
|
|
2415
|
+
}
|
|
1677
2416
|
function defaultPhaseStatus() {
|
|
1678
2417
|
return {
|
|
1679
2418
|
questionsCollected: false,
|
|
@@ -1699,11 +2438,11 @@ function createDefaultState() {
|
|
|
1699
2438
|
};
|
|
1700
2439
|
}
|
|
1701
2440
|
function statePath(cwd) {
|
|
1702
|
-
return
|
|
2441
|
+
return join4(cwd, ".stackwright", "pipeline-state.json");
|
|
1703
2442
|
}
|
|
1704
2443
|
function readState(cwd) {
|
|
1705
2444
|
const p = statePath(cwd);
|
|
1706
|
-
if (!
|
|
2445
|
+
if (!existsSync5(p)) return createDefaultState();
|
|
1707
2446
|
const raw = JSON.parse(safeReadSync(p));
|
|
1708
2447
|
if (typeof raw !== "object" || raw === null || raw.version !== "1.0") {
|
|
1709
2448
|
return createDefaultState();
|
|
@@ -1711,26 +2450,26 @@ function readState(cwd) {
|
|
|
1711
2450
|
return raw;
|
|
1712
2451
|
}
|
|
1713
2452
|
function safeWriteSync(filePath, content) {
|
|
1714
|
-
if (
|
|
1715
|
-
const stat =
|
|
2453
|
+
if (existsSync5(filePath)) {
|
|
2454
|
+
const stat = lstatSync5(filePath);
|
|
1716
2455
|
if (stat.isSymbolicLink()) {
|
|
1717
2456
|
throw new Error(`Refusing to write to symlink: ${filePath}`);
|
|
1718
2457
|
}
|
|
1719
2458
|
}
|
|
1720
|
-
|
|
2459
|
+
writeFileSync4(filePath, content);
|
|
1721
2460
|
}
|
|
1722
2461
|
function safeReadSync(filePath) {
|
|
1723
|
-
if (
|
|
1724
|
-
const stat =
|
|
2462
|
+
if (existsSync5(filePath)) {
|
|
2463
|
+
const stat = lstatSync5(filePath);
|
|
1725
2464
|
if (stat.isSymbolicLink()) {
|
|
1726
2465
|
throw new Error(`Refusing to read symlink: ${filePath}`);
|
|
1727
2466
|
}
|
|
1728
2467
|
}
|
|
1729
|
-
return
|
|
2468
|
+
return readFileSync4(filePath, "utf-8");
|
|
1730
2469
|
}
|
|
1731
2470
|
function writeState(cwd, state) {
|
|
1732
|
-
const dir =
|
|
1733
|
-
|
|
2471
|
+
const dir = join4(cwd, ".stackwright");
|
|
2472
|
+
mkdirSync4(dir, { recursive: true });
|
|
1734
2473
|
state.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1735
2474
|
safeWriteSync(statePath(cwd), JSON.stringify(state, null, 2) + "\n");
|
|
1736
2475
|
}
|
|
@@ -1751,6 +2490,15 @@ function handleGetPipelineState(_cwd) {
|
|
|
1751
2490
|
const cwd = _cwd ?? process.cwd();
|
|
1752
2491
|
try {
|
|
1753
2492
|
const state = readState(cwd);
|
|
2493
|
+
const keyPath = join4(cwd, ".stackwright", "pipeline-keys.json");
|
|
2494
|
+
if (!existsSync5(keyPath)) {
|
|
2495
|
+
try {
|
|
2496
|
+
const { fingerprint } = initPipelineKeys(cwd);
|
|
2497
|
+
state["signingKeyFingerprint"] = fingerprint;
|
|
2498
|
+
writeState(cwd, state);
|
|
2499
|
+
} catch {
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
1754
2502
|
return { text: JSON.stringify(state), isError: false };
|
|
1755
2503
|
} catch (err) {
|
|
1756
2504
|
return { text: JSON.stringify({ error: true, message: String(err) }), isError: true };
|
|
@@ -1758,7 +2506,7 @@ function handleGetPipelineState(_cwd) {
|
|
|
1758
2506
|
}
|
|
1759
2507
|
function handleSetPipelineState(input) {
|
|
1760
2508
|
const cwd = input._cwd ?? process.cwd();
|
|
1761
|
-
if (input.phase && !
|
|
2509
|
+
if (input.phase && !isValidPhase2(input.phase)) {
|
|
1762
2510
|
return {
|
|
1763
2511
|
text: JSON.stringify({
|
|
1764
2512
|
error: true,
|
|
@@ -1777,6 +2525,28 @@ function handleSetPipelineState(input) {
|
|
|
1777
2525
|
isError: true
|
|
1778
2526
|
};
|
|
1779
2527
|
}
|
|
2528
|
+
if (input.updates && input.updates.length > 0) {
|
|
2529
|
+
for (const update of input.updates) {
|
|
2530
|
+
if (!isValidPhase2(update.phase)) {
|
|
2531
|
+
return {
|
|
2532
|
+
text: JSON.stringify({
|
|
2533
|
+
error: true,
|
|
2534
|
+
message: `Invalid phase in batch update: ${update.phase}. Valid phases are: ${PHASE_ORDER.join(", ")}`
|
|
2535
|
+
}),
|
|
2536
|
+
isError: true
|
|
2537
|
+
};
|
|
2538
|
+
}
|
|
2539
|
+
if (!VALID_FIELDS.includes(update.field)) {
|
|
2540
|
+
return {
|
|
2541
|
+
text: JSON.stringify({
|
|
2542
|
+
error: true,
|
|
2543
|
+
message: `Invalid field in batch update: ${update.field}. Valid fields are: ${VALID_FIELDS.join(", ")}`
|
|
2544
|
+
}),
|
|
2545
|
+
isError: true
|
|
2546
|
+
};
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
1780
2550
|
try {
|
|
1781
2551
|
const state = readState(cwd);
|
|
1782
2552
|
if (input.status) {
|
|
@@ -1796,34 +2566,78 @@ function handleSetPipelineState(input) {
|
|
|
1796
2566
|
}
|
|
1797
2567
|
state.currentPhase = phase;
|
|
1798
2568
|
}
|
|
2569
|
+
if (input.updates && input.updates.length > 0) {
|
|
2570
|
+
for (const update of input.updates) {
|
|
2571
|
+
if (!state.phases[update.phase]) {
|
|
2572
|
+
state.phases[update.phase] = defaultPhaseStatus();
|
|
2573
|
+
}
|
|
2574
|
+
const batchPhaseState = state.phases[update.phase];
|
|
2575
|
+
batchPhaseState[update.field] = update.value;
|
|
2576
|
+
state.currentPhase = update.phase;
|
|
2577
|
+
}
|
|
2578
|
+
}
|
|
1799
2579
|
writeState(cwd, state);
|
|
1800
2580
|
return { text: JSON.stringify(state), isError: false };
|
|
1801
2581
|
} catch (err) {
|
|
1802
2582
|
return { text: JSON.stringify({ error: true, message: String(err) }), isError: true };
|
|
1803
2583
|
}
|
|
1804
2584
|
}
|
|
1805
|
-
function handleCheckExecutionReady(_cwd) {
|
|
2585
|
+
function handleCheckExecutionReady(_cwd, phase) {
|
|
1806
2586
|
const cwd = _cwd ?? process.cwd();
|
|
2587
|
+
if (phase) {
|
|
2588
|
+
if (!isValidPhase2(phase)) {
|
|
2589
|
+
return {
|
|
2590
|
+
text: JSON.stringify({
|
|
2591
|
+
error: true,
|
|
2592
|
+
message: `Invalid phase: ${phase}. Valid phases are: ${PHASE_ORDER.join(", ")}`
|
|
2593
|
+
}),
|
|
2594
|
+
isError: true
|
|
2595
|
+
};
|
|
2596
|
+
}
|
|
2597
|
+
const answerFile = join4(cwd, ".stackwright", "answers", `${phase}.json`);
|
|
2598
|
+
if (!existsSync5(answerFile)) {
|
|
2599
|
+
return {
|
|
2600
|
+
text: JSON.stringify({ ready: false, phase, reason: "Answer file not found" }),
|
|
2601
|
+
isError: false
|
|
2602
|
+
};
|
|
2603
|
+
}
|
|
2604
|
+
try {
|
|
2605
|
+
const raw = safeReadSync(answerFile);
|
|
2606
|
+
const parsed = JSON.parse(raw);
|
|
2607
|
+
if (typeof parsed["version"] !== "string" || typeof parsed["phase"] !== "string" || typeof parsed["answers"] !== "object" || parsed["answers"] === null) {
|
|
2608
|
+
return {
|
|
2609
|
+
text: JSON.stringify({ ready: false, phase, reason: "Answer file is malformed" }),
|
|
2610
|
+
isError: false
|
|
2611
|
+
};
|
|
2612
|
+
}
|
|
2613
|
+
return { text: JSON.stringify({ ready: true, phase }), isError: false };
|
|
2614
|
+
} catch {
|
|
2615
|
+
return {
|
|
2616
|
+
text: JSON.stringify({ ready: false, phase, reason: "Answer file could not be parsed" }),
|
|
2617
|
+
isError: false
|
|
2618
|
+
};
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
1807
2621
|
try {
|
|
1808
|
-
const answersDir =
|
|
2622
|
+
const answersDir = join4(cwd, ".stackwright", "answers");
|
|
1809
2623
|
const answeredPhases = [];
|
|
1810
2624
|
const missingPhases = [];
|
|
1811
|
-
for (const
|
|
1812
|
-
const answerFile =
|
|
1813
|
-
if (
|
|
2625
|
+
for (const phase2 of PHASE_ORDER) {
|
|
2626
|
+
const answerFile = join4(answersDir, `${phase2}.json`);
|
|
2627
|
+
if (existsSync5(answerFile)) {
|
|
1814
2628
|
try {
|
|
1815
2629
|
const raw = safeReadSync(answerFile);
|
|
1816
2630
|
const parsed = JSON.parse(raw);
|
|
1817
2631
|
if (typeof parsed["version"] !== "string" || typeof parsed["phase"] !== "string" || typeof parsed["answers"] !== "object" || parsed["answers"] === null) {
|
|
1818
|
-
missingPhases.push(
|
|
2632
|
+
missingPhases.push(phase2);
|
|
1819
2633
|
continue;
|
|
1820
2634
|
}
|
|
1821
|
-
answeredPhases.push(
|
|
2635
|
+
answeredPhases.push(phase2);
|
|
1822
2636
|
} catch {
|
|
1823
|
-
missingPhases.push(
|
|
2637
|
+
missingPhases.push(phase2);
|
|
1824
2638
|
}
|
|
1825
2639
|
} else {
|
|
1826
|
-
missingPhases.push(
|
|
2640
|
+
missingPhases.push(phase2);
|
|
1827
2641
|
}
|
|
1828
2642
|
}
|
|
1829
2643
|
return {
|
|
@@ -1839,18 +2653,74 @@ function handleCheckExecutionReady(_cwd) {
|
|
|
1839
2653
|
return { text: JSON.stringify({ error: true, message: String(err) }), isError: true };
|
|
1840
2654
|
}
|
|
1841
2655
|
}
|
|
2656
|
+
function handleGetReadyPhases(_cwd) {
|
|
2657
|
+
const cwd = _cwd ?? process.cwd();
|
|
2658
|
+
try {
|
|
2659
|
+
const state = readState(cwd);
|
|
2660
|
+
const completed = [];
|
|
2661
|
+
const ready = [];
|
|
2662
|
+
const blocked = [];
|
|
2663
|
+
for (const phase of PHASE_ORDER) {
|
|
2664
|
+
const ps = state.phases[phase];
|
|
2665
|
+
if (ps?.executed) {
|
|
2666
|
+
completed.push(phase);
|
|
2667
|
+
continue;
|
|
2668
|
+
}
|
|
2669
|
+
const deps = PHASE_DEPENDENCIES[phase];
|
|
2670
|
+
const unmetDeps = deps.filter((dep) => !state.phases[dep]?.executed);
|
|
2671
|
+
if (unmetDeps.length === 0) {
|
|
2672
|
+
ready.push(phase);
|
|
2673
|
+
} else {
|
|
2674
|
+
blocked.push(phase);
|
|
2675
|
+
}
|
|
2676
|
+
}
|
|
2677
|
+
return {
|
|
2678
|
+
text: JSON.stringify({
|
|
2679
|
+
readyPhases: ready,
|
|
2680
|
+
completedPhases: completed,
|
|
2681
|
+
blockedPhases: blocked,
|
|
2682
|
+
waveSize: ready.length,
|
|
2683
|
+
allComplete: ready.length === 0 && blocked.length === 0
|
|
2684
|
+
}),
|
|
2685
|
+
isError: false
|
|
2686
|
+
};
|
|
2687
|
+
} catch (err) {
|
|
2688
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2689
|
+
return { text: JSON.stringify({ error: true, message }), isError: true };
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
1842
2692
|
function handleListArtifacts(_cwd) {
|
|
1843
2693
|
const cwd = _cwd ?? process.cwd();
|
|
1844
2694
|
try {
|
|
1845
|
-
const artifactsDir =
|
|
2695
|
+
const artifactsDir = join4(cwd, ".stackwright", "artifacts");
|
|
2696
|
+
let manifest = null;
|
|
2697
|
+
try {
|
|
2698
|
+
manifest = loadSignatureManifest(cwd);
|
|
2699
|
+
} catch {
|
|
2700
|
+
}
|
|
1846
2701
|
const artifacts = [];
|
|
1847
2702
|
let completedCount = 0;
|
|
1848
2703
|
for (const phase of PHASE_ORDER) {
|
|
1849
2704
|
const expectedFile = PHASE_ARTIFACT[phase];
|
|
1850
|
-
const fullPath =
|
|
1851
|
-
const exists =
|
|
2705
|
+
const fullPath = join4(artifactsDir, expectedFile);
|
|
2706
|
+
const exists = existsSync5(fullPath);
|
|
1852
2707
|
if (exists) completedCount++;
|
|
1853
|
-
|
|
2708
|
+
let signed = false;
|
|
2709
|
+
let signatureValid = null;
|
|
2710
|
+
if (exists && manifest) {
|
|
2711
|
+
const entry = manifest.signatures[expectedFile];
|
|
2712
|
+
if (entry) {
|
|
2713
|
+
signed = true;
|
|
2714
|
+
try {
|
|
2715
|
+
const rawBytes = Buffer.from(safeReadSync(fullPath), "utf-8");
|
|
2716
|
+
const { publicKey } = loadPipelineKeys(cwd);
|
|
2717
|
+
signatureValid = verifyArtifact(rawBytes, entry, publicKey);
|
|
2718
|
+
} catch {
|
|
2719
|
+
signatureValid = null;
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2723
|
+
artifacts.push({ phase, expectedFile, exists, path: fullPath, signed, signatureValid });
|
|
1854
2724
|
}
|
|
1855
2725
|
return {
|
|
1856
2726
|
text: JSON.stringify({ artifacts, completedCount, totalCount: PHASE_ORDER.length }),
|
|
@@ -1863,7 +2733,7 @@ function handleListArtifacts(_cwd) {
|
|
|
1863
2733
|
function handleWritePhaseQuestions(input) {
|
|
1864
2734
|
const cwd = input._cwd ?? process.cwd();
|
|
1865
2735
|
const { phase, responseText } = input;
|
|
1866
|
-
if (!
|
|
2736
|
+
if (!isValidPhase2(phase)) {
|
|
1867
2737
|
return {
|
|
1868
2738
|
text: JSON.stringify({ error: true, message: `Unknown phase: ${phase}` }),
|
|
1869
2739
|
isError: true
|
|
@@ -1886,9 +2756,9 @@ function handleWritePhaseQuestions(input) {
|
|
|
1886
2756
|
}
|
|
1887
2757
|
} catch {
|
|
1888
2758
|
}
|
|
1889
|
-
const questionsDir =
|
|
1890
|
-
|
|
1891
|
-
const filePath =
|
|
2759
|
+
const questionsDir = join4(cwd, ".stackwright", "questions");
|
|
2760
|
+
mkdirSync4(questionsDir, { recursive: true });
|
|
2761
|
+
const filePath = join4(questionsDir, `${phase}.json`);
|
|
1892
2762
|
const payload = {
|
|
1893
2763
|
version: "1.0",
|
|
1894
2764
|
phase,
|
|
@@ -1921,43 +2791,88 @@ function handleWritePhaseQuestions(input) {
|
|
|
1921
2791
|
function handleBuildSpecialistPrompt(input) {
|
|
1922
2792
|
const cwd = input._cwd ?? process.cwd();
|
|
1923
2793
|
const { phase } = input;
|
|
1924
|
-
if (!
|
|
2794
|
+
if (!isValidPhase2(phase)) {
|
|
1925
2795
|
return {
|
|
1926
2796
|
text: JSON.stringify({ error: true, message: `Unknown phase: ${phase}` }),
|
|
1927
2797
|
isError: true
|
|
1928
2798
|
};
|
|
1929
2799
|
}
|
|
1930
2800
|
try {
|
|
1931
|
-
const answersPath =
|
|
2801
|
+
const answersPath = join4(cwd, ".stackwright", "answers", `${phase}.json`);
|
|
1932
2802
|
let answers = {};
|
|
1933
|
-
if (
|
|
2803
|
+
if (existsSync5(answersPath)) {
|
|
1934
2804
|
answers = JSON.parse(safeReadSync(answersPath));
|
|
1935
2805
|
}
|
|
2806
|
+
let buildContextText = "";
|
|
2807
|
+
const buildContextPath = join4(cwd, ".stackwright", "build-context.json");
|
|
2808
|
+
if (existsSync5(buildContextPath)) {
|
|
2809
|
+
try {
|
|
2810
|
+
const bcRaw = JSON.parse(safeReadSync(buildContextPath));
|
|
2811
|
+
if (typeof bcRaw.buildContext === "string" && bcRaw.buildContext.trim().length > 0) {
|
|
2812
|
+
buildContextText = bcRaw.buildContext.trim();
|
|
2813
|
+
}
|
|
2814
|
+
} catch {
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
1936
2817
|
const deps = PHASE_DEPENDENCIES[phase];
|
|
1937
2818
|
const artifactSections = [];
|
|
1938
2819
|
const missingDependencies = [];
|
|
1939
2820
|
for (const dep of deps) {
|
|
1940
2821
|
const artifactFile = PHASE_ARTIFACT[dep];
|
|
1941
|
-
const artifactPath =
|
|
1942
|
-
if (
|
|
1943
|
-
const
|
|
2822
|
+
const artifactPath = join4(cwd, ".stackwright", "artifacts", artifactFile);
|
|
2823
|
+
if (existsSync5(artifactPath)) {
|
|
2824
|
+
const rawContent = safeReadSync(artifactPath);
|
|
2825
|
+
const rawBytes = Buffer.from(rawContent, "utf-8");
|
|
2826
|
+
const content = JSON.parse(rawContent);
|
|
2827
|
+
let signatureVerified = false;
|
|
2828
|
+
let signatureAvailable = false;
|
|
2829
|
+
try {
|
|
2830
|
+
const sig = getArtifactSignature(cwd, artifactFile);
|
|
2831
|
+
if (sig) {
|
|
2832
|
+
signatureAvailable = true;
|
|
2833
|
+
const { publicKey } = loadPipelineKeys(cwd);
|
|
2834
|
+
signatureVerified = verifyArtifact(rawBytes, sig, publicKey);
|
|
2835
|
+
if (!signatureVerified) {
|
|
2836
|
+
const actualDigest = createHash3("sha384").update(rawBytes).digest("hex");
|
|
2837
|
+
emitSignatureAuditEvent({
|
|
2838
|
+
artifactFilename: artifactFile,
|
|
2839
|
+
expectedDigest: sig.digest,
|
|
2840
|
+
actualDigest,
|
|
2841
|
+
phase: dep,
|
|
2842
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2843
|
+
source: "stackwright_pro_build_specialist_prompt"
|
|
2844
|
+
});
|
|
2845
|
+
missingDependencies.push(dep);
|
|
2846
|
+
artifactSections.push(
|
|
2847
|
+
`[${artifactFile}]:
|
|
2848
|
+
(integrity check failed: ECDSA-P384 signature verification failed \u2014 artifact may have been tampered with)`
|
|
2849
|
+
);
|
|
2850
|
+
continue;
|
|
2851
|
+
}
|
|
2852
|
+
}
|
|
2853
|
+
} catch {
|
|
2854
|
+
}
|
|
1944
2855
|
const expectedOtter = PHASE_TO_OTTER2[dep];
|
|
1945
2856
|
const artifactOtter = content["generatedBy"];
|
|
2857
|
+
const normalizedOtter = artifactOtter?.replace(/-[a-f0-9]{6}$/, "");
|
|
1946
2858
|
if (!artifactOtter) {
|
|
1947
2859
|
missingDependencies.push(dep);
|
|
1948
2860
|
artifactSections.push(
|
|
1949
2861
|
`[${artifactFile}]:
|
|
1950
2862
|
(integrity check failed: missing generatedBy field)`
|
|
1951
2863
|
);
|
|
1952
|
-
} else if (
|
|
2864
|
+
} else if (normalizedOtter !== expectedOtter) {
|
|
1953
2865
|
missingDependencies.push(dep);
|
|
1954
2866
|
artifactSections.push(
|
|
1955
2867
|
`[${artifactFile}]:
|
|
1956
2868
|
(integrity check failed: artifact claims generatedBy="${artifactOtter}" but expected="${expectedOtter}")`
|
|
1957
2869
|
);
|
|
1958
2870
|
} else {
|
|
1959
|
-
|
|
1960
|
-
|
|
2871
|
+
const sigStatus = signatureAvailable ? signatureVerified ? " [signature verified]" : " [signature check skipped]" : " [unsigned]";
|
|
2872
|
+
artifactSections.push(
|
|
2873
|
+
`[${artifactFile}]${sigStatus}:
|
|
2874
|
+
${JSON.stringify(content, null, 2)}`
|
|
2875
|
+
);
|
|
1961
2876
|
}
|
|
1962
2877
|
} else {
|
|
1963
2878
|
missingDependencies.push(dep);
|
|
@@ -1965,10 +2880,29 @@ ${JSON.stringify(content, null, 2)}`);
|
|
|
1965
2880
|
(not yet available)`);
|
|
1966
2881
|
}
|
|
1967
2882
|
}
|
|
1968
|
-
const parts = [
|
|
2883
|
+
const parts = [];
|
|
2884
|
+
if (buildContextText) {
|
|
2885
|
+
parts.push("BUILD_CONTEXT:", buildContextText, "");
|
|
2886
|
+
}
|
|
2887
|
+
if (phase === "auth") {
|
|
2888
|
+
const initContextPath = join4(cwd, ".stackwright", "init-context.json");
|
|
2889
|
+
if (existsSync5(initContextPath)) {
|
|
2890
|
+
try {
|
|
2891
|
+
const initContext = JSON.parse(safeReadSync(initContextPath));
|
|
2892
|
+
if (initContext.devOnly === true || initContext.nonInteractive === true) {
|
|
2893
|
+
answers["devOnly"] = true;
|
|
2894
|
+
}
|
|
2895
|
+
} catch {
|
|
2896
|
+
}
|
|
2897
|
+
}
|
|
2898
|
+
}
|
|
2899
|
+
parts.push("ANSWERS:", JSON.stringify(answers, null, 2));
|
|
1969
2900
|
if (artifactSections.length > 0) {
|
|
1970
2901
|
parts.push("", "UPSTREAM ARTIFACTS:", "", ...artifactSections);
|
|
1971
2902
|
}
|
|
2903
|
+
const artifactSchema = PHASE_ARTIFACT_SCHEMA[phase];
|
|
2904
|
+
parts.push("", "REQUIRED_ARTIFACT_SCHEMA:");
|
|
2905
|
+
parts.push(artifactSchema);
|
|
1972
2906
|
parts.push("", "Execute using these answers and the upstream artifacts provided.");
|
|
1973
2907
|
const prompt = parts.join("\n");
|
|
1974
2908
|
const dependenciesSatisfied = missingDependencies.length === 0;
|
|
@@ -2003,45 +2937,322 @@ var OFF_SCRIPT_PATTERNS = [
|
|
|
2003
2937
|
var PHASE_REQUIRED_KEYS = {
|
|
2004
2938
|
designer: ["designLanguage", "themeTokenSeeds"],
|
|
2005
2939
|
theme: ["tokens"],
|
|
2006
|
-
api: ["entities"],
|
|
2940
|
+
api: ["entities", "version", "generatedBy"],
|
|
2007
2941
|
auth: ["version", "generatedBy"],
|
|
2008
|
-
data: ["version", "generatedBy"],
|
|
2942
|
+
data: ["version", "generatedBy", "strategy", "collections"],
|
|
2009
2943
|
pages: ["version", "generatedBy"],
|
|
2010
2944
|
dashboard: ["version", "generatedBy"],
|
|
2011
|
-
workflow: ["version", "generatedBy"]
|
|
2945
|
+
workflow: ["version", "generatedBy"],
|
|
2946
|
+
services: ["version", "generatedBy", "flows"],
|
|
2947
|
+
polish: ["version", "generatedBy"],
|
|
2948
|
+
geo: ["version", "generatedBy", "geoCollections"]
|
|
2012
2949
|
};
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2950
|
+
var PHASE_ARTIFACT_SCHEMA = {
|
|
2951
|
+
designer: JSON.stringify(
|
|
2952
|
+
{
|
|
2953
|
+
version: "1.0",
|
|
2954
|
+
generatedBy: "stackwright-pro-designer-otter",
|
|
2955
|
+
application: {
|
|
2956
|
+
type: "<operational|data-explorer|admin|logistics|general>",
|
|
2957
|
+
environment: "<workstation|field|control-room|mixed>",
|
|
2958
|
+
density: "<compact|balanced|spacious>",
|
|
2959
|
+
accessibility: "<wcag-aa|section-508|none>",
|
|
2960
|
+
colorScheme: "<light|dark|both>"
|
|
2961
|
+
},
|
|
2962
|
+
designLanguage: {
|
|
2963
|
+
rationale: "<design rationale>",
|
|
2964
|
+
spacingScale: { base: 8, scale: [0, 4, 8, 16, 24, 32, 48, 64] },
|
|
2965
|
+
colorSemantics: { primary: "#1a365d", accent: "#e53e3e" },
|
|
2966
|
+
typography: {
|
|
2967
|
+
dataFont: "Inter",
|
|
2968
|
+
headingFont: "Inter",
|
|
2969
|
+
monoFont: "monospace",
|
|
2970
|
+
dataSizePx: 12,
|
|
2971
|
+
bodySizePx: 14
|
|
2972
|
+
},
|
|
2973
|
+
contrastRatio: "4.5",
|
|
2974
|
+
borderRadius: "4",
|
|
2975
|
+
shadowElevation: "standard"
|
|
2976
|
+
},
|
|
2977
|
+
themeTokenSeeds: {
|
|
2978
|
+
light: {
|
|
2979
|
+
background: "#ffffff",
|
|
2980
|
+
foreground: "#1a1a1a",
|
|
2981
|
+
primary: "#1a365d",
|
|
2982
|
+
surface: "#f7f7f7",
|
|
2983
|
+
border: "#e2e8f0"
|
|
2984
|
+
},
|
|
2985
|
+
dark: {
|
|
2986
|
+
background: "#1a1a1a",
|
|
2987
|
+
foreground: "#ffffff",
|
|
2988
|
+
primary: "#90cdf4",
|
|
2989
|
+
surface: "#2d2d2d",
|
|
2990
|
+
border: "#4a5568"
|
|
2991
|
+
}
|
|
2992
|
+
},
|
|
2993
|
+
conformsTo: null,
|
|
2994
|
+
operationalNotes: []
|
|
2995
|
+
},
|
|
2996
|
+
null,
|
|
2997
|
+
2
|
|
2998
|
+
),
|
|
2999
|
+
theme: JSON.stringify(
|
|
3000
|
+
{
|
|
3001
|
+
version: "1.0",
|
|
3002
|
+
generatedBy: "stackwright-pro-theme-otter",
|
|
3003
|
+
componentLibrary: "shadcn",
|
|
3004
|
+
colorScheme: "<light|dark|both>",
|
|
3005
|
+
tokens: {
|
|
3006
|
+
colors: { "primary-500": "#1a365d", background: "#ffffff" },
|
|
3007
|
+
spacing: { "spacing-1": "8px", "spacing-2": "16px" },
|
|
3008
|
+
typography: { "font-data": "Inter", "text-sm": "12px" },
|
|
3009
|
+
shape: { "radius-sm": "4px", "radius-md": "8px" },
|
|
3010
|
+
shadows: { "shadow-sm": "0 1px 2px rgba(0,0,0,0.08)" }
|
|
3011
|
+
},
|
|
3012
|
+
cssVariables: {
|
|
3013
|
+
"--background": "0 0% 100%",
|
|
3014
|
+
"--foreground": "222.2 84% 4.9%",
|
|
3015
|
+
"--primary": "222.2 47.4% 11.2%",
|
|
3016
|
+
"--primary-foreground": "210 40% 98%",
|
|
3017
|
+
"--surface": "210 40% 98%",
|
|
3018
|
+
"--border": "214.3 31.8% 91.4%"
|
|
3019
|
+
},
|
|
3020
|
+
dark: { "--background": "222.2 84% 4.9%", "--foreground": "210 40% 98%" }
|
|
3021
|
+
},
|
|
3022
|
+
null,
|
|
3023
|
+
2
|
|
3024
|
+
),
|
|
3025
|
+
api: JSON.stringify(
|
|
3026
|
+
{
|
|
3027
|
+
version: "1.0",
|
|
3028
|
+
generatedBy: "stackwright-pro-api-otter",
|
|
3029
|
+
entities: [
|
|
3030
|
+
{
|
|
3031
|
+
name: "Shipment",
|
|
3032
|
+
endpoint: "/shipments",
|
|
3033
|
+
method: "GET",
|
|
3034
|
+
revalidate: 60,
|
|
3035
|
+
mutationType: null
|
|
3036
|
+
}
|
|
3037
|
+
],
|
|
3038
|
+
skipped: [
|
|
3039
|
+
{
|
|
3040
|
+
spec: "ais-message-models.yaml",
|
|
3041
|
+
format: "asyncapi",
|
|
3042
|
+
reason: "AsyncAPI spec \u2014 WebSocket/event integration required (no REST endpoints)"
|
|
3043
|
+
}
|
|
3044
|
+
],
|
|
3045
|
+
warnings: [
|
|
3046
|
+
"Spec contains external $ref URLs \u2014 recommend bundle: true in stackwright.yml integration config"
|
|
3047
|
+
],
|
|
3048
|
+
auth: { type: "bearer", header: "Authorization", envVar: "API_TOKEN" },
|
|
3049
|
+
baseUrl: "https://api.example.mil/v2",
|
|
3050
|
+
specPath: "./specs/api.yaml"
|
|
3051
|
+
},
|
|
3052
|
+
null,
|
|
3053
|
+
2
|
|
3054
|
+
),
|
|
3055
|
+
data: JSON.stringify(
|
|
3056
|
+
{
|
|
3057
|
+
version: "1.0",
|
|
3058
|
+
generatedBy: "stackwright-pro-data-otter",
|
|
3059
|
+
strategy: "<pulse-fast|isr-fast|isr-standard|isr-slow>",
|
|
3060
|
+
pulseMode: false,
|
|
3061
|
+
collections: [{ name: "equipment", revalidate: 60, pulse: false }],
|
|
3062
|
+
endpoints: { included: ["/equipment/**"], excluded: ["/admin/**"] },
|
|
3063
|
+
requiredPackages: { dependencies: {}, devPackages: {} }
|
|
3064
|
+
},
|
|
3065
|
+
null,
|
|
3066
|
+
2
|
|
3067
|
+
),
|
|
3068
|
+
geo: JSON.stringify(
|
|
3069
|
+
{
|
|
3070
|
+
version: "1.0",
|
|
3071
|
+
generatedBy: "stackwright-pro-geo-otter",
|
|
3072
|
+
geoCollections: [
|
|
3073
|
+
{
|
|
3074
|
+
collection: "vessels",
|
|
3075
|
+
latField: "latitude",
|
|
3076
|
+
lngField: "longitude",
|
|
3077
|
+
labelField: "vesselName",
|
|
3078
|
+
colorField: "navigationStatus"
|
|
3079
|
+
}
|
|
3080
|
+
],
|
|
3081
|
+
pages: [
|
|
3082
|
+
{
|
|
3083
|
+
slug: "fleet-tracker",
|
|
3084
|
+
type: "<tracker|zone|route|combined>",
|
|
3085
|
+
collections: ["vessels"],
|
|
3086
|
+
hasLayers: false
|
|
3087
|
+
}
|
|
3088
|
+
]
|
|
3089
|
+
},
|
|
3090
|
+
null,
|
|
3091
|
+
2
|
|
3092
|
+
),
|
|
3093
|
+
workflow: JSON.stringify(
|
|
3094
|
+
{
|
|
3095
|
+
version: "1.0",
|
|
3096
|
+
generatedBy: "stackwright-pro-workflow-otter",
|
|
3097
|
+
workflowConfig: {
|
|
3098
|
+
id: "procurement-approval",
|
|
3099
|
+
route: "/procurement",
|
|
3100
|
+
files: ["workflows/procurement-approval.yml"],
|
|
3101
|
+
serviceDependencies: ["service:workflow-state"],
|
|
3102
|
+
warnings: []
|
|
3103
|
+
}
|
|
3104
|
+
},
|
|
3105
|
+
null,
|
|
3106
|
+
2
|
|
3107
|
+
),
|
|
3108
|
+
services: JSON.stringify(
|
|
3109
|
+
{
|
|
3110
|
+
version: "1.0",
|
|
3111
|
+
generatedBy: "stackwright-services-otter",
|
|
3112
|
+
flows: [
|
|
3113
|
+
{
|
|
3114
|
+
name: "at-risk-patients",
|
|
3115
|
+
trigger: "<http|event|schedule|queue>",
|
|
3116
|
+
steps: 3,
|
|
3117
|
+
outputSpec: "at-risk-patients.openapi.json"
|
|
3118
|
+
}
|
|
3119
|
+
],
|
|
3120
|
+
workflows: [
|
|
3121
|
+
{
|
|
3122
|
+
name: "evacuation-coordination",
|
|
3123
|
+
states: 4,
|
|
3124
|
+
transitions: 5
|
|
3125
|
+
}
|
|
3126
|
+
],
|
|
3127
|
+
openApiSpecs: ["at-risk-patients.openapi.json"],
|
|
3128
|
+
capabilitiesUsed: ["service.call", "collection.join", "collection.filter"]
|
|
3129
|
+
},
|
|
3130
|
+
null,
|
|
3131
|
+
2
|
|
3132
|
+
),
|
|
3133
|
+
pages: JSON.stringify(
|
|
3134
|
+
{
|
|
3135
|
+
version: "1.0",
|
|
3136
|
+
generatedBy: "stackwright-pro-page-otter",
|
|
3137
|
+
pages: [
|
|
3138
|
+
{
|
|
3139
|
+
slug: "catalog",
|
|
3140
|
+
type: "collection_listing",
|
|
3141
|
+
collection: "products",
|
|
3142
|
+
themeApplied: true,
|
|
3143
|
+
authRequired: false
|
|
3144
|
+
},
|
|
3145
|
+
{
|
|
3146
|
+
slug: "admin",
|
|
3147
|
+
type: "protected",
|
|
3148
|
+
collection: null,
|
|
3149
|
+
themeApplied: true,
|
|
3150
|
+
authRequired: true
|
|
3151
|
+
}
|
|
3152
|
+
]
|
|
3153
|
+
},
|
|
3154
|
+
null,
|
|
3155
|
+
2
|
|
3156
|
+
),
|
|
3157
|
+
dashboard: JSON.stringify(
|
|
3158
|
+
{
|
|
3159
|
+
version: "1.0",
|
|
3160
|
+
generatedBy: "stackwright-pro-dashboard-otter",
|
|
3161
|
+
pages: [
|
|
3162
|
+
{
|
|
3163
|
+
slug: "dashboard",
|
|
3164
|
+
layout: "<grid|table|mixed>",
|
|
3165
|
+
collections: ["equipment", "supplies"],
|
|
3166
|
+
mode: "<ISR|Pulse>"
|
|
3167
|
+
}
|
|
3168
|
+
]
|
|
3169
|
+
},
|
|
3170
|
+
null,
|
|
3171
|
+
2
|
|
3172
|
+
),
|
|
3173
|
+
// type: 'pki' = CAC/DoD certificate auth | 'oidc' = enterprise SSO
|
|
3174
|
+
// For dev-only mock auth: use type: 'oidc' with devOnly: true (Zod strips devOnly — it's a convention only)
|
|
3175
|
+
auth: JSON.stringify(
|
|
3176
|
+
{
|
|
3177
|
+
version: "1.0",
|
|
3178
|
+
generatedBy: "stackwright-pro-auth-otter",
|
|
3179
|
+
authConfig: {
|
|
3180
|
+
type: "<pki|oidc>",
|
|
3181
|
+
// OIDC-only fields (omit for pki):
|
|
3182
|
+
provider: "<azure_ad|okta|cognito|auth0|authentik|keycloak|custom>",
|
|
3183
|
+
discoveryUrl: "<IdP OIDC discovery URL>",
|
|
3184
|
+
clientId: "<OIDC client ID>",
|
|
3185
|
+
clientSecret: "<OIDC client secret>",
|
|
3186
|
+
rbacRoles: ["ADMIN", "ANALYST"],
|
|
3187
|
+
rbacDefaultRole: "ANALYST",
|
|
3188
|
+
protectedRoutes: ["/dashboard/:path*", "/procurement/:path*"],
|
|
3189
|
+
auditEnabled: true,
|
|
3190
|
+
auditRetentionDays: 90
|
|
3191
|
+
},
|
|
3192
|
+
// devScripts is OPTIONAL — only present when devOnly: true was used
|
|
3193
|
+
// Polish otter and foreman MUST check devScripts.written before referencing scripts
|
|
3194
|
+
devScripts: {
|
|
3195
|
+
written: true,
|
|
3196
|
+
scripts: { "dev:admin": "MOCK_USER=admin next dev" }
|
|
3197
|
+
}
|
|
3198
|
+
},
|
|
3199
|
+
null,
|
|
3200
|
+
2
|
|
3201
|
+
),
|
|
3202
|
+
polish: JSON.stringify(
|
|
3203
|
+
{
|
|
3204
|
+
version: "1.0",
|
|
3205
|
+
generatedBy: "stackwright-pro-polish-otter",
|
|
3206
|
+
landingPage: { slug: "", rewritten: true },
|
|
3207
|
+
navigation: { itemCount: 5, items: ["/dashboard", "/equipment", "/workflows"] },
|
|
3208
|
+
gettingStarted: "<rewritten|redirected|not-found>",
|
|
3209
|
+
scaffoldCleanup: {
|
|
3210
|
+
deleted: ["content/posts/getting-started.yaml"],
|
|
3211
|
+
rewritten: ["app/not-found.tsx"],
|
|
3212
|
+
skipped: []
|
|
3213
|
+
}
|
|
3214
|
+
},
|
|
3215
|
+
null,
|
|
3216
|
+
2
|
|
3217
|
+
)
|
|
3218
|
+
};
|
|
3219
|
+
function handleValidateArtifact(input) {
|
|
3220
|
+
const cwd = input._cwd ?? process.cwd();
|
|
3221
|
+
const { phase, responseText, artifact: directArtifact } = input;
|
|
3222
|
+
if (!isValidPhase2(phase)) {
|
|
3223
|
+
return {
|
|
3224
|
+
text: JSON.stringify({ error: true, message: `Unknown phase: ${phase}` }),
|
|
3225
|
+
isError: true
|
|
3226
|
+
};
|
|
3227
|
+
}
|
|
3228
|
+
let artifact;
|
|
3229
|
+
if (directArtifact) {
|
|
3230
|
+
artifact = directArtifact;
|
|
3231
|
+
} else {
|
|
3232
|
+
const text = responseText ?? "";
|
|
3233
|
+
for (const { pattern, label } of OFF_SCRIPT_PATTERNS) {
|
|
3234
|
+
if (pattern.test(text)) {
|
|
3235
|
+
const result = {
|
|
3236
|
+
valid: false,
|
|
3237
|
+
phase,
|
|
3238
|
+
violation: "off-script",
|
|
3239
|
+
retryPrompt: `You returned code output (detected: ${label}). Return ONLY a JSON artifact \u2014 no code, no files.`
|
|
3240
|
+
};
|
|
3241
|
+
return { text: JSON.stringify(result), isError: false };
|
|
3242
|
+
}
|
|
3243
|
+
}
|
|
3244
|
+
try {
|
|
3245
|
+
artifact = extractJsonFromResponse(text);
|
|
3246
|
+
} catch {
|
|
3247
|
+
const result = {
|
|
3248
|
+
valid: false,
|
|
3249
|
+
phase,
|
|
3250
|
+
violation: "invalid-json",
|
|
3251
|
+
retryPrompt: "Your response did not contain valid JSON. Return a single JSON object with no surrounding text."
|
|
3252
|
+
};
|
|
3253
|
+
return { text: JSON.stringify(result), isError: false };
|
|
3254
|
+
}
|
|
3255
|
+
}
|
|
2045
3256
|
if (!artifact.version || !artifact.generatedBy) {
|
|
2046
3257
|
const result = {
|
|
2047
3258
|
valid: false,
|
|
@@ -2062,12 +3273,58 @@ function handleValidateArtifact(input) {
|
|
|
2062
3273
|
};
|
|
2063
3274
|
return { text: JSON.stringify(result), isError: false };
|
|
2064
3275
|
}
|
|
3276
|
+
const PHASE_ZOD_VALIDATORS = {
|
|
3277
|
+
workflow: (artifact2) => {
|
|
3278
|
+
const workflowConfig = artifact2["workflowConfig"];
|
|
3279
|
+
if (!workflowConfig) return { success: true };
|
|
3280
|
+
const result = WorkflowFileSchema.safeParse(workflowConfig);
|
|
3281
|
+
if (!result.success) {
|
|
3282
|
+
const issues = result.error.issues.slice(0, 3).map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
|
|
3283
|
+
return { success: false, error: { message: issues } };
|
|
3284
|
+
}
|
|
3285
|
+
return { success: true };
|
|
3286
|
+
},
|
|
3287
|
+
auth: (artifact2) => {
|
|
3288
|
+
const authConfig = artifact2["authConfig"];
|
|
3289
|
+
if (!authConfig) return { success: true };
|
|
3290
|
+
const result = authConfigSchema.safeParse(authConfig);
|
|
3291
|
+
if (!result.success) {
|
|
3292
|
+
const issues = result.error.issues.slice(0, 3).map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
|
|
3293
|
+
return { success: false, error: { message: issues } };
|
|
3294
|
+
}
|
|
3295
|
+
return { success: true };
|
|
3296
|
+
}
|
|
3297
|
+
};
|
|
3298
|
+
const zodValidator = PHASE_ZOD_VALIDATORS[phase];
|
|
3299
|
+
if (zodValidator) {
|
|
3300
|
+
const zodResult = zodValidator(artifact);
|
|
3301
|
+
if (!zodResult.success) {
|
|
3302
|
+
const result = {
|
|
3303
|
+
valid: false,
|
|
3304
|
+
phase,
|
|
3305
|
+
violation: "schema-mismatch",
|
|
3306
|
+
retryPrompt: `Your artifact failed schema validation: ${zodResult.error?.message}. Fix these fields and return the corrected JSON artifact.`
|
|
3307
|
+
};
|
|
3308
|
+
return { text: JSON.stringify(result), isError: false };
|
|
3309
|
+
}
|
|
3310
|
+
}
|
|
2065
3311
|
try {
|
|
2066
|
-
const artifactsDir =
|
|
2067
|
-
|
|
3312
|
+
const artifactsDir = join4(cwd, ".stackwright", "artifacts");
|
|
3313
|
+
mkdirSync4(artifactsDir, { recursive: true });
|
|
2068
3314
|
const artifactFile = PHASE_ARTIFACT[phase];
|
|
2069
|
-
const artifactPath =
|
|
2070
|
-
|
|
3315
|
+
const artifactPath = join4(artifactsDir, artifactFile);
|
|
3316
|
+
const serialized = JSON.stringify(artifact, null, 2) + "\n";
|
|
3317
|
+
const artifactBytes = Buffer.from(serialized, "utf-8");
|
|
3318
|
+
safeWriteSync(artifactPath, serialized);
|
|
3319
|
+
let signed = false;
|
|
3320
|
+
try {
|
|
3321
|
+
const { privateKey } = loadPipelineKeys(cwd);
|
|
3322
|
+
const sig = signArtifact(artifactBytes, privateKey);
|
|
3323
|
+
const signerOtter = PHASE_TO_OTTER2[phase];
|
|
3324
|
+
saveArtifactSignature(cwd, artifactFile, sig, signerOtter);
|
|
3325
|
+
signed = true;
|
|
3326
|
+
} catch {
|
|
3327
|
+
}
|
|
2071
3328
|
const state = readState(cwd);
|
|
2072
3329
|
if (!state.phases[phase]) state.phases[phase] = defaultPhaseStatus();
|
|
2073
3330
|
const ps = state.phases[phase];
|
|
@@ -2078,7 +3335,7 @@ function handleValidateArtifact(input) {
|
|
|
2078
3335
|
valid: true,
|
|
2079
3336
|
phase,
|
|
2080
3337
|
artifactPath,
|
|
2081
|
-
summary: `Wrote ${artifactFile} (keys: ${topKeys}${Object.keys(artifact).length > 5 ? ", ..." : ""})`
|
|
3338
|
+
summary: `Wrote ${artifactFile} (keys: ${topKeys}${Object.keys(artifact).length > 5 ? ", ..." : ""})${signed ? " [signed]" : ""}`
|
|
2082
3339
|
};
|
|
2083
3340
|
return { text: JSON.stringify(result), isError: false };
|
|
2084
3341
|
} catch (err) {
|
|
@@ -2102,11 +3359,24 @@ function registerPipelineTools(server2) {
|
|
|
2102
3359
|
"stackwright_pro_set_pipeline_state",
|
|
2103
3360
|
`Atomic read\u2192modify\u2192write pipeline state. ${DESC}`,
|
|
2104
3361
|
{
|
|
2105
|
-
phase:
|
|
2106
|
-
field:
|
|
2107
|
-
value:
|
|
2108
|
-
|
|
2109
|
-
|
|
3362
|
+
phase: z11.string().optional().describe('Phase to update, e.g. "designer"'),
|
|
3363
|
+
field: z11.enum(["questionsCollected", "answered", "executed", "artifactWritten"]).optional().describe("Boolean field to set"),
|
|
3364
|
+
value: boolCoerce(z11.boolean().optional()).describe(
|
|
3365
|
+
'Value for the field \u2014 must be a JSON boolean (true/false), NOT the string "true"/"false"'
|
|
3366
|
+
),
|
|
3367
|
+
status: z11.enum(["setup", "questions", "execution", "done"]).optional().describe("Top-level status override"),
|
|
3368
|
+
incrementRetry: boolCoerce(z11.boolean().optional()).describe(
|
|
3369
|
+
"Bump retryCount by 1 \u2014 must be a JSON boolean"
|
|
3370
|
+
),
|
|
3371
|
+
updates: jsonCoerce(
|
|
3372
|
+
z11.array(
|
|
3373
|
+
z11.object({
|
|
3374
|
+
phase: z11.string(),
|
|
3375
|
+
field: z11.enum(["questionsCollected", "answered", "executed", "artifactWritten"]),
|
|
3376
|
+
value: z11.boolean()
|
|
3377
|
+
})
|
|
3378
|
+
).optional()
|
|
3379
|
+
).describe("Batch updates \u2014 apply multiple field changes in one atomic read\u2192modify\u2192write")
|
|
2110
3380
|
},
|
|
2111
3381
|
async (args) => res(
|
|
2112
3382
|
handleSetPipelineState({
|
|
@@ -2114,15 +3384,24 @@ function registerPipelineTools(server2) {
|
|
|
2114
3384
|
...args.field != null ? { field: args.field } : {},
|
|
2115
3385
|
...args.value != null ? { value: args.value } : {},
|
|
2116
3386
|
...args.status != null ? { status: args.status } : {},
|
|
2117
|
-
...args.incrementRetry != null ? { incrementRetry: args.incrementRetry } : {}
|
|
3387
|
+
...args.incrementRetry != null ? { incrementRetry: args.incrementRetry } : {},
|
|
3388
|
+
...args.updates != null ? { updates: args.updates } : {}
|
|
2118
3389
|
})
|
|
2119
3390
|
)
|
|
2120
3391
|
);
|
|
2121
3392
|
server2.tool(
|
|
2122
3393
|
"stackwright_pro_check_execution_ready",
|
|
2123
|
-
`Check all phases have answer files in .stackwright/answers/. ${DESC}`,
|
|
3394
|
+
`Check all phases have answer files in .stackwright/answers/. If phase is provided, check only that phase. ${DESC}`,
|
|
3395
|
+
{
|
|
3396
|
+
phase: z11.string().optional().describe("If provided, check only this phase's readiness. Omit to check all phases.")
|
|
3397
|
+
},
|
|
3398
|
+
async ({ phase }) => res(handleCheckExecutionReady(void 0, phase))
|
|
3399
|
+
);
|
|
3400
|
+
server2.tool(
|
|
3401
|
+
"stackwright_pro_get_ready_phases",
|
|
3402
|
+
`Return phases whose dependencies are all satisfied (executed=true). Use to determine which phases can run next \u2014 enables wave-based execution. ${DESC}`,
|
|
2124
3403
|
{},
|
|
2125
|
-
async () => res(
|
|
3404
|
+
async () => res(handleGetReadyPhases())
|
|
2126
3405
|
);
|
|
2127
3406
|
server2.tool(
|
|
2128
3407
|
"stackwright_pro_list_artifacts",
|
|
@@ -2132,40 +3411,91 @@ function registerPipelineTools(server2) {
|
|
|
2132
3411
|
);
|
|
2133
3412
|
server2.tool(
|
|
2134
3413
|
"stackwright_pro_write_phase_questions",
|
|
2135
|
-
`Parse otter question-collection response \u2192 .stackwright/questions/{phase}.json. ${DESC}`,
|
|
3414
|
+
`Parse otter question-collection response \u2192 .stackwright/questions/{phase}.json. Specialists may also call this directly with a parsed questions array. ${DESC}`,
|
|
2136
3415
|
{
|
|
2137
|
-
phase:
|
|
2138
|
-
responseText:
|
|
3416
|
+
phase: z11.string().optional().describe('Phase name, e.g. "designer" (required for direct write)'),
|
|
3417
|
+
responseText: z11.string().optional().describe("Raw LLM response from QUESTION_COLLECTION_MODE"),
|
|
3418
|
+
questions: jsonCoerce(z11.array(z11.any()).optional()).describe(
|
|
3419
|
+
"Questions array for direct specialist write"
|
|
3420
|
+
)
|
|
2139
3421
|
},
|
|
2140
|
-
async ({ phase, responseText }) =>
|
|
3422
|
+
async ({ phase, responseText, questions }) => {
|
|
3423
|
+
if (phase && questions && Array.isArray(questions)) {
|
|
3424
|
+
if (!SAFE_PHASE.test(phase)) {
|
|
3425
|
+
return {
|
|
3426
|
+
content: [
|
|
3427
|
+
{ type: "text", text: JSON.stringify({ error: `Invalid phase name` }) }
|
|
3428
|
+
],
|
|
3429
|
+
isError: true
|
|
3430
|
+
};
|
|
3431
|
+
}
|
|
3432
|
+
const questionsDir = join4(process.cwd(), ".stackwright", "questions");
|
|
3433
|
+
mkdirSync4(questionsDir, { recursive: true });
|
|
3434
|
+
const outPath = join4(questionsDir, `${phase}.json`);
|
|
3435
|
+
writeFileSync4(
|
|
3436
|
+
outPath,
|
|
3437
|
+
JSON.stringify({ phase, questions, writtenAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2)
|
|
3438
|
+
);
|
|
3439
|
+
return {
|
|
3440
|
+
content: [
|
|
3441
|
+
{
|
|
3442
|
+
type: "text",
|
|
3443
|
+
text: JSON.stringify({
|
|
3444
|
+
written: true,
|
|
3445
|
+
phase,
|
|
3446
|
+
count: questions.length
|
|
3447
|
+
})
|
|
3448
|
+
}
|
|
3449
|
+
]
|
|
3450
|
+
};
|
|
3451
|
+
}
|
|
3452
|
+
return res(
|
|
3453
|
+
handleWritePhaseQuestions({ phase: phase ?? "", responseText: responseText ?? "" })
|
|
3454
|
+
);
|
|
3455
|
+
}
|
|
2141
3456
|
);
|
|
2142
3457
|
server2.tool(
|
|
2143
3458
|
"stackwright_pro_build_specialist_prompt",
|
|
2144
3459
|
`Assemble execution prompt from answers + upstream artifacts. Foreman passes verbatim. ${DESC}`,
|
|
2145
|
-
{ phase:
|
|
3460
|
+
{ phase: z11.string().describe('Phase to build prompt for, e.g. "pages"') },
|
|
2146
3461
|
async ({ phase }) => res(handleBuildSpecialistPrompt({ phase }))
|
|
2147
3462
|
);
|
|
2148
3463
|
server2.tool(
|
|
2149
3464
|
"stackwright_pro_validate_artifact",
|
|
2150
|
-
`Validate
|
|
3465
|
+
`Validate and write artifact to .stackwright/artifacts/. Returns retryPrompt on failure. ${DESC}`,
|
|
2151
3466
|
{
|
|
2152
|
-
phase:
|
|
2153
|
-
responseText:
|
|
3467
|
+
phase: z11.string().describe('Phase that produced this artifact, e.g. "designer"'),
|
|
3468
|
+
responseText: z11.string().optional().describe("Raw response text from the specialist otter (Foreman-mediated path, legacy)"),
|
|
3469
|
+
artifact: jsonCoerce(z11.record(z11.string(), z11.unknown()).optional()).describe(
|
|
3470
|
+
"Artifact object to validate and write directly (specialist direct path \u2014 skips off-script detection and JSON parsing)"
|
|
3471
|
+
)
|
|
2154
3472
|
},
|
|
2155
|
-
async ({ phase, responseText }) =>
|
|
3473
|
+
async ({ phase, responseText, artifact }) => {
|
|
3474
|
+
if (artifact) {
|
|
3475
|
+
return res(
|
|
3476
|
+
handleValidateArtifact({ phase, artifact })
|
|
3477
|
+
);
|
|
3478
|
+
}
|
|
3479
|
+
return res(handleValidateArtifact({ phase, responseText: responseText ?? "" }));
|
|
3480
|
+
}
|
|
2156
3481
|
);
|
|
2157
3482
|
}
|
|
2158
3483
|
|
|
2159
3484
|
// src/tools/safe-write.ts
|
|
2160
|
-
import { z as
|
|
2161
|
-
import { writeFileSync as
|
|
2162
|
-
import { normalize, isAbsolute, dirname, join as
|
|
3485
|
+
import { z as z12 } from "zod";
|
|
3486
|
+
import { writeFileSync as writeFileSync5, readFileSync as readFileSync5, existsSync as existsSync6, mkdirSync as mkdirSync5, lstatSync as lstatSync6 } from "fs";
|
|
3487
|
+
import { normalize, isAbsolute, dirname, join as join5 } from "path";
|
|
2163
3488
|
var OTTER_WRITE_ALLOWLISTS = {
|
|
2164
3489
|
"stackwright-pro-designer-otter": [
|
|
2165
3490
|
{ prefix: ".stackwright/artifacts/", suffix: ".json", description: "Design language artifact" }
|
|
2166
3491
|
],
|
|
2167
3492
|
"stackwright-pro-theme-otter": [
|
|
2168
|
-
{ prefix: ".stackwright/artifacts/", suffix: ".json", description: "Theme tokens artifact" }
|
|
3493
|
+
{ prefix: ".stackwright/artifacts/", suffix: ".json", description: "Theme tokens artifact" },
|
|
3494
|
+
{
|
|
3495
|
+
prefix: "stackwright.theme.",
|
|
3496
|
+
suffix: ".yml",
|
|
3497
|
+
description: "Theme config YAML (merged at build time)"
|
|
3498
|
+
}
|
|
2169
3499
|
],
|
|
2170
3500
|
"stackwright-pro-auth-otter": [
|
|
2171
3501
|
{ prefix: ".stackwright/artifacts/", suffix: ".json", description: "Auth config artifact" },
|
|
@@ -2175,6 +3505,16 @@ var OTTER_WRITE_ALLOWLISTS = {
|
|
|
2175
3505
|
prefix: ".env",
|
|
2176
3506
|
suffix: "",
|
|
2177
3507
|
description: "Dotenv files (.env, .env.local, .env.production, etc.)"
|
|
3508
|
+
},
|
|
3509
|
+
{
|
|
3510
|
+
prefix: "lib/mock-auth",
|
|
3511
|
+
suffix: ".ts",
|
|
3512
|
+
description: "Mock auth module (DEV_ONLY_MODE role updates)"
|
|
3513
|
+
},
|
|
3514
|
+
{
|
|
3515
|
+
prefix: "package.json",
|
|
3516
|
+
suffix: ".json",
|
|
3517
|
+
description: "Project package.json (DEV_ONLY_MODE script cleanup)"
|
|
2178
3518
|
}
|
|
2179
3519
|
],
|
|
2180
3520
|
"stackwright-pro-data-otter": [
|
|
@@ -2196,15 +3536,119 @@ var OTTER_WRITE_ALLOWLISTS = {
|
|
|
2196
3536
|
{ prefix: "workflows/", suffix: ".yaml", description: "Workflow definition" },
|
|
2197
3537
|
{ prefix: ".stackwright/artifacts/", suffix: ".json", description: "Workflow config" }
|
|
2198
3538
|
],
|
|
3539
|
+
"stackwright-pro-scaffold-otter": [
|
|
3540
|
+
{ prefix: "app/", suffix: ".tsx", description: "Next.js App Router shell files" },
|
|
3541
|
+
{
|
|
3542
|
+
prefix: ".stackwright/artifacts/",
|
|
3543
|
+
suffix: ".json",
|
|
3544
|
+
description: "Scaffold manifest artifact"
|
|
3545
|
+
}
|
|
3546
|
+
],
|
|
2199
3547
|
"stackwright-pro-api-otter": [
|
|
2200
3548
|
{ prefix: ".stackwright/artifacts/", suffix: ".json", description: "API config artifact" }
|
|
3549
|
+
],
|
|
3550
|
+
"stackwright-pro-geo-otter": [
|
|
3551
|
+
{ prefix: ".stackwright/artifacts/", suffix: ".json", description: "Geo manifest artifact" },
|
|
3552
|
+
{ prefix: "pages/", suffix: "/content.yml", description: "Map page content" },
|
|
3553
|
+
{ prefix: "pages/", suffix: "/content.yaml", description: "Map page content" }
|
|
3554
|
+
],
|
|
3555
|
+
"stackwright-pro-polish-otter": [
|
|
3556
|
+
{
|
|
3557
|
+
prefix: "stackwright.yml",
|
|
3558
|
+
suffix: "",
|
|
3559
|
+
description: "Stackwright config (navigation updates)"
|
|
3560
|
+
},
|
|
3561
|
+
{ prefix: "pages/", suffix: "/content.yml", description: "Landing page content" },
|
|
3562
|
+
{ prefix: "pages/", suffix: "/content.yaml", description: "Landing page content" },
|
|
3563
|
+
{ prefix: ".stackwright/artifacts/", suffix: ".json", description: "Polish artifact" },
|
|
3564
|
+
{ prefix: "README.md", suffix: "", description: "Project README rewrite" }
|
|
3565
|
+
],
|
|
3566
|
+
"stackwright-services-otter": [
|
|
3567
|
+
{ prefix: ".stackwright/artifacts/", suffix: ".json", description: "Services config artifact" },
|
|
3568
|
+
{ prefix: "services/", suffix: ".ts", description: "Service implementation files" },
|
|
3569
|
+
{ prefix: "services/", suffix: ".yaml", description: "Service flow definitions" },
|
|
3570
|
+
{ prefix: "services/", suffix: ".yml", description: "Service flow definitions" },
|
|
3571
|
+
{ prefix: "lib/seeds/", suffix: ".ts", description: "Seed data files" },
|
|
3572
|
+
{ prefix: "specs/", suffix: ".json", description: "Generated OpenAPI specs" },
|
|
3573
|
+
{ prefix: "specs/", suffix: ".yaml", description: "Generated OpenAPI specs" },
|
|
3574
|
+
{ prefix: "stackwright-generated/", suffix: ".json", description: "Services manifest" }
|
|
2201
3575
|
]
|
|
2202
3576
|
};
|
|
2203
3577
|
var PROTECTED_PATH_PREFIXES = [
|
|
2204
3578
|
".stackwright/pipeline-state.json",
|
|
3579
|
+
".stackwright/pipeline-keys.json",
|
|
3580
|
+
// ephemeral signing keys
|
|
3581
|
+
".stackwright/artifacts/signatures.json",
|
|
3582
|
+
// artifact signature manifest
|
|
2205
3583
|
".stackwright/questions/",
|
|
2206
|
-
".stackwright/answers/"
|
|
3584
|
+
".stackwright/answers/",
|
|
3585
|
+
".stackwright/page-registry.json"
|
|
3586
|
+
// page ownership — managed by safe_write internally
|
|
2207
3587
|
];
|
|
3588
|
+
var MAX_SAFE_WRITE_BYTES_JSON = 512 * 1024;
|
|
3589
|
+
var MAX_SAFE_WRITE_BYTES_YAML = 256 * 1024;
|
|
3590
|
+
var MAX_SAFE_WRITE_BYTES_ENV = 4 * 1024;
|
|
3591
|
+
var MAX_SAFE_WRITE_BYTES_DEFAULT = 256 * 1024;
|
|
3592
|
+
function getMaxBytesForPath(filePath) {
|
|
3593
|
+
if (filePath.endsWith(".json")) return { limit: MAX_SAFE_WRITE_BYTES_JSON, label: "JSON" };
|
|
3594
|
+
if (filePath.endsWith(".yml") || filePath.endsWith(".yaml"))
|
|
3595
|
+
return { limit: MAX_SAFE_WRITE_BYTES_YAML, label: "YAML" };
|
|
3596
|
+
if (filePath === ".env" || /^\.env\.[a-zA-Z0-9]+/.test(filePath))
|
|
3597
|
+
return { limit: MAX_SAFE_WRITE_BYTES_ENV, label: "env" };
|
|
3598
|
+
return { limit: MAX_SAFE_WRITE_BYTES_DEFAULT, label: "default" };
|
|
3599
|
+
}
|
|
3600
|
+
var PAGE_REGISTRY_FILE = ".stackwright/page-registry.json";
|
|
3601
|
+
function extractPageSlug(filePath) {
|
|
3602
|
+
const match = normalize(filePath).match(/^pages\/(.+?)\/content\.ya?ml$/);
|
|
3603
|
+
return match?.[1] ?? null;
|
|
3604
|
+
}
|
|
3605
|
+
function extractContentTypes(yamlContent) {
|
|
3606
|
+
const typePattern = /^\s*-?\s*type:\s*(\S+)/gm;
|
|
3607
|
+
const types = [];
|
|
3608
|
+
let m;
|
|
3609
|
+
while ((m = typePattern.exec(yamlContent)) !== null) {
|
|
3610
|
+
const typeName = m[1];
|
|
3611
|
+
if (typeName && !types.includes(typeName)) {
|
|
3612
|
+
types.push(typeName);
|
|
3613
|
+
}
|
|
3614
|
+
}
|
|
3615
|
+
return types.length > 0 ? types : ["unknown"];
|
|
3616
|
+
}
|
|
3617
|
+
function readPageRegistry(cwd) {
|
|
3618
|
+
const regPath = join5(cwd, PAGE_REGISTRY_FILE);
|
|
3619
|
+
if (!existsSync6(regPath)) return {};
|
|
3620
|
+
try {
|
|
3621
|
+
const stat = lstatSync6(regPath);
|
|
3622
|
+
if (stat.isSymbolicLink()) return {};
|
|
3623
|
+
return JSON.parse(readFileSync5(regPath, "utf-8"));
|
|
3624
|
+
} catch {
|
|
3625
|
+
return {};
|
|
3626
|
+
}
|
|
3627
|
+
}
|
|
3628
|
+
function writePageRegistry(cwd, registry) {
|
|
3629
|
+
const dir = join5(cwd, ".stackwright");
|
|
3630
|
+
mkdirSync5(dir, { recursive: true });
|
|
3631
|
+
const regPath = join5(cwd, PAGE_REGISTRY_FILE);
|
|
3632
|
+
if (existsSync6(regPath)) {
|
|
3633
|
+
const stat = lstatSync6(regPath);
|
|
3634
|
+
if (stat.isSymbolicLink()) {
|
|
3635
|
+
throw new Error("Refusing to write page registry through symlink");
|
|
3636
|
+
}
|
|
3637
|
+
}
|
|
3638
|
+
writeFileSync5(regPath, JSON.stringify(registry, null, 2) + "\n", { encoding: "utf-8" });
|
|
3639
|
+
}
|
|
3640
|
+
function checkPageOwnership(cwd, slug, callerOtter) {
|
|
3641
|
+
const registry = readPageRegistry(cwd);
|
|
3642
|
+
const existing = registry[slug];
|
|
3643
|
+
if (!existing || existing.claimedBy === callerOtter) {
|
|
3644
|
+
return { allowed: true };
|
|
3645
|
+
}
|
|
3646
|
+
return {
|
|
3647
|
+
allowed: false,
|
|
3648
|
+
owner: existing.claimedBy,
|
|
3649
|
+
contentTypes: existing.contentTypes
|
|
3650
|
+
};
|
|
3651
|
+
}
|
|
2208
3652
|
function checkPathAllowed(callerOtter, filePath) {
|
|
2209
3653
|
const normalized = normalize(filePath);
|
|
2210
3654
|
if (normalized.includes("..")) {
|
|
@@ -2246,6 +3690,16 @@ function checkPathAllowed(callerOtter, filePath) {
|
|
|
2246
3690
|
continue;
|
|
2247
3691
|
}
|
|
2248
3692
|
}
|
|
3693
|
+
if (rule.prefix === "lib/mock-auth" && rule.suffix === ".ts") {
|
|
3694
|
+
if (normalized !== "lib/mock-auth.ts") {
|
|
3695
|
+
continue;
|
|
3696
|
+
}
|
|
3697
|
+
}
|
|
3698
|
+
if (rule.prefix === "package.json" && rule.suffix === ".json") {
|
|
3699
|
+
if (normalized !== "package.json") {
|
|
3700
|
+
continue;
|
|
3701
|
+
}
|
|
3702
|
+
}
|
|
2249
3703
|
return { allowed: true, rule: rule.description };
|
|
2250
3704
|
}
|
|
2251
3705
|
}
|
|
@@ -2330,11 +3784,23 @@ function handleSafeWrite(input) {
|
|
|
2330
3784
|
};
|
|
2331
3785
|
return { text: JSON.stringify(result), isError: true };
|
|
2332
3786
|
}
|
|
3787
|
+
const contentBytes = Buffer.byteLength(content, "utf-8");
|
|
3788
|
+
const { limit: maxBytes, label: fileTypeLabel } = getMaxBytesForPath(filePath);
|
|
3789
|
+
if (contentBytes > maxBytes) {
|
|
3790
|
+
const result = {
|
|
3791
|
+
success: false,
|
|
3792
|
+
error: `Content size ${contentBytes} bytes exceeds ${fileTypeLabel} limit of ${maxBytes} bytes (${maxBytes / 1024} KB)`,
|
|
3793
|
+
callerOtter,
|
|
3794
|
+
attemptedPath: filePath,
|
|
3795
|
+
allowedPaths: []
|
|
3796
|
+
};
|
|
3797
|
+
return { text: JSON.stringify(result), isError: true };
|
|
3798
|
+
}
|
|
2333
3799
|
const normalized = normalize(filePath);
|
|
2334
|
-
const fullPath =
|
|
2335
|
-
if (
|
|
3800
|
+
const fullPath = join5(cwd, normalized);
|
|
3801
|
+
if (existsSync6(fullPath)) {
|
|
2336
3802
|
try {
|
|
2337
|
-
const stat =
|
|
3803
|
+
const stat = lstatSync6(fullPath);
|
|
2338
3804
|
if (stat.isSymbolicLink()) {
|
|
2339
3805
|
const result = {
|
|
2340
3806
|
success: false,
|
|
@@ -2359,11 +3825,38 @@ function handleSafeWrite(input) {
|
|
|
2359
3825
|
};
|
|
2360
3826
|
return { text: JSON.stringify(result), isError: true };
|
|
2361
3827
|
}
|
|
3828
|
+
const pageSlug = extractPageSlug(normalized);
|
|
3829
|
+
if (pageSlug) {
|
|
3830
|
+
const ownership = checkPageOwnership(cwd, pageSlug, callerOtter);
|
|
3831
|
+
if (!ownership.allowed) {
|
|
3832
|
+
const result = {
|
|
3833
|
+
success: false,
|
|
3834
|
+
error: `Page slug "${pageSlug}" is already claimed by ${ownership.owner} (content types: ${ownership.contentTypes.join(", ")}). Use a different slug or coordinate with the owning otter.`,
|
|
3835
|
+
callerOtter,
|
|
3836
|
+
attemptedPath: filePath,
|
|
3837
|
+
allowedPaths: []
|
|
3838
|
+
};
|
|
3839
|
+
return { text: JSON.stringify(result), isError: true };
|
|
3840
|
+
}
|
|
3841
|
+
}
|
|
2362
3842
|
try {
|
|
2363
3843
|
if (createDirectories) {
|
|
2364
|
-
|
|
3844
|
+
mkdirSync5(dirname(fullPath), { recursive: true });
|
|
3845
|
+
}
|
|
3846
|
+
writeFileSync5(fullPath, content, { encoding: "utf-8" });
|
|
3847
|
+
if (pageSlug) {
|
|
3848
|
+
try {
|
|
3849
|
+
const registry = readPageRegistry(cwd);
|
|
3850
|
+
registry[pageSlug] = {
|
|
3851
|
+
slug: pageSlug,
|
|
3852
|
+
claimedBy: callerOtter,
|
|
3853
|
+
contentTypes: extractContentTypes(content),
|
|
3854
|
+
writtenAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3855
|
+
};
|
|
3856
|
+
writePageRegistry(cwd, registry);
|
|
3857
|
+
} catch {
|
|
3858
|
+
}
|
|
2365
3859
|
}
|
|
2366
|
-
writeFileSync4(fullPath, content, { encoding: "utf-8" });
|
|
2367
3860
|
const result = {
|
|
2368
3861
|
success: true,
|
|
2369
3862
|
path: normalized,
|
|
@@ -2389,10 +3882,12 @@ function registerSafeWriteTools(server2) {
|
|
|
2389
3882
|
"stackwright_pro_safe_write",
|
|
2390
3883
|
DESC,
|
|
2391
3884
|
{
|
|
2392
|
-
callerOtter:
|
|
2393
|
-
filePath:
|
|
2394
|
-
content:
|
|
2395
|
-
createDirectories:
|
|
3885
|
+
callerOtter: z12.string().describe('The otter agent name requesting the write, e.g. "stackwright-pro-page-otter"'),
|
|
3886
|
+
filePath: z12.string().describe('Relative path from project root, e.g. "pages/dashboard/content.yml"'),
|
|
3887
|
+
content: z12.string().describe("File content to write"),
|
|
3888
|
+
createDirectories: boolCoerce(z12.boolean().optional().default(true)).describe(
|
|
3889
|
+
"Create parent directories if they don't exist. Default: true"
|
|
3890
|
+
)
|
|
2396
3891
|
},
|
|
2397
3892
|
async ({ callerOtter, filePath, content, createDirectories }) => {
|
|
2398
3893
|
const result = handleSafeWrite({
|
|
@@ -2407,9 +3902,9 @@ function registerSafeWriteTools(server2) {
|
|
|
2407
3902
|
}
|
|
2408
3903
|
|
|
2409
3904
|
// src/tools/auth.ts
|
|
2410
|
-
import { z as
|
|
2411
|
-
import { readFileSync as
|
|
2412
|
-
import { join as
|
|
3905
|
+
import { z as z13 } from "zod";
|
|
3906
|
+
import { readFileSync as readFileSync6, writeFileSync as writeFileSync6, existsSync as existsSync7, mkdirSync as mkdirSync6 } from "fs";
|
|
3907
|
+
import { join as join6 } from "path";
|
|
2413
3908
|
function buildHierarchy(roles) {
|
|
2414
3909
|
const h = {};
|
|
2415
3910
|
for (let i = 0; i < roles.length - 1; i++) {
|
|
@@ -2429,6 +3924,19 @@ function routesToYaml(routes, defaultRole, indent) {
|
|
|
2429
3924
|
return routes.map((r) => `${indent}- pattern: ${r}
|
|
2430
3925
|
${indent} requiredRole: ${defaultRole}`).join("\n");
|
|
2431
3926
|
}
|
|
3927
|
+
function detectNextMajorVersion(cwd) {
|
|
3928
|
+
try {
|
|
3929
|
+
const pkgPath = join6(cwd, "package.json");
|
|
3930
|
+
if (!existsSync7(pkgPath)) return null;
|
|
3931
|
+
const pkg = JSON.parse(readFileSync6(pkgPath, "utf8"));
|
|
3932
|
+
const nextVersion = pkg.dependencies?.next ?? pkg.devDependencies?.next;
|
|
3933
|
+
if (!nextVersion) return null;
|
|
3934
|
+
const match = nextVersion.match(/(\d+)/);
|
|
3935
|
+
return match ? parseInt(match[1], 10) : null;
|
|
3936
|
+
} catch {
|
|
3937
|
+
return null;
|
|
3938
|
+
}
|
|
3939
|
+
}
|
|
2432
3940
|
function upsertAuthBlock(existing, authYaml) {
|
|
2433
3941
|
const lines = existing.split("\n");
|
|
2434
3942
|
let authStart = -1;
|
|
@@ -2471,7 +3979,7 @@ function generateMiddlewareContent(method, params, roles, defaultRole, hierarchy
|
|
|
2471
3979
|
// \u26A0\uFE0F SECURITY REVIEW REQUIRED \u2014 CAC/PKI certificate validation
|
|
2472
3980
|
// DoD security officer review required before production deployment.
|
|
2473
3981
|
// Verify: CA bundle completeness, EDIPI lookup endpoint, OCSP accessibility.
|
|
2474
|
-
import { createProMiddleware } from '@stackwright-pro/auth-nextjs';
|
|
3982
|
+
import { createProMiddleware } from '@stackwright-pro/auth-nextjs/edge';
|
|
2475
3983
|
|
|
2476
3984
|
export const middleware = createProMiddleware({
|
|
2477
3985
|
method: 'cac',
|
|
@@ -2493,7 +4001,7 @@ ${configBlock}
|
|
|
2493
4001
|
const scopes2 = params.oidcScopes ?? "openid profile email";
|
|
2494
4002
|
const roleClaim = params.oidcRoleClaim ?? "roles";
|
|
2495
4003
|
return `// middleware.ts \u2014 generated by @stackwright-pro/auth-nextjs
|
|
2496
|
-
import { createProMiddleware } from '@stackwright-pro/auth-nextjs';
|
|
4004
|
+
import { createProMiddleware } from '@stackwright-pro/auth-nextjs/edge';
|
|
2497
4005
|
|
|
2498
4006
|
export const middleware = createProMiddleware({
|
|
2499
4007
|
method: 'oidc',
|
|
@@ -2514,7 +4022,7 @@ ${configBlock}
|
|
|
2514
4022
|
}
|
|
2515
4023
|
const scopes = params.oauth2Scopes ?? "read write";
|
|
2516
4024
|
return `// middleware.ts \u2014 generated by @stackwright-pro/auth-nextjs
|
|
2517
|
-
import { createProMiddleware } from '@stackwright-pro/auth-nextjs';
|
|
4025
|
+
import { createProMiddleware } from '@stackwright-pro/auth-nextjs/edge';
|
|
2518
4026
|
|
|
2519
4027
|
export const middleware = createProMiddleware({
|
|
2520
4028
|
method: 'oauth2',
|
|
@@ -2530,6 +4038,91 @@ ${auditBlock}
|
|
|
2530
4038
|
${routesBlock}
|
|
2531
4039
|
});
|
|
2532
4040
|
|
|
4041
|
+
${configBlock}
|
|
4042
|
+
`;
|
|
4043
|
+
}
|
|
4044
|
+
function generateProxyContent(method, params, roles, defaultRole, hierarchy, auditEnabled, auditRetentionDays, protectedRoutes) {
|
|
4045
|
+
const rbacBlock = ` rbac: {
|
|
4046
|
+
roles: ${JSON.stringify(roles)},
|
|
4047
|
+
defaultRole: '${defaultRole}',
|
|
4048
|
+
hierarchy: ${JSON.stringify(hierarchy, null, 4)},
|
|
4049
|
+
},`;
|
|
4050
|
+
const auditBlock = ` audit: {
|
|
4051
|
+
enabled: ${auditEnabled},
|
|
4052
|
+
retentionDays: ${auditRetentionDays},
|
|
4053
|
+
},`;
|
|
4054
|
+
const routesBlock = ` protectedRoutes: ${JSON.stringify(protectedRoutes)},`;
|
|
4055
|
+
const configBlock = `export const config = {
|
|
4056
|
+
matcher: ${JSON.stringify(protectedRoutes)},
|
|
4057
|
+
};`;
|
|
4058
|
+
if (method === "cac") {
|
|
4059
|
+
const caBundle = params.cacCaBundle ?? "./certs/dod-ca-bundle.pem";
|
|
4060
|
+
const edipiLookup = params.cacEdipiLookup ?? "./config/edipi-lookup.json";
|
|
4061
|
+
const ocspEndpoint = params.cacOcspEndpoint ?? "https://ocsp.disa.mil";
|
|
4062
|
+
const certHeader = params.cacCertHeader ?? "X-SSL-Client-Cert";
|
|
4063
|
+
return `// proxy.ts \u2014 generated by @stackwright-pro/auth (Next.js >=16)
|
|
4064
|
+
// SECURITY REVIEW REQUIRED \u2014 CAC/PKI certificate validation
|
|
4065
|
+
// DoD security officer review required before production deployment.
|
|
4066
|
+
// Verify: CA bundle completeness, EDIPI lookup endpoint, OCSP accessibility.
|
|
4067
|
+
import { createProProxy } from '@stackwright-pro/auth-nextjs/edge';
|
|
4068
|
+
|
|
4069
|
+
export const proxy = createProProxy({
|
|
4070
|
+
method: 'cac',
|
|
4071
|
+
cac: {
|
|
4072
|
+
caBundle: process.env.CAC_CA_BUNDLE ?? '${caBundle}',
|
|
4073
|
+
edipiLookup: '${edipiLookup}',
|
|
4074
|
+
ocspEndpoint: process.env.CAC_OCSP_ENDPOINT ?? '${ocspEndpoint}',
|
|
4075
|
+
certHeader: '${certHeader}',
|
|
4076
|
+
},
|
|
4077
|
+
${rbacBlock}
|
|
4078
|
+
${auditBlock}
|
|
4079
|
+
${routesBlock}
|
|
4080
|
+
});
|
|
4081
|
+
|
|
4082
|
+
${configBlock}
|
|
4083
|
+
`;
|
|
4084
|
+
}
|
|
4085
|
+
if (method === "oidc") {
|
|
4086
|
+
const scopes2 = params.oidcScopes ?? "openid profile email";
|
|
4087
|
+
const roleClaim = params.oidcRoleClaim ?? "roles";
|
|
4088
|
+
return `// proxy.ts \u2014 generated by @stackwright-pro/auth (Next.js >=16)
|
|
4089
|
+
import { createProProxy } from '@stackwright-pro/auth-nextjs/edge';
|
|
4090
|
+
|
|
4091
|
+
export const proxy = createProProxy({
|
|
4092
|
+
method: 'oidc',
|
|
4093
|
+
oidc: {
|
|
4094
|
+
discoveryUrl: process.env.OIDC_DISCOVERY_URL!,
|
|
4095
|
+
clientId: process.env.OIDC_CLIENT_ID!,
|
|
4096
|
+
clientSecret: process.env.OIDC_CLIENT_SECRET!,
|
|
4097
|
+
scopes: '${scopes2}',
|
|
4098
|
+
roleClaim: '${roleClaim}',
|
|
4099
|
+
},
|
|
4100
|
+
${rbacBlock}
|
|
4101
|
+
${auditBlock}
|
|
4102
|
+
${routesBlock}
|
|
4103
|
+
});
|
|
4104
|
+
|
|
4105
|
+
${configBlock}
|
|
4106
|
+
`;
|
|
4107
|
+
}
|
|
4108
|
+
const scopes = params.oauth2Scopes ?? "read write";
|
|
4109
|
+
return `// proxy.ts \u2014 generated by @stackwright-pro/auth (Next.js >=16)
|
|
4110
|
+
import { createProProxy } from '@stackwright-pro/auth-nextjs/edge';
|
|
4111
|
+
|
|
4112
|
+
export const proxy = createProProxy({
|
|
4113
|
+
method: 'oauth2',
|
|
4114
|
+
oauth2: {
|
|
4115
|
+
authorizationUrl: process.env.OAUTH2_AUTH_URL!,
|
|
4116
|
+
tokenUrl: process.env.OAUTH2_TOKEN_URL!,
|
|
4117
|
+
clientId: process.env.OAUTH2_CLIENT_ID!,
|
|
4118
|
+
clientSecret: process.env.OAUTH2_CLIENT_SECRET!,
|
|
4119
|
+
scopes: '${scopes}',
|
|
4120
|
+
},
|
|
4121
|
+
${rbacBlock}
|
|
4122
|
+
${auditBlock}
|
|
4123
|
+
${routesBlock}
|
|
4124
|
+
});
|
|
4125
|
+
|
|
2533
4126
|
${configBlock}
|
|
2534
4127
|
`;
|
|
2535
4128
|
}
|
|
@@ -2559,7 +4152,7 @@ OAUTH2_CLIENT_ID=your-client-id
|
|
|
2559
4152
|
OAUTH2_CLIENT_SECRET=your-client-secret
|
|
2560
4153
|
`;
|
|
2561
4154
|
}
|
|
2562
|
-
function generateYamlBlock(method, params, roles, defaultRole, hierarchy, auditEnabled, auditRetentionDays, protectedRoutes) {
|
|
4155
|
+
function generateYamlBlock(method, params, roles, defaultRole, hierarchy, auditEnabled, auditRetentionDays, protectedRoutes, useProxy) {
|
|
2563
4156
|
const rbacSection = ` rbac:
|
|
2564
4157
|
roles:
|
|
2565
4158
|
${rolesToYaml(roles, " ")}
|
|
@@ -2582,7 +4175,7 @@ ${routesToYaml(protectedRoutes, " ", defaultRole)}`.replace(/\n\s+,/g, "");
|
|
|
2582
4175
|
const certHeader = params.cacCertHeader ?? "X-SSL-Client-Cert";
|
|
2583
4176
|
return `auth:
|
|
2584
4177
|
method: cac
|
|
2585
|
-
${providerLine} middleware:
|
|
4178
|
+
${providerLine} ${useProxy ? "proxy" : "middleware"}: ./${useProxy ? "proxy" : "middleware"}.ts
|
|
2586
4179
|
cac:
|
|
2587
4180
|
caBundle: \${CAC_CA_BUNDLE}
|
|
2588
4181
|
edipiLookup: ${edipiLookup}
|
|
@@ -2599,7 +4192,7 @@ ${auditSection}
|
|
|
2599
4192
|
const roleClaim = params.oidcRoleClaim ?? "roles";
|
|
2600
4193
|
return `auth:
|
|
2601
4194
|
method: oidc
|
|
2602
|
-
${providerLine} middleware:
|
|
4195
|
+
${providerLine} ${useProxy ? "proxy" : "middleware"}: ./${useProxy ? "proxy" : "middleware"}.ts
|
|
2603
4196
|
oidc:
|
|
2604
4197
|
discoveryUrl: \${OIDC_DISCOVERY_URL}
|
|
2605
4198
|
clientId: \${OIDC_CLIENT_ID}
|
|
@@ -2615,7 +4208,7 @@ ${auditSection}
|
|
|
2615
4208
|
const scopes = params.oauth2Scopes ?? "read write";
|
|
2616
4209
|
return `auth:
|
|
2617
4210
|
method: oauth2
|
|
2618
|
-
${providerLine} middleware:
|
|
4211
|
+
${providerLine} ${useProxy ? "proxy" : "middleware"}: ./${useProxy ? "proxy" : "middleware"}.ts
|
|
2619
4212
|
oauth2:
|
|
2620
4213
|
authorizationUrl: \${OAUTH2_AUTH_URL}
|
|
2621
4214
|
tokenUrl: \${OAUTH2_TOKEN_URL}
|
|
@@ -2628,10 +4221,321 @@ ${routeLines}
|
|
|
2628
4221
|
${auditSection}
|
|
2629
4222
|
`;
|
|
2630
4223
|
}
|
|
4224
|
+
function generateDevOnlyMiddlewareContent(method, params, roles, defaultRole, hierarchy, auditEnabled, auditRetentionDays, protectedRoutes) {
|
|
4225
|
+
const rbacBlock = ` rbac: {
|
|
4226
|
+
roles: ${JSON.stringify(roles)},
|
|
4227
|
+
defaultRole: '${defaultRole}',
|
|
4228
|
+
hierarchy: ${JSON.stringify(hierarchy, null, 4)},
|
|
4229
|
+
},`;
|
|
4230
|
+
const auditBlock = ` audit: {
|
|
4231
|
+
enabled: ${auditEnabled},
|
|
4232
|
+
retentionDays: ${auditRetentionDays},
|
|
4233
|
+
},`;
|
|
4234
|
+
const routesBlock = ` protectedRoutes: ${JSON.stringify(protectedRoutes)},`;
|
|
4235
|
+
const configBlock = `export const config = {
|
|
4236
|
+
matcher: ${JSON.stringify(protectedRoutes)},
|
|
4237
|
+
};`;
|
|
4238
|
+
const devHeader = [
|
|
4239
|
+
"// middleware.ts \u2014 generated by @stackwright-pro/auth-nextjs (dev-only mock)",
|
|
4240
|
+
"// DEV ONLY \u2014 uses mock authentication from lib/mock-auth.ts",
|
|
4241
|
+
"// Do NOT deploy to production.",
|
|
4242
|
+
"import { createProMiddleware } from '@stackwright-pro/auth-nextjs/edge';",
|
|
4243
|
+
"import { mockAuthProvider } from './lib/mock-auth';"
|
|
4244
|
+
].join("\n");
|
|
4245
|
+
if (method === "cac") {
|
|
4246
|
+
const caBundle = params.cacCaBundle ?? "./certs/dod-ca-bundle.pem";
|
|
4247
|
+
const edipiLookup = params.cacEdipiLookup ?? "./config/edipi-lookup.json";
|
|
4248
|
+
const ocspEndpoint = params.cacOcspEndpoint ?? "https://ocsp.disa.mil";
|
|
4249
|
+
const certHeader = params.cacCertHeader ?? "X-SSL-Client-Cert";
|
|
4250
|
+
return `${devHeader}
|
|
4251
|
+
|
|
4252
|
+
export const middleware = createProMiddleware({
|
|
4253
|
+
method: 'cac',
|
|
4254
|
+
cac: {
|
|
4255
|
+
caBundle: '${caBundle}',
|
|
4256
|
+
edipiLookup: '${edipiLookup}',
|
|
4257
|
+
ocspEndpoint: '${ocspEndpoint}',
|
|
4258
|
+
certHeader: '${certHeader}',
|
|
4259
|
+
provider: mockAuthProvider,
|
|
4260
|
+
},
|
|
4261
|
+
${rbacBlock}
|
|
4262
|
+
${auditBlock}
|
|
4263
|
+
${routesBlock}
|
|
4264
|
+
});
|
|
4265
|
+
|
|
4266
|
+
${configBlock}
|
|
4267
|
+
`;
|
|
4268
|
+
}
|
|
4269
|
+
if (method === "oidc") {
|
|
4270
|
+
const scopes2 = params.oidcScopes ?? "openid profile email";
|
|
4271
|
+
const roleClaim = params.oidcRoleClaim ?? "roles";
|
|
4272
|
+
return `${devHeader}
|
|
4273
|
+
|
|
4274
|
+
export const middleware = createProMiddleware({
|
|
4275
|
+
method: 'oidc',
|
|
4276
|
+
oidc: {
|
|
4277
|
+
discoveryUrl: 'https://dev-mock-oidc/.well-known/openid-configuration',
|
|
4278
|
+
clientId: 'dev-mock-client',
|
|
4279
|
+
clientSecret: 'dev-mock-secret',
|
|
4280
|
+
scopes: '${scopes2}',
|
|
4281
|
+
roleClaim: '${roleClaim}',
|
|
4282
|
+
provider: mockAuthProvider,
|
|
4283
|
+
},
|
|
4284
|
+
${rbacBlock}
|
|
4285
|
+
${auditBlock}
|
|
4286
|
+
${routesBlock}
|
|
4287
|
+
});
|
|
4288
|
+
|
|
4289
|
+
${configBlock}
|
|
4290
|
+
`;
|
|
4291
|
+
}
|
|
4292
|
+
const scopes = params.oauth2Scopes ?? "read write";
|
|
4293
|
+
return `${devHeader}
|
|
4294
|
+
|
|
4295
|
+
export const middleware = createProMiddleware({
|
|
4296
|
+
method: 'oauth2',
|
|
4297
|
+
oauth2: {
|
|
4298
|
+
authorizationUrl: 'https://dev-mock-oauth2/authorize',
|
|
4299
|
+
tokenUrl: 'https://dev-mock-oauth2/token',
|
|
4300
|
+
clientId: 'dev-mock-client',
|
|
4301
|
+
clientSecret: 'dev-mock-secret',
|
|
4302
|
+
scopes: '${scopes}',
|
|
4303
|
+
provider: mockAuthProvider,
|
|
4304
|
+
},
|
|
4305
|
+
${rbacBlock}
|
|
4306
|
+
${auditBlock}
|
|
4307
|
+
${routesBlock}
|
|
4308
|
+
});
|
|
4309
|
+
|
|
4310
|
+
${configBlock}
|
|
4311
|
+
`;
|
|
4312
|
+
}
|
|
4313
|
+
function generateDevOnlyProxyContent(method, params, roles, defaultRole, hierarchy, auditEnabled, auditRetentionDays, protectedRoutes) {
|
|
4314
|
+
const rbacBlock = ` rbac: {
|
|
4315
|
+
roles: ${JSON.stringify(roles)},
|
|
4316
|
+
defaultRole: '${defaultRole}',
|
|
4317
|
+
hierarchy: ${JSON.stringify(hierarchy, null, 4)},
|
|
4318
|
+
},`;
|
|
4319
|
+
const auditBlock = ` audit: {
|
|
4320
|
+
enabled: ${auditEnabled},
|
|
4321
|
+
retentionDays: ${auditRetentionDays},
|
|
4322
|
+
},`;
|
|
4323
|
+
const routesBlock = ` protectedRoutes: ${JSON.stringify(protectedRoutes)},`;
|
|
4324
|
+
const configBlock = `export const config = {
|
|
4325
|
+
matcher: ${JSON.stringify(protectedRoutes)},
|
|
4326
|
+
};`;
|
|
4327
|
+
const devHeader = [
|
|
4328
|
+
"// proxy.ts -- generated by @stackwright-pro/auth (Next.js >=16, dev-only mock)",
|
|
4329
|
+
"// DEV ONLY -- uses mock authentication from lib/mock-auth.ts",
|
|
4330
|
+
"// Do NOT deploy to production.",
|
|
4331
|
+
"import { createProProxy } from '@stackwright-pro/auth-nextjs/edge';",
|
|
4332
|
+
"import { mockAuthProvider } from './lib/mock-auth';"
|
|
4333
|
+
].join("\n");
|
|
4334
|
+
if (method === "cac") {
|
|
4335
|
+
const caBundle = params.cacCaBundle ?? "./certs/dod-ca-bundle.pem";
|
|
4336
|
+
const edipiLookup = params.cacEdipiLookup ?? "./config/edipi-lookup.json";
|
|
4337
|
+
const ocspEndpoint = params.cacOcspEndpoint ?? "https://ocsp.disa.mil";
|
|
4338
|
+
const certHeader = params.cacCertHeader ?? "X-SSL-Client-Cert";
|
|
4339
|
+
return `${devHeader}
|
|
4340
|
+
|
|
4341
|
+
export const proxy = createProProxy({
|
|
4342
|
+
method: 'cac',
|
|
4343
|
+
cac: {
|
|
4344
|
+
caBundle: '${caBundle}',
|
|
4345
|
+
edipiLookup: '${edipiLookup}',
|
|
4346
|
+
ocspEndpoint: '${ocspEndpoint}',
|
|
4347
|
+
certHeader: '${certHeader}',
|
|
4348
|
+
provider: mockAuthProvider,
|
|
4349
|
+
},
|
|
4350
|
+
${rbacBlock}
|
|
4351
|
+
${auditBlock}
|
|
4352
|
+
${routesBlock}
|
|
4353
|
+
});
|
|
4354
|
+
|
|
4355
|
+
${configBlock}
|
|
4356
|
+
`;
|
|
4357
|
+
}
|
|
4358
|
+
if (method === "oidc") {
|
|
4359
|
+
const scopes2 = params.oidcScopes ?? "openid profile email";
|
|
4360
|
+
const roleClaim = params.oidcRoleClaim ?? "roles";
|
|
4361
|
+
return `${devHeader}
|
|
4362
|
+
|
|
4363
|
+
export const proxy = createProProxy({
|
|
4364
|
+
method: 'oidc',
|
|
4365
|
+
oidc: {
|
|
4366
|
+
discoveryUrl: 'https://dev-mock-oidc/.well-known/openid-configuration',
|
|
4367
|
+
clientId: 'dev-mock-client',
|
|
4368
|
+
clientSecret: 'dev-mock-secret',
|
|
4369
|
+
scopes: '${scopes2}',
|
|
4370
|
+
roleClaim: '${roleClaim}',
|
|
4371
|
+
provider: mockAuthProvider,
|
|
4372
|
+
},
|
|
4373
|
+
${rbacBlock}
|
|
4374
|
+
${auditBlock}
|
|
4375
|
+
${routesBlock}
|
|
4376
|
+
});
|
|
4377
|
+
|
|
4378
|
+
${configBlock}
|
|
4379
|
+
`;
|
|
4380
|
+
}
|
|
4381
|
+
const scopes = params.oauth2Scopes ?? "read write";
|
|
4382
|
+
return `${devHeader}
|
|
4383
|
+
|
|
4384
|
+
export const proxy = createProProxy({
|
|
4385
|
+
method: 'oauth2',
|
|
4386
|
+
oauth2: {
|
|
4387
|
+
authorizationUrl: 'https://dev-mock-oauth2/authorize',
|
|
4388
|
+
tokenUrl: 'https://dev-mock-oauth2/token',
|
|
4389
|
+
clientId: 'dev-mock-client',
|
|
4390
|
+
clientSecret: 'dev-mock-secret',
|
|
4391
|
+
scopes: '${scopes}',
|
|
4392
|
+
provider: mockAuthProvider,
|
|
4393
|
+
},
|
|
4394
|
+
${rbacBlock}
|
|
4395
|
+
${auditBlock}
|
|
4396
|
+
${routesBlock}
|
|
4397
|
+
});
|
|
4398
|
+
|
|
4399
|
+
${configBlock}
|
|
4400
|
+
`;
|
|
4401
|
+
}
|
|
4402
|
+
function generateDevOnlyYamlBlock(method, params, roles, defaultRole, hierarchy, auditEnabled, auditRetentionDays, protectedRoutes, useProxy) {
|
|
4403
|
+
const rbacSection = ` rbac:
|
|
4404
|
+
roles:
|
|
4405
|
+
${rolesToYaml(roles, " ")}
|
|
4406
|
+
defaultRole: ${defaultRole}
|
|
4407
|
+
hierarchy:
|
|
4408
|
+
${hierarchyToYaml(hierarchy, " ")}`;
|
|
4409
|
+
const auditSection = ` audit:
|
|
4410
|
+
enabled: ${auditEnabled}
|
|
4411
|
+
retentionDays: ${auditRetentionDays}`;
|
|
4412
|
+
const routeLines = protectedRoutes.map((r) => ` - pattern: ${r}
|
|
4413
|
+
requiredRole: ${defaultRole}`).join("\n");
|
|
4414
|
+
const providerLine = params.provider ? ` provider: ${params.provider}
|
|
4415
|
+
` : "";
|
|
4416
|
+
if (method === "cac") {
|
|
4417
|
+
const caBundle = params.cacCaBundle ?? "./certs/dod-ca-bundle.pem";
|
|
4418
|
+
const edipiLookup = params.cacEdipiLookup ?? "./config/edipi-lookup.json";
|
|
4419
|
+
const ocspEndpoint = params.cacOcspEndpoint ?? "https://ocsp.disa.mil";
|
|
4420
|
+
const certHeader = params.cacCertHeader ?? "X-SSL-Client-Cert";
|
|
4421
|
+
return `auth:
|
|
4422
|
+
method: cac
|
|
4423
|
+
devOnly: true
|
|
4424
|
+
${providerLine} ${useProxy ? "proxy" : "middleware"}: ./${useProxy ? "proxy" : "middleware"}.ts
|
|
4425
|
+
cac:
|
|
4426
|
+
caBundle: ${caBundle}
|
|
4427
|
+
edipiLookup: ${edipiLookup}
|
|
4428
|
+
ocspEndpoint: ${ocspEndpoint}
|
|
4429
|
+
certHeader: ${certHeader}
|
|
4430
|
+
${rbacSection}
|
|
4431
|
+
protectedRoutes:
|
|
4432
|
+
${routeLines}
|
|
4433
|
+
${auditSection}
|
|
4434
|
+
`;
|
|
4435
|
+
}
|
|
4436
|
+
if (method === "oidc") {
|
|
4437
|
+
const scopes2 = params.oidcScopes ?? "openid profile email";
|
|
4438
|
+
const roleClaim = params.oidcRoleClaim ?? "roles";
|
|
4439
|
+
return `auth:
|
|
4440
|
+
method: oidc
|
|
4441
|
+
devOnly: true
|
|
4442
|
+
${providerLine} ${useProxy ? "proxy" : "middleware"}: ./${useProxy ? "proxy" : "middleware"}.ts
|
|
4443
|
+
oidc:
|
|
4444
|
+
discoveryUrl: https://dev-mock-oidc/.well-known/openid-configuration
|
|
4445
|
+
clientId: dev-mock-client
|
|
4446
|
+
clientSecret: dev-mock-secret
|
|
4447
|
+
scopes: ${scopes2}
|
|
4448
|
+
roleClaim: ${roleClaim}
|
|
4449
|
+
${rbacSection}
|
|
4450
|
+
protectedRoutes:
|
|
4451
|
+
${routeLines}
|
|
4452
|
+
${auditSection}
|
|
4453
|
+
`;
|
|
4454
|
+
}
|
|
4455
|
+
const scopes = params.oauth2Scopes ?? "read write";
|
|
4456
|
+
return `auth:
|
|
4457
|
+
method: oauth2
|
|
4458
|
+
devOnly: true
|
|
4459
|
+
${providerLine} ${useProxy ? "proxy" : "middleware"}: ./${useProxy ? "proxy" : "middleware"}.ts
|
|
4460
|
+
oauth2:
|
|
4461
|
+
authorizationUrl: https://dev-mock-oauth2/authorize
|
|
4462
|
+
tokenUrl: https://dev-mock-oauth2/token
|
|
4463
|
+
clientId: dev-mock-client
|
|
4464
|
+
clientSecret: dev-mock-secret
|
|
4465
|
+
scopes: ${scopes}
|
|
4466
|
+
${rbacSection}
|
|
4467
|
+
protectedRoutes:
|
|
4468
|
+
${routeLines}
|
|
4469
|
+
${auditSection}
|
|
4470
|
+
`;
|
|
4471
|
+
}
|
|
4472
|
+
function deriveDevKey(roleName) {
|
|
4473
|
+
const segment = roleName.split("_")[0];
|
|
4474
|
+
return (segment ?? roleName).toLowerCase();
|
|
4475
|
+
}
|
|
4476
|
+
function generateMockAuthContent(roles, mockUsers) {
|
|
4477
|
+
const entries = [];
|
|
4478
|
+
const scriptLines = [];
|
|
4479
|
+
for (let i = 0; i < roles.length; i++) {
|
|
4480
|
+
const role = roles[i];
|
|
4481
|
+
const devKey = deriveDevKey(role);
|
|
4482
|
+
const persona = mockUsers?.[i];
|
|
4483
|
+
const name = persona?.name ?? `Dev ${role}`;
|
|
4484
|
+
const email = persona?.email ?? `dev-${devKey}@example.mil`;
|
|
4485
|
+
const edipi = String(i + 1).padStart(10, "0");
|
|
4486
|
+
entries.push(` ${devKey}: {
|
|
4487
|
+
id: 'mock-${devKey}-${String(i + 1).padStart(3, "0")}',
|
|
4488
|
+
name: '${name}',
|
|
4489
|
+
email: '${email}',
|
|
4490
|
+
roles: ['${role}'],
|
|
4491
|
+
edipi: '${edipi}',
|
|
4492
|
+
}`);
|
|
4493
|
+
scriptLines.push(` * pnpm dev:${devKey} \u2014 ${name}`);
|
|
4494
|
+
}
|
|
4495
|
+
return `/**
|
|
4496
|
+
* Mock authentication for development mode.
|
|
4497
|
+
*
|
|
4498
|
+
* DEV_ONLY_MODE \u2014 no real auth provider. Select a persona via MOCK_USER env var
|
|
4499
|
+
* or use the convenience scripts in package.json:
|
|
4500
|
+
${scriptLines.join("\n")}
|
|
4501
|
+
*/
|
|
4502
|
+
|
|
4503
|
+
export const MOCK_USERS = {
|
|
4504
|
+
${entries.join(",\n")}
|
|
4505
|
+
} as const;
|
|
4506
|
+
|
|
4507
|
+
export type MockRole = keyof typeof MOCK_USERS;
|
|
4508
|
+
|
|
4509
|
+
export function getMockUser(role?: string) {
|
|
4510
|
+
const key = (role ?? process.env.MOCK_USER) as MockRole | undefined;
|
|
4511
|
+
if (!key) return null;
|
|
4512
|
+
return MOCK_USERS[key] ?? null;
|
|
4513
|
+
}
|
|
4514
|
+
|
|
4515
|
+
export function mockAuthProvider() {
|
|
4516
|
+
return getMockUser();
|
|
4517
|
+
}
|
|
4518
|
+
`;
|
|
4519
|
+
}
|
|
4520
|
+
function updatePackageJsonScripts(existingJson, roles, mockUsers) {
|
|
4521
|
+
const pkg = JSON.parse(existingJson);
|
|
4522
|
+
const scripts = pkg.scripts ?? {};
|
|
4523
|
+
delete scripts["dev:admin"];
|
|
4524
|
+
delete scripts["dev:analyst"];
|
|
4525
|
+
delete scripts["dev:viewer"];
|
|
4526
|
+
for (let i = 0; i < roles.length; i++) {
|
|
4527
|
+
const devKey = deriveDevKey(roles[i]);
|
|
4528
|
+
scripts[`dev:${devKey}`] = `MOCK_USER=${devKey} next dev`;
|
|
4529
|
+
}
|
|
4530
|
+
pkg.scripts = scripts;
|
|
4531
|
+
return JSON.stringify(pkg, null, 2) + "\n";
|
|
4532
|
+
}
|
|
2631
4533
|
async function configureAuthHandler(params, cwd) {
|
|
2632
4534
|
const {
|
|
2633
4535
|
method,
|
|
2634
4536
|
provider,
|
|
4537
|
+
devOnly = false,
|
|
4538
|
+
mockUsers,
|
|
2635
4539
|
rbacRoles = ["SUPER_ADMIN", "ADMIN", "ANALYST"],
|
|
2636
4540
|
auditEnabled = true,
|
|
2637
4541
|
auditRetentionDays = 90,
|
|
@@ -2640,6 +4544,9 @@ async function configureAuthHandler(params, cwd) {
|
|
|
2640
4544
|
const roles = rbacRoles;
|
|
2641
4545
|
const defaultRole = params.rbacDefaultRole ?? roles[roles.length - 1];
|
|
2642
4546
|
const hierarchy = buildHierarchy(roles);
|
|
4547
|
+
const detectedVersion = params.nextMajorVersion ?? detectNextMajorVersion(cwd);
|
|
4548
|
+
const useProxy = (detectedVersion ?? 0) >= 16;
|
|
4549
|
+
const conventionFile = useProxy ? "proxy.ts" : "middleware.ts";
|
|
2643
4550
|
if (method === "none") {
|
|
2644
4551
|
return {
|
|
2645
4552
|
content: [
|
|
@@ -2661,7 +4568,34 @@ async function configureAuthHandler(params, cwd) {
|
|
|
2661
4568
|
}
|
|
2662
4569
|
const filesWritten = [];
|
|
2663
4570
|
try {
|
|
2664
|
-
const
|
|
4571
|
+
const authFileContent = devOnly ? useProxy ? generateDevOnlyProxyContent(
|
|
4572
|
+
method,
|
|
4573
|
+
params,
|
|
4574
|
+
roles,
|
|
4575
|
+
defaultRole,
|
|
4576
|
+
hierarchy,
|
|
4577
|
+
auditEnabled,
|
|
4578
|
+
auditRetentionDays,
|
|
4579
|
+
protectedRoutes
|
|
4580
|
+
) : generateDevOnlyMiddlewareContent(
|
|
4581
|
+
method,
|
|
4582
|
+
params,
|
|
4583
|
+
roles,
|
|
4584
|
+
defaultRole,
|
|
4585
|
+
hierarchy,
|
|
4586
|
+
auditEnabled,
|
|
4587
|
+
auditRetentionDays,
|
|
4588
|
+
protectedRoutes
|
|
4589
|
+
) : useProxy ? generateProxyContent(
|
|
4590
|
+
method,
|
|
4591
|
+
params,
|
|
4592
|
+
roles,
|
|
4593
|
+
defaultRole,
|
|
4594
|
+
hierarchy,
|
|
4595
|
+
auditEnabled,
|
|
4596
|
+
auditRetentionDays,
|
|
4597
|
+
protectedRoutes
|
|
4598
|
+
) : generateMiddlewareContent(
|
|
2665
4599
|
method,
|
|
2666
4600
|
params,
|
|
2667
4601
|
roles,
|
|
@@ -2671,44 +4605,95 @@ async function configureAuthHandler(params, cwd) {
|
|
|
2671
4605
|
auditRetentionDays,
|
|
2672
4606
|
protectedRoutes
|
|
2673
4607
|
);
|
|
2674
|
-
|
|
2675
|
-
filesWritten.push(
|
|
4608
|
+
writeFileSync6(join6(cwd, conventionFile), authFileContent, "utf8");
|
|
4609
|
+
filesWritten.push(conventionFile);
|
|
2676
4610
|
} catch (err) {
|
|
2677
4611
|
const msg = err instanceof Error ? err.message : String(err);
|
|
2678
4612
|
return {
|
|
2679
4613
|
content: [
|
|
2680
4614
|
{
|
|
2681
4615
|
type: "text",
|
|
2682
|
-
text: JSON.stringify({
|
|
4616
|
+
text: JSON.stringify({
|
|
4617
|
+
success: false,
|
|
4618
|
+
error: `Failed writing ${conventionFile}: ${msg}`
|
|
4619
|
+
})
|
|
2683
4620
|
}
|
|
2684
4621
|
],
|
|
2685
4622
|
isError: true
|
|
2686
4623
|
};
|
|
2687
4624
|
}
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
4625
|
+
if (!devOnly) {
|
|
4626
|
+
try {
|
|
4627
|
+
const envBlock = generateEnvBlock(method, params);
|
|
4628
|
+
const envPath = join6(cwd, ".env.example");
|
|
4629
|
+
if (existsSync7(envPath)) {
|
|
4630
|
+
const existing = readFileSync6(envPath, "utf8");
|
|
4631
|
+
writeFileSync6(envPath, existing.trimEnd() + "\n\n" + envBlock, "utf8");
|
|
4632
|
+
} else {
|
|
4633
|
+
writeFileSync6(envPath, envBlock, "utf8");
|
|
4634
|
+
}
|
|
4635
|
+
filesWritten.push(".env.example");
|
|
4636
|
+
} catch (err) {
|
|
4637
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
4638
|
+
return {
|
|
4639
|
+
content: [
|
|
4640
|
+
{
|
|
4641
|
+
type: "text",
|
|
4642
|
+
text: JSON.stringify({ success: false, error: `Failed writing .env.example: ${msg}` })
|
|
4643
|
+
}
|
|
4644
|
+
],
|
|
4645
|
+
isError: true
|
|
4646
|
+
};
|
|
4647
|
+
}
|
|
4648
|
+
}
|
|
4649
|
+
if (devOnly) {
|
|
4650
|
+
try {
|
|
4651
|
+
const mockAuthDir = join6(cwd, "lib");
|
|
4652
|
+
mkdirSync6(mockAuthDir, { recursive: true });
|
|
4653
|
+
const mockAuthContent = generateMockAuthContent(roles, mockUsers);
|
|
4654
|
+
writeFileSync6(join6(mockAuthDir, "mock-auth.ts"), mockAuthContent, "utf8");
|
|
4655
|
+
filesWritten.push("lib/mock-auth.ts");
|
|
4656
|
+
} catch (err) {
|
|
4657
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
4658
|
+
return {
|
|
4659
|
+
content: [
|
|
4660
|
+
{
|
|
4661
|
+
type: "text",
|
|
4662
|
+
text: JSON.stringify({
|
|
4663
|
+
success: false,
|
|
4664
|
+
error: `Failed writing lib/mock-auth.ts: ${msg}`
|
|
4665
|
+
})
|
|
4666
|
+
}
|
|
4667
|
+
],
|
|
4668
|
+
isError: true
|
|
4669
|
+
};
|
|
4670
|
+
}
|
|
4671
|
+
try {
|
|
4672
|
+
const pkgPath = join6(cwd, "package.json");
|
|
4673
|
+
if (existsSync7(pkgPath)) {
|
|
4674
|
+
const existingPkg = readFileSync6(pkgPath, "utf8");
|
|
4675
|
+
const updatedPkg = updatePackageJsonScripts(existingPkg, roles, mockUsers);
|
|
4676
|
+
writeFileSync6(pkgPath, updatedPkg, "utf8");
|
|
4677
|
+
filesWritten.push("package.json");
|
|
4678
|
+
}
|
|
4679
|
+
} catch (err) {
|
|
4680
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
4681
|
+
return {
|
|
4682
|
+
content: [
|
|
4683
|
+
{
|
|
4684
|
+
type: "text",
|
|
4685
|
+
text: JSON.stringify({
|
|
4686
|
+
success: false,
|
|
4687
|
+
error: `Failed updating package.json: ${msg}`
|
|
4688
|
+
})
|
|
4689
|
+
}
|
|
4690
|
+
],
|
|
4691
|
+
isError: true
|
|
4692
|
+
};
|
|
2696
4693
|
}
|
|
2697
|
-
filesWritten.push(".env.example");
|
|
2698
|
-
} catch (err) {
|
|
2699
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
2700
|
-
return {
|
|
2701
|
-
content: [
|
|
2702
|
-
{
|
|
2703
|
-
type: "text",
|
|
2704
|
-
text: JSON.stringify({ success: false, error: `Failed writing .env.example: ${msg}` })
|
|
2705
|
-
}
|
|
2706
|
-
],
|
|
2707
|
-
isError: true
|
|
2708
|
-
};
|
|
2709
4694
|
}
|
|
2710
4695
|
try {
|
|
2711
|
-
const authYaml =
|
|
4696
|
+
const authYaml = devOnly ? generateDevOnlyYamlBlock(
|
|
2712
4697
|
method,
|
|
2713
4698
|
params,
|
|
2714
4699
|
roles,
|
|
@@ -2716,14 +4701,25 @@ async function configureAuthHandler(params, cwd) {
|
|
|
2716
4701
|
hierarchy,
|
|
2717
4702
|
auditEnabled,
|
|
2718
4703
|
auditRetentionDays,
|
|
2719
|
-
protectedRoutes
|
|
4704
|
+
protectedRoutes,
|
|
4705
|
+
useProxy
|
|
4706
|
+
) : generateYamlBlock(
|
|
4707
|
+
method,
|
|
4708
|
+
params,
|
|
4709
|
+
roles,
|
|
4710
|
+
defaultRole,
|
|
4711
|
+
hierarchy,
|
|
4712
|
+
auditEnabled,
|
|
4713
|
+
auditRetentionDays,
|
|
4714
|
+
protectedRoutes,
|
|
4715
|
+
useProxy
|
|
2720
4716
|
);
|
|
2721
|
-
const ymlPath =
|
|
2722
|
-
if (!
|
|
2723
|
-
|
|
4717
|
+
const ymlPath = join6(cwd, "stackwright.yml");
|
|
4718
|
+
if (!existsSync7(ymlPath)) {
|
|
4719
|
+
writeFileSync6(ymlPath, authYaml, "utf8");
|
|
2724
4720
|
} else {
|
|
2725
|
-
const existing =
|
|
2726
|
-
|
|
4721
|
+
const existing = readFileSync6(ymlPath, "utf8");
|
|
4722
|
+
writeFileSync6(ymlPath, upsertAuthBlock(existing, authYaml), "utf8");
|
|
2727
4723
|
}
|
|
2728
4724
|
filesWritten.push("stackwright.yml");
|
|
2729
4725
|
} catch (err) {
|
|
@@ -2751,7 +4747,23 @@ async function configureAuthHandler(params, cwd) {
|
|
|
2751
4747
|
rbacDefaultRole: defaultRole,
|
|
2752
4748
|
protectedRoutesCount: protectedRoutes.length,
|
|
2753
4749
|
filesWritten,
|
|
2754
|
-
|
|
4750
|
+
convention: useProxy ? "proxy" : "middleware",
|
|
4751
|
+
nextMajorVersion: params.nextMajorVersion ?? null,
|
|
4752
|
+
securityWarning,
|
|
4753
|
+
...devOnly ? {
|
|
4754
|
+
devScripts: {
|
|
4755
|
+
written: filesWritten.includes("package.json"),
|
|
4756
|
+
scripts: filesWritten.includes("package.json") ? Object.fromEntries(
|
|
4757
|
+
roles.map((r) => {
|
|
4758
|
+
const k = deriveDevKey(r);
|
|
4759
|
+
return [`dev:${k}`, `MOCK_USER=${k} next dev`];
|
|
4760
|
+
})
|
|
4761
|
+
) : {},
|
|
4762
|
+
...filesWritten.includes("package.json") ? {} : {
|
|
4763
|
+
warning: "package.json not found \u2014 dev scripts were not written. Run the auth phase again after scaffolding creates package.json."
|
|
4764
|
+
}
|
|
4765
|
+
}
|
|
4766
|
+
} : {}
|
|
2755
4767
|
})
|
|
2756
4768
|
}
|
|
2757
4769
|
]
|
|
@@ -2762,35 +4774,38 @@ function registerAuthTools(server2) {
|
|
|
2762
4774
|
"stackwright_pro_configure_auth",
|
|
2763
4775
|
"Generate authentication middleware and configuration for a Next.js Stackwright application. Writes `middleware.ts` from a secure template, appends/updates the `auth:` section in `stackwright.yml`, and generates `.env.example` with required environment variables. \u26A0\uFE0F For CAC/PKI: generated `middleware.ts` carries a SECURITY REVIEW REQUIRED comment \u2014 certificate chain validation must be verified by a DoD security officer before production deployment. This is the ONLY approved path to generating `middleware.ts`. Never write TypeScript auth files directly.",
|
|
2764
4776
|
{
|
|
2765
|
-
method:
|
|
2766
|
-
provider:
|
|
4777
|
+
method: z13.enum(["cac", "oidc", "oauth2", "none"]),
|
|
4778
|
+
provider: z13.enum(["azure-ad", "okta", "ping", "cognito", "custom"]).optional(),
|
|
2767
4779
|
// CAC
|
|
2768
|
-
cacCaBundle:
|
|
2769
|
-
cacEdipiLookup:
|
|
2770
|
-
cacOcspEndpoint:
|
|
2771
|
-
cacCertHeader:
|
|
4780
|
+
cacCaBundle: z13.string().optional(),
|
|
4781
|
+
cacEdipiLookup: z13.string().optional(),
|
|
4782
|
+
cacOcspEndpoint: z13.string().optional(),
|
|
4783
|
+
cacCertHeader: z13.string().optional(),
|
|
2772
4784
|
// OIDC
|
|
2773
|
-
oidcDiscoveryUrl:
|
|
2774
|
-
oidcClientId:
|
|
2775
|
-
oidcClientSecret:
|
|
2776
|
-
oidcScopes:
|
|
2777
|
-
oidcRoleClaim:
|
|
4785
|
+
oidcDiscoveryUrl: z13.string().optional(),
|
|
4786
|
+
oidcClientId: z13.string().optional(),
|
|
4787
|
+
oidcClientSecret: z13.string().optional(),
|
|
4788
|
+
oidcScopes: z13.string().optional(),
|
|
4789
|
+
oidcRoleClaim: z13.string().optional(),
|
|
2778
4790
|
// OAuth2
|
|
2779
|
-
oauth2AuthUrl:
|
|
2780
|
-
oauth2TokenUrl:
|
|
2781
|
-
oauth2ClientId:
|
|
2782
|
-
oauth2ClientSecret:
|
|
2783
|
-
oauth2Scopes:
|
|
4791
|
+
oauth2AuthUrl: z13.string().optional(),
|
|
4792
|
+
oauth2TokenUrl: z13.string().optional(),
|
|
4793
|
+
oauth2ClientId: z13.string().optional(),
|
|
4794
|
+
oauth2ClientSecret: z13.string().optional(),
|
|
4795
|
+
oauth2Scopes: z13.string().optional(),
|
|
2784
4796
|
// RBAC
|
|
2785
|
-
rbacRoles:
|
|
2786
|
-
rbacDefaultRole:
|
|
4797
|
+
rbacRoles: jsonCoerce(z13.array(z13.string()).optional()),
|
|
4798
|
+
rbacDefaultRole: z13.string().optional(),
|
|
2787
4799
|
// Audit
|
|
2788
|
-
auditEnabled:
|
|
2789
|
-
auditRetentionDays:
|
|
4800
|
+
auditEnabled: boolCoerce(z13.boolean().optional()),
|
|
4801
|
+
auditRetentionDays: numCoerce(z13.number().int().positive().optional()),
|
|
2790
4802
|
// Routes
|
|
2791
|
-
protectedRoutes:
|
|
4803
|
+
protectedRoutes: jsonCoerce(z13.array(z13.string()).optional()),
|
|
2792
4804
|
// Injection for tests
|
|
2793
|
-
_cwd:
|
|
4805
|
+
_cwd: z13.string().optional(),
|
|
4806
|
+
devOnly: boolCoerce(z13.boolean().optional()),
|
|
4807
|
+
mockUsers: jsonCoerce(z13.array(z13.object({ name: z13.string(), email: z13.string() })).optional()),
|
|
4808
|
+
nextMajorVersion: numCoerce(z13.number().int().positive().optional())
|
|
2794
4809
|
},
|
|
2795
4810
|
async (params) => {
|
|
2796
4811
|
const cwd = params._cwd ?? process.cwd();
|
|
@@ -2800,45 +4815,65 @@ function registerAuthTools(server2) {
|
|
|
2800
4815
|
}
|
|
2801
4816
|
|
|
2802
4817
|
// src/integrity.ts
|
|
2803
|
-
import { createHash as
|
|
2804
|
-
import { readFileSync as
|
|
2805
|
-
import { join as
|
|
4818
|
+
import { createHash as createHash4, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
4819
|
+
import { readFileSync as readFileSync7, readdirSync as readdirSync2, lstatSync as lstatSync7 } from "fs";
|
|
4820
|
+
import { join as join7, basename } from "path";
|
|
2806
4821
|
var _checksums = /* @__PURE__ */ new Map([
|
|
2807
4822
|
[
|
|
2808
4823
|
"stackwright-pro-api-otter.json",
|
|
2809
|
-
"
|
|
4824
|
+
"1b9fea59a3e5b1e1015229a4e8d1231b6b87e1d12f23a81ecbbd79fe8657a2e0"
|
|
2810
4825
|
],
|
|
2811
4826
|
[
|
|
2812
4827
|
"stackwright-pro-auth-otter.json",
|
|
2813
|
-
"
|
|
4828
|
+
"f91f82468da9c273fbe6481a3a40957d4c7aa3fa72528c492b6ac5d8e06a563a"
|
|
2814
4829
|
],
|
|
2815
4830
|
[
|
|
2816
4831
|
"stackwright-pro-dashboard-otter.json",
|
|
2817
|
-
"
|
|
4832
|
+
"9c319d311801730e8dc9bc142eebb8fc5a7f48da48fa0b8d8c3b7431652447be"
|
|
2818
4833
|
],
|
|
2819
4834
|
[
|
|
2820
4835
|
"stackwright-pro-data-otter.json",
|
|
2821
|
-
"
|
|
4836
|
+
"f39061981caf0bd14e4eb83e74ac200450425536d14b8c00d60be94759a1ca9d"
|
|
2822
4837
|
],
|
|
2823
4838
|
[
|
|
2824
4839
|
"stackwright-pro-designer-otter.json",
|
|
2825
|
-
"
|
|
4840
|
+
"af09ac8f06385bdbac63e2820daa2ff7d38b8ff1ff383c161f07e3fb9d9359c5"
|
|
4841
|
+
],
|
|
4842
|
+
[
|
|
4843
|
+
"stackwright-pro-domain-expert-otter.json",
|
|
4844
|
+
"41e3a5838f05f6a51ed272860dd2d5b1df1f20bfc847eb8f39be109b89738e99"
|
|
2826
4845
|
],
|
|
2827
4846
|
[
|
|
2828
4847
|
"stackwright-pro-foreman-otter.json",
|
|
2829
|
-
"
|
|
4848
|
+
"4948b8189223e4ced81b632cca7d14b79fde85f204483ab8e847182692c4ee57"
|
|
4849
|
+
],
|
|
4850
|
+
[
|
|
4851
|
+
"stackwright-pro-geo-otter.json",
|
|
4852
|
+
"ff1191af8108cc70c09afe6a99b008dcb46f71e20f884da1ee424e431868a1b6"
|
|
2830
4853
|
],
|
|
2831
4854
|
[
|
|
2832
4855
|
"stackwright-pro-page-otter.json",
|
|
2833
|
-
"
|
|
4856
|
+
"cdd878857c1d809c60263693ab0c6cbb45087fd9c3eeda5b4628bd7026d2bded"
|
|
4857
|
+
],
|
|
4858
|
+
[
|
|
4859
|
+
"stackwright-pro-polish-otter.json",
|
|
4860
|
+
"e5f88d054dd536646f48e5d47cbd64f825f493100002309b90c912fa69ef279e"
|
|
4861
|
+
],
|
|
4862
|
+
[
|
|
4863
|
+
"stackwright-pro-scaffold-otter.json",
|
|
4864
|
+
"c9950a34cc6a729a2ad22a4052afc3d7f9b033fe390d9edba4014b76e1d076b6"
|
|
2834
4865
|
],
|
|
2835
4866
|
[
|
|
2836
4867
|
"stackwright-pro-theme-otter.json",
|
|
2837
|
-
"
|
|
4868
|
+
"532d9ca7db6911a09ce5029a8c74c89360bdf6cb8ede8c2636866b509cbe06f9"
|
|
2838
4869
|
],
|
|
2839
4870
|
[
|
|
2840
4871
|
"stackwright-pro-workflow-otter.json",
|
|
2841
|
-
"
|
|
4872
|
+
"80eb6c8d3f079e00533dcb4a3c7fd6baae4d5f4192ab6c27c85d2de5359592fd"
|
|
4873
|
+
],
|
|
4874
|
+
[
|
|
4875
|
+
"stackwright-services-otter.json",
|
|
4876
|
+
"b8a252d6a2c5090138899925d3ea95bf3339b6e49adc276a1f5f7304b4ae5134"
|
|
2842
4877
|
]
|
|
2843
4878
|
]);
|
|
2844
4879
|
Object.freeze(_checksums);
|
|
@@ -2853,11 +4888,11 @@ for (const [name, digest] of CANONICAL_CHECKSUMS) {
|
|
|
2853
4888
|
}
|
|
2854
4889
|
var MAX_OTTER_BYTES = 1 * 1024 * 1024;
|
|
2855
4890
|
function computeSha256(data) {
|
|
2856
|
-
return
|
|
4891
|
+
return createHash4("sha256").update(data).digest("hex");
|
|
2857
4892
|
}
|
|
2858
4893
|
function safeEqual(a, b) {
|
|
2859
4894
|
if (a.length !== b.length) return false;
|
|
2860
|
-
return
|
|
4895
|
+
return timingSafeEqual2(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
|
|
2861
4896
|
}
|
|
2862
4897
|
function verifyOtterFile(filePath) {
|
|
2863
4898
|
const filename = basename(filePath);
|
|
@@ -2867,7 +4902,7 @@ function verifyOtterFile(filePath) {
|
|
|
2867
4902
|
}
|
|
2868
4903
|
let stat;
|
|
2869
4904
|
try {
|
|
2870
|
-
stat =
|
|
4905
|
+
stat = lstatSync7(filePath);
|
|
2871
4906
|
} catch (err) {
|
|
2872
4907
|
const msg = err instanceof Error ? err.message : String(err);
|
|
2873
4908
|
return { verified: false, filename, error: `Cannot stat file: ${msg}` };
|
|
@@ -2885,7 +4920,7 @@ function verifyOtterFile(filePath) {
|
|
|
2885
4920
|
}
|
|
2886
4921
|
let raw;
|
|
2887
4922
|
try {
|
|
2888
|
-
raw =
|
|
4923
|
+
raw = readFileSync7(filePath);
|
|
2889
4924
|
} catch (err) {
|
|
2890
4925
|
const msg = err instanceof Error ? err.message : String(err);
|
|
2891
4926
|
return { verified: false, filename, error: `Cannot read file: ${msg}` };
|
|
@@ -2918,12 +4953,24 @@ function verifyOtterFile(filePath) {
|
|
|
2918
4953
|
return { verified: true, filename };
|
|
2919
4954
|
}
|
|
2920
4955
|
function verifyAllOtters(otterDir) {
|
|
4956
|
+
if (/(?:^|[/\\])\.\.(?:[/\\]|$)/.test(otterDir) || otterDir.includes("..")) {
|
|
4957
|
+
return {
|
|
4958
|
+
verified: [],
|
|
4959
|
+
failed: [
|
|
4960
|
+
{
|
|
4961
|
+
filename: "<directory>",
|
|
4962
|
+
error: `Security: path traversal sequence detected in otter directory parameter`
|
|
4963
|
+
}
|
|
4964
|
+
],
|
|
4965
|
+
unknown: []
|
|
4966
|
+
};
|
|
4967
|
+
}
|
|
2921
4968
|
const verified = [];
|
|
2922
4969
|
const failed = [];
|
|
2923
4970
|
const unknown = [];
|
|
2924
4971
|
let entries;
|
|
2925
4972
|
try {
|
|
2926
|
-
entries =
|
|
4973
|
+
entries = readdirSync2(otterDir);
|
|
2927
4974
|
} catch (err) {
|
|
2928
4975
|
const msg = err instanceof Error ? err.message : String(err);
|
|
2929
4976
|
return {
|
|
@@ -2934,9 +4981,9 @@ function verifyAllOtters(otterDir) {
|
|
|
2934
4981
|
}
|
|
2935
4982
|
const otterFiles = entries.filter((f) => f.endsWith("-otter.json"));
|
|
2936
4983
|
for (const filename of otterFiles) {
|
|
2937
|
-
const filePath =
|
|
4984
|
+
const filePath = join7(otterDir, filename);
|
|
2938
4985
|
try {
|
|
2939
|
-
if (
|
|
4986
|
+
if (lstatSync7(filePath).isSymbolicLink()) {
|
|
2940
4987
|
failed.push({ filename, error: "Skipped: symlink" });
|
|
2941
4988
|
continue;
|
|
2942
4989
|
}
|
|
@@ -2962,15 +5009,30 @@ var DEFAULT_SEARCH_PATHS = ["node_modules/@stackwright-pro/otters/src/", "packag
|
|
|
2962
5009
|
function resolveOtterDir() {
|
|
2963
5010
|
const cwd = process.cwd();
|
|
2964
5011
|
for (const relative of DEFAULT_SEARCH_PATHS) {
|
|
2965
|
-
const candidate =
|
|
5012
|
+
const candidate = join7(cwd, relative);
|
|
2966
5013
|
try {
|
|
2967
|
-
|
|
5014
|
+
lstatSync7(candidate);
|
|
2968
5015
|
return candidate;
|
|
2969
5016
|
} catch {
|
|
2970
5017
|
}
|
|
2971
5018
|
}
|
|
2972
5019
|
return null;
|
|
2973
5020
|
}
|
|
5021
|
+
function emitIntegrityAuditEvent(params) {
|
|
5022
|
+
const record = JSON.stringify({
|
|
5023
|
+
level: "AUDIT",
|
|
5024
|
+
event: "INTEGRITY_FAIL",
|
|
5025
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5026
|
+
source: "stackwright_pro_verify_otter_integrity",
|
|
5027
|
+
otterDir: params.otterDir,
|
|
5028
|
+
failedCount: params.failed.length,
|
|
5029
|
+
unknownCount: params.unknown.length,
|
|
5030
|
+
failures: params.failed,
|
|
5031
|
+
unknown: params.unknown
|
|
5032
|
+
});
|
|
5033
|
+
process.stderr.write(`INTEGRITY_FAIL ${record}
|
|
5034
|
+
`);
|
|
5035
|
+
}
|
|
2974
5036
|
function registerIntegrityTools(server2) {
|
|
2975
5037
|
server2.tool(
|
|
2976
5038
|
"stackwright_pro_verify_otter_integrity",
|
|
@@ -2994,6 +5056,13 @@ function registerIntegrityTools(server2) {
|
|
|
2994
5056
|
}
|
|
2995
5057
|
const result = verifyAllOtters(resolved);
|
|
2996
5058
|
const allGood = result.failed.length === 0 && result.unknown.length === 0;
|
|
5059
|
+
if (!allGood) {
|
|
5060
|
+
emitIntegrityAuditEvent({
|
|
5061
|
+
otterDir: resolved,
|
|
5062
|
+
failed: result.failed,
|
|
5063
|
+
unknown: result.unknown
|
|
5064
|
+
});
|
|
5065
|
+
}
|
|
2997
5066
|
return {
|
|
2998
5067
|
content: [
|
|
2999
5068
|
{
|
|
@@ -3006,7 +5075,10 @@ function registerIntegrityTools(server2) {
|
|
|
3006
5075
|
unknownCount: result.unknown.length,
|
|
3007
5076
|
verified: result.verified,
|
|
3008
5077
|
failed: result.failed,
|
|
3009
|
-
unknown: result.unknown
|
|
5078
|
+
unknown: result.unknown,
|
|
5079
|
+
...allGood ? {} : {
|
|
5080
|
+
error: "INTEGRITY CHECK FAILED: SHA-256 mismatch detected in otter agent definitions. Do not proceed \u2014 otter files may have been tampered with."
|
|
5081
|
+
}
|
|
3010
5082
|
})
|
|
3011
5083
|
}
|
|
3012
5084
|
],
|
|
@@ -3017,14 +5089,14 @@ function registerIntegrityTools(server2) {
|
|
|
3017
5089
|
}
|
|
3018
5090
|
|
|
3019
5091
|
// src/tools/domain.ts
|
|
3020
|
-
import { z as
|
|
3021
|
-
import { readFileSync as
|
|
3022
|
-
import { join as
|
|
5092
|
+
import { z as z14 } from "zod";
|
|
5093
|
+
import { readFileSync as readFileSync8, existsSync as existsSync8 } from "fs";
|
|
5094
|
+
import { join as join8 } from "path";
|
|
3023
5095
|
function handleListCollections(input) {
|
|
3024
5096
|
const cwd = input._cwd ?? process.cwd();
|
|
3025
5097
|
const sources = [
|
|
3026
5098
|
{
|
|
3027
|
-
path:
|
|
5099
|
+
path: join8(cwd, ".stackwright", "artifacts", "data-config.json"),
|
|
3028
5100
|
source: "data-config.json",
|
|
3029
5101
|
parse: (raw) => {
|
|
3030
5102
|
const parsed = JSON.parse(raw);
|
|
@@ -3035,15 +5107,15 @@ function handleListCollections(input) {
|
|
|
3035
5107
|
}
|
|
3036
5108
|
},
|
|
3037
5109
|
{
|
|
3038
|
-
path:
|
|
5110
|
+
path: join8(cwd, "stackwright.yml"),
|
|
3039
5111
|
source: "stackwright.yml",
|
|
3040
5112
|
parse: extractCollectionsFromYaml
|
|
3041
5113
|
}
|
|
3042
5114
|
];
|
|
3043
5115
|
for (const { path: path3, source, parse } of sources) {
|
|
3044
|
-
if (!
|
|
5116
|
+
if (!existsSync8(path3)) continue;
|
|
3045
5117
|
try {
|
|
3046
|
-
const collections = parse(
|
|
5118
|
+
const collections = parse(readFileSync8(path3, "utf8"));
|
|
3047
5119
|
return {
|
|
3048
5120
|
text: JSON.stringify({ collections, source, collectionCount: collections.length }),
|
|
3049
5121
|
isError: false
|
|
@@ -3194,8 +5266,8 @@ function handleValidateWorkflow(input) {
|
|
|
3194
5266
|
if (input.workflow && Object.keys(input.workflow).length > 0) {
|
|
3195
5267
|
raw = input.workflow;
|
|
3196
5268
|
} else {
|
|
3197
|
-
const artifactPath =
|
|
3198
|
-
if (!
|
|
5269
|
+
const artifactPath = join8(cwd, ".stackwright", "artifacts", "workflow-config.json");
|
|
5270
|
+
if (!existsSync8(artifactPath)) {
|
|
3199
5271
|
return fail([
|
|
3200
5272
|
{
|
|
3201
5273
|
code: "NO_WORKFLOW",
|
|
@@ -3204,7 +5276,7 @@ function handleValidateWorkflow(input) {
|
|
|
3204
5276
|
]);
|
|
3205
5277
|
}
|
|
3206
5278
|
try {
|
|
3207
|
-
raw = JSON.parse(
|
|
5279
|
+
raw = JSON.parse(readFileSync8(artifactPath, "utf8"));
|
|
3208
5280
|
} catch (err) {
|
|
3209
5281
|
return fail([{ code: "INVALID_JSON", message: `Failed to parse workflow artifact: ${err}` }]);
|
|
3210
5282
|
}
|
|
@@ -3403,7 +5475,7 @@ function registerDomainTools(server2) {
|
|
|
3403
5475
|
"stackwright_pro_resolve_data_strategy",
|
|
3404
5476
|
"Look up the data freshness strategy configuration from the user's answer. Returns mechanism, revalidation seconds, required packages, and the exact MCP tool call to make. Replaces the strategy table in the data-otter prompt.",
|
|
3405
5477
|
{
|
|
3406
|
-
strategy:
|
|
5478
|
+
strategy: z14.string().describe(
|
|
3407
5479
|
'The data-1 answer value: "pulse-fast", "isr-fast", "isr-standard", or "isr-slow"'
|
|
3408
5480
|
)
|
|
3409
5481
|
},
|
|
@@ -3413,7 +5485,7 @@ function registerDomainTools(server2) {
|
|
|
3413
5485
|
"stackwright_pro_validate_workflow",
|
|
3414
5486
|
"Validate a workflow definition against the Stackwright workflow schema. Checks step ID uniqueness, transition targets, terminal state existence, and service references. Call this after the workflow otter produces output.",
|
|
3415
5487
|
{
|
|
3416
|
-
workflow:
|
|
5488
|
+
workflow: jsonCoerce(z14.record(z14.string(), z14.unknown()).optional()).describe(
|
|
3417
5489
|
"Parsed workflow object. If omitted, reads from .stackwright/artifacts/workflow-config.json"
|
|
3418
5490
|
)
|
|
3419
5491
|
},
|
|
@@ -3421,18 +5493,533 @@ function registerDomainTools(server2) {
|
|
|
3421
5493
|
);
|
|
3422
5494
|
}
|
|
3423
5495
|
|
|
5496
|
+
// src/tools/type-schemas.ts
|
|
5497
|
+
import { z as z15 } from "zod";
|
|
5498
|
+
function buildTypeSchemaSummary() {
|
|
5499
|
+
return {
|
|
5500
|
+
version: "1.0",
|
|
5501
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5502
|
+
domains: {
|
|
5503
|
+
workflow: {
|
|
5504
|
+
description: "Workflow DSL \u2014 step definitions, auth blocks, field types, conditions",
|
|
5505
|
+
schemas: [
|
|
5506
|
+
"WorkflowFileSchema",
|
|
5507
|
+
"WorkflowDefinitionSchema",
|
|
5508
|
+
"WorkflowStepSchema",
|
|
5509
|
+
"WorkflowStepTypeSchema",
|
|
5510
|
+
"WorkflowFieldSchema",
|
|
5511
|
+
"WorkflowActionSchema",
|
|
5512
|
+
"WorkflowAuthSchema",
|
|
5513
|
+
"WorkflowThemeSchema",
|
|
5514
|
+
"TransitionConditionSchema",
|
|
5515
|
+
"PersistenceSchema"
|
|
5516
|
+
],
|
|
5517
|
+
otter: "stackwright-pro-workflow-otter",
|
|
5518
|
+
artifactKey: "workflowConfig"
|
|
5519
|
+
},
|
|
5520
|
+
auth: {
|
|
5521
|
+
description: "Authentication providers \u2014 PKI/CAC, OIDC, RBAC configuration",
|
|
5522
|
+
schemas: [
|
|
5523
|
+
"authConfigSchema",
|
|
5524
|
+
"pkiConfigSchema",
|
|
5525
|
+
"oidcConfigSchema",
|
|
5526
|
+
"rbacConfigSchema",
|
|
5527
|
+
"componentAuthSchema",
|
|
5528
|
+
"authUserSchema",
|
|
5529
|
+
"authSessionSchema"
|
|
5530
|
+
],
|
|
5531
|
+
otter: "stackwright-pro-auth-otter",
|
|
5532
|
+
artifactKey: "authConfig"
|
|
5533
|
+
},
|
|
5534
|
+
openapi: {
|
|
5535
|
+
description: "OpenAPI spec integration \u2014 collection config, endpoint filters, actions",
|
|
5536
|
+
interfaces: [
|
|
5537
|
+
"OpenAPIConfig",
|
|
5538
|
+
"ActionConfig",
|
|
5539
|
+
"EndpointFilter",
|
|
5540
|
+
"ApprovedSpec",
|
|
5541
|
+
"PrebuildSecurityConfig",
|
|
5542
|
+
"SiteConfig",
|
|
5543
|
+
"ValidationResult"
|
|
5544
|
+
],
|
|
5545
|
+
otter: "stackwright-pro-api-otter",
|
|
5546
|
+
artifactKey: "apiConfig"
|
|
5547
|
+
},
|
|
5548
|
+
pulse: {
|
|
5549
|
+
description: "Real-time data polling \u2014 source-agnostic polling options and states",
|
|
5550
|
+
interfaces: ["PulseOptions", "PulseMeta", "PulseState"],
|
|
5551
|
+
note: "React-bound types (PulseProps, PulseIndicatorProps) remain in @stackwright-pro/pulse"
|
|
5552
|
+
},
|
|
5553
|
+
enterprise: {
|
|
5554
|
+
description: "Enterprise collection access \u2014 multi-tenant provider extension",
|
|
5555
|
+
interfaces: [
|
|
5556
|
+
"EnterpriseCollectionProvider",
|
|
5557
|
+
"CollectionProvider",
|
|
5558
|
+
"CollectionEntry",
|
|
5559
|
+
"CollectionListOptions",
|
|
5560
|
+
"CollectionListResult",
|
|
5561
|
+
"TenantFilter",
|
|
5562
|
+
"Collection"
|
|
5563
|
+
],
|
|
5564
|
+
note: "CollectionProvider, CollectionEntry, CollectionListOptions, and CollectionListResult are re-exported from @stackwright/types (^1.5.0). EnterpriseCollectionProvider, TenantFilter, and Collection are Pro-only extensions."
|
|
5565
|
+
}
|
|
5566
|
+
}
|
|
5567
|
+
};
|
|
5568
|
+
}
|
|
5569
|
+
function registerTypeSchemasTool(server2) {
|
|
5570
|
+
server2.tool(
|
|
5571
|
+
"stackwright_pro_get_type_schemas",
|
|
5572
|
+
"Returns a structured summary of all canonical @stackwright-pro/types schemas, organized by domain. Use this to determine which otter owns a given schema and what artifact key to expect.",
|
|
5573
|
+
{
|
|
5574
|
+
format: z15.enum(["full", "domains-only"]).optional().default("full").describe("full = complete summary with all fields; domains-only = just domain names")
|
|
5575
|
+
},
|
|
5576
|
+
async ({ format }) => {
|
|
5577
|
+
const summary = buildTypeSchemaSummary();
|
|
5578
|
+
const output = format === "domains-only" ? Object.keys(summary.domains) : summary;
|
|
5579
|
+
return {
|
|
5580
|
+
content: [{ type: "text", text: JSON.stringify(output, null, 2) }]
|
|
5581
|
+
};
|
|
5582
|
+
}
|
|
5583
|
+
);
|
|
5584
|
+
}
|
|
5585
|
+
|
|
5586
|
+
// src/tools/scaffold-cleanup.ts
|
|
5587
|
+
import {
|
|
5588
|
+
existsSync as existsSync9,
|
|
5589
|
+
lstatSync as lstatSync8,
|
|
5590
|
+
unlinkSync as unlinkSync2,
|
|
5591
|
+
readFileSync as readFileSync9,
|
|
5592
|
+
writeFileSync as writeFileSync7,
|
|
5593
|
+
readdirSync as readdirSync3,
|
|
5594
|
+
rmdirSync,
|
|
5595
|
+
mkdirSync as mkdirSync7
|
|
5596
|
+
} from "fs";
|
|
5597
|
+
import { join as join9, dirname as dirname2 } from "path";
|
|
5598
|
+
var SCAFFOLD_FILES_TO_DELETE = [
|
|
5599
|
+
"content/posts/getting-started.yaml",
|
|
5600
|
+
"content/posts/hello-world.yaml"
|
|
5601
|
+
];
|
|
5602
|
+
var SCAFFOLD_DIRS_TO_PRUNE = [
|
|
5603
|
+
"content/posts"
|
|
5604
|
+
// Don't prune 'content' — user may have other content directories
|
|
5605
|
+
];
|
|
5606
|
+
var NOT_FOUND_PATH = "app/not-found.tsx";
|
|
5607
|
+
var FALLBACK_COLORS = {
|
|
5608
|
+
background: "#ffffff",
|
|
5609
|
+
foreground: "#171717",
|
|
5610
|
+
primary: "#525252",
|
|
5611
|
+
mutedForeground: "#737373"
|
|
5612
|
+
};
|
|
5613
|
+
function readThemeColors(cwd) {
|
|
5614
|
+
const tokenPath = join9(cwd, ".stackwright", "artifacts", "theme-tokens.json");
|
|
5615
|
+
if (!existsSync9(tokenPath)) return { ...FALLBACK_COLORS };
|
|
5616
|
+
const stat = lstatSync8(tokenPath);
|
|
5617
|
+
if (stat.isSymbolicLink()) return { ...FALLBACK_COLORS };
|
|
5618
|
+
try {
|
|
5619
|
+
const raw = JSON.parse(readFileSync9(tokenPath, "utf-8"));
|
|
5620
|
+
const css = raw?.cssVariables ?? {};
|
|
5621
|
+
const toHsl = (hslValue) => {
|
|
5622
|
+
if (!hslValue || typeof hslValue !== "string") return null;
|
|
5623
|
+
return `hsl(${hslValue})`;
|
|
5624
|
+
};
|
|
5625
|
+
return {
|
|
5626
|
+
background: toHsl(css["--background"]) ?? FALLBACK_COLORS.background,
|
|
5627
|
+
foreground: toHsl(css["--foreground"]) ?? FALLBACK_COLORS.foreground,
|
|
5628
|
+
primary: toHsl(css["--primary"]) ?? FALLBACK_COLORS.primary,
|
|
5629
|
+
mutedForeground: toHsl(css["--muted-foreground"]) ?? FALLBACK_COLORS.mutedForeground
|
|
5630
|
+
};
|
|
5631
|
+
} catch {
|
|
5632
|
+
return { ...FALLBACK_COLORS };
|
|
5633
|
+
}
|
|
5634
|
+
}
|
|
5635
|
+
function generateNotFoundPage(colors) {
|
|
5636
|
+
return `export default function NotFound() {
|
|
5637
|
+
return (
|
|
5638
|
+
<div
|
|
5639
|
+
style={{
|
|
5640
|
+
display: 'flex',
|
|
5641
|
+
flexDirection: 'column',
|
|
5642
|
+
alignItems: 'center',
|
|
5643
|
+
justifyContent: 'center',
|
|
5644
|
+
minHeight: '100vh',
|
|
5645
|
+
backgroundColor: '${colors.background}',
|
|
5646
|
+
color: '${colors.foreground}',
|
|
5647
|
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
5648
|
+
padding: '2rem',
|
|
5649
|
+
textAlign: 'center',
|
|
5650
|
+
}}
|
|
5651
|
+
>
|
|
5652
|
+
<h1 style={{ fontSize: '4rem', fontWeight: 700, margin: 0, color: '${colors.primary}' }}>
|
|
5653
|
+
404
|
|
5654
|
+
</h1>
|
|
5655
|
+
<p style={{ fontSize: '1.25rem', color: '${colors.mutedForeground}', marginTop: '0.5rem' }}>
|
|
5656
|
+
Page not found
|
|
5657
|
+
</p>
|
|
5658
|
+
<a
|
|
5659
|
+
href="/"
|
|
5660
|
+
style={{
|
|
5661
|
+
marginTop: '2rem',
|
|
5662
|
+
padding: '0.75rem 1.5rem',
|
|
5663
|
+
backgroundColor: '${colors.primary}',
|
|
5664
|
+
color: '${colors.background}',
|
|
5665
|
+
borderRadius: '0.5rem',
|
|
5666
|
+
textDecoration: 'none',
|
|
5667
|
+
fontSize: '0.875rem',
|
|
5668
|
+
fontWeight: 500,
|
|
5669
|
+
}}
|
|
5670
|
+
>
|
|
5671
|
+
Go Home
|
|
5672
|
+
</a>
|
|
5673
|
+
</div>
|
|
5674
|
+
);
|
|
5675
|
+
}
|
|
5676
|
+
`;
|
|
5677
|
+
}
|
|
5678
|
+
function isSafePath(fullPath) {
|
|
5679
|
+
if (!existsSync9(fullPath)) return true;
|
|
5680
|
+
const stat = lstatSync8(fullPath);
|
|
5681
|
+
return !stat.isSymbolicLink();
|
|
5682
|
+
}
|
|
5683
|
+
function isDirEmpty(dirPath) {
|
|
5684
|
+
if (!existsSync9(dirPath)) return false;
|
|
5685
|
+
const stat = lstatSync8(dirPath);
|
|
5686
|
+
if (!stat.isDirectory()) return false;
|
|
5687
|
+
return readdirSync3(dirPath).length === 0;
|
|
5688
|
+
}
|
|
5689
|
+
function handleCleanupScaffold(_cwd) {
|
|
5690
|
+
const cwd = _cwd ?? process.cwd();
|
|
5691
|
+
const result = {
|
|
5692
|
+
deleted: [],
|
|
5693
|
+
rewritten: [],
|
|
5694
|
+
skipped: [],
|
|
5695
|
+
errors: [],
|
|
5696
|
+
prunedDirs: []
|
|
5697
|
+
};
|
|
5698
|
+
for (const relPath of SCAFFOLD_FILES_TO_DELETE) {
|
|
5699
|
+
const fullPath = join9(cwd, relPath);
|
|
5700
|
+
if (!existsSync9(fullPath)) {
|
|
5701
|
+
result.skipped.push(relPath);
|
|
5702
|
+
continue;
|
|
5703
|
+
}
|
|
5704
|
+
if (!isSafePath(fullPath)) {
|
|
5705
|
+
result.errors.push(`Refusing to delete symlink: ${relPath}`);
|
|
5706
|
+
continue;
|
|
5707
|
+
}
|
|
5708
|
+
try {
|
|
5709
|
+
unlinkSync2(fullPath);
|
|
5710
|
+
result.deleted.push(relPath);
|
|
5711
|
+
} catch (err) {
|
|
5712
|
+
result.errors.push(`Failed to delete ${relPath}: ${String(err)}`);
|
|
5713
|
+
}
|
|
5714
|
+
}
|
|
5715
|
+
for (const relDir of SCAFFOLD_DIRS_TO_PRUNE) {
|
|
5716
|
+
const fullDir = join9(cwd, relDir);
|
|
5717
|
+
if (!existsSync9(fullDir)) continue;
|
|
5718
|
+
if (!isSafePath(fullDir)) {
|
|
5719
|
+
result.errors.push(`Refusing to prune symlink directory: ${relDir}`);
|
|
5720
|
+
continue;
|
|
5721
|
+
}
|
|
5722
|
+
if (isDirEmpty(fullDir)) {
|
|
5723
|
+
try {
|
|
5724
|
+
rmdirSync(fullDir);
|
|
5725
|
+
result.prunedDirs.push(relDir);
|
|
5726
|
+
} catch (err) {
|
|
5727
|
+
result.errors.push(`Failed to prune directory ${relDir}: ${String(err)}`);
|
|
5728
|
+
}
|
|
5729
|
+
}
|
|
5730
|
+
}
|
|
5731
|
+
{
|
|
5732
|
+
const fullPath = join9(cwd, NOT_FOUND_PATH);
|
|
5733
|
+
if (!existsSync9(fullPath)) {
|
|
5734
|
+
result.skipped.push(NOT_FOUND_PATH);
|
|
5735
|
+
} else if (!isSafePath(fullPath)) {
|
|
5736
|
+
result.errors.push(`Refusing to rewrite symlink: ${NOT_FOUND_PATH}`);
|
|
5737
|
+
} else {
|
|
5738
|
+
try {
|
|
5739
|
+
const colors = readThemeColors(cwd);
|
|
5740
|
+
const content = generateNotFoundPage(colors);
|
|
5741
|
+
const dir = dirname2(fullPath);
|
|
5742
|
+
mkdirSync7(dir, { recursive: true });
|
|
5743
|
+
writeFileSync7(fullPath, content, "utf-8");
|
|
5744
|
+
result.rewritten.push(NOT_FOUND_PATH);
|
|
5745
|
+
} catch (err) {
|
|
5746
|
+
result.errors.push(`Failed to rewrite ${NOT_FOUND_PATH}: ${String(err)}`);
|
|
5747
|
+
}
|
|
5748
|
+
}
|
|
5749
|
+
}
|
|
5750
|
+
return {
|
|
5751
|
+
text: JSON.stringify(result),
|
|
5752
|
+
isError: false
|
|
5753
|
+
};
|
|
5754
|
+
}
|
|
5755
|
+
function registerScaffoldCleanupTools(server2) {
|
|
5756
|
+
const DESC = "Deterministic scaffold remnant cleanup \u2014 removes stale OSS scaffold files after raft run.";
|
|
5757
|
+
server2.tool(
|
|
5758
|
+
"stackwright_pro_cleanup_scaffold",
|
|
5759
|
+
`Remove/rewrite stale scaffold files (blog posts, hardcoded not-found page) after the raft pipeline completes. ${DESC}`,
|
|
5760
|
+
{},
|
|
5761
|
+
async () => {
|
|
5762
|
+
const r = handleCleanupScaffold();
|
|
5763
|
+
return {
|
|
5764
|
+
content: [{ type: "text", text: r.text }],
|
|
5765
|
+
isError: r.isError
|
|
5766
|
+
};
|
|
5767
|
+
}
|
|
5768
|
+
);
|
|
5769
|
+
}
|
|
5770
|
+
|
|
5771
|
+
// src/tools/contrast.ts
|
|
5772
|
+
import { z as z16 } from "zod";
|
|
5773
|
+
function linearizeChannel(c255) {
|
|
5774
|
+
const c = c255 / 255;
|
|
5775
|
+
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
5776
|
+
}
|
|
5777
|
+
function relativeLuminance(r, g, b) {
|
|
5778
|
+
return 0.2126 * linearizeChannel(r) + 0.7152 * linearizeChannel(g) + 0.0722 * linearizeChannel(b);
|
|
5779
|
+
}
|
|
5780
|
+
function contrastRatioFromLuminance(l1, l2) {
|
|
5781
|
+
const lighter = Math.max(l1, l2);
|
|
5782
|
+
const darker = Math.min(l1, l2);
|
|
5783
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
5784
|
+
}
|
|
5785
|
+
function parseHex(hex) {
|
|
5786
|
+
const clean = hex.replace("#", "").trim().toLowerCase();
|
|
5787
|
+
if (clean.length === 3) {
|
|
5788
|
+
const r = parseInt(clean.charAt(0) + clean.charAt(0), 16);
|
|
5789
|
+
const g = parseInt(clean.charAt(1) + clean.charAt(1), 16);
|
|
5790
|
+
const b = parseInt(clean.charAt(2) + clean.charAt(2), 16);
|
|
5791
|
+
if (isNaN(r) || isNaN(g) || isNaN(b)) return null;
|
|
5792
|
+
return [r, g, b];
|
|
5793
|
+
}
|
|
5794
|
+
if (clean.length === 6) {
|
|
5795
|
+
const r = parseInt(clean.slice(0, 2), 16);
|
|
5796
|
+
const g = parseInt(clean.slice(2, 4), 16);
|
|
5797
|
+
const b = parseInt(clean.slice(4, 6), 16);
|
|
5798
|
+
if (isNaN(r) || isNaN(g) || isNaN(b)) return null;
|
|
5799
|
+
return [r, g, b];
|
|
5800
|
+
}
|
|
5801
|
+
return null;
|
|
5802
|
+
}
|
|
5803
|
+
function hslToRgb(h, s, l) {
|
|
5804
|
+
const hn = h / 360;
|
|
5805
|
+
const sn = s / 100;
|
|
5806
|
+
const ln = l / 100;
|
|
5807
|
+
const hue2rgb = (p, q, t) => {
|
|
5808
|
+
let tt = t;
|
|
5809
|
+
if (tt < 0) tt += 1;
|
|
5810
|
+
if (tt > 1) tt -= 1;
|
|
5811
|
+
if (tt < 1 / 6) return p + (q - p) * 6 * tt;
|
|
5812
|
+
if (tt < 1 / 2) return q;
|
|
5813
|
+
if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
|
|
5814
|
+
return p;
|
|
5815
|
+
};
|
|
5816
|
+
let r, g, b;
|
|
5817
|
+
if (sn === 0) {
|
|
5818
|
+
r = g = b = ln;
|
|
5819
|
+
} else {
|
|
5820
|
+
const q = ln < 0.5 ? ln * (1 + sn) : ln + sn - ln * sn;
|
|
5821
|
+
const p = 2 * ln - q;
|
|
5822
|
+
r = hue2rgb(p, q, hn + 1 / 3);
|
|
5823
|
+
g = hue2rgb(p, q, hn);
|
|
5824
|
+
b = hue2rgb(p, q, hn - 1 / 3);
|
|
5825
|
+
}
|
|
5826
|
+
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
|
|
5827
|
+
}
|
|
5828
|
+
function parseHsl(hsl) {
|
|
5829
|
+
let str = hsl.replace(/^hsl\s*\(\s*/i, "").replace(/\s*\)$/, "").trim();
|
|
5830
|
+
str = str.replace(/%/g, "");
|
|
5831
|
+
const parts = str.split(/[\s,]+/).filter(Boolean);
|
|
5832
|
+
if (parts.length !== 3) return null;
|
|
5833
|
+
const h = parseFloat(parts[0]);
|
|
5834
|
+
const s = parseFloat(parts[1]);
|
|
5835
|
+
const l = parseFloat(parts[2]);
|
|
5836
|
+
if (isNaN(h) || isNaN(s) || isNaN(l)) return null;
|
|
5837
|
+
return hslToRgb(h, s, l);
|
|
5838
|
+
}
|
|
5839
|
+
function parseColor(color) {
|
|
5840
|
+
const trimmed = color.trim();
|
|
5841
|
+
if (trimmed.startsWith("#")) return parseHex(trimmed);
|
|
5842
|
+
if (/^hsl\s*\(/i.test(trimmed)) return parseHsl(trimmed);
|
|
5843
|
+
if (/^\d[\d.]*[\s,]+\d[\d.]*%?[\s,]+\d[\d.]*%?$/.test(trimmed)) return parseHsl(trimmed);
|
|
5844
|
+
return null;
|
|
5845
|
+
}
|
|
5846
|
+
function rgbToHex(r, g, b) {
|
|
5847
|
+
return "#" + [r, g, b].map(
|
|
5848
|
+
(c) => Math.max(0, Math.min(255, Math.round(c))).toString(16).padStart(2, "0")
|
|
5849
|
+
).join("");
|
|
5850
|
+
}
|
|
5851
|
+
function handleCheckContrast(fg, bg) {
|
|
5852
|
+
const fgRgb = parseColor(fg);
|
|
5853
|
+
const bgRgb = parseColor(bg);
|
|
5854
|
+
if (!fgRgb) throw new Error(`Cannot parse foreground color: "${fg}"`);
|
|
5855
|
+
if (!bgRgb) throw new Error(`Cannot parse background color: "${bg}"`);
|
|
5856
|
+
const fgL = relativeLuminance(...fgRgb);
|
|
5857
|
+
const bgL = relativeLuminance(...bgRgb);
|
|
5858
|
+
const ratio = parseFloat(contrastRatioFromLuminance(fgL, bgL).toFixed(2));
|
|
5859
|
+
return {
|
|
5860
|
+
fgHex: rgbToHex(...fgRgb),
|
|
5861
|
+
bgHex: rgbToHex(...bgRgb),
|
|
5862
|
+
ratio,
|
|
5863
|
+
aa: ratio >= 4.5,
|
|
5864
|
+
aaLarge: ratio >= 3,
|
|
5865
|
+
aaa: ratio >= 7,
|
|
5866
|
+
aaaLarge: ratio >= 4.5
|
|
5867
|
+
};
|
|
5868
|
+
}
|
|
5869
|
+
function handleDeriveAccessiblePalette(seed, targetRatio) {
|
|
5870
|
+
const seedRgb = parseColor(seed);
|
|
5871
|
+
if (!seedRgb) throw new Error(`Cannot parse seed color: "${seed}"`);
|
|
5872
|
+
const seedHex = rgbToHex(...seedRgb);
|
|
5873
|
+
const seedL = relativeLuminance(...seedRgb);
|
|
5874
|
+
const whiteRatio = parseFloat(contrastRatioFromLuminance(1, seedL).toFixed(2));
|
|
5875
|
+
const blackRatio = parseFloat(contrastRatioFromLuminance(0, seedL).toFixed(2));
|
|
5876
|
+
const whiteWorks = whiteRatio >= targetRatio;
|
|
5877
|
+
const blackWorks = blackRatio >= targetRatio;
|
|
5878
|
+
let strategy;
|
|
5879
|
+
let foreground;
|
|
5880
|
+
let ratio;
|
|
5881
|
+
let alternativeForeground;
|
|
5882
|
+
let alternativeRatio;
|
|
5883
|
+
if (blackWorks && whiteWorks) {
|
|
5884
|
+
if (blackRatio >= whiteRatio) {
|
|
5885
|
+
strategy = "black";
|
|
5886
|
+
foreground = "#000000";
|
|
5887
|
+
ratio = blackRatio;
|
|
5888
|
+
alternativeForeground = "#ffffff";
|
|
5889
|
+
alternativeRatio = whiteRatio;
|
|
5890
|
+
} else {
|
|
5891
|
+
strategy = "white";
|
|
5892
|
+
foreground = "#ffffff";
|
|
5893
|
+
ratio = whiteRatio;
|
|
5894
|
+
alternativeForeground = "#000000";
|
|
5895
|
+
alternativeRatio = blackRatio;
|
|
5896
|
+
}
|
|
5897
|
+
} else if (blackWorks) {
|
|
5898
|
+
strategy = "black";
|
|
5899
|
+
foreground = "#000000";
|
|
5900
|
+
ratio = blackRatio;
|
|
5901
|
+
alternativeForeground = "#ffffff";
|
|
5902
|
+
alternativeRatio = whiteRatio;
|
|
5903
|
+
} else if (whiteWorks) {
|
|
5904
|
+
strategy = "white";
|
|
5905
|
+
foreground = "#ffffff";
|
|
5906
|
+
ratio = whiteRatio;
|
|
5907
|
+
alternativeForeground = "#000000";
|
|
5908
|
+
alternativeRatio = blackRatio;
|
|
5909
|
+
} else {
|
|
5910
|
+
strategy = "unachievable";
|
|
5911
|
+
if (blackRatio >= whiteRatio) {
|
|
5912
|
+
foreground = "#000000";
|
|
5913
|
+
ratio = blackRatio;
|
|
5914
|
+
alternativeForeground = "#ffffff";
|
|
5915
|
+
alternativeRatio = whiteRatio;
|
|
5916
|
+
} else {
|
|
5917
|
+
foreground = "#ffffff";
|
|
5918
|
+
ratio = whiteRatio;
|
|
5919
|
+
alternativeForeground = "#000000";
|
|
5920
|
+
alternativeRatio = blackRatio;
|
|
5921
|
+
}
|
|
5922
|
+
}
|
|
5923
|
+
return {
|
|
5924
|
+
seedHex,
|
|
5925
|
+
foreground,
|
|
5926
|
+
ratio,
|
|
5927
|
+
aa: ratio >= 4.5,
|
|
5928
|
+
aaLarge: ratio >= 3,
|
|
5929
|
+
aaa: ratio >= 7,
|
|
5930
|
+
meetsTarget: ratio >= targetRatio,
|
|
5931
|
+
strategy,
|
|
5932
|
+
alternativeForeground,
|
|
5933
|
+
alternativeRatio
|
|
5934
|
+
};
|
|
5935
|
+
}
|
|
5936
|
+
var PASS = "[PASS]";
|
|
5937
|
+
var FAIL = "[FAIL]";
|
|
5938
|
+
var mark = (ok) => ok ? PASS : FAIL;
|
|
5939
|
+
function registerContrastTools(server2) {
|
|
5940
|
+
server2.tool(
|
|
5941
|
+
"stackwright_pro_check_contrast",
|
|
5942
|
+
'Compute the exact WCAG 2.1 contrast ratio between a foreground and background color. Returns the ratio and AA/AAA pass/fail flags. Use this to verify any foreground/background color pair before writing it to the token set. Accepts #RGB, #RRGGBB, hsl(...), or shadcn HSL format ("H S% L%").',
|
|
5943
|
+
{
|
|
5944
|
+
fg: z16.string().describe("Foreground (text) color \u2014 hex or HSL"),
|
|
5945
|
+
bg: z16.string().describe("Background color \u2014 hex or HSL")
|
|
5946
|
+
},
|
|
5947
|
+
async ({ fg, bg }) => {
|
|
5948
|
+
let result;
|
|
5949
|
+
try {
|
|
5950
|
+
result = handleCheckContrast(fg, bg);
|
|
5951
|
+
} catch (err) {
|
|
5952
|
+
return {
|
|
5953
|
+
content: [{ type: "text", text: `Error: ${err.message}` }]
|
|
5954
|
+
};
|
|
5955
|
+
}
|
|
5956
|
+
const text = [
|
|
5957
|
+
`WCAG 2.1 Contrast Check`,
|
|
5958
|
+
``,
|
|
5959
|
+
` Foreground : ${result.fgHex}`,
|
|
5960
|
+
` Background : ${result.bgHex}`,
|
|
5961
|
+
` Ratio : ${result.ratio}:1`,
|
|
5962
|
+
``,
|
|
5963
|
+
` ${mark(result.aa)} AA \u2014 normal text (\u22654.5:1)`,
|
|
5964
|
+
` ${mark(result.aaLarge)} AA \u2014 large text (\u22653.0:1)`,
|
|
5965
|
+
` ${mark(result.aaa)} AAA \u2014 normal text (\u22657.0:1)`,
|
|
5966
|
+
` ${mark(result.aaaLarge)} AAA \u2014 large text (\u22654.5:1)`
|
|
5967
|
+
].join("\n");
|
|
5968
|
+
return { content: [{ type: "text", text }] };
|
|
5969
|
+
}
|
|
5970
|
+
);
|
|
5971
|
+
server2.tool(
|
|
5972
|
+
"stackwright_pro_derive_accessible_palette",
|
|
5973
|
+
'Given a seed color (treated as a background), derive the best accessible foreground (text) color that meets the target contrast ratio. Evaluates #ffffff and #000000 against the seed and picks whichever satisfies the target ratio with the highest contrast. Use this for every {name}-foreground token derivation instead of guessing white vs black. Accepts #RGB, #RRGGBB, hsl(...), or shadcn HSL format ("H S% L%").',
|
|
5974
|
+
{
|
|
5975
|
+
seed: z16.string().describe("Background (seed) color \u2014 hex or HSL"),
|
|
5976
|
+
targetRatio: numCoerce(z16.number().default(4.5)).describe(
|
|
5977
|
+
"Minimum required contrast ratio (default 4.5 for WCAG AA)"
|
|
5978
|
+
)
|
|
5979
|
+
},
|
|
5980
|
+
async ({ seed, targetRatio }) => {
|
|
5981
|
+
let result;
|
|
5982
|
+
try {
|
|
5983
|
+
result = handleDeriveAccessiblePalette(seed, targetRatio ?? 4.5);
|
|
5984
|
+
} catch (err) {
|
|
5985
|
+
return {
|
|
5986
|
+
content: [{ type: "text", text: `Error: ${err.message}` }]
|
|
5987
|
+
};
|
|
5988
|
+
}
|
|
5989
|
+
const metLine = result.meetsTarget ? `${PASS} Meets target (${targetRatio}:1)` : `${FAIL} Target (${targetRatio}:1) UNACHIEVABLE with white or black \u2014 best available used`;
|
|
5990
|
+
const altNote = result.alternativeRatio >= (targetRatio ?? 4.5) ? `also passes (${result.alternativeRatio}:1)` : `fails at ${result.alternativeRatio}:1`;
|
|
5991
|
+
const text = [
|
|
5992
|
+
`Accessible Palette Derivation`,
|
|
5993
|
+
``,
|
|
5994
|
+
` Background (seed) : ${result.seedHex}`,
|
|
5995
|
+
` Foreground : ${result.foreground} (${result.strategy})`,
|
|
5996
|
+
` Contrast ratio : ${result.ratio}:1`,
|
|
5997
|
+
` ${metLine}`,
|
|
5998
|
+
``,
|
|
5999
|
+
` ${mark(result.aa)} AA \u2014 normal text (\u22654.5:1)`,
|
|
6000
|
+
` ${mark(result.aaLarge)} AA \u2014 large text (\u22653.0:1)`,
|
|
6001
|
+
` ${mark(result.aaa)} AAA \u2014 normal text (\u22657.0:1)`,
|
|
6002
|
+
``,
|
|
6003
|
+
` Alternative (${result.alternativeForeground}): ${altNote}`
|
|
6004
|
+
].join("\n");
|
|
6005
|
+
return { content: [{ type: "text", text }] };
|
|
6006
|
+
}
|
|
6007
|
+
);
|
|
6008
|
+
}
|
|
6009
|
+
|
|
3424
6010
|
// package.json
|
|
3425
6011
|
var package_default = {
|
|
3426
6012
|
dependencies: {
|
|
6013
|
+
"@stackwright-pro/types": "workspace:*",
|
|
3427
6014
|
"@modelcontextprotocol/sdk": "^1.10.0",
|
|
3428
6015
|
"@stackwright-pro/cli-data-explorer": "workspace:*",
|
|
3429
|
-
zod: "^4.3
|
|
6016
|
+
zod: "^4.4.3"
|
|
3430
6017
|
},
|
|
3431
6018
|
devDependencies: {
|
|
3432
|
-
"@types/node": "
|
|
3433
|
-
tsup: "
|
|
3434
|
-
typescript: "
|
|
3435
|
-
vitest: "
|
|
6019
|
+
"@types/node": "catalog:",
|
|
6020
|
+
tsup: "catalog:",
|
|
6021
|
+
typescript: "catalog:",
|
|
6022
|
+
vitest: "catalog:"
|
|
3436
6023
|
},
|
|
3437
6024
|
scripts: {
|
|
3438
6025
|
prepublishOnly: "node scripts/verify-integrity-sync.js",
|
|
@@ -3443,10 +6030,13 @@ var package_default = {
|
|
|
3443
6030
|
"test:coverage": "vitest run --coverage"
|
|
3444
6031
|
},
|
|
3445
6032
|
name: "@stackwright-pro/mcp",
|
|
3446
|
-
version: "0.2.0-alpha.
|
|
6033
|
+
version: "0.2.0-alpha.80",
|
|
3447
6034
|
description: "MCP tools for Stackwright Pro - Data Explorer, Security, ISR, and Dashboard generation",
|
|
3448
|
-
license: "
|
|
6035
|
+
license: "SEE LICENSE IN LICENSE",
|
|
3449
6036
|
main: "./dist/server.js",
|
|
6037
|
+
bin: {
|
|
6038
|
+
"stackwright-pro-mcp": "./dist/server.js"
|
|
6039
|
+
},
|
|
3450
6040
|
module: "./dist/server.mjs",
|
|
3451
6041
|
types: "./dist/server.d.ts",
|
|
3452
6042
|
exports: {
|
|
@@ -3459,6 +6049,11 @@ var package_default = {
|
|
|
3459
6049
|
types: "./dist/integrity.d.ts",
|
|
3460
6050
|
import: "./dist/integrity.mjs",
|
|
3461
6051
|
require: "./dist/integrity.js"
|
|
6052
|
+
},
|
|
6053
|
+
"./type-schemas": {
|
|
6054
|
+
types: "./dist/tools/type-schemas.d.ts",
|
|
6055
|
+
import: "./dist/tools/type-schemas.mjs",
|
|
6056
|
+
require: "./dist/tools/type-schemas.js"
|
|
3462
6057
|
}
|
|
3463
6058
|
},
|
|
3464
6059
|
files: [
|
|
@@ -3486,9 +6081,22 @@ registerPipelineTools(server);
|
|
|
3486
6081
|
registerSafeWriteTools(server);
|
|
3487
6082
|
registerAuthTools(server);
|
|
3488
6083
|
registerIntegrityTools(server);
|
|
6084
|
+
registerArtifactSigningTools(server);
|
|
3489
6085
|
registerDomainTools(server);
|
|
6086
|
+
registerTypeSchemasTool(server);
|
|
6087
|
+
registerScaffoldCleanupTools(server);
|
|
6088
|
+
registerContrastTools(server);
|
|
3490
6089
|
async function main() {
|
|
3491
6090
|
const transport = new StdioServerTransport();
|
|
6091
|
+
try {
|
|
6092
|
+
const servicesRegisterPkg = "@stackwright-services/mcp/register";
|
|
6093
|
+
const mod = await import(servicesRegisterPkg);
|
|
6094
|
+
if (typeof mod.registerServicesTools === "function") {
|
|
6095
|
+
mod.registerServicesTools(server);
|
|
6096
|
+
console.error("Stackwright Services tools registered on pro MCP");
|
|
6097
|
+
}
|
|
6098
|
+
} catch {
|
|
6099
|
+
}
|
|
3492
6100
|
await server.connect(transport);
|
|
3493
6101
|
console.error("Stackwright Pro MCP server running on stdio");
|
|
3494
6102
|
}
|