@rexvane/piweb 0.3.17 → 0.3.19
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/.next/BUILD_ID +1 -1
- package/.next/app-path-routes-manifest.json +6 -4
- package/.next/build-manifest.json +3 -3
- package/.next/prerender-manifest.json +4 -4
- package/.next/react-loadable-manifest.json +23 -9
- package/.next/required-server-files.js +2 -1
- package/.next/required-server-files.json +2 -1
- package/.next/routes-manifest.json +12 -0
- package/.next/server/app/_global-error/page.js +2 -2
- package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_global-error.html +1 -1
- package/.next/server/app/_global-error.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found/page.js +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +1 -1
- package/.next/server/app/_not-found.rsc +2 -2
- package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/api/agent/[id]/events/route.js +3 -3
- package/.next/server/app/api/agent/[id]/events/route.js.nft.json +1 -1
- package/.next/server/app/api/agent/[id]/route.js +1 -1
- package/.next/server/app/api/agent/[id]/route.js.nft.json +1 -1
- package/.next/server/app/api/agent/[id]/tree/route.js +1 -1
- package/.next/server/app/api/agent/[id]/tree/route.js.nft.json +1 -1
- package/.next/server/app/api/agent/new/route.js +1 -1
- package/.next/server/app/api/agent/new/route.js.nft.json +1 -1
- package/.next/server/app/api/config/route.js +1 -1
- package/.next/server/app/api/config/route.js.nft.json +1 -1
- package/.next/server/app/api/files/route.js +1 -1
- package/.next/server/app/api/files/route.js.nft.json +1 -1
- package/.next/server/app/api/fs/route.js +1 -0
- package/.next/server/app/api/fs/route.js.nft.json +1 -0
- package/.next/server/app/api/fs/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/git/route.js +2 -2
- package/.next/server/app/api/git/route.js.nft.json +1 -1
- package/.next/server/app/api/growth/route.js +1 -1
- package/.next/server/app/api/growth/route.js.nft.json +1 -1
- package/.next/server/app/api/health/route.js +1 -1
- package/.next/server/app/api/models/route.js +1 -1
- package/.next/server/app/api/models/route.js.nft.json +1 -1
- package/.next/server/app/api/pi-settings/route.js +1 -1
- package/.next/server/app/api/pi-settings/route.js.nft.json +1 -1
- package/.next/server/app/api/plugins/route.js +1 -1
- package/.next/server/app/api/plugins/route.js.nft.json +1 -1
- package/.next/server/app/api/prompts/route.js +1 -1
- package/.next/server/app/api/prompts/route.js.nft.json +1 -1
- package/.next/server/app/api/remote/route.js +8 -0
- package/.next/server/app/api/remote/route.js.nft.json +1 -0
- package/.next/server/app/api/remote/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/security/route.js +1 -1
- package/.next/server/app/api/security/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/[id]/export/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/export/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/[id]/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/import/route.js +2 -2
- package/.next/server/app/api/sessions/import/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/route.js +1 -1
- package/.next/server/app/api/sessions/route.js.nft.json +1 -1
- package/.next/server/app/api/skills/route.js +1 -1
- package/.next/server/app/api/skills/route.js.nft.json +1 -1
- package/.next/server/app/api/update/route.js +2 -2
- package/.next/server/app/api/update/route.js.nft.json +1 -1
- package/.next/server/app/api/version/route.js +1 -1
- package/.next/server/app/api/version/route.js.nft.json +1 -1
- package/.next/server/app/api/web-auth/route.js +3 -1
- package/.next/server/app/api/workspaces/route.js +1 -1
- package/.next/server/app/api/workspaces/route.js.nft.json +1 -1
- package/.next/server/app/icon.svg/route.js +1 -1
- package/.next/server/app/index.html +1 -1
- package/.next/server/app/index.rsc +4 -4
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
- package/.next/server/app/index.segments/_full.segment.rsc +4 -4
- package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/login/page.js +1 -1
- package/.next/server/app/login/page_client-reference-manifest.js +1 -1
- package/.next/server/app/login.html +1 -1
- package/.next/server/app/login.rsc +3 -3
- package/.next/server/app/login.segments/_full.segment.rsc +3 -3
- package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +3 -3
- package/.next/server/app/page.js +1 -1
- package/.next/server/app/page.js.nft.json +1 -1
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app-paths-manifest.json +6 -4
- package/.next/server/chunks/229.js +1 -1
- package/.next/server/chunks/243.js +1 -0
- package/.next/server/chunks/393.js +1 -0
- package/.next/server/chunks/496.js +1 -1
- package/.next/server/chunks/538.js +1 -0
- package/.next/server/chunks/686.js +1 -4
- package/.next/server/chunks/802.js +2 -0
- package/.next/server/chunks/807.js +1 -0
- package/.next/server/chunks/920.js +4 -0
- package/.next/server/chunks/924.js +6 -0
- package/.next/server/instrumentation.js +1 -0
- package/.next/server/instrumentation.js.nft.json +1 -0
- package/.next/server/middleware-build-manifest.js +1 -1
- package/.next/server/middleware-react-loadable-manifest.js +1 -1
- package/.next/server/middleware.js +4 -3
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/server/server-reference-manifest.json +1 -1
- package/.next/static/JDGSR-TIbk9qZWcUNYIX3/_buildManifest.js +1 -0
- package/.next/static/chunks/2716.7ad073038e94a802.js +1 -0
- package/.next/static/chunks/316-5ea443ee6b512aa9.js +1 -0
- package/.next/static/chunks/4673.8a5a9e6855350e34.js +1 -0
- package/.next/static/chunks/4686-e3825f3b6a384aa1.js +1 -0
- package/.next/static/chunks/4778.e4c9ed093a413bc4.js +1 -0
- package/.next/static/chunks/5173.91ddb803785009f8.js +1 -0
- package/.next/static/chunks/839.7403c6bdace4c56c.js +1 -0
- package/.next/static/chunks/8552.6bfe3e101250b20f.js +1 -0
- package/.next/static/chunks/app/_global-error/page-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/agent/[id]/events/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/agent/[id]/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/agent/[id]/tree/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/agent/new/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/config/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/files/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/fs/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/git/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/growth/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/health/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/models/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/pi-settings/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/plugins/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/prompts/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/remote/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/security/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/sessions/[id]/export/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/sessions/[id]/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/sessions/import/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/sessions/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/skills/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/update/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/version/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/web-auth/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/api/workspaces/route-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/app/login/page-5e276a1eeb8867df.js +1 -0
- package/.next/static/chunks/app/page-2e425ef83fba0782.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/app-error-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/forbidden-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/not-found-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-b179d9f7f6f2daf9.js +1 -0
- package/.next/static/chunks/webpack-bd2b9202ecbcdcaa.js +1 -0
- package/.next/static/css/7391c42e1ae617f9.css +1 -0
- package/.next/types/app/api/fs/route.ts +353 -0
- package/.next/types/app/api/remote/route.ts +353 -0
- package/.next/types/routes.d.ts +3 -1
- package/.next/types/validator.ts +18 -0
- package/README.md +56 -10
- package/next.config.ts +3 -2
- package/package.json +3 -3
- package/scripts/launcher.mjs +194 -13
- package/scripts/release.mjs +0 -2
- package/src/app/api/agent/[id]/events/route.ts +11 -6
- package/src/app/api/fs/route.ts +119 -0
- package/src/app/api/growth/route.ts +11 -6
- package/src/app/api/models/route.ts +21 -9
- package/src/app/api/remote/route.ts +67 -0
- package/src/app/api/sessions/[id]/export/route.ts +11 -1
- package/src/app/api/sessions/route.ts +3 -1
- package/src/app/api/web-auth/route.ts +45 -49
- package/src/app/api/workspaces/route.ts +16 -0
- package/src/app/globals.css +11 -3
- package/src/app/login/page.tsx +8 -60
- package/src/components/AppShell.tsx +202 -155
- package/src/components/ChatInput.tsx +61 -6
- package/src/components/ChatWindow.tsx +274 -132
- package/src/components/DirectoryPicker.tsx +189 -0
- package/src/components/DrawerPanel.tsx +164 -0
- package/src/components/FileViewer.tsx +2 -2
- package/src/components/GrowthTree.tsx +6 -5
- package/src/components/MarkdownBlock.tsx +134 -0
- package/src/components/OutlineRail.tsx +22 -1
- package/src/components/ProjectPanel.tsx +197 -14
- package/src/components/RemoteSection.tsx +232 -0
- package/src/components/SessionImportSection.tsx +60 -38
- package/src/components/SettingsPanel.tsx +104 -57
- package/src/components/icons.tsx +23 -0
- package/src/hooks/useGrowth.ts +47 -25
- package/src/hooks/usePiWeb.ts +100 -14
- package/src/i18n.tsx +115 -28
- package/src/instrumentation-node.ts +35 -0
- package/src/instrumentation.ts +18 -0
- package/src/lib/agent-config.ts +134 -0
- package/src/lib/agent-manager.ts +243 -61
- package/src/lib/auth-throttle.ts +102 -0
- package/src/lib/auth.ts +35 -27
- package/src/lib/files-service.ts +61 -1
- package/src/lib/font.ts +84 -0
- package/src/lib/git-service.ts +5 -3
- package/src/lib/growth-service.ts +78 -30
- package/src/lib/growth-tracker.ts +91 -9
- package/src/lib/models-service.ts +36 -11
- package/src/lib/pi-settings.ts +10 -12
- package/src/lib/pi.ts +145 -18
- package/src/lib/plugins-service.ts +67 -35
- package/src/lib/remote/assets.json +4 -0
- package/src/lib/remote/mobile-proxy.ts +149 -0
- package/src/lib/remote/supervisor.ts +482 -0
- package/src/lib/security-service.ts +6 -11
- package/src/lib/session-import/claude.ts +45 -21
- package/src/lib/session-reader.ts +106 -3
- package/src/lib/skills-service.ts +34 -12
- package/src/lib/text-sanitize.ts +12 -0
- package/src/lib/theme.ts +3 -0
- package/src/lib/types.ts +16 -2
- package/src/lib/update-service.ts +0 -1
- package/src/lib/web-auth-shared.ts +5 -1
- package/src/lib/web-auth.ts +139 -22
- package/src/lib/web-launch-token.ts +55 -0
- package/src/lib/web-secret.ts +182 -0
- package/src/lib/workspace-store.ts +91 -7
- package/src/proxy.ts +46 -12
- package/.next/server/chunks/532.js +0 -1
- package/.next/static/chunks/2155.2f27fe7098987699.js +0 -1
- package/.next/static/chunks/2608.ec8ff1f5767c9056.js +0 -1
- package/.next/static/chunks/2716.93be8ad5c970c516.js +0 -1
- package/.next/static/chunks/4632.112d2e893d48d188.js +0 -1
- package/.next/static/chunks/4673.c1fb1580efc6ace7.js +0 -1
- package/.next/static/chunks/4686-f4a037d609cea3f2.js +0 -1
- package/.next/static/chunks/692-7992db0ae9cc3021.js +0 -1
- package/.next/static/chunks/app/_global-error/page-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/agent/[id]/events/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/agent/[id]/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/agent/[id]/tree/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/agent/new/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/config/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/files/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/git/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/growth/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/health/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/models/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/pi-settings/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/plugins/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/prompts/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/security/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/sessions/[id]/export/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/sessions/[id]/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/sessions/import/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/sessions/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/skills/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/update/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/version/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/web-auth/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/api/workspaces/route-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/app/login/page-52ef4841cbd199a8.js +0 -1
- package/.next/static/chunks/app/page-b51087831582ee37.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/app-error-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/forbidden-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/not-found-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-f77e9ede9808b18e.js +0 -1
- package/.next/static/chunks/webpack-c31263fd83d75ce6.js +0 -1
- package/.next/static/css/850c2fd130ffa2fc.css +0 -1
- package/.next/static/zsynuO_9Kx6nrXgkTUJ04/_buildManifest.js +0 -1
- /package/.next/static/{zsynuO_9Kx6nrXgkTUJ04 → JDGSR-TIbk9qZWcUNYIX3}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 登录失败的全局指数退避。
|
|
3
|
+
*
|
|
4
|
+
* 目前无调用方:密码登录已移除,留作将来需要人机凭据时的现成实现。
|
|
5
|
+
*
|
|
6
|
+
* PiWeb 只服务一个操作者,且默认绑 127.0.0.1:路由处理器拿不到可靠的客户端地址
|
|
7
|
+
* (x-forwarded-for 可伪造,直连时根本没有)。与其信任一个按 IP 的键,不如让所有失败
|
|
8
|
+
* 共用一个计数器,短暂挡住全部密码尝试。被误伤的「另一个用户」只有操作者本人,
|
|
9
|
+
* 有界等待可以接受,而爆破被压到大约每分钟一次。
|
|
10
|
+
*
|
|
11
|
+
* 状态挂在 globalThis 上,这样 Next 的热重载不会把计数器重置掉。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const AUTH_THROTTLE_BASE_DELAY_MS = 1_000;
|
|
15
|
+
export const AUTH_THROTTLE_MAX_DELAY_MS = 60_000;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 最后一次失败之后多久忘掉计数器。必须大于最大退避,否则「等过一次封禁」就
|
|
19
|
+
* 等于把退避重置回基础值,反而给攻击者一轮新的尝试额度。
|
|
20
|
+
*/
|
|
21
|
+
export const AUTH_THROTTLE_RESET_AFTER_MS = 5 * 60_000;
|
|
22
|
+
|
|
23
|
+
export interface AuthThrottleState {
|
|
24
|
+
failures: number;
|
|
25
|
+
lastFailureAt: number;
|
|
26
|
+
blockedUntil: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const STATE_KEY = "piweb:auth-throttle";
|
|
30
|
+
|
|
31
|
+
function freshState(): AuthThrottleState {
|
|
32
|
+
return { failures: 0, lastFailureAt: 0, blockedUntil: 0 };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createAuthThrottleState(): AuthThrottleState {
|
|
36
|
+
return freshState();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isState(value: unknown): value is AuthThrottleState {
|
|
40
|
+
return typeof value === "object"
|
|
41
|
+
&& value !== null
|
|
42
|
+
&& typeof (value as AuthThrottleState).failures === "number"
|
|
43
|
+
&& typeof (value as AuthThrottleState).lastFailureAt === "number"
|
|
44
|
+
&& typeof (value as AuthThrottleState).blockedUntil === "number";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getGlobalState(): AuthThrottleState {
|
|
48
|
+
const store = globalThis as Record<PropertyKey, unknown>;
|
|
49
|
+
const key = Symbol.for(STATE_KEY);
|
|
50
|
+
const existing = store[key];
|
|
51
|
+
if (isState(existing)) return existing;
|
|
52
|
+
const created = freshState();
|
|
53
|
+
store[key] = created;
|
|
54
|
+
return created;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function expireIfStale(state: AuthThrottleState, now: number): void {
|
|
58
|
+
if (state.failures > 0 && now - state.lastFailureAt >= AUTH_THROTTLE_RESET_AFTER_MS) {
|
|
59
|
+
Object.assign(state, freshState());
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function backoffDelayMs(failures: number): number {
|
|
64
|
+
if (failures <= 0) return 0;
|
|
65
|
+
const exponent = Math.min(failures - 1, 31);
|
|
66
|
+
return Math.min(AUTH_THROTTLE_BASE_DELAY_MS * 2 ** exponent, AUTH_THROTTLE_MAX_DELAY_MS);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 还要等多少毫秒才允许下一次尝试;0 表示可以试 */
|
|
70
|
+
export function getAuthRetryAfterMs(now = Date.now(), state: AuthThrottleState = getGlobalState()): number {
|
|
71
|
+
expireIfStale(state, now);
|
|
72
|
+
return Math.max(0, state.blockedUntil - now);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 记一次失败,返回下一次尝试被强制的等待时间 */
|
|
76
|
+
export function recordAuthFailure(now = Date.now(), state: AuthThrottleState = getGlobalState()): number {
|
|
77
|
+
expireIfStale(state, now);
|
|
78
|
+
state.failures += 1;
|
|
79
|
+
state.lastFailureAt = now;
|
|
80
|
+
const delay = backoffDelayMs(state.failures);
|
|
81
|
+
state.blockedUntil = now + delay;
|
|
82
|
+
return delay;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 登录成功即清零(与上游 pi-web 的 recordAuthSuccess 一致)。
|
|
87
|
+
* 攻击者不知道密码就不可能触发这条路径,所以清零不会削弱防护,
|
|
88
|
+
* 而操作者输对一次之后不必继续背着之前的退避。
|
|
89
|
+
*/
|
|
90
|
+
export function recordAuthSuccess(state: AuthThrottleState = getGlobalState()): void {
|
|
91
|
+
Object.assign(state, freshState());
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 仅供测试:清掉挂在 globalThis 上的计数器 */
|
|
95
|
+
export function resetAuthThrottle(): void {
|
|
96
|
+
Object.assign(getGlobalState(), freshState());
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Retry-After 头的整秒值;被封禁时至少 1 */
|
|
100
|
+
export function retryAfterSeconds(retryAfterMs: number): number {
|
|
101
|
+
return Math.max(1, Math.ceil(retryAfterMs / 1000));
|
|
102
|
+
}
|
package/src/lib/auth.ts
CHANGED
|
@@ -1,28 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
/** 恒定时间字符串比较(长度不同也走完全程,不早退) */
|
|
4
|
-
export function equalText(left: string, right: string): boolean {
|
|
5
|
-
const max = Math.max(left.length, right.length);
|
|
6
|
-
let mismatch = left.length ^ right.length;
|
|
7
|
-
for (let i = 0; i < max; i += 1) {
|
|
8
|
-
mismatch |= (left.charCodeAt(i) || 0) ^ (right.charCodeAt(i) || 0);
|
|
9
|
-
}
|
|
10
|
-
return mismatch === 0;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function validBasicAuthorization(header: string | null, password: string): boolean {
|
|
14
|
-
if (!header?.startsWith(BASIC_PREFIX)) return false;
|
|
15
|
-
try {
|
|
16
|
-
const decoded = Buffer.from(header.slice(BASIC_PREFIX.length), "base64").toString("utf8");
|
|
17
|
-
const separator = decoded.indexOf(":");
|
|
18
|
-
if (separator < 0) return false;
|
|
19
|
-
return equalText(decoded.slice(0, separator), "pi") && equalText(decoded.slice(separator + 1), password);
|
|
20
|
-
} catch {
|
|
21
|
-
return false;
|
|
22
|
-
}
|
|
23
|
-
}
|
|
1
|
+
import { isIP } from "node:net";
|
|
24
2
|
|
|
3
|
+
/**
|
|
4
|
+
* 同源校验。跨站一律拒绝(Sec-Fetch-Site 由浏览器给出,脚本改不了),
|
|
5
|
+
* 没有 Origin 的请求(curl、服务端调用)放行。
|
|
6
|
+
*/
|
|
25
7
|
export function isSafeOrigin(request: Request): boolean {
|
|
8
|
+
if (request.headers.get("sec-fetch-site") === "cross-site") return false;
|
|
26
9
|
const origin = request.headers.get("origin");
|
|
27
10
|
if (!origin) return true;
|
|
28
11
|
try {
|
|
@@ -34,12 +17,36 @@ export function isSafeOrigin(request: Request): boolean {
|
|
|
34
17
|
}
|
|
35
18
|
}
|
|
36
19
|
|
|
37
|
-
export function
|
|
38
|
-
|
|
20
|
+
export function trustedHostnames(raw: string | undefined): string[] {
|
|
21
|
+
return (raw ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 白名单环境变量。PI_WEB_TRUSTED_HOSTS 是本项目的名字,PI_WEB_ALLOWED_HOSTS 是上游
|
|
26
|
+
* agegr/pi-web 的名字,两个都收,免得用户按上游文档配置却不生效。
|
|
27
|
+
*/
|
|
28
|
+
export function configuredTrustedHosts(env: Record<string, string | undefined> = process.env): string[] {
|
|
29
|
+
return [...trustedHostnames(env.PI_WEB_TRUSTED_HOSTS), ...trustedHostnames(env.PI_WEB_ALLOWED_HOSTS)];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Host 校验:无论有没有开认证都要做。
|
|
34
|
+
*
|
|
35
|
+
* 只信任本机名(含 *.localhost)、IP 字面量、以及操作者显式列出的主机名。
|
|
36
|
+
* IP 字面量保留局域网直连能力,且不可能被 DNS rebinding 利用 —— 浏览器会把字面
|
|
37
|
+
* 地址原样写进 Host,攻击者域名对不上。反代转发的域名必须进白名单:
|
|
38
|
+
* DNS rebinding 靠的是攻击者域名解析到回环后仍带自己的 Host,它不可能等于白名单里的名字。
|
|
39
|
+
*/
|
|
40
|
+
export function isSafeHost(request: Request, trustedHosts: readonly string[] = []): boolean {
|
|
39
41
|
try {
|
|
40
42
|
const host = request.headers.get("host") || new URL(request.url).host;
|
|
41
43
|
const parsed = new URL(`http://${host}`);
|
|
42
|
-
|
|
44
|
+
if (parsed.username || parsed.password || parsed.host.toLowerCase() !== host.toLowerCase()) return false;
|
|
45
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
46
|
+
if (isLoopbackHostname(hostname)) return true;
|
|
47
|
+
// URL 给 IPv6 字面量带着方括号([fe80::1]),isIP 只认去掉括号的写法
|
|
48
|
+
if (isIP(hostname.replace(/^\[(.*)\]$/, "$1")) !== 0) return true;
|
|
49
|
+
return trustedHosts.includes(hostname);
|
|
43
50
|
} catch {
|
|
44
51
|
return false;
|
|
45
52
|
}
|
|
@@ -47,5 +54,6 @@ export function isSafeHost(request: Request, passwordConfigured: boolean): boole
|
|
|
47
54
|
|
|
48
55
|
export function isLoopbackHostname(hostname: string): boolean {
|
|
49
56
|
const host = hostname.trim().toLowerCase().replace(/^\[|\]$/g, "");
|
|
50
|
-
|
|
57
|
+
if (host === "localhost" || host.endsWith(".localhost")) return true;
|
|
58
|
+
return host === "::1" || host === "0:0:0:0:0:0:0:1" || /^127(?:\.\d{1,3}){3}$/.test(host);
|
|
51
59
|
}
|
package/src/lib/files-service.ts
CHANGED
|
@@ -7,6 +7,7 @@ import fs from "node:fs/promises";
|
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { BoundaryError, isPathInside, resolveWorkspacePath } from "./path-security";
|
|
9
9
|
import { getAgentDir } from "./pi";
|
|
10
|
+
import { utf8Prefix } from "./text-sanitize";
|
|
10
11
|
|
|
11
12
|
export interface FileEntry {
|
|
12
13
|
name: string;
|
|
@@ -122,7 +123,7 @@ export async function readWorkspaceFile(cwdValue: unknown, relPath: unknown): Pr
|
|
|
122
123
|
} finally {
|
|
123
124
|
await handle.close();
|
|
124
125
|
}
|
|
125
|
-
const preview = buffer.subarray(0,
|
|
126
|
+
const preview = utf8Prefix(buffer.subarray(0, bytesRead), MAX_READ_BYTES);
|
|
126
127
|
// 只检查预览范围,绝不为识别二进制而加载整个大文件
|
|
127
128
|
if (preview.includes(0)) {
|
|
128
129
|
return { path: toRel(root, real), binary: true, truncated: false, content: "" };
|
|
@@ -185,9 +186,67 @@ export async function saveUpload(name: unknown, dataBase64: unknown): Promise<{
|
|
|
185
186
|
throw error;
|
|
186
187
|
}
|
|
187
188
|
await handle.close();
|
|
189
|
+
scheduleUploadSweep();
|
|
188
190
|
return { path: target, name: safeName, size: buffer.length };
|
|
189
191
|
}
|
|
190
192
|
|
|
193
|
+
/** 上传超过这么多天、且没有任何会话记录提到它,才会被清理 */
|
|
194
|
+
export const UPLOAD_RETENTION_DAYS = 30;
|
|
195
|
+
const UPLOAD_SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
196
|
+
let lastUploadSweep = 0;
|
|
197
|
+
|
|
198
|
+
/** 上传成功后顺带在后台清理,每个进程每天最多一次 */
|
|
199
|
+
function scheduleUploadSweep(): void {
|
|
200
|
+
const now = Date.now();
|
|
201
|
+
if (now - lastUploadSweep < UPLOAD_SWEEP_INTERVAL_MS) return;
|
|
202
|
+
lastUploadSweep = now;
|
|
203
|
+
void sweepUploads(now).catch(() => undefined);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 清理 web-uploads:消息里引用的是上传文件的绝对路径,所以只要还有会话 JSONL 提到文件名就保留
|
|
208
|
+
* (会话被删、或归档过期删除后,它的附件才变成孤儿);没发出去的草稿留 30 天的余量。
|
|
209
|
+
* 会话目录读不全就整次放弃,宁可不删也不误删。返回删掉的文件名。
|
|
210
|
+
*/
|
|
211
|
+
export async function sweepUploads(now = Date.now(), retentionDays = UPLOAD_RETENTION_DAYS): Promise<string[]> {
|
|
212
|
+
const agentDir = getAgentDir();
|
|
213
|
+
const dir = path.join(agentDir, "web-uploads");
|
|
214
|
+
const cutoff = now - retentionDays * 24 * 60 * 60 * 1000;
|
|
215
|
+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
216
|
+
// 文件名 → 在 JSONL 里可能出现的写法(原样 / JSON 转义后)
|
|
217
|
+
const candidates = new Map<string, string[]>();
|
|
218
|
+
for (const entry of entries) {
|
|
219
|
+
if (!entry.isFile()) continue;
|
|
220
|
+
const stat = await fs.stat(path.join(dir, entry.name)).catch(() => null);
|
|
221
|
+
if (!stat || stat.mtimeMs > cutoff) continue;
|
|
222
|
+
candidates.set(entry.name, [entry.name, JSON.stringify(entry.name).slice(1, -1)]);
|
|
223
|
+
}
|
|
224
|
+
if (!candidates.size) return [];
|
|
225
|
+
const sessionsRoot = path.join(agentDir, "sessions");
|
|
226
|
+
let sessionFiles: string[];
|
|
227
|
+
try {
|
|
228
|
+
sessionFiles = (await fs.readdir(sessionsRoot, { recursive: true })).filter((name) => name.endsWith(".jsonl"));
|
|
229
|
+
} catch (error) {
|
|
230
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") return [];
|
|
231
|
+
sessionFiles = [];
|
|
232
|
+
}
|
|
233
|
+
for (const name of sessionFiles) {
|
|
234
|
+
let text: string;
|
|
235
|
+
try {
|
|
236
|
+
text = await fs.readFile(path.join(sessionsRoot, name), "utf8");
|
|
237
|
+
} catch {
|
|
238
|
+
return [];
|
|
239
|
+
}
|
|
240
|
+
for (const [file, needles] of candidates) if (needles.some((needle) => text.includes(needle))) candidates.delete(file);
|
|
241
|
+
if (!candidates.size) return [];
|
|
242
|
+
}
|
|
243
|
+
const removed: string[] = [];
|
|
244
|
+
for (const file of candidates.keys()) {
|
|
245
|
+
await fs.unlink(path.join(dir, file)).then(() => removed.push(file), () => undefined);
|
|
246
|
+
}
|
|
247
|
+
return removed;
|
|
248
|
+
}
|
|
249
|
+
|
|
191
250
|
export function parseUploadLength(value: string | null): number {
|
|
192
251
|
if (value === null || !/^\d+$/.test(value)) throw new BoundaryError("missing or invalid upload size");
|
|
193
252
|
const size = Number(value);
|
|
@@ -220,6 +279,7 @@ export async function saveUploadStream(name: unknown, stream: ReadableStream<Uin
|
|
|
220
279
|
if (size === 0) throw new BoundaryError("empty upload");
|
|
221
280
|
if (expectedSize !== undefined && size !== expectedSize) throw new BoundaryError("incomplete upload: size does not match the declared length");
|
|
222
281
|
complete = true;
|
|
282
|
+
scheduleUploadSweep();
|
|
223
283
|
return { path: target, name: safeName, size };
|
|
224
284
|
} finally {
|
|
225
285
|
await reader.cancel().catch(() => undefined);
|
package/src/lib/font.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 字体族偏好:界面字体与代码字体各自独立选择。
|
|
3
|
+
* 与字号(piweb.fontSize / piweb.chatFontSize,见 theme.ts applyFontSize)分开,
|
|
4
|
+
* 存 localStorage:piweb.fontUi / piweb.fontMono,
|
|
5
|
+
* 通过覆盖 CSS 变量 --font-ui / --font-mono 生效(变量默认值见 globals.css)。
|
|
6
|
+
*
|
|
7
|
+
* 值有两种写法:
|
|
8
|
+
* - 预设 id(如 "maple" / "system" / "jetbrains")
|
|
9
|
+
* - 自定义字体栈,前缀 "custom:"(如 "custom:Fira Code, monospace")
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type FontScope = "ui" | "mono";
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_FONT_STACK: Record<FontScope, string> = {
|
|
15
|
+
ui: '"Maple Mono CN", ui-monospace, SFMono-Regular, Menlo, Consolas, "PingFang SC", "Microsoft YaHei", monospace',
|
|
16
|
+
mono: '"Maple Mono CN", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export interface FontPreset {
|
|
20
|
+
id: string;
|
|
21
|
+
stack: string;
|
|
22
|
+
label: { zh: string; en: string };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const FONT_PRESETS: Record<FontScope, FontPreset[]> = {
|
|
26
|
+
ui: [
|
|
27
|
+
{ id: "maple", stack: DEFAULT_FONT_STACK.ui, label: { zh: "Maple Mono CN(默认)", en: "Maple Mono CN (default)" } },
|
|
28
|
+
{ id: "system", stack: 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif', label: { zh: "系统默认", en: "System default" } },
|
|
29
|
+
{ id: "noto", stack: '"Noto Sans SC", ui-sans-serif, system-ui, "PingFang SC", "Microsoft YaHei", sans-serif', label: { zh: "思源黑体", en: "Noto Sans SC" } },
|
|
30
|
+
],
|
|
31
|
+
mono: [
|
|
32
|
+
{ id: "maple", stack: DEFAULT_FONT_STACK.mono, label: { zh: "Maple Mono CN(默认)", en: "Maple Mono CN (default)" } },
|
|
33
|
+
{ id: "system", stack: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", label: { zh: "系统等宽", en: "System monospace" } },
|
|
34
|
+
{ id: "jetbrains", stack: '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', label: { zh: "JetBrains Mono", en: "JetBrains Mono" } },
|
|
35
|
+
{ id: "fira", stack: '"Fira Code", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', label: { zh: "Fira Code", en: "Fira Code" } },
|
|
36
|
+
],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const CUSTOM_PREFIX = "custom:";
|
|
40
|
+
const KEY: Record<FontScope, string> = { ui: "piweb.fontUi", mono: "piweb.fontMono" };
|
|
41
|
+
const CSS_VAR: Record<FontScope, string> = { ui: "--font-ui", mono: "--font-mono" };
|
|
42
|
+
|
|
43
|
+
/** localStorage 里的原始偏好(预设 id 或 custom: 前缀的字体栈) */
|
|
44
|
+
export function loadFontChoice(scope: FontScope): string | null {
|
|
45
|
+
if (typeof window === "undefined") return null;
|
|
46
|
+
try {
|
|
47
|
+
return localStorage.getItem(KEY[scope]);
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 把偏好解析成可直接写进 CSS 变量的字体栈;无法识别时回落默认 */
|
|
54
|
+
export function resolveFontStack(scope: FontScope, stored: string | null): string {
|
|
55
|
+
if (!stored) return DEFAULT_FONT_STACK[scope];
|
|
56
|
+
if (stored.startsWith(CUSTOM_PREFIX)) {
|
|
57
|
+
const custom = stored.slice(CUSTOM_PREFIX.length).trim();
|
|
58
|
+
return custom || DEFAULT_FONT_STACK[scope];
|
|
59
|
+
}
|
|
60
|
+
return FONT_PRESETS[scope].find((p) => p.id === stored)?.stack ?? DEFAULT_FONT_STACK[scope];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 应用并持久化;value 传 null 表示恢复默认(移除内联覆盖 + 清 localStorage) */
|
|
64
|
+
export function applyFontChoice(scope: FontScope, value: string | null): void {
|
|
65
|
+
if (typeof window === "undefined") return;
|
|
66
|
+
const root = document.documentElement;
|
|
67
|
+
try {
|
|
68
|
+
if (!value) {
|
|
69
|
+
root.style.removeProperty(CSS_VAR[scope]);
|
|
70
|
+
localStorage.removeItem(KEY[scope]);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
root.style.setProperty(CSS_VAR[scope], resolveFontStack(scope, value));
|
|
74
|
+
localStorage.setItem(KEY[scope], value);
|
|
75
|
+
} catch {
|
|
76
|
+
/* ignore */
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 启动 / 系统主题变化 / 跨标签同步时重放两处字体偏好 */
|
|
81
|
+
export function applyAllFonts(): void {
|
|
82
|
+
applyFontChoice("ui", loadFontChoice("ui"));
|
|
83
|
+
applyFontChoice("mono", loadFontChoice("mono"));
|
|
84
|
+
}
|
package/src/lib/git-service.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { execFile } from "node:child_process";
|
|
|
7
7
|
import fs from "node:fs/promises";
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { promisify } from "node:util";
|
|
10
|
+
import { resolveGitBin } from "./growth-service";
|
|
10
11
|
import { BoundaryError, isPathInside, resolveWorkspacePath } from "./path-security";
|
|
11
12
|
|
|
12
13
|
const exec = promisify(execFile);
|
|
@@ -61,8 +62,9 @@ const EMPTY: GitInfo = {
|
|
|
61
62
|
/** 单次差异输出上限:超过则截断并标记(详情栏不是完整的 diff 工具) */
|
|
62
63
|
const MAX_DIFF_BYTES = 400 * 1024;
|
|
63
64
|
|
|
64
|
-
|
|
65
|
-
|
|
65
|
+
/** 与生长记录共用解析出的 git(macOS 从 GUI / launchd 启动时 PATH 里常常没有 Homebrew 的 git) */
|
|
66
|
+
async function runGit(cwd: string, args: string[], maxBuffer = 4 * 1024 * 1024): Promise<string> {
|
|
67
|
+
return exec(await resolveGitBin(), args, { cwd, timeout: 15_000, maxBuffer, windowsHide: true, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0" } }).then((r) => r.stdout);
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
function kindOf(x: string, y: string): GitFileEntry["kind"] {
|
|
@@ -164,7 +166,7 @@ export async function gitInfo(cwdValue: unknown): Promise<GitInfo> {
|
|
|
164
166
|
.split("\n")
|
|
165
167
|
.filter(Boolean)
|
|
166
168
|
.map((line) => {
|
|
167
|
-
const [hash, subject, author, relative] = line.split("
|
|
169
|
+
const [hash, subject, author, relative] = line.split("\x1f");
|
|
168
170
|
return { hash, subject: subject ?? "", author: author ?? "", relative: relative ?? "" };
|
|
169
171
|
});
|
|
170
172
|
} catch {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* growth-service:项目生长 = 每轮一个 git commit(工作区自带 git,无影子仓库、无旁路账本)。
|
|
3
3
|
*
|
|
4
|
-
* commit 直接落在工作区自己的 git
|
|
5
|
-
*
|
|
4
|
+
* commit 直接落在工作区自己的 git 仓库里:工作区已是仓库根目录就直接用;
|
|
5
|
+
* 非 git 目录、或只是别的仓库的子目录,要用户同意后(allowInit)才在工作区根 `git init`
|
|
6
|
+
* (子目录则嵌套 init),保证记录范围恰好等于工作区;没同意就抛 needs-consent。
|
|
6
7
|
* 每轮结束 = 往 PiWeb 独立暂存区 <gitdir>/piweb/index(GIT_INDEX_FILE 传入)add 全部改动,
|
|
7
8
|
* write-tree + commit-tree,挂到专用引用 refs/piweb/rounds/<key>:不碰 HEAD、不碰用户暂存区、不占分支。
|
|
8
9
|
* 排除规则 <gitdir>/piweb/exclude 只叠加、绝不改写用户的 info/exclude。
|
|
@@ -19,6 +20,7 @@ import { existsSync, realpathSync } from "node:fs";
|
|
|
19
20
|
import fs from "node:fs/promises";
|
|
20
21
|
import path from "node:path";
|
|
21
22
|
import { GROWTH_EXCLUDE_DIRS } from "./growth-tree";
|
|
23
|
+
import { utf8Prefix } from "./text-sanitize";
|
|
22
24
|
import type { GrowthChange, GrowthRound, GrowthRoundKind, GrowthRoundStatus } from "./types";
|
|
23
25
|
|
|
24
26
|
/** 单文件快照上限:超过的不进快照(树里也就看不到它) */
|
|
@@ -42,10 +44,17 @@ export const EXCLUDE_DIRS = GROWTH_EXCLUDE_DIRS;
|
|
|
42
44
|
const EXCLUDE_FILES = [".DS_Store", "Thumbs.db", "*.log", ".env", ".env.*", "!.env.example", "*.pem", "*.key", "*.p12", "*.pfx", "id_rsa*", "id_ed25519*"];
|
|
43
45
|
const EXCLUDE_CONTENT = `# PiWeb growth snapshot exclusions (generated, do not edit)\n${[...EXCLUDE_DIRS.map((d) => `${d}/`), ...EXCLUDE_FILES].join("\n")}\n`;
|
|
44
46
|
|
|
47
|
+
/** 工作区还不是仓库根目录:not-git = 不在任何仓库里;nested = 是 parent 仓库的子目录 */
|
|
48
|
+
export interface GrowthInitTarget {
|
|
49
|
+
reason: "not-git" | "nested";
|
|
50
|
+
parent?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
45
53
|
export class GrowthError extends Error {
|
|
46
54
|
constructor(
|
|
47
55
|
message: string,
|
|
48
|
-
public code: "unavailable" | "too-large" | "not-found" | "failed" = "failed",
|
|
56
|
+
public code: "unavailable" | "too-large" | "not-found" | "failed" | "needs-consent" = "failed",
|
|
57
|
+
public target?: GrowthInitTarget,
|
|
49
58
|
) {
|
|
50
59
|
super(message);
|
|
51
60
|
this.name = "GrowthError";
|
|
@@ -120,9 +129,9 @@ type Env = Record<string, string | undefined>;
|
|
|
120
129
|
/**
|
|
121
130
|
* 解析 git 可执行文件:macOS GUI/launchd 启动的进程 PATH 常不含 Homebrew,
|
|
122
131
|
* spawn("git") 会 ENOENT → 生长快照整体误判不可用。PATH 找不到时退回常见安装位置。
|
|
123
|
-
* 只在非 Windows 生效;结果缓存到 registry(成功后不再变)。
|
|
132
|
+
* 只在非 Windows 生效;结果缓存到 registry(成功后不再变)。git-service 也用它。
|
|
124
133
|
*/
|
|
125
|
-
async function resolveGitBin(): Promise<string> {
|
|
134
|
+
export async function resolveGitBin(): Promise<string> {
|
|
126
135
|
if (registry.gitBin) return registry.gitBin;
|
|
127
136
|
let bin = "git";
|
|
128
137
|
if (process.platform !== "win32") {
|
|
@@ -207,10 +216,14 @@ async function gitOk(): Promise<boolean> {
|
|
|
207
216
|
if (registry.gitFailedAt && Date.now() - registry.gitFailedAt < GIT_FAIL_RETRY_MS) return false;
|
|
208
217
|
const check = runGit(["--version"]).then((r) => r.code === 0, () => false);
|
|
209
218
|
registry.gitOk = check;
|
|
219
|
+
// 成功结果一直留着(项目栏每次打开都会问一次,不必每次起一个 git 进程);失败只短缓存
|
|
210
220
|
void check.then((ok) => {
|
|
221
|
+
if (ok) {
|
|
222
|
+
registry.gitFailedAt = undefined;
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
211
225
|
registry.gitOk = undefined;
|
|
212
|
-
|
|
213
|
-
else registry.gitFailedAt = Date.now();
|
|
226
|
+
registry.gitFailedAt = Date.now();
|
|
214
227
|
});
|
|
215
228
|
return check;
|
|
216
229
|
}
|
|
@@ -285,26 +298,28 @@ async function refExistsFast(gitDir: string, ref: string): Promise<boolean> {
|
|
|
285
298
|
|
|
286
299
|
/**
|
|
287
300
|
* 解析工作区可用的 git-dir:工作区已是某仓库的工作树根就直接复用;
|
|
288
|
-
* 否则(非 git
|
|
301
|
+
* 否则(非 git 目录、或只是别的仓库的子目录)只有 allowInit(用户同意过)才在工作区根 `git init`,
|
|
302
|
+
* 不然抛 needs-consent 并带上原因,由界面去问。
|
|
289
303
|
* init 失败(目录不可写等)按终态处理,本会话停用生长跟踪。
|
|
290
304
|
*/
|
|
291
|
-
async function ensureGitDir(cwd: string): Promise<string> {
|
|
292
|
-
const
|
|
305
|
+
async function ensureGitDir(cwd: string, allowInit: boolean): Promise<string> {
|
|
306
|
+
const probe = async (): Promise<{ gitDir: string } | GrowthInitTarget> => {
|
|
293
307
|
const r = await runGit(["rev-parse", "--absolute-git-dir", "--show-toplevel", "--is-bare-repository"], { cwd });
|
|
294
|
-
if (r.code !== 0) return
|
|
308
|
+
if (r.code !== 0) return { reason: "not-git" };
|
|
295
309
|
const [gitDir = "", top = "", bare = ""] = r.stdout.toString("utf8").split("\n").map((s) => s.trim());
|
|
296
|
-
if (!gitDir || bare === "true" || !top) return
|
|
310
|
+
if (!gitDir || bare === "true" || !top) return { reason: "not-git" };
|
|
297
311
|
// 快照范围必须恰好等于工作区:落在别的仓库的子目录里时,宁可嵌套 init 自己的仓库
|
|
298
|
-
if (comparablePath(realpathSafe(top)) !== comparablePath(realpathSafe(cwd))) return
|
|
299
|
-
return gitDir;
|
|
312
|
+
if (comparablePath(realpathSafe(top)) !== comparablePath(realpathSafe(cwd))) return { reason: "nested", parent: path.resolve(top) };
|
|
313
|
+
return { gitDir };
|
|
300
314
|
};
|
|
301
|
-
const existing = await
|
|
302
|
-
if (existing) return existing;
|
|
315
|
+
const existing = await probe();
|
|
316
|
+
if ("gitDir" in existing) return existing.gitDir;
|
|
317
|
+
if (!allowInit) throw new GrowthError("growth tracking needs consent to create a git repository here", "needs-consent", existing);
|
|
303
318
|
const r = await runGit(["init", "-q"], { cwd });
|
|
304
319
|
if (r.code !== 0) throw new GrowthError(`git init failed: ${r.stderr.trim().split("\n")[0] || `exit ${r.code}`}`, "unavailable");
|
|
305
|
-
const created = await
|
|
306
|
-
if (!created) throw new GrowthError("workspace cannot be used as a git repository", "unavailable");
|
|
307
|
-
return created;
|
|
320
|
+
const created = await probe();
|
|
321
|
+
if (!("gitDir" in created)) throw new GrowthError("workspace cannot be used as a git repository", "unavailable");
|
|
322
|
+
return created.gitDir;
|
|
308
323
|
}
|
|
309
324
|
|
|
310
325
|
/** 链头的 commit 与 tree;引用不存在返回 null(for-each-ref 对不存在的引用输出空、退出码 0) */
|
|
@@ -314,10 +329,10 @@ async function readHead(ws: WorkspaceState): Promise<{ commit: string; tree: str
|
|
|
314
329
|
return HASH_RE.test(commit) && HASH_RE.test(tree) ? { commit, tree } : null;
|
|
315
330
|
}
|
|
316
331
|
|
|
317
|
-
async function createWorkspace(cwd: string): Promise<WorkspaceState> {
|
|
332
|
+
async function createWorkspace(cwd: string, allowInit: boolean): Promise<WorkspaceState> {
|
|
318
333
|
if (!(await gitOk())) throw new GrowthError("git is not installed", "unavailable");
|
|
319
334
|
const key = workspaceKey(cwd);
|
|
320
|
-
const gitDir = await ensureGitDir(cwd);
|
|
335
|
+
const gitDir = await ensureGitDir(cwd, allowInit);
|
|
321
336
|
const piwebDir = path.join(gitDir, "piweb");
|
|
322
337
|
await fs.mkdir(piwebDir, { recursive: true });
|
|
323
338
|
const ws: WorkspaceState = {
|
|
@@ -340,7 +355,8 @@ async function createWorkspace(cwd: string): Promise<WorkspaceState> {
|
|
|
340
355
|
return ws;
|
|
341
356
|
}
|
|
342
357
|
|
|
343
|
-
|
|
358
|
+
/** allowInit:工作区还不是仓库根目录时是否允许 `git init`(只有提交路径、且用户同意过才传 true;读取一律不建仓库) */
|
|
359
|
+
async function getWorkspace(cwdValue: string, allowInit = false): Promise<WorkspaceState> {
|
|
344
360
|
const cwd = await fs.realpath(path.resolve(cwdValue)).catch(() => path.resolve(cwdValue));
|
|
345
361
|
const key = workspaceKey(cwd);
|
|
346
362
|
let pending = registry.workspaces.get(key);
|
|
@@ -355,7 +371,7 @@ async function getWorkspace(cwdValue: string): Promise<WorkspaceState> {
|
|
|
355
371
|
}
|
|
356
372
|
}
|
|
357
373
|
if (!pending) {
|
|
358
|
-
pending = createWorkspace(cwd);
|
|
374
|
+
pending = createWorkspace(cwd, allowInit);
|
|
359
375
|
registry.workspaces.set(key, pending);
|
|
360
376
|
pending.catch(() => registry.workspaces.delete(key));
|
|
361
377
|
}
|
|
@@ -663,14 +679,39 @@ export interface RoundMeta {
|
|
|
663
679
|
status?: GrowthRoundStatus;
|
|
664
680
|
}
|
|
665
681
|
|
|
682
|
+
export interface CommitOptions {
|
|
683
|
+
/** 工作区还不是仓库根目录时允许 `git init`(用户同意过) */
|
|
684
|
+
allowInit?: boolean;
|
|
685
|
+
/** 拍完快照、提交之前再问一次:返回 true 就放弃这次提交(返回 null) */
|
|
686
|
+
abortIf?: () => boolean;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** 每个工作区每提交这么多次跑一次 `git gc --auto`(与 git commit 自己的做法一样,没到阈值时什么也不做) */
|
|
690
|
+
const GC_EVERY_COMMITS = 50;
|
|
691
|
+
const commitsSinceGc = new Map<string, number>();
|
|
692
|
+
|
|
693
|
+
/** 不等、不设超时(大仓库打包可能要很久,中途杀掉白干);也不占工作区锁,并发由 git 自己的锁处理 */
|
|
694
|
+
async function gcInBackground(ws: WorkspaceState): Promise<void> {
|
|
695
|
+
const gitBin = await resolveGitBin();
|
|
696
|
+
const child = spawn(gitBin, ["--git-dir", ws.gitDir, "gc", "--auto", "--quiet"], {
|
|
697
|
+
cwd: ws.cwd,
|
|
698
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
|
699
|
+
stdio: "ignore",
|
|
700
|
+
windowsHide: true,
|
|
701
|
+
});
|
|
702
|
+
child.on("error", () => undefined);
|
|
703
|
+
child.unref();
|
|
704
|
+
}
|
|
705
|
+
|
|
666
706
|
/**
|
|
667
707
|
* 提交一个 commit 到 refs/piweb/rounds/<key>(做法等同 git add -A + git commit,但只用 PiWeb 独立暂存区)。
|
|
668
708
|
* pi 的一轮无论有没有改动都提交(空 commit 也算一轮,轮号与对话一一对应);
|
|
669
709
|
* 用户修改 / 基线只在工作区相对上一个 commit 有变化时提交,否则返回 null。
|
|
670
710
|
*/
|
|
671
|
-
export async function commitRound(cwdValue: string, meta: RoundMeta): Promise<GrowthRound | null> {
|
|
672
|
-
const ws = await getWorkspace(cwdValue);
|
|
711
|
+
export async function commitRound(cwdValue: string, meta: RoundMeta, options: CommitOptions = {}): Promise<GrowthRound | null> {
|
|
712
|
+
const ws = await getWorkspace(cwdValue, options.allowInit === true);
|
|
673
713
|
return withLock(ws, async () => {
|
|
714
|
+
if (options.abortIf?.()) return null;
|
|
674
715
|
if (ws.excludeRules !== EXCLUDE_CONTENT) {
|
|
675
716
|
await fs.writeFile(ws.excludeFile, EXCLUDE_CONTENT, "utf8");
|
|
676
717
|
ws.excludeRules = EXCLUDE_CONTENT;
|
|
@@ -678,6 +719,8 @@ export async function commitRound(cwdValue: string, meta: RoundMeta): Promise<Gr
|
|
|
678
719
|
await refreshIndex(ws);
|
|
679
720
|
const tree = (await shadow(ws, ["write-tree"])).stdout.toString("utf8").trim();
|
|
680
721
|
assertHash(tree, "tree");
|
|
722
|
+
// 快照拍到一半时 pi 已经开始改文件:分不清谁改的,宁可不记(改动并入 pi 这一轮)
|
|
723
|
+
if (options.abortIf?.()) return null;
|
|
681
724
|
const env = {
|
|
682
725
|
GIT_AUTHOR_NAME: "PiWeb", GIT_AUTHOR_EMAIL: "piweb@localhost",
|
|
683
726
|
GIT_COMMITTER_NAME: "PiWeb", GIT_COMMITTER_EMAIL: "piweb@localhost",
|
|
@@ -721,15 +764,20 @@ export async function commitRound(cwdValue: string, meta: RoundMeta): Promise<Gr
|
|
|
721
764
|
}
|
|
722
765
|
ws.lastCommit = commit;
|
|
723
766
|
ws.lastTree = tree;
|
|
724
|
-
//
|
|
767
|
+
// commit-tree 是底层命令,不会像 git commit 那样顺手 gc --auto:快照对象一直是松散文件、只增不减。
|
|
768
|
+
// 隔一段补一次,没到阈值 git 什么都不做。不带 PiWeb 暂存区:用户自己 index 里的对象照常算可达;
|
|
769
|
+
// PiWeb 暂存区此刻刚整体提交,里面的对象都已挂在专用引用上
|
|
770
|
+
const count = (commitsSinceGc.get(ws.key) ?? 0) + 1;
|
|
771
|
+
commitsSinceGc.set(ws.key, count % GC_EVERY_COMMITS);
|
|
772
|
+
if (count >= GC_EVERY_COMMITS) void gcInBackground(ws);
|
|
725
773
|
return round;
|
|
726
774
|
}
|
|
727
775
|
});
|
|
728
776
|
}
|
|
729
777
|
|
|
730
778
|
/** 发 prompt 前调用:把两轮之间用户自己的修改单独记一笔(工作区还没有任何提交时记成基线),没改动不提交 */
|
|
731
|
-
export function recordWorkspaceChanges(cwdValue: string, session: string): Promise<GrowthRound | null> {
|
|
732
|
-
return commitRound(cwdValue, { kind: "user", session });
|
|
779
|
+
export function recordWorkspaceChanges(cwdValue: string, session: string, options: CommitOptions = {}): Promise<GrowthRound | null> {
|
|
780
|
+
return commitRound(cwdValue, { kind: "user", session }, options);
|
|
733
781
|
}
|
|
734
782
|
|
|
735
783
|
// ---------- 读取 ----------
|
|
@@ -835,7 +883,7 @@ export async function filePatch(cwdValue: string, from: unknown, to: unknown, re
|
|
|
835
883
|
const r = await shadow(ws, ["diff", "-M", "--no-color", "--no-ext-diff", "-U1000000", assertHash(from, "from"), assertHash(to, "to"), "--", ...paths]);
|
|
836
884
|
if (/^Binary files .* differ$/m.test(r.stdout.toString("utf8", 0, Math.min(r.stdout.length, 64 * 1024)))) return { patch: "", binary: true, truncated: false };
|
|
837
885
|
if (r.stdout.length <= MAX_PATCH_BYTES) return { patch: r.stdout.toString("utf8"), binary: false, truncated: false };
|
|
838
|
-
return { patch: r.stdout
|
|
886
|
+
return { patch: utf8Prefix(r.stdout, MAX_PATCH_BYTES).toString("utf8"), binary: false, truncated: true };
|
|
839
887
|
}
|
|
840
888
|
|
|
841
889
|
export interface GrowthContent {
|
|
@@ -855,5 +903,5 @@ export async function fileContent(cwdValue: string, tree: unknown, relPath: unkn
|
|
|
855
903
|
const head = buf.subarray(0, Math.min(buf.length, 8000));
|
|
856
904
|
if (head.includes(0)) return { content: "", binary: true, truncated: false, size: buf.length };
|
|
857
905
|
if (buf.length <= MAX_CONTENT_BYTES) return { content: buf.toString("utf8"), binary: false, truncated: false, size: buf.length };
|
|
858
|
-
return { content: buf
|
|
906
|
+
return { content: utf8Prefix(buf, MAX_CONTENT_BYTES).toString("utf8"), binary: false, truncated: true, size: buf.length };
|
|
859
907
|
}
|