clawchef 0.1.3 → 0.1.5
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/README.md +17 -0
- package/dist/api.d.ts +1 -0
- package/dist/api.js +5 -2
- package/dist/cli.js +8 -0
- package/dist/env.d.ts +1 -0
- package/dist/env.js +50 -1
- package/dist/index.js +0 -2
- package/dist/recipe.js +130 -31
- package/package.json +1 -1
- package/src/api.ts +5 -2
- package/src/cli.ts +8 -0
- package/src/env.ts +52 -1
- package/src/index.ts +0 -2
- package/src/recipe.ts +169 -28
package/README.md
CHANGED
|
@@ -8,6 +8,7 @@ Recipe-driven OpenClaw environment orchestrator.
|
|
|
8
8
|
- Accepts recipe input from local file/dir/archive and HTTP URL/archive.
|
|
9
9
|
- Resolves `${var}` parameters from `--var`, environment, and defaults.
|
|
10
10
|
- Auto-loads environment variables from `.env` in the current working directory.
|
|
11
|
+
- Supports loading env vars from a custom `.env` path/URL via `--env-file`.
|
|
11
12
|
- Requires secrets to be injected via `--var` / `CLAWCHEF_VAR_*` (no inline secrets in recipe).
|
|
12
13
|
- Prepares OpenClaw version (install or reuse).
|
|
13
14
|
- When installed OpenClaw version mismatches recipe version, prompts: ignore / abort / force reinstall (silent mode auto-picks force reinstall).
|
|
@@ -39,6 +40,19 @@ Run recipe from URL:
|
|
|
39
40
|
clawchef cook https://example.com/recipes/sample.yaml --provider remote
|
|
40
41
|
```
|
|
41
42
|
|
|
43
|
+
Run with custom env file (local path or HTTP URL):
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
clawchef cook recipes/sample.yaml --env-file ./.env.staging
|
|
47
|
+
clawchef cook recipes/sample.yaml --env-file https://example.com/envs/staging.env
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Run recipe from GitHub repository root (`recipe.yaml` at repo root):
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
clawchef cook https://github.com/renorzr/loom-plus-recipe
|
|
54
|
+
```
|
|
55
|
+
|
|
42
56
|
Run recipe from archive (default `recipe.yaml`):
|
|
43
57
|
|
|
44
58
|
```bash
|
|
@@ -184,6 +198,7 @@ await scaffold("./my-recipe-project", {
|
|
|
184
198
|
- `plugins`: plugin npm specs to preinstall for this run (`string[]`)
|
|
185
199
|
- `provider`: `command | remote | mock`
|
|
186
200
|
- `remote`: remote provider config (same fields as CLI remote flags)
|
|
201
|
+
- `envFile`: custom env file path/URL; when set, default cwd `.env` loading is skipped
|
|
187
202
|
- `dryRun`, `allowMissing`, `verbose`
|
|
188
203
|
- `silent` (default: `true` in Node API)
|
|
189
204
|
- `loadDotEnvFromCwd` (default: `true`)
|
|
@@ -206,6 +221,7 @@ Notes:
|
|
|
206
221
|
If `params.<key>.required: true` and no value is found, run fails.
|
|
207
222
|
|
|
208
223
|
If `.env` exists in the directory where `clawchef` is executed, it is loaded before recipe parsing.
|
|
224
|
+
If `--env-file` (or Node API `envFile`) is provided, only that env source is loaded.
|
|
209
225
|
|
|
210
226
|
## Recipe reference formats
|
|
211
227
|
|
|
@@ -219,6 +235,7 @@ If `.env` exists in the directory where `clawchef` is executed, it is loaded bef
|
|
|
219
235
|
- `https://host/recipe.yaml`
|
|
220
236
|
- `https://host/archive.zip` (loads `recipe.yaml` from archive)
|
|
221
237
|
- `https://host/archive.zip:custom/recipe.yaml`
|
|
238
|
+
- `https://github.com/<owner>/<repo>` (loads `recipe.yaml` from repo root)
|
|
222
239
|
|
|
223
240
|
Supported archives: `.zip`, `.tar`, `.tar.gz`, `.tgz`.
|
|
224
241
|
|
package/dist/api.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export interface CookOptions {
|
|
|
9
9
|
silent?: boolean;
|
|
10
10
|
provider?: OpenClawProvider;
|
|
11
11
|
remote?: Partial<OpenClawRemoteConfig>;
|
|
12
|
+
envFile?: string;
|
|
12
13
|
loadDotEnvFromCwd?: boolean;
|
|
13
14
|
}
|
|
14
15
|
export declare function cook(recipeRef: string, options?: CookOptions): Promise<void>;
|
package/dist/api.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import YAML from "js-yaml";
|
|
2
2
|
import { ClawChefError } from "./errors.js";
|
|
3
|
-
import { importDotEnvFromCwd } from "./env.js";
|
|
3
|
+
import { importDotEnvFromCwd, importDotEnvFromRef } from "./env.js";
|
|
4
4
|
import { Logger } from "./logger.js";
|
|
5
5
|
import { runRecipe } from "./orchestrator.js";
|
|
6
6
|
import { loadRecipe, loadRecipeText } from "./recipe.js";
|
|
@@ -21,7 +21,10 @@ function normalizeCookOptions(options) {
|
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
23
|
export async function cook(recipeRef, options = {}) {
|
|
24
|
-
if (options.
|
|
24
|
+
if (options.envFile) {
|
|
25
|
+
await importDotEnvFromRef(options.envFile);
|
|
26
|
+
}
|
|
27
|
+
else if (options.loadDotEnvFromCwd ?? true) {
|
|
25
28
|
importDotEnvFromCwd();
|
|
26
29
|
}
|
|
27
30
|
const runOptions = normalizeCookOptions(options);
|
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { ClawChefError } from "./errors.js";
|
|
3
|
+
import { importDotEnvFromCwd, importDotEnvFromRef } from "./env.js";
|
|
3
4
|
import { Logger } from "./logger.js";
|
|
4
5
|
import { runRecipe } from "./orchestrator.js";
|
|
5
6
|
import { loadRecipe, loadRecipeText } from "./recipe.js";
|
|
@@ -78,6 +79,7 @@ export function buildCli() {
|
|
|
78
79
|
.option("--verbose", "Verbose logging", false)
|
|
79
80
|
.option("-s, --silent", "Skip reset confirmation prompt", false)
|
|
80
81
|
.option("--keep-openclaw-state", "Preserve existing OpenClaw state (skip factory reset)", false)
|
|
82
|
+
.option("--env-file <path-or-url>", "Load env vars from local file or HTTP URL")
|
|
81
83
|
.option("--provider <provider>", "Execution provider: command | remote | mock")
|
|
82
84
|
.option("--plugin <npm-spec>", "Preinstall plugin package (repeatable)", (v, p) => p.concat([v]), [])
|
|
83
85
|
.option("--remote-base-url <url>", "Remote OpenClaw API base URL")
|
|
@@ -87,6 +89,12 @@ export function buildCli() {
|
|
|
87
89
|
.option("--remote-timeout-ms <ms>", "Remote operation timeout in milliseconds")
|
|
88
90
|
.option("--remote-operation-path <path>", "Remote operation endpoint path")
|
|
89
91
|
.action(async (recipeRef, opts) => {
|
|
92
|
+
if (opts.envFile) {
|
|
93
|
+
await importDotEnvFromRef(String(opts.envFile));
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
importDotEnvFromCwd();
|
|
97
|
+
}
|
|
90
98
|
const provider = parseProvider(opts.provider ?? readEnv("CLAWCHEF_PROVIDER") ?? "command");
|
|
91
99
|
const options = {
|
|
92
100
|
vars: parseVarFlags(opts.var),
|
package/dist/env.d.ts
CHANGED
package/dist/env.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
3
|
-
import { config as loadDotenv } from "dotenv";
|
|
4
|
+
import { config as loadDotenv, parse as parseDotenv } from "dotenv";
|
|
4
5
|
import { ClawChefError } from "./errors.js";
|
|
5
6
|
export function importDotEnvFromCwd() {
|
|
6
7
|
const envPath = path.resolve(process.cwd(), ".env");
|
|
@@ -12,3 +13,51 @@ export function importDotEnvFromCwd() {
|
|
|
12
13
|
throw new ClawChefError(`Failed to load .env from current directory: ${result.error.message}`);
|
|
13
14
|
}
|
|
14
15
|
}
|
|
16
|
+
function isHttpUrl(value) {
|
|
17
|
+
try {
|
|
18
|
+
const url = new URL(value);
|
|
19
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function applyEnv(entries) {
|
|
26
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
27
|
+
if (process.env[key] === undefined) {
|
|
28
|
+
process.env[key] = value;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export async function importDotEnvFromRef(ref) {
|
|
33
|
+
const trimmed = ref.trim();
|
|
34
|
+
if (!trimmed) {
|
|
35
|
+
throw new ClawChefError("--env-file cannot be empty");
|
|
36
|
+
}
|
|
37
|
+
if (isHttpUrl(trimmed)) {
|
|
38
|
+
let response;
|
|
39
|
+
try {
|
|
40
|
+
response = await fetch(trimmed);
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
44
|
+
throw new ClawChefError(`Failed to fetch env file URL ${trimmed}: ${message}`);
|
|
45
|
+
}
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
throw new ClawChefError(`Failed to fetch env file URL ${trimmed}: HTTP ${response.status}`);
|
|
48
|
+
}
|
|
49
|
+
const content = await response.text();
|
|
50
|
+
applyEnv(parseDotenv(content));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const envPath = path.resolve(trimmed);
|
|
54
|
+
let content;
|
|
55
|
+
try {
|
|
56
|
+
content = await readFile(envPath, "utf8");
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
60
|
+
throw new ClawChefError(`Failed to load env file ${envPath}: ${message}`);
|
|
61
|
+
}
|
|
62
|
+
applyEnv(parseDotenv(content));
|
|
63
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { buildCli } from "./cli.js";
|
|
3
|
-
import { importDotEnvFromCwd } from "./env.js";
|
|
4
3
|
import { ClawChefError } from "./errors.js";
|
|
5
4
|
async function main() {
|
|
6
|
-
importDotEnvFromCwd();
|
|
7
5
|
const program = buildCli();
|
|
8
6
|
await program.parseAsync(process.argv);
|
|
9
7
|
}
|
package/dist/recipe.js
CHANGED
|
@@ -2,7 +2,7 @@ import path from "node:path";
|
|
|
2
2
|
import process from "node:process";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
|
-
import { readFile, mkdtemp, stat, writeFile, rm, mkdir } from "node:fs/promises";
|
|
5
|
+
import { readFile, mkdtemp, stat, writeFile, rm, mkdir, readdir } from "node:fs/promises";
|
|
6
6
|
import YAML from "js-yaml";
|
|
7
7
|
import { recipeSchema } from "./schema.js";
|
|
8
8
|
import { ClawChefError } from "./errors.js";
|
|
@@ -245,6 +245,128 @@ function parseUrlReference(input) {
|
|
|
245
245
|
directUrl: parsed.toString(),
|
|
246
246
|
};
|
|
247
247
|
}
|
|
248
|
+
function parseGitHubRepoReference(input) {
|
|
249
|
+
let parsed;
|
|
250
|
+
try {
|
|
251
|
+
parsed = new URL(input);
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
259
|
+
if (parsed.hostname.toLowerCase() !== "github.com") {
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
262
|
+
if (parsed.search || parsed.hash) {
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
const parts = parsed.pathname.split("/").filter((part) => part.length > 0);
|
|
266
|
+
if (parts.length !== 2) {
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
const owner = parts[0];
|
|
270
|
+
const repo = parts[1].endsWith(".git") ? parts[1].slice(0, -4) : parts[1];
|
|
271
|
+
if (!owner || !repo) {
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
return { owner, repo };
|
|
275
|
+
}
|
|
276
|
+
async function resolveRecipePathFromExtracted(extractDir, recipeInArchive, missingMessage) {
|
|
277
|
+
const directPath = path.join(extractDir, recipeInArchive);
|
|
278
|
+
try {
|
|
279
|
+
const directStat = await stat(directPath);
|
|
280
|
+
if (directStat.isFile()) {
|
|
281
|
+
return directPath;
|
|
282
|
+
}
|
|
283
|
+
throw new ClawChefError(`Recipe in archive is not a file: ${recipeInArchive}`);
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
if (err instanceof ClawChefError) {
|
|
287
|
+
throw err;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const entries = await readdir(extractDir, { withFileTypes: true });
|
|
291
|
+
const matched = [];
|
|
292
|
+
for (const entry of entries) {
|
|
293
|
+
if (!entry.isDirectory()) {
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const candidate = path.join(extractDir, entry.name, recipeInArchive);
|
|
297
|
+
try {
|
|
298
|
+
const s = await stat(candidate);
|
|
299
|
+
if (s.isFile()) {
|
|
300
|
+
matched.push(candidate);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (matched.length === 1) {
|
|
308
|
+
return matched[0];
|
|
309
|
+
}
|
|
310
|
+
if (matched.length > 1) {
|
|
311
|
+
throw new ClawChefError(`Multiple recipe files found in extracted archive for ${recipeInArchive}; provide explicit selector`);
|
|
312
|
+
}
|
|
313
|
+
throw new ClawChefError(missingMessage);
|
|
314
|
+
}
|
|
315
|
+
async function resolveGitHubRepoReference(recipeInput) {
|
|
316
|
+
const repoRef = parseGitHubRepoReference(recipeInput);
|
|
317
|
+
if (!repoRef) {
|
|
318
|
+
return undefined;
|
|
319
|
+
}
|
|
320
|
+
const repoApiUrl = `https://api.github.com/repos/${repoRef.owner}/${repoRef.repo}`;
|
|
321
|
+
const repoHeaders = {
|
|
322
|
+
Accept: "application/vnd.github+json",
|
|
323
|
+
"User-Agent": "clawchef",
|
|
324
|
+
};
|
|
325
|
+
let repoResponse;
|
|
326
|
+
try {
|
|
327
|
+
repoResponse = await fetch(repoApiUrl, { headers: repoHeaders });
|
|
328
|
+
}
|
|
329
|
+
catch (err) {
|
|
330
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
331
|
+
throw new ClawChefError(`Failed to fetch GitHub repository metadata ${repoApiUrl}: ${message}`);
|
|
332
|
+
}
|
|
333
|
+
if (!repoResponse.ok) {
|
|
334
|
+
throw new ClawChefError(`Failed to fetch GitHub repository metadata ${repoApiUrl}: HTTP ${repoResponse.status}`);
|
|
335
|
+
}
|
|
336
|
+
const repoJson = (await repoResponse.json());
|
|
337
|
+
const defaultBranch = repoJson.default_branch?.trim();
|
|
338
|
+
if (!defaultBranch) {
|
|
339
|
+
throw new ClawChefError(`GitHub repository ${repoRef.owner}/${repoRef.repo} has no default branch`);
|
|
340
|
+
}
|
|
341
|
+
const tarballUrl = `https://api.github.com/repos/${repoRef.owner}/${repoRef.repo}/tarball/${encodeURIComponent(defaultBranch)}`;
|
|
342
|
+
let tarballResponse;
|
|
343
|
+
try {
|
|
344
|
+
tarballResponse = await fetch(tarballUrl, { headers: repoHeaders });
|
|
345
|
+
}
|
|
346
|
+
catch (err) {
|
|
347
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
348
|
+
throw new ClawChefError(`Failed to download GitHub repository tarball ${tarballUrl}: ${message}`);
|
|
349
|
+
}
|
|
350
|
+
if (!tarballResponse.ok) {
|
|
351
|
+
throw new ClawChefError(`Failed to download GitHub repository tarball ${tarballUrl}: HTTP ${tarballResponse.status}`);
|
|
352
|
+
}
|
|
353
|
+
const tempDir = await mkdtemp(path.join(tmpdir(), "clawchef-recipe-"));
|
|
354
|
+
const archivePath = path.join(tempDir, "repo.tar.gz");
|
|
355
|
+
await writeFile(archivePath, Buffer.from(await tarballResponse.arrayBuffer()));
|
|
356
|
+
const extractDir = path.join(tempDir, "extracted");
|
|
357
|
+
await mkdir(extractDir, { recursive: true });
|
|
358
|
+
await extractArchive(archivePath, extractDir);
|
|
359
|
+
const recipePath = await resolveRecipePathFromExtracted(extractDir, DEFAULT_RECIPE_FILE, `Recipe file not found in GitHub repository root: ${DEFAULT_RECIPE_FILE}`);
|
|
360
|
+
return {
|
|
361
|
+
recipePath,
|
|
362
|
+
origin: {
|
|
363
|
+
kind: "local",
|
|
364
|
+
recipePath,
|
|
365
|
+
recipeDir: path.dirname(recipePath),
|
|
366
|
+
},
|
|
367
|
+
cleanupPaths: [tempDir],
|
|
368
|
+
};
|
|
369
|
+
}
|
|
248
370
|
async function runCommand(command, args) {
|
|
249
371
|
await new Promise((resolve, reject) => {
|
|
250
372
|
const child = spawn(command, args, {
|
|
@@ -287,6 +409,10 @@ async function extractArchive(archivePath, extractDir) {
|
|
|
287
409
|
}
|
|
288
410
|
async function resolveRecipeReference(recipeInput) {
|
|
289
411
|
if (isHttpUrl(recipeInput)) {
|
|
412
|
+
const githubResolved = await resolveGitHubRepoReference(recipeInput);
|
|
413
|
+
if (githubResolved) {
|
|
414
|
+
return githubResolved;
|
|
415
|
+
}
|
|
290
416
|
const parsed = parseUrlReference(recipeInput);
|
|
291
417
|
if (parsed.directUrl) {
|
|
292
418
|
let response;
|
|
@@ -334,16 +460,7 @@ async function resolveRecipeReference(recipeInput) {
|
|
|
334
460
|
await rm(extractDir, { recursive: true, force: true });
|
|
335
461
|
await mkdir(extractDir, { recursive: true });
|
|
336
462
|
await extractArchive(downloadedArchivePath, extractDir);
|
|
337
|
-
const recipePath =
|
|
338
|
-
try {
|
|
339
|
-
const s = await stat(recipePath);
|
|
340
|
-
if (!s.isFile()) {
|
|
341
|
-
throw new ClawChefError(`Recipe in archive is not a file: ${recipeInArchive}`);
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
catch {
|
|
345
|
-
throw new ClawChefError(`Recipe file not found in archive: ${recipeInArchive}`);
|
|
346
|
-
}
|
|
463
|
+
const recipePath = await resolveRecipePathFromExtracted(extractDir, recipeInArchive, `Recipe file not found in archive: ${recipeInArchive}`);
|
|
347
464
|
return {
|
|
348
465
|
recipePath,
|
|
349
466
|
origin: {
|
|
@@ -381,16 +498,7 @@ async function resolveRecipeReference(recipeInput) {
|
|
|
381
498
|
const extractDir = path.join(tempDir, "extracted");
|
|
382
499
|
await mkdir(extractDir, { recursive: true });
|
|
383
500
|
await extractArchive(localPath, extractDir);
|
|
384
|
-
const recipePath =
|
|
385
|
-
try {
|
|
386
|
-
const s = await stat(recipePath);
|
|
387
|
-
if (!s.isFile()) {
|
|
388
|
-
throw new ClawChefError(`Recipe in archive is not a file: ${DEFAULT_RECIPE_FILE}`);
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
catch {
|
|
392
|
-
throw new ClawChefError(`Recipe file not found in archive: ${DEFAULT_RECIPE_FILE}`);
|
|
393
|
-
}
|
|
501
|
+
const recipePath = await resolveRecipePathFromExtracted(extractDir, DEFAULT_RECIPE_FILE, `Recipe file not found in archive: ${DEFAULT_RECIPE_FILE}`);
|
|
394
502
|
return {
|
|
395
503
|
recipePath,
|
|
396
504
|
origin: {
|
|
@@ -445,16 +553,7 @@ async function resolveRecipeReference(recipeInput) {
|
|
|
445
553
|
const extractDir = path.join(tempDir, "extracted");
|
|
446
554
|
await mkdir(extractDir, { recursive: true });
|
|
447
555
|
await extractArchive(basePath, extractDir);
|
|
448
|
-
const recipePath =
|
|
449
|
-
try {
|
|
450
|
-
const s = await stat(recipePath);
|
|
451
|
-
if (!s.isFile()) {
|
|
452
|
-
throw new ClawChefError(`Recipe in archive is not a file: ${selector}`);
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
catch {
|
|
456
|
-
throw new ClawChefError(`Recipe file not found in archive: ${selector}`);
|
|
457
|
-
}
|
|
556
|
+
const recipePath = await resolveRecipePathFromExtracted(extractDir, selector, `Recipe file not found in archive: ${selector}`);
|
|
458
557
|
return {
|
|
459
558
|
recipePath,
|
|
460
559
|
origin: {
|
package/package.json
CHANGED
package/src/api.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import YAML from "js-yaml";
|
|
2
2
|
import { ClawChefError } from "./errors.js";
|
|
3
|
-
import { importDotEnvFromCwd } from "./env.js";
|
|
3
|
+
import { importDotEnvFromCwd, importDotEnvFromRef } from "./env.js";
|
|
4
4
|
import { Logger } from "./logger.js";
|
|
5
5
|
import { runRecipe } from "./orchestrator.js";
|
|
6
6
|
import { loadRecipe, loadRecipeText } from "./recipe.js";
|
|
@@ -18,6 +18,7 @@ export interface CookOptions {
|
|
|
18
18
|
silent?: boolean;
|
|
19
19
|
provider?: OpenClawProvider;
|
|
20
20
|
remote?: Partial<OpenClawRemoteConfig>;
|
|
21
|
+
envFile?: string;
|
|
21
22
|
loadDotEnvFromCwd?: boolean;
|
|
22
23
|
}
|
|
23
24
|
|
|
@@ -37,7 +38,9 @@ function normalizeCookOptions(options: CookOptions): RunOptions {
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
export async function cook(recipeRef: string, options: CookOptions = {}): Promise<void> {
|
|
40
|
-
if (options.
|
|
41
|
+
if (options.envFile) {
|
|
42
|
+
await importDotEnvFromRef(options.envFile);
|
|
43
|
+
} else if (options.loadDotEnvFromCwd ?? true) {
|
|
41
44
|
importDotEnvFromCwd();
|
|
42
45
|
}
|
|
43
46
|
|
package/src/cli.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { ClawChefError } from "./errors.js";
|
|
3
|
+
import { importDotEnvFromCwd, importDotEnvFromRef } from "./env.js";
|
|
3
4
|
import { Logger } from "./logger.js";
|
|
4
5
|
import { runRecipe } from "./orchestrator.js";
|
|
5
6
|
import { loadRecipe, loadRecipeText } from "./recipe.js";
|
|
@@ -88,6 +89,7 @@ export function buildCli(): Command {
|
|
|
88
89
|
.option("--verbose", "Verbose logging", false)
|
|
89
90
|
.option("-s, --silent", "Skip reset confirmation prompt", false)
|
|
90
91
|
.option("--keep-openclaw-state", "Preserve existing OpenClaw state (skip factory reset)", false)
|
|
92
|
+
.option("--env-file <path-or-url>", "Load env vars from local file or HTTP URL")
|
|
91
93
|
.option("--provider <provider>", "Execution provider: command | remote | mock")
|
|
92
94
|
.option("--plugin <npm-spec>", "Preinstall plugin package (repeatable)", (v, p: string[]) => p.concat([v]), [])
|
|
93
95
|
.option("--remote-base-url <url>", "Remote OpenClaw API base URL")
|
|
@@ -97,6 +99,12 @@ export function buildCli(): Command {
|
|
|
97
99
|
.option("--remote-timeout-ms <ms>", "Remote operation timeout in milliseconds")
|
|
98
100
|
.option("--remote-operation-path <path>", "Remote operation endpoint path")
|
|
99
101
|
.action(async (recipeRef: string, opts) => {
|
|
102
|
+
if (opts.envFile) {
|
|
103
|
+
await importDotEnvFromRef(String(opts.envFile));
|
|
104
|
+
} else {
|
|
105
|
+
importDotEnvFromCwd();
|
|
106
|
+
}
|
|
107
|
+
|
|
100
108
|
const provider = parseProvider(opts.provider ?? readEnv("CLAWCHEF_PROVIDER") ?? "command");
|
|
101
109
|
const options: RunOptions = {
|
|
102
110
|
vars: parseVarFlags(opts.var),
|
package/src/env.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
3
|
-
import { config as loadDotenv } from "dotenv";
|
|
4
|
+
import { config as loadDotenv, parse as parseDotenv } from "dotenv";
|
|
4
5
|
import { ClawChefError } from "./errors.js";
|
|
5
6
|
|
|
6
7
|
export function importDotEnvFromCwd(): void {
|
|
@@ -14,3 +15,53 @@ export function importDotEnvFromCwd(): void {
|
|
|
14
15
|
throw new ClawChefError(`Failed to load .env from current directory: ${result.error.message}`);
|
|
15
16
|
}
|
|
16
17
|
}
|
|
18
|
+
|
|
19
|
+
function isHttpUrl(value: string): boolean {
|
|
20
|
+
try {
|
|
21
|
+
const url = new URL(value);
|
|
22
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function applyEnv(entries: Record<string, string>): void {
|
|
29
|
+
for (const [key, value] of Object.entries(entries)) {
|
|
30
|
+
if (process.env[key] === undefined) {
|
|
31
|
+
process.env[key] = value;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function importDotEnvFromRef(ref: string): Promise<void> {
|
|
37
|
+
const trimmed = ref.trim();
|
|
38
|
+
if (!trimmed) {
|
|
39
|
+
throw new ClawChefError("--env-file cannot be empty");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (isHttpUrl(trimmed)) {
|
|
43
|
+
let response: Response;
|
|
44
|
+
try {
|
|
45
|
+
response = await fetch(trimmed);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
48
|
+
throw new ClawChefError(`Failed to fetch env file URL ${trimmed}: ${message}`);
|
|
49
|
+
}
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
throw new ClawChefError(`Failed to fetch env file URL ${trimmed}: HTTP ${response.status}`);
|
|
52
|
+
}
|
|
53
|
+
const content = await response.text();
|
|
54
|
+
applyEnv(parseDotenv(content));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const envPath = path.resolve(trimmed);
|
|
59
|
+
let content: string;
|
|
60
|
+
try {
|
|
61
|
+
content = await readFile(envPath, "utf8");
|
|
62
|
+
} catch (err) {
|
|
63
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
64
|
+
throw new ClawChefError(`Failed to load env file ${envPath}: ${message}`);
|
|
65
|
+
}
|
|
66
|
+
applyEnv(parseDotenv(content));
|
|
67
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { buildCli } from "./cli.js";
|
|
3
|
-
import { importDotEnvFromCwd } from "./env.js";
|
|
4
3
|
import { ClawChefError } from "./errors.js";
|
|
5
4
|
|
|
6
5
|
async function main(): Promise<void> {
|
|
7
|
-
importDotEnvFromCwd();
|
|
8
6
|
const program = buildCli();
|
|
9
7
|
await program.parseAsync(process.argv);
|
|
10
8
|
}
|
package/src/recipe.ts
CHANGED
|
@@ -2,7 +2,7 @@ import path from "node:path";
|
|
|
2
2
|
import process from "node:process";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
|
-
import { readFile, mkdtemp, stat, writeFile, rm, mkdir } from "node:fs/promises";
|
|
5
|
+
import { readFile, mkdtemp, stat, writeFile, rm, mkdir, readdir } from "node:fs/promises";
|
|
6
6
|
import YAML from "js-yaml";
|
|
7
7
|
import { recipeSchema } from "./schema.js";
|
|
8
8
|
import { ClawChefError } from "./errors.js";
|
|
@@ -266,6 +266,11 @@ interface ResolvedRecipeReference {
|
|
|
266
266
|
cleanupPaths: string[];
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
+
interface GitHubRepoRef {
|
|
270
|
+
owner: string;
|
|
271
|
+
repo: string;
|
|
272
|
+
}
|
|
273
|
+
|
|
269
274
|
function isHttpUrl(value: string): boolean {
|
|
270
275
|
try {
|
|
271
276
|
const url = new URL(value);
|
|
@@ -323,6 +328,149 @@ function parseUrlReference(input: string): { archiveUrl: string; inner?: string;
|
|
|
323
328
|
};
|
|
324
329
|
}
|
|
325
330
|
|
|
331
|
+
function parseGitHubRepoReference(input: string): GitHubRepoRef | undefined {
|
|
332
|
+
let parsed: URL;
|
|
333
|
+
try {
|
|
334
|
+
parsed = new URL(input);
|
|
335
|
+
} catch {
|
|
336
|
+
return undefined;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
340
|
+
return undefined;
|
|
341
|
+
}
|
|
342
|
+
if (parsed.hostname.toLowerCase() !== "github.com") {
|
|
343
|
+
return undefined;
|
|
344
|
+
}
|
|
345
|
+
if (parsed.search || parsed.hash) {
|
|
346
|
+
return undefined;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const parts = parsed.pathname.split("/").filter((part) => part.length > 0);
|
|
350
|
+
if (parts.length !== 2) {
|
|
351
|
+
return undefined;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const owner = parts[0];
|
|
355
|
+
const repo = parts[1].endsWith(".git") ? parts[1].slice(0, -4) : parts[1];
|
|
356
|
+
if (!owner || !repo) {
|
|
357
|
+
return undefined;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return { owner, repo };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function resolveRecipePathFromExtracted(
|
|
364
|
+
extractDir: string,
|
|
365
|
+
recipeInArchive: string,
|
|
366
|
+
missingMessage: string,
|
|
367
|
+
): Promise<string> {
|
|
368
|
+
const directPath = path.join(extractDir, recipeInArchive);
|
|
369
|
+
try {
|
|
370
|
+
const directStat = await stat(directPath);
|
|
371
|
+
if (directStat.isFile()) {
|
|
372
|
+
return directPath;
|
|
373
|
+
}
|
|
374
|
+
throw new ClawChefError(`Recipe in archive is not a file: ${recipeInArchive}`);
|
|
375
|
+
} catch (err) {
|
|
376
|
+
if (err instanceof ClawChefError) {
|
|
377
|
+
throw err;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const entries = await readdir(extractDir, { withFileTypes: true });
|
|
382
|
+
const matched: string[] = [];
|
|
383
|
+
for (const entry of entries) {
|
|
384
|
+
if (!entry.isDirectory()) {
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
const candidate = path.join(extractDir, entry.name, recipeInArchive);
|
|
388
|
+
try {
|
|
389
|
+
const s = await stat(candidate);
|
|
390
|
+
if (s.isFile()) {
|
|
391
|
+
matched.push(candidate);
|
|
392
|
+
}
|
|
393
|
+
} catch {
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (matched.length === 1) {
|
|
399
|
+
return matched[0];
|
|
400
|
+
}
|
|
401
|
+
if (matched.length > 1) {
|
|
402
|
+
throw new ClawChefError(`Multiple recipe files found in extracted archive for ${recipeInArchive}; provide explicit selector`);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
throw new ClawChefError(missingMessage);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function resolveGitHubRepoReference(recipeInput: string): Promise<ResolvedRecipeReference | undefined> {
|
|
409
|
+
const repoRef = parseGitHubRepoReference(recipeInput);
|
|
410
|
+
if (!repoRef) {
|
|
411
|
+
return undefined;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const repoApiUrl = `https://api.github.com/repos/${repoRef.owner}/${repoRef.repo}`;
|
|
415
|
+
const repoHeaders = {
|
|
416
|
+
Accept: "application/vnd.github+json",
|
|
417
|
+
"User-Agent": "clawchef",
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
let repoResponse: Response;
|
|
421
|
+
try {
|
|
422
|
+
repoResponse = await fetch(repoApiUrl, { headers: repoHeaders });
|
|
423
|
+
} catch (err) {
|
|
424
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
425
|
+
throw new ClawChefError(`Failed to fetch GitHub repository metadata ${repoApiUrl}: ${message}`);
|
|
426
|
+
}
|
|
427
|
+
if (!repoResponse.ok) {
|
|
428
|
+
throw new ClawChefError(`Failed to fetch GitHub repository metadata ${repoApiUrl}: HTTP ${repoResponse.status}`);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const repoJson = (await repoResponse.json()) as { default_branch?: string };
|
|
432
|
+
const defaultBranch = repoJson.default_branch?.trim();
|
|
433
|
+
if (!defaultBranch) {
|
|
434
|
+
throw new ClawChefError(`GitHub repository ${repoRef.owner}/${repoRef.repo} has no default branch`);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const tarballUrl = `https://api.github.com/repos/${repoRef.owner}/${repoRef.repo}/tarball/${encodeURIComponent(defaultBranch)}`;
|
|
438
|
+
let tarballResponse: Response;
|
|
439
|
+
try {
|
|
440
|
+
tarballResponse = await fetch(tarballUrl, { headers: repoHeaders });
|
|
441
|
+
} catch (err) {
|
|
442
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
443
|
+
throw new ClawChefError(`Failed to download GitHub repository tarball ${tarballUrl}: ${message}`);
|
|
444
|
+
}
|
|
445
|
+
if (!tarballResponse.ok) {
|
|
446
|
+
throw new ClawChefError(`Failed to download GitHub repository tarball ${tarballUrl}: HTTP ${tarballResponse.status}`);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const tempDir = await mkdtemp(path.join(tmpdir(), "clawchef-recipe-"));
|
|
450
|
+
const archivePath = path.join(tempDir, "repo.tar.gz");
|
|
451
|
+
await writeFile(archivePath, Buffer.from(await tarballResponse.arrayBuffer()));
|
|
452
|
+
|
|
453
|
+
const extractDir = path.join(tempDir, "extracted");
|
|
454
|
+
await mkdir(extractDir, { recursive: true });
|
|
455
|
+
await extractArchive(archivePath, extractDir);
|
|
456
|
+
|
|
457
|
+
const recipePath = await resolveRecipePathFromExtracted(
|
|
458
|
+
extractDir,
|
|
459
|
+
DEFAULT_RECIPE_FILE,
|
|
460
|
+
`Recipe file not found in GitHub repository root: ${DEFAULT_RECIPE_FILE}`,
|
|
461
|
+
);
|
|
462
|
+
|
|
463
|
+
return {
|
|
464
|
+
recipePath,
|
|
465
|
+
origin: {
|
|
466
|
+
kind: "local",
|
|
467
|
+
recipePath,
|
|
468
|
+
recipeDir: path.dirname(recipePath),
|
|
469
|
+
},
|
|
470
|
+
cleanupPaths: [tempDir],
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
326
474
|
async function runCommand(command: string, args: string[]): Promise<void> {
|
|
327
475
|
await new Promise<void>((resolve, reject) => {
|
|
328
476
|
const child = spawn(command, args, {
|
|
@@ -370,6 +518,11 @@ async function extractArchive(archivePath: string, extractDir: string): Promise<
|
|
|
370
518
|
|
|
371
519
|
async function resolveRecipeReference(recipeInput: string): Promise<ResolvedRecipeReference> {
|
|
372
520
|
if (isHttpUrl(recipeInput)) {
|
|
521
|
+
const githubResolved = await resolveGitHubRepoReference(recipeInput);
|
|
522
|
+
if (githubResolved) {
|
|
523
|
+
return githubResolved;
|
|
524
|
+
}
|
|
525
|
+
|
|
373
526
|
const parsed = parseUrlReference(recipeInput);
|
|
374
527
|
if (parsed.directUrl) {
|
|
375
528
|
let response: Response;
|
|
@@ -421,15 +574,11 @@ async function resolveRecipeReference(recipeInput: string): Promise<ResolvedReci
|
|
|
421
574
|
await mkdir(extractDir, { recursive: true });
|
|
422
575
|
await extractArchive(downloadedArchivePath, extractDir);
|
|
423
576
|
|
|
424
|
-
const recipePath =
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
}
|
|
430
|
-
} catch {
|
|
431
|
-
throw new ClawChefError(`Recipe file not found in archive: ${recipeInArchive}`);
|
|
432
|
-
}
|
|
577
|
+
const recipePath = await resolveRecipePathFromExtracted(
|
|
578
|
+
extractDir,
|
|
579
|
+
recipeInArchive,
|
|
580
|
+
`Recipe file not found in archive: ${recipeInArchive}`,
|
|
581
|
+
);
|
|
433
582
|
|
|
434
583
|
return {
|
|
435
584
|
recipePath,
|
|
@@ -470,15 +619,11 @@ async function resolveRecipeReference(recipeInput: string): Promise<ResolvedReci
|
|
|
470
619
|
const extractDir = path.join(tempDir, "extracted");
|
|
471
620
|
await mkdir(extractDir, { recursive: true });
|
|
472
621
|
await extractArchive(localPath, extractDir);
|
|
473
|
-
const recipePath =
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
}
|
|
479
|
-
} catch {
|
|
480
|
-
throw new ClawChefError(`Recipe file not found in archive: ${DEFAULT_RECIPE_FILE}`);
|
|
481
|
-
}
|
|
622
|
+
const recipePath = await resolveRecipePathFromExtracted(
|
|
623
|
+
extractDir,
|
|
624
|
+
DEFAULT_RECIPE_FILE,
|
|
625
|
+
`Recipe file not found in archive: ${DEFAULT_RECIPE_FILE}`,
|
|
626
|
+
);
|
|
482
627
|
return {
|
|
483
628
|
recipePath,
|
|
484
629
|
origin: {
|
|
@@ -538,15 +683,11 @@ async function resolveRecipeReference(recipeInput: string): Promise<ResolvedReci
|
|
|
538
683
|
const extractDir = path.join(tempDir, "extracted");
|
|
539
684
|
await mkdir(extractDir, { recursive: true });
|
|
540
685
|
await extractArchive(basePath, extractDir);
|
|
541
|
-
const recipePath =
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
}
|
|
547
|
-
} catch {
|
|
548
|
-
throw new ClawChefError(`Recipe file not found in archive: ${selector}`);
|
|
549
|
-
}
|
|
686
|
+
const recipePath = await resolveRecipePathFromExtracted(
|
|
687
|
+
extractDir,
|
|
688
|
+
selector,
|
|
689
|
+
`Recipe file not found in archive: ${selector}`,
|
|
690
|
+
);
|
|
550
691
|
return {
|
|
551
692
|
recipePath,
|
|
552
693
|
origin: {
|