@dench.com/cli 3.7.0-staging.387 → 3.8.1-staging.390

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.
@@ -0,0 +1,165 @@
1
+ import { constants } from "node:fs";
2
+ import {
3
+ lstat,
4
+ mkdir,
5
+ open,
6
+ readdir,
7
+ readFile,
8
+ realpath,
9
+ writeFile,
10
+ } from "node:fs/promises";
11
+ import { dirname, join, resolve } from "node:path";
12
+ import {
13
+ ENVIRONMENT_MANIFEST_FILE,
14
+ type EnvironmentManifest,
15
+ type EnvironmentSourceBundle,
16
+ isForbiddenEnvironmentSourcePath,
17
+ MAX_ENVIRONMENT_FILES,
18
+ MAX_ENVIRONMENT_SOURCE_BYTES,
19
+ parseEnvironmentManifest,
20
+ validateEnvironmentPath,
21
+ } from "./environment-contract";
22
+
23
+ function fail(message: string): never {
24
+ throw new Error(`validation_failed: ${message}`);
25
+ }
26
+
27
+ async function checkedPath(root: string, relative: string) {
28
+ validateEnvironmentPath(relative);
29
+ let at = root;
30
+ for (const part of relative.split("/")) {
31
+ at = join(at, part);
32
+ const stat = await lstat(at);
33
+ if (stat.isSymbolicLink())
34
+ fail(`Source symlinks are not supported: ${relative}`);
35
+ }
36
+ return at;
37
+ }
38
+
39
+ export function validateSourceBundle(value: unknown): EnvironmentSourceBundle {
40
+ if (
41
+ !value ||
42
+ typeof value !== "object" ||
43
+ (value as { version?: unknown }).version !== 1 ||
44
+ !Array.isArray((value as { files?: unknown }).files)
45
+ )
46
+ fail("Invalid environment source bundle.");
47
+ const input = value as EnvironmentSourceBundle;
48
+ if (input.files.length > MAX_ENVIRONMENT_FILES)
49
+ fail("Too many environment source files.");
50
+ const seen = new Set<string>();
51
+ for (const file of input.files) {
52
+ if (
53
+ !file ||
54
+ typeof file.path !== "string" ||
55
+ typeof file.contentBase64 !== "string" ||
56
+ (file.mode !== 420 && file.mode !== 493)
57
+ )
58
+ fail("Invalid source file.");
59
+ validateEnvironmentPath(file.path);
60
+ if (seen.has(file.path) || isForbiddenEnvironmentSourcePath(file.path))
61
+ fail("Duplicate or excluded source path.");
62
+ if (
63
+ !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(
64
+ file.contentBase64,
65
+ )
66
+ )
67
+ fail("Invalid source encoding.");
68
+ seen.add(file.path);
69
+ }
70
+ if (
71
+ Buffer.byteLength(JSON.stringify(input), "utf8") >
72
+ MAX_ENVIRONMENT_SOURCE_BYTES
73
+ )
74
+ fail("Environment source exceeds 16 MiB.");
75
+ return input;
76
+ }
77
+
78
+ export async function collectEnvironmentSource(
79
+ directory: string,
80
+ name: string,
81
+ ): Promise<{
82
+ manifest: EnvironmentManifest;
83
+ bundle: EnvironmentSourceBundle;
84
+ bytes: Buffer;
85
+ }> {
86
+ const root = await realpath(resolve(directory));
87
+ const manifestPath = await checkedPath(root, ENVIRONMENT_MANIFEST_FILE);
88
+ const manifestStat = await lstat(manifestPath);
89
+ if (!manifestStat.isFile() || manifestStat.size > 64 * 1024)
90
+ fail("dench-env.json must be a file of at most 64 KiB.");
91
+ const manifest = parseEnvironmentManifest(
92
+ JSON.parse(await readFile(manifestPath, "utf8")),
93
+ );
94
+ if (manifest.name !== name)
95
+ fail("The saved environment name must match dench-env.json.");
96
+ const files: EnvironmentSourceBundle["files"] = [];
97
+ const seen = new Set<string>();
98
+ let sourceBytes = 0;
99
+ async function visit(relative: string): Promise<void> {
100
+ if (seen.has(relative)) return;
101
+ validateEnvironmentPath(relative);
102
+ if (isForbiddenEnvironmentSourcePath(relative)) return;
103
+ const absolute = await checkedPath(root, relative);
104
+ const stat = await lstat(absolute);
105
+ if (stat.isDirectory()) {
106
+ for (const child of (await readdir(absolute)).sort())
107
+ await visit(`${relative}/${child}`);
108
+ return;
109
+ }
110
+ if (!stat.isFile())
111
+ fail(`Only regular source files can be saved: ${relative}`);
112
+ if (stat.size > MAX_ENVIRONMENT_SOURCE_BYTES)
113
+ fail("A source file exceeds 16 MiB.");
114
+ // NOFOLLOW also closes the final-component symlink race during collection.
115
+ const handle = await open(
116
+ absolute,
117
+ constants.O_RDONLY | constants.O_NOFOLLOW,
118
+ );
119
+ let data: Buffer;
120
+ try {
121
+ data = await handle.readFile();
122
+ } finally {
123
+ await handle.close();
124
+ }
125
+ if (/-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(data.toString("utf8")))
126
+ fail(`Private keys belong in encrypted secrets, not source: ${relative}`);
127
+ sourceBytes += data.length;
128
+ if (
129
+ sourceBytes > MAX_ENVIRONMENT_SOURCE_BYTES ||
130
+ files.length >= MAX_ENVIRONMENT_FILES
131
+ )
132
+ fail("Environment source exceeds the file or byte limit.");
133
+ seen.add(relative);
134
+ files.push({
135
+ path: relative,
136
+ contentBase64: data.toString("base64"),
137
+ mode: stat.mode & 0o111 ? 493 : 420,
138
+ });
139
+ }
140
+ for (const path of [ENVIRONMENT_MANIFEST_FILE, "SKILL.md", ...manifest.files])
141
+ await visit(path);
142
+ files.sort((a, b) => a.path.localeCompare(b.path));
143
+ const bundle = validateSourceBundle({ version: 1, files });
144
+ return {
145
+ manifest,
146
+ bundle,
147
+ bytes: Buffer.from(JSON.stringify(bundle), "utf8"),
148
+ };
149
+ }
150
+
151
+ export async function materializeSource(
152
+ bundle: EnvironmentSourceBundle,
153
+ directory: string,
154
+ ): Promise<void> {
155
+ validateSourceBundle(bundle);
156
+ await mkdir(directory, { recursive: true, mode: 0o700 });
157
+ for (const file of bundle.files) {
158
+ const target = join(directory, file.path);
159
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
160
+ await writeFile(target, Buffer.from(file.contentBase64, "base64"), {
161
+ mode: file.mode,
162
+ flag: "wx",
163
+ });
164
+ }
165
+ }
@@ -51,7 +51,9 @@ function envFlagEnabled(value) {
51
51
 
52
52
  function argvOptIn(argv) {
53
53
  if (!Array.isArray(argv)) return false;
54
- return argv.includes("--dev") || argv.includes("--use-dev-env");
54
+ const boundary = argv.indexOf("--");
55
+ const parentArgs = argv.slice(0, boundary < 0 ? undefined : boundary);
56
+ return parentArgs.includes("--dev") || parentArgs.includes("--use-dev-env");
55
57
  }
56
58
 
57
59
  function configOptIn(env) {
@@ -13,15 +13,32 @@ describe("shouldLoadDevEnv", () => {
13
13
  });
14
14
 
15
15
  it("opts in when --use-dev-env is in argv", () => {
16
- expect(
17
- shouldLoadDevEnv({ argv: ["apps", "--use-dev-env"], env: {} }),
18
- ).toBe("argv");
16
+ expect(shouldLoadDevEnv({ argv: ["apps", "--use-dev-env"], env: {} })).toBe(
17
+ "argv",
18
+ );
19
+ });
20
+
21
+ it("does not load development credentials for a child process flag", () => {
22
+ for (const flag of ["--dev", "--use-dev-env"]) {
23
+ expect(
24
+ shouldLoadDevEnv({
25
+ argv: ["env", "run", "report", "--", "tool", flag],
26
+ env: {},
27
+ }),
28
+ ).toBe(false);
29
+ expect(
30
+ shouldLoadDevEnv({
31
+ argv: [flag, "env", "run", "report", "--", "tool"],
32
+ env: {},
33
+ }),
34
+ ).toBe("argv");
35
+ }
19
36
  });
20
37
 
21
38
  it("opts in when DENCH_USE_DEV_ENV is set to a truthy value", () => {
22
- expect(shouldLoadDevEnv({ argv: [], env: { DENCH_USE_DEV_ENV: "1" } })).toBe(
23
- "env",
24
- );
39
+ expect(
40
+ shouldLoadDevEnv({ argv: [], env: { DENCH_USE_DEV_ENV: "1" } }),
41
+ ).toBe("env");
25
42
  expect(
26
43
  shouldLoadDevEnv({ argv: [], env: { DENCH_USE_DEV_ENV: "true" } }),
27
44
  ).toBe("env");
@@ -34,9 +51,9 @@ describe("shouldLoadDevEnv", () => {
34
51
  expect(shouldLoadDevEnv({ argv: [], env: { DENCH_USE_DEV_ENV: "" } })).toBe(
35
52
  false,
36
53
  );
37
- expect(shouldLoadDevEnv({ argv: [], env: { DENCH_USE_DEV_ENV: "0" } })).toBe(
38
- false,
39
- );
54
+ expect(
55
+ shouldLoadDevEnv({ argv: [], env: { DENCH_USE_DEV_ENV: "0" } }),
56
+ ).toBe(false);
40
57
  expect(
41
58
  shouldLoadDevEnv({ argv: [], env: { DENCH_USE_DEV_ENV: "false" } }),
42
59
  ).toBe(false);
@@ -0,0 +1,20 @@
1
+ /** Parent options accept --flag=value. Tokens after -- belong to the child. */
2
+ export function normalizeParentArgv(input: readonly string[]): {
3
+ args: string[];
4
+ runtimeArgs: string[];
5
+ } {
6
+ const boundary = input.indexOf("--");
7
+ const runtimeArgs = input
8
+ .slice(0, boundary < 0 ? undefined : boundary)
9
+ .flatMap((arg) => {
10
+ const split = arg.startsWith("--") ? arg.indexOf("=") : -1;
11
+ return split > 2 ? [arg.slice(0, split), arg.slice(split + 1)] : [arg];
12
+ });
13
+ return {
14
+ runtimeArgs,
15
+ args:
16
+ boundary < 0
17
+ ? [...runtimeArgs]
18
+ : [...runtimeArgs, ...input.slice(boundary)],
19
+ };
20
+ }