@ai-workbench/connect 0.0.0-stage → 0.7.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE.md +18 -0
- package/README.md +26 -2
- package/client/index.cjs +124 -0
- package/cordis.patch.yml +3 -0
- package/helper/auth-status.cjs +18 -0
- package/helper/claude-callback.cjs +29 -0
- package/helper/main.cjs +19 -0
- package/helper/projects.cjs +57 -0
- package/helper/remote-command.cjs +15 -0
- package/helper/remote-login.cjs +25 -0
- package/helper/window.cjs +52 -0
- package/lib/api.d.ts +196 -0
- package/lib/api.js +1 -0
- package/lib/archive.d.ts +1 -0
- package/lib/archive.js +63 -0
- package/lib/auth-status.d.ts +3 -0
- package/lib/auth-status.js +18 -0
- package/lib/cache.d.ts +21 -0
- package/lib/cache.js +156 -0
- package/lib/component-download.d.ts +32 -0
- package/lib/component-download.js +76 -0
- package/lib/component-source.d.ts +52 -0
- package/lib/component-source.js +146 -0
- package/lib/components.d.ts +10 -0
- package/lib/components.js +154 -0
- package/lib/connect.d.ts +10 -0
- package/lib/connect.js +246 -0
- package/lib/cordis.d.ts +15 -0
- package/lib/cordis.js +1 -0
- package/lib/defaults.d.ts +30 -0
- package/lib/defaults.js +4 -0
- package/lib/electron.d.ts +8 -0
- package/lib/electron.js +83 -0
- package/lib/enrollment.d.ts +3 -0
- package/lib/enrollment.js +78 -0
- package/lib/index.d.ts +42 -0
- package/lib/index.js +55 -0
- package/lib/integration.d.ts +6 -0
- package/lib/integration.js +56 -0
- package/lib/login.d.ts +35 -0
- package/lib/login.js +81 -0
- package/lib/native-import-picker.d.ts +11 -0
- package/lib/native-import-picker.js +49 -0
- package/lib/needs.d.ts +9 -0
- package/lib/needs.js +52 -0
- package/lib/network.d.ts +26 -0
- package/lib/network.js +226 -0
- package/lib/physical-fs.d.ts +3 -0
- package/lib/physical-fs.js +7 -0
- package/lib/ports.d.ts +43 -0
- package/lib/ports.js +1 -0
- package/lib/private-files.d.ts +3 -0
- package/lib/private-files.js +41 -0
- package/lib/processes.d.ts +5 -0
- package/lib/processes.js +147 -0
- package/lib/profile-activation.d.ts +32 -0
- package/lib/profile-activation.js +118 -0
- package/lib/profile-enrollment.d.ts +21 -0
- package/lib/profile-enrollment.js +275 -0
- package/lib/profile-runtime.d.ts +6 -0
- package/lib/profile-runtime.js +47 -0
- package/lib/profile-settings.d.ts +2 -0
- package/lib/profile-settings.js +13 -0
- package/lib/projects.d.ts +4 -0
- package/lib/projects.js +41 -0
- package/lib/provider.d.ts +73 -0
- package/lib/provider.js +39 -0
- package/lib/remote-login.d.ts +2 -0
- package/lib/remote-login.js +9 -0
- package/lib/ssh.d.ts +10 -0
- package/lib/ssh.js +78 -0
- package/lib/startup.d.ts +18 -0
- package/lib/startup.js +211 -0
- package/lib/workspace-bindings.d.ts +32 -0
- package/lib/workspace-bindings.js +328 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/node_modules/argparse/LICENSE +254 -0
- package/node_modules/argparse/README.md +84 -0
- package/node_modules/argparse/argparse.js +3707 -0
- package/node_modules/argparse/lib/sub.js +67 -0
- package/node_modules/argparse/lib/textwrap.js +440 -0
- package/node_modules/argparse/package.json +31 -0
- package/node_modules/js-yaml/LICENSE +21 -0
- package/node_modules/js-yaml/README.md +233 -0
- package/node_modules/js-yaml/bin/js-yaml.js +117 -0
- package/node_modules/js-yaml/dist/js-yaml.js +3022 -0
- package/node_modules/js-yaml/dist/js-yaml.js.map +1 -0
- package/node_modules/js-yaml/dist/js-yaml.min.js +2 -0
- package/node_modules/js-yaml/dist/js-yaml.min.js.map +1 -0
- package/node_modules/js-yaml/dist/js-yaml.mjs +3082 -0
- package/node_modules/js-yaml/dist/js-yaml.mjs.map +1 -0
- package/node_modules/js-yaml/index.js +44 -0
- package/node_modules/js-yaml/lib/common.js +50 -0
- package/node_modules/js-yaml/lib/dumper.js +937 -0
- package/node_modules/js-yaml/lib/exception.js +51 -0
- package/node_modules/js-yaml/lib/index_vite_proxy.tmp.mjs +37 -0
- package/node_modules/js-yaml/lib/loader.js +1803 -0
- package/node_modules/js-yaml/lib/schema/core.js +9 -0
- package/node_modules/js-yaml/lib/schema/default.js +20 -0
- package/node_modules/js-yaml/lib/schema/failsafe.js +14 -0
- package/node_modules/js-yaml/lib/schema/json.js +17 -0
- package/node_modules/js-yaml/lib/schema.js +109 -0
- package/node_modules/js-yaml/lib/snippet.js +96 -0
- package/node_modules/js-yaml/lib/type/binary.js +122 -0
- package/node_modules/js-yaml/lib/type/bool.js +35 -0
- package/node_modules/js-yaml/lib/type/float.js +99 -0
- package/node_modules/js-yaml/lib/type/int.js +142 -0
- package/node_modules/js-yaml/lib/type/map.js +8 -0
- package/node_modules/js-yaml/lib/type/merge.js +12 -0
- package/node_modules/js-yaml/lib/type/null.js +35 -0
- package/node_modules/js-yaml/lib/type/omap.js +45 -0
- package/node_modules/js-yaml/lib/type/pairs.js +50 -0
- package/node_modules/js-yaml/lib/type/seq.js +8 -0
- package/node_modules/js-yaml/lib/type/set.js +29 -0
- package/node_modules/js-yaml/lib/type/str.js +8 -0
- package/node_modules/js-yaml/lib/type/timestamp.js +88 -0
- package/node_modules/js-yaml/lib/type.js +66 -0
- package/node_modules/js-yaml/package.json +83 -0
- package/node_modules/pend/LICENSE +23 -0
- package/node_modules/pend/README.md +41 -0
- package/node_modules/pend/index.js +55 -0
- package/node_modules/pend/package.json +18 -0
- package/node_modules/pend/test.js +137 -0
- package/node_modules/yauzl/LICENSE +21 -0
- package/node_modules/yauzl/README.md +1015 -0
- package/node_modules/yauzl/crc32.js +45 -0
- package/node_modules/yauzl/fd-slicer.js +188 -0
- package/node_modules/yauzl/index.js +1096 -0
- package/node_modules/yauzl/package.json +40 -0
- package/package.json +89 -4
package/lib/startup.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { fail, need, ok } from "./needs.js";
|
|
3
|
+
export function employeeMessage(code) {
|
|
4
|
+
if (code === 'ENROLLMENT_RESULT_UNKNOWN' || code === 'ENROLLMENT_REJECTED')
|
|
5
|
+
return need(code).message;
|
|
6
|
+
if (code === 'DEPENDENCY_REQUIRED')
|
|
7
|
+
return '远程主机连接尚未准备好,请联系管理员。';
|
|
8
|
+
if (code === 'ENROLLMENT_REQUIRED' || code === 'ENROLLMENT_INVALID' || code === 'NETWORK_KEY_INVALID')
|
|
9
|
+
return '我需要连接资料,请联系管理员发放。';
|
|
10
|
+
if (code === 'SETTINGS_REQUIRED')
|
|
11
|
+
return '远程主机连接还没有设置好,请联系管理员。';
|
|
12
|
+
if (code === 'LOGIN_FAILED')
|
|
13
|
+
return '登录没有完成。你可以再次请求登录,或稍后再试。';
|
|
14
|
+
if (code === 'POLICY_DENIED')
|
|
15
|
+
return '这个账号暂时不能使用,请联系公司账号管理员。';
|
|
16
|
+
return '远程主机暂时连不上,请联系管理员。';
|
|
17
|
+
}
|
|
18
|
+
export function createStartup(api, providers, emit = () => { }, result = () => { }, instanceId = randomUUID()) {
|
|
19
|
+
let disposed = false, revision = 0, serial = 0, generation = 0, checkSerial = 0, connectionPhase = 'unavailable', observedConnected = false;
|
|
20
|
+
let flight, authorization, authorizationId;
|
|
21
|
+
const pending = new Set(), listeners = new Set(), deferred = new Set();
|
|
22
|
+
let state = { instanceId, revision: 0, phase: 'checking', message: '正在连接远程主机…', logins: {}, checkingProviders: [], providerNeeds: {}, technicalDetails: {} };
|
|
23
|
+
const publish = (next) => { state = { ...next, instanceId, revision: ++revision }; emit(structuredClone(state)); for (const listener of listeners)
|
|
24
|
+
listener(structuredClone(state)); };
|
|
25
|
+
const track = (p) => { pending.add(p); void p.then(() => pending.delete(p), () => pending.delete(p)); return p; };
|
|
26
|
+
const close = (outcome, code) => { if (state.requestId && state.provider)
|
|
27
|
+
result({ requestId: state.requestId, provider: state.provider, outcome, ...(code ? { need: need(code) } : {}) }); };
|
|
28
|
+
const blocked = (code) => { close('invalidated', code); publish({ phase: 'blocked', message: employeeMessage(code), logins: {}, checkingProviders: [], providerNeeds: {}, technicalDetails: { ...state.technicalDetails, code } }); };
|
|
29
|
+
const snapshot = () => state.phase === 'disposed' ? fail('DISPOSED') : state.phase === 'blocked' ? fail(state.technicalDetails.code ?? 'AUTH_STATUS_INVALID') : ok(structuredClone(state));
|
|
30
|
+
const ask = () => {
|
|
31
|
+
const provider = state.requestId && state.provider && state.logins[state.provider] === false && !deferred.has(state.provider) ? state.provider : providers.find(p => state.logins[p] === false && !deferred.has(p));
|
|
32
|
+
const same = provider && state.provider === provider && state.requestId && state.phase === 'login-required';
|
|
33
|
+
publish({ ...state, phase: provider ? 'login-required' : 'idle', provider, requestId: provider ? (same ? state.requestId : 'login-' + instanceId + '-' + (++serial)) : undefined, message: provider ? '需要登录,请到设置 → 远程主机连接 点登录' : Object.keys(state.providerNeeds).length ? Object.keys(state.providerNeeds).map(p => p === 'claude' ? 'Claude' : 'Codex').join('、') + ' 登录状态无法确认,请联系管理员。' : '远程主机连接检查完成。' });
|
|
34
|
+
};
|
|
35
|
+
const check = () => {
|
|
36
|
+
if (disposed)
|
|
37
|
+
return Promise.resolve();
|
|
38
|
+
if (authorization)
|
|
39
|
+
return Promise.resolve();
|
|
40
|
+
if (flight)
|
|
41
|
+
return flight;
|
|
42
|
+
const mine = generation, checkMine = ++checkSerial;
|
|
43
|
+
const run = async () => {
|
|
44
|
+
try {
|
|
45
|
+
// Preserve an existing question while gathering fresh evidence, so duplicate requests keep its ticket.
|
|
46
|
+
publish({ ...state, ...(!state.requestId ? { phase: 'checking', message: '正在检查登录状态…' } : {}), checkingProviders: [] });
|
|
47
|
+
const connected = await api.connect();
|
|
48
|
+
if (disposed || mine !== generation || checkMine !== checkSerial)
|
|
49
|
+
return;
|
|
50
|
+
if (!connected.ok) {
|
|
51
|
+
blocked(connected.error.code);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const logins = { ...state.logins }, providerNeeds = { ...state.providerNeeds };
|
|
55
|
+
for (const provider of providers) {
|
|
56
|
+
publish({ ...state, checkingProviders: [provider] });
|
|
57
|
+
const status = await api.checkLogin(provider);
|
|
58
|
+
if (disposed || mine !== generation || checkMine !== checkSerial)
|
|
59
|
+
return;
|
|
60
|
+
if (!status.ok) {
|
|
61
|
+
if (status.error.layer !== 'auth' && !['AUTH_STATUS_INVALID', 'LOGIN_FAILED', 'POLICY_DENIED'].includes(status.error.code)) {
|
|
62
|
+
blocked(status.error.code);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
delete logins[provider];
|
|
66
|
+
providerNeeds[provider] = status.error;
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
logins[provider] = status.value.loggedIn;
|
|
70
|
+
delete providerNeeds[provider];
|
|
71
|
+
}
|
|
72
|
+
if (state.requestId && state.provider === provider && logins[provider] !== false) {
|
|
73
|
+
close(logins[provider] === true ? 'logged-in' : 'invalidated', providerNeeds[provider]?.code);
|
|
74
|
+
publish({ ...state, requestId: undefined, provider: undefined, phase: 'checking', message: '正在检查登录状态…' });
|
|
75
|
+
}
|
|
76
|
+
publish({ ...state, logins: { ...logins }, providerNeeds: { ...providerNeeds }, checkingProviders: state.checkingProviders.filter(p => p !== provider) });
|
|
77
|
+
}
|
|
78
|
+
if (state.requestId && state.provider && logins[state.provider] === true) {
|
|
79
|
+
close('logged-in');
|
|
80
|
+
publish({ ...state, requestId: undefined, provider: undefined, phase: 'checking', message: '正在检查登录状态…' });
|
|
81
|
+
}
|
|
82
|
+
publish({ ...state, logins, providerNeeds, checkingProviders: [], technicalDetails: connected.value.hostContext ? { hostContext: structuredClone(connected.value.hostContext) } : {} });
|
|
83
|
+
ask();
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
if (!disposed && mine === generation && checkMine === checkSerial)
|
|
87
|
+
blocked('DEPENDENCY_REQUIRED');
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
const promise = track(run());
|
|
91
|
+
flight = promise;
|
|
92
|
+
void promise.then(() => { if (flight === promise)
|
|
93
|
+
flight = undefined; });
|
|
94
|
+
return promise;
|
|
95
|
+
};
|
|
96
|
+
const providerFailed = (provider, code, sourceNeed = need(code)) => {
|
|
97
|
+
const logins = { ...state.logins };
|
|
98
|
+
delete logins[provider];
|
|
99
|
+
publish({ ...state, phase: 'idle', requestId: undefined, provider: undefined, logins, checkingProviders: [], providerNeeds: { ...state.providerNeeds, [provider]: structuredClone(sourceNeed) } });
|
|
100
|
+
ask();
|
|
101
|
+
};
|
|
102
|
+
const respond = (requestId, action) => {
|
|
103
|
+
if (disposed)
|
|
104
|
+
return Promise.resolve(fail('DISPOSED'));
|
|
105
|
+
if (action === 'authorize' && authorization && authorizationId === requestId)
|
|
106
|
+
return authorization;
|
|
107
|
+
if (!requestId || state.requestId !== requestId || state.phase !== 'login-required' || !state.provider || !['authorize', 'defer'].includes(action))
|
|
108
|
+
return Promise.resolve(fail('LOGIN_PROMPT_INVALID'));
|
|
109
|
+
const provider = state.provider;
|
|
110
|
+
if (action === 'defer') {
|
|
111
|
+
close('deferred');
|
|
112
|
+
deferred.add(provider);
|
|
113
|
+
publish({ ...state, requestId: undefined, provider: undefined });
|
|
114
|
+
ask();
|
|
115
|
+
return Promise.resolve(snapshot());
|
|
116
|
+
}
|
|
117
|
+
if (authorization)
|
|
118
|
+
return Promise.resolve(fail('LOGIN_PROMPT_INVALID'));
|
|
119
|
+
const mine = generation;
|
|
120
|
+
checkSerial++;
|
|
121
|
+
authorizationId = requestId;
|
|
122
|
+
publish({ ...state, phase: 'authorizing', checkingProviders: [provider], message: '请在授权窗口完成本人登录。' });
|
|
123
|
+
const run = async () => {
|
|
124
|
+
try {
|
|
125
|
+
await flight;
|
|
126
|
+
if (disposed || mine !== generation)
|
|
127
|
+
return fail(disposed ? 'DISPOSED' : 'CONNECTION_LOST');
|
|
128
|
+
const current = await api.checkLogin(provider);
|
|
129
|
+
if (disposed || mine !== generation)
|
|
130
|
+
return fail(disposed ? 'DISPOSED' : 'CONNECTION_LOST');
|
|
131
|
+
const verified = current.ok && current.value.loggedIn ? current : current.ok ? await api.login(provider) : current;
|
|
132
|
+
if (disposed || mine !== generation)
|
|
133
|
+
return fail(disposed ? 'DISPOSED' : 'CONNECTION_LOST');
|
|
134
|
+
if (!verified.ok || !verified.value.loggedIn) {
|
|
135
|
+
const code = verified.ok ? 'LOGIN_FAILED' : verified.error.code;
|
|
136
|
+
result({ requestId, provider, outcome: 'failed', need: need(code) });
|
|
137
|
+
providerFailed(provider, code, verified.ok ? need(code) : verified.error);
|
|
138
|
+
return verified.ok ? fail(code) : verified;
|
|
139
|
+
}
|
|
140
|
+
result({ requestId, provider, outcome: 'logged-in' });
|
|
141
|
+
deferred.delete(provider);
|
|
142
|
+
const providerNeeds = { ...state.providerNeeds };
|
|
143
|
+
delete providerNeeds[provider];
|
|
144
|
+
publish({ ...state, requestId: undefined, provider: undefined, logins: { ...state.logins, [provider]: true }, providerNeeds, checkingProviders: [], phase: 'idle' });
|
|
145
|
+
ask();
|
|
146
|
+
return snapshot();
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
if (disposed || mine !== generation)
|
|
150
|
+
return fail(disposed ? 'DISPOSED' : 'CONNECTION_LOST');
|
|
151
|
+
result({ requestId, provider, outcome: 'failed', need: need('LOGIN_FAILED') });
|
|
152
|
+
providerFailed(provider, 'LOGIN_FAILED');
|
|
153
|
+
return fail('LOGIN_FAILED');
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
const promise = track(run());
|
|
157
|
+
authorization = promise;
|
|
158
|
+
void promise.then(() => { if (authorization === promise) {
|
|
159
|
+
authorization = undefined;
|
|
160
|
+
authorizationId = undefined;
|
|
161
|
+
} });
|
|
162
|
+
return promise;
|
|
163
|
+
};
|
|
164
|
+
return { getState: () => structuredClone(state), check,
|
|
165
|
+
subscribe(listener) { listeners.add(listener); return () => { listeners.delete(listener); }; },
|
|
166
|
+
async request(provider, intent) {
|
|
167
|
+
if (!providers.includes(provider) || !['automatic', 'user'].includes(intent))
|
|
168
|
+
return fail('AUTH_STATUS_INVALID');
|
|
169
|
+
const mine = generation;
|
|
170
|
+
if (intent === 'user')
|
|
171
|
+
deferred.delete(provider);
|
|
172
|
+
if (authorization)
|
|
173
|
+
return state.providerNeeds[provider] ? { ok: false, error: structuredClone(state.providerNeeds[provider]) } : snapshot();
|
|
174
|
+
await check();
|
|
175
|
+
if (disposed || mine !== generation)
|
|
176
|
+
return fail(disposed ? 'DISPOSED' : 'CONNECTION_LOST');
|
|
177
|
+
if (intent === 'user' && !authorization && state.logins[provider] === false && !state.providerNeeds[provider] && !state.checkingProviders.includes(provider) && state.phase !== 'blocked' && state.phase !== 'disposed' && state.provider !== provider) {
|
|
178
|
+
close('invalidated', 'LOGIN_PROMPT_INVALID');
|
|
179
|
+
publish({ ...state, phase: 'login-required', provider, requestId: 'login-' + instanceId + '-' + (++serial), message: '需要登录,请到设置 → 远程主机连接 点登录' });
|
|
180
|
+
}
|
|
181
|
+
return state.providerNeeds[provider] ? { ok: false, error: structuredClone(state.providerNeeds[provider]) } : snapshot();
|
|
182
|
+
}, respond,
|
|
183
|
+
// Internal compatibility facade. HTTP and public API must use ticketed responses.
|
|
184
|
+
defer(provider) { if (state.provider === provider && state.requestId)
|
|
185
|
+
void respond(state.requestId, 'defer'); },
|
|
186
|
+
async authorize(provider) { if (state.provider === provider && state.requestId)
|
|
187
|
+
await respond(state.requestId, 'authorize'); },
|
|
188
|
+
connectionChanged(next) {
|
|
189
|
+
if (disposed)
|
|
190
|
+
return;
|
|
191
|
+
const prior = connectionPhase;
|
|
192
|
+
connectionPhase = next.phase;
|
|
193
|
+
if (next.phase === 'unavailable' || next.phase === 'disposed') {
|
|
194
|
+
generation++;
|
|
195
|
+
flight = undefined;
|
|
196
|
+
blocked(next.need?.code ?? (next.phase === 'disposed' ? 'DISPOSED' : 'CONNECTION_LOST'));
|
|
197
|
+
}
|
|
198
|
+
else if (next.phase === 'connected' && prior !== 'connected') {
|
|
199
|
+
const restored = observedConnected;
|
|
200
|
+
observedConnected = true;
|
|
201
|
+
if (restored) {
|
|
202
|
+
const mine = generation;
|
|
203
|
+
queueMicrotask(() => { void Promise.resolve(authorization).then(() => { if (!disposed && mine === generation && connectionPhase === 'connected')
|
|
204
|
+
void check(); }); });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
stop() { if (disposed)
|
|
209
|
+
return; disposed = true; generation++; close('invalidated', 'DISPOSED'); publish({ phase: 'disposed', message: '远程主机连接已关闭。', logins: {}, checkingProviders: [], providerNeeds: {}, technicalDetails: { code: 'DISPOSED' } }); listeners.clear(); },
|
|
210
|
+
async dispose() { this.stop(); await Promise.allSettled([...pending]); }, };
|
|
211
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ConnectApi, SessionProjectTarget, Result, ConnectionState, WorkspaceBindingState, WorkspaceNoticeReceipt, WorkspaceUse } from './api.ts';
|
|
2
|
+
export interface NativeWorkspace {
|
|
3
|
+
id: string;
|
|
4
|
+
path: string;
|
|
5
|
+
title: string;
|
|
6
|
+
sessionIds: string[];
|
|
7
|
+
}
|
|
8
|
+
export interface NativeWorkspaceRegistry {
|
|
9
|
+
get(id: string): NativeWorkspace | undefined;
|
|
10
|
+
list(): NativeWorkspace[];
|
|
11
|
+
}
|
|
12
|
+
export interface WorkspaceBindingOptions {
|
|
13
|
+
profileDir: string;
|
|
14
|
+
metadataMaxBytes: number;
|
|
15
|
+
api: Pick<ConnectApi, 'getConnectionState' | 'getSshTarget' | 'listProjects' | 'createProject' | 'ensureReady'>;
|
|
16
|
+
registry: NativeWorkspaceRegistry;
|
|
17
|
+
readHeader: (sessionId: string) => Promise<{
|
|
18
|
+
id: string;
|
|
19
|
+
cwd?: string;
|
|
20
|
+
} | undefined>;
|
|
21
|
+
nextRevision?: () => number;
|
|
22
|
+
onState?: (state: WorkspaceBindingState) => void;
|
|
23
|
+
diagnostic?: (error: unknown) => void;
|
|
24
|
+
}
|
|
25
|
+
export declare function createWorkspaceBindings(options: WorkspaceBindingOptions): {
|
|
26
|
+
observeConnection(state: ConnectionState): void;
|
|
27
|
+
dispose(): Promise<unknown>;
|
|
28
|
+
getWorkspaceBindingState: () => WorkspaceBindingState;
|
|
29
|
+
onWorkspaceBindingState(listener: (state: WorkspaceBindingState) => void): () => void;
|
|
30
|
+
acknowledgeWorkspaceNotice: (receipt: WorkspaceNoticeReceipt) => Promise<Result<void>>;
|
|
31
|
+
resolveSessionProject: (sessionId: string, use: WorkspaceUse) => Promise<Result<SessionProjectTarget>>;
|
|
32
|
+
};
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { lstat, realpath, open, rename, rm } from 'node:fs/promises';
|
|
2
|
+
import { join, isAbsolute, basename } from 'node:path';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { privateDirectory, readPrivate } from "./private-files.js";
|
|
5
|
+
import { isProjectName } from "./projects.js";
|
|
6
|
+
import { ok, fail, NeedError, need } from "./needs.js";
|
|
7
|
+
// All bindings belonging to one profile share reservation ordering, including reopened service instances.
|
|
8
|
+
const queues = new Map();
|
|
9
|
+
const hash = (v) => createHash('sha256').update(JSON.stringify(v)).digest('hex');
|
|
10
|
+
const token = (v) => typeof v === 'string' && v.length > 0 && !/[\x00-\x1f\x7f]/.test(v);
|
|
11
|
+
const integrity = (r) => hash([r.identity, r.workspaceId, r.localPath, r.localDev, r.localIno, r.project, r.bindingId, r.noticeId, r.sessionIds]);
|
|
12
|
+
export function createWorkspaceBindings(options) {
|
|
13
|
+
const root = join(options.profileDir, 'ai-workbench-connect'), file = join(root, 'workspaces.json'), owner = randomUUID();
|
|
14
|
+
let revision = 0, disposed = false, tail = Promise.resolve(), snapshot = { instanceId: options.api.getConnectionState().instanceId, revision: 0, items: [] };
|
|
15
|
+
let latest = [];
|
|
16
|
+
const listeners = new Set(), statuses = new Map();
|
|
17
|
+
const same = (a) => { const b = options.api.getConnectionState(); return !disposed && b.phase === 'connected' && b.instanceId === a.instanceId && b.epoch === a.epoch; };
|
|
18
|
+
const stale = () => fail(disposed ? 'DISPOSED' : 'CONNECTION_LOST');
|
|
19
|
+
const diagnostic = (error) => options.diagnostic?.(error);
|
|
20
|
+
function publish(items) {
|
|
21
|
+
if (disposed)
|
|
22
|
+
return;
|
|
23
|
+
latest = items;
|
|
24
|
+
snapshot = { instanceId: options.api.getConnectionState().instanceId, revision: (revision = options.nextRevision?.() ?? revision + 1), items: items.map(r => ({ bindingId: r.bindingId, workspaceId: r.workspaceId, workspaceName: r.project.name, status: statuses.get(r.bindingId) ?? (r.phase === 'intent' ? 'pending' : 'unavailable'), notice: { noticeId: r.noticeId, settings: r.settings, provider: r.provider === 'claimed' && r.claim?.owner !== owner ? 'unknown' : r.provider } })) };
|
|
25
|
+
options.onState?.(structuredClone(snapshot));
|
|
26
|
+
for (const listener of listeners)
|
|
27
|
+
listener(structuredClone(snapshot));
|
|
28
|
+
}
|
|
29
|
+
async function prepare() {
|
|
30
|
+
if (!isAbsolute(options.profileDir) || await realpath(options.profileDir) !== options.profileDir)
|
|
31
|
+
throw Error('PROFILE_PATH_INVALID');
|
|
32
|
+
await privateDirectory(root);
|
|
33
|
+
if (await realpath(root) !== root)
|
|
34
|
+
throw Error('BINDING_PATH_INVALID');
|
|
35
|
+
}
|
|
36
|
+
async function records() {
|
|
37
|
+
let raw;
|
|
38
|
+
try {
|
|
39
|
+
raw = await readPrivate(file, options.metadataMaxBytes);
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (error.code === 'ENOENT')
|
|
43
|
+
return [];
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
const value = JSON.parse(raw);
|
|
47
|
+
if (value.schemaVersion !== 2 || !Array.isArray(value.items))
|
|
48
|
+
throw Error('BINDING_INVALID');
|
|
49
|
+
const ids = new Set(), paths = new Set(), notices = new Set(), bindings = new Set(), sessions = new Set();
|
|
50
|
+
for (const r of value.items) {
|
|
51
|
+
if (!r || !token(r.bindingId) || !token(r.noticeId) || !token(r.workspaceId) || !(/^[a-f0-9]{64}$/.test(r.identity)) || !isAbsolute(r.localPath) || !Number.isSafeInteger(r.localDev) || !Number.isSafeInteger(r.localIno) || !isProjectName(r.project?.name) || typeof r.project.path !== 'string' || r.key !== integrity(r) || !['intent', 'bound'].includes(r.phase) || !['pending', 'displayed'].includes(r.settings) || !['pending', 'claimed', 'delivered', 'unknown'].includes(r.provider) || ids.has(r.workspaceId) || paths.has(r.identity + '\0' + r.project.path) || notices.has(r.noticeId) || bindings.has(r.bindingId) || !Array.isArray(r.sessionIds) || r.sessionIds.some(id => !token(id) || sessions.has(id)) || new Set(r.sessionIds).size !== r.sessionIds.length)
|
|
52
|
+
throw Error('BINDING_INVALID');
|
|
53
|
+
if (r.provider === 'pending' ? r.claim !== undefined : (!r.claim || !token(r.claim.id) || !token(r.claim.sessionId) || !['claude', 'codex'].includes(r.claim.provider) || !token(r.claim.owner) || (r.provider === 'delivered' && !token(r.claim.deliveryId))))
|
|
54
|
+
throw Error('BINDING_CLAIM_INVALID');
|
|
55
|
+
ids.add(r.workspaceId);
|
|
56
|
+
paths.add(r.identity + '\0' + r.project.path);
|
|
57
|
+
notices.add(r.noticeId);
|
|
58
|
+
bindings.add(r.bindingId);
|
|
59
|
+
for (const id of r.sessionIds)
|
|
60
|
+
sessions.add(id);
|
|
61
|
+
}
|
|
62
|
+
return value.items;
|
|
63
|
+
}
|
|
64
|
+
async function save(items) {
|
|
65
|
+
for (const r of items)
|
|
66
|
+
r.key = integrity(r);
|
|
67
|
+
const bytes = JSON.stringify({ schemaVersion: 2, items }) + '\n';
|
|
68
|
+
if (Buffer.byteLength(bytes) > options.metadataMaxBytes)
|
|
69
|
+
throw Error('BINDING_LIMIT');
|
|
70
|
+
const temp = join(root, '.workspaces-' + randomUUID());
|
|
71
|
+
try {
|
|
72
|
+
const h = await open(temp, 'wx', 0o600);
|
|
73
|
+
try {
|
|
74
|
+
await h.writeFile(bytes);
|
|
75
|
+
await h.sync();
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
await h.close();
|
|
79
|
+
}
|
|
80
|
+
await rename(temp, file);
|
|
81
|
+
const dir = await open(root, 'r');
|
|
82
|
+
try {
|
|
83
|
+
await dir.sync();
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
await dir.close();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
await rm(temp, { force: true });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function serialized(run) {
|
|
94
|
+
const prior = queues.get(file) ?? Promise.resolve();
|
|
95
|
+
const promise = prior.then(() => disposed ? fail('DISPOSED') : run());
|
|
96
|
+
const settled = promise.then(() => { }, () => { });
|
|
97
|
+
queues.set(file, settled);
|
|
98
|
+
tail = settled;
|
|
99
|
+
void settled.then(() => { if (queues.get(file) === settled)
|
|
100
|
+
queues.delete(file); });
|
|
101
|
+
return promise;
|
|
102
|
+
}
|
|
103
|
+
async function native(sessionId) {
|
|
104
|
+
const matches = options.registry.list().filter(w => Array.isArray(w.sessionIds) && w.sessionIds.includes(sessionId));
|
|
105
|
+
if (matches.length !== 1)
|
|
106
|
+
throw new NeedError(need('PROJECT_BINDING_REQUIRED'));
|
|
107
|
+
const workspace = matches[0], registered = options.registry.get(workspace.id), header = await options.readHeader(sessionId);
|
|
108
|
+
if (!registered || registered.path !== workspace.path || !registered.sessionIds.includes(sessionId) || !isAbsolute(workspace.path) || !header || header.id !== sessionId || !header.cwd || !isAbsolute(header.cwd))
|
|
109
|
+
throw new NeedError(need('PROJECT_BINDING_REQUIRED'));
|
|
110
|
+
const localPath = await realpath(workspace.path), cwd = await realpath(header.cwd), info = await lstat(localPath);
|
|
111
|
+
if (localPath !== cwd || !info.isDirectory() || info.isSymbolicLink())
|
|
112
|
+
throw new NeedError(need('PROJECT_BINDING_REQUIRED'));
|
|
113
|
+
return { workspaceId: workspace.id, localPath, localDev: info.dev, localIno: info.ino, name: basename(localPath) };
|
|
114
|
+
}
|
|
115
|
+
const sameNative = (a, b) => a.workspaceId === b.workspaceId && a.localPath === b.localPath && a.localDev === b.localDev && a.localIno === b.localIno;
|
|
116
|
+
function validList(list, projectRoot) { return Array.isArray(list.projects) && Array.isArray(list.unavailable) && list.projects.every(p => isProjectName(p.name) && p.path === projectRoot + '/' + p.name) && new Set(list.projects.map(p => p.path)).size === list.projects.length; }
|
|
117
|
+
function occupancy(list, name) {
|
|
118
|
+
const aliases = (n) => n.normalize('NFC').toLowerCase() === name.normalize('NFC').toLowerCase();
|
|
119
|
+
return list.unavailable.some(p => aliases(p.name)) || list.projects.some(p => p.name !== name && aliases(p.name));
|
|
120
|
+
}
|
|
121
|
+
async function resolve(sessionId, use) {
|
|
122
|
+
if (!token(sessionId) || !use || !['claude', 'codex'].includes(use.provider) || use.purpose !== 'user-turn')
|
|
123
|
+
return fail('PROJECT_BINDING_REQUIRED');
|
|
124
|
+
let stored, binding;
|
|
125
|
+
try {
|
|
126
|
+
await prepare();
|
|
127
|
+
stored = await records();
|
|
128
|
+
const before = options.api.getConnectionState();
|
|
129
|
+
const ready = await options.api.ensureReady(use.provider);
|
|
130
|
+
if (!same(before))
|
|
131
|
+
return stale();
|
|
132
|
+
if (!ready.ok)
|
|
133
|
+
return ready;
|
|
134
|
+
const targetResult = await options.api.getSshTarget();
|
|
135
|
+
if (!same(before))
|
|
136
|
+
return stale();
|
|
137
|
+
if (!targetResult.ok)
|
|
138
|
+
return targetResult;
|
|
139
|
+
const target = targetResult.value;
|
|
140
|
+
if (JSON.stringify(target) !== JSON.stringify(ready.value))
|
|
141
|
+
return fail('CONNECTION_LOST');
|
|
142
|
+
const trust = await readPrivate(target.knownHostsFile, options.metadataMaxBytes), identity = hash([target.host, target.username, target.port, target.projectRoot, target.session, trust]);
|
|
143
|
+
binding = stored.find(r => r.sessionIds.includes(sessionId));
|
|
144
|
+
let current;
|
|
145
|
+
try {
|
|
146
|
+
current = await native(sessionId);
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
if (stored.some(r => r.sessionIds.includes(sessionId) || options.registry.get(r.workspaceId)?.sessionIds.includes(sessionId))) {
|
|
150
|
+
diagnostic(error);
|
|
151
|
+
throw new NeedError(need('PROJECT_BINDING_INVALID'));
|
|
152
|
+
}
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
if (!isProjectName(current.name))
|
|
156
|
+
return fail('PROJECT_NAME_INVALID');
|
|
157
|
+
const localBinding = stored.find(r => r.workspaceId === current.workspaceId || r.localPath === current.localPath);
|
|
158
|
+
if (binding && binding !== localBinding)
|
|
159
|
+
throw new NeedError(need('PROJECT_BINDING_INVALID'));
|
|
160
|
+
binding = localBinding;
|
|
161
|
+
if (binding && (binding.identity !== identity || binding.workspaceId !== current.workspaceId || binding.localPath !== current.localPath || binding.localDev !== current.localDev || binding.localIno !== current.localIno || binding.project.name !== current.name || binding.project.path !== target.projectRoot + '/' + current.name))
|
|
162
|
+
throw new NeedError(need('PROJECT_BINDING_INVALID'));
|
|
163
|
+
const remotePath = target.projectRoot + '/' + current.name, collision = stored.find(r => r !== binding && r.identity === identity && r.project.path === remotePath);
|
|
164
|
+
if (collision) {
|
|
165
|
+
const result = fail(collision.phase === 'intent' ? 'PROJECT_BINDING_BUSY' : 'PROJECT_EXISTS');
|
|
166
|
+
if (!result.ok)
|
|
167
|
+
result.error.message = `远程主机上的‘${current.name}’已被另一工作区使用/正在关联,请选择名称不同的本地文件夹或联系管理员`;
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
const listed = await options.api.listProjects();
|
|
171
|
+
if (!same(before))
|
|
172
|
+
return stale();
|
|
173
|
+
if (!listed.ok)
|
|
174
|
+
return listed;
|
|
175
|
+
if (!validList(listed.value, target.projectRoot))
|
|
176
|
+
return fail('PROJECT_PROTOCOL_INVALID');
|
|
177
|
+
let project = listed.value.projects.find(p => p.name === current.name && p.path === remotePath);
|
|
178
|
+
if (binding?.phase === 'bound' && !project)
|
|
179
|
+
throw new NeedError(need('PROJECT_BINDING_INVALID'));
|
|
180
|
+
if (occupancy(listed.value, current.name))
|
|
181
|
+
return fail(binding?.phase === 'intent' ? 'PROJECT_CREATE_UNKNOWN' : 'PROJECT_EXISTS');
|
|
182
|
+
if (!binding) {
|
|
183
|
+
binding = { key: '', identity, workspaceId: current.workspaceId, localPath: current.localPath, localDev: current.localDev, localIno: current.localIno, project: { name: current.name, path: remotePath }, bindingId: randomUUID(), noticeId: randomUUID(), sessionIds: [sessionId], phase: project ? 'bound' : 'intent', settings: 'pending', provider: 'pending' };
|
|
184
|
+
stored.push(binding);
|
|
185
|
+
await save(stored);
|
|
186
|
+
if (!same(before))
|
|
187
|
+
return stale();
|
|
188
|
+
if (!project) {
|
|
189
|
+
if (!sameNative(current, await native(sessionId)))
|
|
190
|
+
throw new NeedError(need('PROJECT_BINDING_INVALID'));
|
|
191
|
+
if (!same(before))
|
|
192
|
+
return stale();
|
|
193
|
+
let created;
|
|
194
|
+
try {
|
|
195
|
+
created = await options.api.createProject(current.name);
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
diagnostic(error);
|
|
199
|
+
publish(stored);
|
|
200
|
+
return fail('PROJECT_CREATE_UNKNOWN');
|
|
201
|
+
}
|
|
202
|
+
if (!same(before))
|
|
203
|
+
return stale();
|
|
204
|
+
if (!created.ok) {
|
|
205
|
+
publish(stored);
|
|
206
|
+
return created;
|
|
207
|
+
}
|
|
208
|
+
if (created.value.name !== current.name || created.value.path !== remotePath)
|
|
209
|
+
return fail('PROJECT_PROTOCOL_INVALID');
|
|
210
|
+
const verified = await options.api.listProjects();
|
|
211
|
+
if (!same(before))
|
|
212
|
+
return stale();
|
|
213
|
+
if (!verified.ok || !validList(verified.value, target.projectRoot) || occupancy(verified.value, current.name))
|
|
214
|
+
return fail('PROJECT_CREATE_UNKNOWN');
|
|
215
|
+
project = verified.value.projects.find(p => p.name === current.name && p.path === remotePath);
|
|
216
|
+
if (!project)
|
|
217
|
+
return fail('PROJECT_CREATE_UNKNOWN');
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
else if (!project) {
|
|
221
|
+
publish(stored);
|
|
222
|
+
return fail('PROJECT_CREATE_UNKNOWN');
|
|
223
|
+
}
|
|
224
|
+
if (!sameNative(current, await native(sessionId)))
|
|
225
|
+
throw new NeedError(need('PROJECT_BINDING_INVALID'));
|
|
226
|
+
if (!same(before))
|
|
227
|
+
return stale();
|
|
228
|
+
binding.phase = 'bound';
|
|
229
|
+
if (!binding.sessionIds.includes(sessionId))
|
|
230
|
+
binding.sessionIds.push(sessionId);
|
|
231
|
+
statuses.set(binding.bindingId, 'ready');
|
|
232
|
+
if (binding.provider === 'unknown' || (binding.provider === 'claimed' && binding.claim?.owner !== owner)) {
|
|
233
|
+
binding.provider = 'unknown';
|
|
234
|
+
await save(stored);
|
|
235
|
+
publish(stored);
|
|
236
|
+
return fail('PROJECT_NOTICE_DELIVERY_UNKNOWN');
|
|
237
|
+
}
|
|
238
|
+
if (binding.provider === 'claimed' && (binding.claim?.sessionId !== sessionId || binding.claim.provider !== use.provider)) {
|
|
239
|
+
await save(stored);
|
|
240
|
+
publish(stored);
|
|
241
|
+
return fail('PROJECT_BINDING_BUSY');
|
|
242
|
+
}
|
|
243
|
+
if (binding.provider === 'pending') {
|
|
244
|
+
binding.provider = 'claimed';
|
|
245
|
+
binding.claim = { id: randomUUID(), sessionId, provider: use.provider, owner };
|
|
246
|
+
}
|
|
247
|
+
await save(stored);
|
|
248
|
+
if (!same(before))
|
|
249
|
+
return stale();
|
|
250
|
+
if (!sameNative(current, await native(sessionId)))
|
|
251
|
+
throw new NeedError(need('PROJECT_BINDING_INVALID'));
|
|
252
|
+
if (!same(before))
|
|
253
|
+
return stale();
|
|
254
|
+
publish(stored);
|
|
255
|
+
return ok({ instanceId: before.instanceId, epoch: before.epoch, bindingRevision: revision, bindingId: binding.bindingId, sessionId, workspaceId: current.workspaceId, project: structuredClone(binding.project), target, ...(binding.provider === 'claimed' ? { notice: { noticeId: binding.noticeId, claimId: binding.claim.id, workspaceName: binding.project.name, messageKey: 'remote-workspace-binding' } } : {}) });
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
diagnostic(error);
|
|
259
|
+
if (binding)
|
|
260
|
+
statuses.set(binding.bindingId, 'invalid');
|
|
261
|
+
if (stored)
|
|
262
|
+
publish(stored);
|
|
263
|
+
return error instanceof NeedError ? { ok: false, error: error.need } : fail('PROJECT_BINDING_INVALID');
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
async function acknowledge(receipt) {
|
|
267
|
+
try {
|
|
268
|
+
await prepare();
|
|
269
|
+
const stored = await records();
|
|
270
|
+
if (!receipt || !token(receipt.noticeId))
|
|
271
|
+
return fail('PROJECT_BINDING_INVALID');
|
|
272
|
+
const binding = stored.find(r => r.noticeId === receipt.noticeId);
|
|
273
|
+
if (!binding || binding.phase !== 'bound')
|
|
274
|
+
return fail('PROJECT_BINDING_INVALID');
|
|
275
|
+
if (receipt.surface === 'settings') {
|
|
276
|
+
if (receipt.outcome !== 'displayed' || Object.keys(receipt).sort().join(',') !== 'noticeId,outcome,surface')
|
|
277
|
+
return fail('PROJECT_BINDING_INVALID');
|
|
278
|
+
binding.settings = 'displayed';
|
|
279
|
+
}
|
|
280
|
+
else if (receipt.surface === 'provider') {
|
|
281
|
+
const claim = binding.claim, keys = receipt.outcome === 'delivered' ? 'claimId,deliveryId,noticeId,outcome,provider,sessionId,surface' : 'claimId,noticeId,outcome,provider,sessionId,surface';
|
|
282
|
+
if (!claim || !['claimed', 'unknown', 'delivered'].includes(binding.provider) || Object.keys(receipt).sort().join(',') !== keys || receipt.claimId !== claim.id || receipt.sessionId !== claim.sessionId || receipt.provider !== claim.provider || !['delivered', 'not-delivered', 'unknown'].includes(receipt.outcome) || (receipt.outcome === 'delivered' && !token(receipt.deliveryId)))
|
|
283
|
+
return fail('PROJECT_BINDING_INVALID');
|
|
284
|
+
if (binding.provider === 'delivered') {
|
|
285
|
+
return receipt.outcome === 'delivered' && receipt.deliveryId === claim.deliveryId ? ok(undefined) : fail('PROJECT_BINDING_INVALID');
|
|
286
|
+
}
|
|
287
|
+
if (receipt.outcome === 'not-delivered') {
|
|
288
|
+
binding.provider = 'pending';
|
|
289
|
+
delete binding.claim;
|
|
290
|
+
}
|
|
291
|
+
else if (receipt.outcome === 'unknown')
|
|
292
|
+
binding.provider = 'unknown';
|
|
293
|
+
else if (receipt.outcome === 'delivered') {
|
|
294
|
+
binding.provider = 'delivered';
|
|
295
|
+
claim.deliveryId = receipt.deliveryId;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
else
|
|
299
|
+
return fail('PROJECT_BINDING_INVALID');
|
|
300
|
+
await save(stored);
|
|
301
|
+
publish(stored);
|
|
302
|
+
return ok(undefined);
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
diagnostic(error);
|
|
306
|
+
return fail('PROJECT_BINDING_INVALID');
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
// Loading metadata never resolves a remote project or creates either a local or remote directory.
|
|
310
|
+
void serialized(async () => { try {
|
|
311
|
+
await prepare();
|
|
312
|
+
publish(await records());
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
diagnostic(error);
|
|
316
|
+
} });
|
|
317
|
+
return {
|
|
318
|
+
observeConnection(state) { if (disposed)
|
|
319
|
+
return; for (const [id, status] of statuses)
|
|
320
|
+
if (status === 'ready')
|
|
321
|
+
statuses.set(id, 'unavailable'); publish(latest); },
|
|
322
|
+
dispose() { disposed = true; listeners.clear(); return tail; },
|
|
323
|
+
getWorkspaceBindingState: () => structuredClone(snapshot),
|
|
324
|
+
onWorkspaceBindingState(listener) { listeners.add(listener); return () => { listeners.delete(listener); }; },
|
|
325
|
+
acknowledgeWorkspaceNotice: (receipt) => serialized(() => acknowledge(receipt)),
|
|
326
|
+
resolveSessionProject: (sessionId, use) => serialized(() => resolve(sessionId, use)),
|
|
327
|
+
};
|
|
328
|
+
}
|
package/locale/en.json
ADDED