paseo-req-kit 1.12.1 → 1.13.0-beta.2
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/CHANGELOG.md +33 -0
- package/README.md +8 -3
- package/bin/bootstrap.d.mts +13 -0
- package/bin/bootstrap.mjs +444 -0
- package/bin/req-docs.d.mts +5 -0
- package/bin/req-docs.mjs +169 -0
- package/bin/req.mjs +21 -1
- package/contracts/cli-json.md +2 -0
- package/docs/adr/0032-npm-only-install.md +27 -0
- package/docs/adr/0033-prototype-review-and-ui-check.md +28 -0
- package/docs/adr/README.md +2 -0
- package/docs/guide//344/275/277/347/224/250/350/200/205/346/214/207/345/215/227.md +8 -0
- package/docs/guide//345/256/211/350/243/205/344/270/216/345/215/207/347/272/247.md +22 -2
- package/docs/guide//347/273/264/346/212/244/350/200/205/346/214/207/345/215/227.md +1 -1
- package/docs/guide//351/241/271/347/233/256/345/256/232/345/210/266/344/270/216/350/277/220/350/241/214/350/247/204/345/210/231.md +7 -1
- package/engine/prompts/03-design.md +5 -1
- package/engine/prompts/04b-design-revise.md +1 -1
- package/engine/prompts/browser-verify.md +2 -2
- package/engine/prompts/ui-check.md +15 -0
- package/engine/src/api.ts +17 -2
- package/engine/src/config-view.ts +1 -1
- package/engine/src/pipeline.ts +15 -7
- package/engine/src/review-assets/app.css +420 -0
- package/engine/src/review-assets/app.html +95 -0
- package/engine/src/review-assets/app.js +954 -0
- package/engine/src/review.ts +570 -0
- package/engine/src/ui-check-page.js +208 -0
- package/engine/src/ui-check.ts +366 -0
- package/package.json +3 -1
- package/paseo-plugin/client/data.ts +12 -1
- package/paseo-plugin/client/detail.tsx +3 -0
- package/paseo-plugin/client/inbox.tsx +2 -0
- package/paseo-plugin/client/review-link.tsx +29 -0
- package/paseo-plugin/index.server.ts +2 -1
- package/paseo-plugin/package.json +1 -1
- package/paseo-plugin/server/handlers.ts +7 -1
- package/paseo-plugin/shared/contracts.ts +9 -0
- package/site-dist/404.html +27 -0
- package/site-dist/assets/app.DWPPPhFm.js +1 -0
- package/site-dist/assets/changelog.md.CoelFFCL.js +1 -0
- package/site-dist/assets/changelog.md.CoelFFCL.lean.js +1 -0
- package/site-dist/assets/chunks/@localSearchIndexroot.Hdq0-4L3.js +1 -0
- package/site-dist/assets/chunks/VPLocalSearchBox.D3_9dRGY.js +9 -0
- package/site-dist/assets/chunks/framework.B9qR_F1m.js +20 -0
- package/site-dist/assets/chunks/theme.Bm8aLiLN.js +2 -0
- package/site-dist/assets/guide_getting-started.md.B4GJeoPz.js +6 -0
- package/site-dist/assets/guide_getting-started.md.B4GJeoPz.lean.js +1 -0
- package/site-dist/assets/guide_how-it-works.md.D-kQsF1t.js +25 -0
- package/site-dist/assets/guide_how-it-works.md.D-kQsF1t.lean.js +1 -0
- package/site-dist/assets/guide_index.md.CLnGCmGW.js +1 -0
- package/site-dist/assets/guide_index.md.CLnGCmGW.lean.js +1 -0
- package/site-dist/assets/guide_install.md.DkCyZsOU.js +42 -0
- package/site-dist/assets/guide_install.md.DkCyZsOU.lean.js +42 -0
- package/site-dist/assets/guide_maintainer.md.RZIzyCTV.js +1 -0
- package/site-dist/assets/guide_maintainer.md.RZIzyCTV.lean.js +1 -0
- package/site-dist/assets/guide_phases.md.BpGAb1re.js +1 -0
- package/site-dist/assets/guide_phases.md.BpGAb1re.lean.js +1 -0
- package/site-dist/assets/guide_project.md.nGlTCo4D.js +77 -0
- package/site-dist/assets/guide_project.md.nGlTCo4D.lean.js +77 -0
- package/site-dist/assets/guide_user.md.tiHFxIsQ.js +1 -0
- package/site-dist/assets/guide_user.md.tiHFxIsQ.lean.js +1 -0
- package/site-dist/assets/index.md.Cf_sSf_f.js +1 -0
- package/site-dist/assets/index.md.Cf_sSf_f.lean.js +1 -0
- package/site-dist/assets/inter-italic-cyrillic-ext.r48I6akx.woff2 +0 -0
- package/site-dist/assets/inter-italic-cyrillic.By2_1cv3.woff2 +0 -0
- package/site-dist/assets/inter-italic-greek-ext.1u6EdAuj.woff2 +0 -0
- package/site-dist/assets/inter-italic-greek.DJ8dCoTZ.woff2 +0 -0
- package/site-dist/assets/inter-italic-latin-ext.CN1xVJS-.woff2 +0 -0
- package/site-dist/assets/inter-italic-latin.C2AdPX0b.woff2 +0 -0
- package/site-dist/assets/inter-italic-vietnamese.BSbpV94h.woff2 +0 -0
- package/site-dist/assets/inter-roman-cyrillic-ext.BBPuwvHQ.woff2 +0 -0
- package/site-dist/assets/inter-roman-cyrillic.C5lxZ8CY.woff2 +0 -0
- package/site-dist/assets/inter-roman-greek-ext.CqjqNYQ-.woff2 +0 -0
- package/site-dist/assets/inter-roman-greek.BBVDIX6e.woff2 +0 -0
- package/site-dist/assets/inter-roman-latin-ext.4ZJIpNVo.woff2 +0 -0
- package/site-dist/assets/inter-roman-latin.Di8DUHzh.woff2 +0 -0
- package/site-dist/assets/inter-roman-vietnamese.BjW4sHH5.woff2 +0 -0
- package/site-dist/assets/reference_adr_0001-step-agents-under-origin-chat.md.RtRTq7oi.js +1 -0
- package/site-dist/assets/reference_adr_0001-step-agents-under-origin-chat.md.RtRTq7oi.lean.js +1 -0
- package/site-dist/assets/reference_adr_0002-no-directory-switch-in-chat.md.BxtN-Aa2.js +1 -0
- package/site-dist/assets/reference_adr_0002-no-directory-switch-in-chat.md.BxtN-Aa2.lean.js +1 -0
- package/site-dist/assets/reference_adr_0003-script-engine-not-hub.md.66EgZtqx.js +1 -0
- package/site-dist/assets/reference_adr_0003-script-engine-not-hub.md.66EgZtqx.lean.js +1 -0
- package/site-dist/assets/reference_adr_0004-result-files.md.DbqcCozI.js +1 -0
- package/site-dist/assets/reference_adr_0004-result-files.md.DbqcCozI.lean.js +1 -0
- package/site-dist/assets/reference_adr_0005-no-default-branch-local-commits-only.md.CkImA4s6.js +1 -0
- package/site-dist/assets/reference_adr_0005-no-default-branch-local-commits-only.md.CkImA4s6.lean.js +1 -0
- package/site-dist/assets/reference_adr_0006-skill-install-targets.md.CwY8YepV.js +1 -0
- package/site-dist/assets/reference_adr_0006-skill-install-targets.md.CwY8YepV.lean.js +1 -0
- package/site-dist/assets/reference_adr_0007-own-interview-skill.md.CryhxRyG.js +1 -0
- package/site-dist/assets/reference_adr_0007-own-interview-skill.md.CryhxRyG.lean.js +1 -0
- package/site-dist/assets/reference_adr_0008-entries-do-not-call-entries.md.DsRWAgeA.js +1 -0
- package/site-dist/assets/reference_adr_0008-entries-do-not-call-entries.md.DsRWAgeA.lean.js +1 -0
- package/site-dist/assets/reference_adr_0009-versioned-install-layout.md.CSg1VqlW.js +1 -0
- package/site-dist/assets/reference_adr_0009-versioned-install-layout.md.CSg1VqlW.lean.js +1 -0
- package/site-dist/assets/reference_adr_0010-generated-feature-overview.md.D8F3MK-h.js +1 -0
- package/site-dist/assets/reference_adr_0010-generated-feature-overview.md.D8F3MK-h.lean.js +1 -0
- package/site-dist/assets/reference_adr_0011-resume-after-completion.md.BCrZrKjf.js +1 -0
- package/site-dist/assets/reference_adr_0011-resume-after-completion.md.BCrZrKjf.lean.js +1 -0
- package/site-dist/assets/reference_adr_0012-review-panel.md.k9rrHFFc.js +1 -0
- package/site-dist/assets/reference_adr_0012-review-panel.md.k9rrHFFc.lean.js +1 -0
- package/site-dist/assets/reference_adr_0013-no-release-tooling.md.BQeJ8dEI.js +1 -0
- package/site-dist/assets/reference_adr_0013-no-release-tooling.md.BQeJ8dEI.lean.js +1 -0
- package/site-dist/assets/reference_adr_0014-notifier-slot.md.C23QR1xO.js +1 -0
- package/site-dist/assets/reference_adr_0014-notifier-slot.md.C23QR1xO.lean.js +1 -0
- package/site-dist/assets/reference_adr_0015-incremental-continue.md.Buy7wwO8.js +1 -0
- package/site-dist/assets/reference_adr_0015-incremental-continue.md.Buy7wwO8.lean.js +1 -0
- package/site-dist/assets/reference_adr_0016-upgrade-from-tags.md.qfIW-tqJ.js +1 -0
- package/site-dist/assets/reference_adr_0016-upgrade-from-tags.md.qfIW-tqJ.lean.js +1 -0
- package/site-dist/assets/reference_adr_0017-use-paseo-capabilities.md.CjcGli87.js +1 -0
- package/site-dist/assets/reference_adr_0017-use-paseo-capabilities.md.CjcGli87.lean.js +1 -0
- package/site-dist/assets/reference_adr_0018-own-worktree-option.md.fVDpgGSE.js +1 -0
- package/site-dist/assets/reference_adr_0018-own-worktree-option.md.fVDpgGSE.lean.js +1 -0
- package/site-dist/assets/reference_adr_0019-parallel-orchestration.md.DrfrV0To.js +1 -0
- package/site-dist/assets/reference_adr_0019-parallel-orchestration.md.DrfrV0To.lean.js +1 -0
- package/site-dist/assets/reference_adr_0020-scheduling-through-the-chat.md.BqSemGFG.js +1 -0
- package/site-dist/assets/reference_adr_0020-scheduling-through-the-chat.md.BqSemGFG.lean.js +1 -0
- package/site-dist/assets/reference_adr_0021-npm-version-manager.md.CU3PG6y5.js +1 -0
- package/site-dist/assets/reference_adr_0021-npm-version-manager.md.CU3PG6y5.lean.js +1 -0
- package/site-dist/assets/reference_adr_0022-develop-and-verify-phases.md.C6fQDLq4.js +1 -0
- package/site-dist/assets/reference_adr_0022-develop-and-verify-phases.md.C6fQDLq4.lean.js +1 -0
- package/site-dist/assets/reference_adr_0023-types-gate-slices.md.D2E6yyYQ.js +1 -0
- package/site-dist/assets/reference_adr_0023-types-gate-slices.md.D2E6yyYQ.lean.js +1 -0
- package/site-dist/assets/reference_adr_0024-confirm-early-review-axes.md.FmK_ke0x.js +1 -0
- package/site-dist/assets/reference_adr_0024-confirm-early-review-axes.md.FmK_ke0x.lean.js +1 -0
- package/site-dist/assets/reference_adr_0025-joint-requirements.md.ChoKvMUx.js +1 -0
- package/site-dist/assets/reference_adr_0025-joint-requirements.md.ChoKvMUx.lean.js +1 -0
- package/site-dist/assets/reference_adr_0026-machine-records-and-api.md.veybjBPq.js +1 -0
- package/site-dist/assets/reference_adr_0026-machine-records-and-api.md.veybjBPq.lean.js +1 -0
- package/site-dist/assets/reference_adr_0027-paseo-plugin.md.BMX33ZNU.js +1 -0
- package/site-dist/assets/reference_adr_0027-paseo-plugin.md.BMX33ZNU.lean.js +1 -0
- package/site-dist/assets/reference_adr_0028-external-integrations.md.DtdMe8RK.js +1 -0
- package/site-dist/assets/reference_adr_0028-external-integrations.md.DtdMe8RK.lean.js +1 -0
- package/site-dist/assets/reference_adr_0029-requirements-from-cards.md.BHd1SQPe.js +1 -0
- package/site-dist/assets/reference_adr_0029-requirements-from-cards.md.BHd1SQPe.lean.js +1 -0
- package/site-dist/assets/reference_adr_0030-card-intake-process.md.kLc-DG9_.js +1 -0
- package/site-dist/assets/reference_adr_0030-card-intake-process.md.kLc-DG9_.lean.js +1 -0
- package/site-dist/assets/reference_adr_0031-fewer-interventions.md.BDnQcz8g.js +1 -0
- package/site-dist/assets/reference_adr_0031-fewer-interventions.md.BDnQcz8g.lean.js +1 -0
- package/site-dist/assets/reference_adr_0032-npm-only-install.md.C-3L1FkX.js +1 -0
- package/site-dist/assets/reference_adr_0032-npm-only-install.md.C-3L1FkX.lean.js +1 -0
- package/site-dist/assets/reference_adr_0033-prototype-review-and-ui-check.md.hFcQzmaa.js +1 -0
- package/site-dist/assets/reference_adr_0033-prototype-review-and-ui-check.md.hFcQzmaa.lean.js +1 -0
- package/site-dist/assets/reference_adr_index.md.BYF-Ofor.js +1 -0
- package/site-dist/assets/reference_adr_index.md.BYF-Ofor.lean.js +1 -0
- package/site-dist/assets/reference_commands.md.ClKbS-t8.js +1 -0
- package/site-dist/assets/reference_commands.md.ClKbS-t8.lean.js +1 -0
- package/site-dist/assets/reference_config.md.CRe27LaA.js +6 -0
- package/site-dist/assets/reference_config.md.CRe27LaA.lean.js +1 -0
- package/site-dist/assets/reference_contracts_cli-json.md.N-v2HNUd.js +1 -0
- package/site-dist/assets/reference_contracts_cli-json.md.N-v2HNUd.lean.js +1 -0
- package/site-dist/assets/reference_contracts_feature-files.md.DSiA8OEH.js +26 -0
- package/site-dist/assets/reference_contracts_feature-files.md.DSiA8OEH.lean.js +26 -0
- package/site-dist/assets/reference_contracts_index.md.Bkznz4px.js +1 -0
- package/site-dist/assets/reference_contracts_index.md.Bkznz4px.lean.js +1 -0
- package/site-dist/assets/reference_contracts_notifier-event.md.CCfrkm_A.js +24 -0
- package/site-dist/assets/reference_contracts_notifier-event.md.CCfrkm_A.lean.js +24 -0
- package/site-dist/assets/reference_contracts_result-file.md.DmR4fjB-.js +1 -0
- package/site-dist/assets/reference_contracts_result-file.md.DmR4fjB-.lean.js +1 -0
- package/site-dist/assets/reference_contracts_spec.md.BwwS0L_j.js +8 -0
- package/site-dist/assets/reference_contracts_spec.md.BwwS0L_j.lean.js +8 -0
- package/site-dist/assets/reference_contracts_state.md.5bNTC3ow.js +1 -0
- package/site-dist/assets/reference_contracts_state.md.5bNTC3ow.lean.js +1 -0
- package/site-dist/assets/reference_contracts_usage-record.md._C0OXWeq.js +10 -0
- package/site-dist/assets/reference_contracts_usage-record.md._C0OXWeq.lean.js +10 -0
- package/site-dist/assets/reference_contracts_verify.md.CbA88u6S.js +29 -0
- package/site-dist/assets/reference_contracts_verify.md.CbA88u6S.lean.js +29 -0
- package/site-dist/assets/reference_scope.md.Kzqw8U_f.js +1 -0
- package/site-dist/assets/reference_scope.md.Kzqw8U_f.lean.js +1 -0
- package/site-dist/assets/style.Dwsa4SMe.css +1 -0
- package/site-dist/changelog.html +30 -0
- package/site-dist/guide/getting-started.html +35 -0
- package/site-dist/guide/how-it-works.html +54 -0
- package/site-dist/guide/index.html +30 -0
- package/site-dist/guide/install.html +71 -0
- package/site-dist/guide/maintainer.html +30 -0
- package/site-dist/guide/phases.html +30 -0
- package/site-dist/guide/project.html +106 -0
- package/site-dist/guide/user.html +30 -0
- package/site-dist/hashmap.json +1 -0
- package/site-dist/icons/agents.svg +1 -0
- package/site-dist/icons/chat.svg +1 -0
- package/site-dist/icons/paseo.svg +1 -0
- package/site-dist/icons/phases.svg +1 -0
- package/site-dist/icons/review.svg +1 -0
- package/site-dist/icons/safe.svg +1 -0
- package/site-dist/index.html +30 -0
- package/site-dist/install.ps1 +118 -0
- package/site-dist/install.sh +198 -0
- package/site-dist/logo.svg +11 -0
- package/site-dist/reference/adr/0001-step-agents-under-origin-chat.html +30 -0
- package/site-dist/reference/adr/0002-no-directory-switch-in-chat.html +30 -0
- package/site-dist/reference/adr/0003-script-engine-not-hub.html +30 -0
- package/site-dist/reference/adr/0004-result-files.html +30 -0
- package/site-dist/reference/adr/0005-no-default-branch-local-commits-only.html +30 -0
- package/site-dist/reference/adr/0006-skill-install-targets.html +30 -0
- package/site-dist/reference/adr/0007-own-interview-skill.html +30 -0
- package/site-dist/reference/adr/0008-entries-do-not-call-entries.html +30 -0
- package/site-dist/reference/adr/0009-versioned-install-layout.html +30 -0
- package/site-dist/reference/adr/0010-generated-feature-overview.html +30 -0
- package/site-dist/reference/adr/0011-resume-after-completion.html +30 -0
- package/site-dist/reference/adr/0012-review-panel.html +30 -0
- package/site-dist/reference/adr/0013-no-release-tooling.html +30 -0
- package/site-dist/reference/adr/0014-notifier-slot.html +30 -0
- package/site-dist/reference/adr/0015-incremental-continue.html +30 -0
- package/site-dist/reference/adr/0016-upgrade-from-tags.html +30 -0
- package/site-dist/reference/adr/0017-use-paseo-capabilities.html +30 -0
- package/site-dist/reference/adr/0018-own-worktree-option.html +30 -0
- package/site-dist/reference/adr/0019-parallel-orchestration.html +30 -0
- package/site-dist/reference/adr/0020-scheduling-through-the-chat.html +30 -0
- package/site-dist/reference/adr/0021-npm-version-manager.html +30 -0
- package/site-dist/reference/adr/0022-develop-and-verify-phases.html +30 -0
- package/site-dist/reference/adr/0023-types-gate-slices.html +30 -0
- package/site-dist/reference/adr/0024-confirm-early-review-axes.html +30 -0
- package/site-dist/reference/adr/0025-joint-requirements.html +30 -0
- package/site-dist/reference/adr/0026-machine-records-and-api.html +30 -0
- package/site-dist/reference/adr/0027-paseo-plugin.html +30 -0
- package/site-dist/reference/adr/0028-external-integrations.html +30 -0
- package/site-dist/reference/adr/0029-requirements-from-cards.html +30 -0
- package/site-dist/reference/adr/0030-card-intake-process.html +30 -0
- package/site-dist/reference/adr/0031-fewer-interventions.html +30 -0
- package/site-dist/reference/adr/0032-npm-only-install.html +30 -0
- package/site-dist/reference/adr/0033-prototype-review-and-ui-check.html +30 -0
- package/site-dist/reference/adr/index.html +30 -0
- package/site-dist/reference/commands.html +30 -0
- package/site-dist/reference/config.html +35 -0
- package/site-dist/reference/contracts/cli-json.html +30 -0
- package/site-dist/reference/contracts/feature-files.html +55 -0
- package/site-dist/reference/contracts/index.html +30 -0
- package/site-dist/reference/contracts/notifier-event.html +53 -0
- package/site-dist/reference/contracts/result-file.html +30 -0
- package/site-dist/reference/contracts/spec.html +37 -0
- package/site-dist/reference/contracts/state.html +30 -0
- package/site-dist/reference/contracts/usage-record.html +39 -0
- package/site-dist/reference/contracts/verify.html +58 -0
- package/site-dist/reference/scope.html +30 -0
- package/site-dist/vp-icons.css +1 -0
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
// The prototype review page (1.13): a small web server on this machine where the requester
|
|
2
|
+
// looks at the interface prototype of a requirement, clicks on what they want changed (the page
|
|
3
|
+
// records which element, its text, color, size and position, so nobody has to describe it in
|
|
4
|
+
// technical words), edits texts in place, and then sends the list to the requirement's chat or
|
|
5
|
+
// confirms the design. The Paseo plugin links to it; `req review <需求>` opens it from a terminal.
|
|
6
|
+
//
|
|
7
|
+
// One server per machine, started on demand and stopped after a few idle hours. It listens on
|
|
8
|
+
// 127.0.0.1 only and every address carries a random token (a second token gives a read-only
|
|
9
|
+
// page). REQ_KIT_REVIEW_HOST / REQ_KIT_REVIEW_PORT / REQ_KIT_REVIEW_PUBLIC_URL change that, e.g.
|
|
10
|
+
// when people open Paseo's web UI from other computers.
|
|
11
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
12
|
+
import crypto from "node:crypto";
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import http from "node:http";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { ENGINE_DIR, KIT_ROOT, kitHome, kitVersion } from "./locations.js";
|
|
17
|
+
import { readIndex } from "./records.js";
|
|
18
|
+
import { readState, statePath } from "./state.js";
|
|
19
|
+
|
|
20
|
+
const ASSETS = path.join(ENGINE_DIR, "src", "review-assets");
|
|
21
|
+
const DEFAULT_PORT = 6781;
|
|
22
|
+
const IDLE_EXIT_MS = 4 * 60 * 60_000;
|
|
23
|
+
const MAX_BODY = 2_000_000;
|
|
24
|
+
const MAX_MESSAGE = 7_500;
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// What the page shows
|
|
28
|
+
|
|
29
|
+
export interface Criterion {
|
|
30
|
+
id: string;
|
|
31
|
+
text: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The acceptance criteria in spec.md: "AC-1.2 …" or "**AC-1.2**:…", first mention of each. */
|
|
35
|
+
export function specCriteria(spec: string): Criterion[] {
|
|
36
|
+
const out = new Map<string, string>();
|
|
37
|
+
for (const line of spec.split(/\r?\n/)) {
|
|
38
|
+
const m = /\b(AC-\d+(?:\.\d+)*)\b[\s*_`))\]】::-]*(.*)$/.exec(line);
|
|
39
|
+
if (!m || out.has(m[1])) continue;
|
|
40
|
+
out.set(m[1], m[2].replace(/[*`_]/g, "").replace(/^[\s|::-]+/, "").replace(/[\s|]+$/, "").trim().slice(0, 200));
|
|
41
|
+
}
|
|
42
|
+
return [...out.entries()].map(([id, text]) => ({ id, text }));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function specTitle(spec: string, fallback: string): string {
|
|
46
|
+
const h = /^#\s+(.+)$/m.exec(spec);
|
|
47
|
+
return (h?.[1] ?? fallback).replace(/^需求说明[::\s]*/, "").trim() || fallback;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The HTML pages of a prototype directory (relative, "/"-separated), with their <title>. */
|
|
51
|
+
export function prototypeFiles(dir: string): Array<{ path: string; title: string }> {
|
|
52
|
+
const out: Array<{ path: string; title: string }> = [];
|
|
53
|
+
const walk = (d: string) => {
|
|
54
|
+
let entries: fs.Dirent[] = [];
|
|
55
|
+
try {
|
|
56
|
+
entries = fs.readdirSync(d, { withFileTypes: true });
|
|
57
|
+
} catch {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
61
|
+
const full = path.join(d, e.name);
|
|
62
|
+
if (e.isDirectory()) walk(full);
|
|
63
|
+
else if (/\.html?$/i.test(e.name)) {
|
|
64
|
+
const rel = path.relative(dir, full).split(path.sep).join("/");
|
|
65
|
+
const title = /<title>([^<]*)<\/title>/i.exec(fs.readFileSync(full, "utf8"))?.[1]?.trim();
|
|
66
|
+
out.push({ path: rel, title: title || rel });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
walk(dir);
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A short fingerprint of everything in a directory (names and contents). */
|
|
75
|
+
export function dirHash(dir: string): string | null {
|
|
76
|
+
if (!fs.existsSync(dir)) return null;
|
|
77
|
+
const h = crypto.createHash("sha256");
|
|
78
|
+
const walk = (d: string) => {
|
|
79
|
+
for (const e of fs.readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
80
|
+
const full = path.join(d, e.name);
|
|
81
|
+
if (e.isDirectory()) walk(full);
|
|
82
|
+
else {
|
|
83
|
+
h.update(path.relative(dir, full).split(path.sep).join("/"));
|
|
84
|
+
h.update("\0");
|
|
85
|
+
h.update(fs.readFileSync(full));
|
|
86
|
+
h.update("\0");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
walk(dir);
|
|
91
|
+
return h.digest("hex").slice(0, 12);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Keeps a copy of each prototype version the page has shown (in .pipeline/review/versions), so
|
|
96
|
+
* a revised prototype can be compared with the one the requester commented on.
|
|
97
|
+
*/
|
|
98
|
+
export function snapshotVersion(prototypeDir: string, reviewDir: string, now = new Date()): { current: string | null; previous: string | null } {
|
|
99
|
+
const current = dirHash(prototypeDir);
|
|
100
|
+
if (!current) return { current: null, previous: null };
|
|
101
|
+
const listFile = path.join(reviewDir, "versions.json");
|
|
102
|
+
let list: Array<{ hash: string; at: string }> = [];
|
|
103
|
+
try {
|
|
104
|
+
list = JSON.parse(fs.readFileSync(listFile, "utf8"));
|
|
105
|
+
} catch {
|
|
106
|
+
list = [];
|
|
107
|
+
}
|
|
108
|
+
if (list.at(-1)?.hash !== current) {
|
|
109
|
+
const dest = path.join(reviewDir, "versions", current);
|
|
110
|
+
if (!fs.existsSync(dest)) {
|
|
111
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
112
|
+
fs.cpSync(prototypeDir, dest, { recursive: true });
|
|
113
|
+
}
|
|
114
|
+
list = [...list.filter((v) => v.hash !== current), { hash: current, at: now.toISOString() }];
|
|
115
|
+
fs.mkdirSync(reviewDir, { recursive: true });
|
|
116
|
+
fs.writeFileSync(listFile, JSON.stringify(list, null, 2));
|
|
117
|
+
}
|
|
118
|
+
const i = list.findIndex((v) => v.hash === current);
|
|
119
|
+
return { current, previous: i > 0 ? list[i - 1].hash : null };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Turning clicks into a list the chat (and the design step) can act on
|
|
124
|
+
|
|
125
|
+
export type CommentKind = "text" | "bigger" | "smaller" | "color" | "move" | "remove" | "add" | "other";
|
|
126
|
+
|
|
127
|
+
export interface ReviewComment {
|
|
128
|
+
id: string;
|
|
129
|
+
file: string;
|
|
130
|
+
state?: string | null;
|
|
131
|
+
width?: number | null;
|
|
132
|
+
kind: CommentKind;
|
|
133
|
+
note?: string;
|
|
134
|
+
newText?: string;
|
|
135
|
+
target?: {
|
|
136
|
+
desc?: string;
|
|
137
|
+
text?: string;
|
|
138
|
+
selector?: string;
|
|
139
|
+
region?: { ac?: string | null; label?: string | null; index?: number | null } | null;
|
|
140
|
+
styles?: { color?: string; background?: string; fontSize?: string; size?: string } | null;
|
|
141
|
+
} | null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const KIND_TEXT: Record<CommentKind, string> = {
|
|
145
|
+
text: "改文字",
|
|
146
|
+
bigger: "放大一点",
|
|
147
|
+
smaller: "缩小一点",
|
|
148
|
+
color: "换颜色",
|
|
149
|
+
move: "换位置",
|
|
150
|
+
remove: "删掉",
|
|
151
|
+
add: "在这里加东西",
|
|
152
|
+
other: "其他修改",
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const clip = (s: string | undefined | null, n: number) => {
|
|
156
|
+
const t = (s ?? "").replace(/\s+/g, " ").trim();
|
|
157
|
+
return t.length > n ? `${t.slice(0, n)}…` : t;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/** Plain text for the chat: where, what was clicked (with its look), what to change. */
|
|
161
|
+
export function composeFeedback(comments: ReviewComment[], general: string | undefined, widths: Record<number, string> = { 1280: "电脑", 768: "平板", 390: "手机" }): string {
|
|
162
|
+
const lines: string[] = [];
|
|
163
|
+
lines.push(`来自原型审查页的修改意见(共 ${comments.length} 条${general?.trim() ? ",另有总体意见" : ""})。`);
|
|
164
|
+
lines.push(
|
|
165
|
+
'请据此修改需求说明(changes.md 中原因写"方案确认时的修改"),把要改的地方讲给用户确认后再继续;元素的颜色、大小等是原型里的现状,供定位用。只有意思不清楚时才追问。',
|
|
166
|
+
);
|
|
167
|
+
if (general?.trim()) lines.push("", `总体意见:${clip(general, 800)}`);
|
|
168
|
+
comments.forEach((c, i) => {
|
|
169
|
+
const where = [`原型 ${c.file}`];
|
|
170
|
+
if (c.state) where.push(`状态"${c.state}"`);
|
|
171
|
+
if (c.width) where.push(`${widths[c.width] ?? ""}宽度 ${c.width}px`.trim());
|
|
172
|
+
const r = c.target?.region;
|
|
173
|
+
if (r && (r.ac || r.label)) where.push(`区域${r.index ? ` ${r.index}` : ""}${r.label ? `「${clip(r.label, 40)}」` : ""}${r.ac ? `(${r.ac})` : ""}`);
|
|
174
|
+
lines.push("", `${i + 1}. 位置:${where.join(",")}`);
|
|
175
|
+
const t = c.target;
|
|
176
|
+
if (t?.desc || t?.text) {
|
|
177
|
+
const look = [
|
|
178
|
+
t.styles?.color ? `文字颜色 ${t.styles.color}` : "",
|
|
179
|
+
t.styles?.background ? `背景 ${t.styles.background}` : "",
|
|
180
|
+
t.styles?.fontSize ? `字号 ${t.styles.fontSize}` : "",
|
|
181
|
+
t.styles?.size ? `大小 ${t.styles.size}` : "",
|
|
182
|
+
].filter(Boolean);
|
|
183
|
+
lines.push(` 点的是:${clip(t.desc, 30) || "元素"}${t.text ? `"${clip(t.text, 60)}"` : ""}${look.length ? `(${look.join(",")})` : ""}`);
|
|
184
|
+
}
|
|
185
|
+
if (c.kind === "text") lines.push(` 要改:文字改为"${clip(c.newText, 300)}"${t?.text ? `(原来是"${clip(t.text, 120)}")` : ""}`);
|
|
186
|
+
else lines.push(` 要改:${KIND_TEXT[c.kind] ?? KIND_TEXT.other}${c.newText ? `,改为"${clip(c.newText, 300)}"` : ""}`);
|
|
187
|
+
if (c.note?.trim()) lines.push(` 说明:${clip(c.note, 500)}`);
|
|
188
|
+
});
|
|
189
|
+
let text = lines.join("\n");
|
|
190
|
+
if (text.length > MAX_MESSAGE) {
|
|
191
|
+
text = `${text.slice(0, MAX_MESSAGE)}\n……(意见太长,后面的没有转达,完整内容在 .pipeline/review/submitted 中最新的文件里)`;
|
|
192
|
+
}
|
|
193
|
+
return text;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// The server
|
|
198
|
+
|
|
199
|
+
export interface ReviewServerOptions {
|
|
200
|
+
token: string;
|
|
201
|
+
readToken: string;
|
|
202
|
+
env?: NodeJS.ProcessEnv;
|
|
203
|
+
/** Runs `req approve|reply …` in the requirement's checkout; tests replace it. */
|
|
204
|
+
run?: (command: "approve" | "reply", repo: string, featureDir: string, extra: string[]) => { ok: boolean; message: string };
|
|
205
|
+
onRequest?: () => void;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const TYPES: Record<string, string> = {
|
|
209
|
+
".html": "text/html; charset=utf-8",
|
|
210
|
+
".htm": "text/html; charset=utf-8",
|
|
211
|
+
".js": "text/javascript; charset=utf-8",
|
|
212
|
+
".css": "text/css; charset=utf-8",
|
|
213
|
+
".json": "application/json; charset=utf-8",
|
|
214
|
+
".svg": "image/svg+xml",
|
|
215
|
+
".png": "image/png",
|
|
216
|
+
".jpg": "image/jpeg",
|
|
217
|
+
".jpeg": "image/jpeg",
|
|
218
|
+
".gif": "image/gif",
|
|
219
|
+
".webp": "image/webp",
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
function sameToken(a: string, b: string): boolean {
|
|
223
|
+
const x = Buffer.from(a);
|
|
224
|
+
const y = Buffer.from(b);
|
|
225
|
+
return x.length === y.length && crypto.timingSafeEqual(x, y);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** A path inside `base`, or null when the request tries to leave it. */
|
|
229
|
+
function inside(base: string, rel: string): string | null {
|
|
230
|
+
let decoded: string;
|
|
231
|
+
try {
|
|
232
|
+
decoded = decodeURIComponent(rel);
|
|
233
|
+
} catch {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
const target = path.resolve(base, `.${path.posix.normalize(`/${decoded}`)}`);
|
|
237
|
+
return target === base || target.startsWith(base + path.sep) ? target : null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Prototypes are written by an agent (from text the requester or a card supplied) and are served
|
|
241
|
+
// next to the page's own API, so nothing in them may run: the page reads them from outside.
|
|
242
|
+
const PROTOTYPE_CSP =
|
|
243
|
+
"default-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; media-src 'self' data:; script-src 'none'; form-action 'none'; frame-ancestors 'self'";
|
|
244
|
+
const APP_CSP = "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'";
|
|
245
|
+
|
|
246
|
+
function send(res: http.ServerResponse, status: number, body: string | Buffer, type = "text/plain; charset=utf-8", csp = APP_CSP) {
|
|
247
|
+
res.writeHead(status, {
|
|
248
|
+
"content-type": type,
|
|
249
|
+
"cache-control": "no-store",
|
|
250
|
+
"x-content-type-options": "nosniff",
|
|
251
|
+
"referrer-policy": "no-referrer",
|
|
252
|
+
"content-security-policy": csp,
|
|
253
|
+
});
|
|
254
|
+
res.end(body);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const json = (res: http.ServerResponse, status: number, value: unknown) => send(res, status, JSON.stringify(value), TYPES[".json"]);
|
|
258
|
+
|
|
259
|
+
function sendFile(res: http.ServerResponse, file: string | null, csp = APP_CSP) {
|
|
260
|
+
if (!file) return send(res, 404, "没有这个文件");
|
|
261
|
+
try {
|
|
262
|
+
if (!fs.statSync(file).isFile()) return send(res, 404, "没有这个文件");
|
|
263
|
+
} catch {
|
|
264
|
+
return send(res, 404, "没有这个文件");
|
|
265
|
+
}
|
|
266
|
+
send(res, 200, fs.readFileSync(file), TYPES[path.extname(file).toLowerCase()] ?? "application/octet-stream", csp);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function readBody(req: http.IncomingMessage): Promise<unknown> {
|
|
270
|
+
const chunks: Buffer[] = [];
|
|
271
|
+
let size = 0;
|
|
272
|
+
for await (const chunk of req) {
|
|
273
|
+
size += (chunk as Buffer).length;
|
|
274
|
+
if (size > MAX_BODY) throw new Error("内容太大");
|
|
275
|
+
chunks.push(chunk as Buffer);
|
|
276
|
+
}
|
|
277
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8") || "null");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** `req <approve|reply> <需求> …` in the requirement's checkout, as `req api run` does (api.ts imports this file). */
|
|
281
|
+
function defaultRun(command: "approve" | "reply", repo: string, featureDir: string, extra: string[]) {
|
|
282
|
+
const env = { ...process.env };
|
|
283
|
+
delete env.PASEO_AGENT_ID;
|
|
284
|
+
delete env.PASEO_AGENT_CWD;
|
|
285
|
+
const r = spawnSync(process.execPath, [path.join(KIT_ROOT, "bin", "req.mjs"), command, featureDir, ...extra], {
|
|
286
|
+
cwd: repo,
|
|
287
|
+
env,
|
|
288
|
+
encoding: "utf8",
|
|
289
|
+
timeout: 180_000,
|
|
290
|
+
windowsHide: true,
|
|
291
|
+
});
|
|
292
|
+
return { ok: r.status === 0, message: r.error ? r.error.message : `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function createReviewServer(opts: ReviewServerOptions): http.Server {
|
|
296
|
+
const env = opts.env ?? process.env;
|
|
297
|
+
const run = opts.run ?? defaultRun;
|
|
298
|
+
return http.createServer((req, res) => {
|
|
299
|
+
opts.onRequest?.();
|
|
300
|
+
handle(req, res).catch((error: Error) => json(res, 500, { ok: false, message: error.message }));
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
async function handle(req: http.IncomingMessage, res: http.ServerResponse) {
|
|
304
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
305
|
+
const parts = url.pathname.split("/");
|
|
306
|
+
// /r/<token>/health · /r/<token>/f/<key>/<rest…>
|
|
307
|
+
if (parts[1] !== "r" || !parts[2]) return send(res, 404, "没有这个页面");
|
|
308
|
+
const full = sameToken(parts[2], opts.token);
|
|
309
|
+
if (!full && !sameToken(parts[2], opts.readToken)) return send(res, 403, "链接无效或已过期,请从 Paseo 的 req-kit 插件或 req review 重新打开");
|
|
310
|
+
if (parts[3] === "health") return json(res, 200, { ok: true, version: kitVersion() });
|
|
311
|
+
if (parts[3] !== "f" || !parts[4]) return send(res, 404, "没有这个页面");
|
|
312
|
+
const entry = readIndex(env).find((e) => e.key === parts[4]);
|
|
313
|
+
if (!entry) return send(res, 404, "这台机器上没有这个需求(需求列表中找不到)");
|
|
314
|
+
const rest = parts.slice(5).join("/");
|
|
315
|
+
const featureAbs = path.join(entry.repo, entry.featureDir);
|
|
316
|
+
const prototypeDir = path.join(featureAbs, "prototype");
|
|
317
|
+
const reviewDir = path.join(featureAbs, ".pipeline", "review");
|
|
318
|
+
|
|
319
|
+
if (rest === "" && parts.length === 5) {
|
|
320
|
+
res.writeHead(301, { location: `${url.pathname}/` });
|
|
321
|
+
return res.end();
|
|
322
|
+
}
|
|
323
|
+
if (rest === "") return sendFile(res, path.join(ASSETS, "app.html"));
|
|
324
|
+
if (rest === "app.js" || rest === "app.css") return sendFile(res, path.join(ASSETS, rest));
|
|
325
|
+
if (rest.startsWith("proto/cur/")) return sendFile(res, inside(prototypeDir, rest.slice("proto/cur/".length)), PROTOTYPE_CSP);
|
|
326
|
+
if (rest.startsWith("proto/v/")) {
|
|
327
|
+
const [hash, ...file] = rest.slice("proto/v/".length).split("/");
|
|
328
|
+
if (!/^[0-9a-f]{12}$/.test(hash)) return send(res, 404, "没有这个版本");
|
|
329
|
+
return sendFile(res, inside(path.join(reviewDir, "versions", hash), file.join("/")), PROTOTYPE_CSP);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (rest === "api/info" && req.method === "GET") {
|
|
333
|
+
let spec = "";
|
|
334
|
+
try {
|
|
335
|
+
spec = fs.readFileSync(path.join(featureAbs, "spec.md"), "utf8");
|
|
336
|
+
} catch {
|
|
337
|
+
/* shown without criteria */
|
|
338
|
+
}
|
|
339
|
+
const state = readState(statePath(entry.repo, entry.featureDir));
|
|
340
|
+
const versions = fs.existsSync(prototypeDir) ? snapshotVersion(prototypeDir, reviewDir) : { current: null, previous: null };
|
|
341
|
+
return json(res, 200, {
|
|
342
|
+
key: entry.key,
|
|
343
|
+
project: entry.project,
|
|
344
|
+
featureDir: entry.featureDir,
|
|
345
|
+
title: specTitle(spec, path.basename(entry.featureDir)),
|
|
346
|
+
status: state?.status ?? null,
|
|
347
|
+
waitingForApproval: state?.status === "waiting_for_approval",
|
|
348
|
+
waitingForReply: state?.status === "waiting_for_approval" || state?.status === "waiting_for_business",
|
|
349
|
+
canReply: Boolean(state?.originAgentId),
|
|
350
|
+
readOnly: !full,
|
|
351
|
+
files: fs.existsSync(prototypeDir) ? prototypeFiles(prototypeDir) : [],
|
|
352
|
+
criteria: specCriteria(spec),
|
|
353
|
+
versions,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
if (rest === "api/draft") {
|
|
357
|
+
const file = path.join(reviewDir, "draft.json");
|
|
358
|
+
if (req.method === "GET") {
|
|
359
|
+
try {
|
|
360
|
+
return json(res, 200, JSON.parse(fs.readFileSync(file, "utf8")));
|
|
361
|
+
} catch {
|
|
362
|
+
return json(res, 200, { comments: [], general: "" });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (req.method === "PUT") {
|
|
366
|
+
if (!full) return json(res, 403, { ok: false, message: "这是只读链接" });
|
|
367
|
+
const body = (await readBody(req)) as { comments?: unknown; general?: unknown } | null;
|
|
368
|
+
if (!body || !Array.isArray(body.comments)) return json(res, 400, { ok: false, message: "格式不对" });
|
|
369
|
+
fs.mkdirSync(reviewDir, { recursive: true });
|
|
370
|
+
fs.writeFileSync(file, JSON.stringify({ comments: body.comments, general: typeof body.general === "string" ? body.general : "", savedAt: new Date().toISOString() }, null, 2));
|
|
371
|
+
return json(res, 200, { ok: true });
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
if (rest === "api/submit" && req.method === "POST") {
|
|
375
|
+
if (!full) return json(res, 403, { ok: false, message: "这是只读链接,不能提交" });
|
|
376
|
+
const body = (await readBody(req)) as { action?: string; comments?: ReviewComment[]; general?: string } | null;
|
|
377
|
+
const action = body?.action;
|
|
378
|
+
if (action !== "approve" && action !== "changes") return json(res, 400, { ok: false, message: "action 只能是 approve 或 changes" });
|
|
379
|
+
const comments = Array.isArray(body?.comments) ? body.comments : [];
|
|
380
|
+
if (action === "changes" && comments.length === 0 && !body?.general?.trim()) return json(res, 400, { ok: false, message: "还没有写任何意见" });
|
|
381
|
+
const result =
|
|
382
|
+
action === "approve" ? run("approve", entry.repo, entry.featureDir, []) : run("reply", entry.repo, entry.featureDir, [composeFeedback(comments, body?.general)]);
|
|
383
|
+
fs.mkdirSync(path.join(reviewDir, "submitted"), { recursive: true });
|
|
384
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
385
|
+
fs.writeFileSync(path.join(reviewDir, "submitted", `${stamp}-${action}.json`), JSON.stringify({ action, comments, general: body?.general ?? "", result, versions: dirHash(prototypeDir) }, null, 2));
|
|
386
|
+
if (result.ok) fs.rmSync(path.join(reviewDir, "draft.json"), { force: true });
|
|
387
|
+
return json(res, result.ok ? 200 : 409, result);
|
|
388
|
+
}
|
|
389
|
+
return send(res, 404, "没有这个页面");
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
// One server per machine
|
|
395
|
+
|
|
396
|
+
export interface ServerInfo {
|
|
397
|
+
pid: number;
|
|
398
|
+
port: number;
|
|
399
|
+
token: string;
|
|
400
|
+
readToken: string;
|
|
401
|
+
version: string;
|
|
402
|
+
publicUrl: string;
|
|
403
|
+
startedAt: string;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export const infoFile = (env: NodeJS.ProcessEnv = process.env) => path.join(kitHome(env), "review-server.json");
|
|
407
|
+
|
|
408
|
+
function readInfo(env: NodeJS.ProcessEnv): ServerInfo | null {
|
|
409
|
+
try {
|
|
410
|
+
return JSON.parse(fs.readFileSync(infoFile(env), "utf8")) as ServerInfo;
|
|
411
|
+
} catch {
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function alive(pid: number): boolean {
|
|
417
|
+
try {
|
|
418
|
+
process.kill(pid, 0);
|
|
419
|
+
return true;
|
|
420
|
+
} catch (error) {
|
|
421
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function healthy(info: ServerInfo): Promise<boolean> {
|
|
426
|
+
try {
|
|
427
|
+
const r = await fetch(`http://127.0.0.1:${info.port}/r/${info.token}/health`, { signal: AbortSignal.timeout(2000) });
|
|
428
|
+
return r.ok && ((await r.json()) as { version?: string }).version === kitVersion();
|
|
429
|
+
} catch {
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** The running review server, started in the background when there is none (or an older one). */
|
|
435
|
+
export async function ensureReviewServer(env: NodeJS.ProcessEnv = process.env): Promise<ServerInfo> {
|
|
436
|
+
const existing = readInfo(env);
|
|
437
|
+
if (existing && alive(existing.pid) && (await healthy(existing))) return existing;
|
|
438
|
+
if (existing && alive(existing.pid)) {
|
|
439
|
+
try {
|
|
440
|
+
process.kill(existing.pid);
|
|
441
|
+
} catch {
|
|
442
|
+
/* gone */
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
fs.mkdirSync(kitHome(env), { recursive: true });
|
|
446
|
+
const log = fs.openSync(path.join(kitHome(env), "review-server.log"), "a");
|
|
447
|
+
const child = spawn(process.execPath, [...process.execArgv, path.join(ENGINE_DIR, "src", "review.ts"), "--serve"], {
|
|
448
|
+
detached: true,
|
|
449
|
+
stdio: ["ignore", log, log],
|
|
450
|
+
env,
|
|
451
|
+
windowsHide: true,
|
|
452
|
+
});
|
|
453
|
+
child.unref();
|
|
454
|
+
for (let i = 0; i < 100; i++) {
|
|
455
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
456
|
+
const info = readInfo(env);
|
|
457
|
+
if (info && info.pid === child.pid && (await healthy(info))) return info;
|
|
458
|
+
}
|
|
459
|
+
throw new Error(`原型审查页没有启动,日志:${path.join(kitHome(env), "review-server.log")}`);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** The page of one requirement (by its key in the requirement index). */
|
|
463
|
+
export function reviewUrl(info: ServerInfo, key: string, readOnly = false): string {
|
|
464
|
+
return `${info.publicUrl.replace(/\/+$/, "")}/r/${readOnly ? info.readToken : info.token}/f/${encodeURIComponent(key)}/`;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Finds the index key of a requirement from its repository and feature directory. */
|
|
468
|
+
export function keyOf(repo: string, featureDir: string, env: NodeJS.ProcessEnv = process.env): string | null {
|
|
469
|
+
const norm = (p: string) => path.resolve(p).toLowerCase();
|
|
470
|
+
const dir = norm(path.join(repo, featureDir));
|
|
471
|
+
const e = readIndex(env).find((x) => norm(path.join(x.repo, x.featureDir)) === dir || (x.sourceRepo !== null && norm(path.join(x.sourceRepo, x.featureDir)) === dir));
|
|
472
|
+
return e?.key ?? null;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function serve(env: NodeJS.ProcessEnv): Promise<void> {
|
|
476
|
+
const token = crypto.randomBytes(18).toString("base64url");
|
|
477
|
+
const readToken = crypto.randomBytes(18).toString("base64url");
|
|
478
|
+
let last = Date.now();
|
|
479
|
+
const server = createReviewServer({ token, readToken, env, onRequest: () => (last = Date.now()) });
|
|
480
|
+
const host = env.REQ_KIT_REVIEW_HOST?.trim() || "127.0.0.1";
|
|
481
|
+
const wanted = Number(env.REQ_KIT_REVIEW_PORT ?? DEFAULT_PORT);
|
|
482
|
+
const listen = (port: number) =>
|
|
483
|
+
new Promise<number>((resolve, reject) => {
|
|
484
|
+
server.once("error", reject);
|
|
485
|
+
server.listen(port, host, () => {
|
|
486
|
+
server.off("error", reject);
|
|
487
|
+
resolve((server.address() as { port: number }).port);
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
let port: number;
|
|
491
|
+
try {
|
|
492
|
+
port = await listen(wanted);
|
|
493
|
+
} catch {
|
|
494
|
+
port = await listen(0);
|
|
495
|
+
}
|
|
496
|
+
const info: ServerInfo = {
|
|
497
|
+
pid: process.pid,
|
|
498
|
+
port,
|
|
499
|
+
token,
|
|
500
|
+
readToken,
|
|
501
|
+
version: kitVersion(),
|
|
502
|
+
publicUrl: env.REQ_KIT_REVIEW_PUBLIC_URL?.trim() || `http://127.0.0.1:${port}`,
|
|
503
|
+
startedAt: new Date().toISOString(),
|
|
504
|
+
};
|
|
505
|
+
fs.mkdirSync(kitHome(env), { recursive: true });
|
|
506
|
+
fs.writeFileSync(infoFile(env), JSON.stringify(info, null, 2), { mode: 0o600 });
|
|
507
|
+
console.log(`[${info.startedAt}] 原型审查页在 ${host}:${port} 运行(req-kit ${info.version})`);
|
|
508
|
+
const timer = setInterval(() => {
|
|
509
|
+
if (Date.now() - last < IDLE_EXIT_MS) return;
|
|
510
|
+
clearInterval(timer);
|
|
511
|
+
const current = readInfo(env);
|
|
512
|
+
if (current?.pid === process.pid) fs.rmSync(infoFile(env), { force: true });
|
|
513
|
+
server.close(() => process.exit(0));
|
|
514
|
+
}, 60_000);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// ---------------------------------------------------------------------------
|
|
518
|
+
// req review <需求> [--no-open] [--read-only]
|
|
519
|
+
|
|
520
|
+
function openBrowser(url: string) {
|
|
521
|
+
const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
522
|
+
try {
|
|
523
|
+
const child = spawn(cmd, args as string[], { stdio: "ignore", detached: true, windowsHide: true });
|
|
524
|
+
child.on("error", () => {});
|
|
525
|
+
child.unref();
|
|
526
|
+
} catch {
|
|
527
|
+
/* the address is printed anyway */
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async function main(argv: string[]): Promise<number> {
|
|
532
|
+
if (argv[0] === "--serve") {
|
|
533
|
+
await serve(process.env);
|
|
534
|
+
return -1;
|
|
535
|
+
}
|
|
536
|
+
const open = !argv.includes("--no-open");
|
|
537
|
+
const readOnly = argv.includes("--read-only");
|
|
538
|
+
const feature = argv.find((a) => !a.startsWith("--"));
|
|
539
|
+
if (!feature || argv.includes("--help") || argv.includes("-h")) {
|
|
540
|
+
console.log("用法:req review <需求> [--no-open] [--read-only]\n\n在浏览器中打开这个需求的原型审查页:点选要改的地方、直接改文字,然后提交修改意见或确认方案。");
|
|
541
|
+
return feature ? 0 : 2;
|
|
542
|
+
}
|
|
543
|
+
const { locate } = await import("./api.js");
|
|
544
|
+
const { repo, featureDir } = locate(process.cwd(), feature);
|
|
545
|
+
if (!fs.existsSync(path.join(repo, featureDir, "prototype"))) {
|
|
546
|
+
console.error(`${featureDir} 还没有界面原型(prototype/)。界面改动的需求在方案设计后才会有。`);
|
|
547
|
+
return 1;
|
|
548
|
+
}
|
|
549
|
+
const key = keyOf(repo, featureDir);
|
|
550
|
+
if (!key) {
|
|
551
|
+
console.error(`${featureDir} 不在本机的需求列表中,先运行一次 req index --rebuild`);
|
|
552
|
+
return 1;
|
|
553
|
+
}
|
|
554
|
+
const url = reviewUrl(await ensureReviewServer(), key, readOnly);
|
|
555
|
+
console.log(`原型审查页:${url}`);
|
|
556
|
+
if (open) openBrowser(url);
|
|
557
|
+
return 0;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
|
|
561
|
+
main(process.argv.slice(2)).then(
|
|
562
|
+
(code) => {
|
|
563
|
+
if (code >= 0) process.exit(code);
|
|
564
|
+
},
|
|
565
|
+
(error: Error) => {
|
|
566
|
+
console.error(error.message);
|
|
567
|
+
process.exit(1);
|
|
568
|
+
},
|
|
569
|
+
);
|
|
570
|
+
}
|