@savvy-web/github-action-builder 2.1.1 → 2.2.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/bin/github-action-builder.js +1 -1
- package/cli/commands/build.js +1 -1
- package/cli/commands/init.js +3 -2
- package/github-action.js +1 -1
- package/index.d.ts +38 -4
- package/index.js +1 -1
- package/layers/app.js +9 -9
- package/package.json +1 -1
- package/services/build.js +286 -2
- package/services/config.js +141 -3
- package/services/persist-local.js +214 -2
- package/services/validation.js +223 -2
- package/services/build-live.js +0 -282
- package/services/config-live.js +0 -138
- package/services/persist-local-live.js +0 -211
- package/services/validation-live.js +0 -217
package/services/build-live.js
DELETED
|
@@ -1,282 +0,0 @@
|
|
|
1
|
-
import { BundleFailed, CleanError, WriteError } from "../errors.js";
|
|
2
|
-
import { BuildService } from "./build.js";
|
|
3
|
-
import { ConfigService } from "./config.js";
|
|
4
|
-
import { buildNativeDynamicImportRules } from "./native-dynamic-imports.js";
|
|
5
|
-
import { Effect, Layer, Result } from "effect";
|
|
6
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
7
|
-
import { basename, resolve } from "node:path";
|
|
8
|
-
import { fileURLToPath } from "node:url";
|
|
9
|
-
import { createRsbuild } from "@rsbuild/core";
|
|
10
|
-
|
|
11
|
-
//#region src/services/build-live.ts
|
|
12
|
-
/* v8 ignore start - build service requires actual bundling for integration testing */
|
|
13
|
-
/**
|
|
14
|
-
* BuildService Layer implementation.
|
|
15
|
-
*
|
|
16
|
-
*/
|
|
17
|
-
/**
|
|
18
|
-
* Source of the stub module that replaces packages listed in `build.ignore`.
|
|
19
|
-
* It is bundled in place of the real module and throws if ever loaded.
|
|
20
|
-
*/
|
|
21
|
-
const IGNORE_STUB_SOURCE = `throw new Error("A module excluded via the build 'ignore' option was loaded at runtime.");\n`;
|
|
22
|
-
/**
|
|
23
|
-
* Self-referencing specifier for the `webpackIgnore`-injecting loader
|
|
24
|
-
* shipped from `public/loaders/webpack-ignore-dynamic-imports.cjs` (see
|
|
25
|
-
* `package.json` `exports`). Resolved through the package's own `exports`
|
|
26
|
-
* map via `import.meta.resolve`, which stays correct whether this module is
|
|
27
|
-
* running from `src` (the map points at `./public/loaders/...`) or from a
|
|
28
|
-
* built `dist` (the map points at the flattened `./loaders/...`, since the
|
|
29
|
-
* `public/` copy step drops the `public/` prefix both on disk and in the
|
|
30
|
-
* built manifest) — no relative-path assumption needed either way.
|
|
31
|
-
*/
|
|
32
|
-
const WEBPACK_IGNORE_LOADER_SPECIFIER = "@savvy-web/github-action-builder/loaders/webpack-ignore-dynamic-imports.cjs";
|
|
33
|
-
/**
|
|
34
|
-
* Resolve the absolute on-disk path to the `webpackIgnore`-injecting loader.
|
|
35
|
-
*/
|
|
36
|
-
function resolveWebpackIgnoreLoaderPath() {
|
|
37
|
-
return fileURLToPath(import.meta.resolve(WEBPACK_IGNORE_LOADER_SPECIFIER));
|
|
38
|
-
}
|
|
39
|
-
/**
|
|
40
|
-
* Format bytes as a human-readable string.
|
|
41
|
-
*/
|
|
42
|
-
function formatBytes(bytes) {
|
|
43
|
-
if (bytes < 1024) return `${bytes} B`;
|
|
44
|
-
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
45
|
-
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
46
|
-
}
|
|
47
|
-
/**
|
|
48
|
-
* Format build result for terminal output.
|
|
49
|
-
*/
|
|
50
|
-
/* v8 ignore start - formatting function tested via integration */
|
|
51
|
-
function formatBuildResult(result) {
|
|
52
|
-
const lines = [];
|
|
53
|
-
if (result.success) {
|
|
54
|
-
lines.push("Build Summary:");
|
|
55
|
-
for (const entry of result.entries) if (entry.success && entry.stats) {
|
|
56
|
-
const { entry: name, size, duration, outputPath } = entry.stats;
|
|
57
|
-
lines.push(` ✓ ${name}: ${formatBytes(size)} (${duration}ms) → ${outputPath}`);
|
|
58
|
-
}
|
|
59
|
-
lines.push(`\nTotal time: ${result.duration}ms`);
|
|
60
|
-
} else {
|
|
61
|
-
lines.push("Build Failed:");
|
|
62
|
-
for (const entry of result.entries) if (!entry.success) lines.push(` ✗ ${entry.error}`);
|
|
63
|
-
}
|
|
64
|
-
return lines.join("\n");
|
|
65
|
-
}
|
|
66
|
-
/* v8 ignore stop */
|
|
67
|
-
/**
|
|
68
|
-
* Clean output directory.
|
|
69
|
-
*/
|
|
70
|
-
function cleanDirectory(dir) {
|
|
71
|
-
return Effect.try({
|
|
72
|
-
try: () => {
|
|
73
|
-
if (existsSync(dir)) rmSync(dir, {
|
|
74
|
-
recursive: true,
|
|
75
|
-
force: true
|
|
76
|
-
});
|
|
77
|
-
},
|
|
78
|
-
/* v8 ignore next 5 - error branch requires fs permission failures */
|
|
79
|
-
catch: (error) => new CleanError({
|
|
80
|
-
directory: dir,
|
|
81
|
-
cause: error
|
|
82
|
-
})
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
/**
|
|
86
|
-
* Write file with directory creation.
|
|
87
|
-
*/
|
|
88
|
-
function writeFile(path, content) {
|
|
89
|
-
return Effect.try({
|
|
90
|
-
try: () => {
|
|
91
|
-
mkdirSync(resolve(path, ".."), { recursive: true });
|
|
92
|
-
writeFileSync(path, content, "utf8");
|
|
93
|
-
},
|
|
94
|
-
/* v8 ignore next 5 - error branch requires fs permission failures */
|
|
95
|
-
catch: (error) => new WriteError({
|
|
96
|
-
path,
|
|
97
|
-
cause: error
|
|
98
|
-
})
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
/**
|
|
102
|
-
* Fold an extracted `*.LICENSE.txt` sidecar back into its bundle.
|
|
103
|
-
*
|
|
104
|
-
* `legalComments: "linked"` is the only mode whose extraction actually sees
|
|
105
|
-
* bundled license banners — the "inline" mode's SWC comment-preservation path
|
|
106
|
-
* never receives them and silently drops attribution (verified against
|
|
107
|
-
* rsbuild 2.1.8). A committed action still must not carry sidecar files
|
|
108
|
-
* (issue #94), so the sidecar's verbatim comment blocks replace the
|
|
109
|
-
* `LICENSE:` reference banner in the bundle and the sidecar is deleted —
|
|
110
|
-
* attribution inline, no extra dist file.
|
|
111
|
-
*/
|
|
112
|
-
function inlineLicenseSidecar(outputPath) {
|
|
113
|
-
return Effect.try({
|
|
114
|
-
try: () => {
|
|
115
|
-
const sidecarPath = `${outputPath}.LICENSE.txt`;
|
|
116
|
-
if (!existsSync(sidecarPath)) return;
|
|
117
|
-
const licenses = readFileSync(sidecarPath, "utf8").trim();
|
|
118
|
-
const bundle = readFileSync(outputPath, "utf8");
|
|
119
|
-
const reference = `/*! LICENSE: ${basename(sidecarPath)} */`;
|
|
120
|
-
const afterReference = bundle.startsWith(reference) && bundle.charAt(reference.length) === "\n" ? reference.length + 1 : bundle.startsWith(reference) ? reference.length : 0;
|
|
121
|
-
writeFileSync(outputPath, `${licenses}\n${bundle.slice(afterReference)}`, "utf8");
|
|
122
|
-
unlinkSync(sidecarPath);
|
|
123
|
-
},
|
|
124
|
-
/* v8 ignore next 5 - error branch requires fs permission failures */
|
|
125
|
-
catch: (error) => new WriteError({
|
|
126
|
-
path: outputPath,
|
|
127
|
-
cause: error
|
|
128
|
-
})
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
/**
|
|
132
|
-
* Bundle a single entry with rsbuild.
|
|
133
|
-
*/
|
|
134
|
-
/* v8 ignore start - bundling requires actual rsbuild execution */
|
|
135
|
-
function bundleEntry(entry, config, cwd) {
|
|
136
|
-
return Effect.gen(function* () {
|
|
137
|
-
const startTime = Date.now();
|
|
138
|
-
const outputDir = resolve(cwd, "dist");
|
|
139
|
-
const externalsSet = new Set(config.build.externals);
|
|
140
|
-
const ignoreSet = new Set(config.build.ignore);
|
|
141
|
-
const ignoreAlias = {};
|
|
142
|
-
// webpackIgnore-injecting loader below leaves those calls as native
|
|
143
|
-
const nativeDynamicImportRules = config.build.nativeDynamicImports.length > 0 ? buildNativeDynamicImportRules(config.build.nativeDynamicImports, resolveWebpackIgnoreLoaderPath()) : [];
|
|
144
|
-
if (config.build.ignore.length > 0) {
|
|
145
|
-
const stubPath = resolve(cwd, "node_modules", ".cache", "github-action-builder", "ignore-stub.mjs");
|
|
146
|
-
yield* writeFile(stubPath, IGNORE_STUB_SOURCE);
|
|
147
|
-
for (const moduleName of config.build.ignore) ignoreAlias[`${moduleName}$`] = stubPath;
|
|
148
|
-
}
|
|
149
|
-
const rsbuild = yield* Effect.tryPromise({
|
|
150
|
-
try: () => createRsbuild({ rsbuildConfig: {
|
|
151
|
-
mode: "production",
|
|
152
|
-
source: { entry: { [entry.type]: entry.path } },
|
|
153
|
-
resolve: { alias: ignoreAlias },
|
|
154
|
-
output: {
|
|
155
|
-
target: "node",
|
|
156
|
-
module: true,
|
|
157
|
-
distPath: { root: outputDir },
|
|
158
|
-
filename: { js: "[name].js" },
|
|
159
|
-
externals: (data) => {
|
|
160
|
-
const request = data.request;
|
|
161
|
-
if (!request) return false;
|
|
162
|
-
if (request.startsWith("node:")) return `node-commonjs ${request}`;
|
|
163
|
-
if (externalsSet.has(request) && !ignoreSet.has(request)) return request;
|
|
164
|
-
return false;
|
|
165
|
-
},
|
|
166
|
-
cleanDistPath: false,
|
|
167
|
-
legalComments: "linked",
|
|
168
|
-
minify: config.build.minify,
|
|
169
|
-
sourceMap: config.build.sourceMap ? { js: "source-map" } : false
|
|
170
|
-
},
|
|
171
|
-
performance: { chunkSplit: { strategy: "all-in-one" } },
|
|
172
|
-
tools: { rspack: {
|
|
173
|
-
node: {
|
|
174
|
-
__dirname: "node-module",
|
|
175
|
-
__filename: "node-module"
|
|
176
|
-
},
|
|
177
|
-
module: {
|
|
178
|
-
parser: { javascript: { importMeta: false } },
|
|
179
|
-
// webpackIgnore-injecting loader (empty when the option is unset).
|
|
180
|
-
rules: nativeDynamicImportRules
|
|
181
|
-
},
|
|
182
|
-
output: { asyncChunks: false }
|
|
183
|
-
} }
|
|
184
|
-
} }),
|
|
185
|
-
catch: (error) => new BundleFailed({
|
|
186
|
-
entry: entry.path,
|
|
187
|
-
cause: error
|
|
188
|
-
})
|
|
189
|
-
});
|
|
190
|
-
const buildResult = yield* Effect.tryPromise({
|
|
191
|
-
try: () => rsbuild.build(),
|
|
192
|
-
catch: (error) => new BundleFailed({
|
|
193
|
-
entry: entry.path,
|
|
194
|
-
cause: error
|
|
195
|
-
})
|
|
196
|
-
});
|
|
197
|
-
yield* Effect.tryPromise({
|
|
198
|
-
try: () => buildResult.close(),
|
|
199
|
-
catch: (error) => new BundleFailed({
|
|
200
|
-
entry: entry.path,
|
|
201
|
-
cause: /* @__PURE__ */ new Error(`rsbuild close() failed: ${error}`)
|
|
202
|
-
})
|
|
203
|
-
});
|
|
204
|
-
const outputPath = resolve(outputDir, `${entry.type}.js`);
|
|
205
|
-
yield* inlineLicenseSidecar(outputPath);
|
|
206
|
-
const size = yield* Effect.try({
|
|
207
|
-
try: () => statSync(outputPath).size,
|
|
208
|
-
catch: (error) => new BundleFailed({
|
|
209
|
-
entry: entry.path,
|
|
210
|
-
cause: error
|
|
211
|
-
})
|
|
212
|
-
});
|
|
213
|
-
const duration = Date.now() - startTime;
|
|
214
|
-
return {
|
|
215
|
-
success: true,
|
|
216
|
-
stats: {
|
|
217
|
-
entry: entry.type,
|
|
218
|
-
size,
|
|
219
|
-
duration,
|
|
220
|
-
outputPath: entry.output
|
|
221
|
-
}
|
|
222
|
-
};
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
/* v8 ignore stop */
|
|
226
|
-
/**
|
|
227
|
-
* Live implementation of BuildService.
|
|
228
|
-
*
|
|
229
|
-
* @remarks
|
|
230
|
-
* Uses rsbuild for bundling.
|
|
231
|
-
*/
|
|
232
|
-
const BuildServiceLive = Layer.effect(BuildService, Effect.gen(function* () {
|
|
233
|
-
const configService = yield* ConfigService;
|
|
234
|
-
return {
|
|
235
|
-
/* v8 ignore start - build execution requires actual rsbuild bundling */
|
|
236
|
-
build: (config, options = {}) => Effect.gen(function* () {
|
|
237
|
-
const cwd = options.cwd ?? process.cwd();
|
|
238
|
-
const shouldClean = options.clean ?? true;
|
|
239
|
-
const startTime = Date.now();
|
|
240
|
-
const entriesConfig = { main: config.entries.main };
|
|
241
|
-
if (config.entries.pre) entriesConfig.pre = config.entries.pre;
|
|
242
|
-
if (config.entries.post) entriesConfig.post = config.entries.post;
|
|
243
|
-
if (config.entries.workers) entriesConfig.workers = config.entries.workers;
|
|
244
|
-
const entriesResult = yield* configService.detectEntries(cwd, entriesConfig);
|
|
245
|
-
if (shouldClean) yield* cleanDirectory(resolve(cwd, "dist"));
|
|
246
|
-
const entryResults = [];
|
|
247
|
-
for (const entry of entriesResult.entries) {
|
|
248
|
-
const result = yield* Effect.result(bundleEntry(entry, config, cwd));
|
|
249
|
-
if (Result.isFailure(result)) {
|
|
250
|
-
const err = result.failure;
|
|
251
|
-
entryResults.push({
|
|
252
|
-
success: false,
|
|
253
|
-
error: err.cause instanceof Error ? err.cause.message : String(err.cause)
|
|
254
|
-
});
|
|
255
|
-
} else entryResults.push(result.success);
|
|
256
|
-
}
|
|
257
|
-
yield* writeFile(resolve(cwd, "dist/package.json"), "{ \"type\": \"module\" }");
|
|
258
|
-
const duration = Date.now() - startTime;
|
|
259
|
-
const success = entryResults.every((r) => r.success);
|
|
260
|
-
if (!success) return {
|
|
261
|
-
success,
|
|
262
|
-
entries: entryResults,
|
|
263
|
-
duration,
|
|
264
|
-
error: "One or more entries failed to build"
|
|
265
|
-
};
|
|
266
|
-
return {
|
|
267
|
-
success,
|
|
268
|
-
entries: entryResults,
|
|
269
|
-
duration
|
|
270
|
-
};
|
|
271
|
-
}),
|
|
272
|
-
/* v8 ignore stop */
|
|
273
|
-
bundle: (entry, config) => bundleEntry(entry, config, process.cwd()),
|
|
274
|
-
clean: (outputDir) => cleanDirectory(outputDir),
|
|
275
|
-
formatResult: formatBuildResult,
|
|
276
|
-
formatBytes
|
|
277
|
-
};
|
|
278
|
-
}));
|
|
279
|
-
/* v8 ignore stop */
|
|
280
|
-
|
|
281
|
-
//#endregion
|
|
282
|
-
export { BuildServiceLive };
|
package/services/config-live.js
DELETED
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
import { ConfigInvalid, ConfigLoadFailed, ConfigNotFound, MainEntryMissing, WorkerEntryInvalidName, WorkerEntryMissing } from "../errors.js";
|
|
2
|
-
import { defineConfig } from "../schemas/config.js";
|
|
3
|
-
import { ConfigService } from "./config.js";
|
|
4
|
-
import { Effect, Layer } from "effect";
|
|
5
|
-
import { existsSync } from "node:fs";
|
|
6
|
-
import { resolve } from "node:path";
|
|
7
|
-
import { createJiti } from "jiti";
|
|
8
|
-
|
|
9
|
-
//#region src/services/config-live.ts
|
|
10
|
-
/**
|
|
11
|
-
* ConfigService Layer implementation.
|
|
12
|
-
*
|
|
13
|
-
*/
|
|
14
|
-
const CONFIG_FILENAMES = [
|
|
15
|
-
"action.config.ts",
|
|
16
|
-
"action.config.js",
|
|
17
|
-
"action.config.mjs"
|
|
18
|
-
];
|
|
19
|
-
const DEFAULT_ENTRIES = {
|
|
20
|
-
main: "src/main.ts",
|
|
21
|
-
pre: "src/pre.ts",
|
|
22
|
-
post: "src/post.ts"
|
|
23
|
-
};
|
|
24
|
-
/** Lifecycle bundle names a worker entry must not reuse — they own `dist/main.js` etc. */
|
|
25
|
-
const RESERVED_ENTRY_NAMES = /* @__PURE__ */ new Set([
|
|
26
|
-
"main",
|
|
27
|
-
"pre",
|
|
28
|
-
"post"
|
|
29
|
-
]);
|
|
30
|
-
/**
|
|
31
|
-
* Find config file in the given directory.
|
|
32
|
-
*/
|
|
33
|
-
function findConfigFile(cwd) {
|
|
34
|
-
for (const filename of CONFIG_FILENAMES) {
|
|
35
|
-
const configPath = resolve(cwd, filename);
|
|
36
|
-
if (existsSync(configPath)) return configPath;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
/**
|
|
40
|
-
* Detect a single optional entry.
|
|
41
|
-
*/
|
|
42
|
-
function detectOptionalEntry(cwd, type, explicitPath) {
|
|
43
|
-
const defaultPath = DEFAULT_ENTRIES[type];
|
|
44
|
-
const absolutePath = resolve(cwd, explicitPath ?? defaultPath);
|
|
45
|
-
if (existsSync(absolutePath)) return {
|
|
46
|
-
type,
|
|
47
|
-
path: absolutePath,
|
|
48
|
-
output: `dist/${type}.js`
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
/**
|
|
52
|
-
* Live implementation of ConfigService.
|
|
53
|
-
*/
|
|
54
|
-
const ConfigServiceLive = Layer.succeed(ConfigService, {
|
|
55
|
-
load: (options = {}) => Effect.gen(function* () {
|
|
56
|
-
const cwd = options.cwd ?? process.cwd();
|
|
57
|
-
const configPath = options.configPath ?? findConfigFile(cwd);
|
|
58
|
-
if (!configPath) return {
|
|
59
|
-
config: defineConfig({}),
|
|
60
|
-
usingDefaults: true
|
|
61
|
-
};
|
|
62
|
-
/* v8 ignore start - requires explicit configPath to non-existent file */
|
|
63
|
-
if (!existsSync(configPath)) return yield* Effect.fail(new ConfigNotFound({
|
|
64
|
-
path: configPath,
|
|
65
|
-
message: "Specified config file does not exist"
|
|
66
|
-
}));
|
|
67
|
-
/* v8 ignore stop */
|
|
68
|
-
const absolutePath = resolve(cwd, configPath);
|
|
69
|
-
/* v8 ignore stop */
|
|
70
|
-
const configInput = (yield* Effect.tryPromise({
|
|
71
|
-
try: async () => {
|
|
72
|
-
if (absolutePath.endsWith(".ts")) return createJiti(absolutePath, { interopDefault: true }).import(absolutePath);
|
|
73
|
-
return import(absolutePath);
|
|
74
|
-
},
|
|
75
|
-
catch: (error) => new ConfigLoadFailed({
|
|
76
|
-
path: configPath,
|
|
77
|
-
cause: error
|
|
78
|
-
})
|
|
79
|
-
})).default;
|
|
80
|
-
/* v8 ignore start - requires config file with non-object default export */
|
|
81
|
-
if (!configInput || typeof configInput !== "object") return yield* Effect.fail(new ConfigInvalid({
|
|
82
|
-
path: configPath,
|
|
83
|
-
errors: ["Config file must export a default configuration object"]
|
|
84
|
-
}));
|
|
85
|
-
return {
|
|
86
|
-
config: defineConfig(configInput),
|
|
87
|
-
configPath,
|
|
88
|
-
usingDefaults: false
|
|
89
|
-
};
|
|
90
|
-
}),
|
|
91
|
-
resolve: (input = {}) => Effect.succeed(defineConfig(input)),
|
|
92
|
-
detectEntries: (cwd, entries) => Effect.gen(function* () {
|
|
93
|
-
const detected = [];
|
|
94
|
-
const mainPath = entries?.main ?? DEFAULT_ENTRIES.main;
|
|
95
|
-
const absoluteMainPath = resolve(cwd, mainPath);
|
|
96
|
-
if (!existsSync(absoluteMainPath)) return yield* Effect.fail(new MainEntryMissing({
|
|
97
|
-
expectedPath: mainPath,
|
|
98
|
-
cwd
|
|
99
|
-
}));
|
|
100
|
-
detected.push({
|
|
101
|
-
type: "main",
|
|
102
|
-
path: absoluteMainPath,
|
|
103
|
-
output: "dist/main.js"
|
|
104
|
-
});
|
|
105
|
-
const preEntry = detectOptionalEntry(cwd, "pre", entries?.pre);
|
|
106
|
-
if (preEntry) detected.push(preEntry);
|
|
107
|
-
const postEntry = detectOptionalEntry(cwd, "post", entries?.post);
|
|
108
|
-
if (postEntry) detected.push(postEntry);
|
|
109
|
-
for (const [name, workerPath] of Object.entries(entries?.workers ?? {})) {
|
|
110
|
-
if (RESERVED_ENTRY_NAMES.has(name)) return yield* Effect.fail(new WorkerEntryInvalidName({
|
|
111
|
-
workerName: name,
|
|
112
|
-
reason: `"${name}" is a reserved lifecycle bundle name (main/pre/post)`
|
|
113
|
-
}));
|
|
114
|
-
if (name.length === 0 || name.includes("/") || name.includes("\\") || name.includes("..")) return yield* Effect.fail(new WorkerEntryInvalidName({
|
|
115
|
-
workerName: name,
|
|
116
|
-
reason: "worker names must be non-empty and free of path separators"
|
|
117
|
-
}));
|
|
118
|
-
const absoluteWorkerPath = resolve(cwd, workerPath);
|
|
119
|
-
if (!existsSync(absoluteWorkerPath)) return yield* Effect.fail(new WorkerEntryMissing({
|
|
120
|
-
workerName: name,
|
|
121
|
-
expectedPath: workerPath,
|
|
122
|
-
cwd
|
|
123
|
-
}));
|
|
124
|
-
detected.push({
|
|
125
|
-
type: name,
|
|
126
|
-
path: absoluteWorkerPath,
|
|
127
|
-
output: `dist/${name}.js`
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
return {
|
|
131
|
-
success: true,
|
|
132
|
-
entries: detected
|
|
133
|
-
};
|
|
134
|
-
})
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
//#endregion
|
|
138
|
-
export { ConfigServiceLive };
|
|
@@ -1,211 +0,0 @@
|
|
|
1
|
-
import { ActionYmlPathError, PersistLocalError } from "../errors.js";
|
|
2
|
-
import { PersistLocalService } from "./persist-local.js";
|
|
3
|
-
import { Effect, Layer } from "effect";
|
|
4
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
5
|
-
import { dirname, join, relative, resolve } from "node:path";
|
|
6
|
-
import { createHash } from "node:crypto";
|
|
7
|
-
import { Yaml } from "@effected/yaml";
|
|
8
|
-
|
|
9
|
-
//#region src/services/persist-local-live.ts
|
|
10
|
-
/**
|
|
11
|
-
* PersistLocalService Layer implementation.
|
|
12
|
-
*
|
|
13
|
-
*/
|
|
14
|
-
/**
|
|
15
|
-
* Compute SHA-256 hash of a file's contents.
|
|
16
|
-
*/
|
|
17
|
-
function fileHash(filePath) {
|
|
18
|
-
const content = readFileSync(filePath);
|
|
19
|
-
return createHash("sha256").update(content).digest("hex");
|
|
20
|
-
}
|
|
21
|
-
/**
|
|
22
|
-
* Sync a single file from src to dest using hash comparison.
|
|
23
|
-
* Returns true if the file was copied, false if skipped.
|
|
24
|
-
*/
|
|
25
|
-
function syncFile(src, dest) {
|
|
26
|
-
if (existsSync(dest)) {
|
|
27
|
-
if (fileHash(src) === fileHash(dest)) return false;
|
|
28
|
-
}
|
|
29
|
-
mkdirSync(dirname(dest), { recursive: true });
|
|
30
|
-
copyFileSync(src, dest);
|
|
31
|
-
return true;
|
|
32
|
-
}
|
|
33
|
-
/**
|
|
34
|
-
* Recursively collect all file paths relative to a base directory.
|
|
35
|
-
*/
|
|
36
|
-
function walkDirectory(dir, base = dir) {
|
|
37
|
-
const files = [];
|
|
38
|
-
if (!existsSync(dir)) return files;
|
|
39
|
-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
40
|
-
const fullPath = join(dir, entry.name);
|
|
41
|
-
if (entry.isDirectory()) files.push(...walkDirectory(fullPath, base));
|
|
42
|
-
else files.push(relative(base, fullPath));
|
|
43
|
-
}
|
|
44
|
-
return files;
|
|
45
|
-
}
|
|
46
|
-
/**
|
|
47
|
-
* Smart-sync a directory: copy changed files, remove stale dest files.
|
|
48
|
-
*/
|
|
49
|
-
function syncDirectory(srcDir, destDir) {
|
|
50
|
-
const stats = {
|
|
51
|
-
copied: 0,
|
|
52
|
-
skipped: 0
|
|
53
|
-
};
|
|
54
|
-
const srcFiles = walkDirectory(srcDir);
|
|
55
|
-
for (const relPath of srcFiles) if (syncFile(join(srcDir, relPath), join(destDir, relPath))) stats.copied++;
|
|
56
|
-
else stats.skipped++;
|
|
57
|
-
const srcFileSet = new Set(srcFiles);
|
|
58
|
-
const destFiles = walkDirectory(destDir);
|
|
59
|
-
for (const relPath of destFiles) if (!srcFileSet.has(relPath)) {
|
|
60
|
-
rmSync(join(destDir, relPath), { force: true });
|
|
61
|
-
let parent = dirname(join(destDir, relPath));
|
|
62
|
-
while (parent !== destDir && existsSync(parent)) if (readdirSync(parent).length === 0) {
|
|
63
|
-
rmSync(parent, { recursive: true });
|
|
64
|
-
parent = dirname(parent);
|
|
65
|
-
} else break;
|
|
66
|
-
}
|
|
67
|
-
return stats;
|
|
68
|
-
}
|
|
69
|
-
/**
|
|
70
|
-
* Validate that action.yml runs paths resolve correctly relative to the destination.
|
|
71
|
-
*/
|
|
72
|
-
function validateActionYmlPaths(actionYmlPath, destDir) {
|
|
73
|
-
return Effect.gen(function* () {
|
|
74
|
-
if (!existsSync(actionYmlPath)) return;
|
|
75
|
-
const content = readFileSync(actionYmlPath, "utf8");
|
|
76
|
-
const parsed = yield* Yaml.parse(content).pipe(Effect.catch(() => Effect.succeed(null)));
|
|
77
|
-
if (!parsed?.runs) return;
|
|
78
|
-
for (const entryType of [
|
|
79
|
-
"main",
|
|
80
|
-
"pre",
|
|
81
|
-
"post"
|
|
82
|
-
]) {
|
|
83
|
-
const specifiedPath = parsed.runs[entryType];
|
|
84
|
-
if (!specifiedPath) continue;
|
|
85
|
-
const expectedPath = resolve(destDir, specifiedPath);
|
|
86
|
-
if (!existsSync(expectedPath)) return yield* Effect.fail(new ActionYmlPathError({
|
|
87
|
-
entryType,
|
|
88
|
-
specifiedPath,
|
|
89
|
-
expectedPath
|
|
90
|
-
}));
|
|
91
|
-
}
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
const ACTRC_CONTENT = `--container-architecture linux/amd64
|
|
95
|
-
-W .github/workflows/act-test.yml
|
|
96
|
-
`;
|
|
97
|
-
const ACT_WORKFLOW_CONTENT = `name: Local Test
|
|
98
|
-
on:
|
|
99
|
-
workflow_dispatch:
|
|
100
|
-
|
|
101
|
-
jobs:
|
|
102
|
-
test:
|
|
103
|
-
runs-on: ubuntu-latest
|
|
104
|
-
steps:
|
|
105
|
-
- uses: actions/checkout@v7
|
|
106
|
-
- uses: ./.github/actions/local
|
|
107
|
-
`;
|
|
108
|
-
function formatPersistResult(result) {
|
|
109
|
-
const lines = [];
|
|
110
|
-
if (result.success) {
|
|
111
|
-
lines.push("Persist Local Summary:");
|
|
112
|
-
lines.push(` Output: ${result.outputPath}`);
|
|
113
|
-
lines.push(` Files copied: ${result.filesCopied}`);
|
|
114
|
-
lines.push(` Files skipped (unchanged): ${result.filesSkipped}`);
|
|
115
|
-
if (result.actTemplateGenerated) lines.push(" Act template files generated");
|
|
116
|
-
} else lines.push(`Persist Local Failed: ${result.error}`);
|
|
117
|
-
return lines.join("\n");
|
|
118
|
-
}
|
|
119
|
-
/**
|
|
120
|
-
* Live implementation of PersistLocalService.
|
|
121
|
-
*/
|
|
122
|
-
const PersistLocalServiceLive = Layer.succeed(PersistLocalService, {
|
|
123
|
-
persist: (config, options = {}) => Effect.gen(function* () {
|
|
124
|
-
const cwd = options.cwd ?? process.cwd();
|
|
125
|
-
const outputPath = resolve(cwd, config.persistLocal.path);
|
|
126
|
-
if (!config.persistLocal.enabled) return {
|
|
127
|
-
success: true,
|
|
128
|
-
filesCopied: 0,
|
|
129
|
-
filesSkipped: 0,
|
|
130
|
-
actTemplateGenerated: false,
|
|
131
|
-
outputPath
|
|
132
|
-
};
|
|
133
|
-
yield* Effect.try({
|
|
134
|
-
try: () => mkdirSync(outputPath, { recursive: true }),
|
|
135
|
-
/* v8 ignore next 5 - error branch requires fs permission failures */
|
|
136
|
-
catch: (error) => new PersistLocalError({
|
|
137
|
-
path: outputPath,
|
|
138
|
-
cause: error
|
|
139
|
-
})
|
|
140
|
-
});
|
|
141
|
-
let totalCopied = 0;
|
|
142
|
-
let totalSkipped = 0;
|
|
143
|
-
const actionYmlSrc = resolve(cwd, "action.yml");
|
|
144
|
-
const actionYmlDest = resolve(outputPath, "action.yml");
|
|
145
|
-
if (existsSync(actionYmlSrc)) if (yield* Effect.try({
|
|
146
|
-
try: () => syncFile(actionYmlSrc, actionYmlDest),
|
|
147
|
-
/* v8 ignore next 5 - error branch requires fs permission failures */
|
|
148
|
-
catch: (error) => new PersistLocalError({
|
|
149
|
-
path: actionYmlSrc,
|
|
150
|
-
cause: error
|
|
151
|
-
})
|
|
152
|
-
})) totalCopied++;
|
|
153
|
-
else totalSkipped++;
|
|
154
|
-
else if (existsSync(actionYmlDest)) rmSync(actionYmlDest, { force: true });
|
|
155
|
-
const distSrc = resolve(cwd, "dist");
|
|
156
|
-
if (existsSync(distSrc) && statSync(distSrc).isDirectory()) {
|
|
157
|
-
const distStats = yield* Effect.try({
|
|
158
|
-
try: () => syncDirectory(distSrc, resolve(outputPath, "dist")),
|
|
159
|
-
/* v8 ignore next 5 - error branch requires fs permission failures */
|
|
160
|
-
catch: (error) => new PersistLocalError({
|
|
161
|
-
path: distSrc,
|
|
162
|
-
cause: error
|
|
163
|
-
})
|
|
164
|
-
});
|
|
165
|
-
totalCopied += distStats.copied;
|
|
166
|
-
totalSkipped += distStats.skipped;
|
|
167
|
-
}
|
|
168
|
-
yield* validateActionYmlPaths(resolve(outputPath, "action.yml"), outputPath);
|
|
169
|
-
let actTemplateGenerated = false;
|
|
170
|
-
if (config.persistLocal.actTemplate) {
|
|
171
|
-
const actrcPath = resolve(cwd, ".actrc");
|
|
172
|
-
const actWorkflowPath = resolve(cwd, ".github/workflows/act-test.yml");
|
|
173
|
-
if (!existsSync(actrcPath)) {
|
|
174
|
-
yield* Effect.try({
|
|
175
|
-
try: () => writeFileSync(actrcPath, ACTRC_CONTENT, "utf8"),
|
|
176
|
-
/* v8 ignore next 5 - error branch requires fs permission failures */
|
|
177
|
-
catch: (error) => new PersistLocalError({
|
|
178
|
-
path: actrcPath,
|
|
179
|
-
cause: error
|
|
180
|
-
})
|
|
181
|
-
});
|
|
182
|
-
actTemplateGenerated = true;
|
|
183
|
-
}
|
|
184
|
-
if (!existsSync(actWorkflowPath)) {
|
|
185
|
-
yield* Effect.try({
|
|
186
|
-
try: () => {
|
|
187
|
-
mkdirSync(dirname(actWorkflowPath), { recursive: true });
|
|
188
|
-
writeFileSync(actWorkflowPath, ACT_WORKFLOW_CONTENT, "utf8");
|
|
189
|
-
},
|
|
190
|
-
/* v8 ignore next 5 - error branch requires fs permission failures */
|
|
191
|
-
catch: (error) => new PersistLocalError({
|
|
192
|
-
path: actWorkflowPath,
|
|
193
|
-
cause: error
|
|
194
|
-
})
|
|
195
|
-
});
|
|
196
|
-
actTemplateGenerated = true;
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
return {
|
|
200
|
-
success: true,
|
|
201
|
-
filesCopied: totalCopied,
|
|
202
|
-
filesSkipped: totalSkipped,
|
|
203
|
-
actTemplateGenerated,
|
|
204
|
-
outputPath
|
|
205
|
-
};
|
|
206
|
-
}),
|
|
207
|
-
formatResult: formatPersistResult
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
//#endregion
|
|
211
|
-
export { PersistLocalServiceLive };
|