@dench.com/cli 3.6.6-staging.386 → 3.8.0-staging.389
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 +43 -44
- package/agent-config.ts +21 -57
- package/app-sdk.d.ts +367 -24
- package/chat-spawn.ts +11 -2
- package/chat.ts +1 -1
- package/context-settings.ts +86 -0
- package/crm.ts +92 -24
- package/cron.ts +19 -0
- package/dench.ts +318 -90
- package/email-inbox.ts +169 -0
- package/email.ts +103 -21
- package/environments.ts +694 -0
- package/lib/api-schemas.ts +160 -14
- package/lib/app-sdk-catalog.generated.ts +39 -19
- package/lib/bundled-references.ts +17 -0
- package/lib/cli-args.ts +30 -3
- package/lib/command-args.ts +252 -0
- package/lib/command-help.ts +93 -0
- package/lib/command-registry.ts +296 -10
- package/lib/environment-archive.ts +255 -0
- package/lib/environment-cli-args.ts +96 -0
- package/lib/environment-contract.ts +237 -0
- package/lib/environment-execution.ts +769 -0
- package/lib/environment-source.ts +165 -0
- package/lib/load-dev-env.mjs +3 -1
- package/lib/load-dev-env.test.mjs +26 -9
- package/lib/native-email-wait.ts +75 -0
- package/lib/parent-argv.ts +20 -0
- package/lib/public-error.ts +82 -0
- package/lib/references.generated.json +74 -0
- package/package.json +7 -3
- package/reference.ts +47 -0
- package/workspace-secrets.ts +231 -0
- package/agent.ts +0 -37
|
@@ -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
|
+
}
|
package/lib/load-dev-env.mjs
CHANGED
|
@@ -51,7 +51,9 @@ function envFlagEnabled(value) {
|
|
|
51
51
|
|
|
52
52
|
function argvOptIn(argv) {
|
|
53
53
|
if (!Array.isArray(argv)) return false;
|
|
54
|
-
|
|
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
|
-
|
|
18
|
-
)
|
|
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(
|
|
23
|
-
|
|
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(
|
|
38
|
-
|
|
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,75 @@
|
|
|
1
|
+
import { backendErrorText, publicErrorDetails } from "./public-error";
|
|
2
|
+
|
|
3
|
+
export const NATIVE_EMAIL_WAIT_MS = 10_000;
|
|
4
|
+
const pending = new Set(["pending", "syncing", "synced", "sending"]);
|
|
5
|
+
type Receipt = Record<string, unknown>;
|
|
6
|
+
|
|
7
|
+
/** Observe one durable receipt. Never resubmit a send after an uncertain read. */
|
|
8
|
+
export async function waitForNativeEmail(
|
|
9
|
+
initial: unknown,
|
|
10
|
+
read: (draftId: string, signal: AbortSignal) => Promise<unknown>,
|
|
11
|
+
waitMs = NATIVE_EMAIL_WAIT_MS,
|
|
12
|
+
): Promise<unknown> {
|
|
13
|
+
if (!Number.isFinite(waitMs) || waitMs < 0 || waitMs > 60_000)
|
|
14
|
+
throw new Error(
|
|
15
|
+
"Email receipt wait must be between 0 and 60000 milliseconds",
|
|
16
|
+
);
|
|
17
|
+
if (waitMs === 0 || !initial || typeof initial !== "object") return initial;
|
|
18
|
+
let receipt = initial as Receipt;
|
|
19
|
+
const draftId = receipt.draftId;
|
|
20
|
+
if (typeof draftId !== "string" || !pending.has(String(receipt.syncStatus)))
|
|
21
|
+
return initial;
|
|
22
|
+
const controller = new AbortController();
|
|
23
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
24
|
+
const deadline = new Promise<never>((_, reject) => {
|
|
25
|
+
timer = setTimeout(() => {
|
|
26
|
+
controller.abort();
|
|
27
|
+
reject(new Error("Receipt observation timed out"));
|
|
28
|
+
}, waitMs);
|
|
29
|
+
});
|
|
30
|
+
try {
|
|
31
|
+
return await Promise.race([
|
|
32
|
+
(async () => {
|
|
33
|
+
while (!controller.signal.aborted) {
|
|
34
|
+
const next = await read(draftId, controller.signal);
|
|
35
|
+
if (
|
|
36
|
+
!next ||
|
|
37
|
+
typeof next !== "object" ||
|
|
38
|
+
(next as Receipt).draftId !== draftId ||
|
|
39
|
+
typeof (next as Receipt).syncStatus !== "string"
|
|
40
|
+
) {
|
|
41
|
+
throw new Error("Invalid email receipt response");
|
|
42
|
+
}
|
|
43
|
+
receipt = next as Receipt;
|
|
44
|
+
if (!pending.has(String(receipt.syncStatus))) return receipt;
|
|
45
|
+
await new Promise<void>((resolve) => {
|
|
46
|
+
const pollTimer = setTimeout(done, 500);
|
|
47
|
+
function done() {
|
|
48
|
+
clearTimeout(pollTimer);
|
|
49
|
+
controller.signal.removeEventListener("abort", done);
|
|
50
|
+
resolve();
|
|
51
|
+
}
|
|
52
|
+
controller.signal.addEventListener("abort", done, { once: true });
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return receipt;
|
|
56
|
+
})(),
|
|
57
|
+
deadline,
|
|
58
|
+
]);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
return {
|
|
61
|
+
...receipt,
|
|
62
|
+
observation: {
|
|
63
|
+
status: controller.signal.aborted ? "timed_out" : "unavailable",
|
|
64
|
+
...publicErrorDetails(error),
|
|
65
|
+
message: controller.signal.aborted
|
|
66
|
+
? "Still awaiting the send result."
|
|
67
|
+
: backendErrorText(error),
|
|
68
|
+
nextAction: `dench email draft show ${draftId} --json`,
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
} finally {
|
|
72
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
73
|
+
controller.abort();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -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
|
+
}
|
package/lib/public-error.ts
CHANGED
|
@@ -1,6 +1,88 @@
|
|
|
1
1
|
const BACKEND_CALL_PREFIX = /\[CONVEX [^\]]*\]\s*/g;
|
|
2
2
|
const REQUEST_ID_ONLY = /^\[Request ID:\s*([A-Za-z0-9]+)\]\s*Server Error$/i;
|
|
3
3
|
|
|
4
|
+
/** Preserve actionable public fields without dumping arbitrary backend data. */
|
|
5
|
+
export function publicErrorDetails(error: unknown): Record<string, unknown> {
|
|
6
|
+
if (!error || typeof error !== "object") return {};
|
|
7
|
+
let outer = error as Record<string, unknown>;
|
|
8
|
+
// Domain wrappers can preserve the original RPC error as a cause. Keep the
|
|
9
|
+
// walk bounded so an accidental cyclic cause never hides the actual failure.
|
|
10
|
+
for (let depth = 0; depth < 3; depth++) {
|
|
11
|
+
if (
|
|
12
|
+
outer.data !== undefined ||
|
|
13
|
+
outer.payload !== undefined ||
|
|
14
|
+
typeof outer.code === "string"
|
|
15
|
+
)
|
|
16
|
+
break;
|
|
17
|
+
if (
|
|
18
|
+
!outer.cause ||
|
|
19
|
+
typeof outer.cause !== "object" ||
|
|
20
|
+
outer.cause === outer
|
|
21
|
+
)
|
|
22
|
+
break;
|
|
23
|
+
outer = outer.cause as Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
const raw = outer.data ?? outer.payload ?? outer;
|
|
26
|
+
let data = raw;
|
|
27
|
+
if (typeof raw === "string") {
|
|
28
|
+
try {
|
|
29
|
+
data = JSON.parse(raw);
|
|
30
|
+
} catch {
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) return {};
|
|
35
|
+
const value = data as Record<string, unknown>;
|
|
36
|
+
const result: Record<string, unknown> = {};
|
|
37
|
+
for (const key of [
|
|
38
|
+
"code",
|
|
39
|
+
"requestId",
|
|
40
|
+
"operationId",
|
|
41
|
+
"executionStatus",
|
|
42
|
+
"draftId",
|
|
43
|
+
"receiptId",
|
|
44
|
+
"nextAction",
|
|
45
|
+
] as const) {
|
|
46
|
+
if (typeof value[key] === "string") result[key] = value[key];
|
|
47
|
+
}
|
|
48
|
+
if (typeof value.retryable === "boolean") result.retryable = value.retryable;
|
|
49
|
+
const neverRetry = new Set(["uncertain_outcome", "unconfirmed"]);
|
|
50
|
+
const noAutomaticRetry = new Set([
|
|
51
|
+
"invalid_arguments",
|
|
52
|
+
"validation_failed",
|
|
53
|
+
"missing_connection",
|
|
54
|
+
"needs_connection",
|
|
55
|
+
"needs_sender",
|
|
56
|
+
"permission_denied",
|
|
57
|
+
"forbidden",
|
|
58
|
+
"login_required",
|
|
59
|
+
"canceled",
|
|
60
|
+
"cancelled",
|
|
61
|
+
"stale_owner",
|
|
62
|
+
"provider_failure",
|
|
63
|
+
"provider_failed",
|
|
64
|
+
"upstream_failed",
|
|
65
|
+
]);
|
|
66
|
+
if (
|
|
67
|
+
typeof result.code === "string" &&
|
|
68
|
+
(neverRetry.has(result.code) ||
|
|
69
|
+
(result.retryable === undefined && noAutomaticRetry.has(result.code)))
|
|
70
|
+
)
|
|
71
|
+
result.retryable = false;
|
|
72
|
+
if (
|
|
73
|
+
typeof value.retryAfterMs === "number" &&
|
|
74
|
+
Number.isFinite(value.retryAfterMs)
|
|
75
|
+
) {
|
|
76
|
+
result.retryAfterMs = value.retryAfterMs;
|
|
77
|
+
}
|
|
78
|
+
if (Array.isArray(value.nextActions)) {
|
|
79
|
+
result.nextActions = value.nextActions.filter(
|
|
80
|
+
(item) => typeof item === "string",
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
|
|
4
86
|
export function publicErrorMessage(message: string): string {
|
|
5
87
|
const cleaned = message
|
|
6
88
|
.replace(BACKEND_CALL_PREFIX, "")
|