norn-cli 1.10.3 → 1.10.5
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/AGENTS.md +11 -2
- package/CHANGELOG.md +31 -2
- package/demos/tests-showcase/01-single-requests.norn +31 -0
- package/demos/tests-showcase/02-sequences.norn +54 -0
- package/demos/tests-showcase/03-sidecars.norn +42 -0
- package/demos/tests-showcase/04-api-plus-sql.norn +27 -0
- package/demos/tests-showcase/db/testDb.nornsql +12 -0
- package/demos/tests-showcase/demo-api.nornapi +17 -0
- package/demos/tests-showcase/norn.adapters.json +8 -0
- package/demos/tests-showcase/norn.sql.json +9 -0
- package/demos/tests-showcase/scripts/fake-sql-adapter.js +70 -0
- package/dist/cli.js +148 -31
- package/package.json +3 -2
- package/scripts/validate-skills.mjs +50 -0
package/AGENTS.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
These instructions apply to all conversations about the Norn VS Code extension.
|
|
4
4
|
|
|
5
|
+
## Repo Boundary
|
|
6
|
+
|
|
7
|
+
These instructions apply only inside `/Users/petercrest/Worktable/Projects/vsApi`.
|
|
8
|
+
|
|
9
|
+
- Do **not** apply them to standalone work in sibling repos such as `/Users/petercrest/Worktable/Projects/norn_website`.
|
|
10
|
+
- If a task is website-only, prefer the website repo's local instructions instead.
|
|
11
|
+
- Only use these rules for cross-repo work when the task explicitly includes `vsApi`.
|
|
12
|
+
|
|
5
13
|
## CLI Support is Mandatory
|
|
6
14
|
|
|
7
15
|
Every feature implementation must work in both:
|
|
@@ -17,10 +25,10 @@ Before considering any feature complete, verify it works in the CLI. The CLI sha
|
|
|
17
25
|
**ALWAYS use the local compiled CLI:**
|
|
18
26
|
```bash
|
|
19
27
|
# Correct - runs your local development version
|
|
20
|
-
node ./dist/cli.js
|
|
28
|
+
node ./dist/cli.js tests/file.norn --env prelive
|
|
21
29
|
|
|
22
30
|
# WRONG - runs the published npm package, ignores your changes
|
|
23
|
-
npx norn
|
|
31
|
+
npx norn tests/file.norn
|
|
24
32
|
```
|
|
25
33
|
|
|
26
34
|
Before testing CLI changes:
|
|
@@ -33,6 +41,7 @@ When implementing features, check the `.github/skills/` directory for relevant s
|
|
|
33
41
|
|
|
34
42
|
- **If a skill is incorrect or outdated:** Update it with the correct information
|
|
35
43
|
- **If a skill is missing:** Create a new one following the Agent Skills format
|
|
44
|
+
- After creating or editing skills, run `npm run validate:skills`
|
|
36
45
|
|
|
37
46
|
Skills should capture lessons learned and patterns discovered during implementation to help future development.
|
|
38
47
|
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,35 @@ All notable changes to the "Norn" extension will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.10.5] - 2026-03-28
|
|
8
|
+
|
|
9
|
+
### Improved
|
|
10
|
+
- **URL-Encoded Request Bodies (Extension + CLI)**:
|
|
11
|
+
- Added stricter shared validation for `application/x-www-form-urlencoded` request bodies so malformed lines now fail fast with clear preflight errors in both the editor and CLI.
|
|
12
|
+
- Added support for single-line ampersand-delimited form bodies such as `key1=value1&key2=value2`.
|
|
13
|
+
- Preserved raw-request header-group workflows by stripping standalone header-group lines from the request body before validation and send.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- **Imported Parameterized Sequences**:
|
|
17
|
+
- Fixed `.norn` import resolution so imported helper sequences keep their parameter lists and can be invoked correctly from other files.
|
|
18
|
+
|
|
19
|
+
- **Form Body Syntax Highlighting**:
|
|
20
|
+
- Fixed `.norn` syntax coloring so URL-encoded body lines are highlighted correctly after inline headers in raw request blocks.
|
|
21
|
+
|
|
22
|
+
## [1.10.4] - 2026-03-21
|
|
23
|
+
|
|
24
|
+
### Improved
|
|
25
|
+
- **Request Failure Response Panel**:
|
|
26
|
+
- Refreshed request-failure views with a clearer error summary, request context bar, structured issue cards, and common-cause guidance.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
- **Regression + Contributor Tooling**:
|
|
30
|
+
- Standardized regression test docs and scripts around `node ./dist/cli.js`, added `npm run validate:skills`, and refreshed bundled skill/demo assets.
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
- **SQL Server Live Regression Fixture**:
|
|
34
|
+
- Made the SQL Server Docker regression setup safe to rerun during repeated local release verification.
|
|
35
|
+
|
|
7
36
|
## [1.10.2] - 2026-03-15
|
|
8
37
|
|
|
9
38
|
### Fixed
|
|
@@ -677,8 +706,8 @@ All notable changes to the "Norn" extension will be documented in this file.
|
|
|
677
706
|
- **Tag Diagnostics**: Warning when tags are placed incorrectly (not before `sequence`)
|
|
678
707
|
|
|
679
708
|
### Changed
|
|
680
|
-
- **Regression Test
|
|
681
|
-
- Example:
|
|
709
|
+
- **Regression Test Filtering**: Regression CLI runs support `--tag` and `--tags` options
|
|
710
|
+
- Example: `node ./dist/cli.js tests/Regression/ --env prelive --tag smoke` runs only smoke-tagged sequences
|
|
682
711
|
|
|
683
712
|
## [1.0.25] - 2026-01-28
|
|
684
713
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
|
|
2
|
+
GET https://httpbin.org/uuid
|
|
3
|
+
|
|
4
|
+
GET https://httpbin.org/anything/catalog?region=uk&segment=enterprise
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
POST https://httpbin.org/post
|
|
11
|
+
Content-Type: application/json
|
|
12
|
+
Accept: application/json
|
|
13
|
+
X-Demo-Stage: exploratory-json
|
|
14
|
+
{
|
|
15
|
+
"customerId": "C-1001",
|
|
16
|
+
"workspace": "Northwind",
|
|
17
|
+
"plan": "Enterprise"
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
POST https://httpbin.org/post
|
|
22
|
+
Content-Type: application/json
|
|
23
|
+
Accept: application/json
|
|
24
|
+
X-Demo-Stage: exploratory-quote
|
|
25
|
+
X-Demo-Audience: exploratory-testers
|
|
26
|
+
{
|
|
27
|
+
"customerId": "C-1001",
|
|
28
|
+
"step": "quote-preview",
|
|
29
|
+
"seats": 250,
|
|
30
|
+
"currency": "GBP"
|
|
31
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
@demo
|
|
4
|
+
test sequence SimpleRequestChain
|
|
5
|
+
print "Simple requests" | "Bare URLs become a repeatable flow with assertions."
|
|
6
|
+
|
|
7
|
+
GET https://httpbin.org/uuid
|
|
8
|
+
assert $1.status == 200
|
|
9
|
+
var traceId = $1.body.uuid
|
|
10
|
+
|
|
11
|
+
GET https://httpbin.org/anything/catalog?region=uk&segment=enterprise&trace={{traceId}}
|
|
12
|
+
assert $2.status == 200
|
|
13
|
+
assert $2.body.args.region == "uk"
|
|
14
|
+
assert $2.body.args.segment == "enterprise"
|
|
15
|
+
assert $2.body.url contains "{{traceId}}"
|
|
16
|
+
|
|
17
|
+
print "Trace id" | "{{traceId}}"
|
|
18
|
+
end sequence
|
|
19
|
+
|
|
20
|
+
@demo
|
|
21
|
+
test sequence RichRequestChain
|
|
22
|
+
print "Headers and payloads" | "The same idea works when the requests carry real data."
|
|
23
|
+
|
|
24
|
+
var draft = POST https://httpbin.org/post
|
|
25
|
+
Content-Type: application/json
|
|
26
|
+
Accept: application/json
|
|
27
|
+
X-Demo-Stage: sequence-draft
|
|
28
|
+
{
|
|
29
|
+
"customerId": "C-1001",
|
|
30
|
+
"workspace": "Northwind",
|
|
31
|
+
"plan": "Enterprise"
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
assert draft.status == 200
|
|
35
|
+
assert draft.body.json.customerId == "C-1001"
|
|
36
|
+
var customerId = draft.body.json.customerId
|
|
37
|
+
|
|
38
|
+
var quote = POST https://httpbin.org/post
|
|
39
|
+
Content-Type: application/json
|
|
40
|
+
Accept: application/json
|
|
41
|
+
X-Demo-Stage: sequence-quote
|
|
42
|
+
{
|
|
43
|
+
"customerId": "{{customerId}}",
|
|
44
|
+
"step": "quote-preview",
|
|
45
|
+
"seats": 250,
|
|
46
|
+
"currency": "GBP"
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
assert quote.status == 200
|
|
50
|
+
assert quote.body.json.customerId == "C-1001"
|
|
51
|
+
assert quote.body.json.step == "quote-preview"
|
|
52
|
+
|
|
53
|
+
print "Chained value" | "customerId={{customerId}}"
|
|
54
|
+
end sequence
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import "./demo-api.nornapi"
|
|
2
|
+
|
|
3
|
+
@demo
|
|
4
|
+
test sequence CleanerExplorationFlow
|
|
5
|
+
print "Cleaner requests" | "Shared setup moved into .nornapi and .nornenv."
|
|
6
|
+
|
|
7
|
+
var trace = GET GetTraceId
|
|
8
|
+
assert trace.status == 200
|
|
9
|
+
|
|
10
|
+
var catalog = GET BrowseCustomer({{demoCustomerId}}) DemoHeaders
|
|
11
|
+
assert catalog.status == 200
|
|
12
|
+
assert catalog.body.args.region == "uk"
|
|
13
|
+
assert catalog.body.url contains "/customers/C-1001"
|
|
14
|
+
|
|
15
|
+
print "Why this helps" | "The request lines stay focused on intent, not plumbing."
|
|
16
|
+
end sequence
|
|
17
|
+
|
|
18
|
+
@demo
|
|
19
|
+
test sequence CleanerPayloadFlow
|
|
20
|
+
var draft = POST SaveDraft Json DemoHeaders
|
|
21
|
+
{
|
|
22
|
+
"customerId": "{{demoCustomerId}}",
|
|
23
|
+
"workspace": "{{workspaceName}}",
|
|
24
|
+
"owner": "{{ownerName}}"
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
assert draft.status == 200
|
|
28
|
+
assert draft.body.json.workspace == "Northwind-Workspace"
|
|
29
|
+
|
|
30
|
+
var quote = POST QuotePreview Json DemoHeaders
|
|
31
|
+
{
|
|
32
|
+
"customerId": "{{demoCustomerId}}",
|
|
33
|
+
"traceId": "{{tracePrefix}}-001",
|
|
34
|
+
"step": "quote-preview",
|
|
35
|
+
"seats": 250
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
assert quote.status == 200
|
|
39
|
+
assert quote.body.json.step == "quote-preview"
|
|
40
|
+
|
|
41
|
+
print "Cleaner file" | "Base URL, reusable headers, and shared names are all sidecars now."
|
|
42
|
+
end sequence
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import "./demo-api.nornapi"
|
|
2
|
+
import "./db//testDb.nornsql"
|
|
3
|
+
|
|
4
|
+
@demo
|
|
5
|
+
test sequence ApiPlusSqlDemo
|
|
6
|
+
print "API plus SQL" | "Show the service call and the data-side check in one workflow."
|
|
7
|
+
|
|
8
|
+
var quote = POST QuotePreview Json DemoHeaders
|
|
9
|
+
{
|
|
10
|
+
"customerId": "{{demoCustomerId}}",
|
|
11
|
+
"step": "quote-preview",
|
|
12
|
+
"seats": 250,
|
|
13
|
+
"currency": "GBP"
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
assert quote.status == 200
|
|
17
|
+
assert quote.body.json.customerId == "C-1001"
|
|
18
|
+
|
|
19
|
+
var buyers = run sql ListBuyerAccounts("Enterprise")
|
|
20
|
+
assert buyers[0].Segment == "Enterprise"
|
|
21
|
+
assert buyers[0].Company == "Northwind Retail"
|
|
22
|
+
|
|
23
|
+
var audit = run sql RecordDemoRun("northwind@example.com", "buyer-showcase")
|
|
24
|
+
assert audit.affectedRows == 1
|
|
25
|
+
|
|
26
|
+
print "SQL sidecar" | "HTTP stays in .norn, SQL stays in .nornsql, config stays in sidecars."
|
|
27
|
+
end sequence
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
connection buyerDemoDb
|
|
2
|
+
|
|
3
|
+
query ListBuyerAccounts(segment)
|
|
4
|
+
select Id, Company, Email, Segment
|
|
5
|
+
from Buyers
|
|
6
|
+
where Segment = :segment
|
|
7
|
+
end query
|
|
8
|
+
|
|
9
|
+
command RecordDemoRun(email, scenario)
|
|
10
|
+
insert into DemoAudit (Email, Scenario)
|
|
11
|
+
values (:email, :scenario)
|
|
12
|
+
end command
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
headers Json
|
|
2
|
+
Content-Type: application/json
|
|
3
|
+
Accept: application/json
|
|
4
|
+
end headers
|
|
5
|
+
|
|
6
|
+
headers DemoHeaders
|
|
7
|
+
X-Demo-Stage: {{demoStage}}
|
|
8
|
+
X-Demo-Audience: {{audience}}
|
|
9
|
+
X-Demo-Owner: {{ownerName}}
|
|
10
|
+
end headers
|
|
11
|
+
|
|
12
|
+
endpoints
|
|
13
|
+
GetTraceId: GET {{baseUrl}}/uuid
|
|
14
|
+
BrowseCustomer: GET {{baseUrl}}/anything/customers/{customerId}?region={{region}}
|
|
15
|
+
SaveDraft: POST {{baseUrl}}/post
|
|
16
|
+
QuotePreview: POST {{baseUrl}}/post
|
|
17
|
+
end endpoints
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const chunks = [];
|
|
2
|
+
|
|
3
|
+
process.stdin.on('data', chunk => {
|
|
4
|
+
chunks.push(chunk);
|
|
5
|
+
});
|
|
6
|
+
|
|
7
|
+
process.stdin.on('end', () => {
|
|
8
|
+
try {
|
|
9
|
+
const request = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
10
|
+
const values = request.connection && request.connection.values ? request.connection.values : {};
|
|
11
|
+
|
|
12
|
+
if (!values.server || !values.database || !values.user || !values.password) {
|
|
13
|
+
process.stdout.write(JSON.stringify({
|
|
14
|
+
success: false,
|
|
15
|
+
error: 'Missing expected buyerDemoDb connection values'
|
|
16
|
+
}));
|
|
17
|
+
process.exit(0);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
switch (request.operation.name) {
|
|
22
|
+
case 'ListBuyerAccounts': {
|
|
23
|
+
const segment = request.params.segment;
|
|
24
|
+
process.stdout.write(JSON.stringify({
|
|
25
|
+
success: true,
|
|
26
|
+
result: {
|
|
27
|
+
kind: 'rows',
|
|
28
|
+
rowCount: 2,
|
|
29
|
+
rows: [
|
|
30
|
+
{
|
|
31
|
+
Id: 1,
|
|
32
|
+
Company: 'Northwind Retail',
|
|
33
|
+
Email: 'northwind@example.com',
|
|
34
|
+
Segment: segment
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
Id: 2,
|
|
38
|
+
Company: 'Contoso Services',
|
|
39
|
+
Email: 'contoso@example.com',
|
|
40
|
+
Segment: segment
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
}));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
case 'RecordDemoRun':
|
|
49
|
+
process.stdout.write(JSON.stringify({
|
|
50
|
+
success: true,
|
|
51
|
+
result: {
|
|
52
|
+
kind: 'exec',
|
|
53
|
+
affectedRows: 1
|
|
54
|
+
}
|
|
55
|
+
}));
|
|
56
|
+
return;
|
|
57
|
+
|
|
58
|
+
default:
|
|
59
|
+
process.stdout.write(JSON.stringify({
|
|
60
|
+
success: false,
|
|
61
|
+
error: `Unknown buyer showcase SQL operation: ${request.operation.name}`
|
|
62
|
+
}));
|
|
63
|
+
}
|
|
64
|
+
} catch (error) {
|
|
65
|
+
process.stdout.write(JSON.stringify({
|
|
66
|
+
success: false,
|
|
67
|
+
error: error instanceof Error ? error.message : String(error)
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
});
|
package/dist/cli.js
CHANGED
|
@@ -102749,8 +102749,9 @@ ${resolvedContent}`);
|
|
|
102749
102749
|
});
|
|
102750
102750
|
} else {
|
|
102751
102751
|
sequenceSources.set(lowerName, absolutePath);
|
|
102752
|
+
const resolvedDeclaration = substituteVariables(seq.declaration, importedVariables);
|
|
102752
102753
|
const resolvedContent = substituteVariables(seq.content, importedVariables);
|
|
102753
|
-
importedContents.push(
|
|
102754
|
+
importedContents.push(`${resolvedDeclaration}
|
|
102754
102755
|
${resolvedContent}
|
|
102755
102756
|
end sequence`);
|
|
102756
102757
|
}
|
|
@@ -102781,17 +102782,20 @@ function extractSequencesFromText(text) {
|
|
|
102781
102782
|
let currentSequence = null;
|
|
102782
102783
|
for (let i = 0; i < lines.length; i++) {
|
|
102783
102784
|
const line2 = lines[i].trim();
|
|
102784
|
-
const
|
|
102785
|
+
const lineWithoutComment = stripInlineComment(line2);
|
|
102786
|
+
const sequenceMatch = lineWithoutComment.match(/^sequence\s+([a-zA-Z_][a-zA-Z0-9_-]*)(\s*\([^)]*\))?\s*$/);
|
|
102785
102787
|
if (sequenceMatch) {
|
|
102786
102788
|
currentSequence = {
|
|
102787
102789
|
name: sequenceMatch[1],
|
|
102790
|
+
declaration: `sequence ${sequenceMatch[1]}${sequenceMatch[2] || ""}`,
|
|
102788
102791
|
lines: []
|
|
102789
102792
|
};
|
|
102790
102793
|
continue;
|
|
102791
102794
|
}
|
|
102792
|
-
if (
|
|
102795
|
+
if (lineWithoutComment === "end sequence" && currentSequence) {
|
|
102793
102796
|
sequences.push({
|
|
102794
102797
|
name: currentSequence.name,
|
|
102798
|
+
declaration: currentSequence.declaration,
|
|
102795
102799
|
content: currentSequence.lines.join("\n")
|
|
102796
102800
|
});
|
|
102797
102801
|
currentSequence = null;
|
|
@@ -108597,6 +108601,88 @@ function getVerifyTlsCertificates() {
|
|
|
108597
108601
|
return verifyTlsCertificates;
|
|
108598
108602
|
}
|
|
108599
108603
|
|
|
108604
|
+
// src/formUrlEncoded.ts
|
|
108605
|
+
function parseEqualsField(segment) {
|
|
108606
|
+
const eqIndex = segment.indexOf("=");
|
|
108607
|
+
if (eqIndex <= 0) {
|
|
108608
|
+
return null;
|
|
108609
|
+
}
|
|
108610
|
+
return {
|
|
108611
|
+
key: segment.substring(0, eqIndex).trim(),
|
|
108612
|
+
value: segment.substring(eqIndex + 1).trim()
|
|
108613
|
+
};
|
|
108614
|
+
}
|
|
108615
|
+
function isFormUrlEncodedContentType(contentType) {
|
|
108616
|
+
return Boolean(contentType && contentType.toLowerCase().includes("application/x-www-form-urlencoded"));
|
|
108617
|
+
}
|
|
108618
|
+
function parseFormUrlEncodedLines(lines) {
|
|
108619
|
+
const fields = [];
|
|
108620
|
+
const errors = [];
|
|
108621
|
+
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
|
|
108622
|
+
const line2 = lines[lineIndex].trim();
|
|
108623
|
+
if (!line2) {
|
|
108624
|
+
continue;
|
|
108625
|
+
}
|
|
108626
|
+
const firstEqIndex = line2.indexOf("=");
|
|
108627
|
+
const firstColonIndex = line2.indexOf(":");
|
|
108628
|
+
if (firstEqIndex > 0 && (firstColonIndex === -1 || firstEqIndex < firstColonIndex)) {
|
|
108629
|
+
if (line2.includes("&")) {
|
|
108630
|
+
const segments = line2.split("&").map((segment) => segment.trim());
|
|
108631
|
+
const hasInvalidSegment = segments.some((segment) => !parseEqualsField(segment));
|
|
108632
|
+
if (hasInvalidSegment) {
|
|
108633
|
+
errors.push({
|
|
108634
|
+
lineIndex,
|
|
108635
|
+
line: line2,
|
|
108636
|
+
message: "Ampersand-delimited form body lines must use key=value for every segment."
|
|
108637
|
+
});
|
|
108638
|
+
continue;
|
|
108639
|
+
}
|
|
108640
|
+
for (const segment of segments) {
|
|
108641
|
+
const field2 = parseEqualsField(segment);
|
|
108642
|
+
if (field2) {
|
|
108643
|
+
fields.push(field2);
|
|
108644
|
+
}
|
|
108645
|
+
}
|
|
108646
|
+
continue;
|
|
108647
|
+
}
|
|
108648
|
+
const field = parseEqualsField(line2);
|
|
108649
|
+
if (field) {
|
|
108650
|
+
fields.push(field);
|
|
108651
|
+
continue;
|
|
108652
|
+
}
|
|
108653
|
+
}
|
|
108654
|
+
if (firstColonIndex > 0) {
|
|
108655
|
+
fields.push({
|
|
108656
|
+
key: line2.substring(0, firstColonIndex).trim(),
|
|
108657
|
+
value: line2.substring(firstColonIndex + 1).trim()
|
|
108658
|
+
});
|
|
108659
|
+
continue;
|
|
108660
|
+
}
|
|
108661
|
+
errors.push({
|
|
108662
|
+
lineIndex,
|
|
108663
|
+
line: line2,
|
|
108664
|
+
message: "Expected key=value, key: value, or ampersand-delimited key=value pairs."
|
|
108665
|
+
});
|
|
108666
|
+
}
|
|
108667
|
+
return { fields, errors };
|
|
108668
|
+
}
|
|
108669
|
+
function parseFormUrlEncodedBody(body) {
|
|
108670
|
+
if (!body) {
|
|
108671
|
+
return { fields: [], errors: [] };
|
|
108672
|
+
}
|
|
108673
|
+
return parseFormUrlEncodedLines(body.split("\n"));
|
|
108674
|
+
}
|
|
108675
|
+
function encodeFormUrlEncodedBody(body) {
|
|
108676
|
+
const { fields, errors } = parseFormUrlEncodedBody(body);
|
|
108677
|
+
if (errors.length > 0) {
|
|
108678
|
+
return { encodedBody: "", errors };
|
|
108679
|
+
}
|
|
108680
|
+
return {
|
|
108681
|
+
encodedBody: fields.map((field) => `${encodeURIComponent(field.key)}=${encodeURIComponent(field.value)}`).join("&"),
|
|
108682
|
+
errors: []
|
|
108683
|
+
};
|
|
108684
|
+
}
|
|
108685
|
+
|
|
108600
108686
|
// src/httpClient.ts
|
|
108601
108687
|
var sharedCookieJar = new CookieJar();
|
|
108602
108688
|
var insecureHttpsAgent = new https2.Agent({ rejectUnauthorized: false });
|
|
@@ -108661,24 +108747,23 @@ async function sendRequestWithJar(request, jar, retryOptions) {
|
|
|
108661
108747
|
(key) => key.toLowerCase() === "content-type"
|
|
108662
108748
|
);
|
|
108663
108749
|
const contentTypeValue = contentType ? headers[contentType] : "";
|
|
108664
|
-
if (contentTypeValue
|
|
108665
|
-
const
|
|
108666
|
-
|
|
108667
|
-
|
|
108668
|
-
|
|
108669
|
-
|
|
108670
|
-
|
|
108671
|
-
|
|
108672
|
-
|
|
108673
|
-
|
|
108674
|
-
|
|
108675
|
-
|
|
108676
|
-
|
|
108677
|
-
|
|
108678
|
-
}
|
|
108679
|
-
|
|
108680
|
-
|
|
108681
|
-
data = params.join("&");
|
|
108750
|
+
if (isFormUrlEncodedContentType(contentTypeValue)) {
|
|
108751
|
+
const encoded = encodeFormUrlEncodedBody(currentBody);
|
|
108752
|
+
if (encoded.errors.length > 0) {
|
|
108753
|
+
throw new NornError({
|
|
108754
|
+
category: "syntax",
|
|
108755
|
+
code: "invalid-form-urlencoded-body",
|
|
108756
|
+
message: "Request body is not valid application/x-www-form-urlencoded syntax.",
|
|
108757
|
+
details: encoded.errors.map((error) => `Body line ${error.lineIndex + 1}: ${error.message} (${error.line})`),
|
|
108758
|
+
hint: "Use key=value, key: value, or a single line like key1=value1&key2=value2.",
|
|
108759
|
+
context: {
|
|
108760
|
+
source: "httpClient",
|
|
108761
|
+
method: currentMethod,
|
|
108762
|
+
url: currentUrl
|
|
108763
|
+
}
|
|
108764
|
+
});
|
|
108765
|
+
}
|
|
108766
|
+
data = encoded.encodedBody;
|
|
108682
108767
|
} else if (contentTypeValue.includes("application/json")) {
|
|
108683
108768
|
try {
|
|
108684
108769
|
data = JSON.parse(currentBody);
|
|
@@ -109333,6 +109418,15 @@ function buildRequestContext(parsed, context) {
|
|
|
109333
109418
|
environment: context?.environment
|
|
109334
109419
|
};
|
|
109335
109420
|
}
|
|
109421
|
+
function getHeaderValueCaseInsensitive(headers, headerName) {
|
|
109422
|
+
const targetName = headerName.toLowerCase();
|
|
109423
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
109424
|
+
if (name.toLowerCase() === targetName) {
|
|
109425
|
+
return value;
|
|
109426
|
+
}
|
|
109427
|
+
}
|
|
109428
|
+
return void 0;
|
|
109429
|
+
}
|
|
109336
109430
|
function describeUnresolvedLocation(label, vars) {
|
|
109337
109431
|
if (vars.length === 0) {
|
|
109338
109432
|
return void 0;
|
|
@@ -109388,6 +109482,20 @@ function validatePreparedRequest(parsed, context) {
|
|
|
109388
109482
|
cause: error
|
|
109389
109483
|
});
|
|
109390
109484
|
}
|
|
109485
|
+
const contentType = getHeaderValueCaseInsensitive(parsed.headers, "Content-Type");
|
|
109486
|
+
if (isFormUrlEncodedContentType(contentType)) {
|
|
109487
|
+
const { errors } = parseFormUrlEncodedBody(parsed.body);
|
|
109488
|
+
if (errors.length > 0) {
|
|
109489
|
+
throw new NornError({
|
|
109490
|
+
category: "syntax",
|
|
109491
|
+
code: "invalid-form-urlencoded-body",
|
|
109492
|
+
message: "Request could not be prepared: invalid application/x-www-form-urlencoded body syntax.",
|
|
109493
|
+
details: errors.map((error) => `Body line ${error.lineIndex + 1}: ${error.message} (${error.line})`),
|
|
109494
|
+
hint: "Use key=value, key: value, or a single line like key1=value1&key2=value2.",
|
|
109495
|
+
context: buildRequestContext(parsed, context)
|
|
109496
|
+
});
|
|
109497
|
+
}
|
|
109498
|
+
}
|
|
109391
109499
|
}
|
|
109392
109500
|
|
|
109393
109501
|
// src/sequenceRunner.ts
|
|
@@ -110257,13 +110365,17 @@ function applyHeaderGroupsToRequest(parsed, requestText, headerGroups, variables
|
|
|
110257
110365
|
const headerGroupNames = headerGroups.map((hg) => hg.name);
|
|
110258
110366
|
const headerGroupHeaders = {};
|
|
110259
110367
|
const foundGroups = [];
|
|
110368
|
+
const cleanedBodyLines = [];
|
|
110369
|
+
let sawRequestLine = false;
|
|
110370
|
+
let inBodySection = false;
|
|
110260
110371
|
for (const line2 of lines) {
|
|
110261
110372
|
const trimmed = line2.trim();
|
|
110262
110373
|
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("var ")) {
|
|
110263
110374
|
continue;
|
|
110264
110375
|
}
|
|
110265
110376
|
const methodMatch = trimmed.match(/^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+(.+)$/i);
|
|
110266
|
-
if (methodMatch) {
|
|
110377
|
+
if (!sawRequestLine && methodMatch) {
|
|
110378
|
+
sawRequestLine = true;
|
|
110267
110379
|
const afterMethod = methodMatch[2];
|
|
110268
110380
|
const tokens = afterMethod.split(/\s+/);
|
|
110269
110381
|
for (let i = tokens.length - 1; i >= 0; i--) {
|
|
@@ -110275,18 +110387,22 @@ function applyHeaderGroupsToRequest(parsed, requestText, headerGroups, variables
|
|
|
110275
110387
|
}
|
|
110276
110388
|
continue;
|
|
110277
110389
|
}
|
|
110278
|
-
if (
|
|
110279
|
-
continue;
|
|
110280
|
-
}
|
|
110281
|
-
if (trimmed.startsWith("{") || trimmed.startsWith("[") || trimmed.startsWith('"')) {
|
|
110390
|
+
if (!sawRequestLine) {
|
|
110282
110391
|
continue;
|
|
110283
110392
|
}
|
|
110284
|
-
|
|
110285
|
-
|
|
110286
|
-
|
|
110287
|
-
|
|
110393
|
+
if (!inBodySection) {
|
|
110394
|
+
if (/^[A-Za-z0-9\-_]+\s*:\s*.+$/.test(trimmed)) {
|
|
110395
|
+
continue;
|
|
110396
|
+
}
|
|
110397
|
+
const potentialGroups = trimmed.split(/\s+/).filter(Boolean);
|
|
110398
|
+
const matchingGroups = potentialGroups.filter((groupName) => headerGroupNames.includes(groupName));
|
|
110399
|
+
if (matchingGroups.length > 0 && matchingGroups.length === potentialGroups.length) {
|
|
110400
|
+
foundGroups.push(...matchingGroups);
|
|
110401
|
+
continue;
|
|
110288
110402
|
}
|
|
110403
|
+
inBodySection = true;
|
|
110289
110404
|
}
|
|
110405
|
+
cleanedBodyLines.push(trimmed);
|
|
110290
110406
|
}
|
|
110291
110407
|
for (const groupName of foundGroups) {
|
|
110292
110408
|
const group = headerGroups.find((hg) => hg.name === groupName);
|
|
@@ -110303,7 +110419,8 @@ function applyHeaderGroupsToRequest(parsed, requestText, headerGroups, variables
|
|
|
110303
110419
|
modifiedUrl = modifiedUrl.trim();
|
|
110304
110420
|
const result = {
|
|
110305
110421
|
...parsed,
|
|
110306
|
-
url: modifiedUrl
|
|
110422
|
+
url: modifiedUrl,
|
|
110423
|
+
body: cleanedBodyLines.length > 0 ? substituteVariables(cleanedBodyLines.join("\n"), variables) : void 0
|
|
110307
110424
|
};
|
|
110308
110425
|
if (Object.keys(headerGroupHeaders).length > 0) {
|
|
110309
110426
|
result.headers = { ...headerGroupHeaders, ...parsed.headers };
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "norn-cli",
|
|
3
3
|
"displayName": "Norn - REST Client",
|
|
4
4
|
"description": "A powerful REST client for making HTTP requests with sequences, variables, scripts, and cookie support",
|
|
5
|
-
"version": "1.10.
|
|
5
|
+
"version": "1.10.5",
|
|
6
6
|
"publisher": "Norn-PeterKrustanov",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "Peter Krastanov"
|
|
@@ -386,8 +386,9 @@
|
|
|
386
386
|
"pretest": "npm run compile-tests && npm run compile && npm run lint",
|
|
387
387
|
"check-types": "tsc --noEmit",
|
|
388
388
|
"lint": "eslint src",
|
|
389
|
+
"validate:skills": "node ./scripts/validate-skills.mjs",
|
|
389
390
|
"test": "vscode-test",
|
|
390
|
-
"test:regression": "./tests/Regression/
|
|
391
|
+
"test:regression": "node ./dist/cli.js ./tests/Regression/ -e prelive",
|
|
391
392
|
"publish:npm": "node -e \"const p=require('./package.json');p.name='norn-cli';require('fs').writeFileSync('package.json',JSON.stringify(p,null,2))\" && npm publish && node -e \"const p=require('./package.json');p.name='norn';require('fs').writeFileSync('package.json',JSON.stringify(p,null,2))\"",
|
|
392
393
|
"publish:vsce": "node -e \"const p=require('./package.json');p.name='norn';require('fs').writeFileSync('package.json',JSON.stringify(p,null,2))\" && npx vsce publish",
|
|
393
394
|
"publish:all": "npm run publish:npm && npm run publish:vsce"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
const repoRoot = process.cwd();
|
|
9
|
+
const skillsRoot = path.join(repoRoot, '.github', 'skills');
|
|
10
|
+
const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
11
|
+
const validatorPath = path.join(
|
|
12
|
+
codexHome,
|
|
13
|
+
'skills',
|
|
14
|
+
'.system',
|
|
15
|
+
'skill-creator',
|
|
16
|
+
'scripts',
|
|
17
|
+
'quick_validate.py'
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
if (!existsSync(skillsRoot)) {
|
|
21
|
+
console.error(`Skills directory not found: ${skillsRoot}`);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!existsSync(validatorPath)) {
|
|
26
|
+
console.error(`Skill validator not found: ${validatorPath}`);
|
|
27
|
+
console.error('Install the Codex skill-creator system skill or set CODEX_HOME correctly.');
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const skillDirs = readdirSync(skillsRoot)
|
|
32
|
+
.map((name) => path.join(skillsRoot, name))
|
|
33
|
+
.filter((dir) => statSync(dir).isDirectory() && existsSync(path.join(dir, 'SKILL.md')))
|
|
34
|
+
.sort();
|
|
35
|
+
|
|
36
|
+
if (skillDirs.length === 0) {
|
|
37
|
+
console.log('No skills found to validate.');
|
|
38
|
+
process.exit(0);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
for (const skillDir of skillDirs) {
|
|
42
|
+
const label = path.relative(repoRoot, skillDir);
|
|
43
|
+
console.log(`Validating ${label}`);
|
|
44
|
+
const result = spawnSync('python3', [validatorPath, skillDir], { stdio: 'inherit' });
|
|
45
|
+
if (result.status !== 0) {
|
|
46
|
+
process.exit(result.status ?? 1);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
console.log(`Validated ${skillDirs.length} skills.`);
|