c8y-nitro 0.1.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 +292 -0
- package/dist/cli/commands/bootstrap.mjs +64 -0
- package/dist/cli/commands/roles.mjs +41 -0
- package/dist/cli/index.d.mts +1 -0
- package/dist/cli/index.mjs +18 -0
- package/dist/cli/utils/c8y-api.mjs +172 -0
- package/dist/cli/utils/config.mjs +57 -0
- package/dist/cli/utils/env-file.mjs +61 -0
- package/dist/index.d.mts +12 -0
- package/dist/index.mjs +51 -0
- package/dist/module/apiClient.mjs +207 -0
- package/dist/module/autoBootstrap.mjs +54 -0
- package/dist/module/c8yzip.mjs +66 -0
- package/dist/module/constants.mjs +6 -0
- package/dist/module/docker.mjs +101 -0
- package/dist/module/manifest.mjs +72 -0
- package/dist/module/probeCheck.mjs +30 -0
- package/dist/module/register.mjs +58 -0
- package/dist/module/runtime/handlers/liveness-readiness.ts +7 -0
- package/dist/module/runtime/middlewares/dev-user.ts +25 -0
- package/dist/module/runtime/plugins/c8y-variables.ts +24 -0
- package/dist/module/runtime.mjs +31 -0
- package/dist/package.mjs +7 -0
- package/dist/types/apiClient.d.mts +16 -0
- package/dist/types/manifest.d.mts +323 -0
- package/dist/types/roles.d.mts +4 -0
- package/dist/types/zip.d.mts +22 -0
- package/dist/types.d.mts +13 -0
- package/dist/types.mjs +1 -0
- package/dist/utils/client.d.mts +50 -0
- package/dist/utils/client.mjs +91 -0
- package/dist/utils/credentials.d.mts +66 -0
- package/dist/utils/credentials.mjs +117 -0
- package/dist/utils/internal/common.mjs +26 -0
- package/dist/utils/middleware.d.mts +89 -0
- package/dist/utils/middleware.mjs +62 -0
- package/dist/utils/resources.d.mts +28 -0
- package/dist/utils/resources.mjs +50 -0
- package/dist/utils.d.mts +5 -0
- package/dist/utils.mjs +6 -0
- package/package.json +87 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
//#region src/cli/utils/env-file.ts
|
|
6
|
+
/**
|
|
7
|
+
* Writes or updates environment variables in a file.
|
|
8
|
+
* Existing variables are overwritten, new ones are appended.
|
|
9
|
+
* @param filePath - Absolute path to the .env file
|
|
10
|
+
* @param variables - Key-value pairs to write
|
|
11
|
+
*/
|
|
12
|
+
async function writeEnvVariables(filePath, variables) {
|
|
13
|
+
let content = "";
|
|
14
|
+
if (existsSync(filePath)) content = await readFile(filePath, "utf-8");
|
|
15
|
+
const lines = content.split("\n");
|
|
16
|
+
const variableNames = Object.keys(variables);
|
|
17
|
+
const foundVariables = /* @__PURE__ */ new Set();
|
|
18
|
+
const updatedLines = lines.map((line) => {
|
|
19
|
+
for (const varName of variableNames) if (new RegExp(`^${varName}\\s*=`).test(line)) {
|
|
20
|
+
foundVariables.add(varName);
|
|
21
|
+
return `${varName}=${variables[varName]}`;
|
|
22
|
+
}
|
|
23
|
+
return line;
|
|
24
|
+
});
|
|
25
|
+
const missingVariables = variableNames.filter((v) => !foundVariables.has(v));
|
|
26
|
+
if (missingVariables.length > 0) {
|
|
27
|
+
if (updatedLines[updatedLines.length - 1] !== "" && updatedLines.length > 0) updatedLines.push("");
|
|
28
|
+
for (const varName of missingVariables) updatedLines.push(`${varName}=${variables[varName]}`);
|
|
29
|
+
}
|
|
30
|
+
let finalContent = updatedLines.join("\n");
|
|
31
|
+
if (!finalContent.endsWith("\n")) finalContent += "\n";
|
|
32
|
+
await writeFile(filePath, finalContent, "utf-8");
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Determines which .env file to write to and writes the variables.
|
|
36
|
+
* Priority: .env.local (if exists) > .env (if exists) > create .env
|
|
37
|
+
* @param configDir - Directory containing the config files
|
|
38
|
+
* @param variables - Key-value pairs to write
|
|
39
|
+
* @returns The filename that was written to (e.g., '.env.local' or '.env')
|
|
40
|
+
*/
|
|
41
|
+
async function writeBootstrapCredentials(configDir, variables) {
|
|
42
|
+
const envLocalPath = join(configDir, ".env.local");
|
|
43
|
+
const envPath = join(configDir, ".env");
|
|
44
|
+
let targetPath;
|
|
45
|
+
let targetName;
|
|
46
|
+
if (existsSync(envLocalPath)) {
|
|
47
|
+
targetPath = envLocalPath;
|
|
48
|
+
targetName = ".env.local";
|
|
49
|
+
} else if (existsSync(envPath)) {
|
|
50
|
+
targetPath = envPath;
|
|
51
|
+
targetName = ".env";
|
|
52
|
+
} else {
|
|
53
|
+
targetPath = envPath;
|
|
54
|
+
targetName = ".env";
|
|
55
|
+
}
|
|
56
|
+
await writeEnvVariables(targetPath, variables);
|
|
57
|
+
return targetName;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
//#endregion
|
|
61
|
+
export { writeBootstrapCredentials };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { C8yNitroModuleOptions } from "./types.mjs";
|
|
2
|
+
import { NitroModule } from "nitro/types";
|
|
3
|
+
|
|
4
|
+
//#region src/index.d.ts
|
|
5
|
+
declare module 'nitro/types' {
|
|
6
|
+
interface NitroOptions {
|
|
7
|
+
c8y?: C8yNitroModuleOptions;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
declare function c8y(): NitroModule;
|
|
11
|
+
//#endregion
|
|
12
|
+
export { c8y, c8y as default };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { writeAPIClient } from "./module/apiClient.mjs";
|
|
2
|
+
import { createC8yZip } from "./module/c8yzip.mjs";
|
|
3
|
+
import { setupRuntime } from "./module/runtime.mjs";
|
|
4
|
+
import { registerRuntime } from "./module/register.mjs";
|
|
5
|
+
import { checkProbes } from "./module/probeCheck.mjs";
|
|
6
|
+
import { autoBootstrap } from "./module/autoBootstrap.mjs";
|
|
7
|
+
|
|
8
|
+
//#region src/index.ts
|
|
9
|
+
function c8y() {
|
|
10
|
+
return {
|
|
11
|
+
name: "c8y-nitro",
|
|
12
|
+
setup: async (nitro) => {
|
|
13
|
+
const options = nitro.options.c8y ?? {};
|
|
14
|
+
nitro.options.typescript.generateTsConfig = true;
|
|
15
|
+
nitro.options.typescript.tsConfig = {};
|
|
16
|
+
nitro.options.typescript.tsConfig.include = ["./**/*.d.ts"];
|
|
17
|
+
nitro.options.typescript.tsConfig.exclude = [];
|
|
18
|
+
nitro.options.experimental.asyncContext = true;
|
|
19
|
+
if (!nitro.options.preset.startsWith("nitro") && !nitro.options.preset.startsWith("node")) {
|
|
20
|
+
nitro.logger.error(`Unsupported preset "${nitro.options.preset}" for c8y-nitro module, only node presets are supported.`);
|
|
21
|
+
throw new Error("Unsupported preset for c8y-nitro module");
|
|
22
|
+
}
|
|
23
|
+
await autoBootstrap(nitro);
|
|
24
|
+
setupRuntime(nitro, options.manifest);
|
|
25
|
+
registerRuntime(nitro, options);
|
|
26
|
+
nitro.hooks.hook("dev:reload", async () => {
|
|
27
|
+
setupRuntime(nitro, options.manifest);
|
|
28
|
+
if (options.apiClient) {
|
|
29
|
+
nitro.logger.debug("Generating C8Y API client");
|
|
30
|
+
await writeAPIClient(nitro, options);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
nitro.hooks.hook("build:before", async () => {
|
|
34
|
+
if (options.manifest) checkProbes(nitro, options.manifest);
|
|
35
|
+
});
|
|
36
|
+
nitro.hooks.hook("types:extend", async () => {
|
|
37
|
+
if (options.apiClient) {
|
|
38
|
+
nitro.logger.debug("Generating C8Y API client");
|
|
39
|
+
await writeAPIClient(nitro, options);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
nitro.hooks.hook("close", async () => {
|
|
43
|
+
if (nitro.options.preset !== "nitro-dev") await createC8yZip(nitro, options.zip);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
var src_default = c8y;
|
|
49
|
+
|
|
50
|
+
//#endregion
|
|
51
|
+
export { c8y, src_default as default };
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { createC8yManifestFromNitro } from "./manifest.mjs";
|
|
2
|
+
import { dirname, join, relative } from "node:path";
|
|
3
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
4
|
+
|
|
5
|
+
//#region src/module/apiClient.ts
|
|
6
|
+
/**
|
|
7
|
+
* Converts route path to PascalCase function name.
|
|
8
|
+
* Examples:
|
|
9
|
+
* - /health + get -> GetHealth
|
|
10
|
+
* - /health + post -> PostHealth
|
|
11
|
+
* - /someRoute -> SomeRoute (default)
|
|
12
|
+
* - /[id] or /:id + get -> GetById
|
|
13
|
+
* - /api/[multiple]/[params] -> GetApiByMultipleByParams
|
|
14
|
+
* @param path - The route path (e.g., "/api/[id]" or "/api/:id")
|
|
15
|
+
* @param method - HTTP method (get, post, put, delete, etc.)
|
|
16
|
+
*/
|
|
17
|
+
function generateFunctionName(path, method) {
|
|
18
|
+
return `${method}${path.replace(/^\//, "").replaceAll(".", "_").replaceAll("*", "").split("/").map((seg) => {
|
|
19
|
+
if (seg.startsWith("[") && seg.endsWith("]")) return `By${capitalize(seg.slice(1, -1))}`;
|
|
20
|
+
if (seg.startsWith(":")) return `By${capitalize(seg.slice(1))}`;
|
|
21
|
+
return capitalize(seg);
|
|
22
|
+
}).join("") || "Index"}`;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Extracts route parameters from path.
|
|
26
|
+
* Example: "/api/[id]/items/[itemId]" or "/api/:id/items/:itemId" -> [{name: "id", type: "string"}, {name: "itemId", type: "string"}]
|
|
27
|
+
* @param path - The route path to extract parameters from
|
|
28
|
+
*/
|
|
29
|
+
function extractParams(path) {
|
|
30
|
+
const params = [];
|
|
31
|
+
const segments = path.split("/");
|
|
32
|
+
for (const seg of segments) if (seg.startsWith("[") && seg.endsWith("]")) {
|
|
33
|
+
const name = seg.slice(1, -1);
|
|
34
|
+
params.push(name);
|
|
35
|
+
} else if (seg.startsWith(":")) {
|
|
36
|
+
const name = seg.slice(1);
|
|
37
|
+
params.push(name);
|
|
38
|
+
}
|
|
39
|
+
return params;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Capitalizes first letter of a string.
|
|
43
|
+
* @param str - The string to capitalize
|
|
44
|
+
*/
|
|
45
|
+
function capitalize(str) {
|
|
46
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Converts a file name to a valid JavaScript class name.
|
|
50
|
+
* Only allows letters, numbers (not at start), $ and _.
|
|
51
|
+
* Removes or replaces invalid characters and ensures PascalCase.
|
|
52
|
+
* Always prefixed with "Generated" for clarity.
|
|
53
|
+
* @param fileName - The file name to convert
|
|
54
|
+
* @example "my-service-api" -> "GeneratedMyServiceApi"
|
|
55
|
+
* @example "playgroundAPIClient" -> "GeneratedPlaygroundAPIClient"
|
|
56
|
+
* @example "special@chars!" -> "GeneratedSpecialchars"
|
|
57
|
+
*/
|
|
58
|
+
function toValidClassName(fileName) {
|
|
59
|
+
let cleaned = fileName.replace(/\.(ts|js|mjs|cjs|tsx|jsx)$/, "").replace(/[-_.]/g, " ");
|
|
60
|
+
cleaned = cleaned.replace(/[^a-zA-Z0-9\s$]/g, "");
|
|
61
|
+
return `Generated${cleaned.split(/\s+/).filter((word) => word.length > 0).map((word) => capitalize(word)).join("")}`;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Generates TypeScript method code for a route in an Angular service.
|
|
65
|
+
* @param route - Parsed route information including path, method, params, and return type
|
|
66
|
+
*/
|
|
67
|
+
function generateMethod(route) {
|
|
68
|
+
const hasParams = route.params.length > 0;
|
|
69
|
+
let inlineParamsType = "";
|
|
70
|
+
if (hasParams) inlineParamsType = `{ ${route.params.map((name) => `${name}: string | number`).join("; ")} }`;
|
|
71
|
+
const methodParam = hasParams ? `params: ${inlineParamsType}` : "";
|
|
72
|
+
const returnTypeAnnotation = `Promise<${route.returnType}>`;
|
|
73
|
+
let pathExpression = `\`\${this.BASE_PATH}${route.path}\``;
|
|
74
|
+
if (hasParams) pathExpression = `\`\${this.BASE_PATH}${route.path.replace(/\[([^\]]+)\]/g, (_, paramName) => `\${params.${paramName}}`)}\``;
|
|
75
|
+
const fetchOptions = `{ method: '${route.method}', headers: { 'Content-Type': 'application/json' } }`;
|
|
76
|
+
return ` async ${route.functionName}(${methodParam}): ${returnTypeAnnotation} {
|
|
77
|
+
const response = await this.fetchClient.fetch(${pathExpression}, ${fetchOptions});
|
|
78
|
+
return this.serialize(response);
|
|
79
|
+
}`;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Helper types for proper type serialization from Nitro.
|
|
83
|
+
* https://github.com/nitrojs/nitro/blob/67b43f2692a41728a2759462b6982c6872ed3a81/src/types/fetch/_serialize.ts
|
|
84
|
+
*/
|
|
85
|
+
const serializationTypes = `type JsonPrimitive = string | number | boolean | string | number | boolean | null;
|
|
86
|
+
type NonJsonPrimitive = undefined | Function | symbol;
|
|
87
|
+
type IsAny<T> = 0 extends 1 & T ? true : false;
|
|
88
|
+
type FilterKeys<TObj extends object, TFilter> = { [TKey in keyof TObj]: TObj[TKey] extends TFilter ? TKey : never }[keyof TObj];
|
|
89
|
+
type Serialize<T> = IsAny<T> extends true ? any : T extends JsonPrimitive | undefined ? T : T extends Map<any, any> | Set<any> ? Record<string, never> : T extends NonJsonPrimitive ? never : T extends {
|
|
90
|
+
toJSON: () => infer U;
|
|
91
|
+
} ? U : T extends [] ? [] : T extends [unknown, ...unknown[]] ? SerializeTuple<T> : T extends ReadonlyArray<infer U> ? (U extends NonJsonPrimitive ? null : Serialize<U>)[] : T extends object ? SerializeObject<T> : never;
|
|
92
|
+
type SerializeTuple<T extends [unknown, ...unknown[]]> = { [k in keyof T]: T[k] extends NonJsonPrimitive ? null : Serialize<T[k]> };
|
|
93
|
+
type SerializeObject<T extends object> = { [k in keyof Omit<T, FilterKeys<T, NonJsonPrimitive>>]: Serialize<T[k]> };
|
|
94
|
+
type Simplify<TType> = TType extends any[] | Date ? TType : { [K in keyof TType]: Simplify<TType[K]> };
|
|
95
|
+
type Awaited<T> = T extends PromiseLike<infer U> ? Awaited<U> : T;
|
|
96
|
+
type C8ySerialize<T> = T extends void | undefined | null ? undefined : T extends string ? string : Simplify<Serialize<T>>;
|
|
97
|
+
`;
|
|
98
|
+
/**
|
|
99
|
+
* Generates complete Angular API client service.
|
|
100
|
+
* @param routes - Array of parsed routes to generate methods for
|
|
101
|
+
* @param contextPath - The microservice context path (e.g., "my-service")
|
|
102
|
+
* @param className - The class name for the generated service (e.g., "PlaygroundAPIClient")
|
|
103
|
+
*/
|
|
104
|
+
function generateAPIClient(routes, contextPath, className) {
|
|
105
|
+
return `/* eslint-disable eslint-comments/no-unlimited-disable */
|
|
106
|
+
/* eslint-disable */
|
|
107
|
+
/**
|
|
108
|
+
* Auto-generated Cumulocity API Client
|
|
109
|
+
* Generated by c8y-nitro
|
|
110
|
+
*
|
|
111
|
+
* This Angular service provides typed methods for all Nitro routes.
|
|
112
|
+
* Each method corresponds to a route handler and returns properly typed responses.
|
|
113
|
+
*/
|
|
114
|
+
import { Injectable, inject } from '@angular/core'
|
|
115
|
+
import { FetchClient } from '@c8y/client'
|
|
116
|
+
|
|
117
|
+
// Type helpers for proper serialization
|
|
118
|
+
${serializationTypes}
|
|
119
|
+
|
|
120
|
+
@Injectable({ providedIn: 'root' })
|
|
121
|
+
export class ${className} {
|
|
122
|
+
private readonly BASE_PATH = '/service/${contextPath}';
|
|
123
|
+
private readonly fetchClient: FetchClient = inject(FetchClient)
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Serializes the response based on content type and body.
|
|
127
|
+
* - Empty body (null, undefined, void) -> undefined
|
|
128
|
+
* - text/plain (string) -> raw text
|
|
129
|
+
* - application/json (number, boolean, object, array) -> JSON parsed
|
|
130
|
+
*/
|
|
131
|
+
private async serialize(response: Response): Promise<any> {
|
|
132
|
+
const contentType = response.headers.get('content-type');
|
|
133
|
+
const text = await response.text();
|
|
134
|
+
|
|
135
|
+
// Handle empty responses (null, undefined, empty return)
|
|
136
|
+
if (!text || text.length === 0) {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Handle plain text responses (strings)
|
|
141
|
+
if (contentType?.includes('text/plain')) {
|
|
142
|
+
return text;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Handle JSON responses (numbers, booleans, objects, arrays)
|
|
146
|
+
return JSON.parse(text);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
${routes.map((route) => generateMethod(route)).join("\n\n")}
|
|
150
|
+
}`;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Generates the TypeScript return type for a route handler.
|
|
154
|
+
* @param handlerPath - Absolute path to the route handler file
|
|
155
|
+
* @param outputFile - Absolute path to the output API client file
|
|
156
|
+
* @returns TypeScript return type string with proper serialization wrappers
|
|
157
|
+
*/
|
|
158
|
+
function getReturnType(handlerPath, outputFile) {
|
|
159
|
+
const relativeHandlerPath = relative(dirname(outputFile), handlerPath);
|
|
160
|
+
return `C8ySerialize<Awaited<ReturnType<typeof import('${(relativeHandlerPath.startsWith(".") ? relativeHandlerPath : `./${relativeHandlerPath}`).replace(/\.ts$/, "")}').default>>>`;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Writes the generated API client to disk.
|
|
164
|
+
* @param nitro - Nitro instance
|
|
165
|
+
* @param options - Complete module options including apiClient and manifest
|
|
166
|
+
*/
|
|
167
|
+
async function writeAPIClient(nitro, options) {
|
|
168
|
+
const { apiClient: apiClientOptions } = options;
|
|
169
|
+
if (!apiClientOptions) {
|
|
170
|
+
nitro.logger.debug("API client generation skipped: no apiClient options provided");
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const manifest = await createC8yManifestFromNitro(nitro);
|
|
174
|
+
const serviceName = manifest.name;
|
|
175
|
+
const serviceContextPath = apiClientOptions.contextPath ?? manifest.contextPath ?? serviceName;
|
|
176
|
+
const name = `${serviceName}APIClient`;
|
|
177
|
+
const rootDir = nitro.options.rootDir;
|
|
178
|
+
const outputDir = join(rootDir, apiClientOptions.dir);
|
|
179
|
+
const outputFile = join(outputDir, `${name}.ts`);
|
|
180
|
+
const routes = (nitro.routing.routes._routes ?? []).filter((route) => {
|
|
181
|
+
return !route.handler.includes("nitro/dist/runtime/internal");
|
|
182
|
+
}).map((route) => {
|
|
183
|
+
const path = route.route;
|
|
184
|
+
const method = (!route.method ? "GET" : route.method).toUpperCase();
|
|
185
|
+
const params = extractParams(path);
|
|
186
|
+
const functionName = generateFunctionName(path, method);
|
|
187
|
+
const returnType = getReturnType(route.handler, outputFile);
|
|
188
|
+
return {
|
|
189
|
+
path: path.replace(/:([^/]+)/g, "[$1]"),
|
|
190
|
+
method,
|
|
191
|
+
functionName,
|
|
192
|
+
params,
|
|
193
|
+
returnType
|
|
194
|
+
};
|
|
195
|
+
});
|
|
196
|
+
if (routes.length === 0) {
|
|
197
|
+
nitro.logger.warn("No routes found to generate API client");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const code = generateAPIClient(routes, serviceContextPath, toValidClassName(name));
|
|
201
|
+
await mkdir(outputDir, { recursive: true });
|
|
202
|
+
await writeFile(outputFile, code, "utf-8");
|
|
203
|
+
nitro.logger.success(`Generated API client with ${routes.length} routes at: ${relative(rootDir, outputFile)}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
//#endregion
|
|
207
|
+
export { writeAPIClient };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createC8yManifest } from "./manifest.mjs";
|
|
2
|
+
import { createBasicAuthHeader, createMicroservice, findMicroserviceByName, getBootstrapCredentials, subscribeToApplication } from "../cli/utils/c8y-api.mjs";
|
|
3
|
+
import { writeBootstrapCredentials } from "../cli/utils/env-file.mjs";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
|
|
6
|
+
//#region src/module/autoBootstrap.ts
|
|
7
|
+
/**
|
|
8
|
+
* Automatically bootstraps the microservice to the development tenant if needed.
|
|
9
|
+
* Runs silently - only logs if bootstrap was performed or if errors occur.
|
|
10
|
+
* @param nitro - Nitro instance
|
|
11
|
+
*/
|
|
12
|
+
async function autoBootstrap(nitro) {
|
|
13
|
+
try {
|
|
14
|
+
if ([
|
|
15
|
+
"C8Y_BOOTSTRAP_TENANT",
|
|
16
|
+
"C8Y_BOOTSTRAP_USER",
|
|
17
|
+
"C8Y_BOOTSTRAP_PASSWORD"
|
|
18
|
+
].every((v) => process.env[v])) return;
|
|
19
|
+
if ([
|
|
20
|
+
"C8Y_BASEURL",
|
|
21
|
+
"C8Y_DEVELOPMENT_TENANT",
|
|
22
|
+
"C8Y_DEVELOPMENT_USER",
|
|
23
|
+
"C8Y_DEVELOPMENT_PASSWORD"
|
|
24
|
+
].filter((v) => !process.env[v]).length > 0) return;
|
|
25
|
+
const baseUrl = process.env.C8Y_BASEURL.endsWith("/") ? process.env.C8Y_BASEURL.slice(0, -1) : process.env.C8Y_BASEURL;
|
|
26
|
+
const authHeader = createBasicAuthHeader(process.env.C8Y_DEVELOPMENT_TENANT, process.env.C8Y_DEVELOPMENT_USER, process.env.C8Y_DEVELOPMENT_PASSWORD);
|
|
27
|
+
const manifest = await createC8yManifest(nitro.options.rootDir, nitro.options.c8y?.manifest, nitro.logger);
|
|
28
|
+
const existingApp = await findMicroserviceByName(baseUrl, manifest.name, authHeader);
|
|
29
|
+
let appId;
|
|
30
|
+
if (existingApp) {
|
|
31
|
+
appId = existingApp.id;
|
|
32
|
+
nitro.logger.debug(`Microservice "${manifest.name}" already exists (ID: ${appId}), retrieving bootstrap credentials...`);
|
|
33
|
+
} else {
|
|
34
|
+
appId = (await createMicroservice(baseUrl, manifest, authHeader)).id;
|
|
35
|
+
nitro.logger.debug(`Microservice "${manifest.name}" created (ID: ${appId})`);
|
|
36
|
+
}
|
|
37
|
+
await subscribeToApplication(baseUrl, process.env.C8Y_DEVELOPMENT_TENANT, appId, authHeader);
|
|
38
|
+
const credentials = await getBootstrapCredentials(baseUrl, appId, authHeader);
|
|
39
|
+
const envFileName = await writeBootstrapCredentials(nitro.options.rootDir, {
|
|
40
|
+
C8Y_BOOTSTRAP_TENANT: credentials.tenant,
|
|
41
|
+
C8Y_BOOTSTRAP_USER: credentials.name,
|
|
42
|
+
C8Y_BOOTSTRAP_PASSWORD: credentials.password
|
|
43
|
+
});
|
|
44
|
+
process.env.C8Y_BOOTSTRAP_TENANT = credentials.tenant;
|
|
45
|
+
process.env.C8Y_BOOTSTRAP_USER = credentials.name;
|
|
46
|
+
process.env.C8Y_BOOTSTRAP_PASSWORD = credentials.password;
|
|
47
|
+
nitro.logger.success(`Auto-bootstrap complete! Bootstrap credentials written to ${envFileName}`);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
nitro.logger.warn("Auto-bootstrap failed:", error instanceof Error ? error.message : String(error));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
//#endregion
|
|
54
|
+
export { autoBootstrap };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createC8yManifestFromNitro } from "./manifest.mjs";
|
|
2
|
+
import { createDockerImage } from "./docker.mjs";
|
|
3
|
+
import { join } from "pathe";
|
|
4
|
+
import { mkdir, readFile, stat, writeFile } from "fs/promises";
|
|
5
|
+
import JSZip from "jszip";
|
|
6
|
+
import Spinnies from "spinnies";
|
|
7
|
+
import { colors } from "consola/utils";
|
|
8
|
+
|
|
9
|
+
//#region src/module/c8yzip.ts
|
|
10
|
+
const spinnies = new Spinnies();
|
|
11
|
+
function formatBytes(bytes) {
|
|
12
|
+
if (bytes === 0) return "0 B";
|
|
13
|
+
const k = 1024;
|
|
14
|
+
const sizes = [
|
|
15
|
+
"B",
|
|
16
|
+
"kB",
|
|
17
|
+
"MB",
|
|
18
|
+
"GB"
|
|
19
|
+
];
|
|
20
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
21
|
+
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Resolve the output path for the zip file based on options and manifest
|
|
25
|
+
* @param rootDir - Root directory of the project
|
|
26
|
+
* @param options - Zip options with optional name and outputDir
|
|
27
|
+
* @param manifest - Cumulocity manifest containing name and version
|
|
28
|
+
* @returns Absolute path to the output zip file
|
|
29
|
+
*/
|
|
30
|
+
function resolveZipOutputPath(rootDir, options, manifest) {
|
|
31
|
+
return join(join(rootDir, options.outputDir ?? "./"), typeof options.name === "function" ? options.name(manifest.name, manifest.version) : options.name ?? `${manifest.name}-${manifest.version}.zip`);
|
|
32
|
+
}
|
|
33
|
+
async function createC8yZip(nitro, options = {}) {
|
|
34
|
+
const startTime = Date.now();
|
|
35
|
+
const spinnerName = "c8y-zip";
|
|
36
|
+
spinnies.add(spinnerName, { text: "Creating Dockerfile..." });
|
|
37
|
+
spinnies.update(spinnerName, { text: "Building Docker image..." });
|
|
38
|
+
const imageTarPath = await createDockerImage(nitro);
|
|
39
|
+
spinnies.update(spinnerName, { text: "Creating manifest..." });
|
|
40
|
+
const manifest = await createC8yManifestFromNitro(nitro);
|
|
41
|
+
spinnies.update(spinnerName, { text: "Reading image.tar..." });
|
|
42
|
+
const imageTarBuffer = await readFile(imageTarPath);
|
|
43
|
+
spinnies.update(spinnerName, { text: "Building zip file..." });
|
|
44
|
+
const zip = new JSZip();
|
|
45
|
+
zip.file("image.tar", imageTarBuffer);
|
|
46
|
+
zip.file("cumulocity.json", JSON.stringify(manifest, null, 2));
|
|
47
|
+
const zipBuffer = await zip.generateAsync({
|
|
48
|
+
type: "nodebuffer",
|
|
49
|
+
compression: "STORE"
|
|
50
|
+
});
|
|
51
|
+
const outputFile = resolveZipOutputPath(nitro.options.rootDir, options, manifest);
|
|
52
|
+
const outputDir = join(outputFile, "..");
|
|
53
|
+
spinnies.update(spinnerName, { text: "Writing zip file..." });
|
|
54
|
+
await mkdir(outputDir, { recursive: true });
|
|
55
|
+
await writeFile(outputFile, zipBuffer);
|
|
56
|
+
const zipSize = formatBytes((await stat(outputFile)).size);
|
|
57
|
+
const duration = Date.now() - startTime;
|
|
58
|
+
spinnies.remove(spinnerName);
|
|
59
|
+
spinnies.stopAll();
|
|
60
|
+
nitro.logger.success(`Cumulocity zip built in ${duration}ms`);
|
|
61
|
+
nitro.logger.log(colors.gray(` └─ ${outputFile} (${zipSize})`));
|
|
62
|
+
nitro.logger.info("Zip file can be uploaded to Cumulocity IoT platform");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
//#endregion
|
|
66
|
+
export { createC8yZip };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { basename, join } from "node:path";
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { x } from "tinyexec";
|
|
4
|
+
|
|
5
|
+
//#region src/module/docker.ts
|
|
6
|
+
/**
|
|
7
|
+
* Generate the Dockerfile content for a Nitro build
|
|
8
|
+
* @param outputDirName - Name of the output directory (e.g., '.output')
|
|
9
|
+
* @returns Dockerfile content as a string
|
|
10
|
+
*/
|
|
11
|
+
function getDockerfileContent(outputDirName) {
|
|
12
|
+
return `FROM node:22-slim AS runtime
|
|
13
|
+
|
|
14
|
+
WORKDIR /app
|
|
15
|
+
|
|
16
|
+
# Copy the Nitro build output
|
|
17
|
+
COPY ${outputDirName}/ ${outputDirName}/
|
|
18
|
+
|
|
19
|
+
ENV NODE_ENV=production
|
|
20
|
+
ENV PORT=80
|
|
21
|
+
|
|
22
|
+
EXPOSE 80
|
|
23
|
+
|
|
24
|
+
# Run the Nitro server entrypoint. Use source maps to aid debugging if present.
|
|
25
|
+
CMD ["node", "--enable-source-maps", "${outputDirName}/server/index.mjs"]`;
|
|
26
|
+
}
|
|
27
|
+
async function checkDockerInstalled() {
|
|
28
|
+
try {
|
|
29
|
+
const result = await x("docker", ["--version"]);
|
|
30
|
+
if (result.exitCode !== 0) return false;
|
|
31
|
+
if (result.stderr) return false;
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async function writeDockerfile(outputDir) {
|
|
38
|
+
const outputDirName = basename(outputDir);
|
|
39
|
+
const c8yDir = join(outputDir, "../.c8y");
|
|
40
|
+
const dockerfilePath = join(c8yDir, "Dockerfile");
|
|
41
|
+
await mkdir(c8yDir, { recursive: true });
|
|
42
|
+
await writeFile(dockerfilePath, getDockerfileContent(outputDirName), "utf-8");
|
|
43
|
+
return c8yDir;
|
|
44
|
+
}
|
|
45
|
+
async function buildDockerImage(nitro, c8yDir) {
|
|
46
|
+
const imageName = `${nitro.options.rootDir.split("/").pop() || "c8y-app"}:latest`;
|
|
47
|
+
const buildContext = join(c8yDir, "..");
|
|
48
|
+
nitro.logger.debug(`Building Docker image: ${imageName}`);
|
|
49
|
+
nitro.logger.debug(`Build context: ${buildContext}`);
|
|
50
|
+
try {
|
|
51
|
+
const result = await x("docker", [
|
|
52
|
+
"build",
|
|
53
|
+
"-t",
|
|
54
|
+
imageName,
|
|
55
|
+
"-f",
|
|
56
|
+
join(c8yDir, "Dockerfile"),
|
|
57
|
+
buildContext
|
|
58
|
+
]);
|
|
59
|
+
if (result.stdout) nitro.logger.debug(result.stdout);
|
|
60
|
+
if (result.stderr) nitro.logger.debug(result.stderr);
|
|
61
|
+
if (result.exitCode !== 0) throw new Error(`Docker build failed with exit code ${result.exitCode}`, { cause: result.stderr });
|
|
62
|
+
nitro.logger.debug(`Docker image built successfully: ${imageName}`);
|
|
63
|
+
return imageName;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
throw new Error("Failed to build Docker image", { cause: error });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async function saveDockerImageToTar(nitro, c8yDir, imageName) {
|
|
69
|
+
const imageTarPath = join(c8yDir, "image.tar");
|
|
70
|
+
nitro.logger.debug(`Saving Docker image to ${imageTarPath}`);
|
|
71
|
+
try {
|
|
72
|
+
const result = await x("docker", [
|
|
73
|
+
"save",
|
|
74
|
+
"-o",
|
|
75
|
+
imageTarPath,
|
|
76
|
+
imageName
|
|
77
|
+
]);
|
|
78
|
+
if (result.stderr) nitro.logger.debug(result.stderr);
|
|
79
|
+
if (result.exitCode !== 0) throw new Error(`Docker save failed with exit code ${result.exitCode}`, { cause: result.stderr });
|
|
80
|
+
nitro.logger.debug(`Docker image saved to ${imageTarPath}`);
|
|
81
|
+
return imageTarPath;
|
|
82
|
+
} catch (error) {
|
|
83
|
+
throw new Error("Failed to save Docker image to tar file", { cause: error });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Create a Docker image from the Nitro build output
|
|
88
|
+
* @param nitro Nitro instance
|
|
89
|
+
* @returns Path to the saved Docker image tar file
|
|
90
|
+
*/
|
|
91
|
+
async function createDockerImage(nitro) {
|
|
92
|
+
if (!await checkDockerInstalled()) throw new Error("Docker is not installed or not available in PATH. Please install Docker to build images.");
|
|
93
|
+
nitro.logger.debug("Creating Docker image...");
|
|
94
|
+
const c8yDir = await writeDockerfile(nitro.options.output.dir);
|
|
95
|
+
const imageTarPath = await saveDockerImageToTar(nitro, c8yDir, await buildDockerImage(nitro, c8yDir));
|
|
96
|
+
nitro.logger.debug("Docker image creation complete.");
|
|
97
|
+
return imageTarPath;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
//#endregion
|
|
101
|
+
export { createDockerImage };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { GENERATED_LIVENESS_ROUTE, GENERATED_READINESS_ROUTE } from "./constants.mjs";
|
|
2
|
+
import { readPackage } from "pkg-types";
|
|
3
|
+
|
|
4
|
+
//#region src/module/manifest.ts
|
|
5
|
+
async function readPackageJsonFieldsForManifest(rootDir, logger) {
|
|
6
|
+
logger?.debug(`Reading package file from ${rootDir}`);
|
|
7
|
+
const pkg = await readPackage(rootDir);
|
|
8
|
+
const name = pkg.name?.replace(/^@[^/]+\//, "");
|
|
9
|
+
const version = pkg.version;
|
|
10
|
+
const author = pkg.author;
|
|
11
|
+
const authorName = typeof author === "string" ? author : author?.name;
|
|
12
|
+
const authorEmail = typeof author === "string" ? void 0 : author?.email;
|
|
13
|
+
const authorUrl = typeof author === "string" ? void 0 : author?.url;
|
|
14
|
+
if (!name || !version || !authorName) throw new Error("package.json must contain name, version, and author name fields");
|
|
15
|
+
const support = typeof pkg.bugs === "string" ? pkg.bugs : pkg.bugs?.url ?? pkg.bugs?.email;
|
|
16
|
+
const provider = {
|
|
17
|
+
name: authorName ?? name,
|
|
18
|
+
domain: authorUrl ?? pkg.homepage,
|
|
19
|
+
support: support ?? authorEmail
|
|
20
|
+
};
|
|
21
|
+
logger?.debug(`Found package.json fields for manifest: name=${name}, version=${version}, provider=${JSON.stringify(provider)}`);
|
|
22
|
+
return {
|
|
23
|
+
name,
|
|
24
|
+
version,
|
|
25
|
+
provider,
|
|
26
|
+
contextPath: name
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Creates a Cumulocity manifest from rootDir and options.
|
|
31
|
+
* Standalone function that can be used by CLI or module.
|
|
32
|
+
* @param rootDir - Directory containing package.json
|
|
33
|
+
* @param options - Manifest options
|
|
34
|
+
* @param logger - Optional logger for debug output
|
|
35
|
+
*/
|
|
36
|
+
async function createC8yManifest(rootDir, options = {}, logger) {
|
|
37
|
+
const { name, version, provider, ...restManifestFields } = await readPackageJsonFieldsForManifest(rootDir, logger);
|
|
38
|
+
const probeFields = {};
|
|
39
|
+
if (!options.livenessProbe?.httpGet) probeFields.livenessProbe = {
|
|
40
|
+
...options.livenessProbe,
|
|
41
|
+
httpGet: { path: GENERATED_LIVENESS_ROUTE }
|
|
42
|
+
};
|
|
43
|
+
if (!options.readinessProbe?.httpGet) probeFields.readinessProbe = {
|
|
44
|
+
...options.readinessProbe,
|
|
45
|
+
httpGet: { path: GENERATED_READINESS_ROUTE }
|
|
46
|
+
};
|
|
47
|
+
const key = `${name}-key`;
|
|
48
|
+
const manifest = {
|
|
49
|
+
...restManifestFields,
|
|
50
|
+
provider,
|
|
51
|
+
...probeFields,
|
|
52
|
+
...options,
|
|
53
|
+
name,
|
|
54
|
+
version,
|
|
55
|
+
apiVersion: "v2",
|
|
56
|
+
key,
|
|
57
|
+
type: "MICROSERVICE"
|
|
58
|
+
};
|
|
59
|
+
logger?.debug(`Created Cumulocity manifest: ${JSON.stringify(manifest, null, 2)}`);
|
|
60
|
+
return manifest;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Creates a Cumulocity manifest from a Nitro instance.
|
|
64
|
+
* Convenience wrapper for use in the Nitro module.
|
|
65
|
+
* @param nitro - The Nitro instance
|
|
66
|
+
*/
|
|
67
|
+
async function createC8yManifestFromNitro(nitro) {
|
|
68
|
+
return createC8yManifest(nitro.options.rootDir, nitro.options.c8y?.manifest, nitro.logger);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
//#endregion
|
|
72
|
+
export { createC8yManifest, createC8yManifestFromNitro };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
//#region src/module/probeCheck.ts
|
|
2
|
+
function checkProbes(nitro, manifestOptions = {}) {
|
|
3
|
+
if (manifestOptions.livenessProbe?.httpGet) {
|
|
4
|
+
const probe = manifestOptions.livenessProbe.httpGet;
|
|
5
|
+
checkProbeEndpoint(nitro, probe, "livenessProbe");
|
|
6
|
+
}
|
|
7
|
+
if (manifestOptions.readinessProbe?.httpGet) {
|
|
8
|
+
const probe = manifestOptions.readinessProbe.httpGet;
|
|
9
|
+
checkProbeEndpoint(nitro, probe, "readinessProbe");
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function checkProbeEndpoint(nitro, probe, probeType) {
|
|
13
|
+
const path = probe.path;
|
|
14
|
+
const method = "GET";
|
|
15
|
+
const matchingHandlers = nitro.scannedHandlers.filter((h) => h.route === path);
|
|
16
|
+
if (matchingHandlers.length === 0) {
|
|
17
|
+
nitro.logger.warn(`${probeType} route "${path}" not found in scanned handlers. The probe will fail at runtime.`);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (!matchingHandlers.some((h) => {
|
|
21
|
+
if (!h.method) return true;
|
|
22
|
+
return h.method.toUpperCase() === method.toUpperCase();
|
|
23
|
+
})) {
|
|
24
|
+
const availableMethods = matchingHandlers.filter((h) => h.method).map((h) => h.method?.toUpperCase()).join(", ");
|
|
25
|
+
nitro.logger.warn(`${probeType} route "${path}" exists but does not accept ${method} requests. Available methods: ${availableMethods || "none specified"}. The probe will fail at runtime.`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
//#endregion
|
|
30
|
+
export { checkProbes };
|