@ekanos/cli 0.15.0 → 0.17.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/README.md +103 -8
- package/dist/auth/ekanos-api.d.ts +23 -0
- package/dist/auth/ekanos-api.js +33 -6
- package/dist/auth/ekanos-api.js.map +1 -1
- package/dist/commands/add.d.ts +2 -0
- package/dist/commands/add.js +118 -0
- package/dist/commands/add.js.map +1 -0
- package/dist/commands/agents.js +3 -2
- package/dist/commands/agents.js.map +1 -1
- package/dist/commands/apps.d.ts +11 -3
- package/dist/commands/apps.js +86 -28
- package/dist/commands/apps.js.map +1 -1
- package/dist/commands/audiences.d.ts +31 -0
- package/dist/commands/audiences.js +239 -0
- package/dist/commands/audiences.js.map +1 -0
- package/dist/commands/build.d.ts +2 -0
- package/dist/commands/build.js +41 -0
- package/dist/commands/build.js.map +1 -0
- package/dist/commands/catalog.d.ts +4 -2
- package/dist/commands/catalog.js +166 -35
- package/dist/commands/catalog.js.map +1 -1
- package/dist/commands/check.d.ts +2 -0
- package/dist/commands/check.js +43 -0
- package/dist/commands/check.js.map +1 -0
- package/dist/commands/codegen.d.ts +2 -0
- package/dist/commands/codegen.js +49 -0
- package/dist/commands/codegen.js.map +1 -0
- package/dist/commands/contract.d.ts +52 -0
- package/dist/commands/contract.js +300 -0
- package/dist/commands/contract.js.map +1 -0
- package/dist/commands/dev.d.ts +7 -0
- package/dist/commands/dev.js +186 -12
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/index-experience.d.ts +31 -0
- package/dist/commands/index-experience.js +21 -0
- package/dist/commands/index-experience.js.map +1 -0
- package/dist/commands/init.d.ts +7 -0
- package/dist/commands/init.js +12 -0
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/login.d.ts +2 -0
- package/dist/commands/login.js +17 -0
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/new.d.ts +2 -0
- package/dist/commands/new.js +39 -0
- package/dist/commands/new.js.map +1 -0
- package/dist/commands/publish.d.ts +59 -0
- package/dist/commands/publish.js +118 -65
- package/dist/commands/publish.js.map +1 -1
- package/dist/commands/runs.d.ts +7 -3
- package/dist/commands/runs.js +21 -5
- package/dist/commands/runs.js.map +1 -1
- package/dist/commands/scope-notice.d.ts +13 -0
- package/dist/commands/scope-notice.js +12 -4
- package/dist/commands/scope-notice.js.map +1 -1
- package/dist/commands/sources-roles.d.ts +5 -0
- package/dist/commands/sources-roles.js +235 -0
- package/dist/commands/sources-roles.js.map +1 -0
- package/dist/commands/sources.d.ts +5 -0
- package/dist/commands/sources.js +8 -1
- package/dist/commands/sources.js.map +1 -1
- package/dist/commands/sync.d.ts +2 -0
- package/dist/commands/sync.js +150 -0
- package/dist/commands/sync.js.map +1 -0
- package/dist/commands/team-agent-readiness.d.ts +18 -2
- package/dist/commands/team-agent-readiness.js +39 -39
- package/dist/commands/team-agent-readiness.js.map +1 -1
- package/dist/commands/team-agents-agents.d.ts +5 -2
- package/dist/commands/team-agents-agents.js +152 -15
- package/dist/commands/team-agents-agents.js.map +1 -1
- package/dist/commands/team-agents-define.d.ts +31 -0
- package/dist/commands/team-agents-define.js +176 -0
- package/dist/commands/team-agents-define.js.map +1 -0
- package/dist/commands/team-agents-mcp.js +112 -10
- package/dist/commands/team-agents-mcp.js.map +1 -1
- package/dist/commands/team-agents-triggers.d.ts +1 -1
- package/dist/commands/team-agents-triggers.js +137 -5
- package/dist/commands/team-agents-triggers.js.map +1 -1
- package/dist/commands/team-agents.d.ts +4 -1
- package/dist/commands/team-agents.js +17 -4
- package/dist/commands/team-agents.js.map +1 -1
- package/dist/commands/team-llm-key.d.ts +14 -0
- package/dist/commands/team-llm-key.js +122 -0
- package/dist/commands/team-llm-key.js.map +1 -0
- package/dist/commands/teams-audience.d.ts +12 -0
- package/dist/commands/teams-audience.js +144 -0
- package/dist/commands/teams-audience.js.map +1 -0
- package/dist/commands/teams-connect.js +123 -31
- package/dist/commands/teams-connect.js.map +1 -1
- package/dist/commands/teams-lifecycle.d.ts +90 -0
- package/dist/commands/teams-lifecycle.js +457 -0
- package/dist/commands/teams-lifecycle.js.map +1 -0
- package/dist/commands/teams-onboard.d.ts +14 -0
- package/dist/commands/teams-onboard.js +62 -13
- package/dist/commands/teams-onboard.js.map +1 -1
- package/dist/commands/teams-overview.js +6 -2
- package/dist/commands/teams-overview.js.map +1 -1
- package/dist/commands/teams-scope.js +4 -0
- package/dist/commands/teams-scope.js.map +1 -1
- package/dist/commands/teams.d.ts +25 -5
- package/dist/commands/teams.js +106 -39
- package/dist/commands/teams.js.map +1 -1
- package/dist/commands/workflows.d.ts +37 -0
- package/dist/commands/workflows.js +589 -0
- package/dist/commands/workflows.js.map +1 -0
- package/dist/compatibility.d.ts +10 -1
- package/dist/compatibility.js +10 -1
- package/dist/compatibility.js.map +1 -1
- package/dist/connect-fields.d.ts +22 -0
- package/dist/connect-fields.js +115 -0
- package/dist/connect-fields.js.map +1 -0
- package/dist/context.d.ts +12 -0
- package/dist/context.js +32 -9
- package/dist/context.js.map +1 -1
- package/dist/dev-experience.d.ts +125 -0
- package/dist/dev-experience.js +128 -0
- package/dist/dev-experience.js.map +1 -0
- package/dist/experience/boundary.d.ts +12 -0
- package/dist/experience/boundary.js +260 -0
- package/dist/experience/boundary.js.map +1 -0
- package/dist/experience/build.d.ts +16 -0
- package/dist/experience/build.js +90 -0
- package/dist/experience/build.js.map +1 -0
- package/dist/experience/checks.d.ts +29 -0
- package/dist/experience/checks.js +133 -0
- package/dist/experience/checks.js.map +1 -0
- package/dist/experience/contract-sources.d.ts +36 -0
- package/dist/experience/contract-sources.js +111 -0
- package/dist/experience/contract-sources.js.map +1 -0
- package/dist/experience/definition-checks.d.ts +26 -0
- package/dist/experience/definition-checks.js +201 -0
- package/dist/experience/definition-checks.js.map +1 -0
- package/dist/experience/definition-shape.d.ts +60 -0
- package/dist/experience/definition-shape.js +2 -0
- package/dist/experience/definition-shape.js.map +1 -0
- package/dist/experience/init.d.ts +10 -0
- package/dist/experience/init.js +64 -0
- package/dist/experience/init.js.map +1 -0
- package/dist/experience/integrations-codegen.d.ts +17 -0
- package/dist/experience/integrations-codegen.js +51 -0
- package/dist/experience/integrations-codegen.js.map +1 -0
- package/dist/experience/json-schema-to-ts.d.ts +1 -0
- package/dist/experience/json-schema-to-ts.js +90 -0
- package/dist/experience/json-schema-to-ts.js.map +1 -0
- package/dist/experience/load-experience.d.ts +23 -0
- package/dist/experience/load-experience.js +115 -0
- package/dist/experience/load-experience.js.map +1 -0
- package/dist/experience/overrides.d.ts +8 -0
- package/dist/experience/overrides.js +21 -0
- package/dist/experience/overrides.js.map +1 -0
- package/dist/experience/pin-integration.d.ts +8 -0
- package/dist/experience/pin-integration.js +17 -0
- package/dist/experience/pin-integration.js.map +1 -0
- package/dist/experience/pin-ops.d.ts +12 -0
- package/dist/experience/pin-ops.js +36 -0
- package/dist/experience/pin-ops.js.map +1 -0
- package/dist/experience/pins.d.ts +35 -0
- package/dist/experience/pins.js +91 -0
- package/dist/experience/pins.js.map +1 -0
- package/dist/experience/project.d.ts +52 -0
- package/dist/experience/project.js +149 -0
- package/dist/experience/project.js.map +1 -0
- package/dist/experience/publish.d.ts +51 -0
- package/dist/experience/publish.js +144 -0
- package/dist/experience/publish.js.map +1 -0
- package/dist/experience/regenerate.d.ts +10 -0
- package/dist/experience/regenerate.js +33 -0
- package/dist/experience/regenerate.js.map +1 -0
- package/dist/experience/report.d.ts +9 -0
- package/dist/experience/report.js +23 -0
- package/dist/experience/report.js.map +1 -0
- package/dist/harness-scaffold.d.ts +52 -18
- package/dist/harness-scaffold.js +189 -9
- package/dist/harness-scaffold.js.map +1 -1
- package/dist/index.d.ts +13 -0
- package/dist/index.js +494 -127
- package/dist/index.js.map +1 -1
- package/dist/invocation.d.ts +31 -0
- package/dist/invocation.js +58 -0
- package/dist/invocation.js.map +1 -0
- package/dist/project-checks.d.ts +12 -0
- package/dist/project-checks.js +46 -0
- package/dist/project-checks.js.map +1 -1
- package/dist/project.d.ts +14 -0
- package/dist/project.js +10 -0
- package/dist/project.js.map +1 -1
- package/dist/repo/publish.d.ts +42 -0
- package/dist/repo/publish.js +225 -0
- package/dist/repo/publish.js.map +1 -0
- package/dist/repo/scaffold.d.ts +30 -0
- package/dist/repo/scaffold.js +165 -0
- package/dist/repo/scaffold.js.map +1 -0
- package/dist/runs-api.js +8 -3
- package/dist/runs-api.js.map +1 -1
- package/dist/safe-fs.d.ts +18 -0
- package/dist/safe-fs.js +57 -0
- package/dist/safe-fs.js.map +1 -1
- package/dist/secret-form.d.ts +48 -0
- package/dist/secret-form.js +212 -0
- package/dist/secret-form.js.map +1 -0
- package/dist/secret-input.d.ts +96 -0
- package/dist/secret-input.js +299 -0
- package/dist/secret-input.js.map +1 -0
- package/dist/team-connect-api.d.ts +78 -3
- package/dist/team-connect-api.js +17 -2
- package/dist/team-connect-api.js.map +1 -1
- package/dist/team-lifecycle-api.d.ts +435 -0
- package/dist/team-lifecycle-api.js +301 -0
- package/dist/team-lifecycle-api.js.map +1 -0
- package/dist/teams-api.d.ts +49 -1
- package/dist/teams-api.js +149 -27
- package/dist/teams-api.js.map +1 -1
- package/dist/unknown-command.d.ts +18 -0
- package/dist/unknown-command.js +50 -0
- package/dist/unknown-command.js.map +1 -0
- package/dist/validate-findings.js +2 -1
- package/dist/validate-findings.js.map +1 -1
- package/dist/workflows-api.d.ts +69 -0
- package/dist/workflows-api.js +182 -0
- package/dist/workflows-api.js.map +1 -0
- package/package.json +3 -3
- package/templates/AGENTS.md.tmpl +6 -4
- package/templates/claude-skill.md.tmpl +4 -1
- package/templates/ekanos.json.tmpl +1 -0
- package/templates/experience/AGENTS.md.tmpl +22 -0
- package/templates/experience/ekanos.json.tmpl +7 -0
- package/templates/experience/ekanos.lock.json.tmpl +4 -0
- package/templates/experience/eslint.config.mjs.tmpl +25 -0
- package/templates/experience/gitignore.tmpl +17 -0
- package/templates/experience/package.json.tmpl +41 -0
- package/templates/experience/src/.ekanos/integrations.d.ts.tmpl +3 -0
- package/templates/experience/src/experience.ts.tmpl +30 -0
- package/templates/experience/src/widgets/hello-widget.tsx.tmpl +40 -0
- package/templates/experience/tsconfig.json.tmpl +20 -0
- package/templates/harness/app/%5F%5Fekanos/api/[...path]/route.ts.tmpl +9 -0
- package/templates/harness/app/[slug]/activation/page.tsx.tmpl +1 -1
- package/templates/harness/app/[slug]/page.tsx.tmpl +1 -1
- package/templates/harness/app/[slug]/tile/page.tsx.tmpl +1 -1
- package/templates/harness/app/[slug]/triggers/page.tsx.tmpl +1 -1
- package/templates/harness/app/[slug]/widgets/[widgetId]/page.tsx.tmpl +1 -1
- package/templates/harness/app/[slug]/widgets/page.tsx.tmpl +1 -1
- package/templates/harness/app/experience/layout.tsx.tmpl +17 -0
- package/templates/harness/app/experience/page.tsx.tmpl +1 -0
- package/templates/harness/app/experience/pages/[id]/page.tsx.tmpl +1 -0
- package/templates/harness/app/experience/review-queue/page.tsx.tmpl +1 -0
- package/templates/harness/app/experience/runs/page.tsx.tmpl +1 -0
- package/templates/harness/app/experience/widgets/[id]/page.tsx.tmpl +1 -0
- package/templates/harness/app/experience/widgets/page.tsx.tmpl +1 -0
- package/templates/harness/app/experience/workflows/[id]/page.tsx.tmpl +1 -0
- package/templates/harness/app/experience/workflows/page.tsx.tmpl +1 -0
- package/templates/harness/app/integrations/[slug]/connect/page.tsx.tmpl +1 -0
- package/templates/harness/app/integrations/[slug]/events/page.tsx.tmpl +1 -0
- package/templates/harness/app/integrations/[slug]/health/page.tsx.tmpl +1 -0
- package/templates/harness/app/integrations/[slug]/operations/[kind]/[name]/page.tsx.tmpl +1 -0
- package/templates/harness/app/integrations/[slug]/operations/page.tsx.tmpl +1 -0
- package/templates/harness/app/integrations/[slug]/page.tsx.tmpl +1 -0
- package/templates/harness/app/integrations/[slug]/preview/activation/page.tsx.tmpl +1 -0
- package/templates/harness/app/integrations/[slug]/preview/layout.tsx.tmpl +1 -0
- package/templates/harness/app/integrations/[slug]/preview/page.tsx.tmpl +1 -0
- package/templates/harness/app/integrations/[slug]/preview/tile/page.tsx.tmpl +1 -0
- package/templates/harness/app/integrations/[slug]/preview/triggers/page.tsx.tmpl +1 -0
- package/templates/harness/app/integrations/[slug]/preview/widgets/[widgetId]/page.tsx.tmpl +1 -0
- package/templates/harness/app/integrations/[slug]/preview/widgets/page.tsx.tmpl +1 -0
- package/templates/harness/app/integrations/[slug]/runs/page.tsx.tmpl +1 -0
- package/templates/harness/app/integrations/layout.tsx.tmpl +1 -0
- package/templates/harness/app/integrations/page.tsx.tmpl +1 -0
- package/templates/harness/app/page.experience.tsx.tmpl +5 -0
- package/templates/harness/repo/app/%5F%5Fekanos/api/[...path]/route.ts.tmpl +11 -0
- package/templates/harness/repo/app/(repo)/connections/page.tsx.tmpl +1 -0
- package/templates/harness/repo/app/(repo)/layout.tsx.tmpl +1 -0
- package/templates/harness/repo/app/(repo)/page.tsx.tmpl +1 -0
- package/templates/harness/repo/app/experiences/[name]/connect/page.tsx.tmpl +1 -0
- package/templates/harness/repo/app/experiences/[name]/layout.tsx.tmpl +9 -0
- package/templates/harness/repo/app/experiences/page.tsx.tmpl +5 -0
- package/templates/harness/repo/app/harness-shell.tsx.tmpl +9 -0
- package/templates/harness/repo/app/integrations/[slug]/layout.tsx.tmpl +1 -0
- package/templates/harness/repo/app/integrations/page.tsx.tmpl +5 -0
- package/templates/harness/repo/app/repo-experiences.ts.tmpl +5 -0
- package/templates/harness/styles/globals.css.tmpl +15 -1
- package/templates/package.json.tmpl +4 -4
- package/templates/skills/build-agent/SKILL.md +43 -10
- package/templates/skills/build-agent/references/agent-json.md +4 -1
- package/templates/skills/build-agent/references/app-setup.md +14 -7
- package/templates/skills/build-agent/references/rollout.md +26 -2
- package/templates/skills/build-agent/references/set-up-an-integration.md +46 -29
- package/templates/skills/build-integration/references/widget-design.md +1 -1
- package/templates/skills/ekanos-platform/SKILL.md +66 -14
- package/templates/skills/polish-widgets/references/patterns.md +3 -3
package/dist/safe-fs.d.ts
CHANGED
|
@@ -55,3 +55,21 @@ export declare function readBytesNoFollow(root: string, filePath: string): Buffe
|
|
|
55
55
|
export declare function writeFileNoFollow(root: string, filePath: string, contents: string | Uint8Array): void;
|
|
56
56
|
/** Append to an existing file under the same no-follow guarantee. */
|
|
57
57
|
export declare function appendFileNoFollow(root: string, filePath: string, contents: string): void;
|
|
58
|
+
/** Why `readPrivateFileNoFollow` would not read a file. */
|
|
59
|
+
export type PrivateFileProblem = 'missing' | 'symlink' | 'not-a-file' | 'readable-by-others' | 'not-yours' | 'too-large';
|
|
60
|
+
/**
|
|
61
|
+
* Read a file that holds a secret — a path a person or an agent named, not
|
|
62
|
+
* one inside a tree we write — only if it is a regular file, not a symlink
|
|
63
|
+
* (`O_NOFOLLOW` at open, and an `lstat` first for platforms without it),
|
|
64
|
+
* owned by this user and readable by no one else. Every check after the
|
|
65
|
+
* open runs on the open descriptor, so the file judged is the file read.
|
|
66
|
+
* Returns the bytes, or the reason it was refused.
|
|
67
|
+
*/
|
|
68
|
+
export declare function readPrivateFileNoFollow(filePath: string, maxBytes: number): {
|
|
69
|
+
ok: true;
|
|
70
|
+
bytes: Buffer;
|
|
71
|
+
} | {
|
|
72
|
+
ok: false;
|
|
73
|
+
problem: PrivateFileProblem;
|
|
74
|
+
mode?: number;
|
|
75
|
+
};
|
package/dist/safe-fs.js
CHANGED
|
@@ -205,6 +205,63 @@ export function appendFileNoFollow(root, filePath, contents) {
|
|
|
205
205
|
fs.closeSync(fd);
|
|
206
206
|
}
|
|
207
207
|
}
|
|
208
|
+
/**
|
|
209
|
+
* Read a file that holds a secret — a path a person or an agent named, not
|
|
210
|
+
* one inside a tree we write — only if it is a regular file, not a symlink
|
|
211
|
+
* (`O_NOFOLLOW` at open, and an `lstat` first for platforms without it),
|
|
212
|
+
* owned by this user and readable by no one else. Every check after the
|
|
213
|
+
* open runs on the open descriptor, so the file judged is the file read.
|
|
214
|
+
* Returns the bytes, or the reason it was refused.
|
|
215
|
+
*/
|
|
216
|
+
export function readPrivateFileNoFollow(filePath, maxBytes) {
|
|
217
|
+
try {
|
|
218
|
+
if (fs.lstatSync(filePath).isSymbolicLink()) {
|
|
219
|
+
return { ok: false, problem: 'symlink' };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
if (error.code === 'ENOENT') {
|
|
224
|
+
return { ok: false, problem: 'missing' };
|
|
225
|
+
}
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
let fd;
|
|
229
|
+
try {
|
|
230
|
+
fd = fs.openSync(filePath, fs.constants.O_RDONLY | O_NOFOLLOW);
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
const code = error.code;
|
|
234
|
+
if (code === 'ENOENT')
|
|
235
|
+
return { ok: false, problem: 'missing' };
|
|
236
|
+
if (code === 'ELOOP')
|
|
237
|
+
return { ok: false, problem: 'symlink' };
|
|
238
|
+
if (code === 'EISDIR')
|
|
239
|
+
return { ok: false, problem: 'not-a-file' };
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
const stats = fs.fstatSync(fd);
|
|
244
|
+
if (!stats.isFile())
|
|
245
|
+
return { ok: false, problem: 'not-a-file' };
|
|
246
|
+
// Mode bits and owners mean nothing on Windows; there the ACL governs.
|
|
247
|
+
if (process.platform !== 'win32') {
|
|
248
|
+
const mode = stats.mode & 0o777;
|
|
249
|
+
if ((mode & 0o077) !== 0) {
|
|
250
|
+
return { ok: false, problem: 'readable-by-others', mode };
|
|
251
|
+
}
|
|
252
|
+
if (typeof process.getuid === 'function' &&
|
|
253
|
+
stats.uid !== process.getuid()) {
|
|
254
|
+
return { ok: false, problem: 'not-yours' };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (stats.size > maxBytes)
|
|
258
|
+
return { ok: false, problem: 'too-large' };
|
|
259
|
+
return { ok: true, bytes: fs.readFileSync(fd) };
|
|
260
|
+
}
|
|
261
|
+
finally {
|
|
262
|
+
fs.closeSync(fd);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
208
265
|
/**
|
|
209
266
|
* `O_NOFOLLOW` is POSIX and present on Linux and macOS. Node exposes it as 0
|
|
210
267
|
* where the platform has no equivalent (Windows), in which case the explicit
|
package/dist/safe-fs.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"safe-fs.js","sourceRoot":"","sources":["../src/safe-fs.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'node:fs';\nimport * as path from 'node:path';\n\nimport { invalidStateError } from './errors';\n\n/**\n * Filesystem primitives for writing into a directory we do not fully trust.\n *\n * `ekanos dev` writes a generated tree into a partner's project, and every\n * path it touches is one an attacker (or a careless `ln -s`) may have created\n * first. Plain `existsSync` + `writeFileSync` follows symlinks, so a\n * `.ekanos/harness` pointing at `~/.ssh`, a generated leaf pointing at a\n * tracked source file, or a DANGLING link (which `existsSync` reports as\n * absent, and which `writeFileSync` then materializes) all let a scaffold\n * write outside the project.\n *\n * Two invariants close that, and both are enforced here rather than at the\n * call sites, because a single unguarded write anywhere reopens the hole:\n *\n * 1. **Containment** — every path resolves inside the project root.\n * 2. **No symlinks** — no component of a path we write through may be a\n * symlink, and the write itself uses `O_NOFOLLOW` so the kernel enforces\n * it at open time rather than trusting our earlier `lstat`. That closes\n * the check→write TOCTOU window: a link swapped in after the check makes\n * `open` fail with ELOOP instead of following.\n */\n\n/** True when `target` is inside `root` (or is `root` itself). */\nexport function isContained(root: string, target: string): boolean {\n const relative = path.relative(root, target);\n if (relative === '') return true;\n return !relative.startsWith('..') && !path.isAbsolute(relative);\n}\n\nfunction containmentError(root: string, target: string): never {\n throw invalidStateError(\n `Refusing to write ${target}: it resolves outside ${root}.`,\n 'Remove the symlink or path component that redirects it, then re-run ' +\n 'the command.',\n );\n}\n\nfunction symlinkError(link: string): never {\n throw invalidStateError(\n `Refusing to write through the symlink ${link}.`,\n `The CLI only writes real files inside the folder it writes to. Delete ${link} ` +\n '(or the tree containing it) and re-run.',\n );\n}\n\n/**\n * Walk every path component from `root` down to `target` and refuse if any\n * existing one is a symlink. `lstat` is deliberate — `stat` would resolve the\n * link and report the target's type, which is exactly what we must not do.\n */\nexport function assertNoSymlinkedComponent(root: string, target: string): void {\n if (!isContained(root, target)) containmentError(root, target);\n\n const relative = path.relative(root, target);\n if (relative === '') return;\n\n let current = root;\n for (const segment of relative.split(path.sep)) {\n current = path.join(current, segment);\n let stats: fs.Stats;\n try {\n stats = fs.lstatSync(current);\n } catch {\n // Does not exist yet — nothing to follow, and everything below it is\n // ours to create.\n return;\n }\n if (stats.isSymbolicLink()) symlinkError(current);\n }\n}\n\n/**\n * `mkdir -p`, one guarded component at a time. `fs.mkdirSync(recursive)` is\n * happy to walk through a symlinked ancestor; this is not.\n */\nexport function mkdirSafe(root: string, dir: string): void {\n if (!isContained(root, dir)) containmentError(root, dir);\n\n const relative = path.relative(root, dir);\n if (relative === '') return;\n\n let current = root;\n for (const segment of relative.split(path.sep)) {\n current = path.join(current, segment);\n let stats: fs.Stats | null = null;\n try {\n stats = fs.lstatSync(current);\n } catch {\n stats = null;\n }\n\n if (stats === null) {\n try {\n fs.mkdirSync(current);\n } catch (error) {\n const code = (error as NodeJS.ErrnoException).code;\n // Lost a race to another process; re-check what landed there.\n if (code !== 'EEXIST') throw error;\n stats = fs.lstatSync(current);\n }\n if (stats === null) continue;\n }\n\n if (stats.isSymbolicLink()) symlinkError(current);\n if (!stats.isDirectory()) {\n throw invalidStateError(\n `${current} exists and is not a directory, so nothing can be ` +\n 'written there.',\n `Move or delete ${current}, then re-run the command.`,\n );\n }\n }\n}\n\n/**\n * What `destPath` currently is, without following anything. `'symlink'` is\n * reported for a dangling link too — the case `existsSync` calls absent and\n * `writeFileSync` would silently materialize outside the project.\n */\nexport type PathKind = 'missing' | 'file' | 'directory' | 'symlink' | 'other';\n\nexport function classifyPath(destPath: string): PathKind {\n let stats: fs.Stats;\n try {\n stats = fs.lstatSync(destPath);\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 'missing';\n throw error;\n }\n if (stats.isSymbolicLink()) return 'symlink';\n if (stats.isDirectory()) return 'directory';\n if (stats.isFile()) return 'file';\n return 'other';\n}\n\n/**\n * Read a file, refusing to follow a symlink at the final component. Returns\n * null when it does not exist. `O_NOFOLLOW` makes the kernel enforce this, so\n * there is no window between deciding and reading.\n */\nexport function readFileNoFollow(\n root: string,\n filePath: string,\n): string | null {\n return readBytesNoFollow(root, filePath)?.toString('utf8') ?? null;\n}\n\n/** `readFileNoFollow`, as bytes — for comparing a binary file exactly. */\nexport function readBytesNoFollow(\n root: string,\n filePath: string,\n): Buffer | null {\n assertNoSymlinkedComponent(root, filePath);\n\n let fd: number;\n try {\n fd = fs.openSync(filePath, fs.constants.O_RDONLY | O_NOFOLLOW);\n } catch (error) {\n const code = (error as NodeJS.ErrnoException).code;\n if (code === 'ENOENT') return null;\n if (code === 'ELOOP') symlinkError(filePath);\n throw error;\n }\n\n try {\n return fs.readFileSync(fd);\n } finally {\n fs.closeSync(fd);\n }\n}\n\n/**\n * Write a file, creating parent directories, refusing every symlink on the\n * way and at the leaf. `O_NOFOLLOW` on the final open is what makes this safe\n * against a link swapped in after our checks.\n */\nexport function writeFileNoFollow(\n root: string,\n filePath: string,\n contents: string | Uint8Array,\n): void {\n assertNoSymlinkedComponent(root, filePath);\n mkdirSafe(root, path.dirname(filePath));\n\n let fd: number;\n try {\n fd = fs.openSync(\n filePath,\n fs.constants.O_WRONLY |\n fs.constants.O_CREAT |\n fs.constants.O_TRUNC |\n O_NOFOLLOW,\n 0o644,\n );\n } catch (error) {\n const code = (error as NodeJS.ErrnoException).code;\n if (code === 'ELOOP') symlinkError(filePath);\n throw error;\n }\n\n try {\n fs.writeFileSync(fd, contents);\n } finally {\n fs.closeSync(fd);\n }\n}\n\n/** Append to an existing file under the same no-follow guarantee. */\nexport function appendFileNoFollow(\n root: string,\n filePath: string,\n contents: string,\n): void {\n assertNoSymlinkedComponent(root, filePath);\n\n let fd: number;\n try {\n fd = fs.openSync(\n filePath,\n fs.constants.O_WRONLY | fs.constants.O_APPEND | O_NOFOLLOW,\n );\n } catch (error) {\n const code = (error as NodeJS.ErrnoException).code;\n if (code === 'ELOOP') symlinkError(filePath);\n throw error;\n }\n\n try {\n fs.writeFileSync(fd, contents);\n } finally {\n fs.closeSync(fd);\n }\n}\n\n/**\n * `O_NOFOLLOW` is POSIX and present on Linux and macOS. Node exposes it as 0\n * where the platform has no equivalent (Windows), in which case the explicit\n * `lstat` walk above is the whole guarantee rather than a belt to the kernel's\n * braces.\n */\nconst O_NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;\n"]}
|
|
1
|
+
{"version":3,"file":"safe-fs.js","sourceRoot":"","sources":["../src/safe-fs.ts"],"names":[],"mappings":"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","sourcesContent":["import * as fs from 'node:fs';\nimport * as path from 'node:path';\n\nimport { invalidStateError } from './errors';\n\n/**\n * Filesystem primitives for writing into a directory we do not fully trust.\n *\n * `ekanos dev` writes a generated tree into a partner's project, and every\n * path it touches is one an attacker (or a careless `ln -s`) may have created\n * first. Plain `existsSync` + `writeFileSync` follows symlinks, so a\n * `.ekanos/harness` pointing at `~/.ssh`, a generated leaf pointing at a\n * tracked source file, or a DANGLING link (which `existsSync` reports as\n * absent, and which `writeFileSync` then materializes) all let a scaffold\n * write outside the project.\n *\n * Two invariants close that, and both are enforced here rather than at the\n * call sites, because a single unguarded write anywhere reopens the hole:\n *\n * 1. **Containment** — every path resolves inside the project root.\n * 2. **No symlinks** — no component of a path we write through may be a\n * symlink, and the write itself uses `O_NOFOLLOW` so the kernel enforces\n * it at open time rather than trusting our earlier `lstat`. That closes\n * the check→write TOCTOU window: a link swapped in after the check makes\n * `open` fail with ELOOP instead of following.\n */\n\n/** True when `target` is inside `root` (or is `root` itself). */\nexport function isContained(root: string, target: string): boolean {\n const relative = path.relative(root, target);\n if (relative === '') return true;\n return !relative.startsWith('..') && !path.isAbsolute(relative);\n}\n\nfunction containmentError(root: string, target: string): never {\n throw invalidStateError(\n `Refusing to write ${target}: it resolves outside ${root}.`,\n 'Remove the symlink or path component that redirects it, then re-run ' +\n 'the command.',\n );\n}\n\nfunction symlinkError(link: string): never {\n throw invalidStateError(\n `Refusing to write through the symlink ${link}.`,\n `The CLI only writes real files inside the folder it writes to. Delete ${link} ` +\n '(or the tree containing it) and re-run.',\n );\n}\n\n/**\n * Walk every path component from `root` down to `target` and refuse if any\n * existing one is a symlink. `lstat` is deliberate — `stat` would resolve the\n * link and report the target's type, which is exactly what we must not do.\n */\nexport function assertNoSymlinkedComponent(root: string, target: string): void {\n if (!isContained(root, target)) containmentError(root, target);\n\n const relative = path.relative(root, target);\n if (relative === '') return;\n\n let current = root;\n for (const segment of relative.split(path.sep)) {\n current = path.join(current, segment);\n let stats: fs.Stats;\n try {\n stats = fs.lstatSync(current);\n } catch {\n // Does not exist yet — nothing to follow, and everything below it is\n // ours to create.\n return;\n }\n if (stats.isSymbolicLink()) symlinkError(current);\n }\n}\n\n/**\n * `mkdir -p`, one guarded component at a time. `fs.mkdirSync(recursive)` is\n * happy to walk through a symlinked ancestor; this is not.\n */\nexport function mkdirSafe(root: string, dir: string): void {\n if (!isContained(root, dir)) containmentError(root, dir);\n\n const relative = path.relative(root, dir);\n if (relative === '') return;\n\n let current = root;\n for (const segment of relative.split(path.sep)) {\n current = path.join(current, segment);\n let stats: fs.Stats | null = null;\n try {\n stats = fs.lstatSync(current);\n } catch {\n stats = null;\n }\n\n if (stats === null) {\n try {\n fs.mkdirSync(current);\n } catch (error) {\n const code = (error as NodeJS.ErrnoException).code;\n // Lost a race to another process; re-check what landed there.\n if (code !== 'EEXIST') throw error;\n stats = fs.lstatSync(current);\n }\n if (stats === null) continue;\n }\n\n if (stats.isSymbolicLink()) symlinkError(current);\n if (!stats.isDirectory()) {\n throw invalidStateError(\n `${current} exists and is not a directory, so nothing can be ` +\n 'written there.',\n `Move or delete ${current}, then re-run the command.`,\n );\n }\n }\n}\n\n/**\n * What `destPath` currently is, without following anything. `'symlink'` is\n * reported for a dangling link too — the case `existsSync` calls absent and\n * `writeFileSync` would silently materialize outside the project.\n */\nexport type PathKind = 'missing' | 'file' | 'directory' | 'symlink' | 'other';\n\nexport function classifyPath(destPath: string): PathKind {\n let stats: fs.Stats;\n try {\n stats = fs.lstatSync(destPath);\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 'missing';\n throw error;\n }\n if (stats.isSymbolicLink()) return 'symlink';\n if (stats.isDirectory()) return 'directory';\n if (stats.isFile()) return 'file';\n return 'other';\n}\n\n/**\n * Read a file, refusing to follow a symlink at the final component. Returns\n * null when it does not exist. `O_NOFOLLOW` makes the kernel enforce this, so\n * there is no window between deciding and reading.\n */\nexport function readFileNoFollow(\n root: string,\n filePath: string,\n): string | null {\n return readBytesNoFollow(root, filePath)?.toString('utf8') ?? null;\n}\n\n/** `readFileNoFollow`, as bytes — for comparing a binary file exactly. */\nexport function readBytesNoFollow(\n root: string,\n filePath: string,\n): Buffer | null {\n assertNoSymlinkedComponent(root, filePath);\n\n let fd: number;\n try {\n fd = fs.openSync(filePath, fs.constants.O_RDONLY | O_NOFOLLOW);\n } catch (error) {\n const code = (error as NodeJS.ErrnoException).code;\n if (code === 'ENOENT') return null;\n if (code === 'ELOOP') symlinkError(filePath);\n throw error;\n }\n\n try {\n return fs.readFileSync(fd);\n } finally {\n fs.closeSync(fd);\n }\n}\n\n/**\n * Write a file, creating parent directories, refusing every symlink on the\n * way and at the leaf. `O_NOFOLLOW` on the final open is what makes this safe\n * against a link swapped in after our checks.\n */\nexport function writeFileNoFollow(\n root: string,\n filePath: string,\n contents: string | Uint8Array,\n): void {\n assertNoSymlinkedComponent(root, filePath);\n mkdirSafe(root, path.dirname(filePath));\n\n let fd: number;\n try {\n fd = fs.openSync(\n filePath,\n fs.constants.O_WRONLY |\n fs.constants.O_CREAT |\n fs.constants.O_TRUNC |\n O_NOFOLLOW,\n 0o644,\n );\n } catch (error) {\n const code = (error as NodeJS.ErrnoException).code;\n if (code === 'ELOOP') symlinkError(filePath);\n throw error;\n }\n\n try {\n fs.writeFileSync(fd, contents);\n } finally {\n fs.closeSync(fd);\n }\n}\n\n/** Append to an existing file under the same no-follow guarantee. */\nexport function appendFileNoFollow(\n root: string,\n filePath: string,\n contents: string,\n): void {\n assertNoSymlinkedComponent(root, filePath);\n\n let fd: number;\n try {\n fd = fs.openSync(\n filePath,\n fs.constants.O_WRONLY | fs.constants.O_APPEND | O_NOFOLLOW,\n );\n } catch (error) {\n const code = (error as NodeJS.ErrnoException).code;\n if (code === 'ELOOP') symlinkError(filePath);\n throw error;\n }\n\n try {\n fs.writeFileSync(fd, contents);\n } finally {\n fs.closeSync(fd);\n }\n}\n\n/** Why `readPrivateFileNoFollow` would not read a file. */\nexport type PrivateFileProblem =\n | 'missing'\n | 'symlink'\n | 'not-a-file'\n | 'readable-by-others'\n | 'not-yours'\n | 'too-large';\n\n/**\n * Read a file that holds a secret — a path a person or an agent named, not\n * one inside a tree we write — only if it is a regular file, not a symlink\n * (`O_NOFOLLOW` at open, and an `lstat` first for platforms without it),\n * owned by this user and readable by no one else. Every check after the\n * open runs on the open descriptor, so the file judged is the file read.\n * Returns the bytes, or the reason it was refused.\n */\nexport function readPrivateFileNoFollow(\n filePath: string,\n maxBytes: number,\n):\n | { ok: true; bytes: Buffer }\n | { ok: false; problem: PrivateFileProblem; mode?: number } {\n try {\n if (fs.lstatSync(filePath).isSymbolicLink()) {\n return { ok: false, problem: 'symlink' };\n }\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === 'ENOENT') {\n return { ok: false, problem: 'missing' };\n }\n throw error;\n }\n\n let fd: number;\n try {\n fd = fs.openSync(filePath, fs.constants.O_RDONLY | O_NOFOLLOW);\n } catch (error) {\n const code = (error as NodeJS.ErrnoException).code;\n if (code === 'ENOENT') return { ok: false, problem: 'missing' };\n if (code === 'ELOOP') return { ok: false, problem: 'symlink' };\n if (code === 'EISDIR') return { ok: false, problem: 'not-a-file' };\n throw error;\n }\n\n try {\n const stats = fs.fstatSync(fd);\n if (!stats.isFile()) return { ok: false, problem: 'not-a-file' };\n // Mode bits and owners mean nothing on Windows; there the ACL governs.\n if (process.platform !== 'win32') {\n const mode = stats.mode & 0o777;\n if ((mode & 0o077) !== 0) {\n return { ok: false, problem: 'readable-by-others', mode };\n }\n if (\n typeof process.getuid === 'function' &&\n stats.uid !== process.getuid()\n ) {\n return { ok: false, problem: 'not-yours' };\n }\n }\n if (stats.size > maxBytes) return { ok: false, problem: 'too-large' };\n return { ok: true, bytes: fs.readFileSync(fd) };\n } finally {\n fs.closeSync(fd);\n }\n}\n\n/**\n * `O_NOFOLLOW` is POSIX and present on Linux and macOS. Node exposes it as 0\n * where the platform has no equivalent (Windows), in which case the explicit\n * `lstat` walk above is the whole guarantee rather than a belt to the kernel's\n * braces.\n */\nconst O_NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;\n"]}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "little GUI" for a secret: a one-shot form served by the CLI itself on
|
|
3
|
+
* this computer, for a run with no terminal to type into (a coding agent, a
|
|
4
|
+
* script) but a person at the desktop. The person types the secret into
|
|
5
|
+
* their own browser; it goes from there to this process and on to the host,
|
|
6
|
+
* and never to stdout, stderr, a log or the `--json` envelope — so an agent
|
|
7
|
+
* driving the CLI starts the form and never sees what was entered.
|
|
8
|
+
*
|
|
9
|
+
* What keeps it to that one person and that one submission:
|
|
10
|
+
*
|
|
11
|
+
* - **Loopback only.** It listens on 127.0.0.1, on a port the OS picks.
|
|
12
|
+
* - **An unguessable path.** 256 random bits; any other path is a 404.
|
|
13
|
+
* - **Host and Origin.** A request whose `Host` is not exactly
|
|
14
|
+
* `127.0.0.1:<port>` is refused (a DNS-rebinding page names its own
|
|
15
|
+
* host), and a submission must carry this form's `Origin` — so another
|
|
16
|
+
* page in the same browser cannot post to it.
|
|
17
|
+
* - **One use, short life.** The first good submission closes the server;
|
|
18
|
+
* so does the timeout, which fails the command rather than waiting on.
|
|
19
|
+
* - **A page that loads nothing.** No script, no external asset: a CSP of
|
|
20
|
+
* `default-src 'none'` with one nonce'd stylesheet, never framed, never
|
|
21
|
+
* cached, no referrer.
|
|
22
|
+
*/
|
|
23
|
+
export interface SecretFormField {
|
|
24
|
+
name: string;
|
|
25
|
+
label: string;
|
|
26
|
+
sensitive: boolean;
|
|
27
|
+
description?: string;
|
|
28
|
+
required?: boolean;
|
|
29
|
+
/** A value is saved already: leaving it empty keeps it. */
|
|
30
|
+
set?: boolean;
|
|
31
|
+
/** A non-secret field's saved value, shown as the placeholder. Never a secret. */
|
|
32
|
+
value?: string | null;
|
|
33
|
+
}
|
|
34
|
+
export interface SecretFormOptions {
|
|
35
|
+
title: string;
|
|
36
|
+
fields: readonly SecretFormField[];
|
|
37
|
+
/** Opens the form's URL in the person's browser. */
|
|
38
|
+
open: (url: string) => void;
|
|
39
|
+
/** How long to wait for the submission (default five minutes). */
|
|
40
|
+
timeoutMs?: number;
|
|
41
|
+
}
|
|
42
|
+
export declare const SECRET_FORM_TIMEOUT_MS: number;
|
|
43
|
+
/**
|
|
44
|
+
* Serve the form, open it, and resolve with what was submitted: each field's
|
|
45
|
+
* value by name, empty ones left out. Rejects on the timeout.
|
|
46
|
+
*/
|
|
47
|
+
export declare function collectWithSecretForm(options: SecretFormOptions): Promise<Record<string, string>>;
|
|
48
|
+
export declare function escapeHtml(value: string): string;
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import * as http from 'node:http';
|
|
3
|
+
import { validationError } from './errors.js';
|
|
4
|
+
export const SECRET_FORM_TIMEOUT_MS = 5 * 60 * 1000;
|
|
5
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
6
|
+
/**
|
|
7
|
+
* Serve the form, open it, and resolve with what was submitted: each field's
|
|
8
|
+
* value by name, empty ones left out. Rejects on the timeout.
|
|
9
|
+
*/
|
|
10
|
+
export function collectWithSecretForm(options) {
|
|
11
|
+
var _a;
|
|
12
|
+
const token = randomBytes(32).toString('base64url');
|
|
13
|
+
const nonce = randomBytes(16).toString('base64');
|
|
14
|
+
const timeoutMs = (_a = options.timeoutMs) !== null && _a !== void 0 ? _a : SECRET_FORM_TIMEOUT_MS;
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
let settled = false;
|
|
17
|
+
let origin = '';
|
|
18
|
+
let hostHeader = '';
|
|
19
|
+
const server = http.createServer((request, response) => {
|
|
20
|
+
handle(request, response).catch(() => {
|
|
21
|
+
if (!response.headersSent)
|
|
22
|
+
send(response, 500, page('Something went wrong', 'Return to your terminal.'));
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
const finish = (outcome) => {
|
|
26
|
+
if (settled)
|
|
27
|
+
return;
|
|
28
|
+
settled = true;
|
|
29
|
+
clearTimeout(timer);
|
|
30
|
+
server.close();
|
|
31
|
+
server.closeAllConnections();
|
|
32
|
+
outcome();
|
|
33
|
+
};
|
|
34
|
+
const timer = setTimeout(() => {
|
|
35
|
+
finish(() => reject(validationError(`The secure form was not submitted within ${duration(timeoutMs)}; nothing was saved.`, 'Run the command again for a fresh form, or pass the secret with --secret-env, --secret-file or --secret-stdin.')));
|
|
36
|
+
}, timeoutMs);
|
|
37
|
+
const send = (response, status, body) => {
|
|
38
|
+
response.writeHead(status, {
|
|
39
|
+
'content-type': 'text/html; charset=utf-8',
|
|
40
|
+
'cache-control': 'no-store',
|
|
41
|
+
'content-security-policy': `default-src 'none'; style-src 'nonce-${nonce}'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'`,
|
|
42
|
+
'x-frame-options': 'DENY',
|
|
43
|
+
'x-content-type-options': 'nosniff',
|
|
44
|
+
'referrer-policy': 'no-referrer',
|
|
45
|
+
'cross-origin-resource-policy': 'same-origin',
|
|
46
|
+
});
|
|
47
|
+
response.end(body);
|
|
48
|
+
};
|
|
49
|
+
const page = (heading, text, inner = '') => [
|
|
50
|
+
'<!doctype html>',
|
|
51
|
+
'<html lang="en"><head><meta charset="utf-8">',
|
|
52
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
53
|
+
'<meta name="referrer" content="no-referrer">',
|
|
54
|
+
`<title>${escapeHtml(heading)} · ekanos</title>`,
|
|
55
|
+
`<style nonce="${nonce}">${STYLE}</style>`,
|
|
56
|
+
'</head><body><main>',
|
|
57
|
+
`<h1>${escapeHtml(heading)}</h1>`,
|
|
58
|
+
`<p class="lede">${escapeHtml(text)}</p>`,
|
|
59
|
+
inner,
|
|
60
|
+
'</main></body></html>',
|
|
61
|
+
].join('');
|
|
62
|
+
const isOurPath = (pathname) => {
|
|
63
|
+
const expected = Buffer.from(`/${token}`);
|
|
64
|
+
const given = Buffer.from(pathname);
|
|
65
|
+
return (given.length === expected.length && timingSafeEqual(given, expected));
|
|
66
|
+
};
|
|
67
|
+
async function handle(request, response) {
|
|
68
|
+
var _a, _b, _c;
|
|
69
|
+
if (settled) {
|
|
70
|
+
send(response, 410, page('This form is closed', 'Return to your terminal.'));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (request.headers.host !== hostHeader) {
|
|
74
|
+
send(response, 421, page('Wrong address', 'Open the link the ekanos CLI opened.'));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const pathname = (_b = ((_a = request.url) !== null && _a !== void 0 ? _a : '').split('?')[0]) !== null && _b !== void 0 ? _b : '';
|
|
78
|
+
if (!isOurPath(pathname)) {
|
|
79
|
+
send(response, 404, page('Not found', 'Open the link the ekanos CLI opened.'));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (request.method === 'GET') {
|
|
83
|
+
send(response, 200, formPage());
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (request.method !== 'POST') {
|
|
87
|
+
response.setHeader('allow', 'GET, POST');
|
|
88
|
+
send(response, 405, page('Not allowed', 'Return to your terminal.'));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const fetchSite = request.headers['sec-fetch-site'];
|
|
92
|
+
if (request.headers.origin !== origin ||
|
|
93
|
+
(fetchSite !== undefined && fetchSite !== 'same-origin')) {
|
|
94
|
+
send(response, 403, page('Refused', 'This form only accepts what is typed into it.'));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (!((_c = request.headers['content-type']) !== null && _c !== void 0 ? _c : '')
|
|
98
|
+
.toLowerCase()
|
|
99
|
+
.startsWith('application/x-www-form-urlencoded')) {
|
|
100
|
+
send(response, 415, page('Refused', 'Submit the form itself.'));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const body = await readBody(request);
|
|
104
|
+
if (body === null) {
|
|
105
|
+
send(response, 413, page('Too large', 'Return to your terminal.'));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const params = new URLSearchParams(body);
|
|
109
|
+
const values = {};
|
|
110
|
+
options.fields.forEach((field, index) => {
|
|
111
|
+
var _a;
|
|
112
|
+
const value = ((_a = params.get(`f${index}`)) !== null && _a !== void 0 ? _a : '').trim();
|
|
113
|
+
if (value)
|
|
114
|
+
values[field.name] = value;
|
|
115
|
+
});
|
|
116
|
+
send(response, 200, page('Received', 'The ekanos CLI has what you entered and is saving it. You can close this tab and return to your terminal.'));
|
|
117
|
+
finish(() => resolve(values));
|
|
118
|
+
}
|
|
119
|
+
function formPage() {
|
|
120
|
+
const inputs = options.fields
|
|
121
|
+
.map((field, index) => {
|
|
122
|
+
const id = `f${index}`;
|
|
123
|
+
const placeholder = field.sensitive
|
|
124
|
+
? field.set
|
|
125
|
+
? 'Saved — leave empty to keep it'
|
|
126
|
+
: ''
|
|
127
|
+
: field.set
|
|
128
|
+
? field.value
|
|
129
|
+
? `Current: ${field.value} — leave empty to keep it`
|
|
130
|
+
: 'Saved — leave empty to keep it'
|
|
131
|
+
: '';
|
|
132
|
+
const required = field.required && !field.set;
|
|
133
|
+
return [
|
|
134
|
+
'<div class="field">',
|
|
135
|
+
`<label for="${id}">${escapeHtml(field.label)}${required ? '' : ' <span class="optional">(optional)</span>'}</label>`,
|
|
136
|
+
field.description
|
|
137
|
+
? `<p class="help" id="${id}-help">${escapeHtml(field.description)}</p>`
|
|
138
|
+
: '',
|
|
139
|
+
`<input id="${id}" name="${id}" type="${field.sensitive ? 'password' : 'text'}"`,
|
|
140
|
+
' autocomplete="off" autocapitalize="off" spellcheck="false"',
|
|
141
|
+
placeholder ? ` placeholder="${escapeHtml(placeholder)}"` : '',
|
|
142
|
+
field.description ? ` aria-describedby="${id}-help"` : '',
|
|
143
|
+
required ? ' required' : '',
|
|
144
|
+
index === 0 ? ' autofocus' : '',
|
|
145
|
+
'>',
|
|
146
|
+
'</div>',
|
|
147
|
+
].join('');
|
|
148
|
+
})
|
|
149
|
+
.join('');
|
|
150
|
+
return page(options.title, 'This page is served by the ekanos CLI on your own computer. What you enter goes only to it, then to Ekanos over HTTPS — never to your terminal, a log or the agent that ran the command. It works once and closes.', `<form method="post" action="/${token}">${inputs}<button type="submit">Save</button></form>`);
|
|
151
|
+
}
|
|
152
|
+
server.on('error', (error) => finish(() => reject(error)));
|
|
153
|
+
server.listen(0, '127.0.0.1', () => {
|
|
154
|
+
const { port } = server.address();
|
|
155
|
+
origin = `http://127.0.0.1:${port}`;
|
|
156
|
+
hostHeader = `127.0.0.1:${port}`;
|
|
157
|
+
try {
|
|
158
|
+
options.open(`${origin}/${token}`);
|
|
159
|
+
}
|
|
160
|
+
catch (_a) {
|
|
161
|
+
// The timeout still ends the wait.
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
function readBody(request) {
|
|
167
|
+
return new Promise((resolve, reject) => {
|
|
168
|
+
const chunks = [];
|
|
169
|
+
let size = 0;
|
|
170
|
+
let tooLarge = false;
|
|
171
|
+
request.on('data', (chunk) => {
|
|
172
|
+
size += chunk.length;
|
|
173
|
+
if (size > MAX_BODY_BYTES) {
|
|
174
|
+
tooLarge = true;
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
chunks.push(chunk);
|
|
178
|
+
});
|
|
179
|
+
request.on('end', () => resolve(tooLarge ? null : Buffer.concat(chunks).toString('utf8')));
|
|
180
|
+
request.on('error', reject);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
function duration(ms) {
|
|
184
|
+
if (ms < 60000) {
|
|
185
|
+
const seconds = Math.max(1, Math.ceil(ms / 1000));
|
|
186
|
+
return `${seconds} second${seconds === 1 ? '' : 's'}`;
|
|
187
|
+
}
|
|
188
|
+
const minutes = Math.round(ms / 60000);
|
|
189
|
+
return `${minutes} minute${minutes === 1 ? '' : 's'}`;
|
|
190
|
+
}
|
|
191
|
+
export function escapeHtml(value) {
|
|
192
|
+
return value
|
|
193
|
+
.replace(/&/g, '&')
|
|
194
|
+
.replace(/</g, '<')
|
|
195
|
+
.replace(/>/g, '>')
|
|
196
|
+
.replace(/"/g, '"')
|
|
197
|
+
.replace(/'/g, ''');
|
|
198
|
+
}
|
|
199
|
+
const STYLE = [
|
|
200
|
+
':root{color-scheme:light dark;font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;line-height:1.5}',
|
|
201
|
+
'body{margin:0;display:flex;justify-content:center;padding:48px 16px}',
|
|
202
|
+
'main{width:100%;max-width:440px}',
|
|
203
|
+
'h1{font-size:1.25rem;margin:0 0 8px}',
|
|
204
|
+
'.lede{margin:0 0 24px;opacity:.75;font-size:.9rem}',
|
|
205
|
+
'.field{margin:0 0 16px}',
|
|
206
|
+
'label{display:block;font-weight:600;font-size:.9rem;margin:0 0 4px}',
|
|
207
|
+
'.optional{font-weight:400;opacity:.6}',
|
|
208
|
+
'.help{margin:0 0 6px;font-size:.85rem;opacity:.7}',
|
|
209
|
+
'input{box-sizing:border-box;width:100%;padding:8px 10px;font:inherit;border:1px solid #8888;border-radius:6px;background:transparent;color:inherit}',
|
|
210
|
+
'button{margin-top:8px;padding:8px 16px;font:inherit;font-weight:600;border:1px solid currentColor;border-radius:6px;background:transparent;color:inherit;cursor:pointer}',
|
|
211
|
+
].join('');
|
|
212
|
+
//# sourceMappingURL=secret-form.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"secret-form.js","sourceRoot":"","sources":["../src/secret-form.ts"],"names":[],"mappings":"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","sourcesContent":["import { randomBytes, timingSafeEqual } from 'node:crypto';\nimport * as http from 'node:http';\nimport type { AddressInfo } from 'node:net';\n\nimport { validationError } from './errors';\n\n/**\n * The \"little GUI\" for a secret: a one-shot form served by the CLI itself on\n * this computer, for a run with no terminal to type into (a coding agent, a\n * script) but a person at the desktop. The person types the secret into\n * their own browser; it goes from there to this process and on to the host,\n * and never to stdout, stderr, a log or the `--json` envelope — so an agent\n * driving the CLI starts the form and never sees what was entered.\n *\n * What keeps it to that one person and that one submission:\n *\n * - **Loopback only.** It listens on 127.0.0.1, on a port the OS picks.\n * - **An unguessable path.** 256 random bits; any other path is a 404.\n * - **Host and Origin.** A request whose `Host` is not exactly\n * `127.0.0.1:<port>` is refused (a DNS-rebinding page names its own\n * host), and a submission must carry this form's `Origin` — so another\n * page in the same browser cannot post to it.\n * - **One use, short life.** The first good submission closes the server;\n * so does the timeout, which fails the command rather than waiting on.\n * - **A page that loads nothing.** No script, no external asset: a CSP of\n * `default-src 'none'` with one nonce'd stylesheet, never framed, never\n * cached, no referrer.\n */\n\nexport interface SecretFormField {\n name: string;\n label: string;\n sensitive: boolean;\n description?: string;\n required?: boolean;\n /** A value is saved already: leaving it empty keeps it. */\n set?: boolean;\n /** A non-secret field's saved value, shown as the placeholder. Never a secret. */\n value?: string | null;\n}\n\nexport interface SecretFormOptions {\n title: string;\n fields: readonly SecretFormField[];\n /** Opens the form's URL in the person's browser. */\n open: (url: string) => void;\n /** How long to wait for the submission (default five minutes). */\n timeoutMs?: number;\n}\n\nexport const SECRET_FORM_TIMEOUT_MS = 5 * 60 * 1000;\nconst MAX_BODY_BYTES = 64 * 1024;\n\n/**\n * Serve the form, open it, and resolve with what was submitted: each field's\n * value by name, empty ones left out. Rejects on the timeout.\n */\nexport function collectWithSecretForm(\n options: SecretFormOptions,\n): Promise<Record<string, string>> {\n const token = randomBytes(32).toString('base64url');\n const nonce = randomBytes(16).toString('base64');\n const timeoutMs = options.timeoutMs ?? SECRET_FORM_TIMEOUT_MS;\n\n return new Promise((resolve, reject) => {\n let settled = false;\n let origin = '';\n let hostHeader = '';\n\n const server = http.createServer((request, response) => {\n handle(request, response).catch(() => {\n if (!response.headersSent)\n send(\n response,\n 500,\n page('Something went wrong', 'Return to your terminal.'),\n );\n });\n });\n\n const finish = (outcome: () => void) => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n server.close();\n server.closeAllConnections();\n outcome();\n };\n\n const timer = setTimeout(() => {\n finish(() =>\n reject(\n validationError(\n `The secure form was not submitted within ${duration(timeoutMs)}; nothing was saved.`,\n 'Run the command again for a fresh form, or pass the secret with --secret-env, --secret-file or --secret-stdin.',\n ),\n ),\n );\n }, timeoutMs);\n\n const send = (\n response: http.ServerResponse,\n status: number,\n body: string,\n ) => {\n response.writeHead(status, {\n 'content-type': 'text/html; charset=utf-8',\n 'cache-control': 'no-store',\n 'content-security-policy': `default-src 'none'; style-src 'nonce-${nonce}'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'`,\n 'x-frame-options': 'DENY',\n 'x-content-type-options': 'nosniff',\n 'referrer-policy': 'no-referrer',\n 'cross-origin-resource-policy': 'same-origin',\n });\n response.end(body);\n };\n\n const page = (heading: string, text: string, inner = '') =>\n [\n '<!doctype html>',\n '<html lang=\"en\"><head><meta charset=\"utf-8\">',\n '<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">',\n '<meta name=\"referrer\" content=\"no-referrer\">',\n `<title>${escapeHtml(heading)} · ekanos</title>`,\n `<style nonce=\"${nonce}\">${STYLE}</style>`,\n '</head><body><main>',\n `<h1>${escapeHtml(heading)}</h1>`,\n `<p class=\"lede\">${escapeHtml(text)}</p>`,\n inner,\n '</main></body></html>',\n ].join('');\n\n const isOurPath = (pathname: string): boolean => {\n const expected = Buffer.from(`/${token}`);\n const given = Buffer.from(pathname);\n return (\n given.length === expected.length && timingSafeEqual(given, expected)\n );\n };\n\n async function handle(\n request: http.IncomingMessage,\n response: http.ServerResponse,\n ): Promise<void> {\n if (settled) {\n send(\n response,\n 410,\n page('This form is closed', 'Return to your terminal.'),\n );\n return;\n }\n if (request.headers.host !== hostHeader) {\n send(\n response,\n 421,\n page('Wrong address', 'Open the link the ekanos CLI opened.'),\n );\n return;\n }\n const pathname = (request.url ?? '').split('?')[0] ?? '';\n if (!isOurPath(pathname)) {\n send(\n response,\n 404,\n page('Not found', 'Open the link the ekanos CLI opened.'),\n );\n return;\n }\n\n if (request.method === 'GET') {\n send(response, 200, formPage());\n return;\n }\n if (request.method !== 'POST') {\n response.setHeader('allow', 'GET, POST');\n send(response, 405, page('Not allowed', 'Return to your terminal.'));\n return;\n }\n\n const fetchSite = request.headers['sec-fetch-site'];\n if (\n request.headers.origin !== origin ||\n (fetchSite !== undefined && fetchSite !== 'same-origin')\n ) {\n send(\n response,\n 403,\n page('Refused', 'This form only accepts what is typed into it.'),\n );\n return;\n }\n if (\n !(request.headers['content-type'] ?? '')\n .toLowerCase()\n .startsWith('application/x-www-form-urlencoded')\n ) {\n send(response, 415, page('Refused', 'Submit the form itself.'));\n return;\n }\n\n const body = await readBody(request);\n if (body === null) {\n send(response, 413, page('Too large', 'Return to your terminal.'));\n return;\n }\n const params = new URLSearchParams(body);\n const values: Record<string, string> = {};\n options.fields.forEach((field, index) => {\n const value = (params.get(`f${index}`) ?? '').trim();\n if (value) values[field.name] = value;\n });\n\n send(\n response,\n 200,\n page(\n 'Received',\n 'The ekanos CLI has what you entered and is saving it. You can close this tab and return to your terminal.',\n ),\n );\n finish(() => resolve(values));\n }\n\n function formPage(): string {\n const inputs = options.fields\n .map((field, index) => {\n const id = `f${index}`;\n const placeholder = field.sensitive\n ? field.set\n ? 'Saved — leave empty to keep it'\n : ''\n : field.set\n ? field.value\n ? `Current: ${field.value} — leave empty to keep it`\n : 'Saved — leave empty to keep it'\n : '';\n const required = field.required && !field.set;\n return [\n '<div class=\"field\">',\n `<label for=\"${id}\">${escapeHtml(field.label)}${required ? '' : ' <span class=\"optional\">(optional)</span>'}</label>`,\n field.description\n ? `<p class=\"help\" id=\"${id}-help\">${escapeHtml(field.description)}</p>`\n : '',\n `<input id=\"${id}\" name=\"${id}\" type=\"${field.sensitive ? 'password' : 'text'}\"`,\n ' autocomplete=\"off\" autocapitalize=\"off\" spellcheck=\"false\"',\n placeholder ? ` placeholder=\"${escapeHtml(placeholder)}\"` : '',\n field.description ? ` aria-describedby=\"${id}-help\"` : '',\n required ? ' required' : '',\n index === 0 ? ' autofocus' : '',\n '>',\n '</div>',\n ].join('');\n })\n .join('');\n return page(\n options.title,\n 'This page is served by the ekanos CLI on your own computer. What you enter goes only to it, then to Ekanos over HTTPS — never to your terminal, a log or the agent that ran the command. It works once and closes.',\n `<form method=\"post\" action=\"/${token}\">${inputs}<button type=\"submit\">Save</button></form>`,\n );\n }\n\n server.on('error', (error) => finish(() => reject(error)));\n server.listen(0, '127.0.0.1', () => {\n const { port } = server.address() as AddressInfo;\n origin = `http://127.0.0.1:${port}`;\n hostHeader = `127.0.0.1:${port}`;\n try {\n options.open(`${origin}/${token}`);\n } catch {\n // The timeout still ends the wait.\n }\n });\n });\n}\n\nfunction readBody(request: http.IncomingMessage): Promise<string | null> {\n return new Promise((resolve, reject) => {\n const chunks: Buffer[] = [];\n let size = 0;\n let tooLarge = false;\n request.on('data', (chunk: Buffer) => {\n size += chunk.length;\n if (size > MAX_BODY_BYTES) {\n tooLarge = true;\n return;\n }\n chunks.push(chunk);\n });\n request.on('end', () =>\n resolve(tooLarge ? null : Buffer.concat(chunks).toString('utf8')),\n );\n request.on('error', reject);\n });\n}\n\nfunction duration(ms: number): string {\n if (ms < 60_000) {\n const seconds = Math.max(1, Math.ceil(ms / 1000));\n return `${seconds} second${seconds === 1 ? '' : 's'}`;\n }\n const minutes = Math.round(ms / 60_000);\n return `${minutes} minute${minutes === 1 ? '' : 's'}`;\n}\n\nexport function escapeHtml(value: string): string {\n return value\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\n}\n\nconst STYLE = [\n ':root{color-scheme:light dark;font-family:ui-sans-serif,system-ui,-apple-system,\"Segoe UI\",sans-serif;line-height:1.5}',\n 'body{margin:0;display:flex;justify-content:center;padding:48px 16px}',\n 'main{width:100%;max-width:440px}',\n 'h1{font-size:1.25rem;margin:0 0 8px}',\n '.lede{margin:0 0 24px;opacity:.75;font-size:.9rem}',\n '.field{margin:0 0 16px}',\n 'label{display:block;font-weight:600;font-size:.9rem;margin:0 0 4px}',\n '.optional{font-weight:400;opacity:.6}',\n '.help{margin:0 0 6px;font-size:.85rem;opacity:.7}',\n 'input{box-sizing:border-box;width:100%;padding:8px 10px;font:inherit;border:1px solid #8888;border-radius:6px;background:transparent;color:inherit}',\n 'button{margin-top:8px;padding:8px 16px;font:inherit;font-weight:600;border:1px solid currentColor;border-radius:6px;background:transparent;color:inherit;cursor:pointer}',\n].join('');\n"]}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { CliContext } from './context.js';
|
|
2
|
+
import { type SecretFormField } from './secret-form.js';
|
|
3
|
+
/**
|
|
4
|
+
* How every command that takes a secret gets it — `apps set`, `apps
|
|
5
|
+
* connect`, `teams app set`, `teams connect`, `teams mcp token|add`,
|
|
6
|
+
* `teams llm-key set` — so a coding agent or a script can run them as well
|
|
7
|
+
* as a person at a terminal, and none of them ever has the secret in argv.
|
|
8
|
+
*
|
|
9
|
+
* In order:
|
|
10
|
+
*
|
|
11
|
+
* 1. **`--browser`**: a one-shot form on this computer (`secret-form.ts`).
|
|
12
|
+
* The person types it in their browser; the run that asked never sees it.
|
|
13
|
+
* 2. **A named source**: `--secret-env NAME`, `--secret-file PATH` (a
|
|
14
|
+
* 0600 file of yours, never through a symlink) or `--secret-stdin` (piped)
|
|
15
|
+
* for the one secret; `--set-secret <field>=env:NAME|file:PATH|stdin` for
|
|
16
|
+
* each field of a form. Nothing else is asked.
|
|
17
|
+
* 3. **A terminal**: hidden input, as before.
|
|
18
|
+
* 4. Otherwise a usage error naming the forms above.
|
|
19
|
+
*
|
|
20
|
+
* A literal (`--set-secret field=sk-…`) is refused: argv lands in shell
|
|
21
|
+
* history and the process list. No error, log or output here ever carries a
|
|
22
|
+
* value — not even a path or a name that might be one by mistake.
|
|
23
|
+
*
|
|
24
|
+
* `--browser` is never chosen for you. A run with no terminal is usually an
|
|
25
|
+
* agent or CI, and the CLI cannot tell whether anyone is at the desktop: a
|
|
26
|
+
* form nobody fills would stall the run for its whole timeout. So it is
|
|
27
|
+
* explicit, refused up front where it cannot work (CI, SSH, no display),
|
|
28
|
+
* and bounded by a timeout everywhere.
|
|
29
|
+
*/
|
|
30
|
+
export interface SecretFlags {
|
|
31
|
+
/** `--secret-stdin` */
|
|
32
|
+
stdin: boolean;
|
|
33
|
+
/** `--secret-env <NAME>` */
|
|
34
|
+
env?: string;
|
|
35
|
+
/** `--secret-file <path>` */
|
|
36
|
+
file?: string;
|
|
37
|
+
/** `--set-secret <field>=env:NAME|file:PATH|stdin`, repeatable. */
|
|
38
|
+
sets: string[];
|
|
39
|
+
/** `--browser` */
|
|
40
|
+
browser: boolean;
|
|
41
|
+
}
|
|
42
|
+
/** The flags, from a command's parsed flags. */
|
|
43
|
+
export declare function readSecretFlags(flags: Record<string, string | boolean | string[]> | undefined): SecretFlags;
|
|
44
|
+
/** Whether any way of giving a secret without a terminal was passed. */
|
|
45
|
+
export declare function secretInputGiven(flags: SecretFlags): boolean;
|
|
46
|
+
/** The forms, as a hint names them. */
|
|
47
|
+
export declare const SINGLE_SECRET_FORMS = "--secret-env <NAME>, --secret-file <path> (a file only you can read), --secret-stdin (piped in), or --browser (a form in your browser on this computer)";
|
|
48
|
+
export declare const FIELD_SECRET_FORMS = "--set-secret <field>=env:<NAME>|file:<path>|stdin for each secret (--secret-env, --secret-file or --secret-stdin when there is one), or --browser (a form in your browser on this computer)";
|
|
49
|
+
export interface SecretIO {
|
|
50
|
+
env: Record<string, string | undefined>;
|
|
51
|
+
stdinIsTTY: boolean;
|
|
52
|
+
/** Test seam: the whole of stdin. */
|
|
53
|
+
readStdin?: () => Promise<string>;
|
|
54
|
+
/** Test seam: the browser opener for `--browser`. */
|
|
55
|
+
openUrl?: (url: string) => void;
|
|
56
|
+
/** Test seam: how long `--browser` waits. */
|
|
57
|
+
formTimeoutMs?: number;
|
|
58
|
+
/** Test seam: the platform `--browser` checks for a desktop. */
|
|
59
|
+
platform?: NodeJS.Platform;
|
|
60
|
+
/** Where a relative `--secret-file` resolves (default: the working directory). */
|
|
61
|
+
cwd?: string;
|
|
62
|
+
}
|
|
63
|
+
export type SecretField = SecretFormField;
|
|
64
|
+
export interface CollectSecretsInput {
|
|
65
|
+
/** Every field this run would ask, in order. */
|
|
66
|
+
fields: readonly SecretField[];
|
|
67
|
+
flags: SecretFlags;
|
|
68
|
+
io: SecretIO;
|
|
69
|
+
/** A human at a terminal (not `--json`, stdin a TTY). */
|
|
70
|
+
interactive: boolean;
|
|
71
|
+
/** The terminal question for one field — hidden for a secret. */
|
|
72
|
+
prompt: (field: SecretField) => Promise<string>;
|
|
73
|
+
/** The secure form's heading. */
|
|
74
|
+
title: string;
|
|
75
|
+
/** What is needed, when nothing can be asked: the refusal's message. */
|
|
76
|
+
refusal: string;
|
|
77
|
+
/** The command, without `npx @ekanos/cli`, for hints. */
|
|
78
|
+
again: string;
|
|
79
|
+
/**
|
|
80
|
+
* `--set <field>=<value>` for a field that is not a secret (a subdomain, a
|
|
81
|
+
* client ID): taken as given, and never asked. A secret here is refused.
|
|
82
|
+
*/
|
|
83
|
+
plain?: Record<string, string>;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* `--set <field>=<value>` pairs. A malformed one is refused without echoing
|
|
87
|
+
* it — it may be a secret typed where a field name was meant.
|
|
88
|
+
*/
|
|
89
|
+
export declare function parsePlainValues(raw: string | boolean | string[] | undefined, example?: string): Record<string, string>;
|
|
90
|
+
/**
|
|
91
|
+
* Each field's value by name; a field left empty is left out (its saved
|
|
92
|
+
* value stays). Refuses a required field that is neither saved nor given.
|
|
93
|
+
*/
|
|
94
|
+
export declare function collectSecrets(ctx: CliContext, input: CollectSecretsInput): Promise<Record<string, string>>;
|
|
95
|
+
/** Why a browser form cannot be filled here, or null when it can. */
|
|
96
|
+
export declare function desktopProblem(env: Record<string, string | undefined>, platform: NodeJS.Platform): string | null;
|