expandai 0.0.2 → 0.0.3
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 +42 -5
- package/build/dts/ApiClient.d.ts +128 -194
- package/build/dts/ApiClient.d.ts.map +1 -1
- package/build/dts/Auth/Domain.d.ts +95 -0
- package/build/dts/Auth/Domain.d.ts.map +1 -0
- package/build/dts/Auth/ManualAuthorization.d.ts +107 -0
- package/build/dts/Auth/ManualAuthorization.d.ts.map +1 -0
- package/build/dts/Auth/PendingInit.d.ts +35 -0
- package/build/dts/Auth/PendingInit.d.ts.map +1 -0
- package/build/dts/Auth/Pkce.d.ts +10 -0
- package/build/dts/Auth/Pkce.d.ts.map +1 -0
- package/build/dts/Auth/SetupGrantAuthorization.d.ts +34 -0
- package/build/dts/Auth/SetupGrantAuthorization.d.ts.map +1 -0
- package/build/dts/Authentication.d.ts +10 -9
- package/build/dts/Authentication.d.ts.map +1 -1
- package/build/dts/BrowserOAuth.d.ts +7 -7
- package/build/dts/BrowserOAuth.d.ts.map +1 -1
- package/build/dts/CommandPath.d.ts +2 -0
- package/build/dts/CommandPath.d.ts.map +1 -0
- package/build/dts/Commands/Fetch.d.ts.map +1 -1
- package/build/dts/Commands/Hook.d.ts +5 -5
- package/build/dts/Commands/Init.d.ts +55 -0
- package/build/dts/Commands/Init.d.ts.map +1 -0
- package/build/dts/Commands/Mcp.d.ts +6 -6
- package/build/dts/Commands/Search.d.ts.map +1 -1
- package/build/dts/Commands/Skill.d.ts +4 -4
- package/build/dts/Commands/Status.d.ts +27 -0
- package/build/dts/Commands/Status.d.ts.map +1 -0
- package/build/dts/Commands/Telemetry.d.ts +7 -0
- package/build/dts/Commands/Telemetry.d.ts.map +1 -0
- package/build/dts/Commands/Uninstall.d.ts +4 -4
- package/build/dts/Config.d.ts +108 -17
- package/build/dts/Config.d.ts.map +1 -1
- package/build/dts/Hook/SnapshotNote.d.ts +8 -4
- package/build/dts/Hook/SnapshotNote.d.ts.map +1 -1
- package/build/dts/Init/ClaudeAdapter.d.ts +61 -0
- package/build/dts/Init/ClaudeAdapter.d.ts.map +1 -0
- package/build/dts/Init/ClaudeProfile.d.ts +78 -0
- package/build/dts/Init/ClaudeProfile.d.ts.map +1 -0
- package/build/dts/Init/Domain.d.ts +139 -0
- package/build/dts/Init/Domain.d.ts.map +1 -0
- package/build/dts/Init/HarnessAdapter.d.ts +33 -0
- package/build/dts/Init/HarnessAdapter.d.ts.map +1 -0
- package/build/dts/Init/HarnessDetector.d.ts +19 -0
- package/build/dts/Init/HarnessDetector.d.ts.map +1 -0
- package/build/dts/Init/HarnessRegistry.d.ts +50 -0
- package/build/dts/Init/HarnessRegistry.d.ts.map +1 -0
- package/build/dts/Init/MutationTransaction.d.ts +96 -0
- package/build/dts/Init/MutationTransaction.d.ts.map +1 -0
- package/build/dts/Init/Service.d.ts +72 -0
- package/build/dts/Init/Service.d.ts.map +1 -0
- package/build/dts/Init/StandardAdapters.d.ts +42 -0
- package/build/dts/Init/StandardAdapters.d.ts.map +1 -0
- package/build/dts/Init/Status.d.ts +149 -0
- package/build/dts/Init/Status.d.ts.map +1 -0
- package/build/dts/Install/Claude/Hook.d.ts +85 -25
- package/build/dts/Install/Claude/Hook.d.ts.map +1 -1
- package/build/dts/Install/Claude/Mcp.d.ts +21 -18
- package/build/dts/Install/Claude/Mcp.d.ts.map +1 -1
- package/build/dts/Install/Claude/Skill.d.ts +14 -15
- package/build/dts/Install/Claude/Skill.d.ts.map +1 -1
- package/build/dts/Install/Codex/Mcp.d.ts +14 -8
- package/build/dts/Install/Codex/Mcp.d.ts.map +1 -1
- package/build/dts/Install/Codex/Skill.d.ts +4 -2
- package/build/dts/Install/Codex/Skill.d.ts.map +1 -1
- package/build/dts/Install/Cursor/Mcp.d.ts +13 -13
- package/build/dts/Install/Cursor/Mcp.d.ts.map +1 -1
- package/build/dts/Install/Cursor/Skill.d.ts +4 -2
- package/build/dts/Install/Cursor/Skill.d.ts.map +1 -1
- package/build/dts/Install/Domain.d.ts +35 -5
- package/build/dts/Install/Domain.d.ts.map +1 -1
- package/build/dts/Install/Hook.d.ts +4 -4
- package/build/dts/Install/Hook.d.ts.map +1 -1
- package/build/dts/Install/Mcp.d.ts +5 -5
- package/build/dts/Install/Mcp.d.ts.map +1 -1
- package/build/dts/Install/Opencode/Hook.d.ts +4 -3
- package/build/dts/Install/Opencode/Hook.d.ts.map +1 -1
- package/build/dts/Install/Opencode/Mcp.d.ts +11 -11
- package/build/dts/Install/Opencode/Mcp.d.ts.map +1 -1
- package/build/dts/Install/Opencode/Skill.d.ts +4 -2
- package/build/dts/Install/Opencode/Skill.d.ts.map +1 -1
- package/build/dts/Install/Skill.d.ts +3 -3
- package/build/dts/Install/Uninstall.d.ts +6 -6
- package/build/dts/Install/Uninstall.d.ts.map +1 -1
- package/build/dts/MarkdownAnsi.d.ts +29 -0
- package/build/dts/MarkdownAnsi.d.ts.map +1 -0
- package/build/dts/Mcp/ExpandDocs.d.ts +17 -5
- package/build/dts/Mcp/ExpandDocs.d.ts.map +1 -1
- package/build/dts/Mcp/Server.d.ts +1 -1
- package/build/dts/Mcp/Server.d.ts.map +1 -1
- package/build/dts/Mcp/tools/Citations.d.ts +100 -71
- package/build/dts/Mcp/tools/Citations.d.ts.map +1 -1
- package/build/dts/Mcp/tools/Fetch.d.ts +16 -15
- package/build/dts/Mcp/tools/Fetch.d.ts.map +1 -1
- package/build/dts/SecureFile.d.ts +23 -0
- package/build/dts/SecureFile.d.ts.map +1 -0
- package/build/dts/Snippets.d.ts +11 -12
- package/build/dts/Snippets.d.ts.map +1 -1
- package/build/dts/Telemetry.d.ts +30 -0
- package/build/dts/Telemetry.d.ts.map +1 -0
- package/build/dts/Version.d.ts +2 -0
- package/build/dts/Version.d.ts.map +1 -0
- package/build/dts/Xdg.d.ts +11 -0
- package/build/dts/Xdg.d.ts.map +1 -0
- package/build/esm/ApiClient.js +133 -10
- package/build/esm/ApiClient.js.map +1 -1
- package/build/esm/Auth/Domain.js +56 -0
- package/build/esm/Auth/Domain.js.map +1 -0
- package/build/esm/Auth/ManualAuthorization.js +253 -0
- package/build/esm/Auth/ManualAuthorization.js.map +1 -0
- package/build/esm/Auth/PendingInit.js +267 -0
- package/build/esm/Auth/PendingInit.js.map +1 -0
- package/build/esm/Auth/Pkce.js +23 -0
- package/build/esm/Auth/Pkce.js.map +1 -0
- package/build/esm/Auth/SetupGrantAuthorization.js +91 -0
- package/build/esm/Auth/SetupGrantAuthorization.js.map +1 -0
- package/build/esm/Authentication.js +38 -7
- package/build/esm/Authentication.js.map +1 -1
- package/build/esm/BrowserOAuth.js +7 -10
- package/build/esm/BrowserOAuth.js.map +1 -1
- package/build/esm/CommandPath.js +31 -0
- package/build/esm/CommandPath.js.map +1 -0
- package/build/esm/Commands/Fetch.js +30 -6
- package/build/esm/Commands/Fetch.js.map +1 -1
- package/build/esm/Commands/Init.js +371 -0
- package/build/esm/Commands/Init.js.map +1 -0
- package/build/esm/Commands/Search.js +6 -3
- package/build/esm/Commands/Search.js.map +1 -1
- package/build/esm/Commands/Status.js +186 -0
- package/build/esm/Commands/Status.js.map +1 -0
- package/build/esm/Commands/Telemetry.js +23 -0
- package/build/esm/Commands/Telemetry.js.map +1 -0
- package/build/esm/Config.js +502 -28
- package/build/esm/Config.js.map +1 -1
- package/build/esm/Hook/Claude.js +1 -1
- package/build/esm/Hook/Claude.js.map +1 -1
- package/build/esm/Hook/SnapshotNote.js +54 -7
- package/build/esm/Hook/SnapshotNote.js.map +1 -1
- package/build/esm/Init/ClaudeAdapter.js +137 -0
- package/build/esm/Init/ClaudeAdapter.js.map +1 -0
- package/build/esm/Init/ClaudeProfile.js +380 -0
- package/build/esm/Init/ClaudeProfile.js.map +1 -0
- package/build/esm/Init/Domain.js +118 -0
- package/build/esm/Init/Domain.js.map +1 -0
- package/build/esm/Init/HarnessAdapter.js +21 -0
- package/build/esm/Init/HarnessAdapter.js.map +1 -0
- package/build/esm/Init/HarnessDetector.js +65 -0
- package/build/esm/Init/HarnessDetector.js.map +1 -0
- package/build/esm/Init/HarnessRegistry.js +58 -0
- package/build/esm/Init/HarnessRegistry.js.map +1 -0
- package/build/esm/Init/MutationTransaction.js +494 -0
- package/build/esm/Init/MutationTransaction.js.map +1 -0
- package/build/esm/Init/Service.js +319 -0
- package/build/esm/Init/Service.js.map +1 -0
- package/build/esm/Init/StandardAdapters.js +116 -0
- package/build/esm/Init/StandardAdapters.js.map +1 -0
- package/build/esm/Init/Status.js +215 -0
- package/build/esm/Init/Status.js.map +1 -0
- package/build/esm/Install/Claude/Hook.js +182 -50
- package/build/esm/Install/Claude/Hook.js.map +1 -1
- package/build/esm/Install/Claude/Mcp.js +56 -26
- package/build/esm/Install/Claude/Mcp.js.map +1 -1
- package/build/esm/Install/Claude/Skill.js +49 -27
- package/build/esm/Install/Claude/Skill.js.map +1 -1
- package/build/esm/Install/Codex/Mcp.js +9 -4
- package/build/esm/Install/Codex/Mcp.js.map +1 -1
- package/build/esm/Install/Codex/Skill.js +4 -1
- package/build/esm/Install/Codex/Skill.js.map +1 -1
- package/build/esm/Install/Cursor/Mcp.js +7 -4
- package/build/esm/Install/Cursor/Mcp.js.map +1 -1
- package/build/esm/Install/Cursor/Skill.js +4 -1
- package/build/esm/Install/Cursor/Skill.js.map +1 -1
- package/build/esm/Install/Domain.js +60 -3
- package/build/esm/Install/Domain.js.map +1 -1
- package/build/esm/Install/Hook.js.map +1 -1
- package/build/esm/Install/Mcp.js +1 -1
- package/build/esm/Install/Mcp.js.map +1 -1
- package/build/esm/Install/Opencode/Hook.js +1 -1
- package/build/esm/Install/Opencode/Hook.js.map +1 -1
- package/build/esm/Install/Opencode/Mcp.js +6 -3
- package/build/esm/Install/Opencode/Mcp.js.map +1 -1
- package/build/esm/Install/Opencode/Skill.js +4 -1
- package/build/esm/Install/Opencode/Skill.js.map +1 -1
- package/build/esm/Install/Opencode/expandai-webfetch.js +27 -9
- package/build/esm/Install/Skill.js.map +1 -1
- package/build/esm/Install/Uninstall.js +2 -2
- package/build/esm/Install/Uninstall.js.map +1 -1
- package/build/esm/MarkdownAnsi.js +142 -0
- package/build/esm/MarkdownAnsi.js.map +1 -0
- package/build/esm/Mcp/ExpandDocs.js +44 -52
- package/build/esm/Mcp/ExpandDocs.js.map +1 -1
- package/build/esm/Mcp/RequestHeaders.js.map +1 -1
- package/build/esm/Mcp/Server.js +2 -1
- package/build/esm/Mcp/Server.js.map +1 -1
- package/build/esm/Mcp/tools/Citations.js +109 -8
- package/build/esm/Mcp/tools/Citations.js.map +1 -1
- package/build/esm/Mcp/tools/Fetch.js +36 -12
- package/build/esm/Mcp/tools/Fetch.js.map +1 -1
- package/build/esm/SecureFile.js +36 -0
- package/build/esm/SecureFile.js.map +1 -0
- package/build/esm/Snippets.js +12 -1
- package/build/esm/Snippets.js.map +1 -1
- package/build/esm/Telemetry.js +157 -0
- package/build/esm/Telemetry.js.map +1 -0
- package/build/esm/Version.js +4 -0
- package/build/esm/Version.js.map +1 -0
- package/build/esm/Xdg.js +15 -0
- package/build/esm/Xdg.js.map +1 -0
- package/build/esm/main.js +122 -14
- package/build/esm/main.js.map +1 -1
- package/build/skills/expandai/SKILL.md +26 -3
- package/package.json +4 -4
- package/src/ApiClient.ts +206 -13
- package/src/Auth/Domain.ts +63 -0
- package/src/Auth/ManualAuthorization.ts +296 -0
- package/src/Auth/PendingInit.ts +338 -0
- package/src/Auth/Pkce.ts +26 -0
- package/src/Auth/SetupGrantAuthorization.ts +116 -0
- package/src/Authentication.ts +49 -11
- package/src/BrowserOAuth.ts +10 -12
- package/src/CommandPath.ts +30 -0
- package/src/Commands/Fetch.ts +42 -11
- package/src/Commands/Init.ts +489 -0
- package/src/Commands/Search.ts +8 -3
- package/src/Commands/Status.ts +228 -0
- package/src/Commands/Telemetry.ts +42 -0
- package/src/Config.ts +695 -50
- package/src/Hook/Claude.ts +1 -1
- package/src/Hook/SnapshotNote.ts +55 -7
- package/src/Init/ClaudeAdapter.ts +191 -0
- package/src/Init/ClaudeProfile.ts +496 -0
- package/src/Init/Domain.ts +153 -0
- package/src/Init/HarnessAdapter.ts +35 -0
- package/src/Init/HarnessDetector.ts +77 -0
- package/src/Init/HarnessRegistry.ts +87 -0
- package/src/Init/MutationTransaction.ts +747 -0
- package/src/Init/Service.ts +385 -0
- package/src/Init/StandardAdapters.ts +141 -0
- package/src/Init/Status.ts +253 -0
- package/src/Install/Claude/Hook.ts +234 -81
- package/src/Install/Claude/Mcp.ts +80 -33
- package/src/Install/Claude/Skill.ts +70 -37
- package/src/Install/Codex/Mcp.ts +14 -9
- package/src/Install/Codex/Skill.ts +9 -5
- package/src/Install/Cursor/Mcp.ts +12 -9
- package/src/Install/Cursor/Skill.ts +9 -5
- package/src/Install/Domain.ts +77 -4
- package/src/Install/Hook.ts +3 -3
- package/src/Install/Mcp.ts +6 -6
- package/src/Install/Opencode/Hook.ts +5 -5
- package/src/Install/Opencode/Mcp.ts +11 -8
- package/src/Install/Opencode/Skill.ts +9 -5
- package/src/Install/Opencode/expandai-webfetch.js +27 -9
- package/src/Install/Skill.ts +3 -3
- package/src/Install/Uninstall.ts +3 -3
- package/src/MarkdownAnsi.ts +162 -0
- package/src/Mcp/ExpandDocs.ts +46 -55
- package/src/Mcp/Server.ts +2 -1
- package/src/Mcp/tools/Citations.ts +117 -8
- package/src/Mcp/tools/Fetch.ts +40 -14
- package/src/SecureFile.ts +70 -0
- package/src/Snippets.ts +19 -12
- package/src/Telemetry.ts +186 -0
- package/src/Version.ts +3 -0
- package/src/Xdg.ts +18 -0
- package/src/main.ts +171 -12
- package/build/dts/DeviceAuth.d.ts +0 -87
- package/build/dts/DeviceAuth.d.ts.map +0 -1
- package/build/esm/DeviceAuth.js +0 -127
- package/build/esm/DeviceAuth.js.map +0 -1
- package/src/DeviceAuth.ts +0 -193
package/build/esm/Config.js
CHANGED
|
@@ -1,28 +1,103 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CLI configuration. Persisted at `$XDG_CONFIG_HOME/expandai/config.json`
|
|
3
|
-
* (default `~/.config/expandai/config.json`).
|
|
4
|
-
* `
|
|
3
|
+
* (default `~/.config/expandai/config.json`). `EXPAND_API_URL` overrides the
|
|
4
|
+
* file. `EXPAND_API_KEY` overrides an API key stored in the file, but not
|
|
5
|
+
* credentials established by `expandai login` or agent setup — signing in is a
|
|
6
|
+
* deliberate act, and an exported API key usually is not. `EXPAND_APP_URL`
|
|
7
|
+
* points guide links at the web app serving them.
|
|
5
8
|
*/
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { NodeFileSystem, NodePath } from '@effect/platform-node';
|
|
9
|
+
import { Command, CommandExecutor, FileSystem, Path, Error as PlatformError } from '@effect/platform';
|
|
10
|
+
import { NodeContext } from '@effect/platform-node';
|
|
9
11
|
import { DEFAULT_PLAYGROUND_HOST } from '@expandai/sdk/Playground';
|
|
10
|
-
import { Config, DateTime, Effect, Option, Schema } from 'effect';
|
|
12
|
+
import { Config, DateTime, Effect, Either, Exit, Option, Random, Redacted, Schema } from 'effect';
|
|
13
|
+
import { createExclusiveFile, matchesCreatedFile } from './SecureFile.js';
|
|
14
|
+
import { xdgConfigDirectory } from './Xdg.js';
|
|
11
15
|
/** Production API endpoint used when neither `EXPAND_API_URL` nor the config file overrides it. */
|
|
12
16
|
export const DEFAULT_API_URL = 'https://api.expand.ai';
|
|
17
|
+
/** Production web app used when `EXPAND_APP_URL` does not override it. */
|
|
18
|
+
export const DEFAULT_APP_URL = 'https://expand.ai';
|
|
19
|
+
/**
|
|
20
|
+
* Lenient env readers. `Config.option` / `Config.withDefault` only absorb
|
|
21
|
+
* missing data — a present-but-malformed value (`CLAUDECODE=banana`,
|
|
22
|
+
* `EXPANDAI_HARNESS=bogus`) fails as InvalidData and would crash the CLI at
|
|
23
|
+
* layer construction. These read the raw string and degrade gracefully:
|
|
24
|
+
* flags follow the DO_NOT_TRACK convention (set unless an explicit off),
|
|
25
|
+
* literals decode to None instead of failing.
|
|
26
|
+
*/
|
|
27
|
+
export const envFlag = (name) => Config.string(name).pipe(Config.option, Config.map(Option.match({
|
|
28
|
+
onNone: () => false,
|
|
29
|
+
onSome: (value) => value !== '' && value !== '0' && value !== 'false',
|
|
30
|
+
})));
|
|
31
|
+
export const envLiteral = (name, schema) => Config.string(name).pipe(Config.option, Config.map(Option.flatMap(Schema.decodeUnknownOption(schema))));
|
|
13
32
|
export const ApiKey = Schema.Redacted(Schema.NonEmptyTrimmedString.pipe(Schema.brand('ApiKey')));
|
|
14
33
|
export const ApiKeyConfig = Schema.TaggedStruct('ApiKey', { apiKey: ApiKey });
|
|
15
34
|
export const OAuthConfig = Schema.TaggedStruct('OAuth', {
|
|
16
35
|
accessToken: Schema.Redacted(Schema.String),
|
|
17
36
|
refreshToken: Schema.optional(Schema.Redacted(Schema.String)),
|
|
18
37
|
expiresAt: Schema.DateTimeUtc,
|
|
38
|
+
// Refresh tokens are bound to their issuing client, so a credential minted by
|
|
39
|
+
// browser-approved setup cannot be refreshed as `expandai-cli`. Optional because
|
|
40
|
+
// configs written before this existed all came from `expandai login`.
|
|
41
|
+
clientId: Schema.optional(Schema.String),
|
|
42
|
+
});
|
|
43
|
+
export const TelemetrySettings = Schema.Struct({
|
|
44
|
+
enabled: Schema.optional(Schema.Boolean),
|
|
45
|
+
distinctId: Schema.optional(Schema.String),
|
|
46
|
+
noticeShown: Schema.optional(Schema.Boolean),
|
|
19
47
|
});
|
|
20
48
|
export class ExpandCliConfig extends Schema.Class('ExpandCliConfig')({
|
|
21
49
|
apiUrl: Schema.optional(Schema.URL),
|
|
22
50
|
authMethod: Schema.optional(Schema.Union(ApiKeyConfig, OAuthConfig)),
|
|
51
|
+
telemetry: Schema.optional(TelemetrySettings),
|
|
23
52
|
}) {
|
|
24
53
|
static Json = Schema.parseJson(ExpandCliConfig);
|
|
25
54
|
}
|
|
55
|
+
export function authMethodState(authMethod, now) {
|
|
56
|
+
if (authMethod._tag === 'ApiKey')
|
|
57
|
+
return 'healthy';
|
|
58
|
+
if (DateTime.lessThan(now, authMethod.expiresAt)) {
|
|
59
|
+
return Redacted.value(authMethod.accessToken).trim().length > 0 ? 'healthy' : 'unusable';
|
|
60
|
+
}
|
|
61
|
+
const refreshToken = authMethod.refreshToken;
|
|
62
|
+
return refreshToken !== undefined && Redacted.value(refreshToken).trim().length > 0 ? 'refresh_required' : 'unusable';
|
|
63
|
+
}
|
|
64
|
+
/** Returns whether a local credential can be used now or renewed on first API use. */
|
|
65
|
+
export function isAuthMethodLocallyUsable(authMethod, now) {
|
|
66
|
+
return authMethodState(authMethod, now) !== 'unusable';
|
|
67
|
+
}
|
|
68
|
+
const RawConfigRecord = Schema.Record({ key: Schema.String, value: Schema.Unknown });
|
|
69
|
+
const RawConfigJson = Schema.parseJson(RawConfigRecord);
|
|
70
|
+
// Unknown top-level keys survive a write, so a config written by a newer CLI
|
|
71
|
+
// is not silently truncated by an older one.
|
|
72
|
+
function mergeRawConfig(existing, known) {
|
|
73
|
+
return { ...existing, ...known };
|
|
74
|
+
}
|
|
75
|
+
function isErrnoCode(input, code) {
|
|
76
|
+
return typeof input === 'object' && input !== null && 'code' in input && input.code === code;
|
|
77
|
+
}
|
|
78
|
+
function powerShellLiteral(value) {
|
|
79
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
80
|
+
}
|
|
81
|
+
function scriptWithTargetPaths(script, targetPaths) {
|
|
82
|
+
return `$targetPaths = @(${targetPaths.map((targetPath) => powerShellLiteral(targetPath)).join(', ')})
|
|
83
|
+
try {
|
|
84
|
+
Import-Module (Join-Path $PSHOME 'Modules/Microsoft.PowerShell.Security/Microsoft.PowerShell.Security.psd1') -ErrorAction Stop
|
|
85
|
+
$null = & {
|
|
86
|
+
${script}
|
|
87
|
+
}
|
|
88
|
+
exit 0
|
|
89
|
+
} catch {
|
|
90
|
+
[Console]::Error.WriteLine($_.Exception.Message)
|
|
91
|
+
exit 1
|
|
92
|
+
}`;
|
|
93
|
+
}
|
|
94
|
+
function sameIdentity(left, right) {
|
|
95
|
+
return (left.exists === right.exists &&
|
|
96
|
+
left.dev === right.dev &&
|
|
97
|
+
left.ino === right.ino &&
|
|
98
|
+
left.realPath === right.realPath &&
|
|
99
|
+
left.type === right.type);
|
|
100
|
+
}
|
|
26
101
|
export class ConfigNotWritable extends Schema.TaggedError()('ConfigNotWritable', {
|
|
27
102
|
path: Schema.String,
|
|
28
103
|
}) {
|
|
@@ -30,6 +105,16 @@ export class ConfigNotWritable extends Schema.TaggedError()('ConfigNotWritable',
|
|
|
30
105
|
return `Cannot write to ${this.path}. Check filesystem permissions on the config directory.`;
|
|
31
106
|
}
|
|
32
107
|
}
|
|
108
|
+
export class ConfigCleanupError extends Schema.TaggedError()('ConfigCleanupError', {
|
|
109
|
+
path: Schema.String,
|
|
110
|
+
temporaryPath: Schema.String,
|
|
111
|
+
error: Schema.Defect,
|
|
112
|
+
operationError: Schema.Defect,
|
|
113
|
+
}) {
|
|
114
|
+
get message() {
|
|
115
|
+
return `Could not safely clean up config temporary state at ${this.temporaryPath}; it was left unchanged.`;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
33
118
|
export class ConfigParseError extends Schema.TaggedError()('ConfigParseError', {
|
|
34
119
|
path: Schema.String,
|
|
35
120
|
error: Schema.Defect,
|
|
@@ -38,6 +123,15 @@ export class ConfigParseError extends Schema.TaggedError()('ConfigParseError', {
|
|
|
38
123
|
return `Could not parse ${this.path}. Delete the file and run \`expandai login\` to recreate it.`;
|
|
39
124
|
}
|
|
40
125
|
}
|
|
126
|
+
export class CredentialStoreInsecure extends Schema.TaggedError()('CredentialStoreInsecure', {
|
|
127
|
+
path: Schema.String,
|
|
128
|
+
reason: Schema.String,
|
|
129
|
+
error: Schema.Defect,
|
|
130
|
+
}) {
|
|
131
|
+
get message() {
|
|
132
|
+
return `Credential storage at ${this.path} is insecure: ${this.reason}`;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
41
135
|
export class NotAuthenticated extends Schema.TaggedError()('NotAuthenticated', {}) {
|
|
42
136
|
get message() {
|
|
43
137
|
return 'Not authenticated. Run `expandai login` to authenticate.';
|
|
@@ -59,19 +153,353 @@ const Environment = Effect.gen(function* () {
|
|
|
59
153
|
});
|
|
60
154
|
});
|
|
61
155
|
export class ConfigManager extends Effect.Service()('@expandai/cli/Config/ConfigManager', {
|
|
62
|
-
dependencies: [
|
|
156
|
+
dependencies: [NodeContext.layer],
|
|
63
157
|
effect: Effect.gen(function* () {
|
|
158
|
+
const commandExecutor = yield* CommandExecutor.CommandExecutor;
|
|
64
159
|
const fs = yield* FileSystem.FileSystem;
|
|
65
160
|
const path = yield* Path.Path;
|
|
66
|
-
const configPath =
|
|
67
|
-
const
|
|
161
|
+
const configPath = path.join(yield* xdgConfigDirectory, 'expandai', 'config.json');
|
|
162
|
+
const configDirectory = path.dirname(configPath);
|
|
163
|
+
const insecure = (targetPath, reason, error) => new CredentialStoreInsecure({ path: targetPath, reason, error });
|
|
164
|
+
const assertNotSymbolicLink = Effect.fnUntraced(function* (targetPath) {
|
|
165
|
+
const result = yield* Effect.either(fs.readLink(targetPath));
|
|
166
|
+
if (Either.isRight(result)) {
|
|
167
|
+
return yield* insecure(targetPath, 'symbolic links are not allowed for credential storage', new Error(result.right));
|
|
168
|
+
}
|
|
169
|
+
const error = result.left;
|
|
170
|
+
if (error instanceof PlatformError.SystemError &&
|
|
171
|
+
(error.reason === 'InvalidData' ||
|
|
172
|
+
error.reason === 'NotFound' ||
|
|
173
|
+
(error.method === 'readLink' && isErrnoCode(error.cause, 'EINVAL')))) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
return yield* insecure(targetPath, 'could not inspect the credential path without following links', error);
|
|
177
|
+
});
|
|
178
|
+
const inspectPath = Effect.fnUntraced(function* (targetPath) {
|
|
179
|
+
yield* assertNotSymbolicLink(targetPath);
|
|
180
|
+
const exists = yield* fs
|
|
181
|
+
.exists(targetPath)
|
|
182
|
+
.pipe(Effect.mapError((error) => insecure(targetPath, 'could not inspect the credential path', error)));
|
|
183
|
+
if (!exists) {
|
|
184
|
+
const missing = { exists: false };
|
|
185
|
+
return missing;
|
|
186
|
+
}
|
|
187
|
+
const info = yield* fs
|
|
188
|
+
.stat(targetPath)
|
|
189
|
+
.pipe(Effect.mapError((error) => insecure(targetPath, 'could not inspect the credential path', error)));
|
|
190
|
+
const realPath = yield* fs
|
|
191
|
+
.realPath(targetPath)
|
|
192
|
+
.pipe(Effect.mapError((error) => insecure(targetPath, 'could not resolve the credential path', error)));
|
|
193
|
+
const identity = {
|
|
194
|
+
exists: true,
|
|
195
|
+
dev: info.dev,
|
|
196
|
+
ino: Option.getOrUndefined(info.ino),
|
|
197
|
+
realPath,
|
|
198
|
+
type: info.type,
|
|
199
|
+
};
|
|
200
|
+
return identity;
|
|
201
|
+
});
|
|
202
|
+
const assertStable = (targetPath, before, after) => sameIdentity(before, after)
|
|
203
|
+
? Effect.void
|
|
204
|
+
: Effect.fail(insecure(targetPath, 'credential path identity changed during the operation', new Error('Path swap detected')));
|
|
205
|
+
const inspectParentAndFile = Effect.fnUntraced(function* (targetPath) {
|
|
206
|
+
const parentPath = path.dirname(targetPath);
|
|
207
|
+
const parent = yield* inspectPath(parentPath);
|
|
208
|
+
if (!parent.exists || parent.type !== 'Directory') {
|
|
209
|
+
return yield* insecure(parentPath, 'credential parent is not a directory', new Error('Invalid parent'));
|
|
210
|
+
}
|
|
211
|
+
return { parentPath, parent, file: yield* inspectPath(targetPath) };
|
|
212
|
+
});
|
|
213
|
+
const readCredentialFile = Effect.fnUntraced(function* (targetPath) {
|
|
214
|
+
const before = yield* inspectParentAndFile(targetPath);
|
|
215
|
+
if (!before.file.exists || before.file.type !== 'File') {
|
|
216
|
+
return yield* insecure(targetPath, 'credential path is not a regular file', new Error('Invalid file'));
|
|
217
|
+
}
|
|
218
|
+
const contents = yield* fs
|
|
219
|
+
.readFileString(targetPath)
|
|
220
|
+
.pipe(Effect.mapError((error) => insecure(targetPath, 'could not read the credential file', error)));
|
|
221
|
+
const after = yield* inspectParentAndFile(targetPath);
|
|
222
|
+
yield* assertStable(before.parentPath, before.parent, after.parent);
|
|
223
|
+
yield* assertStable(targetPath, before.file, after.file);
|
|
224
|
+
return contents;
|
|
225
|
+
});
|
|
226
|
+
const ensurePosixMode = Effect.fnUntraced(function* (targetPath, mode, expectedType) {
|
|
227
|
+
const before = yield* inspectPath(targetPath);
|
|
228
|
+
if (!before.exists || before.type !== expectedType) {
|
|
229
|
+
return yield* insecure(targetPath, `expected a ${expectedType.toLowerCase()}`, new Error('Invalid path type'));
|
|
230
|
+
}
|
|
231
|
+
yield* fs
|
|
232
|
+
.chmod(targetPath, mode)
|
|
233
|
+
.pipe(Effect.mapError((error) => insecure(targetPath, `could not set mode ${mode.toString(8)}`, error)));
|
|
234
|
+
const info = yield* fs
|
|
235
|
+
.stat(targetPath)
|
|
236
|
+
.pipe(Effect.mapError((error) => insecure(targetPath, 'could not verify permissions', error)));
|
|
237
|
+
const actualMode = info.mode & 0o7777;
|
|
238
|
+
if (info.type !== expectedType || actualMode !== mode) {
|
|
239
|
+
return yield* insecure(targetPath, `expected ${expectedType.toLowerCase()} mode ${mode.toString(8)}, found ${info.type.toLowerCase()} mode ${actualMode.toString(8)}`, new Error('Permission verification failed'));
|
|
240
|
+
}
|
|
241
|
+
const after = yield* inspectPath(targetPath);
|
|
242
|
+
yield* assertStable(targetPath, before, after);
|
|
243
|
+
});
|
|
244
|
+
const WINDOWS_ACL_SCRIPT = String.raw `
|
|
245
|
+
$ErrorActionPreference = 'Stop'
|
|
246
|
+
$currentSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
|
247
|
+
$allowedSids = @($currentSid, 'S-1-5-18', 'S-1-5-32-544')
|
|
248
|
+
foreach ($targetPath in $targetPaths) {
|
|
249
|
+
if (-not (Test-Path -LiteralPath $targetPath)) { throw "Missing credential path: $targetPath" }
|
|
250
|
+
$item = Get-Item -LiteralPath $targetPath -Force
|
|
251
|
+
$acl = Get-Acl -LiteralPath $targetPath
|
|
252
|
+
$acl.SetAccessRuleProtection($true, $false)
|
|
253
|
+
foreach ($rule in @($acl.Access)) { [void]$acl.RemoveAccessRuleSpecific($rule) }
|
|
254
|
+
$inheritance = if ($item.PSIsContainer) {
|
|
255
|
+
[System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'
|
|
256
|
+
} else {
|
|
257
|
+
[System.Security.AccessControl.InheritanceFlags]::None
|
|
258
|
+
}
|
|
259
|
+
foreach ($sidValue in $allowedSids) {
|
|
260
|
+
$sid = [System.Security.Principal.SecurityIdentifier]::new($sidValue)
|
|
261
|
+
$rule = [System.Security.AccessControl.FileSystemAccessRule]::new(
|
|
262
|
+
$sid,
|
|
263
|
+
[System.Security.AccessControl.FileSystemRights]::FullControl,
|
|
264
|
+
$inheritance,
|
|
265
|
+
[System.Security.AccessControl.PropagationFlags]::None,
|
|
266
|
+
[System.Security.AccessControl.AccessControlType]::Allow
|
|
267
|
+
)
|
|
268
|
+
[void]$acl.AddAccessRule($rule)
|
|
269
|
+
}
|
|
270
|
+
Set-Acl -LiteralPath $targetPath -AclObject $acl
|
|
271
|
+
|
|
272
|
+
$verified = Get-Acl -LiteralPath $targetPath
|
|
273
|
+
if (-not $verified.AreAccessRulesProtected) { throw "Inherited ACL remains enabled: $targetPath" }
|
|
274
|
+
$seenSids = @{}
|
|
275
|
+
foreach ($rule in @($verified.Access)) {
|
|
276
|
+
$sid = $rule.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value
|
|
277
|
+
if ($allowedSids -notcontains $sid) { throw "Unexpected ACL principal $sid on $targetPath" }
|
|
278
|
+
if ($rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) {
|
|
279
|
+
throw "Unexpected deny ACL for $sid on $targetPath"
|
|
280
|
+
}
|
|
281
|
+
$requiredRights = [System.Security.AccessControl.FileSystemRights]::FullControl
|
|
282
|
+
if (($rule.FileSystemRights -band $requiredRights) -ne $requiredRights) {
|
|
283
|
+
throw "ACL principal $sid lacks FullControl on $targetPath"
|
|
284
|
+
}
|
|
285
|
+
$seenSids[$sid] = $true
|
|
286
|
+
}
|
|
287
|
+
foreach ($allowedSid in $allowedSids) {
|
|
288
|
+
if (-not $seenSids.ContainsKey($allowedSid)) { throw "Missing ACL principal $allowedSid on $targetPath" }
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
'SECURE'
|
|
292
|
+
`;
|
|
293
|
+
const WINDOWS_ACL_INSPECTION_SCRIPT = String.raw `
|
|
294
|
+
$ErrorActionPreference = 'Stop'
|
|
295
|
+
$currentSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
|
296
|
+
$allowedSids = @($currentSid, 'S-1-5-18', 'S-1-5-32-544')
|
|
297
|
+
foreach ($targetPath in $targetPaths) {
|
|
298
|
+
if (-not (Test-Path -LiteralPath $targetPath)) { throw "Missing credential path: $targetPath" }
|
|
299
|
+
$acl = Get-Acl -LiteralPath $targetPath
|
|
300
|
+
if (-not $acl.AreAccessRulesProtected) { throw "Inherited ACL remains enabled: $targetPath" }
|
|
301
|
+
$seenSids = @{}
|
|
302
|
+
foreach ($rule in @($acl.Access)) {
|
|
303
|
+
$sid = $rule.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value
|
|
304
|
+
if ($allowedSids -notcontains $sid) { throw "Unexpected ACL principal $sid on $targetPath" }
|
|
305
|
+
if ($rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) {
|
|
306
|
+
throw "Unexpected deny ACL for $sid on $targetPath"
|
|
307
|
+
}
|
|
308
|
+
$requiredRights = [System.Security.AccessControl.FileSystemRights]::FullControl
|
|
309
|
+
if (($rule.FileSystemRights -band $requiredRights) -ne $requiredRights) {
|
|
310
|
+
throw "ACL principal $sid lacks FullControl on $targetPath"
|
|
311
|
+
}
|
|
312
|
+
$seenSids[$sid] = $true
|
|
313
|
+
}
|
|
314
|
+
foreach ($allowedSid in $allowedSids) {
|
|
315
|
+
if (-not $seenSids.ContainsKey($allowedSid)) { throw "Missing ACL principal $allowedSid on $targetPath" }
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
'SECURE'
|
|
319
|
+
`;
|
|
320
|
+
const ensureWindowsAcl = Effect.fnUntraced(function* (targetPaths) {
|
|
321
|
+
const exitCode = yield* Command.make('powershell.exe', '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', scriptWithTargetPaths(WINDOWS_ACL_SCRIPT, targetPaths)).pipe(Command.stderr('inherit'), Command.exitCode, Effect.provideService(CommandExecutor.CommandExecutor, commandExecutor), Effect.mapError((error) => insecure(configDirectory, 'could not apply or verify the Windows ACL allowlist', error)));
|
|
322
|
+
if (exitCode !== 0) {
|
|
323
|
+
return yield* insecure(configDirectory, 'Windows ACL verification failed', new Error(`PowerShell exited with status ${exitCode}`));
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
const inspectWindowsAcl = Effect.fnUntraced(function* (targetPaths) {
|
|
327
|
+
const exitCode = yield* Command.make('powershell.exe', '-NoLogo', '-NoProfile', '-NonInteractive', '-Command', scriptWithTargetPaths(WINDOWS_ACL_INSPECTION_SCRIPT, targetPaths)).pipe(Command.stderr('inherit'), Command.exitCode, Effect.provideService(CommandExecutor.CommandExecutor, commandExecutor), Effect.mapError((error) => insecure(configDirectory, 'could not inspect the Windows ACL allowlist', error)));
|
|
328
|
+
if (exitCode !== 0) {
|
|
329
|
+
return yield* insecure(configDirectory, 'Windows ACL inspection failed', new Error(`PowerShell exited with status ${exitCode}`));
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
const inspectSecureStorage = Effect.fn('ConfigManager.inspectSecureStorage')(function* () {
|
|
333
|
+
const directory = yield* inspectPath(configDirectory);
|
|
334
|
+
if (!directory.exists)
|
|
335
|
+
return yield* Effect.void;
|
|
336
|
+
if (directory.type !== 'Directory') {
|
|
337
|
+
return yield* insecure(configDirectory, 'config directory is not a regular directory', new Error('Invalid config directory'));
|
|
338
|
+
}
|
|
339
|
+
const file = yield* inspectPath(configPath);
|
|
340
|
+
if (file.exists && file.type !== 'File') {
|
|
341
|
+
return yield* insecure(configPath, 'credential path is not a regular file', new Error('Invalid file'));
|
|
342
|
+
}
|
|
343
|
+
if (process.platform === 'win32') {
|
|
344
|
+
const targets = file.exists ? [configDirectory, configPath] : [configDirectory];
|
|
345
|
+
const before = yield* Effect.forEach(targets, inspectPath);
|
|
346
|
+
yield* inspectWindowsAcl(targets);
|
|
347
|
+
const after = yield* Effect.forEach(targets, inspectPath);
|
|
348
|
+
yield* Effect.forEach(targets, (targetPath, index) => assertStable(targetPath, before[index] ?? { exists: false }, after[index] ?? { exists: false }), { discard: true });
|
|
349
|
+
return yield* Effect.void;
|
|
350
|
+
}
|
|
351
|
+
const directoryInfo = yield* fs
|
|
352
|
+
.stat(configDirectory)
|
|
353
|
+
.pipe(Effect.mapError((error) => insecure(configDirectory, 'could not inspect directory mode', error)));
|
|
354
|
+
if ((directoryInfo.mode & 0o7777) !== 0o700) {
|
|
355
|
+
return yield* insecure(configDirectory, 'config directory mode is not 700', new Error('Invalid directory mode'));
|
|
356
|
+
}
|
|
357
|
+
if (file.exists) {
|
|
358
|
+
const fileInfo = yield* fs
|
|
359
|
+
.stat(configPath)
|
|
360
|
+
.pipe(Effect.mapError((error) => insecure(configPath, 'could not inspect credential file mode', error)));
|
|
361
|
+
if ((fileInfo.mode & 0o7777) !== 0o600) {
|
|
362
|
+
return yield* insecure(configPath, 'credential file mode is not 600', new Error('Invalid file mode'));
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return yield* Effect.void;
|
|
366
|
+
});
|
|
367
|
+
const ensureSecureStorage = Effect.fn('ConfigManager.ensureSecureStorage')(function* () {
|
|
368
|
+
yield* fs
|
|
369
|
+
.makeDirectory(configDirectory, { recursive: true, mode: 0o700 })
|
|
370
|
+
.pipe(Effect.mapError((error) => insecure(configDirectory, 'could not create the config directory', error)));
|
|
371
|
+
const directory = yield* inspectPath(configDirectory);
|
|
372
|
+
if (!directory.exists || directory.type !== 'Directory') {
|
|
373
|
+
return yield* insecure(configDirectory, 'config directory is not a regular directory', new Error('Invalid config directory'));
|
|
374
|
+
}
|
|
375
|
+
const exists = yield* fs
|
|
376
|
+
.exists(configPath)
|
|
377
|
+
.pipe(Effect.mapError((error) => insecure(configPath, 'could not inspect the credential file', error)));
|
|
378
|
+
if (!exists) {
|
|
379
|
+
const creation = yield* Effect.either(fs.writeFileString(configPath, '{}', { flag: 'wx', mode: 0o600 }));
|
|
380
|
+
if (Either.isLeft(creation) &&
|
|
381
|
+
(!(creation.left instanceof PlatformError.SystemError) || creation.left.reason !== 'AlreadyExists')) {
|
|
382
|
+
return yield* insecure(configPath, 'could not create the credential file', creation.left);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
yield* inspectParentAndFile(configPath);
|
|
386
|
+
if (process.platform === 'win32') {
|
|
387
|
+
const targets = [configDirectory, configPath];
|
|
388
|
+
const before = yield* Effect.forEach(targets, (targetPath) => inspectPath(targetPath));
|
|
389
|
+
yield* ensureWindowsAcl(targets);
|
|
390
|
+
const after = yield* Effect.forEach(targets, (targetPath) => inspectPath(targetPath));
|
|
391
|
+
yield* Effect.forEach(targets, (targetPath, index) => assertStable(targetPath, before[index] ?? { exists: false }, after[index] ?? { exists: false }), { discard: true });
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
yield* ensurePosixMode(configDirectory, 0o700, 'Directory');
|
|
395
|
+
yield* ensurePosixMode(configPath, 0o600, 'File');
|
|
396
|
+
}
|
|
397
|
+
return yield* Effect.void;
|
|
398
|
+
});
|
|
399
|
+
const atomicWriteConfig = Effect.fnUntraced(function* (encoded) {
|
|
400
|
+
yield* ensureSecureStorage();
|
|
401
|
+
const before = yield* inspectParentAndFile(configPath);
|
|
402
|
+
const nonce = yield* Random.nextIntBetween(0, 0x7fffffff);
|
|
403
|
+
const temporaryPath = path.join(configDirectory, `.config.json.${process.pid}.${nonce.toString(16)}.tmp`);
|
|
404
|
+
const cleanupTemporary = Effect.fnUntraced(function* (created, operationError) {
|
|
405
|
+
const inspected = yield* Effect.either(inspectParentAndFile(temporaryPath));
|
|
406
|
+
if (Either.isLeft(inspected)) {
|
|
407
|
+
return yield* new ConfigCleanupError({
|
|
408
|
+
path: configPath,
|
|
409
|
+
temporaryPath,
|
|
410
|
+
error: inspected.left,
|
|
411
|
+
operationError,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
if (!sameIdentity(before.parent, inspected.right.parent)) {
|
|
415
|
+
return yield* new ConfigCleanupError({
|
|
416
|
+
path: configPath,
|
|
417
|
+
temporaryPath,
|
|
418
|
+
error: new Error('Config temp parent identity changed; refusing cleanup'),
|
|
419
|
+
operationError,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
if (!matchesCreatedFile(created, inspected.right.file)) {
|
|
423
|
+
return yield* new ConfigCleanupError({
|
|
424
|
+
path: configPath,
|
|
425
|
+
temporaryPath,
|
|
426
|
+
error: new Error('Config temp identity changed or disappeared; refusing cleanup'),
|
|
427
|
+
operationError,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
const removed = yield* Effect.either(fs.remove(temporaryPath));
|
|
431
|
+
if (Either.isLeft(removed)) {
|
|
432
|
+
return yield* new ConfigCleanupError({
|
|
433
|
+
path: configPath,
|
|
434
|
+
temporaryPath,
|
|
435
|
+
error: removed.left,
|
|
436
|
+
operationError,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
const creation = yield* createExclusiveFile(fs, temporaryPath, encoded, 0o600).pipe(Effect.mapError(() => new ConfigNotWritable({ path: temporaryPath })));
|
|
441
|
+
if (Exit.isFailure(creation.writeExit)) {
|
|
442
|
+
const primary = new ConfigNotWritable({ path: temporaryPath });
|
|
443
|
+
yield* cleanupTemporary(creation.identity, primary);
|
|
444
|
+
return yield* primary;
|
|
445
|
+
}
|
|
446
|
+
if (!creation.pathMatchesHandle) {
|
|
447
|
+
const primary = new ConfigNotWritable({ path: temporaryPath });
|
|
448
|
+
yield* cleanupTemporary(creation.identity, primary);
|
|
449
|
+
return yield* primary;
|
|
450
|
+
}
|
|
451
|
+
const installResult = yield* Effect.either(Effect.gen(function* () {
|
|
452
|
+
const temporary = yield* inspectParentAndFile(temporaryPath);
|
|
453
|
+
yield* assertStable(before.parentPath, before.parent, temporary.parent);
|
|
454
|
+
if (!matchesCreatedFile(creation.identity, temporary.file)) {
|
|
455
|
+
return yield* insecure(temporaryPath, 'config temp identity differs from its creating handle', new Error('Config temp replacement detected'));
|
|
456
|
+
}
|
|
457
|
+
if (process.platform === 'win32') {
|
|
458
|
+
yield* ensureWindowsAcl([temporaryPath]);
|
|
459
|
+
const hardened = yield* inspectParentAndFile(temporaryPath);
|
|
460
|
+
yield* assertStable(before.parentPath, before.parent, hardened.parent);
|
|
461
|
+
if (!matchesCreatedFile(creation.identity, hardened.file)) {
|
|
462
|
+
return yield* insecure(temporaryPath, 'config temp changed while applying its ACL', new Error('Config temp replacement detected'));
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
else {
|
|
466
|
+
const info = yield* fs
|
|
467
|
+
.stat(temporaryPath)
|
|
468
|
+
.pipe(Effect.mapError((error) => insecure(temporaryPath, 'could not verify config temp mode', error)));
|
|
469
|
+
if ((info.mode & 0o7777) !== 0o600) {
|
|
470
|
+
return yield* insecure(temporaryPath, 'config temp mode is not 600', new Error('Invalid config temp mode'));
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
const current = yield* inspectParentAndFile(configPath);
|
|
474
|
+
yield* assertStable(before.parentPath, before.parent, current.parent);
|
|
475
|
+
yield* assertStable(configPath, before.file, current.file);
|
|
476
|
+
yield* fs
|
|
477
|
+
.rename(temporaryPath, configPath)
|
|
478
|
+
.pipe(Effect.mapError(() => new ConfigNotWritable({ path: configPath })));
|
|
479
|
+
}));
|
|
480
|
+
if (Either.isLeft(installResult)) {
|
|
481
|
+
yield* cleanupTemporary(creation.identity, installResult.left);
|
|
482
|
+
return yield* installResult.left;
|
|
483
|
+
}
|
|
484
|
+
const installed = yield* inspectParentAndFile(configPath);
|
|
485
|
+
yield* assertStable(before.parentPath, before.parent, installed.parent);
|
|
486
|
+
if (!matchesCreatedFile(creation.identity, installed.file)) {
|
|
487
|
+
return yield* insecure(configPath, 'atomic credential installation identity could not be verified', new Error('Installed file identity mismatch'));
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
const ConfigFileFromDisk = Effect.gen(function* () {
|
|
68
491
|
const exists = yield* fs.exists(configPath);
|
|
69
492
|
if (!exists) {
|
|
70
|
-
|
|
493
|
+
const empty = { config: ExpandCliConfig.make({}), raw: {} };
|
|
494
|
+
return empty;
|
|
71
495
|
}
|
|
72
|
-
const string = yield*
|
|
73
|
-
|
|
496
|
+
const string = yield* readCredentialFile(configPath);
|
|
497
|
+
const raw = yield* Schema.decode(RawConfigJson)(string).pipe(Effect.mapError((error) => new ConfigParseError({ path: configPath, error })));
|
|
498
|
+
const config = yield* Schema.decodeUnknown(ExpandCliConfig)(raw).pipe(Effect.mapError((error) => new ConfigParseError({ path: configPath, error })));
|
|
499
|
+
const contents = { config, raw };
|
|
500
|
+
return contents;
|
|
74
501
|
});
|
|
502
|
+
const EnvironmentFromConfigFile = ConfigFileFromDisk.pipe(Effect.map(({ config }) => config));
|
|
75
503
|
const loadConfig = Effect.fn('loadConfig')(function* () {
|
|
76
504
|
const env = yield* Environment;
|
|
77
505
|
const file = yield* EnvironmentFromConfigFile;
|
|
@@ -84,8 +512,23 @@ export class ConfigManager extends Effect.Service()('@expandai/cli/Config/Config
|
|
|
84
512
|
file: Option.fromNullable(file.authMethod),
|
|
85
513
|
};
|
|
86
514
|
yield* Effect.logWarning(`EXPAND_API_URL overrides apiUrl from ${configPath}`).pipe(Effect.when(() => Option.zipWith(apiUrl.env, apiUrl.file, (env, file) => env.toString() !== file.toString()).pipe(Option.getOrElse(() => false))));
|
|
87
|
-
|
|
88
|
-
|
|
515
|
+
// Credentials signed in through Expand's own authorization flow win over
|
|
516
|
+
// an ambient `EXPAND_API_KEY`. Someone who just approved this machine in
|
|
517
|
+
// a browser means those credentials; the environment variable is a
|
|
518
|
+
// default for shells and CI, and it is frequently exported machine-wide
|
|
519
|
+
// by tooling nobody is thinking about at the time. It still beats an API
|
|
520
|
+
// key stored in the file, which is the same kind of credential.
|
|
521
|
+
// Usable, not merely present. A stored OAuth credential that expired with
|
|
522
|
+
// no refresh token can never succeed, and outranking the environment with
|
|
523
|
+
// it left `EXPAND_API_KEY` permanently shadowed on exactly the CI and
|
|
524
|
+
// agent machines this is meant to serve, with `expandai logout` the only
|
|
525
|
+
// way out.
|
|
526
|
+
const now = yield* DateTime.now;
|
|
527
|
+
const storedOAuth = authMethod.file.pipe(Option.filter((method) => method._tag === 'OAuth' && isAuthMethodLocallyUsable(method, now)));
|
|
528
|
+
const effectiveAuthMethod = Option.firstSomeOf([storedOAuth, authMethod.env, authMethod.file]);
|
|
529
|
+
yield* Effect.logWarning(`EXPAND_API_KEY is set but ${configPath} holds credentials from \`expandai login\` or agent setup, which take precedence. Unset EXPAND_API_KEY to silence this, or run \`expandai logout\` to use the API key.`).pipe(Effect.when(() => Option.isSome(authMethod.env) && Option.isSome(storedOAuth)));
|
|
530
|
+
yield* Effect.logWarning(`EXPAND_API_KEY overrides the API key stored in ${configPath}`).pipe(Effect.when(() => Option.isSome(authMethod.env) && Option.isSome(authMethod.file) && Option.isNone(storedOAuth)));
|
|
531
|
+
return yield* Option.match(effectiveAuthMethod, {
|
|
89
532
|
onNone: () => Effect.fail(new NotAuthenticated()),
|
|
90
533
|
onSome: (authMethod) => Effect.succeed({
|
|
91
534
|
apiUrl: Option.firstSomeOf([apiUrl.env, apiUrl.file]).pipe(Option.getOrElse(() => new URL(DEFAULT_API_URL))),
|
|
@@ -94,25 +537,46 @@ export class ConfigManager extends Effect.Service()('@expandai/cli/Config/Config
|
|
|
94
537
|
});
|
|
95
538
|
});
|
|
96
539
|
const saveConfig = Effect.fn('saveConfig')(function* (config) {
|
|
97
|
-
yield* fs
|
|
98
|
-
.makeDirectory(path.dirname(configPath), { recursive: true })
|
|
99
|
-
.pipe(Effect.mapError(() => new ConfigNotWritable({ path: configPath })));
|
|
100
540
|
const encoded = yield* Schema.encode(ExpandCliConfig.Json)(config);
|
|
101
|
-
yield*
|
|
102
|
-
.writeFileString(configPath, encoded, { mode: 0o600 })
|
|
103
|
-
.pipe(Effect.mapError(() => new ConfigNotWritable({ path: configPath })));
|
|
104
|
-
// `mode` only applies on creation; chmod handles a pre-existing file.
|
|
105
|
-
// Failures are non-fatal (e.g. chmod is a no-op on Windows, or the file
|
|
106
|
-
// is owned by another user) but surfaced at debug level so they're
|
|
107
|
-
// inspectable when DEBUG=true.
|
|
108
|
-
yield* fs.chmod(configPath, 0o600).pipe(Effect.tapErrorCause((cause) => Effect.logDebug(`chmod 0o600 ${configPath} failed (file mode may not enforce 0600)`).pipe(Effect.annotateLogs({ cause }))), Effect.ignore);
|
|
541
|
+
yield* atomicWriteConfig(encoded);
|
|
109
542
|
});
|
|
543
|
+
// Propagates ConfigParseError: merging onto an empty fallback would
|
|
544
|
+
// serialize `{}` over a file that failed to decode (corrupt, or written
|
|
545
|
+
// by a newer CLI version) and destroy the credentials stored in it. No
|
|
546
|
+
// write may ever follow a parse failure.
|
|
110
547
|
const updateConfig = Effect.fn('updateConfig')(function* (patch) {
|
|
111
|
-
const existing = yield*
|
|
112
|
-
|
|
548
|
+
const existing = yield* ConfigFileFromDisk;
|
|
549
|
+
const updated = ExpandCliConfig.make({ ...existing.config, ...patch });
|
|
550
|
+
const known = yield* Schema.encode(ExpandCliConfig.Json)(updated).pipe(Effect.flatMap(Schema.decode(RawConfigJson)));
|
|
551
|
+
const encoded = yield* Schema.encode(RawConfigJson)(mergeRawConfig(existing.raw, known));
|
|
552
|
+
yield* atomicWriteConfig(encoded);
|
|
553
|
+
});
|
|
554
|
+
// The config file alone, without the env overrides or the NotAuthenticated
|
|
555
|
+
// failure of loadConfig — telemetry settings must be readable before the
|
|
556
|
+
// user has ever logged in. An unreadable or undecodable file is tolerated
|
|
557
|
+
// but surfaced via `parseFailed` so callers never mistake it for an empty
|
|
558
|
+
// one (and never write over it).
|
|
559
|
+
const readConfigFile = Effect.fn('readConfigFile')(function* () {
|
|
560
|
+
return yield* ConfigFileFromDisk.pipe(Effect.map(({ config }) => ({ config, parseFailed: false })), Effect.orElseSucceed(() => ({ config: ExpandCliConfig.make({}), parseFailed: true })));
|
|
113
561
|
});
|
|
562
|
+
// Clears only authentication state: telemetry preferences (an explicit
|
|
563
|
+
// opt-out, the distinct id, the notice flag) and a configured apiUrl
|
|
564
|
+
// survive logout. An undecodable file is deleted instead of rewritten —
|
|
565
|
+
// nothing in it can be preserved, and writing `{}` would break the
|
|
566
|
+
// no-write-after-parse-failure rule updateConfig upholds.
|
|
114
567
|
const clearConfig = Effect.fn('clearConfig')(function* () {
|
|
115
|
-
yield*
|
|
568
|
+
const result = yield* Effect.either(ConfigFileFromDisk);
|
|
569
|
+
if (Either.isLeft(result)) {
|
|
570
|
+
if (!(result.left instanceof ConfigParseError))
|
|
571
|
+
return yield* result.left;
|
|
572
|
+
yield* Effect.logWarning(`Could not parse ${configPath}; removing it. Telemetry preferences and apiUrl were not preserved.`);
|
|
573
|
+
yield* fs.remove(configPath).pipe(Effect.mapError(() => new ConfigNotWritable({ path: configPath })));
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
const raw = { ...result.right.raw };
|
|
577
|
+
delete raw.authMethod;
|
|
578
|
+
const encoded = yield* Schema.encode(RawConfigJson)(raw);
|
|
579
|
+
yield* atomicWriteConfig(encoded);
|
|
116
580
|
});
|
|
117
581
|
const getApiUrl = Effect.fn('getApiUrl')(function* () {
|
|
118
582
|
const env = yield* Environment;
|
|
@@ -123,6 +587,12 @@ export class ConfigManager extends Effect.Service()('@expandai/cli/Config/Config
|
|
|
123
587
|
};
|
|
124
588
|
return Option.firstSomeOf([apiUrl.env, apiUrl.file]).pipe(Option.getOrElse(() => new URL(DEFAULT_API_URL)));
|
|
125
589
|
});
|
|
590
|
+
// Web app origin for guide links the CLI hands to an agent. Reads `EXPAND_APP_URL` — the
|
|
591
|
+
// counterpart to `EXPAND_API_URL` — so a local or self-hosted stack points agents at the
|
|
592
|
+
// app actually serving `/start.md` instead of production.
|
|
593
|
+
const getAppUrl = Effect.fn('getAppUrl')(function* () {
|
|
594
|
+
return yield* Config.string('EXPAND_APP_URL').pipe(Config.withDefault(DEFAULT_APP_URL));
|
|
595
|
+
});
|
|
126
596
|
// Public playground host for citation links. Reads `EXPAND_PLAYGROUND_HOST` — the same env
|
|
127
597
|
// var the API / cdp-server bake into `meta.playground` — so local/staging set one host for
|
|
128
598
|
// both server and CLI. The fetch path prefers the server-resolved `meta.playground` origin;
|
|
@@ -132,11 +602,15 @@ export class ConfigManager extends Effect.Service()('@expandai/cli/Config/Config
|
|
|
132
602
|
});
|
|
133
603
|
return {
|
|
134
604
|
configPath,
|
|
605
|
+
inspectSecureStorage,
|
|
606
|
+
ensureSecureStorage,
|
|
135
607
|
loadConfig,
|
|
136
608
|
saveConfig,
|
|
137
609
|
updateConfig,
|
|
138
610
|
clearConfig,
|
|
611
|
+
readConfigFile,
|
|
139
612
|
getApiUrl,
|
|
613
|
+
getAppUrl,
|
|
140
614
|
getPlaygroundHost,
|
|
141
615
|
};
|
|
142
616
|
}),
|