@le-space/node 0.1.2 → 0.1.4

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.
Files changed (3) hide show
  1. package/index.d.ts +194 -3
  2. package/index.js +669 -1
  3. package/package.json +4 -3
package/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as _shared_aleph_shared_types from '@shared-aleph/shared-types';
2
- import { MessageSigner, InstanceAllocation, PortMapping, RuntimeDiagnostics, CrnRecord, RootfsRequiredPortForward, MessageHasher, RootfsManifest } from '@shared-aleph/shared-types';
2
+ import { MessageSigner, InstanceAllocation, PortMapping, RuntimeDiagnostics, CrnRecord, RootfsRequiredPortForward, MessageHasher, RootfsManifest as RootfsManifest$1 } from '@shared-aleph/shared-types';
3
3
 
4
4
  declare function requiredEnv(name: string, env?: NodeJS.ProcessEnv): string;
5
5
  declare function optionalEnv(name: string, fallback?: string, env?: NodeJS.ProcessEnv): string;
@@ -132,7 +132,7 @@ interface DeployExecutorDependencies {
132
132
  signer?: MessageSigner;
133
133
  sender?: string;
134
134
  hasher?: MessageHasher;
135
- manifest?: RootfsManifest | null;
135
+ manifest?: RootfsManifest$1 | null;
136
136
  log?: (message: string) => void;
137
137
  }
138
138
  declare function executeDeployPlan(plan: DeployPlan, dependencies?: DeployExecutorDependencies): Promise<DeployOutputResult>;
@@ -216,4 +216,195 @@ declare function runActionMode(env?: NodeJS.ProcessEnv, hooks?: {
216
216
  }): Promise<void>;
217
217
  declare function main(): Promise<void>;
218
218
 
219
- export { type DeployConfigurationResult, type DeployExecutorDependencies, type DeployMetadataResult, type DeployOutputResult, type DeployPlan, type PrivateKeyIdentity, actionLog, appendGithubOutput, appendGithubSummary, booleanEnv, buildScaffoldDeployResult, createPrivateKeyIdentity, createPrivateKeySigner, emitDeployOutputs, emitGeocodedCrnOutputs, executeDeployPlan, integerEnv, jsonEnv, main, optionalEnv, parseDeployPlan, requiredEnv, runActionMode };
219
+ interface RootfsContractPort {
220
+ port: number;
221
+ tcp?: boolean;
222
+ udp?: boolean;
223
+ purpose?: string;
224
+ }
225
+ interface RootfsContractRootfs {
226
+ profile: string;
227
+ installMode: string;
228
+ installDir: string;
229
+ binaryPath: string;
230
+ dataDir: string;
231
+ envFile: string;
232
+ }
233
+ interface RootfsContractServices {
234
+ bootstrap: string;
235
+ main: string;
236
+ autotlsRefresh: string;
237
+ }
238
+ interface RootfsContractManifest {
239
+ copyTarget: string;
240
+ notes?: string;
241
+ }
242
+ interface RootfsContractSource {
243
+ repository?: string;
244
+ subdirectory?: string;
245
+ }
246
+ interface RootfsContract {
247
+ schemaVersion: number;
248
+ id: string;
249
+ displayName?: string;
250
+ source?: RootfsContractSource;
251
+ rootfs: RootfsContractRootfs;
252
+ services: RootfsContractServices;
253
+ ports: RootfsContractPort[];
254
+ manifest: RootfsContractManifest;
255
+ }
256
+
257
+ type RootfsBuildDriver = 'auto' | 'host' | 'docker';
258
+ interface RootfsBuildPlan {
259
+ contract: RootfsContract;
260
+ contractPath: string;
261
+ projectDir: string;
262
+ alephDir: string;
263
+ outDir: string;
264
+ driver: RootfsBuildDriver;
265
+ rootfsSizeMiB: number;
266
+ rootfsImageSize: string;
267
+ rootfsVersion: string;
268
+ channel: string;
269
+ skipUpload: boolean;
270
+ skipBuild: boolean;
271
+ ipfsAddUrl: string;
272
+ ipfsGatewayUrl: string;
273
+ alephApiHost: string;
274
+ alephMessageWaitAttempts: number;
275
+ alephMessageWaitDelaySeconds: number;
276
+ alephPinAttempts: number;
277
+ alephPinDelaySeconds: number;
278
+ ipfsGatewayWaitAttempts: number;
279
+ ipfsGatewayWaitDelaySeconds: number;
280
+ manifestPath: string;
281
+ latestManifestPath: string | null;
282
+ versionedManifestPath: string | null;
283
+ imagePath: string;
284
+ baseImagePath: string;
285
+ binaryPath: string;
286
+ }
287
+
288
+ interface RootfsManifest {
289
+ profile: string;
290
+ version: string;
291
+ rootfsInstallStrategy: string;
292
+ requiresBootstrapNetwork: boolean;
293
+ bootstrapSummary: string;
294
+ rootfsSourceSizeBytes?: number;
295
+ requiredPortForwards: RootfsContract["ports"];
296
+ rootfsCid?: string;
297
+ rootfsItemHash?: string;
298
+ rootfsSizeMiB: number;
299
+ createdAt: string;
300
+ notes: string;
301
+ }
302
+ interface RootfsManifestOutputPaths {
303
+ primaryPath: string;
304
+ copyTargetPath?: string;
305
+ versionedTargetPath?: string;
306
+ }
307
+
308
+ interface RootfsToolchainAvailability {
309
+ hasDocker: boolean;
310
+ dockerDaemonRunning?: boolean;
311
+ hasVirtCustomize: boolean;
312
+ githubActions?: boolean;
313
+ }
314
+ interface RootfsCommandPlan {
315
+ command: string;
316
+ args: string[];
317
+ env?: Record<string, string>;
318
+ workdir?: string;
319
+ }
320
+ interface RootfsExecutionPlan {
321
+ mode: "host" | "docker";
322
+ reason: string;
323
+ referenceRootfsDir: string;
324
+ prepareCommand?: RootfsCommandPlan;
325
+ runCommand: RootfsCommandPlan;
326
+ }
327
+ interface RootfsExecutionPlanOptions {
328
+ referenceRootfsDir?: string;
329
+ projectMountPath?: string;
330
+ rootfsMountPath?: string;
331
+ dockerImageTag?: string;
332
+ }
333
+
334
+ interface RootfsPublicationArtifacts {
335
+ ipfsAddResponsePath: string;
336
+ storeMessagePath: string;
337
+ storeMessageStderrPath: string;
338
+ }
339
+ interface RootfsPublicationResult {
340
+ cid: string;
341
+ itemHash: string;
342
+ sourceSizeBytes?: number;
343
+ }
344
+
345
+ interface RootfsBuildPipeline {
346
+ buildPlan: RootfsBuildPlan;
347
+ executionPlan: RootfsExecutionPlan;
348
+ publicationArtifacts: RootfsPublicationArtifacts;
349
+ manifestPaths: RootfsManifestOutputPaths;
350
+ }
351
+ interface RootfsFinalizeResult {
352
+ manifest: RootfsManifest;
353
+ manifestJson: string;
354
+ manifestPaths: RootfsManifestOutputPaths;
355
+ publication?: RootfsPublicationResult;
356
+ }
357
+
358
+ interface RootfsExecutedCommand {
359
+ command: string;
360
+ args: string[];
361
+ workdir?: string;
362
+ env?: Record<string, string>;
363
+ }
364
+ interface RootfsCommandRunner {
365
+ run(command: RootfsExecutedCommand): Promise<void>;
366
+ }
367
+ interface RootfsFileReader {
368
+ readText(path: string): Promise<string>;
369
+ }
370
+ interface RootfsExecutorDependencies extends RootfsCommandRunner, RootfsFileReader {
371
+ }
372
+ interface RootfsExecutionResult {
373
+ pipeline: RootfsBuildPipeline;
374
+ executedCommands: RootfsExecutedCommand[];
375
+ }
376
+ interface RootfsPublishExecutionResult extends RootfsExecutionResult {
377
+ finalized: RootfsFinalizeResult;
378
+ }
379
+ interface RootfsPublishOptions extends RootfsExecutionPlanOptions {
380
+ createdAt?: string;
381
+ referenceRootfsDir?: string;
382
+ }
383
+ declare function buildRootfs(buildPlan: RootfsBuildPlan, deps: RootfsCommandRunner, availability: RootfsToolchainAvailability, options?: RootfsExecutionPlanOptions): Promise<RootfsExecutionResult>;
384
+ declare function publishRootfs(buildPlan: RootfsBuildPlan, deps: RootfsExecutorDependencies, options?: RootfsPublishOptions): Promise<RootfsPublishExecutionResult>;
385
+
386
+ interface ParsedRootfsRunnerInputs {
387
+ buildPlan: RootfsBuildPlan;
388
+ availability: RootfsToolchainAvailability;
389
+ referenceRootfsDir?: string;
390
+ createdAt?: string;
391
+ }
392
+ declare function parseRootfsRunnerInputs(env?: NodeJS.ProcessEnv): Promise<ParsedRootfsRunnerInputs>;
393
+ declare function runLocalCommand(command: {
394
+ command: string;
395
+ args: string[];
396
+ workdir?: string;
397
+ env?: Record<string, string>;
398
+ }): Promise<void>;
399
+ declare function emitRootfsOutputs(result: RootfsPublishExecutionResult, env?: NodeJS.ProcessEnv): Promise<void>;
400
+ declare function runRootfsMode(env?: NodeJS.ProcessEnv, hooks?: {
401
+ stdout?: (text: string) => void;
402
+ parseInputs?: typeof parseRootfsRunnerInputs;
403
+ buildRootfs?: typeof buildRootfs;
404
+ publishRootfs?: typeof publishRootfs;
405
+ runCommand?: typeof runLocalCommand;
406
+ readText?: (targetPath: string) => Promise<string>;
407
+ }): Promise<void>;
408
+ declare function rootfsMain(): Promise<void>;
409
+
410
+ export { type DeployConfigurationResult, type DeployExecutorDependencies, type DeployMetadataResult, type DeployOutputResult, type DeployPlan, type ParsedRootfsRunnerInputs, type PrivateKeyIdentity, actionLog, appendGithubOutput, appendGithubSummary, booleanEnv, buildScaffoldDeployResult, createPrivateKeyIdentity, createPrivateKeySigner, emitDeployOutputs, emitGeocodedCrnOutputs, emitRootfsOutputs, executeDeployPlan, integerEnv, jsonEnv, main, optionalEnv, parseDeployPlan, parseRootfsRunnerInputs, requiredEnv, rootfsMain, runActionMode, runLocalCommand, runRootfsMode };
package/index.js CHANGED
@@ -2102,6 +2102,669 @@ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
2102
2102
  process.exitCode = 1;
2103
2103
  });
2104
2104
  }
2105
+
2106
+ // src/rootfs-runner.ts
2107
+ import { pathToFileURL as pathToFileURL2 } from "url";
2108
+ import { readFile as readFile2 } from "fs/promises";
2109
+ import { spawn } from "child_process";
2110
+
2111
+ // ../rootfs/src/contract.ts
2112
+ import { readFile } from "fs/promises";
2113
+ import { fileURLToPath } from "url";
2114
+ function asObject(value) {
2115
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
2116
+ }
2117
+ function asString5(value) {
2118
+ return typeof value === "string" && value.trim() ? value.trim() : null;
2119
+ }
2120
+ function asNumber3(value) {
2121
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
2122
+ }
2123
+ function parsePorts(value, errors) {
2124
+ if (!Array.isArray(value)) {
2125
+ errors.push("ports must be an array");
2126
+ return [];
2127
+ }
2128
+ return value.flatMap((entry, index) => {
2129
+ const port = asObject(entry);
2130
+ if (!port) {
2131
+ errors.push(`ports[${index}] must be an object`);
2132
+ return [];
2133
+ }
2134
+ const portNumber = asNumber3(port.port);
2135
+ if (portNumber == null || portNumber < 1 || portNumber > 65535) {
2136
+ errors.push(`ports[${index}].port must be an integer between 1 and 65535`);
2137
+ return [];
2138
+ }
2139
+ return [{
2140
+ port: portNumber,
2141
+ tcp: port.tcp === true,
2142
+ udp: port.udp === true,
2143
+ purpose: asString5(port.purpose) ?? void 0
2144
+ }];
2145
+ });
2146
+ }
2147
+ function validateRootfsContract(input) {
2148
+ const errors = [];
2149
+ const payload = asObject(input);
2150
+ if (!payload) {
2151
+ return { contract: null, valid: false, errors: ["rootfs contract must be an object"] };
2152
+ }
2153
+ const rootfs = asObject(payload.rootfs);
2154
+ const services = asObject(payload.services);
2155
+ const manifest = asObject(payload.manifest);
2156
+ const source = asObject(payload.source) ?? void 0;
2157
+ const schemaVersion = asNumber3(payload.schemaVersion);
2158
+ if (schemaVersion == null || !Number.isInteger(schemaVersion)) {
2159
+ errors.push("schemaVersion must be an integer");
2160
+ }
2161
+ const id = asString5(payload.id);
2162
+ if (!id) {
2163
+ errors.push("id must be a non-empty string");
2164
+ }
2165
+ if (!rootfs) {
2166
+ errors.push("rootfs must be an object");
2167
+ }
2168
+ if (!services) {
2169
+ errors.push("services must be an object");
2170
+ }
2171
+ if (!manifest) {
2172
+ errors.push("manifest must be an object");
2173
+ }
2174
+ const profile = asString5(rootfs?.profile);
2175
+ if (!profile) errors.push("rootfs.profile must be a non-empty string");
2176
+ const installMode = asString5(rootfs?.installMode);
2177
+ if (!installMode) errors.push("rootfs.installMode must be a non-empty string");
2178
+ const installDir = asString5(rootfs?.installDir);
2179
+ if (!installDir) errors.push("rootfs.installDir must be a non-empty string");
2180
+ const binaryPath = asString5(rootfs?.binaryPath) ?? "/usr/local/bin/universal-chat-go";
2181
+ const dataDir = asString5(rootfs?.dataDir);
2182
+ if (!dataDir) errors.push("rootfs.dataDir must be a non-empty string");
2183
+ const envFile = asString5(rootfs?.envFile);
2184
+ if (!envFile) errors.push("rootfs.envFile must be a non-empty string");
2185
+ const bootstrap = asString5(services?.bootstrap);
2186
+ if (!bootstrap) errors.push("services.bootstrap must be a non-empty string");
2187
+ const main2 = asString5(services?.main);
2188
+ if (!main2) errors.push("services.main must be a non-empty string");
2189
+ const autotlsRefresh = asString5(services?.autotlsRefresh);
2190
+ if (!autotlsRefresh) errors.push("services.autotlsRefresh must be a non-empty string");
2191
+ const copyTarget = asString5(manifest?.copyTarget);
2192
+ if (!copyTarget) errors.push("manifest.copyTarget must be a non-empty string");
2193
+ const ports = parsePorts(payload.ports, errors);
2194
+ if (errors.length > 0 || !schemaVersion || !id || !profile || !installMode || !installDir || !dataDir || !envFile || !bootstrap || !main2 || !autotlsRefresh || !copyTarget) {
2195
+ return { contract: null, valid: false, errors };
2196
+ }
2197
+ return {
2198
+ contract: {
2199
+ schemaVersion,
2200
+ id,
2201
+ displayName: asString5(payload.displayName) ?? void 0,
2202
+ source: source ? {
2203
+ repository: asString5(source.repository) ?? void 0,
2204
+ subdirectory: asString5(source.subdirectory) ?? void 0
2205
+ } : void 0,
2206
+ rootfs: { profile, installMode, installDir, binaryPath, dataDir, envFile },
2207
+ services: { bootstrap, main: main2, autotlsRefresh },
2208
+ ports,
2209
+ manifest: { copyTarget, notes: asString5(manifest?.notes) ?? void 0 }
2210
+ },
2211
+ valid: true,
2212
+ errors: []
2213
+ };
2214
+ }
2215
+ function parseRootfsContract(input) {
2216
+ const payload = typeof input === "string" ? JSON.parse(input) : input;
2217
+ const result = validateRootfsContract(payload);
2218
+ if (!result.valid || !result.contract) {
2219
+ throw new Error(`Invalid rootfs contract: ${result.errors.join("; ")}`);
2220
+ }
2221
+ return result.contract;
2222
+ }
2223
+ async function readRootfsContractFile(path4) {
2224
+ return parseRootfsContract(await readFile(path4, "utf8"));
2225
+ }
2226
+ function referenceProfileRootfsDir(profile) {
2227
+ return fileURLToPath(new URL(`../reference/${profile}/rootfs/`, import.meta.url));
2228
+ }
2229
+
2230
+ // ../rootfs/src/build-plan.ts
2231
+ import path from "path";
2232
+ function positiveInteger(value, fallback) {
2233
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
2234
+ }
2235
+ function deriveRootfsVersion(options = {}) {
2236
+ if (options.rootfsVersion && options.rootfsVersion.trim()) {
2237
+ return options.rootfsVersion.trim();
2238
+ }
2239
+ if (options.gitShortSha && options.gitShortSha.trim()) {
2240
+ const now = options.now ?? /* @__PURE__ */ new Date();
2241
+ const yyyy = String(now.getUTCFullYear());
2242
+ const mm = String(now.getUTCMonth() + 1).padStart(2, "0");
2243
+ const dd = String(now.getUTCDate()).padStart(2, "0");
2244
+ return `uc-go-peer-git-${yyyy}${mm}${dd}-${options.gitShortSha.trim()}`;
2245
+ }
2246
+ return "uc-go-peer-v0.1.0";
2247
+ }
2248
+ function createRootfsBuildPlan(contract, options) {
2249
+ const projectDir = path.resolve(options.projectDir);
2250
+ const alephDir = path.resolve(options.alephDir ?? path.join(projectDir, "go-peer/aleph"));
2251
+ const outDir = path.resolve(options.outDir ?? path.join(alephDir, "dist-rootfs"));
2252
+ const contractPath = path.resolve(options.contractPath ?? path.join(alephDir, "root-profiles", `${contract.id}.json`));
2253
+ const rootfsVersion = deriveRootfsVersion(options);
2254
+ const copyTarget = contract.manifest.copyTarget?.trim() ?? "";
2255
+ const latestManifestPath = copyTarget ? path.resolve(copyTarget.startsWith("/") ? copyTarget : path.join(projectDir, copyTarget)) : null;
2256
+ const versionedManifestPath = latestManifestPath ? path.join(path.dirname(latestManifestPath), `${rootfsVersion}.json`) : null;
2257
+ return {
2258
+ contract,
2259
+ contractPath,
2260
+ projectDir,
2261
+ alephDir,
2262
+ outDir,
2263
+ driver: options.driver ?? "auto",
2264
+ rootfsSizeMiB: positiveInteger(options.rootfsSizeMiB, 20480),
2265
+ rootfsImageSize: options.rootfsImageSize?.trim() || "20G",
2266
+ rootfsVersion,
2267
+ channel: options.channel?.trim() || "ALEPH-CLOUDSOLUTIONS",
2268
+ skipUpload: options.skipUpload === true,
2269
+ skipBuild: options.skipBuild === true,
2270
+ ipfsAddUrl: options.ipfsAddUrl?.trim() || "https://ipfs.aleph.cloud/api/v0/add",
2271
+ ipfsGatewayUrl: options.ipfsGatewayUrl?.trim() || "https://ipfs.aleph.cloud/ipfs",
2272
+ alephApiHost: options.alephApiHost?.trim() || "https://api2.aleph.im",
2273
+ alephMessageWaitAttempts: positiveInteger(options.alephMessageWaitAttempts, 60),
2274
+ alephMessageWaitDelaySeconds: positiveInteger(options.alephMessageWaitDelaySeconds, 5),
2275
+ alephPinAttempts: positiveInteger(options.alephPinAttempts, 4),
2276
+ alephPinDelaySeconds: positiveInteger(options.alephPinDelaySeconds, 10),
2277
+ ipfsGatewayWaitAttempts: positiveInteger(options.ipfsGatewayWaitAttempts, 30),
2278
+ ipfsGatewayWaitDelaySeconds: positiveInteger(options.ipfsGatewayWaitDelaySeconds, 10),
2279
+ manifestPath: path.join(outDir, "rootfs-manifest.json"),
2280
+ latestManifestPath,
2281
+ versionedManifestPath,
2282
+ imagePath: path.join(outDir, "aleph-uc-go-peer.qcow2"),
2283
+ baseImagePath: path.join(outDir, "debian-12-genericcloud-amd64.qcow2"),
2284
+ binaryPath: path.join(outDir, "universal-chat-go")
2285
+ };
2286
+ }
2287
+ function rootfsBuildShellEnv(plan) {
2288
+ return {
2289
+ OUT_DIR: plan.outDir,
2290
+ ROOTFS_CONTRACT_FILE: plan.contractPath,
2291
+ ROOTFS_BUILD_DRIVER: plan.driver,
2292
+ ROOTFS_SIZE_MIB: String(plan.rootfsSizeMiB),
2293
+ ROOTFS_IMAGE_SIZE: plan.rootfsImageSize,
2294
+ ROOTFS_VERSION: plan.rootfsVersion,
2295
+ CHANNEL: plan.channel,
2296
+ SKIP_UPLOAD: plan.skipUpload ? "1" : "0",
2297
+ SKIP_BUILD: plan.skipBuild ? "1" : "0",
2298
+ IPFS_ADD_URL: plan.ipfsAddUrl,
2299
+ IPFS_GATEWAY_URL: plan.ipfsGatewayUrl,
2300
+ ALEPH_API_HOST: plan.alephApiHost,
2301
+ ALEPH_MESSAGE_WAIT_ATTEMPTS: String(plan.alephMessageWaitAttempts),
2302
+ ALEPH_MESSAGE_WAIT_DELAY_SECONDS: String(plan.alephMessageWaitDelaySeconds),
2303
+ ALEPH_PIN_ATTEMPTS: String(plan.alephPinAttempts),
2304
+ ALEPH_PIN_DELAY_SECONDS: String(plan.alephPinDelaySeconds),
2305
+ IPFS_GATEWAY_WAIT_ATTEMPTS: String(plan.ipfsGatewayWaitAttempts),
2306
+ IPFS_GATEWAY_WAIT_DELAY_SECONDS: String(plan.ipfsGatewayWaitDelaySeconds)
2307
+ };
2308
+ }
2309
+
2310
+ // ../rootfs/src/manifest.ts
2311
+ import { dirname, extname, isAbsolute, join } from "path";
2312
+ function createRootfsManifest(plan, contract, options = {}) {
2313
+ const manifest = {
2314
+ profile: contract.rootfs.profile,
2315
+ version: plan.rootfsVersion,
2316
+ rootfsInstallStrategy: contract.rootfs.installMode,
2317
+ requiresBootstrapNetwork: false,
2318
+ bootstrapSummary: "Dependencies are preinstalled in the image.",
2319
+ requiredPortForwards: contract.ports,
2320
+ rootfsSizeMiB: plan.rootfsSizeMiB,
2321
+ createdAt: options.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
2322
+ notes: contract.manifest.notes ?? ""
2323
+ };
2324
+ if (typeof options.rootfsSourceSizeBytes === "number" && Number.isFinite(options.rootfsSourceSizeBytes) && options.rootfsSourceSizeBytes > 0) {
2325
+ manifest.rootfsSourceSizeBytes = options.rootfsSourceSizeBytes;
2326
+ }
2327
+ if (options.rootfsCid) {
2328
+ manifest.rootfsCid = options.rootfsCid;
2329
+ }
2330
+ if (options.rootfsItemHash) {
2331
+ manifest.rootfsItemHash = options.rootfsItemHash;
2332
+ }
2333
+ return manifest;
2334
+ }
2335
+ function resolveRootfsManifestOutputPaths(plan) {
2336
+ const paths = {
2337
+ primaryPath: plan.manifestPath
2338
+ };
2339
+ const copyTarget = plan.latestManifestPath;
2340
+ if (!copyTarget) {
2341
+ return paths;
2342
+ }
2343
+ const resolvedCopyTarget = isAbsolute(copyTarget) ? copyTarget : join(plan.projectDir, copyTarget);
2344
+ paths.copyTargetPath = resolvedCopyTarget;
2345
+ const copyTargetExt = extname(resolvedCopyTarget) || ".json";
2346
+ paths.versionedTargetPath = join(dirname(resolvedCopyTarget), `${plan.rootfsVersion}${copyTargetExt}`);
2347
+ return paths;
2348
+ }
2349
+ function serializeRootfsManifest(manifest) {
2350
+ return `${JSON.stringify(manifest, null, 2)}
2351
+ `;
2352
+ }
2353
+
2354
+ // ../rootfs/src/execution-plan.ts
2355
+ import path2 from "path";
2356
+ function ensurePathWithin(parent, child, label) {
2357
+ const relative = path2.relative(parent, child);
2358
+ if (relative.startsWith("..") || path2.isAbsolute(relative)) {
2359
+ throw new Error(`${label} must be inside ${parent}`);
2360
+ }
2361
+ return relative || ".";
2362
+ }
2363
+ function containerPathForProjectFile(hostPath, projectDir, mountPath, label) {
2364
+ const relative = ensurePathWithin(projectDir, hostPath, label);
2365
+ return path2.posix.join(mountPath, relative.split(path2.sep).join("/"));
2366
+ }
2367
+ function resolveReferenceRootfsDir(contract, override) {
2368
+ if (override) {
2369
+ return path2.resolve(override);
2370
+ }
2371
+ return referenceProfileRootfsDir(contract.id);
2372
+ }
2373
+ function createHostRootfsExecutionPlan(plan, options = {}) {
2374
+ const referenceRootfsDir = resolveReferenceRootfsDir(plan.contract, options.referenceRootfsDir);
2375
+ return {
2376
+ mode: "host",
2377
+ reason: "Using host virt-customize/qemu-img toolchain.",
2378
+ referenceRootfsDir,
2379
+ runCommand: {
2380
+ command: "/bin/bash",
2381
+ args: [path2.join(referenceRootfsDir, "build-rootfs-image.sh")],
2382
+ workdir: referenceRootfsDir,
2383
+ env: {
2384
+ PROJECT_DIR: plan.projectDir,
2385
+ OUT_DIR: plan.outDir,
2386
+ ROOTFS_CONTRACT_FILE: plan.contractPath,
2387
+ ROOTFS_IMAGE_SIZE: plan.rootfsImageSize
2388
+ }
2389
+ }
2390
+ };
2391
+ }
2392
+ function createDockerRootfsExecutionPlan(plan, options = {}) {
2393
+ const referenceRootfsDir = resolveReferenceRootfsDir(plan.contract, options.referenceRootfsDir);
2394
+ const projectMountPath = options.projectMountPath ?? "/workspace/project";
2395
+ const rootfsMountPath = options.rootfsMountPath ?? "/workspace/shared-rootfs";
2396
+ const dockerImageTag = options.dockerImageTag ?? `${plan.contract.id}-rootfs-builder:local`;
2397
+ const containerProjectDir = projectMountPath;
2398
+ const containerContractPath = containerPathForProjectFile(plan.contractPath, plan.projectDir, projectMountPath, "contractPath");
2399
+ const containerOutDir = containerPathForProjectFile(plan.outDir, plan.projectDir, projectMountPath, "outDir");
2400
+ return {
2401
+ mode: "docker",
2402
+ reason: "Using Dockerized Debian/libguestfs builder.",
2403
+ referenceRootfsDir,
2404
+ prepareCommand: {
2405
+ command: "docker",
2406
+ args: [
2407
+ "build",
2408
+ "--platform",
2409
+ "linux/amd64",
2410
+ "-t",
2411
+ dockerImageTag,
2412
+ "-f",
2413
+ path2.join(referenceRootfsDir, "Dockerfile.rootfs"),
2414
+ referenceRootfsDir
2415
+ ]
2416
+ },
2417
+ runCommand: {
2418
+ command: "docker",
2419
+ args: [
2420
+ "run",
2421
+ "--rm",
2422
+ "--privileged",
2423
+ "--platform",
2424
+ "linux/amd64",
2425
+ "-e",
2426
+ "LIBGUESTFS_BACKEND=direct",
2427
+ "-e",
2428
+ `ROOTFS_CONTRACT_FILE=${containerContractPath}`,
2429
+ "-e",
2430
+ `OUT_DIR=${containerOutDir}`,
2431
+ "-e",
2432
+ `ROOTFS_IMAGE_SIZE=${plan.rootfsImageSize}`,
2433
+ "-e",
2434
+ `PROJECT_DIR=${containerProjectDir}`,
2435
+ "-v",
2436
+ `${plan.projectDir}:${projectMountPath}`,
2437
+ "-v",
2438
+ `${referenceRootfsDir}:${rootfsMountPath}`,
2439
+ "-w",
2440
+ rootfsMountPath,
2441
+ dockerImageTag,
2442
+ "/bin/bash",
2443
+ path2.posix.join(rootfsMountPath, "build-rootfs-image.sh")
2444
+ ]
2445
+ }
2446
+ };
2447
+ }
2448
+ function selectRootfsExecutionPlan(plan, availability, options = {}) {
2449
+ if (plan.driver === "host") {
2450
+ if (!availability.hasVirtCustomize) {
2451
+ throw new Error("ROOTFS_BUILD_DRIVER=host requested, but virt-customize is not available.");
2452
+ }
2453
+ return createHostRootfsExecutionPlan(plan, options);
2454
+ }
2455
+ if (plan.driver === "docker") {
2456
+ if (!availability.hasDocker) {
2457
+ throw new Error("ROOTFS_BUILD_DRIVER=docker requested, but docker is not available.");
2458
+ }
2459
+ if (availability.dockerDaemonRunning === false) {
2460
+ throw new Error("ROOTFS_BUILD_DRIVER=docker requested, but the Docker daemon is not running.");
2461
+ }
2462
+ return createDockerRootfsExecutionPlan(plan, options);
2463
+ }
2464
+ if (availability.githubActions && availability.hasDocker && availability.dockerDaemonRunning !== false) {
2465
+ return createDockerRootfsExecutionPlan(plan, options);
2466
+ }
2467
+ if (availability.hasVirtCustomize) {
2468
+ return createHostRootfsExecutionPlan(plan, options);
2469
+ }
2470
+ if (availability.hasDocker && availability.dockerDaemonRunning !== false) {
2471
+ return createDockerRootfsExecutionPlan(plan, options);
2472
+ }
2473
+ throw new Error("No supported rootfs build toolchain is available.");
2474
+ }
2475
+
2476
+ // ../rootfs/src/publication.ts
2477
+ import { join as join2 } from "path";
2478
+ function publicationArtifacts(plan) {
2479
+ return {
2480
+ ipfsAddResponsePath: join2(plan.outDir, "ipfs-add-response.jsonl"),
2481
+ storeMessagePath: join2(plan.outDir, "store-message.json"),
2482
+ storeMessageStderrPath: join2(plan.outDir, "store-message.stderr.log")
2483
+ };
2484
+ }
2485
+ function parseIpfsAddResponse(content) {
2486
+ return content.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean).map((line) => JSON.parse(line));
2487
+ }
2488
+ function extractRootfsCid(entries) {
2489
+ if (entries.length === 0) {
2490
+ throw new Error("No response received from the IPFS add endpoint");
2491
+ }
2492
+ const cid = entries.at(-1)?.Hash?.trim();
2493
+ if (!cid) {
2494
+ throw new Error(`IPFS add response did not include a Hash: ${JSON.stringify(entries.at(-1) ?? {})}`);
2495
+ }
2496
+ return cid;
2497
+ }
2498
+ function extractRootfsSourceSizeBytes(entries) {
2499
+ const size = entries.at(-1)?.Size;
2500
+ if (typeof size === "number" && Number.isFinite(size) && size > 0) {
2501
+ return size;
2502
+ }
2503
+ if (typeof size === "string" && /^\d+$/u.test(size)) {
2504
+ return Number(size);
2505
+ }
2506
+ return void 0;
2507
+ }
2508
+ function parseStoreMessageResponse(content) {
2509
+ const payload = JSON.parse(content);
2510
+ const itemHash = payload.item_hash?.trim();
2511
+ if (!itemHash) {
2512
+ throw new Error("Failed to extract Aleph item hash from store message response");
2513
+ }
2514
+ return { item_hash: itemHash };
2515
+ }
2516
+ function createRootfsPublicationResult(ipfsAddContent, storeMessageContent) {
2517
+ const entries = parseIpfsAddResponse(ipfsAddContent);
2518
+ const storeMessage = parseStoreMessageResponse(storeMessageContent);
2519
+ return {
2520
+ cid: extractRootfsCid(entries),
2521
+ itemHash: storeMessage.item_hash,
2522
+ sourceSizeBytes: extractRootfsSourceSizeBytes(entries)
2523
+ };
2524
+ }
2525
+
2526
+ // ../rootfs/src/orchestration.ts
2527
+ function createRootfsBuildPipeline(buildPlan, availability, options = {}) {
2528
+ return {
2529
+ buildPlan,
2530
+ executionPlan: selectRootfsExecutionPlan(buildPlan, availability, options),
2531
+ publicationArtifacts: publicationArtifacts(buildPlan),
2532
+ manifestPaths: resolveRootfsManifestOutputPaths(buildPlan)
2533
+ };
2534
+ }
2535
+ function finalizeRootfsBuildPipeline(buildPlan, options = {}) {
2536
+ let publication;
2537
+ if (options.ipfsAddResponseContent && options.storeMessageContent) {
2538
+ publication = createRootfsPublicationResult(options.ipfsAddResponseContent, options.storeMessageContent);
2539
+ }
2540
+ const manifest = createRootfsManifest(buildPlan, buildPlan.contract, {
2541
+ createdAt: options.createdAt,
2542
+ rootfsCid: publication?.cid ?? options.rootfsCid,
2543
+ rootfsItemHash: publication?.itemHash ?? options.rootfsItemHash,
2544
+ rootfsSourceSizeBytes: publication?.sourceSizeBytes ?? options.rootfsSourceSizeBytes
2545
+ });
2546
+ return {
2547
+ manifest,
2548
+ manifestJson: serializeRootfsManifest(manifest),
2549
+ manifestPaths: resolveRootfsManifestOutputPaths(buildPlan),
2550
+ publication
2551
+ };
2552
+ }
2553
+
2554
+ // ../rootfs/src/executor.ts
2555
+ import path3 from "path";
2556
+ function rootfsScriptDir(buildPlan, override) {
2557
+ return override ? path3.resolve(override) : referenceProfileRootfsDir(buildPlan.contract.id);
2558
+ }
2559
+ function createRootfsScriptCommand(buildPlan, referenceRootfsDir) {
2560
+ const scriptDir = rootfsScriptDir(buildPlan, referenceRootfsDir);
2561
+ return {
2562
+ command: "/bin/bash",
2563
+ args: [path3.join(scriptDir, "build-rootfs.sh")],
2564
+ workdir: scriptDir,
2565
+ env: rootfsBuildShellEnv(buildPlan)
2566
+ };
2567
+ }
2568
+ async function buildRootfs(buildPlan, deps, availability, options = {}) {
2569
+ const pipeline = createRootfsBuildPipeline(buildPlan, availability, options);
2570
+ const executedCommands = [];
2571
+ if (pipeline.executionPlan.prepareCommand) {
2572
+ const prepareCommand = {
2573
+ command: pipeline.executionPlan.prepareCommand.command,
2574
+ args: pipeline.executionPlan.prepareCommand.args,
2575
+ workdir: pipeline.executionPlan.prepareCommand.workdir,
2576
+ env: pipeline.executionPlan.prepareCommand.env
2577
+ };
2578
+ await deps.run(prepareCommand);
2579
+ executedCommands.push(prepareCommand);
2580
+ }
2581
+ const runCommand = {
2582
+ command: pipeline.executionPlan.runCommand.command,
2583
+ args: pipeline.executionPlan.runCommand.args,
2584
+ workdir: pipeline.executionPlan.runCommand.workdir,
2585
+ env: pipeline.executionPlan.runCommand.env
2586
+ };
2587
+ await deps.run(runCommand);
2588
+ executedCommands.push(runCommand);
2589
+ return {
2590
+ pipeline,
2591
+ executedCommands
2592
+ };
2593
+ }
2594
+ async function publishRootfs(buildPlan, deps, options = {}) {
2595
+ const command = createRootfsScriptCommand(buildPlan, options.referenceRootfsDir);
2596
+ await deps.run(command);
2597
+ let ipfsAddResponseContent;
2598
+ let storeMessageContent;
2599
+ const publicationArtifacts2 = {
2600
+ ipfsAddResponsePath: path3.join(buildPlan.outDir, "ipfs-add-response.jsonl"),
2601
+ storeMessagePath: path3.join(buildPlan.outDir, "store-message.json"),
2602
+ storeMessageStderrPath: path3.join(buildPlan.outDir, "store-message.stderr.log")
2603
+ };
2604
+ if (!buildPlan.skipUpload) {
2605
+ ipfsAddResponseContent = await deps.readText(publicationArtifacts2.ipfsAddResponsePath);
2606
+ storeMessageContent = await deps.readText(publicationArtifacts2.storeMessagePath);
2607
+ }
2608
+ const finalized = finalizeRootfsBuildPipeline(buildPlan, {
2609
+ createdAt: options.createdAt,
2610
+ ipfsAddResponseContent,
2611
+ storeMessageContent
2612
+ });
2613
+ return {
2614
+ pipeline: {
2615
+ buildPlan,
2616
+ executionPlan: {
2617
+ mode: "docker",
2618
+ reason: "Running shared rootfs build orchestrator script.",
2619
+ referenceRootfsDir: command.workdir ?? rootfsScriptDir(buildPlan, options.referenceRootfsDir),
2620
+ runCommand: {
2621
+ command: command.command,
2622
+ args: command.args,
2623
+ workdir: command.workdir,
2624
+ env: command.env
2625
+ }
2626
+ },
2627
+ publicationArtifacts: publicationArtifacts2,
2628
+ manifestPaths: finalized.manifestPaths
2629
+ },
2630
+ executedCommands: [command],
2631
+ finalized
2632
+ };
2633
+ }
2634
+
2635
+ // src/rootfs-runner.ts
2636
+ async function parseRootfsRunnerInputs(env = process.env) {
2637
+ const contractPath = requiredEnv("ALEPH_ROOTFS_CONTRACT_PATH", env);
2638
+ const contract = await readRootfsContractFile(contractPath);
2639
+ const buildPlan = createRootfsBuildPlan(contract, {
2640
+ projectDir: requiredEnv("ALEPH_ROOTFS_PROJECT_DIR", env),
2641
+ contractPath,
2642
+ alephDir: optionalEnv("ALEPH_ROOTFS_ALEPH_DIR", void 0, env) || void 0,
2643
+ outDir: optionalEnv("ALEPH_ROOTFS_OUT_DIR", void 0, env) || void 0,
2644
+ driver: optionalEnv("ALEPH_ROOTFS_DRIVER", "auto", env),
2645
+ rootfsVersion: optionalEnv("ALEPH_ROOTFS_VERSION", void 0, env) || void 0,
2646
+ rootfsSizeMiB: Number(optionalEnv("ALEPH_ROOTFS_SIZE_MIB", "", env)) || void 0,
2647
+ rootfsImageSize: optionalEnv("ALEPH_ROOTFS_IMAGE_SIZE", void 0, env) || void 0,
2648
+ channel: optionalEnv("ALEPH_ROOTFS_CHANNEL", void 0, env) || void 0,
2649
+ skipUpload: booleanEnv("ALEPH_ROOTFS_SKIP_UPLOAD", false, env),
2650
+ skipBuild: booleanEnv("ALEPH_ROOTFS_SKIP_BUILD", false, env),
2651
+ ipfsAddUrl: optionalEnv("ALEPH_ROOTFS_IPFS_ADD_URL", void 0, env) || void 0,
2652
+ ipfsGatewayUrl: optionalEnv("ALEPH_ROOTFS_IPFS_GATEWAY_URL", void 0, env) || void 0,
2653
+ alephApiHost: optionalEnv("ALEPH_ROOTFS_ALEPH_API_HOST", void 0, env) || void 0,
2654
+ alephMessageWaitAttempts: Number(optionalEnv("ALEPH_ROOTFS_ALEPH_MESSAGE_WAIT_ATTEMPTS", "", env)) || void 0,
2655
+ alephMessageWaitDelaySeconds: Number(optionalEnv("ALEPH_ROOTFS_ALEPH_MESSAGE_WAIT_DELAY_SECONDS", "", env)) || void 0,
2656
+ alephPinAttempts: Number(optionalEnv("ALEPH_ROOTFS_ALEPH_PIN_ATTEMPTS", "", env)) || void 0,
2657
+ alephPinDelaySeconds: Number(optionalEnv("ALEPH_ROOTFS_ALEPH_PIN_DELAY_SECONDS", "", env)) || void 0,
2658
+ ipfsGatewayWaitAttempts: Number(optionalEnv("ALEPH_ROOTFS_IPFS_GATEWAY_WAIT_ATTEMPTS", "", env)) || void 0,
2659
+ ipfsGatewayWaitDelaySeconds: Number(optionalEnv("ALEPH_ROOTFS_IPFS_GATEWAY_WAIT_DELAY_SECONDS", "", env)) || void 0
2660
+ });
2661
+ return {
2662
+ buildPlan,
2663
+ availability: {
2664
+ githubActions: env.GITHUB_ACTIONS === "true",
2665
+ hasDocker: booleanEnv("ALEPH_ROOTFS_HAS_DOCKER", false, env),
2666
+ dockerDaemonRunning: env.ALEPH_ROOTFS_DOCKER_DAEMON_RUNNING == null ? void 0 : booleanEnv("ALEPH_ROOTFS_DOCKER_DAEMON_RUNNING", false, env),
2667
+ hasVirtCustomize: booleanEnv("ALEPH_ROOTFS_HAS_VIRT_CUSTOMIZE", false, env)
2668
+ },
2669
+ referenceRootfsDir: optionalEnv("ALEPH_ROOTFS_REFERENCE_ROOTFS_DIR", void 0, env) || void 0,
2670
+ createdAt: optionalEnv("ALEPH_ROOTFS_CREATED_AT", void 0, env) || void 0
2671
+ };
2672
+ }
2673
+ async function runLocalCommand(command) {
2674
+ await new Promise((resolve, reject) => {
2675
+ const child = spawn(command.command, command.args, {
2676
+ cwd: command.workdir,
2677
+ env: { ...process.env, ...command.env },
2678
+ stdio: "inherit"
2679
+ });
2680
+ child.on("error", reject);
2681
+ child.on("exit", (code) => {
2682
+ if (code === 0) {
2683
+ resolve();
2684
+ } else {
2685
+ reject(new Error(`${command.command} ${command.args.join(" ")} failed with exit code ${code ?? "unknown"}`));
2686
+ }
2687
+ });
2688
+ });
2689
+ }
2690
+ async function emitRootfsOutputs(result, env = process.env) {
2691
+ await appendGithubOutput("rootfs_version", result.finalized.manifest.version, env);
2692
+ await appendGithubOutput("rootfs_manifest_path", result.finalized.manifestPaths.primaryPath, env);
2693
+ await appendGithubOutput("rootfs_manifest_json", result.finalized.manifestJson, env);
2694
+ await appendGithubOutput("rootfs_image_path", result.pipeline.buildPlan.imagePath, env);
2695
+ await appendGithubOutput("rootfs_execution_mode", result.pipeline.executionPlan.mode, env);
2696
+ if (result.finalized.manifestPaths.copyTargetPath) {
2697
+ await appendGithubOutput("rootfs_manifest_copy_target_path", result.finalized.manifestPaths.copyTargetPath, env);
2698
+ }
2699
+ if (result.finalized.manifestPaths.versionedTargetPath) {
2700
+ await appendGithubOutput("rootfs_manifest_versioned_path", result.finalized.manifestPaths.versionedTargetPath, env);
2701
+ }
2702
+ if (result.finalized.publication?.cid) {
2703
+ await appendGithubOutput("rootfs_cid", result.finalized.publication.cid, env);
2704
+ }
2705
+ if (result.finalized.publication?.itemHash) {
2706
+ await appendGithubOutput("rootfs_item_hash", result.finalized.publication.itemHash, env);
2707
+ }
2708
+ if (typeof result.finalized.publication?.sourceSizeBytes === "number") {
2709
+ await appendGithubOutput("rootfs_source_size_bytes", result.finalized.publication.sourceSizeBytes, env);
2710
+ }
2711
+ await appendGithubSummary([
2712
+ "## Shared Rootfs Runner",
2713
+ "",
2714
+ `- Version: \`${result.finalized.manifest.version}\``,
2715
+ `- Execution mode: \`${result.pipeline.executionPlan.mode}\``,
2716
+ `- Image path: \`${result.pipeline.buildPlan.imagePath}\``,
2717
+ `- Manifest path: \`${result.finalized.manifestPaths.primaryPath}\``,
2718
+ `- Published CID: \`${result.finalized.publication?.cid ?? ""}\``,
2719
+ `- Aleph item hash: \`${result.finalized.publication?.itemHash ?? ""}\``
2720
+ ], env);
2721
+ }
2722
+ async function runRootfsMode(env = process.env, hooks = {}) {
2723
+ const mode = optionalEnv("ALEPH_VM_MODE", "rootfs-publish", env);
2724
+ const stdout = hooks.stdout ?? ((text) => process.stdout.write(text));
2725
+ const parsed = await (hooks.parseInputs ?? parseRootfsRunnerInputs)(env);
2726
+ if (mode === "rootfs-build-plan") {
2727
+ stdout(`${JSON.stringify(parsed.buildPlan)}
2728
+ `);
2729
+ return;
2730
+ }
2731
+ if (mode === "rootfs-build") {
2732
+ const result = await (hooks.buildRootfs ?? buildRootfs)(
2733
+ parsed.buildPlan,
2734
+ { run: hooks.runCommand ?? runLocalCommand },
2735
+ parsed.availability,
2736
+ { referenceRootfsDir: parsed.referenceRootfsDir }
2737
+ );
2738
+ stdout(`${JSON.stringify(result.pipeline)}
2739
+ `);
2740
+ return;
2741
+ }
2742
+ if (mode === "rootfs-publish") {
2743
+ const result = await (hooks.publishRootfs ?? publishRootfs)(
2744
+ parsed.buildPlan,
2745
+ {
2746
+ run: hooks.runCommand ?? runLocalCommand,
2747
+ readText: hooks.readText ?? ((targetPath) => readFile2(targetPath, "utf8"))
2748
+ },
2749
+ { createdAt: parsed.createdAt, referenceRootfsDir: parsed.referenceRootfsDir }
2750
+ );
2751
+ await emitRootfsOutputs(result, env);
2752
+ stdout(`${JSON.stringify(result.finalized)}
2753
+ `);
2754
+ return;
2755
+ }
2756
+ throw new Error(`Unsupported ALEPH_VM_MODE "${mode}" in shared rootfs runner.`);
2757
+ }
2758
+ async function rootfsMain() {
2759
+ await runRootfsMode(process.env);
2760
+ }
2761
+ if (import.meta.url === pathToFileURL2(process.argv[1] ?? "").href) {
2762
+ rootfsMain().catch((error) => {
2763
+ const message = error instanceof Error ? error.message : String(error);
2764
+ console.error(message);
2765
+ process.exitCode = 1;
2766
+ });
2767
+ }
2105
2768
  export {
2106
2769
  actionLog,
2107
2770
  appendGithubOutput,
@@ -2112,12 +2775,17 @@ export {
2112
2775
  createPrivateKeySigner,
2113
2776
  emitDeployOutputs,
2114
2777
  emitGeocodedCrnOutputs,
2778
+ emitRootfsOutputs,
2115
2779
  executeDeployPlan,
2116
2780
  integerEnv,
2117
2781
  jsonEnv,
2118
2782
  main,
2119
2783
  optionalEnv,
2120
2784
  parseDeployPlan,
2785
+ parseRootfsRunnerInputs,
2121
2786
  requiredEnv,
2122
- runActionMode
2787
+ rootfsMain,
2788
+ runActionMode,
2789
+ runLocalCommand,
2790
+ runRootfsMode
2123
2791
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@le-space/node",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Node and GitHub Actions adapters for shared Aleph tooling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -16,8 +16,9 @@
16
16
  "access": "public"
17
17
  },
18
18
  "dependencies": {
19
- "@le-space/core": "0.1.2",
20
- "@le-space/shared-types": "0.1.2",
19
+ "@le-space/core": "0.1.4",
20
+ "@le-space/shared-types": "0.1.4",
21
+ "@le-space/rootfs": "0.1.4",
21
22
  "ethers": "^6.15.0"
22
23
  }
23
24
  }