@ishlabs/cli 0.23.1 → 0.24.0
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/dist/commands/ask.js +4 -4
- package/dist/commands/iteration.js +25 -3
- package/dist/commands/study-share.d.ts +18 -0
- package/dist/commands/study-share.js +117 -0
- package/dist/commands/study.js +54 -7
- package/dist/commands/workspace.js +4 -1
- package/dist/connect.d.ts +4 -2
- package/dist/connect.js +151 -11
- package/dist/index.js +63 -6
- package/dist/lib/ask-questions.d.ts +15 -5
- package/dist/lib/ask-questions.js +34 -11
- package/dist/lib/auth.d.ts +1 -0
- package/dist/lib/auth.js +7 -1
- package/dist/lib/command-helpers.js +33 -5
- package/dist/lib/docs.js +140 -8
- package/dist/lib/output.js +8 -1
- package/dist/lib/reverse-proxy.d.ts +19 -0
- package/dist/lib/reverse-proxy.js +87 -0
- package/dist/lib/reverse-proxy.test.d.ts +10 -0
- package/dist/lib/reverse-proxy.test.js +149 -0
- package/dist/lib/segmentation.d.ts +31 -0
- package/dist/lib/segmentation.js +105 -0
- package/dist/lib/skill-content.js +76 -4
- package/dist/lib/types.d.ts +2 -0
- package/package.json +3 -1
package/dist/lib/docs.js
CHANGED
|
@@ -98,10 +98,20 @@ ish workspace list
|
|
|
98
98
|
ish workspace create --name "My product" --base-url https://example.com
|
|
99
99
|
ish workspace use w-6ec # set as active
|
|
100
100
|
ish workspace get # show the active workspace
|
|
101
|
+
ish workspace update w-6ec --logo https://logo.clearbit.com/acme.com # brand logo
|
|
101
102
|
ish workspace info # usage counters + plan caps (see below)
|
|
102
103
|
ish workspace site-access status
|
|
103
104
|
\`\`\`
|
|
104
105
|
|
|
106
|
+
## Branding a workspace (\`--logo\`)
|
|
107
|
+
|
|
108
|
+
\`ish workspace update <id> --logo <url>\` sets a brand logo from an
|
|
109
|
+
external image URL. The logo shows on the workspace and — importantly —
|
|
110
|
+
on **shared study links** (\`ish study share\`), so a prospect opening the
|
|
111
|
+
public link sees the demo branded with their own logo. There is no
|
|
112
|
+
\`--logo\` on \`workspace create\`; create first, then update. See
|
|
113
|
+
\`concepts/sharing\`.
|
|
114
|
+
|
|
105
115
|
## Checking usage before destructive calls
|
|
106
116
|
|
|
107
117
|
\`ish workspace info\` shows usage counters so an agent can branch on
|
|
@@ -217,6 +227,15 @@ its iterations. Think: a study is the recipe; an iteration is one batch.
|
|
|
217
227
|
iteration A inline in the same call. Useful when you have a single
|
|
218
228
|
test artifact and don't need to A/B iterations:
|
|
219
229
|
|
|
230
|
+
For text + media, the inline iteration A can also carry
|
|
231
|
+
\`--segmentation-json\` (+ \`--content-config-json\`) and the text
|
|
232
|
+
email-styling flags (\`--content-html\`, \`--sender-name\`,
|
|
233
|
+
\`--sender-email\`, \`--featured-image-url\`). So a single-iteration
|
|
234
|
+
**segmented** study is one \`study create\` call — you do NOT need a
|
|
235
|
+
second \`iteration create\` (which would leave an empty A plus a
|
|
236
|
+
redundant B). Reach for \`iteration create\` only when you genuinely
|
|
237
|
+
want a 2nd iteration to A/B.
|
|
238
|
+
|
|
220
239
|
| Modality | Inline content flag |
|
|
221
240
|
|-----------------|------------------------------------------------------|
|
|
222
241
|
| \`interactive\` | \`--url <url>\` (\`--screen-format desktop\` is the default; pass \`mobile_portrait\` for mobile) |
|
|
@@ -402,6 +421,14 @@ Each segment can carry a human-readable **label** ("Intro", "Pricing
|
|
|
402
421
|
section", "Call to action") that surfaces in the participant UI and in
|
|
403
422
|
results.
|
|
404
423
|
|
|
424
|
+
**Segments are semantic sections, not paragraphs.** Group related
|
|
425
|
+
paragraphs into a few coherent sections — a 16-paragraph article is
|
|
426
|
+
usually 3–6 sections (e.g. "Lede", "The argument", "Counterpoints",
|
|
427
|
+
"Conclusion"), not 16. \`paragraph_start\`/\`paragraph_end\` only mark
|
|
428
|
+
where a section begins and ends; the unit you are choosing is the
|
|
429
|
+
*section*. The CLI errors on a missing label and warns when you emit one
|
|
430
|
+
section per paragraph.
|
|
431
|
+
|
|
405
432
|
Segments live inside the iteration's \`segmentation\` field — there is
|
|
406
433
|
no separate segments resource. Three discriminated shapes:
|
|
407
434
|
|
|
@@ -431,6 +458,11 @@ no separate segments resource. Three discriminated shapes:
|
|
|
431
458
|
}
|
|
432
459
|
\`\`\`
|
|
433
460
|
|
|
461
|
+
The three sections above each group several paragraphs (greeting +
|
|
462
|
+
context, the body, the call to action) — semantic grouping, not one
|
|
463
|
+
section per paragraph. Adjust the ranges to your content's logical
|
|
464
|
+
structure.
|
|
465
|
+
|
|
434
466
|
- **page_based** (document): pages are auto-derived from the document.
|
|
435
467
|
No additional fields.
|
|
436
468
|
|
|
@@ -888,12 +920,16 @@ Two flags, mutually exclusive:
|
|
|
888
920
|
# --question is repeatable. Defaults to type=text, timing=after.
|
|
889
921
|
ish study create … --question "How easy was it?" --question "Anything confusing?"
|
|
890
922
|
|
|
891
|
-
# Richer types
|
|
892
|
-
|
|
923
|
+
# Richer types via --questionnaire. Three interchangeable input forms — no
|
|
924
|
+
# temp file required (mirrors how --assignments takes inline JSON):
|
|
925
|
+
ish study create … --questionnaire '[{"question":"How easy?","type":"slider","min":0,"max":10}]' # inline JSON
|
|
926
|
+
ish study create … --questionnaire @/tmp/questionnaire.json # @file
|
|
927
|
+
ish study create … --questionnaire ./questionnaire.json # bare path
|
|
893
928
|
\`\`\`
|
|
894
929
|
|
|
895
|
-
|
|
896
|
-
|
|
930
|
+
The payload is always an array of question objects in the shape above
|
|
931
|
+
(inline JSON must start with \`[\`; an \`@\`-prefixed or bare value is read
|
|
932
|
+
from disk). The same three input forms are accepted by \`ish ask … --questions\`.
|
|
897
933
|
|
|
898
934
|
The \`type\` field is hyphenated for the multi-word values (\`single-choice\`,
|
|
899
935
|
\`multiple-choice\`). The CLI normalises the underscored variants
|
|
@@ -2130,11 +2166,27 @@ The CLI guarantees these contracts so agents can chain safely:
|
|
|
2130
2166
|
\`--fields\` set, you can identify the affected resource. Default
|
|
2131
2167
|
write-path JSON is compact (\`{id, alias, name, updated_at,
|
|
2132
2168
|
...changed_fields}\`); pass \`--verbose\` for the full server payload.
|
|
2169
|
+
- **Write-path echoes keep collection arrays even when empty.** On a
|
|
2170
|
+
create/update echo (e.g. \`study create\`/\`study update\`), entity
|
|
2171
|
+
collections like \`assignments\`, \`interview_questions\`, and
|
|
2172
|
+
\`iterations\` are always present — \`[]\` when the resource has none,
|
|
2173
|
+
not dropped. So the echo reflects exactly what was persisted: an empty
|
|
2174
|
+
\`assignments\` means the study genuinely has no assignment and will
|
|
2175
|
+
fail at run with "Study has no assignments" — you don't need a second
|
|
2176
|
+
\`--verbose\` (or \`study get\`) call to tell "zero persisted" from
|
|
2177
|
+
"stripped by lean mode." (Read-path \`list\` responses still drop empty
|
|
2178
|
+
per-item arrays as noise; this guarantee is write-path only.)
|
|
2133
2179
|
- **\`person generate\` returns \`{job: {id, status, person_ids},
|
|
2134
2180
|
profiles: [...]}\`** in \`--json\` mode. Each profile is the
|
|
2135
2181
|
lean \`person\` shape (pass \`--verbose\` for the full record,
|
|
2136
2182
|
including \`simulation_config\`) with its evidence-grounded
|
|
2137
2183
|
\`scenarios\` attached; pass \`--no-scenarios\` to omit them.
|
|
2184
|
+
- **\`study share\` returns \`{id, token, share_url, expires_at,
|
|
2185
|
+
created_at}\`** in \`--json\` mode (full envelope, not lean-stripped).
|
|
2186
|
+
\`share_url\` is the public no-login URL — use it verbatim. In human
|
|
2187
|
+
mode \`share_url\` goes to stdout, context to stderr. \`study share
|
|
2188
|
+
--list\` returns rows of \`{token, study, expires_at, is_revoked}\`
|
|
2189
|
+
(no \`share_url\` — only create returns it). See \`concepts/sharing\`.
|
|
2138
2190
|
- **\`<entity> get\` accepts multiple IDs.** \`person get\`, \`study get\`,
|
|
2139
2191
|
\`iteration get\`, and \`ask get\` all take \`<ids...>\` — pass two or
|
|
2140
2192
|
more aliases (space- or comma-separated) and the response is a
|
|
@@ -2835,10 +2887,16 @@ script or agent session.
|
|
|
2835
2887
|
|
|
2836
2888
|
### \`ish login\` is idempotent
|
|
2837
2889
|
|
|
2838
|
-
When you already have a
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2890
|
+
When you already have a saved token that is **both unexpired and still
|
|
2891
|
+
accepted by the API**, \`ish login\` short-circuits with a friendly
|
|
2892
|
+
"Already logged in" message and **does not** open a new browser tab or
|
|
2893
|
+
register a fresh OAuth client. If the saved token is unexpired but the
|
|
2894
|
+
server rejects it — a revoked session, a rotated signing key, or a token
|
|
2895
|
+
minted against the wrong Supabase project (e.g. a dev-issued token while
|
|
2896
|
+
calling the prod api) — the guard falls through and re-runs the browser
|
|
2897
|
+
flow instead of falsely reporting "Already logged in". Use \`--force\`
|
|
2898
|
+
(or \`-f\`) to bypass the guard unconditionally — typical reason is
|
|
2899
|
+
switching accounts.
|
|
2842
2900
|
|
|
2843
2901
|
\`\`\`bash
|
|
2844
2902
|
ish login # no-op when already authenticated
|
|
@@ -4187,6 +4245,74 @@ overridden URL.
|
|
|
4187
4245
|
|
|
4188
4246
|
- \`reference/json-mode\` — display vs capture vs chain output rules.
|
|
4189
4247
|
`;
|
|
4248
|
+
const CONCEPT_SHARE = `# concept: sharing study results
|
|
4249
|
+
|
|
4250
|
+
A **share link** is a public, no-login URL to one study's results. Anyone
|
|
4251
|
+
with the link opens it in a browser — no ish account — and sees the study's
|
|
4252
|
+
summary, key insights, participant journeys, interactive frames, and segment
|
|
4253
|
+
breakdowns (read-only). This is how you hand a study to someone outside your
|
|
4254
|
+
workspace: a prospect, a stakeholder, a teammate without a seat.
|
|
4255
|
+
|
|
4256
|
+
- Created via: \`ish study share [id]\` (defaults to the active study).
|
|
4257
|
+
- Revoked via: \`ish study unshare <token>\`.
|
|
4258
|
+
- The link host is the **web app frontend**, not the API host. The backend
|
|
4259
|
+
returns the fully-formed \`share_url\` — print/use it verbatim. Do NOT
|
|
4260
|
+
hand-build the URL from the API host or app URL; they differ.
|
|
4261
|
+
|
|
4262
|
+
## Create a link
|
|
4263
|
+
|
|
4264
|
+
\`\`\`
|
|
4265
|
+
ish study share # share the active study
|
|
4266
|
+
ish study share s-b2c # share a specific study
|
|
4267
|
+
ish study share s-b2c --expires 30 # auto-expire 30 days from now
|
|
4268
|
+
ish study share s-b2c --json # { token, share_url, expires_at, created_at, id }
|
|
4269
|
+
\`\`\`
|
|
4270
|
+
|
|
4271
|
+
Human mode prints the \`share_url\` to **stdout** (it's the deliverable — a
|
|
4272
|
+
URL to paste into an email) and the token / expiry / revoke hint to stderr.
|
|
4273
|
+
JSON mode returns the full create envelope:
|
|
4274
|
+
|
|
4275
|
+
\`\`\`json
|
|
4276
|
+
{
|
|
4277
|
+
"id": "…",
|
|
4278
|
+
"token": "Hk9_…", // opaque url-safe token, NOT an alias
|
|
4279
|
+
"share_url": "https://<frontend>/share/study/Hk9_…",
|
|
4280
|
+
"expires_at": null, // null = never expires
|
|
4281
|
+
"created_at": "…"
|
|
4282
|
+
}
|
|
4283
|
+
\`\`\`
|
|
4284
|
+
|
|
4285
|
+
## List and revoke
|
|
4286
|
+
|
|
4287
|
+
\`\`\`
|
|
4288
|
+
ish study share --list # every share link you created (all studies)
|
|
4289
|
+
ish study unshare Hk9_… # revoke by raw token; URL stops working immediately
|
|
4290
|
+
ish study unshare Hk9_… --yes # skip the confirmation (required in --json / non-TTY)
|
|
4291
|
+
\`\`\`
|
|
4292
|
+
|
|
4293
|
+
The \`--list\` rows carry \`token\`, \`study\` (aliased), \`expires_at\`,
|
|
4294
|
+
\`is_revoked\`. The full \`share_url\` only comes back from \`share\` (create) —
|
|
4295
|
+
list responses do not reconstruct it. \`study unshare\` takes the **raw token**,
|
|
4296
|
+
never a study ID or alias.
|
|
4297
|
+
|
|
4298
|
+
## What a good shareable study looks like
|
|
4299
|
+
|
|
4300
|
+
The viewer is only as good as the run behind it. Before sharing, make sure:
|
|
4301
|
+
- The study has **run** with enough participants (\`ish study run … --wait\`;
|
|
4302
|
+
analysis needs ≥5 completed participants) and no broken simulations.
|
|
4303
|
+
- An **analysis** has been generated so the summary + key insights render
|
|
4304
|
+
(\`ish study analyze --wait\` → \`ish study insights\`).
|
|
4305
|
+
- For media studies, every **segment is labelled** (see \`concepts/iteration\`).
|
|
4306
|
+
- The workspace has a **logo** if you want the link branded
|
|
4307
|
+
(\`ish workspace update <id> --logo <url>\`).
|
|
4308
|
+
|
|
4309
|
+
## Related
|
|
4310
|
+
|
|
4311
|
+
- \`concepts/study\` — the artifact a link points at.
|
|
4312
|
+
- \`concepts/workspace\` — \`--logo\` branding shown on the shared link.
|
|
4313
|
+
- \`concepts/active-context\` — \`ish study share\` defaults to the active study.
|
|
4314
|
+
- \`reference/json-mode\` — the \`{ token, share_url, … }\` envelope.
|
|
4315
|
+
`;
|
|
4190
4316
|
const PAGES = [
|
|
4191
4317
|
{
|
|
4192
4318
|
slug: "overview",
|
|
@@ -4284,6 +4410,12 @@ const PAGES = [
|
|
|
4284
4410
|
description: "Saved workspace/study/ask state and how to inspect it (ish status).",
|
|
4285
4411
|
body: CONCEPT_ACTIVE_CONTEXT,
|
|
4286
4412
|
},
|
|
4413
|
+
{
|
|
4414
|
+
slug: "concepts/sharing",
|
|
4415
|
+
title: "concept: sharing study results",
|
|
4416
|
+
description: "Public no-login share links for a study: study share / study unshare / --list, --expires, token vs URL, branding with workspace --logo.",
|
|
4417
|
+
body: CONCEPT_SHARE,
|
|
4418
|
+
},
|
|
4287
4419
|
{
|
|
4288
4420
|
slug: "reference/aliases",
|
|
4289
4421
|
title: "reference: aliases",
|
package/dist/lib/output.js
CHANGED
|
@@ -196,7 +196,14 @@ function leanJson(data, keepIds = false) {
|
|
|
196
196
|
// Recurse into objects/arrays
|
|
197
197
|
if (typeof value === "object") {
|
|
198
198
|
const cleaned = leanJson(value, keepIds);
|
|
199
|
-
|
|
199
|
+
// Read paths drop empty arrays as noise. Write-path echoes (keepIds)
|
|
200
|
+
// must NOT: an empty `assignments`/`interview_questions` is the
|
|
201
|
+
// "zero persisted" signal the create/update echo exists to surface —
|
|
202
|
+
// a study with no assignments fails at run with "Study has no
|
|
203
|
+
// assignments". Dropping it made the echo indistinguishable from a
|
|
204
|
+
// lean-strip, which is why agents were told not to trust it.
|
|
205
|
+
const dropEmptyArray = !keepIds && Array.isArray(cleaned) && cleaned.length === 0;
|
|
206
|
+
if (cleaned !== undefined && !dropEmptyArray) {
|
|
200
207
|
result[key] = cleaned;
|
|
201
208
|
}
|
|
202
209
|
continue;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local reverse proxy: fan one inbound port out to multiple localhost services
|
|
3
|
+
* by path prefix. Wired into `ish connect` so a single cloudflared tunnel can
|
|
4
|
+
* serve a frontend + backend + extras under one origin (no CORS / cookie
|
|
5
|
+
* cross-origin pain in the cloud browser).
|
|
6
|
+
*/
|
|
7
|
+
export type Route = {
|
|
8
|
+
prefix: string;
|
|
9
|
+
target: string;
|
|
10
|
+
};
|
|
11
|
+
export interface ReverseProxyHandle {
|
|
12
|
+
port: number;
|
|
13
|
+
close: () => Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
export interface StartReverseProxyOptions {
|
|
16
|
+
primaryPort: number;
|
|
17
|
+
routes: Route[];
|
|
18
|
+
}
|
|
19
|
+
export declare function startReverseProxy(opts: StartReverseProxyOptions): Promise<ReverseProxyHandle>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local reverse proxy: fan one inbound port out to multiple localhost services
|
|
3
|
+
* by path prefix. Wired into `ish connect` so a single cloudflared tunnel can
|
|
4
|
+
* serve a frontend + backend + extras under one origin (no CORS / cookie
|
|
5
|
+
* cross-origin pain in the cloud browser).
|
|
6
|
+
*/
|
|
7
|
+
import http from "node:http";
|
|
8
|
+
import httpProxy from "http-proxy";
|
|
9
|
+
function resolveRoute(url, sortedRoutes, fallback) {
|
|
10
|
+
const path = url ?? "/";
|
|
11
|
+
for (const route of sortedRoutes) {
|
|
12
|
+
// Match the prefix at a segment boundary so `/api` doesn't catch `/apiary`.
|
|
13
|
+
if (path === route.prefix || path.startsWith(route.prefix + "/") || path.startsWith(route.prefix + "?")) {
|
|
14
|
+
return route.target;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return fallback;
|
|
18
|
+
}
|
|
19
|
+
export function startReverseProxy(opts) {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const primaryTarget = `http://127.0.0.1:${opts.primaryPort}`;
|
|
22
|
+
// Longest prefix wins: a request to `/api/v1/x` with routes
|
|
23
|
+
// `[/api, /api/v1]` should land on `/api/v1`.
|
|
24
|
+
const sortedRoutes = [...opts.routes]
|
|
25
|
+
.map((r) => ({ prefix: r.prefix, target: r.target }))
|
|
26
|
+
.sort((a, b) => b.prefix.length - a.prefix.length);
|
|
27
|
+
const proxy = httpProxy.createProxyServer({
|
|
28
|
+
xfwd: true,
|
|
29
|
+
ws: true,
|
|
30
|
+
// Preserve the full original path — http-proxy does this by default when
|
|
31
|
+
// we pass `target` without `prependPath`/`ignorePath`. Setting changeOrigin
|
|
32
|
+
// false keeps the Host header pointing at the upstream's address.
|
|
33
|
+
changeOrigin: false,
|
|
34
|
+
});
|
|
35
|
+
proxy.on("error", (err, _req, res) => {
|
|
36
|
+
// `res` can be either an HTTP response or a raw socket (WS upgrade path).
|
|
37
|
+
if (res && "writeHead" in res && typeof res.writeHead === "function") {
|
|
38
|
+
const httpRes = res;
|
|
39
|
+
if (!httpRes.headersSent) {
|
|
40
|
+
httpRes.writeHead(502, { "Content-Type": "text/plain; charset=utf-8" });
|
|
41
|
+
}
|
|
42
|
+
httpRes.end(`Bad gateway: upstream not reachable (${err.message})`);
|
|
43
|
+
}
|
|
44
|
+
else if (res && "destroy" in res && typeof res.destroy === "function") {
|
|
45
|
+
res.destroy();
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
// Track open sockets so close() can force-destroy them — mirrors the
|
|
49
|
+
// shutdown discipline in src/auth.ts. server.close() alone waits for
|
|
50
|
+
// keep-alive sockets to drain, which hangs the CLI on SIGINT.
|
|
51
|
+
const sockets = new Set();
|
|
52
|
+
const server = http.createServer((req, res) => {
|
|
53
|
+
const target = resolveRoute(req.url, sortedRoutes, primaryTarget);
|
|
54
|
+
proxy.web(req, res, { target });
|
|
55
|
+
});
|
|
56
|
+
server.on("upgrade", (req, socket, head) => {
|
|
57
|
+
const target = resolveRoute(req.url, sortedRoutes, primaryTarget);
|
|
58
|
+
proxy.ws(req, socket, head, { target });
|
|
59
|
+
});
|
|
60
|
+
server.on("connection", (socket) => {
|
|
61
|
+
sockets.add(socket);
|
|
62
|
+
socket.on("close", () => sockets.delete(socket));
|
|
63
|
+
});
|
|
64
|
+
server.on("error", reject);
|
|
65
|
+
server.listen(0, "127.0.0.1", () => {
|
|
66
|
+
const addr = server.address();
|
|
67
|
+
if (!addr || typeof addr === "string") {
|
|
68
|
+
reject(new Error("Failed to bind reverse proxy"));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
resolve({
|
|
72
|
+
port: addr.port,
|
|
73
|
+
close: () => new Promise((resolveClose) => {
|
|
74
|
+
// Stop accepting new connections, then force-destroy anything still
|
|
75
|
+
// open. closeAllConnections + the manual socket sweep is what makes
|
|
76
|
+
// shutdown reliable on macOS (see auth.ts comment).
|
|
77
|
+
server.close(() => resolveClose());
|
|
78
|
+
server.closeAllConnections?.();
|
|
79
|
+
for (const socket of sockets)
|
|
80
|
+
socket.destroy();
|
|
81
|
+
sockets.clear();
|
|
82
|
+
proxy.close();
|
|
83
|
+
}),
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke test for the reverse-proxy module. Spins up two mock HTTP servers,
|
|
3
|
+
* routes through the proxy, and asserts paths land on the right upstream
|
|
4
|
+
* with the full path preserved. Also verifies a raw WebSocket upgrade
|
|
5
|
+
* routes via the prefix rules.
|
|
6
|
+
*
|
|
7
|
+
* Compiled to dist/lib/reverse-proxy.test.js and runnable with:
|
|
8
|
+
* node --test dist/lib/reverse-proxy.test.js
|
|
9
|
+
*/
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke test for the reverse-proxy module. Spins up two mock HTTP servers,
|
|
3
|
+
* routes through the proxy, and asserts paths land on the right upstream
|
|
4
|
+
* with the full path preserved. Also verifies a raw WebSocket upgrade
|
|
5
|
+
* routes via the prefix rules.
|
|
6
|
+
*
|
|
7
|
+
* Compiled to dist/lib/reverse-proxy.test.js and runnable with:
|
|
8
|
+
* node --test dist/lib/reverse-proxy.test.js
|
|
9
|
+
*/
|
|
10
|
+
import { test } from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import http from "node:http";
|
|
13
|
+
import { startReverseProxy } from "./reverse-proxy.js";
|
|
14
|
+
function startMockServer(name) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const hits = [];
|
|
17
|
+
const sockets = new Set();
|
|
18
|
+
const server = http.createServer((req, res) => {
|
|
19
|
+
hits.push({ url: req.url ?? "", upgrade: false });
|
|
20
|
+
res.writeHead(200, { "Content-Type": "text/plain", "X-Mock-Name": name });
|
|
21
|
+
res.end(`${name}:${req.url}`);
|
|
22
|
+
});
|
|
23
|
+
server.on("connection", (socket) => {
|
|
24
|
+
sockets.add(socket);
|
|
25
|
+
socket.on("close", () => sockets.delete(socket));
|
|
26
|
+
});
|
|
27
|
+
server.on("upgrade", (req, socket) => {
|
|
28
|
+
hits.push({ url: req.url ?? "", upgrade: true });
|
|
29
|
+
sockets.add(socket);
|
|
30
|
+
socket.on("close", () => sockets.delete(socket));
|
|
31
|
+
// Minimal handshake: accept the upgrade with a static accept token so we
|
|
32
|
+
// don't pull in the `ws` library just for the test.
|
|
33
|
+
const acceptKey = req.headers["sec-websocket-key"];
|
|
34
|
+
socket.write("HTTP/1.1 101 Switching Protocols\r\n" +
|
|
35
|
+
"Upgrade: websocket\r\n" +
|
|
36
|
+
"Connection: Upgrade\r\n" +
|
|
37
|
+
`Sec-WebSocket-Accept: ${acceptKey ?? "x"}\r\n` +
|
|
38
|
+
`X-Mock-Name: ${name}\r\n\r\n`);
|
|
39
|
+
});
|
|
40
|
+
server.on("error", reject);
|
|
41
|
+
server.listen(0, "127.0.0.1", () => {
|
|
42
|
+
const addr = server.address();
|
|
43
|
+
resolve({
|
|
44
|
+
port: addr.port,
|
|
45
|
+
hits,
|
|
46
|
+
close: () => new Promise((r) => {
|
|
47
|
+
server.closeAllConnections?.();
|
|
48
|
+
for (const s of sockets)
|
|
49
|
+
s.destroy();
|
|
50
|
+
sockets.clear();
|
|
51
|
+
server.close(() => r());
|
|
52
|
+
server.unref();
|
|
53
|
+
}),
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
test("reverse-proxy routes by prefix and preserves the full path", async () => {
|
|
59
|
+
const primary = await startMockServer("primary");
|
|
60
|
+
const api = await startMockServer("api");
|
|
61
|
+
const proxy = await startReverseProxy({
|
|
62
|
+
primaryPort: primary.port,
|
|
63
|
+
routes: [{ prefix: "/api", target: `http://127.0.0.1:${api.port}` }],
|
|
64
|
+
});
|
|
65
|
+
try {
|
|
66
|
+
const root = await fetch(`http://127.0.0.1:${proxy.port}/`);
|
|
67
|
+
assert.equal(root.status, 200);
|
|
68
|
+
assert.equal(root.headers.get("x-mock-name"), "primary");
|
|
69
|
+
assert.equal(await root.text(), "primary:/");
|
|
70
|
+
assert.equal(primary.hits.at(-1)?.url, "/");
|
|
71
|
+
const apiHit = await fetch(`http://127.0.0.1:${proxy.port}/api/health`);
|
|
72
|
+
assert.equal(apiHit.status, 200);
|
|
73
|
+
assert.equal(apiHit.headers.get("x-mock-name"), "api");
|
|
74
|
+
// Full path preserved — the upstream sees `/api/health`, NOT `/health`.
|
|
75
|
+
assert.equal(await apiHit.text(), "api:/api/health");
|
|
76
|
+
assert.equal(api.hits.at(-1)?.url, "/api/health");
|
|
77
|
+
// Non-matching path that just happens to start with the prefix letters
|
|
78
|
+
// must fall through to primary (segment-boundary match, not substring).
|
|
79
|
+
const apiary = await fetch(`http://127.0.0.1:${proxy.port}/apiary`);
|
|
80
|
+
assert.equal(apiary.headers.get("x-mock-name"), "primary");
|
|
81
|
+
const deep = await fetch(`http://127.0.0.1:${proxy.port}/api/v1/users`);
|
|
82
|
+
assert.equal(deep.headers.get("x-mock-name"), "api");
|
|
83
|
+
assert.equal(await deep.text(), "api:/api/v1/users");
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
await proxy.close();
|
|
87
|
+
await primary.close();
|
|
88
|
+
await api.close();
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
test("reverse-proxy routes WebSocket upgrades by prefix", async () => {
|
|
92
|
+
const primary = await startMockServer("primary");
|
|
93
|
+
const api = await startMockServer("api");
|
|
94
|
+
const proxy = await startReverseProxy({
|
|
95
|
+
primaryPort: primary.port,
|
|
96
|
+
routes: [{ prefix: "/api", target: `http://127.0.0.1:${api.port}` }],
|
|
97
|
+
});
|
|
98
|
+
try {
|
|
99
|
+
const status = await new Promise((resolve, reject) => {
|
|
100
|
+
const req = http.request({
|
|
101
|
+
host: "127.0.0.1",
|
|
102
|
+
port: proxy.port,
|
|
103
|
+
path: "/api/ws",
|
|
104
|
+
method: "GET",
|
|
105
|
+
headers: {
|
|
106
|
+
Connection: "Upgrade",
|
|
107
|
+
Upgrade: "websocket",
|
|
108
|
+
"Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==",
|
|
109
|
+
"Sec-WebSocket-Version": "13",
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
req.on("upgrade", (res, socket) => {
|
|
113
|
+
resolve({
|
|
114
|
+
statusLine: `HTTP/1.1 ${res.statusCode} ${res.statusMessage}`,
|
|
115
|
+
mockName: typeof res.headers["x-mock-name"] === "string"
|
|
116
|
+
? res.headers["x-mock-name"]
|
|
117
|
+
: undefined,
|
|
118
|
+
});
|
|
119
|
+
socket.destroy();
|
|
120
|
+
});
|
|
121
|
+
req.on("error", reject);
|
|
122
|
+
req.end();
|
|
123
|
+
});
|
|
124
|
+
assert.match(status.statusLine, /^HTTP\/1\.1 101/);
|
|
125
|
+
assert.equal(status.mockName, "api");
|
|
126
|
+
assert.ok(api.hits.some((h) => h.upgrade && h.url === "/api/ws"));
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
await proxy.close();
|
|
130
|
+
await primary.close();
|
|
131
|
+
await api.close();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
test("reverse-proxy returns 502 when upstream is down", async () => {
|
|
135
|
+
// No primary mock — pick an arbitrary port nothing is bound on.
|
|
136
|
+
const proxy = await startReverseProxy({
|
|
137
|
+
primaryPort: 1, // privileged, definitely not listening to our process
|
|
138
|
+
routes: [],
|
|
139
|
+
});
|
|
140
|
+
try {
|
|
141
|
+
const res = await fetch(`http://127.0.0.1:${proxy.port}/whatever`);
|
|
142
|
+
assert.equal(res.status, 502);
|
|
143
|
+
const body = await res.text();
|
|
144
|
+
assert.match(body, /Bad gateway/i);
|
|
145
|
+
}
|
|
146
|
+
finally {
|
|
147
|
+
await proxy.close();
|
|
148
|
+
}
|
|
149
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation + nudge for media/text `segmentation` (the parsed value of
|
|
3
|
+
* `--segmentation-json` on `study create` / `iteration create`).
|
|
4
|
+
*
|
|
5
|
+
* THE PRINCIPLE these guard: **segments are semantic sections, not
|
|
6
|
+
* paragraphs.** Group related paragraphs into a few coherent sections
|
|
7
|
+
* (intro → argument → conclusion). A long article is usually 3–6 sections,
|
|
8
|
+
* not one per paragraph; `paragraph_start`/`paragraph_end` only mark where a
|
|
9
|
+
* section begins and ends — the unit is the *section*.
|
|
10
|
+
*
|
|
11
|
+
* - `validateSegmentation` is FATAL (throws ValidationError → exit 2) on a
|
|
12
|
+
* malformed `section_based` shape — most importantly a missing/empty label,
|
|
13
|
+
* which the backend would otherwise reject after a network round-trip.
|
|
14
|
+
* - `warnIfOverSegmented` is NON-FATAL: an agent that ignores the docs and
|
|
15
|
+
* emits one section per paragraph gets a stderr nudge, but is never blocked
|
|
16
|
+
* (over-segmenting can be intentional).
|
|
17
|
+
*
|
|
18
|
+
* Both take the already-JSON-parsed object; `undefined` is a no-op.
|
|
19
|
+
*/
|
|
20
|
+
/** Throw on a malformed segmentation shape. No-op for undefined / unknown types. */
|
|
21
|
+
export declare function validateSegmentation(seg: unknown): void;
|
|
22
|
+
/**
|
|
23
|
+
* Non-fatal nudge toward semantic sections. Conservative on purpose: only
|
|
24
|
+
* fires for `section_based` with >= 5 sections that EACH span a single
|
|
25
|
+
* paragraph — the signature of one-section-per-paragraph — so a genuine
|
|
26
|
+
* 3-section piece never trips it. stderr only (keeps --json stdout clean);
|
|
27
|
+
* suppressed under --quiet.
|
|
28
|
+
*/
|
|
29
|
+
export declare function warnIfOverSegmented(seg: unknown, opts?: {
|
|
30
|
+
quiet?: boolean;
|
|
31
|
+
}): void;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation + nudge for media/text `segmentation` (the parsed value of
|
|
3
|
+
* `--segmentation-json` on `study create` / `iteration create`).
|
|
4
|
+
*
|
|
5
|
+
* THE PRINCIPLE these guard: **segments are semantic sections, not
|
|
6
|
+
* paragraphs.** Group related paragraphs into a few coherent sections
|
|
7
|
+
* (intro → argument → conclusion). A long article is usually 3–6 sections,
|
|
8
|
+
* not one per paragraph; `paragraph_start`/`paragraph_end` only mark where a
|
|
9
|
+
* section begins and ends — the unit is the *section*.
|
|
10
|
+
*
|
|
11
|
+
* - `validateSegmentation` is FATAL (throws ValidationError → exit 2) on a
|
|
12
|
+
* malformed `section_based` shape — most importantly a missing/empty label,
|
|
13
|
+
* which the backend would otherwise reject after a network round-trip.
|
|
14
|
+
* - `warnIfOverSegmented` is NON-FATAL: an agent that ignores the docs and
|
|
15
|
+
* emits one section per paragraph gets a stderr nudge, but is never blocked
|
|
16
|
+
* (over-segmenting can be intentional).
|
|
17
|
+
*
|
|
18
|
+
* Both take the already-JSON-parsed object; `undefined` is a no-op.
|
|
19
|
+
*/
|
|
20
|
+
import { writeSync } from "node:fs";
|
|
21
|
+
import { c } from "./colors.js";
|
|
22
|
+
import { ValidationError } from "./output.js";
|
|
23
|
+
/** Throw on a malformed segmentation shape. No-op for undefined / unknown types. */
|
|
24
|
+
export function validateSegmentation(seg) {
|
|
25
|
+
if (!seg || typeof seg !== "object")
|
|
26
|
+
return;
|
|
27
|
+
const s = seg;
|
|
28
|
+
if (s.type === "section_based") {
|
|
29
|
+
const sections = s.sections;
|
|
30
|
+
if (!Array.isArray(sections) || sections.length === 0) {
|
|
31
|
+
throw new ValidationError("section_based segmentation needs a non-empty `sections` array.", [], "Group related paragraphs into a few semantic sections (intro, argument, conclusion) — not one per paragraph.");
|
|
32
|
+
}
|
|
33
|
+
sections.forEach((raw, i) => {
|
|
34
|
+
const sec = (raw ?? {});
|
|
35
|
+
const name = typeof sec.name === "string" ? sec.name.trim() : "";
|
|
36
|
+
const label = typeof sec.label === "string" ? sec.label.trim() : "";
|
|
37
|
+
if (!name) {
|
|
38
|
+
throw new ValidationError(`section_based sections[${i}] is missing a non-empty \`name\`.`, []);
|
|
39
|
+
}
|
|
40
|
+
if (!label) {
|
|
41
|
+
throw new ValidationError(`section_based sections[${i}] ("${name}") is missing a non-empty \`label\`. ` +
|
|
42
|
+
"Every section needs a human-readable label — it surfaces in the participant UI and in results.", []);
|
|
43
|
+
}
|
|
44
|
+
// Paragraph-bounded sections: validate the range when present. (A
|
|
45
|
+
// marker-bounded section_based variant may omit these — don't require.)
|
|
46
|
+
const start = sec.paragraph_start;
|
|
47
|
+
const end = sec.paragraph_end;
|
|
48
|
+
if (start !== undefined || end !== undefined) {
|
|
49
|
+
if (typeof start !== "number" || typeof end !== "number" || start < 0 || end <= start) {
|
|
50
|
+
throw new ValidationError(`section_based sections[${i}] ("${name}") has an invalid paragraph range ` +
|
|
51
|
+
`(paragraph_start=${String(start)}, paragraph_end=${String(end)}). ` +
|
|
52
|
+
"Need paragraph_start >= 0 and paragraph_end > paragraph_start.", []);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (s.type === "time_based") {
|
|
59
|
+
const iv = s.intervals_seconds;
|
|
60
|
+
if (Array.isArray(iv)) {
|
|
61
|
+
for (let i = 1; i < iv.length; i++) {
|
|
62
|
+
const prev = iv[i - 1];
|
|
63
|
+
const cur = iv[i];
|
|
64
|
+
if (typeof prev !== "number" || typeof cur !== "number" || cur <= prev) {
|
|
65
|
+
throw new ValidationError(`time_based intervals_seconds must be strictly ascending numbers ` +
|
|
66
|
+
`(problem at index ${i}: ${String(prev)} → ${String(cur)}).`, []);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Non-fatal nudge toward semantic sections. Conservative on purpose: only
|
|
74
|
+
* fires for `section_based` with >= 5 sections that EACH span a single
|
|
75
|
+
* paragraph — the signature of one-section-per-paragraph — so a genuine
|
|
76
|
+
* 3-section piece never trips it. stderr only (keeps --json stdout clean);
|
|
77
|
+
* suppressed under --quiet.
|
|
78
|
+
*/
|
|
79
|
+
export function warnIfOverSegmented(seg, opts = {}) {
|
|
80
|
+
if (opts.quiet)
|
|
81
|
+
return;
|
|
82
|
+
if (!seg || typeof seg !== "object")
|
|
83
|
+
return;
|
|
84
|
+
const s = seg;
|
|
85
|
+
if (s.type !== "section_based" || !Array.isArray(s.sections))
|
|
86
|
+
return;
|
|
87
|
+
const sections = s.sections;
|
|
88
|
+
if (sections.length < 5)
|
|
89
|
+
return;
|
|
90
|
+
const allSingleParagraph = sections.every((sec) => {
|
|
91
|
+
const start = sec?.paragraph_start;
|
|
92
|
+
const end = sec?.paragraph_end;
|
|
93
|
+
return typeof start === "number" && typeof end === "number" && end - start <= 1;
|
|
94
|
+
});
|
|
95
|
+
if (!allSingleParagraph)
|
|
96
|
+
return;
|
|
97
|
+
// Synchronous fd-2 write, not console.error: this fires moments before the
|
|
98
|
+
// command's own output + a process.exit (via exitWithFlush), which truncates
|
|
99
|
+
// async-buffered stderr writes to a pipe/file. writeSync guarantees the nudge
|
|
100
|
+
// lands.
|
|
101
|
+
writeSync(2, `${c.yellow}⚠ ${sections.length} single-paragraph sections.${c.reset} ` +
|
|
102
|
+
"Segments are meant to be semantic sections — group related paragraphs into a few " +
|
|
103
|
+
"coherent sections (e.g. intro → argument → conclusion), not one per paragraph. " +
|
|
104
|
+
"A long article is usually 3–6 sections. Proceeding as-is.\n");
|
|
105
|
+
}
|