@hellolynk-ai/codex-plugin 0.0.0-stage → 0.2.100
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/.agents/plugins/marketplace.json +20 -0
- package/LICENSE +92 -0
- package/README.md +101 -2
- package/bin/hellolynk-client-setup.mjs +2748 -0
- package/bin/hellolynk-mcp-config-shape.mjs +23 -0
- package/hellolynk-client-pack-manifest.json +619 -0
- package/hellolynk-client-pack-pin.json +4 -0
- package/package.json +58 -4
- package/plugins/hellolynk-codex/.app.json +3 -0
- package/plugins/hellolynk-codex/.codex-plugin/plugin.json +38 -0
- package/plugins/hellolynk-codex/.mcp.json +19 -0
- package/plugins/hellolynk-codex/assets/hellolynk-mark.png +0 -0
- package/plugins/hellolynk-codex/package.json +11 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-account.mjs +180 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-core.mjs +770 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-profile.mjs +191 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-auth.mjs +347 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-protected-store.mjs +2721 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-local-process-lock.mjs +157 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-neutral-host-installation-store.mjs +765 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-platform-credential-store.mjs +773 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-runtime-safety.cjs +471 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
- package/plugins/hellolynk-codex/runtime/connect-activate.mjs +837 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-agent-configurator.mjs +701 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-account.mjs +180 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-core.mjs +770 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-profile.mjs +191 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-entry.mjs +31 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-runtime.mjs +600 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update.mjs +453 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-contact-request-e2ee.mjs +867 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-device-enrollment.mjs +435 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-direct-text-e2ee-client.mjs +1964 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-direct-text-e2ee.mjs +1207 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host-auth.mjs +347 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host-protected-store.mjs +2721 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host-transport-client.mjs +946 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host.mjs +2374 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-human-confirmation-ledger.mjs +256 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-human-relationship-controller.mjs +1234 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-inbox-read-state.mjs +11 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-indexed-message-store.mjs +1558 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-instance-conversation.mjs +185 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-contact-requests.mjs +1733 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-contacts.mjs +1228 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-migration.mjs +766 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-protection.mjs +747 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-identity-trust.mjs +274 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-messaging.mjs +3043 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-peer-transport.mjs +103 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-process-lock.mjs +157 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-model-inbox.mjs +230 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-model-notices.cjs +60 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-addressing-tools.cjs +231 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-conversations.mjs +280 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-human-confirmation-broker.mjs +1036 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-inbox-runtime.mjs +553 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-notice.mjs +50 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-reply-routes.mjs +96 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-worker-lease.mjs +84 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-assembly.mjs +955 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-installation-store.mjs +765 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-launcher.mjs +665 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-process.mjs +4374 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-store-adapters.mjs +548 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-workbench-read-model.mjs +1365 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-platform-credential-store.mjs +773 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-relay-client-cli.mjs +232 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-release-retention.mjs +277 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-runtime-safety.cjs +471 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-action-grants.mjs +457 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-connection.mjs +234 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-bootstrap.mjs +64 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-entry.mjs +70 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-loader.mjs +156 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-manifest.json +358 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-pin.json +4 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/configure-agent.ps1 +281 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/credential-store.ps1 +844 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-human-confirmation-ledger.ps1 +155 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-local-data-key.ps1 +300 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-native-human-confirmation-broker.ps1 +216 -0
- package/plugins/hellolynk-codex/runtime/workbench/candidate-bootstrap.mjs +54 -0
- package/plugins/hellolynk-codex/runtime/workbench/contract.mjs +1314 -0
- package/plugins/hellolynk-codex/runtime/workbench/hellolynk-workbench-build.json +215 -0
- package/plugins/hellolynk-codex/runtime/workbench/host-capabilities.mjs +80 -0
- package/plugins/hellolynk-codex/runtime/workbench/i18n.mjs +1127 -0
- package/plugins/hellolynk-codex/runtime/workbench/loopback-surface.mjs +740 -0
- package/plugins/hellolynk-codex/runtime/workbench/mcp-app-bundle.mjs +97 -0
- package/plugins/hellolynk-codex/runtime/workbench/service.mjs +485 -0
- package/plugins/hellolynk-codex/runtime/workbench/state.mjs +421 -0
- package/plugins/hellolynk-codex/runtime/workbench/transports.mjs +171 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/app.mjs +6120 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/connection-form.mjs +155 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/hellolynk-mark.png +0 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/http-transport.mjs +247 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/arrow-left.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/close.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/contacts.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/inbox.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/refresh.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/search.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/security.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/settings.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/index.html +139 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/loopback-entry.mjs +26 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/mcp-app-client.mjs +151 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/mcp-app-entry.mjs +452 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/styles.css +686 -0
- package/plugins/hellolynk-codex/server/codex-neutral-production.mjs +520 -0
- package/plugins/hellolynk-codex/server/mcp-proxy.mjs +306 -0
- package/plugins/hellolynk-codex/server/mcp-server.mjs +1193 -0
- package/plugins/hellolynk-codex/server/native-conversation-directory.mjs +33 -0
- package/plugins/hellolynk-codex/server/native-reply-routes.mjs +32 -0
- package/plugins/hellolynk-codex/server/native-task-client.mjs +382 -0
- package/plugins/hellolynk-codex/server/native-task-runtime.mjs +8 -0
- package/plugins/hellolynk-codex/server/neutral-host-adapter.mjs +649 -0
- package/plugins/hellolynk-codex/server/workbench-runtime.mjs +180 -0
- package/plugins/hellolynk-codex/skills/hellolynk-open/SKILL.md +11 -0
- package/plugins/hellolynk-codex/skills/hellolynk-open/agents/openai.yaml +4 -0
- package/tools/hellolynk-activate-neutral-host-clients.mjs +837 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { basename, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { redeemAgentBindingKey, runSanitizedDoctor } from "./hellolynk-client-core.mjs";
|
|
6
|
+
import { createPlatformCredentialStore } from "./hellolynk-platform-credential-store.mjs";
|
|
7
|
+
import {
|
|
8
|
+
defaultHelloLynkClientRoot,
|
|
9
|
+
readClientProfile,
|
|
10
|
+
writeClientProfile,
|
|
11
|
+
} from "./hellolynk-client-profile.mjs";
|
|
12
|
+
import {
|
|
13
|
+
createNeutralHostInstallationStore,
|
|
14
|
+
writeNeutralHostProfileManifest,
|
|
15
|
+
} from "./hellolynk-neutral-host-installation-store.mjs";
|
|
16
|
+
import { createHostProtectedStore } from "./hellolynk-host-protected-store.mjs";
|
|
17
|
+
import { createDeviceEnrollmentClient } from "./hellolynk-device-enrollment.mjs";
|
|
18
|
+
|
|
19
|
+
function parseArgs(argv) {
|
|
20
|
+
const [command, ...rest] = argv;
|
|
21
|
+
const options = {};
|
|
22
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
23
|
+
const part = rest[index];
|
|
24
|
+
if ([
|
|
25
|
+
"--allow-loopback-http", "--binding-key-from-stdin", "--bootstrap-host-transport",
|
|
26
|
+
].includes(part)) {
|
|
27
|
+
options[part.slice(2)] = true;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (!part.startsWith("--") || index + 1 >= rest.length) throw new Error("client_arguments_invalid");
|
|
31
|
+
options[part.slice(2)] = rest[index + 1];
|
|
32
|
+
index += 1;
|
|
33
|
+
}
|
|
34
|
+
return { command, options };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function required(options, key) {
|
|
38
|
+
const value = String(options[key] || "").trim();
|
|
39
|
+
if (!value) throw new Error(`client_argument_${key}_required`);
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function readBindingKeyFromStdin() {
|
|
44
|
+
const chunks = [];
|
|
45
|
+
let bytes = 0;
|
|
46
|
+
for await (const input of process.stdin) {
|
|
47
|
+
const chunk = Buffer.from(input);
|
|
48
|
+
bytes += chunk.length;
|
|
49
|
+
if (bytes > 4 * 1024) throw new Error("binding_key_input_too_large");
|
|
50
|
+
chunks.push(chunk);
|
|
51
|
+
const value = Buffer.concat(chunks);
|
|
52
|
+
const lineEnd = value.indexOf(0x0a);
|
|
53
|
+
if (lineEnd >= 0) {
|
|
54
|
+
const line = value.subarray(0, lineEnd).toString("utf8").replace(/\r$/, "");
|
|
55
|
+
if (!line) throw new Error("binding_key_stdin_required");
|
|
56
|
+
return line;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const value = Buffer.concat(chunks).toString("utf8").replace(/\r$/, "");
|
|
60
|
+
if (!value) throw new Error("binding_key_stdin_required");
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function publicErrorCode(error) {
|
|
65
|
+
const code = String(error?.code || error?.message || "client_operation_failed");
|
|
66
|
+
return /^[a-z][a-z0-9_]{0,127}$/.test(code) ? code : "client_operation_failed";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function bind(options, dependencies = {}) {
|
|
70
|
+
if (options["binding-key-from-stdin"] !== true) throw new Error("binding_key_stdin_flag_required");
|
|
71
|
+
const root = options.root || defaultHelloLynkClientRoot();
|
|
72
|
+
const profileId = required(options, "profile");
|
|
73
|
+
const configPath = options.config || join(root, "profiles", `${profileId}.json`);
|
|
74
|
+
const bindingKey = await (dependencies.readSecret || readBindingKeyFromStdin)();
|
|
75
|
+
const store = dependencies.store || createPlatformCredentialStore({ storeRoot: join(root, "credentials") });
|
|
76
|
+
const redeemed = await redeemAgentBindingKey({
|
|
77
|
+
baseUrl: required(options, "base-url"),
|
|
78
|
+
bindingKey,
|
|
79
|
+
deviceId: required(options, "device-id"),
|
|
80
|
+
hostInstallationId: required(options, "host-installation-id"),
|
|
81
|
+
bootstrapHostTransport: options["bootstrap-host-transport"] === true,
|
|
82
|
+
allowLoopbackHttp: options["allow-loopback-http"] === true,
|
|
83
|
+
fetchImpl: dependencies.fetchImpl,
|
|
84
|
+
});
|
|
85
|
+
await store.save(profileId, redeemed.credential);
|
|
86
|
+
try {
|
|
87
|
+
const profile = await writeClientProfile(configPath, {
|
|
88
|
+
contract: "hellolynk-client-profile-v1",
|
|
89
|
+
schemaVersion: redeemed.hostId && redeemed.authorizationVersion ? 2 : 1,
|
|
90
|
+
profileId,
|
|
91
|
+
baseUrl: options["base-url"],
|
|
92
|
+
allowLoopbackHttp: options["allow-loopback-http"] === true,
|
|
93
|
+
deviceId: redeemed.deviceId,
|
|
94
|
+
credentialId: redeemed.credentialId,
|
|
95
|
+
principalId: redeemed.principalId,
|
|
96
|
+
hellolynkId: redeemed.hellolynkId,
|
|
97
|
+
agentId: redeemed.agentId,
|
|
98
|
+
delegateId: redeemed.delegateId,
|
|
99
|
+
...(redeemed.hostId && redeemed.authorizationVersion ? {
|
|
100
|
+
hostId: redeemed.hostId,
|
|
101
|
+
authorizationVersion: redeemed.authorizationVersion,
|
|
102
|
+
} : {}),
|
|
103
|
+
region: redeemed.region,
|
|
104
|
+
allowedScopes: redeemed.allowedScopes,
|
|
105
|
+
platform: process.platform,
|
|
106
|
+
});
|
|
107
|
+
const neutralHostRoot = options["neutral-host-root"] == null
|
|
108
|
+
? null : resolve(required(options, "neutral-host-root"));
|
|
109
|
+
let neutralHostActivated = false;
|
|
110
|
+
let bootstrapTransportStored = false;
|
|
111
|
+
if (neutralHostRoot) {
|
|
112
|
+
if (options.adapter == null) throw new Error("client_argument_adapter_required");
|
|
113
|
+
const installationStore = dependencies.installationStore
|
|
114
|
+
|| createNeutralHostInstallationStore({ root: neutralHostRoot });
|
|
115
|
+
const installation = await installationStore.adoptProfileFile({
|
|
116
|
+
profileFile: configPath,
|
|
117
|
+
adapterId: required(options, "adapter"),
|
|
118
|
+
});
|
|
119
|
+
await writeActivationProfileManifest({
|
|
120
|
+
clientRoot: root,
|
|
121
|
+
configPath,
|
|
122
|
+
neutralHostRoot,
|
|
123
|
+
installation,
|
|
124
|
+
});
|
|
125
|
+
if (redeemed.bootstrapHostTransportCredential) {
|
|
126
|
+
const protectedStore = dependencies.protectedStore || createHostProtectedStore({
|
|
127
|
+
root: neutralHostRoot,
|
|
128
|
+
installationStore,
|
|
129
|
+
});
|
|
130
|
+
await protectedStore.saveTransportCredential({
|
|
131
|
+
credentialId: redeemed.bootstrapHostTransportCredential.credentialId,
|
|
132
|
+
principalId: redeemed.principalId,
|
|
133
|
+
hostInstallationId: redeemed.hostId,
|
|
134
|
+
state: "active",
|
|
135
|
+
credential: redeemed.bootstrapHostTransportCredential.credential,
|
|
136
|
+
createdAt: redeemed.bootstrapHostTransportCredential.createdAt,
|
|
137
|
+
expiresAt: redeemed.bootstrapHostTransportCredential.expiresAt,
|
|
138
|
+
revokedAt: null,
|
|
139
|
+
});
|
|
140
|
+
bootstrapTransportStored = true;
|
|
141
|
+
}
|
|
142
|
+
neutralHostActivated = true;
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
ok: true,
|
|
146
|
+
profileId: profile.profileId,
|
|
147
|
+
credentialStored: true,
|
|
148
|
+
configPath,
|
|
149
|
+
neutralHostActivated,
|
|
150
|
+
bootstrapTransportStored,
|
|
151
|
+
};
|
|
152
|
+
} catch (error) {
|
|
153
|
+
await store.remove(profileId).catch(() => {});
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function writeActivationProfileManifest(input) {
|
|
159
|
+
const manifestPath = join(input.neutralHostRoot, "profile-manifest-v1.json");
|
|
160
|
+
const knownPaths = new Map([[basename(input.configPath, ".json"), input.configPath]]);
|
|
161
|
+
try {
|
|
162
|
+
const existing = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
163
|
+
if (existing?.contract === "hellolynk-neutral-host-profile-manifest-v1" && Array.isArray(existing.profiles)) {
|
|
164
|
+
for (const candidate of existing.profiles) {
|
|
165
|
+
const absolute = resolve(String(candidate || ""));
|
|
166
|
+
knownPaths.set(basename(absolute, ".json"), absolute);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
} catch {}
|
|
170
|
+
const profileIds = input.installation.delegateBindings.map((item) => item.profileId);
|
|
171
|
+
const orderedIds = [
|
|
172
|
+
input.installation.canonicalProfileId,
|
|
173
|
+
...profileIds.filter((profileId) => profileId !== input.installation.canonicalProfileId).sort(),
|
|
174
|
+
];
|
|
175
|
+
const profiles = orderedIds.map((profileId) => (
|
|
176
|
+
knownPaths.get(profileId) || join(input.clientRoot, "profiles", `${profileId}.json`)
|
|
177
|
+
));
|
|
178
|
+
await writeNeutralHostProfileManifest({ root: input.neutralHostRoot, profiles });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function doctor(options, dependencies = {}) {
|
|
182
|
+
const root = options.root || defaultHelloLynkClientRoot();
|
|
183
|
+
const profileId = required(options, "profile");
|
|
184
|
+
const configPath = options.config || join(root, "profiles", `${profileId}.json`);
|
|
185
|
+
const profile = await readClientProfile(configPath);
|
|
186
|
+
if (profile.profileId !== profileId) throw new Error("client_profile_binding_mismatch");
|
|
187
|
+
const store = dependencies.store || createPlatformCredentialStore({ storeRoot: join(root, "credentials") });
|
|
188
|
+
let credentialPresent = false;
|
|
189
|
+
try {
|
|
190
|
+
let credential = await store.load(profileId);
|
|
191
|
+
credentialPresent = Boolean(credential);
|
|
192
|
+
credential = null;
|
|
193
|
+
} catch {
|
|
194
|
+
credentialPresent = false;
|
|
195
|
+
}
|
|
196
|
+
return runSanitizedDoctor({
|
|
197
|
+
baseUrl: profile.baseUrl,
|
|
198
|
+
allowLoopbackHttp: profile.allowLoopbackHttp,
|
|
199
|
+
credentialPresent,
|
|
200
|
+
platform: profile.platform,
|
|
201
|
+
fetchImpl: dependencies.fetchImpl,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function runRelayClientCli(argv = process.argv.slice(2), dependencies = {}) {
|
|
206
|
+
const { command, options } = parseArgs(argv);
|
|
207
|
+
if (command === "connect") {
|
|
208
|
+
const allowed = new Set(["base-url", "root", "neutral-host-root", "allow-loopback-http", "hellolynk-id", "adapter", "profile", "instance-name", "device-label"]);
|
|
209
|
+
if (Object.keys(options).some((key) => !allowed.has(key))) throw new Error("device_enrollment_argument_unknown");
|
|
210
|
+
return createDeviceEnrollmentClient({
|
|
211
|
+
baseUrl: required(options, "base-url"), root: options.root, hostRoot: options["neutral-host-root"],
|
|
212
|
+
allowLoopbackHttp: options["allow-loopback-http"] === true,
|
|
213
|
+
}, dependencies).connect({
|
|
214
|
+
hellolynkId: required(options, "hellolynk-id"), clientType: required(options, "adapter"),
|
|
215
|
+
profileId: options.profile, requestedName: options["instance-name"], deviceLabel: options["device-label"],
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
if (command === "bind") return bind(options, dependencies);
|
|
219
|
+
if (command === "doctor") return doctor(options, dependencies);
|
|
220
|
+
throw new Error("client_command_invalid");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const directRun = process.argv[1] && basename(process.argv[1]) === basename(fileURLToPath(import.meta.url));
|
|
224
|
+
if (directRun) {
|
|
225
|
+
runRelayClientCli().then(
|
|
226
|
+
(result) => process.stdout.write(`${JSON.stringify(result, null, 2)}\n`),
|
|
227
|
+
(error) => {
|
|
228
|
+
process.stderr.write(`${JSON.stringify({ ok: false, error: publicErrorCode(error) })}\n`);
|
|
229
|
+
process.exitCode = 1;
|
|
230
|
+
},
|
|
231
|
+
);
|
|
232
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
|
|
7
|
+
const execute = promisify(execFile);
|
|
8
|
+
const clients = ['codex', 'openclaw', 'trae-work', 'workbuddy', 'dsh', 'claude-code'];
|
|
9
|
+
const contract = 'hellolynk-release-retention-v1';
|
|
10
|
+
// Claude starts every HelloLynk process through one stable copy of the entry, so
|
|
11
|
+
// a command line never names the release that process loaded. The entry records
|
|
12
|
+
// it under releases/.claude-code-processes/<pid>.json (see stable-entry.mjs).
|
|
13
|
+
// Cleanup code from before these records reads only a v1 receipt and stops at
|
|
14
|
+
// any other one before deleting, so a client with a stable entry keeps a v2
|
|
15
|
+
// receipt: an older worker that cleans up after an update deletes nothing.
|
|
16
|
+
const ENTRY = { 'claude-code': ['claude-code', 'hellolynk-claude-code.mjs'] };
|
|
17
|
+
const contractWithEntry = 'hellolynk-release-retention-v2';
|
|
18
|
+
const PROCESS_CONTRACT = 'hellolynk-release-process-v1';
|
|
19
|
+
const START_TOLERANCE_MS = 15000;
|
|
20
|
+
export const releaseProcessDirectory = (releasesRoot, client) => path.join(releasesRoot, `.${client}-processes`);
|
|
21
|
+
const failure = code => Object.assign(new Error(code), { code });
|
|
22
|
+
const equal = (a, b) => process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
23
|
+
|
|
24
|
+
async function directory(target) {
|
|
25
|
+
const stat = await fs.lstat(target);
|
|
26
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw failure('release_retention_path_invalid');
|
|
27
|
+
return stat;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function rootPath(root) {
|
|
31
|
+
if (!path.isAbsolute(root || '') || path.basename(root) !== 'releases') throw failure('release_retention_root_invalid');
|
|
32
|
+
const resolved = path.resolve(root);
|
|
33
|
+
for (let p = resolved; ; p = path.dirname(p)) {
|
|
34
|
+
await directory(p);
|
|
35
|
+
if (p === path.dirname(p)) break;
|
|
36
|
+
}
|
|
37
|
+
// Windows may supply an 8.3 TEMP/user path. Parents were checked above;
|
|
38
|
+
// delete only under its canonical absolute spelling.
|
|
39
|
+
return fs.realpath(resolved);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function regularTree(target) {
|
|
43
|
+
await directory(target);
|
|
44
|
+
for (const item of await fs.readdir(target, { withFileTypes: true })) {
|
|
45
|
+
const child = path.join(target, item.name), stat = await fs.lstat(child);
|
|
46
|
+
if (stat.isSymbolicLink()) throw failure('release_retention_link');
|
|
47
|
+
if (stat.isDirectory()) await regularTree(child);
|
|
48
|
+
else if (!stat.isFile() || stat.nlink !== 1) throw failure('release_retention_file_invalid');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Read only release references, never return process command lines. A running
|
|
53
|
+
// older Host may still import files after installation; deletion waits for the
|
|
54
|
+
// next successful install instead of stopping any user process.
|
|
55
|
+
export async function runningReleaseNames(releasesRoot) {
|
|
56
|
+
if (process.platform !== 'win32') throw failure('release_retention_process_check_unavailable');
|
|
57
|
+
const root = await rootPath(releasesRoot);
|
|
58
|
+
const literal = `${root}${path.sep}`.replaceAll("'", "''");
|
|
59
|
+
const script = `$ErrorActionPreference='Stop'; $pattern=[regex]::Escape('${literal}')+'((?:codex|openclaw|trae-work|workbuddy|dsh|claude-code)-[a-f0-9]{64})(?=[\\\\/"\\s]|$)'; $names=@(Get-CimInstance Win32_Process | ForEach-Object { if ($_.CommandLine) { foreach ($m in [regex]::Matches($_.CommandLine,$pattern,'IgnoreCase')) { $m.Groups[1].Value.ToLowerInvariant() } } }); ConvertTo-Json -Compress -InputObject @($names | Sort-Object -Unique)`;
|
|
60
|
+
const shell = path.join(process.env.SystemRoot || process.env.SYSTEMROOT || 'C:\\Windows', 'System32/WindowsPowerShell/v1.0/powershell.exe');
|
|
61
|
+
const result = await execute(shell, ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script],
|
|
62
|
+
{ windowsHide: true, timeout: 15000, maxBuffer: 256 * 1024 });
|
|
63
|
+
const names = JSON.parse(result.stdout.replace(/^\uFEFF/, '').trim());
|
|
64
|
+
if (!Array.isArray(names) || names.some(n => typeof n !== 'string' || !/^(?:codex|openclaw|trae-work|workbuddy|dsh|claude-code)-[a-f0-9]{64}$/.test(n))) {
|
|
65
|
+
throw failure('release_retention_process_check_invalid');
|
|
66
|
+
}
|
|
67
|
+
return names;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The given pids and every process whose command line runs this installation's
|
|
71
|
+
// stable entry: pid, start time and whether it runs the entry. Only processes
|
|
72
|
+
// with a positive pid are listed, and command lines never leave PowerShell.
|
|
73
|
+
export async function entryProcessList(client, releasesRoot, pids = []) {
|
|
74
|
+
if (!ENTRY[client]) return [];
|
|
75
|
+
if (process.platform !== 'win32') throw failure('release_retention_process_check_unavailable');
|
|
76
|
+
if (!Array.isArray(pids) || pids.some(pid => !Number.isSafeInteger(pid) || pid <= 0)) throw failure('release_retention_process_check_invalid');
|
|
77
|
+
const root = await rootPath(releasesRoot);
|
|
78
|
+
// This installation's entry only, in its given and canonical spellings:
|
|
79
|
+
// any case, either slash, quoted or not.
|
|
80
|
+
const literal = value => value.split(/[\\/]+/).map(part => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('[\\\\/]+');
|
|
81
|
+
const entries = [...new Set([root, path.resolve(releasesRoot)])].map(value => literal(path.join(path.dirname(value), ...ENTRY[client])));
|
|
82
|
+
const pattern = `(?<=^|["\\s])(?:${entries.join('|')})(?=["\\s]|$)`.replaceAll("'", "''");
|
|
83
|
+
const script = `$ErrorActionPreference='Stop'; $wanted=@(${pids.join(',')}); $epoch=[datetime]::new(1970,1,1,0,0,0,[DateTimeKind]::Utc); $list=@(Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -gt 0 } | ForEach-Object { $entry=[bool]($_.CommandLine -and [regex]::IsMatch($_.CommandLine,'${pattern}','IgnoreCase')); if ($entry -or ($wanted -contains [int64]$_.ProcessId)) { [pscustomobject]@{ pid=[int64]$_.ProcessId; startedAt=$(if ($_.CreationDate) { [int64](($_.CreationDate.ToUniversalTime() - $epoch).TotalMilliseconds) } else { $null }); entry=$entry } } }); ConvertTo-Json -Compress -InputObject $list`;
|
|
84
|
+
const shell = path.join(process.env.SystemRoot || process.env.SYSTEMROOT || 'C:\\Windows', 'System32/WindowsPowerShell/v1.0/powershell.exe');
|
|
85
|
+
const { stdout } = await execute(shell, ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script],
|
|
86
|
+
{ windowsHide: true, timeout: 15000, maxBuffer: 1024 * 1024 });
|
|
87
|
+
const list = JSON.parse(stdout.replace(/^\uFEFF/, '').trim() || '[]');
|
|
88
|
+
if (!Array.isArray(list) || list.some(p => !Number.isSafeInteger(p?.pid) || p.pid <= 0 || typeof p.entry !== 'boolean'
|
|
89
|
+
|| (p.startedAt !== null && !Number.isSafeInteger(p.startedAt)))) throw failure('release_retention_process_check_invalid');
|
|
90
|
+
return list;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Releases named by live entry records, and whether a process started through
|
|
94
|
+
// the stable entry has no matching record (one from before records existed).
|
|
95
|
+
// Records of exited or reused pids are removed, so a release can go at the
|
|
96
|
+
// next install once its last process is gone.
|
|
97
|
+
async function entryReleases(releasesRoot, root, client, pattern, dependencies) {
|
|
98
|
+
if (!ENTRY[client]) return { names: [], unregistered: false };
|
|
99
|
+
const folder = releaseProcessDirectory(root, client);
|
|
100
|
+
let items = [];
|
|
101
|
+
try {
|
|
102
|
+
await directory(folder);
|
|
103
|
+
items = await fs.readdir(folder, { withFileTypes: true });
|
|
104
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
105
|
+
const records = items.filter(item => item.isFile() && /^[1-9]\d{0,9}\.json$/.test(item.name))
|
|
106
|
+
.map(item => ({ pid: Number(item.name.slice(0, -5)), file: path.join(folder, item.name) }));
|
|
107
|
+
// The given root too: an entry may be named in the spelling the caller uses, not only the canonical one.
|
|
108
|
+
const processes = await (dependencies.entryProcesses || entryProcessList)(client, releasesRoot, records.map(record => record.pid));
|
|
109
|
+
const live = new Map(processes.map(p => [p.pid, p])), names = new Set(), registered = new Set();
|
|
110
|
+
let unregistered = false;
|
|
111
|
+
for (const { pid, file } of records) {
|
|
112
|
+
const alive = live.get(pid), started = Number.isSafeInteger(alive?.startedAt) ? alive.startedAt : null;
|
|
113
|
+
let record = null;
|
|
114
|
+
try { record = JSON.parse(await fs.readFile(file, 'utf8')); } catch {}
|
|
115
|
+
const valid = record?.contract === PROCESS_CONTRACT && record.pid === pid && Number.isSafeInteger(record.startedAt)
|
|
116
|
+
&& typeof record.release === 'string' && pattern.test(record.release);
|
|
117
|
+
if (valid && started !== null && Math.abs(started - record.startedAt) <= START_TOLERANCE_MS) {
|
|
118
|
+
registered.add(pid); names.add(record.release);
|
|
119
|
+
} else if (!alive || (valid && started !== null)) {
|
|
120
|
+
await fs.rm(file, { force: true }).catch(() => {}); // Exited, or the pid now belongs to a later process.
|
|
121
|
+
} else unregistered = true; // Live, but its record is still being written or its start time is unknown.
|
|
122
|
+
}
|
|
123
|
+
if (processes.some(p => p.entry && !registered.has(p.pid))) unregistered = true;
|
|
124
|
+
return { names: [...names], unregistered };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// A stopped on-demand task is still a configuration reference. Keeping only
|
|
128
|
+
// live processes can delete its executable before the next client startup.
|
|
129
|
+
export async function configuredReleaseNames(releasesRoot) {
|
|
130
|
+
if (process.platform !== 'win32') throw failure('release_retention_configuration_check_unavailable');
|
|
131
|
+
const root = await rootPath(releasesRoot);
|
|
132
|
+
const prefixes = [...new Set([root, path.resolve(releasesRoot)])].map(value => (value + path.sep).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
133
|
+
const pattern = ('(?:' + prefixes.join('|') + ')((?:codex|openclaw|trae-work|workbuddy|dsh|claude-code)-[a-f0-9]{64})(?=[\\\\/"\\s]|$)').replaceAll("'", "''");
|
|
134
|
+
const script = "$ErrorActionPreference='Stop'; $pattern='" + pattern + "'; $scheduler=New-Object -ComObject 'Schedule.Service'; $scheduler.Connect(); $names=@($scheduler.GetFolder('\\').GetTasks(1) | Where-Object { $_.Name -like 'HelloLynk *' } | ForEach-Object { foreach ($a in $_.Definition.Actions) { foreach ($v in @($a.Path,$a.Arguments,$a.WorkingDirectory)) { foreach ($m in [regex]::Matches([string]$v,$pattern,'IgnoreCase')) { $m.Groups[1].Value.ToLowerInvariant() } } } }); ConvertTo-Json -Compress -InputObject @($names | Sort-Object -Unique)";
|
|
135
|
+
const shell = path.join(process.env.SystemRoot || process.env.SYSTEMROOT || 'C:\\Windows', 'System32/WindowsPowerShell/v1.0/powershell.exe');
|
|
136
|
+
const { stdout } = await execute(shell, ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script],
|
|
137
|
+
{ windowsHide: true, timeout: 15000, maxBuffer: 256 * 1024 });
|
|
138
|
+
const names = JSON.parse(stdout.replace(/^\uFEFF/, '').trim());
|
|
139
|
+
if (!Array.isArray(names) || names.some(n => typeof n !== 'string' || !/^(?:codex|openclaw|trae-work|workbuddy|dsh|claude-code)-[a-f0-9]{64}$/.test(n))) {
|
|
140
|
+
throw failure('release_retention_configuration_check_invalid');
|
|
141
|
+
}
|
|
142
|
+
return names;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Call only after the caller's complete adoption transaction succeeds, while
|
|
146
|
+
// holding its install/account lock. Never call from candidate staging or a
|
|
147
|
+
// rollback. The receipt records adoption order, not directory timestamps.
|
|
148
|
+
// Cleanup failure is reported separately and must not roll back a good install.
|
|
149
|
+
export async function pruneInstalledReleases({ releasesRoot, client, currentRelease, previousRelease = null, referencedReleases = [] }, dependencies = {}) {
|
|
150
|
+
const result = { complete: false, retained: [], removed: [], deferred: [] };
|
|
151
|
+
try {
|
|
152
|
+
if (!clients.includes(client)) throw failure('release_retention_client_invalid');
|
|
153
|
+
const root = await rootPath(releasesRoot), pattern = new RegExp(`^${client}-[a-f0-9]{64}$`);
|
|
154
|
+
const nameOf = value => {
|
|
155
|
+
if (typeof value !== 'string' || !path.isAbsolute(value)
|
|
156
|
+
|| ![root, path.resolve(releasesRoot)].some(p => equal(path.dirname(path.resolve(value)), p))
|
|
157
|
+
|| !pattern.test(path.basename(value))) throw failure('release_retention_target_invalid');
|
|
158
|
+
return path.basename(value);
|
|
159
|
+
};
|
|
160
|
+
const current = nameOf(currentRelease);
|
|
161
|
+
const receiptPath = path.join(root, `.${client}-retention.json`);
|
|
162
|
+
const old = await readReceipt(receiptPath, client, pattern);
|
|
163
|
+
const supplied = previousRelease === null ? null : nameOf(previousRelease);
|
|
164
|
+
const previous = supplied && supplied !== current ? supplied
|
|
165
|
+
: old?.current === current ? old.previous : old?.current || null;
|
|
166
|
+
const retained = [current, previous].filter(Boolean);
|
|
167
|
+
for (const name of retained) await directory(path.join(root, name));
|
|
168
|
+
const referenced = new Set([...referencedReleases.map(nameOf),
|
|
169
|
+
...await (dependencies.configuredReleases || configuredReleaseNames)(releasesRoot)]);
|
|
170
|
+
const entry = await entryReleases(releasesRoot, root, client, pattern, dependencies);
|
|
171
|
+
const running = new Set([...await (dependencies.runningReleases || runningReleaseNames)(root), ...entry.names]);
|
|
172
|
+
// Publish before deleting. An interrupted cleanup can retry the exact same
|
|
173
|
+
// pair on same-version reinstall without accidentally dropping its backup.
|
|
174
|
+
await writeReceipt(receiptPath, { contract: receiptContract(client), client, current, previous });
|
|
175
|
+
result.retained = retained;
|
|
176
|
+
await removeReleases(root, name => pattern.test(name) && !retained.includes(name), referenced, running, result, dependencies, entry.unregistered);
|
|
177
|
+
result.complete = result.deferred.length === 0;
|
|
178
|
+
} catch (error) { result.error = safeCode(error); }
|
|
179
|
+
return result;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// A stable-entry client calls this before it switches an install on its stable
|
|
183
|
+
// entry. An update worker of an earlier release cleans up after that install with
|
|
184
|
+
// its own code, which cannot see what a stable-entry process runs and stops
|
|
185
|
+
// before deleting only at a receipt other than v1. Resolves once the receipt is
|
|
186
|
+
// v2, keeping the adoption it records, with whether it wrote. Throws when that
|
|
187
|
+
// cannot be confirmed, even for a cause that passes before the worker reads the
|
|
188
|
+
// receipt (a file another process holds open): the caller then switches nothing.
|
|
189
|
+
// currentRelease, the release the install starts now, is recorded only when
|
|
190
|
+
// there is no receipt yet.
|
|
191
|
+
export async function protectRetentionReceipt({ releasesRoot, client, currentRelease }) {
|
|
192
|
+
if (!ENTRY[client]) throw failure('release_retention_client_invalid');
|
|
193
|
+
const root = await rootPath(releasesRoot), pattern = new RegExp(`^${client}-[a-f0-9]{64}$`);
|
|
194
|
+
const receiptPath = path.join(root, `.${client}-retention.json`);
|
|
195
|
+
const old = await readReceipt(receiptPath, client, pattern);
|
|
196
|
+
if (old?.contract === contractWithEntry) return false;
|
|
197
|
+
let current = old?.current;
|
|
198
|
+
if (!current) {
|
|
199
|
+
if (typeof currentRelease !== 'string' || !path.isAbsolute(currentRelease) || !pattern.test(path.basename(currentRelease))
|
|
200
|
+
|| ![root, path.resolve(releasesRoot)].some(p => equal(path.dirname(path.resolve(currentRelease)), p))) throw failure('release_retention_target_invalid');
|
|
201
|
+
current = path.basename(currentRelease);
|
|
202
|
+
await directory(path.join(root, current));
|
|
203
|
+
}
|
|
204
|
+
await writeReceipt(receiptPath, { contract: contractWithEntry, client, current, previous: old ? old.previous : null });
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const receiptContract = client => ENTRY[client] ? contractWithEntry : contract;
|
|
209
|
+
|
|
210
|
+
async function readReceipt(receiptPath, client, pattern) {
|
|
211
|
+
let old = null;
|
|
212
|
+
try {
|
|
213
|
+
const stat = await fs.lstat(receiptPath);
|
|
214
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > 4096) throw failure('release_retention_receipt_invalid');
|
|
215
|
+
old = JSON.parse(await fs.readFile(receiptPath, 'utf8'));
|
|
216
|
+
if (![contract, contractWithEntry].includes(old?.contract) || old.client !== client || !pattern.test(old.current)
|
|
217
|
+
|| old.previous !== null && !pattern.test(old.previous) || old.current === old.previous) throw failure('release_retention_receipt_invalid');
|
|
218
|
+
} catch (error) {
|
|
219
|
+
if (error.code === 'ENOENT') return null;
|
|
220
|
+
throw error.code ? error : failure('release_retention_receipt_invalid');
|
|
221
|
+
}
|
|
222
|
+
return old;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function writeReceipt(receiptPath, receipt) {
|
|
226
|
+
const temporary = `${receiptPath}.${crypto.randomUUID()}.tmp`;
|
|
227
|
+
try {
|
|
228
|
+
await fs.writeFile(temporary, JSON.stringify(receipt) + '\n', { flag: 'wx', mode: 0o600 });
|
|
229
|
+
await fs.rename(temporary, receiptPath);
|
|
230
|
+
} finally { await fs.unlink(temporary).catch(e => { if (e.code !== 'ENOENT') throw e; }); }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Uninstall keeps no release. One a process or scheduled task still uses is
|
|
234
|
+
// left for the next install or uninstall, like any other deferred release.
|
|
235
|
+
export async function removeInstalledReleases({ releasesRoot, client }, dependencies = {}) {
|
|
236
|
+
const result = { complete: false, removed: [], deferred: [] };
|
|
237
|
+
try {
|
|
238
|
+
if (!clients.includes(client)) throw failure('release_retention_client_invalid');
|
|
239
|
+
try { await fs.lstat(releasesRoot); } catch (error) { if (error.code === 'ENOENT') return { ...result, complete: true }; throw error; }
|
|
240
|
+
const root = await rootPath(releasesRoot), pattern = new RegExp(`^${client}-[a-f0-9]{64}$`);
|
|
241
|
+
const referenced = new Set(await (dependencies.configuredReleases || configuredReleaseNames)(releasesRoot));
|
|
242
|
+
const entry = await entryReleases(releasesRoot, root, client, pattern, dependencies);
|
|
243
|
+
const running = new Set([...await (dependencies.runningReleases || runningReleaseNames)(root), ...entry.names]);
|
|
244
|
+
await removeReleases(root, name => pattern.test(name), referenced, running, result, dependencies, entry.unregistered);
|
|
245
|
+
if (!result.deferred.length) {
|
|
246
|
+
await fs.rm(path.join(root, `.${client}-retention.json`), { force: true });
|
|
247
|
+
// entryReleases checked above that this is a folder in the releases root.
|
|
248
|
+
if (ENTRY[client]) await fs.rm(releaseProcessDirectory(root, client), { recursive: true, force: true });
|
|
249
|
+
}
|
|
250
|
+
result.complete = result.deferred.length === 0;
|
|
251
|
+
} catch (error) { result.error = safeCode(error); }
|
|
252
|
+
return result;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// An unregistered entry process may run any release of its client, so then
|
|
256
|
+
// none is removed until it exits.
|
|
257
|
+
async function removeReleases(root, selected, referenced, running, result, dependencies, unregistered = false) {
|
|
258
|
+
for (const name of await fs.readdir(root)) {
|
|
259
|
+
if (!selected(name)) continue;
|
|
260
|
+
if (referenced.has(name)) { result.deferred.push({ name, reason: 'release_still_referenced' }); continue; }
|
|
261
|
+
if (running.has(name)) { result.deferred.push({ name, reason: 'release_in_use' }); continue; }
|
|
262
|
+
if (unregistered) { result.deferred.push({ name, reason: 'release_in_use_unregistered' }); continue; }
|
|
263
|
+
try {
|
|
264
|
+
// The final absolute target is a checked direct child of the canonical
|
|
265
|
+
// releases root; never follow a junction inside an obsolete package.
|
|
266
|
+
const target = path.resolve(root, name);
|
|
267
|
+
if (!equal(path.dirname(target), root) || !equal(await fs.realpath(target), target)) throw failure('release_retention_path_invalid');
|
|
268
|
+
await regularTree(target);
|
|
269
|
+
await (dependencies.remove || fs.rm)(target, { recursive: true });
|
|
270
|
+
result.removed.push(name);
|
|
271
|
+
} catch (error) { result.deferred.push({ name, reason: safeCode(error) }); }
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function safeCode(error) {
|
|
276
|
+
return /^(?:release_retention_[a-z_]+|E[A-Z]+)$/.test(error?.code || '') ? error.code : 'release_retention_failed';
|
|
277
|
+
}
|