spiderly 19.8.4 → 19.8.6
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/agent/docs/angular-customization/SKILL.md +389 -0
- package/agent/docs/angular-customization/references/controls.generated.md +23 -0
- package/agent/docs/angular-customization/references/helper-functions.generated.md +39 -0
- package/agent/docs/angular-customization/references/ui-control-types.generated.md +24 -0
- package/agent/docs/angular-customization/references/validators.generated.md +13 -0
- package/agent/docs/authorization/SKILL.md +385 -0
- package/agent/docs/authorization/references/api-error-codes.generated.md +17 -0
- package/agent/docs/authorization/references/security-endpoints.generated.md +24 -0
- package/agent/docs/backend-hooks/SKILL.md +231 -0
- package/agent/docs/backend-localization/SKILL.md +170 -0
- package/agent/docs/backend-testing/SKILL.md +65 -0
- package/agent/docs/custom-endpoints/SKILL.md +409 -0
- package/agent/docs/e2e-testing/SKILL.md +139 -0
- package/agent/docs/entity-design/SKILL.md +346 -0
- package/agent/docs/entity-design/references/attributes.generated.md +53 -0
- package/agent/docs/file-storage/SKILL.md +262 -0
- package/agent/docs/filtering-patterns/SKILL.md +127 -0
- package/agent/docs/filtering-patterns/references/match-mode-codes.generated.md +15 -0
- package/agent/docs/frontend-localization/SKILL.md +120 -0
- package/agent/docs/mapper-customization/SKILL.md +105 -0
- package/agent/manifest.json +34 -0
- package/agent/skills/add-entity/SKILL.md +158 -0
- package/agent/skills/deployment/SKILL.md +551 -0
- package/agent/skills/ef-migrations/SKILL.md +49 -0
- package/agent/skills/report-gap/SKILL.md +110 -0
- package/agent/skills/report-gap/scripts/build-issue-url.mjs +82 -0
- package/agent/skills/spiderly-upgrade/SKILL.md +166 -0
- package/agent/skills/verify-ui/SKILL.md +148 -0
- package/agent/skills/verify-ui/scripts/get-admin-token.mjs +134 -0
- package/fesm2022/spiderly.mjs +11 -6
- package/fesm2022/spiderly.mjs.map +1 -1
- package/lib/components/spiderly-data-table/spiderly-data-table.component.d.ts +29 -3
- package/package.json +1 -1
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: report-gap
|
|
3
|
+
description: Use the moment you're forced into a workaround because the clean Spiderly-native path didn't exist — you looked for a lifecycle hook, an override, an entity attribute, or a generator option and it was structurally missing, so you had to bypass or copy generated code, or reach into framework internals. Also use when the gap is in Spiderly's own skills, plugins, or docs — a skill that should have fired but didn't, or skill/doc content that was missing, stale, or wrong for the case you hit. Turns that gap into a pre-filled GitHub issue URL against filiptrivan/spiderly that the user copies and submits. Also invoke manually to report a Spiderly limitation. NOT for hacks in the consumer's own business logic, NOT for ordinary bugs in your own code.
|
|
4
|
+
allowed-tools: Bash(node:*)
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Report a Spiderly gap
|
|
8
|
+
|
|
9
|
+
When Spiderly forces you into a workaround — or its own guidance lets you down — that's signal the maintainer needs. This skill captures the gap as a **pre-filled GitHub issue URL** against `filiptrivan/spiderly`. It does **not** file anything — it prints a URL the user opens, reviews, and submits themselves.
|
|
10
|
+
|
|
11
|
+
## When this fires (and when it doesn't)
|
|
12
|
+
|
|
13
|
+
The trigger is sharp on purpose. Two shapes of gap qualify:
|
|
14
|
+
|
|
15
|
+
**A framework-code gap** — *all* of these are true:
|
|
16
|
+
|
|
17
|
+
- You wanted to do something the Spiderly way (a hook, an override, an entity attribute, a generator/CLI option).
|
|
18
|
+
- You looked for that clean path and it was **structurally missing** — not just unfamiliar.
|
|
19
|
+
- So you fell back to a hack: bypassing or **copying generated code to edit it**, reaching into framework internals, or duplicating logic Spiderly should own.
|
|
20
|
+
|
|
21
|
+
**A gap in Spiderly's own skills, plugins, or docs** — the guidance layer itself failed you:
|
|
22
|
+
|
|
23
|
+
- A Spiderly skill should have fired for your task but its description didn't match, so you worked blind until someone pointed you at it.
|
|
24
|
+
- Skill or doc content you followed was **missing the case you hit, stale** against current Spiderly behavior, **or outright wrong** — and following it cost you a wrong turn.
|
|
25
|
+
|
|
26
|
+
For these, use `--labels "agent-reported,documentation"` and name the skill/doc file in the body. The same sharpness applies: "the docs could say more about X" is not a gap; "the docs told me the wrong thing / the skill never fired" is.
|
|
27
|
+
|
|
28
|
+
Do **NOT** use it for:
|
|
29
|
+
|
|
30
|
+
- Workarounds in the **consumer's own business logic** — that's the consumer's problem, not a Spiderly gap.
|
|
31
|
+
- Ordinary bugs in code you wrote, or a clean path you simply hadn't found yet (look harder first).
|
|
32
|
+
- **Non-interactive sessions** (a subagent, CI, a scheduled run): there's no one to copy the URL. Just note the gap in your final output and move on — never block.
|
|
33
|
+
|
|
34
|
+
## Flow
|
|
35
|
+
|
|
36
|
+
1. Confirm it's a real Spiderly gap against the bar above.
|
|
37
|
+
2. Fill the six-section body template (below), **sanitized**.
|
|
38
|
+
3. Build the URL with the helper script.
|
|
39
|
+
4. Print a short summary **and the URL** to the user. They copy it, open it, review the pre-filled form, and click Submit. Nothing is filed until they do.
|
|
40
|
+
5. If there's a workaround code snippet, **show it in chat** and tell the user to paste it as the first comment after the issue opens — keep it **out of the URL** (see the length gotcha).
|
|
41
|
+
|
|
42
|
+
## Be ruthlessly concise
|
|
43
|
+
|
|
44
|
+
Brevity is the whole game here — it keeps the URL well under the 414 ceiling **and** means a single short comment instead of several. Concretely:
|
|
45
|
+
|
|
46
|
+
- **One or two sentences per section.** No filler, no restating the title, no hedging. The maintainer reads fast.
|
|
47
|
+
- **Trim any snippet to the 1–3 lines that actually show the gap** — not the whole class. A trimmed snippet usually fits in **one** comment; never spread it across multiple.
|
|
48
|
+
- If everything is tight enough, you won't need a comment at all. Prefer that.
|
|
49
|
+
|
|
50
|
+
## Body template
|
|
51
|
+
|
|
52
|
+
Fill every section, one or two sentences each — prose only, no code snippet (that goes in a single comment if needed).
|
|
53
|
+
|
|
54
|
+
```markdown
|
|
55
|
+
### What I was trying to do
|
|
56
|
+
<the clean goal, one or two sentences>
|
|
57
|
+
|
|
58
|
+
### The clean Spiderly approach I expected
|
|
59
|
+
<the hook / override / attribute / option you looked for>
|
|
60
|
+
|
|
61
|
+
### Why it didn't work
|
|
62
|
+
<what was structurally missing>
|
|
63
|
+
|
|
64
|
+
### The workaround I used instead
|
|
65
|
+
<the hack, described in prose — code snippet posted as a comment below>
|
|
66
|
+
|
|
67
|
+
### Suggested fix
|
|
68
|
+
<what Spiderly could add: a new hook, attribute, generator option, ...>
|
|
69
|
+
|
|
70
|
+
### Context
|
|
71
|
+
Spiderly version: <from the consumer's package.json / .csproj>. Project type: <e.g. .NET 9 + Angular 19>. (No proprietary code included.)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Sanitize — this is a public repo
|
|
75
|
+
|
|
76
|
+
The issue lands in a **public** repository, and you're running inside someone's (possibly private) codebase. Before building the URL, strip: secrets/keys, real entity/table/field names that reveal the business, customer data, internal URLs. Describe the *shape* of the problem, not the proprietary specifics. When in doubt, generalize.
|
|
77
|
+
|
|
78
|
+
## Build the URL
|
|
79
|
+
|
|
80
|
+
Title is a **plain summary with no `[agent-reported]` prefix** — the label carries provenance. Pipe the body on stdin:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
node scripts/build-issue-url.mjs \
|
|
84
|
+
--title "No hook to override generated validator message" \
|
|
85
|
+
--labels "agent-reported,enhancement" <<'EOF'
|
|
86
|
+
### What I was trying to do
|
|
87
|
+
...
|
|
88
|
+
### Context
|
|
89
|
+
Spiderly version: 19.8.2. Project type: .NET 9 + Angular 19.
|
|
90
|
+
EOF
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
It prints the encoded `https://github.com/filiptrivan/spiderly/issues/new?...` URL. Defaults: `--labels` is `agent-reported,enhancement`; the repo is hardcoded. The script does **not** open a browser — print the URL for the user to copy.
|
|
94
|
+
|
|
95
|
+
> **Why a script instead of building the URL by hand:** encoding a multi-line body into a query string by hand (`%0A` for every newline, escaping `#`, `&`, spaces) is error-prone, and one wrong escape gives the user a broken form. `URLSearchParams` gets it right every time. If Node is somehow unavailable, encode by hand as a last resort — but Node ships with every Spiderly app.
|
|
96
|
+
|
|
97
|
+
## Gotcha — URL length (414)
|
|
98
|
+
|
|
99
|
+
GitHub returns **414 URI Too Long** around **8 KB**, and URL-encoding inflates the body ~3×. A code snippet in the body blows past this fast. So the snippet **never goes in the URL** — show it in chat and have the user paste it as a single comment. The script warns on stderr if the URL crosses ~7500 chars; if you see that warning, you wrote too much — **cut the prose down**, don't move it to comments.
|
|
100
|
+
|
|
101
|
+
## The `agent-reported` label
|
|
102
|
+
|
|
103
|
+
The pre-filled `&labels=agent-reported` only sticks if that label **exists** in the repo — GitHub silently drops unknown labels (you'd still get `enhancement`). **In `filiptrivan/spiderly` the label already exists — there is no setup left to do, so never prompt the maintainer to create it.** Only when targeting a fork that lacks it, create it once (authenticated `gh`):
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
gh label create agent-reported \
|
|
107
|
+
--repo <owner>/<fork> \
|
|
108
|
+
--color BFD4F2 \
|
|
109
|
+
--description "Gap surfaced by a coding agent forced into a Spiderly workaround"
|
|
110
|
+
```
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Build a pre-filled GitHub "new issue" URL for the Spiderly repo.
|
|
2
|
+
//
|
|
3
|
+
// Why a script: hand-encoding a multi-line issue body into a query string is
|
|
4
|
+
// error-prone (every newline, space, #, & must be escaped). URLSearchParams
|
|
5
|
+
// does it correctly every time. This script ONLY builds and prints the URL —
|
|
6
|
+
// it never opens a browser and never files anything. The user copies the
|
|
7
|
+
// printed URL, opens it, reviews the pre-filled form, and clicks Submit.
|
|
8
|
+
//
|
|
9
|
+
// Usage:
|
|
10
|
+
// echo "<markdown body>" | node build-issue-url.mjs --title "..." [--labels "a,b"]
|
|
11
|
+
// node build-issue-url.mjs --title "..." --body-file ./body.md
|
|
12
|
+
//
|
|
13
|
+
// Flags:
|
|
14
|
+
// --title (required) plain issue title, NO "[agent-reported]" prefix
|
|
15
|
+
// (the label carries provenance)
|
|
16
|
+
// --labels (optional) comma-separated; default "agent-reported,enhancement"
|
|
17
|
+
// --body-file (optional) read body from a file instead of stdin
|
|
18
|
+
//
|
|
19
|
+
// Fails loudly (non-zero exit) on a missing title or empty body.
|
|
20
|
+
|
|
21
|
+
import { readFileSync } from "node:fs";
|
|
22
|
+
|
|
23
|
+
const REPO = "filiptrivan/spiderly"; // hardcoded: this skill reports Spiderly gaps only
|
|
24
|
+
const URL_SOFT_LIMIT = 7500; // GitHub returns 414 around ~8 KB; warn before we get there
|
|
25
|
+
|
|
26
|
+
function arg(name) {
|
|
27
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
28
|
+
return i !== -1 ? process.argv[i + 1] : undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function fail(message) {
|
|
32
|
+
console.error(`build-issue-url: ${message}`);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function readStdin() {
|
|
37
|
+
if (process.stdin.isTTY) return ""; // nothing piped in
|
|
38
|
+
process.stdin.setEncoding("utf8");
|
|
39
|
+
let data = "";
|
|
40
|
+
for await (const chunk of process.stdin) data += chunk;
|
|
41
|
+
return data;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const title = arg("title");
|
|
45
|
+
if (!title || !title.trim()) {
|
|
46
|
+
fail('missing --title. Pass a plain title, e.g. --title "No hook to override generated validator message"');
|
|
47
|
+
}
|
|
48
|
+
if (/^\s*\[agent-reported\]/i.test(title)) {
|
|
49
|
+
fail('drop the "[agent-reported]" prefix from --title — the label already carries provenance');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const labels = (arg("labels") ?? "agent-reported,enhancement").trim();
|
|
53
|
+
|
|
54
|
+
const bodyFile = arg("body-file");
|
|
55
|
+
let rawBody;
|
|
56
|
+
if (bodyFile) {
|
|
57
|
+
try {
|
|
58
|
+
rawBody = readFileSync(bodyFile, "utf8");
|
|
59
|
+
} catch (err) {
|
|
60
|
+
fail(`could not read --body-file "${bodyFile}": ${err.message}`);
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
rawBody = await readStdin();
|
|
64
|
+
}
|
|
65
|
+
const body = rawBody.trim();
|
|
66
|
+
if (!body) {
|
|
67
|
+
fail("empty body. Pipe the markdown body on stdin, or pass --body-file <path>.");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const params = new URLSearchParams({ title: title.trim(), body });
|
|
71
|
+
if (labels) params.set("labels", labels);
|
|
72
|
+
|
|
73
|
+
const url = `https://github.com/${REPO}/issues/new?${params.toString()}`;
|
|
74
|
+
|
|
75
|
+
if (url.length > URL_SOFT_LIMIT) {
|
|
76
|
+
console.error(
|
|
77
|
+
`build-issue-url: WARNING — URL is ${url.length} chars (soft limit ${URL_SOFT_LIMIT}). ` +
|
|
78
|
+
"GitHub returns 414 around 8 KB. Trim the body and post any code snippet as a comment instead."
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log(url);
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: spiderly-upgrade
|
|
3
|
+
description: Upgrade a Spiderly consumer app's package version (NuGet + npm) to a newer Spiderly release. Use when the user asks to upgrade Spiderly, bump the Spiderly version, jump to a newer Spiderly release, or migrate to a newer Spiderly package version. Do NOT use for EF Core schema migrations (see ef-migrations skill).
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Spiderly Upgrade
|
|
7
|
+
|
|
8
|
+
End-to-end version upgrade for apps consuming Spiderly via the NuGet packages (`Spiderly.Shared`, `Spiderly.Security`, `Spiderly.Infrastructure`, `Spiderly.SourceGenerators`) and the `spiderly` npm package. (`Spiderly.CLI` is a `dotnet tool`, not a project reference — out of scope.)
|
|
9
|
+
|
|
10
|
+
Invocation forms:
|
|
11
|
+
|
|
12
|
+
- `/spiderly-upgrade <version>` — explicit target (e.g. `21.2.0`)
|
|
13
|
+
- `/spiderly-upgrade latest` — fetch latest stable from NuGet
|
|
14
|
+
- `/spiderly-upgrade` — same as `latest`
|
|
15
|
+
|
|
16
|
+
Spiderly has no backward-compatibility commitment — every release can break public API. This skill closes that gap by reading what changed on GitHub, scanning the user's app for what actually affects them, asking once, then applying everything with build-verification and self-healing retries.
|
|
17
|
+
|
|
18
|
+
## Step 1 — Pre-flight
|
|
19
|
+
|
|
20
|
+
Follow Spiderly's fail-loudly convention: every refused-to-start condition exits non-zero with an actionable message. Cache the parsed csproj and `package.json` contents from this step — Step 6 reuses them.
|
|
21
|
+
|
|
22
|
+
1. **Locate the app.** Glob `**/*.csproj` and `**/package.json` from CWD (skip `node_modules`, `bin`, `obj`). Identify the Backend (csproj with `Spiderly.*` PackageReferences) and Frontend (package.json with `"spiderly"` dep). If neither is found, exit:
|
|
23
|
+
|
|
24
|
+
> Not a Spiderly app: no `Spiderly.*` PackageReference or `spiderly` npm dep found from this directory.
|
|
25
|
+
|
|
26
|
+
2. **Reject local-dev consumers.** If any csproj has a `<ProjectReference Include="...\spiderly\...">` pointing at a sibling Spiderly checkout, exit:
|
|
27
|
+
|
|
28
|
+
> This skill is for NuGet/npm consumers. You're using local ProjectReferences to a sibling `spiderly/` directory — upgrade by `git pull` in that directory instead.
|
|
29
|
+
|
|
30
|
+
3. **Require clean git state.** Run `git status --porcelain`. If non-empty, exit:
|
|
31
|
+
|
|
32
|
+
> Working tree must be clean before upgrading. Commit or stash your changes first — the upgrade will make many edits, and a clean tree is your `git restore .` safety net.
|
|
33
|
+
|
|
34
|
+
4. **Detect current version.** Read all `Spiderly.*` `PackageReference` `Version=` values across every csproj, plus the `"spiderly"` value in `Frontend/package.json`. They should agree. If they drift, list the values and ask the user which to treat as "current".
|
|
35
|
+
|
|
36
|
+
5. **Multi-solution monorepo.** If multiple Backend dirs (each with their own Spiderly refs) exist, ask the user which one to upgrade. One app per invocation.
|
|
37
|
+
|
|
38
|
+
## Step 2 — Resolve target version
|
|
39
|
+
|
|
40
|
+
- If arg is `latest` or omitted: WebFetch `https://api.nuget.org/v3-flatcontainer/spiderly.shared/index.json`, parse the `versions` array, pick the highest stable (no `-` suffix), confirm with the user.
|
|
41
|
+
- Otherwise validate the arg as `X.Y.Z` (or `X.Y.Z-preview.N` if the user is explicitly opting into a preview — warn that preview release notes are usually thin).
|
|
42
|
+
- Reject downgrades: if target < current, exit `Downgrades are not supported. To downgrade, edit Spiderly.* PackageReference Version= attributes and the "spiderly" npm dep manually, then run dotnet restore and npm install.`
|
|
43
|
+
- Reject no-op: if target == current, exit `Already on {version}.`
|
|
44
|
+
|
|
45
|
+
## Step 3 — Fetch what changed
|
|
46
|
+
|
|
47
|
+
Fire these two WebFetch calls **in parallel** (same message, two tool calls):
|
|
48
|
+
|
|
49
|
+
1. **Diff between tags.** `https://api.github.com/repos/filiptrivan/spiderly/compare/v{current}...v{target}` (three-dot syntax). Response has `files[]` with `filename` and `patch`. Filter client-side to the user-facing paths below — GitHub's compare endpoint has no path-filter parameter. If `truncated: true`, warn the user the diff was capped and proceed with what was returned.
|
|
50
|
+
|
|
51
|
+
2. **All release notes in one call.** `https://api.github.com/repos/filiptrivan/spiderly/releases?per_page=100`. Returns every release. Filter in-memory to tags `v{X.Y.Z}` strictly above current and ≤ target. Concatenate `body` fields in ascending version order. (One call, not N — Spiderly's total release count fits in one page.)
|
|
52
|
+
|
|
53
|
+
On 403 rate-limited: ask the user to export `GH_TOKEN` (no scopes needed) and retry.
|
|
54
|
+
|
|
55
|
+
### User-facing file paths (filter the compare response client-side)
|
|
56
|
+
|
|
57
|
+
**Include** — changes here affect consumers:
|
|
58
|
+
|
|
59
|
+
- `Spiderly.Shared/**/*.cs` — public attributes, contracts, builders
|
|
60
|
+
- `Spiderly.Security/**/*.cs` — auth interfaces, attributes
|
|
61
|
+
- `Spiderly.Infrastructure/**/*.cs` — DI extensions, base services
|
|
62
|
+
- `Spiderly.SourceGenerators/**/*.cs` — generator output shape changes (may break consumer overrides)
|
|
63
|
+
- `Spiderly.Shared/Helpers/NetAndAngularFilesGenerator.cs` — init template; drift here implies existing apps may need mirroring edits (new service registration, new file in scaffold)
|
|
64
|
+
- `Angular/projects/spiderly/src/lib/**/*.ts`
|
|
65
|
+
- `Angular/projects/spiderly/src/public-api.ts`
|
|
66
|
+
|
|
67
|
+
**Exclude** — `*.Tests/**`, `tests/**`, `Spiderly.CLI/**` (consumers don't reference CLI internals — flag CLI flag/command changes only via release notes), `Angular/node_modules/**`, `Angular/dist/**`, `.github/**`, `.claude-plugin/**`, `claude-plugins/**`, `*.csproj`, `package.json`, `*.md`.
|
|
68
|
+
|
|
69
|
+
## Step 4 — Impact scan and plan
|
|
70
|
+
|
|
71
|
+
Read the combined release notes + filtered diff in a single pass. Identify changes that likely affect a consumer:
|
|
72
|
+
|
|
73
|
+
- New / renamed / removed public types, methods, attributes in `Spiderly.Shared`, `Spiderly.Security`, `Spiderly.Infrastructure`
|
|
74
|
+
- Changes in source-generator output shape (consumers may have overrides that no longer compile)
|
|
75
|
+
- Changes in the init template — signals what NEW apps look like; existing apps may need to mirror (e.g. a new `services.AddTransient<X>()`)
|
|
76
|
+
- Changes in the Angular library's public surface (`public-api.ts` exports, component/service contracts)
|
|
77
|
+
- CLI command/flag changes (if the user has CI scripts invoking `spiderly`)
|
|
78
|
+
|
|
79
|
+
For each candidate change:
|
|
80
|
+
|
|
81
|
+
1. Describe in one line what changed.
|
|
82
|
+
2. Use Grep over the user's codebase to find real usages. Read context, don't just match symbol names blindly.
|
|
83
|
+
3. If no real usages exist, drop the item from the plan.
|
|
84
|
+
|
|
85
|
+
Plan format:
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
Spiderly upgrade: 19.5.0 → 21.2.0
|
|
89
|
+
|
|
90
|
+
Code edits (N items affect you):
|
|
91
|
+
1. <change> — <N> usages in <files>
|
|
92
|
+
2. ...
|
|
93
|
+
|
|
94
|
+
Manual steps after upgrade:
|
|
95
|
+
- <step the agent can't do for you>
|
|
96
|
+
|
|
97
|
+
Version bumps: Spiderly.* 19.5.0 → 21.2.0 (4 csproj + Frontend/package.json[ + .claude/settings.json legacy-plugin removal])
|
|
98
|
+
|
|
99
|
+
After approval: apply → bump → migrate guidance + config → restore + install (parallel) → build → agent-sync. Up to 2 build-fix retries on failure.
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Step 5 — Single approval gate
|
|
103
|
+
|
|
104
|
+
Present the plan and ask the user explicitly. Don't proceed without a clear yes.
|
|
105
|
+
|
|
106
|
+
## Step 6 — Apply
|
|
107
|
+
|
|
108
|
+
In this order. A failure at any step is a hard stop until the recovery procedure in Step 7.
|
|
109
|
+
|
|
110
|
+
1. **Code edits.** Apply each plan item via Edit. Read each target file first.
|
|
111
|
+
2. **Version bumps.** Iterate the csproj list cached in Step 1: rewrite every `<PackageReference Include="Spiderly.X" Version="OLD" />` to the new version. Rewrite `"spiderly": "OLD"` in `Frontend/package.json`. Don't re-glob.
|
|
112
|
+
3. **Migrate agent guidance off the legacy plugin.** Spiderly's AI-agent guidance now ships *inside* the `spiderly` npm package (projected by `spiderly agent-sync` in item 7 below), replacing the old `spiderly@spiderly` Claude Code plugin. If `.claude/settings.json` contains `extraKnownMarketplaces.spiderly` and/or `enabledPlugins."spiderly@spiderly"`, remove just those keys (leave any other settings intact; delete the file only if it becomes an empty `{}`). No settings file or no spiderly entries → skip.
|
|
113
|
+
4. **Migrate `spiderly.json` → `.spiderly/config.json`.** Recent Spiderly moved the generator/api config out of a root `spiderly.json` into `.spiderly/config.json`. If the app has a `spiderly.json` registered via `<AdditionalFiles Include="spiderly.json" />`, `git mv` it to `.spiderly/config.json` and rewrite that csproj line to `<AdditionalFiles Include=".spiderly/config.json" />`. No `spiderly.json` → skip.
|
|
114
|
+
5. **Restore packages in parallel.** Same message, two Bash calls: `dotnet restore` in Backend and `npm install` in Frontend. They share no locks. If either fails, surface the actual error and exit.
|
|
115
|
+
6. **`dotnet build`** from the Backend dir. On failure, go to Step 7.
|
|
116
|
+
7. **Sync agent guidance.** Run `spiderly agent-sync` from the app root. It reads the freshly-installed `Frontend/node_modules/spiderly/agent` bundle and reconciles `AGENTS.md` (doc index + `@AGENTS.md` in `CLAUDE.md`) and `.claude/skills/spiderly-*` (junctions, pruning any renamed/removed skill). Idempotent; non-fatal if it warns the bundle is missing (an older package predating the bundle).
|
|
117
|
+
|
|
118
|
+
## Step 7 — Build-failure recovery (max 2 retries)
|
|
119
|
+
|
|
120
|
+
Loop up to 2 times — stop on first green build:
|
|
121
|
+
|
|
122
|
+
1. Scan output for the **first `SPIDERLY`-prefixed diagnostic** (these are the root cause; downstream `CS0246` errors about missing `*DTO` types are noise). Fall back to the first `CS` error only if no SPIDERLY diagnostic exists.
|
|
123
|
+
2. Read the offending file. Apply the most likely fix using the error message + the diff context from Step 3.
|
|
124
|
+
3. Re-run `dotnet build`.
|
|
125
|
+
|
|
126
|
+
After 2 failed attempts, stop:
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
Upgrade halted after 2 build-fix attempts.
|
|
130
|
+
|
|
131
|
+
Build error still present:
|
|
132
|
+
<paste the diagnostic>
|
|
133
|
+
|
|
134
|
+
Attempted fixes:
|
|
135
|
+
1. <what>
|
|
136
|
+
2. <what>
|
|
137
|
+
|
|
138
|
+
The in-progress upgrade is in your working tree.
|
|
139
|
+
- To roll back: git restore .
|
|
140
|
+
- To continue manually: fix the error, run `dotnet build`, then commit.
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Step 8 — Done
|
|
144
|
+
|
|
145
|
+
Print summary:
|
|
146
|
+
|
|
147
|
+
- Versions bumped (from → to)
|
|
148
|
+
- Files edited (count + list)
|
|
149
|
+
- Manual steps remaining (re-print verbatim from the plan)
|
|
150
|
+
|
|
151
|
+
Do **not** auto-commit. The user reviews the diff and commits themselves.
|
|
152
|
+
|
|
153
|
+
## Refresh AI-agent guidance
|
|
154
|
+
|
|
155
|
+
After the new `spiderly` package is installed, project the version-matched guidance:
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
spiderly agent-sync
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
This is idempotent and reconciling: it rewrites the static `AGENTS.md` docs pointer, ensures `CLAUDE.md` imports it (`@AGENTS.md`), and adds/refreshes/prunes `.claude/skills/spiderly-*` junctions so renamed or removed skills self-heal. Re-running it is always safe.
|
|
162
|
+
|
|
163
|
+
## Rules
|
|
164
|
+
|
|
165
|
+
- **Never auto-revert.** Leave the in-progress state in the working tree so the user can see what was attempted; they roll back with `git restore .`
|
|
166
|
+
- **Never silently drop a candidate breaking change.** If the diff shows one but you can't determine impact, include it in the plan as "verify manually".
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: verify-ui
|
|
3
|
+
description: Log past Spiderly's email-code auth wall to visually verify the running Angular admin panel — screenshot a page, click through a flow, or dogfood a UI change without writing a test. Use when asked to "verify the admin UI", "check this page renders", "screenshot the admin panel", "does this screen look right", or to eyeball a change in the live app. For authoring Playwright test suites or debugging CI traces, use the e2e-testing skill instead.
|
|
4
|
+
allowed-tools: Bash(node:*), Bash(npx:*), Bash(agent-browser:*), Bash(curl:*)
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Verify Spiderly Admin UI (past the auth wall)
|
|
8
|
+
|
|
9
|
+
The Spiderly admin panel sits entirely behind a passwordless **email-code** login, which blocks any agent that just opens `localhost:4200`. This skill gets you a logged-in session headlessly so you can look at the real UI.
|
|
10
|
+
|
|
11
|
+
**This is for ad-hoc visual verification, not test authoring.** If you're writing a Playwright suite or debugging a failing CI run, use the `e2e-testing` skill — it owns the canonical login helper and the PrimeNG selector quirks.
|
|
12
|
+
|
|
13
|
+
## The core idea (driver-agnostic)
|
|
14
|
+
|
|
15
|
+
In development, `SecurityService.SendLoginVerificationEmail` returns the verification code **in the response body** (when `ShouldShowVerificationCodeInNotification()` is true — i.e. `IWebHostEnvironment.IsDevelopment()` **and** SMTP is not fully configured, so `emailingService.IsConfigured()` is false). So no email inbox is needed. The flow is always:
|
|
16
|
+
|
|
17
|
+
1. `POST /api/Security/SendLoginVerificationEmail` `{ email, browserId }` → read `verificationCode`.
|
|
18
|
+
2. `POST /api/Security/Login` `{ verificationCode, email, browserId }` → get `accessToken` + `refreshToken`.
|
|
19
|
+
3. In the browser, set three `localStorage` keys on the admin origin, then reload:
|
|
20
|
+
- `access_token`, `refresh_token`, `browser_id` (the last must equal the `browserId` used above).
|
|
21
|
+
4. Wait for `sidebar-menu` to appear — that confirms you're authenticated and in.
|
|
22
|
+
|
|
23
|
+
Step 1+2 are scripted for you: **`scripts/get-admin-token.mjs`** (pure Node, no dependencies, fails loudly).
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
node scripts/get-admin-token.mjs --email admin@example.com
|
|
27
|
+
# → { "accessToken": "...", "refreshToken": "...", "browserId": "verify-ui" }
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Flags: `--email` (required — must have the **Admin role**, see gotcha below), `--api` (default `http://localhost:5000`), `--browser-id` (default `verify-ui`).
|
|
31
|
+
|
|
32
|
+
> **Don't trust the default ports — read them from the project.** The `:5000` (backend) and `:4200` (admin) values used throughout this skill are only the Spiderly scaffold defaults; any project can change them. The authoritative values live in **`Frontend/src/environments/environment.ts`**:
|
|
33
|
+
> - `apiUrl` (e.g. `http://localhost:5000/api`) → pass its **origin** (strip the trailing `/api`) to `--api`.
|
|
34
|
+
> - `frontendUrl` (e.g. `http://localhost:4200`) → the admin URL to open in the browser.
|
|
35
|
+
>
|
|
36
|
+
> The backend's actually-bound port is in **`Backend/<App>.WebAPI/Properties/launchSettings.json`** → `applicationUrl`. If a connection fails or the project looks non-standard, read those files first instead of assuming the defaults below.
|
|
37
|
+
|
|
38
|
+
> **CORS origin must match.** The backend allows one origin (`appsettings.json` → `Spiderly.Shared:FrontendUrl`). Serving the admin on a different port blocks every API call and surfaces as a misleading **"Connection Lost"** toast + bounce to `/login` (looks like an auth failure).
|
|
39
|
+
|
|
40
|
+
## Prerequisites — validate before doing anything
|
|
41
|
+
|
|
42
|
+
The token script already fails loudly on the first two. Confirm all four up front so you don't burn a turn on a half-running stack:
|
|
43
|
+
|
|
44
|
+
| Requirement | How to confirm | If it fails |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| Backend reachable | the script's first POST succeeds (port from `launchSettings.json` → `applicationUrl`) | start the backend (`dotnet run` in the WebAPI project) |
|
|
47
|
+
| Dev-mode code exposure on | response contains `verificationCode` | run backend with `ASPNETCORE_ENVIRONMENT=Development` **and** SMTP unconfigured (`IsConfigured()` false) — a fully-configured SMTP setup does a real send even in Development |
|
|
48
|
+
| Admin SPA running | `frontendUrl` (from `environment.ts`, default `:4200`) responds | start the admin (`npm start` in `Frontend/`) |
|
|
49
|
+
| Account has Admin role | login lands on a populated sidebar, not an empty shell | use an Admin-roled email (see gotcha) |
|
|
50
|
+
|
|
51
|
+
## Driver A — agent-browser (preferred when available)
|
|
52
|
+
|
|
53
|
+
[agent-browser](https://www.skills.sh/vercel-labs/agent-browser/agent-browser) gives an agent live, interactive control — ideal for clicking through a flow and screenshotting. If it isn't installed, install once (or skip to Driver B):
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
npx skills add https://github.com/vercel-labs/agent-browser --skill agent-browser
|
|
57
|
+
npm i -g agent-browser && agent-browser install # CLI + Chrome binary
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Then log in and verify (replace `<AT>` / `<RT>` with the script's output, and `verify-ui` if you changed `--browser-id`):
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
# 1. Open the admin origin first so localStorage writes land on the right origin
|
|
64
|
+
agent-browser open http://localhost:4200
|
|
65
|
+
|
|
66
|
+
# 2. Inject the session (use --stdin so quoting can't corrupt the values)
|
|
67
|
+
agent-browser eval --stdin <<'EOF'
|
|
68
|
+
localStorage.setItem('access_token', '<AT>');
|
|
69
|
+
localStorage.setItem('refresh_token', '<RT>');
|
|
70
|
+
localStorage.setItem('browser_id', 'verify-ui');
|
|
71
|
+
EOF
|
|
72
|
+
|
|
73
|
+
# 3. Navigate straight to the page under review — this load reads the just-set
|
|
74
|
+
# localStorage; the sidebar-menu wait confirms auth (it renders on every
|
|
75
|
+
# authenticated route, so no separate dashboard round-trip is needed).
|
|
76
|
+
agent-browser open http://localhost:4200/administration/banner \
|
|
77
|
+
&& agent-browser wait "sidebar-menu" \
|
|
78
|
+
&& agent-browser wait --load networkidle \
|
|
79
|
+
&& agent-browser screenshot --full
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
From here use the normal agent-browser loop (`snapshot -i` → `click`/`fill` → re-snapshot) to drive a flow. Close the session when done: `agent-browser close`.
|
|
83
|
+
|
|
84
|
+
## Driver B — Playwright (guaranteed floor, zero extra install)
|
|
85
|
+
|
|
86
|
+
Every Spiderly app ships Playwright for e2e, so this always works without installing anything new. Run it **from the `Frontend/` directory** so `playwright` resolves. It reuses the same token script.
|
|
87
|
+
|
|
88
|
+
Save as `Frontend/verify-page.mjs` (gitignore it — it's a throwaway), then:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
node verify-page.mjs --email admin@example.com \
|
|
92
|
+
--url http://localhost:4200/administration/banner \
|
|
93
|
+
--token-script <skill-dir>/scripts/get-admin-token.mjs --out ./_verify.png
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
It reuses the same token helper (`--token-script`, required — pass the absolute path to this skill's `scripts/get-admin-token.mjs`), so the localStorage key names and `sidebar-menu` wait stay defined in one place. Keep that injection block in sync with `e2e-testing`'s `authenticateBrowser` if either changes.
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
import { chromium } from "playwright";
|
|
100
|
+
import { execFileSync } from "node:child_process";
|
|
101
|
+
|
|
102
|
+
function arg(name, fallback) {
|
|
103
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
104
|
+
return i !== -1 ? process.argv[i + 1] : fallback;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const email = arg("email");
|
|
108
|
+
const url = arg("url", "http://localhost:4200");
|
|
109
|
+
const api = arg("api", "http://localhost:5000");
|
|
110
|
+
const out = arg("out", "./_verify.png");
|
|
111
|
+
const tokenScript = arg("token-script");
|
|
112
|
+
if (!email || !tokenScript) {
|
|
113
|
+
throw new Error("Pass --email <admin-roled account> and --token-script <path to get-admin-token.mjs>");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const { accessToken, refreshToken, browserId } = JSON.parse(
|
|
117
|
+
execFileSync("node", [tokenScript, "--email", email, "--api", api], { encoding: "utf8" })
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const browser = await chromium.launch();
|
|
121
|
+
try {
|
|
122
|
+
const page = await browser.newPage();
|
|
123
|
+
// Tokens must be set on the admin origin before the app reads them, so land there first.
|
|
124
|
+
await page.goto(new URL(url).origin);
|
|
125
|
+
await page.evaluate(
|
|
126
|
+
([at, rt, bid]) => {
|
|
127
|
+
localStorage.setItem("access_token", at);
|
|
128
|
+
localStorage.setItem("refresh_token", rt);
|
|
129
|
+
localStorage.setItem("browser_id", bid);
|
|
130
|
+
},
|
|
131
|
+
[accessToken, refreshToken, browserId]
|
|
132
|
+
);
|
|
133
|
+
await page.goto(url);
|
|
134
|
+
await page.locator("sidebar-menu").waitFor({ state: "visible", timeout: 15000 });
|
|
135
|
+
await page.screenshot({ path: out, fullPage: true });
|
|
136
|
+
console.log(`Saved ${out}`);
|
|
137
|
+
} finally {
|
|
138
|
+
await browser.close();
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Gotcha — login can succeed but show an empty shell
|
|
143
|
+
|
|
144
|
+
`SendLoginVerificationEmail` mints a session for any email when `OnlyAdminCanAddUsers` is `false` (the default) — a not-yet-existing user is **auto-provisioned with no role**, so the sidebar comes up empty and most pages are inaccessible. If you can log in but see nothing useful, the account lacks permissions. Use an email that already has the **Admin role** (role/permission assignment is seeded per-project, e.g. via a `PermissionSeeder`). Permissions hang off roles, not off the user directly.
|
|
145
|
+
|
|
146
|
+
## Why a separate skill from e2e-testing
|
|
147
|
+
|
|
148
|
+
Same login mechanism, different job: `e2e-testing` is for *authoring* Playwright suites and debugging CI traces; this is for *looking at* the running admin UI ad-hoc. Keeping them separate keeps each skill's trigger sharp. The login flow lives in both only as a thin reference — `e2e-testing` holds the canonical Playwright helper (`authenticateBrowser`); this skill holds the dependency-free token script and the agent-browser path.
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Obtain a Spiderly admin session token without an email inbox.
|
|
3
|
+
//
|
|
4
|
+
// In development, SecurityService.SendLoginVerificationEmail returns the
|
|
5
|
+
// verification code directly in the response body (when
|
|
6
|
+
// ShouldShowVerificationCodeInNotification() is true). This script uses that
|
|
7
|
+
// to complete the passwordless login flow headlessly and print the tokens an
|
|
8
|
+
// admin SPA expects in localStorage.
|
|
9
|
+
//
|
|
10
|
+
// The --api default (http://localhost:5000) is the Spiderly scaffold default only.
|
|
11
|
+
// The authoritative backend URL for a project is the origin of `apiUrl` in
|
|
12
|
+
// Frontend/src/environments/environment.ts (strip the trailing /api); the bound
|
|
13
|
+
// port is in Backend/<App>.WebAPI/Properties/launchSettings.json -> applicationUrl.
|
|
14
|
+
// Pass --api explicitly when a project overrides the default.
|
|
15
|
+
//
|
|
16
|
+
// Usage:
|
|
17
|
+
// node get-admin-token.mjs --email admin@example.com
|
|
18
|
+
// node get-admin-token.mjs --email admin@example.com --api http://localhost:5000 --browser-id verify-ui
|
|
19
|
+
//
|
|
20
|
+
// Output (stdout, JSON): { "accessToken": "...", "refreshToken": "...", "browserId": "..." }
|
|
21
|
+
// Exit code 0 on success, 1 on any failure (fails loudly — no partial success).
|
|
22
|
+
|
|
23
|
+
function parseArgs(argv) {
|
|
24
|
+
const args = {};
|
|
25
|
+
for (let i = 0; i < argv.length; i++) {
|
|
26
|
+
const token = argv[i];
|
|
27
|
+
if (!token.startsWith("--")) continue;
|
|
28
|
+
|
|
29
|
+
// Support both --flag=value and --flag value.
|
|
30
|
+
const eq = token.indexOf("=");
|
|
31
|
+
if (eq !== -1) {
|
|
32
|
+
args[token.slice(2, eq)] = token.slice(eq + 1);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const key = token.slice(2);
|
|
37
|
+
const next = argv[i + 1];
|
|
38
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
39
|
+
args[key] = next;
|
|
40
|
+
i++;
|
|
41
|
+
} else {
|
|
42
|
+
args[key] = true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return args;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function fail(message) {
|
|
49
|
+
console.error(`[get-admin-token] ${message}`);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const args = parseArgs(process.argv.slice(2));
|
|
54
|
+
|
|
55
|
+
const email = args.email;
|
|
56
|
+
const apiBaseUrl = (args.api || "http://localhost:5000").replace(/\/+$/, "");
|
|
57
|
+
const browserId = args["browser-id"] || "verify-ui";
|
|
58
|
+
|
|
59
|
+
if (!email || email === true) {
|
|
60
|
+
fail(
|
|
61
|
+
"Missing required --email. Pass an account that has the Admin role in the target DB.\n" +
|
|
62
|
+
" Usage: node get-admin-token.mjs --email admin@example.com [--api http://localhost:5000] [--browser-id verify-ui]"
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function postJson(path, body) {
|
|
67
|
+
let response;
|
|
68
|
+
try {
|
|
69
|
+
response = await fetch(`${apiBaseUrl}${path}`, {
|
|
70
|
+
method: "POST",
|
|
71
|
+
headers: { "Content-Type": "application/json" },
|
|
72
|
+
body: JSON.stringify(body),
|
|
73
|
+
});
|
|
74
|
+
} catch (err) {
|
|
75
|
+
fail(
|
|
76
|
+
`Could not reach the backend at ${apiBaseUrl}${path}. Is the backend running?\n ${err.message}`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const text = await response.text();
|
|
81
|
+
let json;
|
|
82
|
+
try {
|
|
83
|
+
json = text ? JSON.parse(text) : {};
|
|
84
|
+
} catch {
|
|
85
|
+
json = { raw: text };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!response.ok) {
|
|
89
|
+
fail(`POST ${path} failed (HTTP ${response.status}): ${text || "(empty body)"}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return json;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const sendCodeResult = await postJson("/api/Security/SendLoginVerificationEmail", {
|
|
96
|
+
email,
|
|
97
|
+
browserId,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const verificationCode = sendCodeResult.verificationCode;
|
|
101
|
+
if (!verificationCode) {
|
|
102
|
+
fail(
|
|
103
|
+
"SendLoginVerificationEmail did not return a verificationCode.\n" +
|
|
104
|
+
" This means dev-mode code exposure is OFF (ShouldShowVerificationCodeInNotification() returned false).\n" +
|
|
105
|
+
" The gate is IsDevelopment() && !IsConfigured(): run the backend with ASPNETCORE_ENVIRONMENT=Development\n" +
|
|
106
|
+
" AND with SMTP left unconfigured. A fully-configured SMTP setup (EmailSender.Email + EmailSenderPassword\n" +
|
|
107
|
+
" + SmtpHost + SmtpPort) does a real email send instead, even in Development."
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const loginResult = await postJson("/api/Security/Login", {
|
|
112
|
+
verificationCode,
|
|
113
|
+
email,
|
|
114
|
+
browserId,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
if (!loginResult.accessToken || !loginResult.refreshToken) {
|
|
118
|
+
fail(
|
|
119
|
+
`Login did not return tokens. Response: ${JSON.stringify(loginResult)}\n` +
|
|
120
|
+
" The email may not exist, or the code expired. Check the account and retry."
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
process.stdout.write(
|
|
125
|
+
JSON.stringify(
|
|
126
|
+
{
|
|
127
|
+
accessToken: loginResult.accessToken,
|
|
128
|
+
refreshToken: loginResult.refreshToken,
|
|
129
|
+
browserId,
|
|
130
|
+
},
|
|
131
|
+
null,
|
|
132
|
+
2
|
|
133
|
+
) + "\n"
|
|
134
|
+
);
|