@getformation/cloud-cli 1.0.0
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 +39 -0
- package/bin/formation-cloud.mjs +4 -0
- package/connector/SKILL.md +42 -0
- package/package.json +28 -0
- package/src/cli.mjs +122 -0
- package/src/client.mjs +203 -0
- package/src/connector.mjs +408 -0
- package/src/errors.mjs +29 -0
- package/src/installer.mjs +520 -0
- package/src/manifest.mjs +296 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { lstat, mkdir, open, readFile, readdir, realpath, rmdir, stat, unlink } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fail } from "./errors.mjs";
|
|
6
|
+
|
|
7
|
+
export const CONNECTOR_RECEIPT_NAME = ".formation-connector-install.json";
|
|
8
|
+
export const CONNECTOR_VERSION = "1.0.0";
|
|
9
|
+
const CONNECTOR_NAME = "formation";
|
|
10
|
+
const PACKAGE_NAME = "@getformation/cloud-cli";
|
|
11
|
+
const MAX_CONNECTOR_BYTES = 65_536;
|
|
12
|
+
const POSIX_PATH_LIMIT_BYTES = 1_024;
|
|
13
|
+
const INSTALL_TOKEN_BYTES = 18;
|
|
14
|
+
const INSTALL_TOKEN_HEX_LENGTH = INSTALL_TOKEN_BYTES * 2;
|
|
15
|
+
const INSTALL_TOKEN_PLACEHOLDER = "0".repeat(INSTALL_TOKEN_HEX_LENGTH);
|
|
16
|
+
const LOCK_PREFIX = ".formation-connector-lock-";
|
|
17
|
+
const STAGE_PREFIX = ".formation-connector-stage-";
|
|
18
|
+
const CONNECTOR_FILE_NAME = "SKILL.md";
|
|
19
|
+
|
|
20
|
+
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
21
|
+
|
|
22
|
+
function connectorFiles(directory) {
|
|
23
|
+
return {
|
|
24
|
+
skill: path.join(directory, CONNECTOR_FILE_NAME),
|
|
25
|
+
receipt: path.join(directory, CONNECTOR_RECEIPT_NAME),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function installPaths(target, token = INSTALL_TOKEN_PLACEHOLDER) {
|
|
30
|
+
const parent = path.dirname(target);
|
|
31
|
+
const lock = path.join(parent, `${LOCK_PREFIX}${sha256(Buffer.from(target, "utf8"))}`);
|
|
32
|
+
const stage = path.join(parent, `${STAGE_PREFIX}${token}`);
|
|
33
|
+
return {
|
|
34
|
+
lock,
|
|
35
|
+
stage,
|
|
36
|
+
targetFiles: connectorFiles(target),
|
|
37
|
+
stageFiles: connectorFiles(stage),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function assertInstallPathBudget(paths) {
|
|
42
|
+
const candidates = [
|
|
43
|
+
paths.lock, paths.stage, ...Object.values(paths.targetFiles), ...Object.values(paths.stageFiles),
|
|
44
|
+
];
|
|
45
|
+
if (candidates.some((candidate) => Buffer.byteLength(candidate, "utf8") + 1 > POSIX_PATH_LIMIT_BYTES)) {
|
|
46
|
+
fail("invalid_target", "The connector target is too long for a safe portable install.",
|
|
47
|
+
"Choose a shorter absolute target path.");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function identity(info) {
|
|
52
|
+
return { dev: info.dev, ino: info.ino, type: info.isDirectory() ? "directory" : info.isFile() ? "file" : "other" };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function sameIdentity(left, right) {
|
|
56
|
+
return left?.dev === right?.dev && left?.ino === right?.ino && left?.type === right?.type;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function artifact() {
|
|
60
|
+
const bytes = await readFile(new URL("../connector/SKILL.md", import.meta.url));
|
|
61
|
+
if (!bytes.length || bytes.length > MAX_CONNECTOR_BYTES) {
|
|
62
|
+
fail("invalid_connector", "The bundled Formation connector is empty or oversized.", "Install a valid @getformation/cloud-cli package.");
|
|
63
|
+
}
|
|
64
|
+
const digest = sha256(bytes);
|
|
65
|
+
return { bytes, sha256: digest, artifactRevisionId: `connectorrev_${digest}` };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function exactTarget(target) {
|
|
69
|
+
if (typeof target !== "string" || !path.isAbsolute(target) || path.resolve(target) !== target) {
|
|
70
|
+
fail("invalid_target", "The connector target must be one exact normalized absolute path.",
|
|
71
|
+
"Pass --target with an absolute path that contains no dot segments or trailing separator.");
|
|
72
|
+
}
|
|
73
|
+
if ([path.parse(target).root, path.resolve(os.homedir())].includes(target)) {
|
|
74
|
+
fail("unsafe_target", "The filesystem root and home directory cannot be connector targets.",
|
|
75
|
+
"Choose a dedicated absent connector directory below an owner-controlled parent.");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function assertOwnerSafe(info, subject) {
|
|
80
|
+
if (typeof process.getuid === "function" && info.uid !== process.getuid()) {
|
|
81
|
+
fail("install_conflict", `${subject} has foreign ownership.`, "Choose an owner-controlled target.");
|
|
82
|
+
}
|
|
83
|
+
if ((info.mode & 0o022) !== 0) {
|
|
84
|
+
fail("install_conflict", `${subject} is group- or world-writable.`, "Choose an owner-controlled target.");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function maybeLstat(filePath) {
|
|
89
|
+
try { return await lstat(filePath); }
|
|
90
|
+
catch (error) { if (error?.code === "ENOENT") return null; throw error; }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function safeParent(target) {
|
|
94
|
+
const root = path.parse(target).root;
|
|
95
|
+
let cursor = root;
|
|
96
|
+
for (const part of path.relative(root, path.dirname(target)).split(path.sep).filter(Boolean)) {
|
|
97
|
+
cursor = path.join(cursor, part);
|
|
98
|
+
const info = await maybeLstat(cursor);
|
|
99
|
+
if (!info || !info.isDirectory() || info.isSymbolicLink()) {
|
|
100
|
+
fail("invalid_target_parent", "Every target ancestor must be one existing real directory.",
|
|
101
|
+
"Create one owner-controlled parent without symbolic links.");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const parent = await realpath(path.dirname(target));
|
|
105
|
+
if (parent !== path.dirname(target)) {
|
|
106
|
+
fail("install_conflict", "The connector target parent uses an alias.", "Use the canonical parent path.");
|
|
107
|
+
}
|
|
108
|
+
const info = await stat(parent);
|
|
109
|
+
assertOwnerSafe(info, "The connector target parent");
|
|
110
|
+
return { path: parent, identity: identity(info) };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function boundedRegularFile(filePath, maximum) {
|
|
114
|
+
const info = await lstat(filePath);
|
|
115
|
+
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 || info.size < 1 || info.size > maximum) {
|
|
116
|
+
fail("install_conflict", "The connector target contains an invalid file.", "Use a new absent target.");
|
|
117
|
+
}
|
|
118
|
+
assertOwnerSafe(info, "A connector target file");
|
|
119
|
+
const bytes = await readFile(filePath);
|
|
120
|
+
if (bytes.length !== info.size) fail("install_conflict", "A connector target file changed while it was read.", "Use a new absent target.");
|
|
121
|
+
return bytes;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function exactReceipt(value, expected) {
|
|
125
|
+
const keys = ["schemaVersion", "installer", "connector", "connectorVersion", "artifactRevisionId", "sha256", "installedAt", "file"];
|
|
126
|
+
if (!value || typeof value !== "object" || Array.isArray(value)
|
|
127
|
+
|| Object.keys(value).sort().join("\0") !== keys.sort().join("\0")
|
|
128
|
+
|| value.schemaVersion !== 1 || value.installer !== PACKAGE_NAME || value.connector !== CONNECTOR_NAME
|
|
129
|
+
|| value.connectorVersion !== CONNECTOR_VERSION || value.artifactRevisionId !== expected.artifactRevisionId
|
|
130
|
+
|| value.sha256 !== expected.sha256 || value.file !== CONNECTOR_FILE_NAME
|
|
131
|
+
|| typeof value.installedAt !== "string" || !Number.isFinite(Date.parse(value.installedAt))) {
|
|
132
|
+
fail("install_conflict", "The connector install receipt is invalid or stale.", "Use a new absent target.");
|
|
133
|
+
}
|
|
134
|
+
return value;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function inspect(target, expected, { allowEmpty = false } = {}) {
|
|
138
|
+
const info = await maybeLstat(target);
|
|
139
|
+
if (!info) return { exists: false };
|
|
140
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
141
|
+
fail("install_conflict", "The connector target is not one real directory.", "Use a new absent target.");
|
|
142
|
+
}
|
|
143
|
+
assertOwnerSafe(info, "The connector target");
|
|
144
|
+
const entries = (await readdir(target)).sort();
|
|
145
|
+
if (allowEmpty && entries.length === 0) {
|
|
146
|
+
return { exists: true, empty: true, identity: identity(info) };
|
|
147
|
+
}
|
|
148
|
+
if (entries.join("\0") !== [CONNECTOR_RECEIPT_NAME, CONNECTOR_FILE_NAME].sort().join("\0")) {
|
|
149
|
+
fail("install_conflict", "The connector target contains missing or extra paths.", "Use a new absent target.");
|
|
150
|
+
}
|
|
151
|
+
const files = connectorFiles(target);
|
|
152
|
+
const body = await boundedRegularFile(files.skill, MAX_CONNECTOR_BYTES);
|
|
153
|
+
const receiptBytes = await boundedRegularFile(files.receipt, 16_384);
|
|
154
|
+
let receipt;
|
|
155
|
+
try { receipt = exactReceipt(JSON.parse(receiptBytes.toString("utf8")), expected); }
|
|
156
|
+
catch (error) {
|
|
157
|
+
if (error?.code) throw error;
|
|
158
|
+
fail("install_conflict", "The connector install receipt is not valid JSON.", "Use a new absent target.");
|
|
159
|
+
}
|
|
160
|
+
if (sha256(body) !== expected.sha256) {
|
|
161
|
+
fail("install_conflict", "The installed connector bytes do not match their receipt.", "Use a new absent target.");
|
|
162
|
+
}
|
|
163
|
+
return { exists: true, empty: false, identity: identity(info), receipt };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function assertIdentity(filePath, expected, subject) {
|
|
167
|
+
const info = await maybeLstat(filePath);
|
|
168
|
+
if (!info || !sameIdentity(identity(info), expected)) {
|
|
169
|
+
fail("install_conflict", `${subject} changed during installation.`, "Inspect the target and retry with one unchanged owner-controlled parent.");
|
|
170
|
+
}
|
|
171
|
+
assertOwnerSafe(info, subject);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function writeExclusive(filePath, bytes, onCreate, afterWrite) {
|
|
175
|
+
const handle = await open(filePath, "wx", 0o600);
|
|
176
|
+
try {
|
|
177
|
+
const descriptor = { identity: identity(await handle.stat()), complete: false };
|
|
178
|
+
onCreate?.(descriptor);
|
|
179
|
+
await handle.writeFile(bytes);
|
|
180
|
+
await afterWrite?.({ filePath });
|
|
181
|
+
await handle.sync();
|
|
182
|
+
Object.assign(descriptor, { complete: true, byteLength: bytes.length, sha256: sha256(bytes) });
|
|
183
|
+
} finally { await handle.close(); }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function syncDirectory(directory) {
|
|
187
|
+
const handle = await open(directory, "r");
|
|
188
|
+
try { await handle.sync(); } finally { await handle.close(); }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function removeOwnedFile(filePath, expectedIdentity, expectedContents = null) {
|
|
192
|
+
const info = await maybeLstat(filePath);
|
|
193
|
+
if (!info || !info.isFile() || info.isSymbolicLink() || info.nlink !== 1
|
|
194
|
+
|| !sameIdentity(identity(info), expectedIdentity)) return false;
|
|
195
|
+
assertOwnerSafe(info, "An installer-owned file");
|
|
196
|
+
if (expectedContents !== null && await readFile(filePath, "utf8").catch(() => null) !== expectedContents) return false;
|
|
197
|
+
const current = await maybeLstat(filePath);
|
|
198
|
+
if (!current || !sameIdentity(identity(current), expectedIdentity)) return false;
|
|
199
|
+
await unlink(filePath);
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function removeOwnedDirectory(directory, parent) {
|
|
204
|
+
if (!directory?.path || !directory.identity) return false;
|
|
205
|
+
try {
|
|
206
|
+
const parentInfo = await maybeLstat(parent.path);
|
|
207
|
+
if (!parentInfo || !sameIdentity(identity(parentInfo), parent.identity)) return false;
|
|
208
|
+
assertOwnerSafe(parentInfo, "The connector target parent");
|
|
209
|
+
const directoryInfo = await maybeLstat(directory.path);
|
|
210
|
+
if (!directoryInfo || !directoryInfo.isDirectory() || directoryInfo.isSymbolicLink()
|
|
211
|
+
|| !sameIdentity(identity(directoryInfo), directory.identity)) return false;
|
|
212
|
+
assertOwnerSafe(directoryInfo, "An installer-owned connector directory");
|
|
213
|
+
const entries = (await readdir(directory.path)).sort();
|
|
214
|
+
if (entries.some((entry) => !directory.files.has(entry))) return false;
|
|
215
|
+
for (const entry of entries) {
|
|
216
|
+
const descriptor = directory.files.get(entry);
|
|
217
|
+
const filePath = path.join(directory.path, entry);
|
|
218
|
+
const info = await maybeLstat(filePath);
|
|
219
|
+
if (!info || !info.isFile() || info.isSymbolicLink() || info.nlink !== 1
|
|
220
|
+
|| !sameIdentity(identity(info), descriptor.identity)) return false;
|
|
221
|
+
if (descriptor.complete) {
|
|
222
|
+
const bytes = await readFile(filePath);
|
|
223
|
+
if (bytes.length !== descriptor.byteLength || sha256(bytes) !== descriptor.sha256) return false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (!sameIdentity(identity(await lstat(directory.path)), directory.identity)
|
|
227
|
+
|| (await readdir(directory.path)).sort().join("\0") !== entries.join("\0")) return false;
|
|
228
|
+
for (const entry of entries) {
|
|
229
|
+
if (!await removeOwnedFile(path.join(directory.path, entry), directory.files.get(entry).identity)) return false;
|
|
230
|
+
}
|
|
231
|
+
const current = await maybeLstat(directory.path);
|
|
232
|
+
if (!current || !sameIdentity(identity(current), directory.identity) || (await readdir(directory.path)).length !== 0) return false;
|
|
233
|
+
await rmdir(directory.path);
|
|
234
|
+
await syncDirectory(parent.path);
|
|
235
|
+
return true;
|
|
236
|
+
} catch { return false; }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function removeOwnedTargetFiles(target, files) {
|
|
240
|
+
try {
|
|
241
|
+
for (const [name, descriptor] of files) {
|
|
242
|
+
const filePath = path.join(target, name);
|
|
243
|
+
const info = await maybeLstat(filePath);
|
|
244
|
+
if (!info || !info.isFile() || info.isSymbolicLink() || info.nlink !== 1
|
|
245
|
+
|| !sameIdentity(identity(info), descriptor.identity)) continue;
|
|
246
|
+
if (descriptor.complete) {
|
|
247
|
+
const bytes = await readFile(filePath);
|
|
248
|
+
if (bytes.length !== descriptor.byteLength || sha256(bytes) !== descriptor.sha256) continue;
|
|
249
|
+
}
|
|
250
|
+
await removeOwnedFile(filePath, descriptor.identity);
|
|
251
|
+
}
|
|
252
|
+
const targetInfo = await maybeLstat(target);
|
|
253
|
+
if (targetInfo?.isDirectory() && !targetInfo.isSymbolicLink()) await syncDirectory(target);
|
|
254
|
+
} catch {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function ensureRetryableTarget(target, expected) {
|
|
261
|
+
try { await mkdir(target, { mode: 0o700 }); }
|
|
262
|
+
catch (error) { if (error?.code !== "EEXIST") throw error; }
|
|
263
|
+
const state = await inspect(target, expected, { allowEmpty: true });
|
|
264
|
+
if (!state.exists || !state.empty) {
|
|
265
|
+
fail("install_conflict", "The connector target changed before publication.",
|
|
266
|
+
"Inspect it and retry only when it is one owner-safe empty directory.");
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function acquireLock(paths, token, parent) {
|
|
271
|
+
await assertIdentity(parent.path, parent.identity, "The connector target parent");
|
|
272
|
+
let handle;
|
|
273
|
+
try { handle = await open(paths.lock, "wx", 0o600); }
|
|
274
|
+
catch (error) {
|
|
275
|
+
if (error?.code === "EEXIST") fail("install_conflict", "Another connector install lock exists.", "Wait for that install to finish.");
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
278
|
+
let lockIdentity;
|
|
279
|
+
try {
|
|
280
|
+
lockIdentity = identity(await handle.stat());
|
|
281
|
+
await handle.writeFile(token);
|
|
282
|
+
await handle.sync();
|
|
283
|
+
await syncDirectory(parent.path);
|
|
284
|
+
} catch (error) {
|
|
285
|
+
await handle.close().catch(() => {});
|
|
286
|
+
if (lockIdentity) await removeOwnedFile(paths.lock, lockIdentity).catch(() => {});
|
|
287
|
+
await syncDirectory(parent.path).catch(() => {});
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
290
|
+
await handle.close();
|
|
291
|
+
return {
|
|
292
|
+
path: paths.lock,
|
|
293
|
+
token,
|
|
294
|
+
async release() {
|
|
295
|
+
if (await removeOwnedFile(paths.lock, lockIdentity, token)) await syncDirectory(parent.path);
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export async function installConnector({ target, apply = false, hooks = {} }) {
|
|
301
|
+
exactTarget(target);
|
|
302
|
+
assertInstallPathBudget(installPaths(target));
|
|
303
|
+
const parent = await safeParent(target);
|
|
304
|
+
const expected = await artifact();
|
|
305
|
+
const before = await inspect(target, expected, { allowEmpty: true });
|
|
306
|
+
if (before.exists && !before.empty) {
|
|
307
|
+
return { status: "current", action: "unchanged", applied: false, target,
|
|
308
|
+
artifactRevisionId: expected.artifactRevisionId, sha256: expected.sha256 };
|
|
309
|
+
}
|
|
310
|
+
if (!apply) {
|
|
311
|
+
return { status: "ready", action: "would_install", applied: false, target,
|
|
312
|
+
artifactRevisionId: expected.artifactRevisionId, sha256: expected.sha256 };
|
|
313
|
+
}
|
|
314
|
+
const token = randomBytes(INSTALL_TOKEN_BYTES).toString("hex");
|
|
315
|
+
const paths = installPaths(target, token);
|
|
316
|
+
const lock = await acquireLock(paths, token, parent);
|
|
317
|
+
const stage = {
|
|
318
|
+
path: paths.stage,
|
|
319
|
+
identity: null,
|
|
320
|
+
files: new Map(),
|
|
321
|
+
};
|
|
322
|
+
const published = { files: new Map(), committed: false };
|
|
323
|
+
try {
|
|
324
|
+
await assertIdentity(parent.path, parent.identity, "The connector target parent");
|
|
325
|
+
const lockedTarget = await inspect(target, expected, { allowEmpty: true });
|
|
326
|
+
if (lockedTarget.exists && !lockedTarget.empty) {
|
|
327
|
+
return { status: "current", action: "unchanged", applied: false, target,
|
|
328
|
+
artifactRevisionId: expected.artifactRevisionId, sha256: expected.sha256 };
|
|
329
|
+
}
|
|
330
|
+
await mkdir(stage.path, { mode: 0o700 });
|
|
331
|
+
stage.identity = identity(await lstat(stage.path));
|
|
332
|
+
await hooks.afterStageDirectoryCreate?.({ stage: stage.path, target });
|
|
333
|
+
await writeExclusive(paths.stageFiles.skill, expected.bytes,
|
|
334
|
+
(descriptor) => stage.files.set(CONNECTOR_FILE_NAME, descriptor));
|
|
335
|
+
const receipt = {
|
|
336
|
+
schemaVersion: 1, installer: PACKAGE_NAME, connector: CONNECTOR_NAME, connectorVersion: CONNECTOR_VERSION,
|
|
337
|
+
artifactRevisionId: expected.artifactRevisionId, sha256: expected.sha256,
|
|
338
|
+
installedAt: new Date().toISOString(), file: CONNECTOR_FILE_NAME,
|
|
339
|
+
};
|
|
340
|
+
await writeExclusive(paths.stageFiles.receipt, Buffer.from(`${JSON.stringify(receipt, null, 2)}\n`, "utf8"),
|
|
341
|
+
(descriptor) => stage.files.set(CONNECTOR_RECEIPT_NAME, descriptor));
|
|
342
|
+
await syncDirectory(stage.path);
|
|
343
|
+
await syncDirectory(parent.path);
|
|
344
|
+
await hooks.afterStage?.({ stage: stage.path, target });
|
|
345
|
+
const staged = await inspect(stage.path, expected);
|
|
346
|
+
if (!staged.exists || !sameIdentity(staged.identity, stage.identity)) {
|
|
347
|
+
fail("install_conflict", "The staged connector changed during installation.", "Inspect the staged path and retry with a new absent target.");
|
|
348
|
+
}
|
|
349
|
+
await assertIdentity(parent.path, parent.identity, "The connector target parent");
|
|
350
|
+
const publishTarget = await inspect(target, expected, { allowEmpty: true });
|
|
351
|
+
if (publishTarget.exists && !publishTarget.empty) {
|
|
352
|
+
fail("install_conflict", "The connector target changed during installation.",
|
|
353
|
+
"Inspect it and retry only when it is one owner-safe empty directory.");
|
|
354
|
+
}
|
|
355
|
+
await assertIdentity(stage.path, stage.identity, "The staged connector");
|
|
356
|
+
const verifiedStage = await inspect(stage.path, expected);
|
|
357
|
+
if (!verifiedStage.exists || !sameIdentity(verifiedStage.identity, stage.identity)) {
|
|
358
|
+
fail("install_conflict", "The staged connector changed before installation.", "Inspect the staged path and retry with a new absent target.");
|
|
359
|
+
}
|
|
360
|
+
await hooks.beforeInstall?.({ stage: stage.path, target });
|
|
361
|
+
await ensureRetryableTarget(target, expected);
|
|
362
|
+
await hooks.afterTargetDirectoryCreate?.({ stage: stage.path, target });
|
|
363
|
+
await writeExclusive(paths.targetFiles.skill, expected.bytes,
|
|
364
|
+
(descriptor) => published.files.set(CONNECTOR_FILE_NAME, descriptor),
|
|
365
|
+
(context) => hooks.afterSkillWrite?.({ ...context, stage: stage.path, target }));
|
|
366
|
+
await writeExclusive(paths.targetFiles.receipt, Buffer.from(`${JSON.stringify(receipt, null, 2)}\n`, "utf8"),
|
|
367
|
+
(descriptor) => published.files.set(CONNECTOR_RECEIPT_NAME, descriptor));
|
|
368
|
+
await syncDirectory(target);
|
|
369
|
+
await syncDirectory(parent.path);
|
|
370
|
+
const installed = await inspect(target, expected);
|
|
371
|
+
if (!installed.exists) {
|
|
372
|
+
fail("install_conflict", "The installed connector changed before it committed.", "Inspect the target before retrying.");
|
|
373
|
+
}
|
|
374
|
+
if (!await removeOwnedDirectory(stage, parent)) {
|
|
375
|
+
fail("install_conflict", "The staged connector changed before cleanup.", "Inspect the staged path before retrying.");
|
|
376
|
+
}
|
|
377
|
+
stage.path = null;
|
|
378
|
+
published.committed = true;
|
|
379
|
+
return { status: "current", action: "installed", applied: true, target,
|
|
380
|
+
artifactRevisionId: expected.artifactRevisionId, sha256: expected.sha256 };
|
|
381
|
+
} catch (error) {
|
|
382
|
+
if (["EEXIST", "ENOTEMPTY", "ENOTDIR", "EISDIR"].includes(error?.code)) {
|
|
383
|
+
fail("install_conflict", "The connector target appeared during installation.", "Inspect it and choose a new absent target.");
|
|
384
|
+
}
|
|
385
|
+
throw error;
|
|
386
|
+
} finally {
|
|
387
|
+
if (!published.committed) await removeOwnedTargetFiles(target, published.files).catch(() => {});
|
|
388
|
+
await removeOwnedDirectory(stage, parent).catch(() => {});
|
|
389
|
+
await lock.release().catch(() => {});
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export async function checkConnector({ target }) {
|
|
394
|
+
exactTarget(target);
|
|
395
|
+
try {
|
|
396
|
+
await safeParent(target);
|
|
397
|
+
const expected = await artifact();
|
|
398
|
+
const state = await inspect(target, expected);
|
|
399
|
+
if (!state.exists) return { status: "conflict", target, reason: "not_installed", deleted: false };
|
|
400
|
+
return { status: "current", target, artifactRevisionId: expected.artifactRevisionId,
|
|
401
|
+
sha256: expected.sha256, deleted: false };
|
|
402
|
+
} catch (error) {
|
|
403
|
+
if (["install_conflict", "invalid_target_parent"].includes(error?.code)) {
|
|
404
|
+
return { status: "conflict", target, reason: error.message, deleted: false };
|
|
405
|
+
}
|
|
406
|
+
throw error;
|
|
407
|
+
}
|
|
408
|
+
}
|
package/src/errors.mjs
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export class CloudCliError extends Error {
|
|
2
|
+
constructor(code, message, recovery, details = undefined) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = "CloudCliError";
|
|
5
|
+
this.code = code;
|
|
6
|
+
this.recovery = recovery;
|
|
7
|
+
if (details !== undefined) this.details = details;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function fail(code, message, recovery, details) {
|
|
12
|
+
throw new CloudCliError(code, message, recovery, details);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function safeFailure(error) {
|
|
16
|
+
if (error instanceof CloudCliError) {
|
|
17
|
+
return {
|
|
18
|
+
code: error.code,
|
|
19
|
+
error: error.message,
|
|
20
|
+
recovery: error.recovery,
|
|
21
|
+
...(error.details === undefined ? {} : { details: error.details }),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
code: "command_failed",
|
|
26
|
+
error: "The Cloud command could not complete safely.",
|
|
27
|
+
recovery: "Check the command, configured origin, and current target state.",
|
|
28
|
+
};
|
|
29
|
+
}
|