@zhin.js/gateway 0.0.1-bootstrap.0 → 1.1.1
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/LICENSE +21 -0
- package/README.md +61 -0
- package/lib/config/ai-config-loader.d.ts +22 -0
- package/lib/config/ai-config-loader.js +96 -0
- package/lib/config/configuration-chat-policy.d.ts +57 -0
- package/lib/config/configuration-chat-policy.js +423 -0
- package/lib/config/configuration-patch.d.ts +16 -0
- package/lib/config/configuration-patch.js +105 -0
- package/lib/config/configuration-path.d.ts +6 -0
- package/lib/config/configuration-path.js +40 -0
- package/lib/config/configuration-projection.d.ts +6 -0
- package/lib/config/configuration-projection.js +28 -0
- package/lib/config/configuration-redaction.d.ts +24 -0
- package/lib/config/configuration-redaction.js +128 -0
- package/lib/config/configuration.d.ts +81 -0
- package/lib/config/configuration.js +686 -0
- package/lib/config/console-schema.d.ts +11 -0
- package/lib/config/console-schema.js +222 -0
- package/lib/config/environment-files.d.ts +2 -0
- package/lib/config/environment-files.js +21 -0
- package/lib/config/plugin-package-map.d.ts +2 -0
- package/lib/config/plugin-package-map.js +14 -0
- package/lib/config/plugin-schema-catalog.d.ts +22 -0
- package/lib/config/plugin-schema-catalog.js +262 -0
- package/lib/config.d.ts +12 -0
- package/lib/config.js +12 -0
- package/lib/database/config.d.ts +22 -0
- package/lib/database/config.js +159 -0
- package/lib/database/index.d.ts +2 -0
- package/lib/database/index.js +2 -0
- package/lib/database/project-database.d.ts +31 -0
- package/lib/database/project-database.js +130 -0
- package/lib/database.d.ts +2 -0
- package/lib/database.js +2 -0
- package/lib/http/console-rpc.d.ts +19 -0
- package/lib/http/console-rpc.js +51 -0
- package/lib/http/gateway-console.d.ts +63 -0
- package/lib/http/gateway-console.js +309 -0
- package/lib/http/gateway-events.d.ts +35 -0
- package/lib/http/gateway-events.js +172 -0
- package/lib/http/gateway-http-server.d.ts +95 -0
- package/lib/http/gateway-http-server.js +518 -0
- package/lib/http/local-principal.d.ts +2 -0
- package/lib/http/local-principal.js +16 -0
- package/lib/http/run-gateway.d.ts +43 -0
- package/lib/http/run-gateway.js +268 -0
- package/lib/http/start-gateway.d.ts +114 -0
- package/lib/http/start-gateway.js +220 -0
- package/lib/http-response.d.ts +6 -0
- package/lib/http-response.js +25 -0
- package/lib/http.d.ts +6 -0
- package/lib/http.js +6 -0
- package/lib/iam/crypto.d.ts +12 -0
- package/lib/iam/crypto.js +58 -0
- package/lib/iam/index.d.ts +8 -0
- package/lib/iam/index.js +8 -0
- package/lib/iam/install.d.ts +36 -0
- package/lib/iam/install.js +74 -0
- package/lib/iam/local-admin.d.ts +27 -0
- package/lib/iam/local-admin.js +102 -0
- package/lib/iam/rate-limit.d.ts +15 -0
- package/lib/iam/rate-limit.js +52 -0
- package/lib/iam/routes.d.ts +23 -0
- package/lib/iam/routes.js +157 -0
- package/lib/iam/rpc.d.ts +14 -0
- package/lib/iam/rpc.js +100 -0
- package/lib/iam/service.d.ts +203 -0
- package/lib/iam/service.js +784 -0
- package/lib/iam/setup-service.d.ts +83 -0
- package/lib/iam/setup-service.js +206 -0
- package/lib/iam/tables.d.ts +20 -0
- package/lib/iam/tables.js +82 -0
- package/lib/iam.d.ts +2 -0
- package/lib/iam.js +2 -0
- package/lib/index.d.ts +22 -0
- package/lib/index.js +22 -0
- package/lib/local/client.d.ts +71 -0
- package/lib/local/client.js +296 -0
- package/lib/local/handler.d.ts +30 -0
- package/lib/local/handler.js +86 -0
- package/lib/local/local-socket.d.ts +55 -0
- package/lib/local/local-socket.js +232 -0
- package/lib/local/paths.d.ts +34 -0
- package/lib/local/paths.js +58 -0
- package/lib/local/protocol.d.ts +140 -0
- package/lib/local/protocol.js +90 -0
- package/lib/local/state-file.d.ts +8 -0
- package/lib/local/state-file.js +45 -0
- package/lib/local.d.ts +10 -0
- package/lib/local.js +10 -0
- package/lib/onboarding/allowlist.d.ts +54 -0
- package/lib/onboarding/allowlist.js +106 -0
- package/lib/onboarding/errors.d.ts +7 -0
- package/lib/onboarding/errors.js +17 -0
- package/lib/onboarding/index.d.ts +6 -0
- package/lib/onboarding/index.js +6 -0
- package/lib/onboarding/install.d.ts +99 -0
- package/lib/onboarding/install.js +132 -0
- package/lib/onboarding/package-manager.d.ts +47 -0
- package/lib/onboarding/package-manager.js +179 -0
- package/lib/onboarding/service.d.ts +53 -0
- package/lib/onboarding/service.js +144 -0
- package/lib/onboarding/state-store.d.ts +26 -0
- package/lib/onboarding/state-store.js +97 -0
- package/lib/onboarding.d.ts +2 -0
- package/lib/onboarding.js +2 -0
- package/lib/packages/authorization.d.ts +23 -0
- package/lib/packages/authorization.js +109 -0
- package/lib/packages/errors.d.ts +11 -0
- package/lib/packages/errors.js +26 -0
- package/lib/packages/index.d.ts +5 -0
- package/lib/packages/index.js +5 -0
- package/lib/packages/rpc.d.ts +30 -0
- package/lib/packages/rpc.js +155 -0
- package/lib/packages/service.d.ts +121 -0
- package/lib/packages/service.js +441 -0
- package/lib/packages/snapshot.d.ts +14 -0
- package/lib/packages/snapshot.js +73 -0
- package/lib/packages.d.ts +2 -0
- package/lib/packages.js +2 -0
- package/lib/plugin-management/activity.d.ts +6 -0
- package/lib/plugin-management/activity.js +14 -0
- package/lib/plugin-management/config-explanation.d.ts +6 -0
- package/lib/plugin-management/config-explanation.js +113 -0
- package/lib/plugin-management/use-cases.d.ts +15 -0
- package/lib/plugin-management/use-cases.js +120 -0
- package/lib/plugin-management/workflow.d.ts +92 -0
- package/lib/plugin-management/workflow.js +772 -0
- package/lib/plugin-management.d.ts +5 -0
- package/lib/plugin-management.js +5 -0
- package/lib/project/config-file.d.ts +6 -0
- package/lib/project/config-file.js +58 -0
- package/lib/project/environment.d.ts +13 -0
- package/lib/project/environment.js +52 -0
- package/lib/project/instance-declarations.d.ts +55 -0
- package/lib/project/instance-declarations.js +542 -0
- package/lib/project/legacy-config.d.ts +25 -0
- package/lib/project/legacy-config.js +70 -0
- package/lib/project/node-requirements.d.ts +7 -0
- package/lib/project/node-requirements.js +20 -0
- package/lib/project/tsx-module-loader.d.ts +7 -0
- package/lib/project/tsx-module-loader.js +29 -0
- package/lib/project.d.ts +7 -0
- package/lib/project.js +7 -0
- package/lib/server.d.ts +11 -0
- package/lib/server.js +11 -0
- package/lib/supervisor/instance-logs.d.ts +39 -0
- package/lib/supervisor/instance-logs.js +152 -0
- package/lib/supervisor/instance-supervisor.d.ts +140 -0
- package/lib/supervisor/instance-supervisor.js +408 -0
- package/lib/supervisor/run-instance.d.ts +35 -0
- package/lib/supervisor/run-instance.js +161 -0
- package/lib/supervisor/terminal-state.d.ts +30 -0
- package/lib/supervisor/terminal-state.js +57 -0
- package/lib/supervisor.d.ts +4 -0
- package/lib/supervisor.js +4 -0
- package/lib/terminal/backends.d.ts +38 -0
- package/lib/terminal/backends.js +193 -0
- package/lib/terminal/config.d.ts +63 -0
- package/lib/terminal/config.js +237 -0
- package/lib/terminal/host.d.ts +26 -0
- package/lib/terminal/host.js +76 -0
- package/lib/terminal/index.d.ts +60 -0
- package/lib/terminal/index.js +135 -0
- package/lib/terminal/modules.d.ts +87 -0
- package/lib/terminal/modules.js +44 -0
- package/lib/terminal/service.d.ts +101 -0
- package/lib/terminal/service.js +455 -0
- package/lib/terminal/tickets.d.ts +46 -0
- package/lib/terminal/tickets.js +76 -0
- package/lib/terminal.d.ts +3 -0
- package/lib/terminal.js +3 -0
- package/lib/usage/aggregate.d.ts +87 -0
- package/lib/usage/aggregate.js +212 -0
- package/lib/usage.d.ts +2 -0
- package/lib/usage.js +2 -0
- package/package.json +117 -4
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency transactions of the management gateway (ADR 0054 §4): one at a time, the instance
|
|
3
|
+
* stopped while the package manager runs, and a failed install / uninstall / update rolled back
|
|
4
|
+
* to the captured project files and lockfile with the old instance started again.
|
|
5
|
+
*/
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { mkdir, open, readFile, rm } from 'node:fs/promises';
|
|
8
|
+
import { join, resolve } from 'node:path';
|
|
9
|
+
import { CONSOLE_PERMISSIONS } from '@zhin.js/console-protocol';
|
|
10
|
+
import { createInstallAllowlist } from '../onboarding/allowlist.js';
|
|
11
|
+
import { InstallOutputRelay, streamEvents } from '../onboarding/install.js';
|
|
12
|
+
import { createStreamingPackageManager, detectPackageManager } from '../onboarding/package-manager.js';
|
|
13
|
+
import { PluginDeclarationError, PluginManagementOperationError, PluginManagementWorkflow } from '../plugin-management/workflow.js';
|
|
14
|
+
import { InstanceStartError } from '../supervisor/instance-supervisor.js';
|
|
15
|
+
import { authorizePackages, gatewayPrincipalAllows } from './authorization.js';
|
|
16
|
+
import { GatewayError } from './errors.js';
|
|
17
|
+
import { ProjectFileSnapshot } from './snapshot.js';
|
|
18
|
+
export const DEFAULT_PACKAGE_READY_TIMEOUT_MS = 60_000;
|
|
19
|
+
export const DEFAULT_PACKAGE_STABLE_MS = 5_000;
|
|
20
|
+
const LOCK_FILE = 'gateway-packages.lock';
|
|
21
|
+
const LOG_TAIL_LINES = 50;
|
|
22
|
+
const RUNNING_STATES = new Set(['running', 'starting', 'backoff']);
|
|
23
|
+
/** Thrown inside a transaction when the instance does not come up; carries its log tail. */
|
|
24
|
+
class InstanceStartFailure extends Error {
|
|
25
|
+
logTail;
|
|
26
|
+
constructor(message, logTail) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.logTail = logTail;
|
|
29
|
+
this.name = 'InstanceStartFailure';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export class GatewayPackageService {
|
|
33
|
+
#root;
|
|
34
|
+
#options;
|
|
35
|
+
#relay = new InstallOutputRelay();
|
|
36
|
+
#listeners = new Set();
|
|
37
|
+
#packageManager;
|
|
38
|
+
#workflow;
|
|
39
|
+
#active;
|
|
40
|
+
constructor(options) {
|
|
41
|
+
this.#root = resolve(options.projectRoot);
|
|
42
|
+
this.#options = options;
|
|
43
|
+
const streaming = createStreamingPackageManager(this.#relay.output);
|
|
44
|
+
this.#packageManager = options.packageManager ?? ((args, root) => streaming(args, root));
|
|
45
|
+
this.#workflow = options.workflow ?? new PluginManagementWorkflow(this.#root, {
|
|
46
|
+
...options.workflowOptions,
|
|
47
|
+
installDependencies: (args, root) => this.#packageManager(args, root, this.#relay.output),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
/** Id of the running transaction, if any. */
|
|
51
|
+
get activeTransaction() { return this.#active; }
|
|
52
|
+
/** Progress of every transaction (for `gateway.packages.progress`); returns an unsubscribe function. */
|
|
53
|
+
subscribe(listener) {
|
|
54
|
+
this.#listeners.add(listener);
|
|
55
|
+
return () => { this.#listeners.delete(listener); };
|
|
56
|
+
}
|
|
57
|
+
async allowlist() {
|
|
58
|
+
return createInstallAllowlist({ market: await this.#options.marketCatalog?.() ?? [] });
|
|
59
|
+
}
|
|
60
|
+
/** Authorizes one action; throws {@link GatewayError} (deny by default). */
|
|
61
|
+
async authorize(principal, action, packages = []) {
|
|
62
|
+
if (action === 'list') {
|
|
63
|
+
if (!gatewayPrincipalAllows(principal, CONSOLE_PERMISSIONS.FEATURE_VIEW))
|
|
64
|
+
throw new GatewayError('forbidden', `需要 ${CONSOLE_PERMISSIONS.FEATURE_VIEW} 权限`);
|
|
65
|
+
await this.#options.authorize?.(principal, action, []);
|
|
66
|
+
return Object.freeze([]);
|
|
67
|
+
}
|
|
68
|
+
if (action === 'plan')
|
|
69
|
+
throw new GatewayError('invalid_input', 'plan 需要具体操作');
|
|
70
|
+
const authorized = authorizePackages(principal, action, packages, {
|
|
71
|
+
allowlist: await this.allowlist(),
|
|
72
|
+
ownerExists: await (this.#options.hasOwner?.() ?? true),
|
|
73
|
+
});
|
|
74
|
+
await this.#options.authorize?.(principal, action, authorized);
|
|
75
|
+
return authorized;
|
|
76
|
+
}
|
|
77
|
+
/** Declared dependencies with their installed versions. */
|
|
78
|
+
async list(principal) {
|
|
79
|
+
await this.authorize(principal, 'list');
|
|
80
|
+
const manifest = JSON.parse(await readFile(join(this.#root, 'package.json'), 'utf8'));
|
|
81
|
+
const allowlist = await this.allowlist();
|
|
82
|
+
const packages = [];
|
|
83
|
+
for (const dependencyType of ['dependencies', 'optionalDependencies', 'devDependencies']) {
|
|
84
|
+
const entries = manifest[dependencyType];
|
|
85
|
+
if (!entries || typeof entries !== 'object')
|
|
86
|
+
continue;
|
|
87
|
+
for (const [name, range] of Object.entries(entries)) {
|
|
88
|
+
if (typeof range !== 'string')
|
|
89
|
+
continue;
|
|
90
|
+
const catalog = allowlist.get(name)?.kind;
|
|
91
|
+
packages.push(Object.freeze({ name, range, dependencyType, installedVersion: await this.#installedVersion(name), ...(catalog ? { catalog } : {}) }));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
packages.sort((a, b) => a.name.localeCompare(b.name));
|
|
95
|
+
return Object.freeze({ packageManager: await detectPackageManager(this.#root), packages: Object.freeze(packages) });
|
|
96
|
+
}
|
|
97
|
+
/** What a transaction would do, after authorization; changes nothing. */
|
|
98
|
+
async plan(operation, packages, principal) {
|
|
99
|
+
const authorized = await this.authorize(principal, operation, packages);
|
|
100
|
+
const instanceState = this.#instanceState();
|
|
101
|
+
return Object.freeze({
|
|
102
|
+
operation,
|
|
103
|
+
packages: authorized,
|
|
104
|
+
all: packages === 'all',
|
|
105
|
+
packageManager: await detectPackageManager(this.#root),
|
|
106
|
+
instanceState,
|
|
107
|
+
restartsInstance: instanceState !== 'unmanaged' && RUNNING_STATES.has(instanceState),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
install(packages, options) {
|
|
111
|
+
return this.#transaction('install', packages, options, async (ctx, authorized) => {
|
|
112
|
+
const needs = [];
|
|
113
|
+
for (const item of authorized) {
|
|
114
|
+
const need = configurationNeed(await ctx.workflow.install(item.spec, undefined, undefined, { enable: options.enable !== false }));
|
|
115
|
+
if (need)
|
|
116
|
+
needs.push(need);
|
|
117
|
+
}
|
|
118
|
+
return needs.length ? { needsConfiguration: Object.freeze(needs) } : undefined;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* `gateway.plugins.declare`: declares an installed plugin with its first configuration (schema
|
|
123
|
+
* validated before the instance is touched), then restarts the instance; a failed start rolls
|
|
124
|
+
* the configuration file back and starts the previous instance again.
|
|
125
|
+
*/
|
|
126
|
+
async declare(request, options) {
|
|
127
|
+
const workflow = this.#workflow;
|
|
128
|
+
if (!workflow.planDeclaration || !workflow.declare)
|
|
129
|
+
throw new GatewayError('unavailable', '插件声明不可用');
|
|
130
|
+
if (typeof request.package !== 'string')
|
|
131
|
+
throw new GatewayError('invalid_input', 'package 必须是包名');
|
|
132
|
+
if (request.instanceKey !== undefined && typeof request.instanceKey !== 'string')
|
|
133
|
+
throw new GatewayError('invalid_input', 'instanceKey 必须是字符串');
|
|
134
|
+
const config = request.config ?? {};
|
|
135
|
+
const declareOptions = request.instanceKey === undefined ? {} : { instanceKey: request.instanceKey };
|
|
136
|
+
// Authorization first, then validation: nothing is stopped for a request that cannot succeed.
|
|
137
|
+
await this.authorize(options.principal, 'declare', [request.package]);
|
|
138
|
+
try {
|
|
139
|
+
await workflow.planDeclaration(request.package, config, declareOptions);
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
throw declarationError(error);
|
|
143
|
+
}
|
|
144
|
+
return this.#transaction('declare', [request.package], options, async (ctx, authorized) => {
|
|
145
|
+
await ctx.workflow.declare(authorized[0].name, config, declareOptions);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
uninstall(packages, options) {
|
|
149
|
+
return this.#transaction('uninstall', packages, options, async (ctx, authorized) => {
|
|
150
|
+
for (const item of authorized) {
|
|
151
|
+
if (options.removeDependency === false)
|
|
152
|
+
await ctx.workflow.uninstall(item.name, undefined, undefined, { removeDependency: false });
|
|
153
|
+
else
|
|
154
|
+
await ctx.workflow.uninstall(item.name);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
/** Exact versions go through the workflow (verified); bare names / `'all'` run the manager's update. */
|
|
159
|
+
update(packages, options) {
|
|
160
|
+
return this.#transaction('update', packages, options, async (ctx, authorized) => {
|
|
161
|
+
if (packages === 'all') {
|
|
162
|
+
await ctx.run(['update']);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const ranged = authorized.filter(item => item.version === undefined).map(item => item.name);
|
|
166
|
+
for (const item of authorized) {
|
|
167
|
+
if (item.version === undefined)
|
|
168
|
+
continue;
|
|
169
|
+
const declared = await ctx.workflow.inspect(item.name).then(state => state.declared, () => false);
|
|
170
|
+
if (declared)
|
|
171
|
+
await ctx.workflow.update(item.name, item.version);
|
|
172
|
+
else
|
|
173
|
+
await ctx.workflow.install(item.spec, undefined, undefined, { enable: false });
|
|
174
|
+
}
|
|
175
|
+
if (ranged.length)
|
|
176
|
+
await ctx.run(['update', ...ranged]);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
/** {@link install} / {@link uninstall} / {@link update} as an async iterator of its events. */
|
|
180
|
+
stream(operation, packages, options) {
|
|
181
|
+
return streamEvents(emit => {
|
|
182
|
+
const forward = { ...options, onEvent: (event) => { emit(event); options.onEvent?.(event); } };
|
|
183
|
+
if (operation === 'update')
|
|
184
|
+
return this.update(packages, forward);
|
|
185
|
+
if (packages === 'all')
|
|
186
|
+
return Promise.reject(new GatewayError('invalid_input', '只有 update 可以作用于全部依赖'));
|
|
187
|
+
return operation === 'install' ? this.install(packages, forward) : this.uninstall(packages, forward);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
async #transaction(operation, packages, options, execute) {
|
|
191
|
+
if (this.#active)
|
|
192
|
+
throw new GatewayError('conflict', '另一个依赖事务正在进行,请等待它结束');
|
|
193
|
+
const id = randomUUID();
|
|
194
|
+
this.#active = id;
|
|
195
|
+
try {
|
|
196
|
+
const authorized = await this.authorize(options.principal, operation, packages);
|
|
197
|
+
const release = await this.#lock();
|
|
198
|
+
try {
|
|
199
|
+
await this.#options.beforeTransaction?.().catch(() => undefined);
|
|
200
|
+
return await this.#run(id, operation, authorized, options, execute);
|
|
201
|
+
}
|
|
202
|
+
finally {
|
|
203
|
+
await release();
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
this.#active = undefined;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
async #run(id, operation, authorized, options, execute) {
|
|
211
|
+
const now = this.#options.now ?? Date.now;
|
|
212
|
+
const startedAt = now();
|
|
213
|
+
const emit = (event) => {
|
|
214
|
+
for (const listener of [options.onEvent, ...this.#listeners]) {
|
|
215
|
+
try {
|
|
216
|
+
listener?.(event);
|
|
217
|
+
}
|
|
218
|
+
catch { /* listeners never break a transaction */ }
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
let phase = 'snapshot';
|
|
222
|
+
const enter = (next) => { phase = next; emit({ type: 'phase', id, phase: next }); };
|
|
223
|
+
const detach = this.#relay.attach((stream, chunk) => emit({ type: 'output', id, stream, chunk }));
|
|
224
|
+
const instance = this.#options.instance;
|
|
225
|
+
const before = this.#instanceState();
|
|
226
|
+
const wasRunning = instance !== undefined && RUNNING_STATES.has(instance.state);
|
|
227
|
+
const startAfter = instance !== undefined && (options.startInstance ?? wasRunning);
|
|
228
|
+
const run = (args) => this.#packageManager(args, this.#root, this.#relay.output);
|
|
229
|
+
// Rollback log tails only show what this transaction's instances printed.
|
|
230
|
+
const logMark = instance?.logs?.tail(1).at(-1)?.seq ?? -1;
|
|
231
|
+
// A declaration only writes the configuration file: no package manager, no reinstall on rollback.
|
|
232
|
+
const configOnly = operation === 'declare';
|
|
233
|
+
let extras = undefined;
|
|
234
|
+
let snapshot;
|
|
235
|
+
let mutated = false;
|
|
236
|
+
let status = 'succeeded';
|
|
237
|
+
let failedPhase;
|
|
238
|
+
let failure;
|
|
239
|
+
let rollbackFailure;
|
|
240
|
+
let logTail;
|
|
241
|
+
emit({ type: 'start', id, operation, packages: authorized });
|
|
242
|
+
try {
|
|
243
|
+
try {
|
|
244
|
+
enter('snapshot');
|
|
245
|
+
snapshot = await ProjectFileSnapshot.capture(this.#root);
|
|
246
|
+
if (instance && instance.state !== 'stopped') {
|
|
247
|
+
enter('stop-instance');
|
|
248
|
+
await instance.stop({ reason: `packages:${operation}` });
|
|
249
|
+
}
|
|
250
|
+
enter(configOnly ? 'configure' : 'package-manager');
|
|
251
|
+
mutated = true;
|
|
252
|
+
extras = await execute({ workflow: this.#workflow, run }, authorized);
|
|
253
|
+
if (startAfter) {
|
|
254
|
+
enter('start-instance');
|
|
255
|
+
await this.#startInstance(instance, logMark);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
failure = error;
|
|
260
|
+
failedPhase = phase;
|
|
261
|
+
if (error instanceof InstanceStartFailure)
|
|
262
|
+
logTail = error.logTail;
|
|
263
|
+
status = 'rolledBack';
|
|
264
|
+
enter('rollback');
|
|
265
|
+
try {
|
|
266
|
+
// Stopping also cancels a pending automatic restart of the broken instance.
|
|
267
|
+
if (instance && instance.state !== 'stopped')
|
|
268
|
+
await instance.stop({ reason: 'packages:rollback' });
|
|
269
|
+
if (mutated && snapshot) {
|
|
270
|
+
enter('restore-files');
|
|
271
|
+
await snapshot.restore();
|
|
272
|
+
if (!configOnly) {
|
|
273
|
+
enter('reinstall');
|
|
274
|
+
await run(snapshot.hasLockfile ? ['install', '--frozen-lockfile'] : ['install']);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (wasRunning) {
|
|
278
|
+
enter('restart-instance');
|
|
279
|
+
await this.#startInstance(instance, logMark);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
status = 'rollbackFailed';
|
|
284
|
+
rollbackFailure = error;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
enter('done');
|
|
288
|
+
const result = Object.freeze({
|
|
289
|
+
id,
|
|
290
|
+
operation,
|
|
291
|
+
packages: authorized,
|
|
292
|
+
ok: status === 'succeeded',
|
|
293
|
+
status,
|
|
294
|
+
...(failedPhase ? { failedPhase } : {}),
|
|
295
|
+
...(failure === undefined ? {} : { error: failureMessage(failure), ...declarationDetails(failure) }),
|
|
296
|
+
...(rollbackFailure === undefined ? {} : { rollbackError: failureMessage(rollbackFailure) }),
|
|
297
|
+
...(logTail?.length ? { logTail } : {}),
|
|
298
|
+
...(status === 'succeeded' && extras?.needsConfiguration?.length ? { needsConfiguration: extras.needsConfiguration } : {}),
|
|
299
|
+
instance: Object.freeze({ before, after: this.#instanceState() }),
|
|
300
|
+
startedAt,
|
|
301
|
+
finishedAt: now(),
|
|
302
|
+
});
|
|
303
|
+
emit({ type: 'result', id, result });
|
|
304
|
+
return result;
|
|
305
|
+
}
|
|
306
|
+
finally {
|
|
307
|
+
detach();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/** Starts the instance, waits for `ready` within the timeout, then for the stability window. */
|
|
311
|
+
async #startInstance(instance, logMark) {
|
|
312
|
+
const configured = this.#options.readyTimeoutMs;
|
|
313
|
+
const readyTimeoutMs = (typeof configured === 'function' ? configured() : configured) ?? DEFAULT_PACKAGE_READY_TIMEOUT_MS;
|
|
314
|
+
const stableMs = this.#options.stableMs ?? DEFAULT_PACKAGE_STABLE_MS;
|
|
315
|
+
// With the log collector, only lines captured since the transaction started (the supervisor's
|
|
316
|
+
// failure tail also holds earlier runs); without it, the failure's own tail.
|
|
317
|
+
const tail = (fallback) => {
|
|
318
|
+
if (instance.logs) {
|
|
319
|
+
return Object.freeze(instance.logs.tail().filter(line => line.seq > logMark).slice(-LOG_TAIL_LINES).map(line => line.line));
|
|
320
|
+
}
|
|
321
|
+
const failureTail = fallback?.length ? fallback : instance.status().lastFailure?.logTail;
|
|
322
|
+
return Object.freeze([...(failureTail ?? [])]);
|
|
323
|
+
};
|
|
324
|
+
let timer;
|
|
325
|
+
const starting = instance.start();
|
|
326
|
+
starting.catch(() => undefined);
|
|
327
|
+
try {
|
|
328
|
+
await Promise.race([
|
|
329
|
+
starting,
|
|
330
|
+
new Promise((_, reject) => {
|
|
331
|
+
timer = setTimeout(() => reject(new Error(`Instance did not become ready within ${readyTimeoutMs} ms`)), readyTimeoutMs);
|
|
332
|
+
}),
|
|
333
|
+
]);
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
throw new InstanceStartFailure(failureMessage(error), tail(error instanceof InstanceStartError ? error.failure.logTail : undefined));
|
|
337
|
+
}
|
|
338
|
+
finally {
|
|
339
|
+
clearTimeout(timer);
|
|
340
|
+
}
|
|
341
|
+
if (stableMs <= 0)
|
|
342
|
+
return;
|
|
343
|
+
await new Promise((resolvePromise, reject) => {
|
|
344
|
+
const onFailure = (failure) => {
|
|
345
|
+
cleanup();
|
|
346
|
+
reject(new InstanceStartFailure(`Instance failed after ready: ${failure.message}`, tail(failure.logTail)));
|
|
347
|
+
};
|
|
348
|
+
const cleanup = () => { clearTimeout(stable); instance.off('failure', onFailure); };
|
|
349
|
+
const stable = setTimeout(() => {
|
|
350
|
+
cleanup();
|
|
351
|
+
if (instance.state === 'running')
|
|
352
|
+
resolvePromise();
|
|
353
|
+
else
|
|
354
|
+
reject(new InstanceStartFailure(`Instance is ${instance.state} after ready`, tail()));
|
|
355
|
+
}, stableMs);
|
|
356
|
+
instance.on('failure', onFailure);
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
#instanceState() {
|
|
360
|
+
return this.#options.instance?.state ?? 'unmanaged';
|
|
361
|
+
}
|
|
362
|
+
async #installedVersion(name) {
|
|
363
|
+
try {
|
|
364
|
+
const manifest = JSON.parse(await readFile(join(this.#root, 'node_modules', ...name.split('/'), 'package.json'), 'utf8'));
|
|
365
|
+
return manifest.name === name && typeof manifest.version === 'string' ? manifest.version : null;
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
/** Cross-process exclusion (CLI and gateway); separate from the workflow's own lock file. */
|
|
372
|
+
async #lock() {
|
|
373
|
+
const directory = join(this.#root, '.zhin');
|
|
374
|
+
await mkdir(directory, { recursive: true });
|
|
375
|
+
const file = join(directory, LOCK_FILE);
|
|
376
|
+
let handle;
|
|
377
|
+
try {
|
|
378
|
+
handle = await open(file, 'wx', 0o600);
|
|
379
|
+
}
|
|
380
|
+
catch (error) {
|
|
381
|
+
if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST'))
|
|
382
|
+
throw error;
|
|
383
|
+
let detail = '另一进程正在执行依赖事务';
|
|
384
|
+
try {
|
|
385
|
+
const previous = JSON.parse(await readFile(file, 'utf8'));
|
|
386
|
+
try {
|
|
387
|
+
process.kill(previous.pid, 0);
|
|
388
|
+
}
|
|
389
|
+
catch (probe) {
|
|
390
|
+
if (probe instanceof Error && 'code' in probe && probe.code === 'ESRCH')
|
|
391
|
+
detail = `上一次依赖事务进程 ${previous.pid} 已退出;确认没有安装进程后删除 ${file} 再重试`;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
catch { /* an incomplete lock is not safe to take over */ }
|
|
395
|
+
throw new GatewayError('conflict', detail, { cause: error });
|
|
396
|
+
}
|
|
397
|
+
const marker = JSON.stringify({ pid: process.pid, nonce: randomUUID() });
|
|
398
|
+
try {
|
|
399
|
+
await handle.writeFile(marker);
|
|
400
|
+
}
|
|
401
|
+
catch (error) {
|
|
402
|
+
await handle.close();
|
|
403
|
+
await rm(file, { force: true });
|
|
404
|
+
throw error;
|
|
405
|
+
}
|
|
406
|
+
return async () => {
|
|
407
|
+
await handle.close();
|
|
408
|
+
if (await readFile(file, 'utf8').catch(() => '') === marker)
|
|
409
|
+
await rm(file, { force: true });
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
/** Reads the configuration need a workflow `install` result reports, if any. */
|
|
414
|
+
function configurationNeed(result) {
|
|
415
|
+
const need = result?.needsConfiguration;
|
|
416
|
+
if (!need || typeof need.name !== 'string' || typeof need.instanceKey !== 'string')
|
|
417
|
+
return undefined;
|
|
418
|
+
const strings = (value) => Object.freeze(Array.isArray(value) ? value.filter((item) => typeof item === 'string') : []);
|
|
419
|
+
return Object.freeze({ name: need.name, instanceKey: need.instanceKey, missing: strings(need.missing), missingEnv: strings(need.missingEnv) });
|
|
420
|
+
}
|
|
421
|
+
/** A refused declaration is the caller's input problem; anything else stays internal. */
|
|
422
|
+
function declarationError(error) {
|
|
423
|
+
return error instanceof PluginDeclarationError
|
|
424
|
+
? new GatewayError('invalid_input', error.message, { cause: error, details: { errors: error.errors, missingEnv: error.missingEnv } })
|
|
425
|
+
: error;
|
|
426
|
+
}
|
|
427
|
+
/** Structured feedback of a declaration refused inside the transaction (config changed after preflight). */
|
|
428
|
+
function declarationDetails(error) {
|
|
429
|
+
const cause = error instanceof PluginManagementOperationError ? error.cause : error;
|
|
430
|
+
if (!(cause instanceof PluginDeclarationError))
|
|
431
|
+
return {};
|
|
432
|
+
return {
|
|
433
|
+
...(cause.errors?.length ? { errors: cause.errors } : {}),
|
|
434
|
+
...(cause.missingEnv?.length ? { missingEnv: cause.missingEnv } : {}),
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
/** The workflow's own error says files were not rolled back; the gateway does roll back, so report the cause. */
|
|
438
|
+
function failureMessage(error) {
|
|
439
|
+
const cause = error instanceof PluginManagementOperationError ? error.cause : error;
|
|
440
|
+
return cause instanceof Error ? cause.message : String(cause);
|
|
441
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Lockfiles of every supported manager; all are captured so a manager switch is also undone. */
|
|
2
|
+
export declare const PROJECT_LOCKFILES: readonly string[];
|
|
3
|
+
/** Files a dependency transaction may change, captured before it starts. */
|
|
4
|
+
export declare class ProjectFileSnapshot {
|
|
5
|
+
#private;
|
|
6
|
+
private constructor();
|
|
7
|
+
/** package.json, lockfiles, pnpm-workspace.yaml and the root config file. */
|
|
8
|
+
static capture(projectRoot: string, extra?: readonly string[]): Promise<ProjectFileSnapshot>;
|
|
9
|
+
/** Whether a lockfile existed, so the rollback can reinstall with a frozen lockfile. */
|
|
10
|
+
get hasLockfile(): boolean;
|
|
11
|
+
get paths(): readonly string[];
|
|
12
|
+
/** Writes back every changed file (atomically) and removes files created since the capture. */
|
|
13
|
+
restore(): Promise<void>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { loadProjectConfig } from '@zhin.js/scaffold-wizard';
|
|
5
|
+
/** Lockfiles of every supported manager; all are captured so a manager switch is also undone. */
|
|
6
|
+
export const PROJECT_LOCKFILES = Object.freeze([
|
|
7
|
+
'pnpm-lock.yaml', 'package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock', 'bun.lock', 'bun.lockb',
|
|
8
|
+
]);
|
|
9
|
+
/** Files a dependency transaction may change, captured before it starts. */
|
|
10
|
+
export class ProjectFileSnapshot {
|
|
11
|
+
#files;
|
|
12
|
+
constructor(files) { this.#files = files; }
|
|
13
|
+
/** package.json, lockfiles, pnpm-workspace.yaml and the root config file. */
|
|
14
|
+
static async capture(projectRoot, extra = []) {
|
|
15
|
+
const root = resolve(projectRoot);
|
|
16
|
+
const names = new Set(['package.json', ...PROJECT_LOCKFILES, 'pnpm-workspace.yaml', ...extra]);
|
|
17
|
+
const loaded = loadProjectConfig(root);
|
|
18
|
+
if (loaded.configPath)
|
|
19
|
+
names.add(loaded.configPath);
|
|
20
|
+
const files = [];
|
|
21
|
+
for (const name of names) {
|
|
22
|
+
const path = isAbsolute(name) ? name : join(root, name);
|
|
23
|
+
const inside = relative(root, path);
|
|
24
|
+
if (!inside || inside.startsWith('..') || isAbsolute(inside))
|
|
25
|
+
throw new Error(`Snapshot path escapes the project: ${name}`);
|
|
26
|
+
files.push(await captureFile(path));
|
|
27
|
+
}
|
|
28
|
+
return new ProjectFileSnapshot(Object.freeze(files));
|
|
29
|
+
}
|
|
30
|
+
/** Whether a lockfile existed, so the rollback can reinstall with a frozen lockfile. */
|
|
31
|
+
get hasLockfile() {
|
|
32
|
+
return this.#files.some(file => file.content !== null && PROJECT_LOCKFILES.includes(basename(file.path)));
|
|
33
|
+
}
|
|
34
|
+
get paths() { return this.#files.map(file => file.path); }
|
|
35
|
+
/** Writes back every changed file (atomically) and removes files created since the capture. */
|
|
36
|
+
async restore() {
|
|
37
|
+
for (const file of this.#files) {
|
|
38
|
+
const current = await readOptional(file.path);
|
|
39
|
+
if (file.content === null) {
|
|
40
|
+
if (current !== null)
|
|
41
|
+
await rm(file.path, { force: true });
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (current !== null && current.equals(file.content))
|
|
45
|
+
continue;
|
|
46
|
+
const temporary = join(dirname(file.path), `.${basename(file.path)}.${randomUUID()}.restore`);
|
|
47
|
+
try {
|
|
48
|
+
await writeFile(temporary, file.content, { flag: 'wx', ...(file.mode === undefined ? {} : { mode: file.mode }) });
|
|
49
|
+
await rename(temporary, file.path);
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
await rm(temporary, { force: true });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function captureFile(path) {
|
|
58
|
+
const content = await readOptional(path);
|
|
59
|
+
if (content === null)
|
|
60
|
+
return Object.freeze({ path, content: null });
|
|
61
|
+
const { mode } = await stat(path);
|
|
62
|
+
return Object.freeze({ path, content, mode: mode & 0o777 });
|
|
63
|
+
}
|
|
64
|
+
async function readOptional(path) {
|
|
65
|
+
try {
|
|
66
|
+
return await readFile(path);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
|
|
70
|
+
return null;
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
}
|
package/lib/packages.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Process composition guard: dependency watchers must not interrupt a mutation. */
|
|
2
|
+
export class PluginManagementActivity {
|
|
3
|
+
#operations = 0;
|
|
4
|
+
get active() { return this.#operations > 0; }
|
|
5
|
+
async run(operation) {
|
|
6
|
+
this.#operations += 1;
|
|
7
|
+
try {
|
|
8
|
+
return await operation();
|
|
9
|
+
}
|
|
10
|
+
finally {
|
|
11
|
+
this.#operations -= 1;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ConsoleConfigExplanation } from '@zhin.js/console-protocol';
|
|
2
|
+
type Projection = Pick<ConsoleConfigExplanation, 'projection' | 'reason' | 'fields'>;
|
|
3
|
+
type Schema = Readonly<Record<string, unknown>>;
|
|
4
|
+
/** Read-only metadata projection. Actual values never leave this function. */
|
|
5
|
+
export declare function explainConfigFields(schema: Schema, input: unknown, current: unknown, committedInput: unknown): Projection;
|
|
6
|
+
export {};
|