@dharta/cli 0.6.77
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/LICENSE +21 -0
- package/README.md +167 -0
- package/dist/agent.d.ts +17 -0
- package/dist/agent.js +134 -0
- package/dist/agent.js.map +1 -0
- package/dist/api-error.d.ts +3 -0
- package/dist/api-error.js +80 -0
- package/dist/api-error.js.map +1 -0
- package/dist/browser.d.ts +1 -0
- package/dist/browser.js +19 -0
- package/dist/browser.js.map +1 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +1978 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/agents.d.ts +131 -0
- package/dist/commands/agents.js +439 -0
- package/dist/commands/agents.js.map +1 -0
- package/dist/commands/analytics.d.ts +25 -0
- package/dist/commands/analytics.js +166 -0
- package/dist/commands/analytics.js.map +1 -0
- package/dist/commands/api.d.ts +19 -0
- package/dist/commands/api.js +278 -0
- package/dist/commands/api.js.map +1 -0
- package/dist/commands/appearance.d.ts +19 -0
- package/dist/commands/appearance.js +63 -0
- package/dist/commands/appearance.js.map +1 -0
- package/dist/commands/artifact.d.ts +33 -0
- package/dist/commands/artifact.js +235 -0
- package/dist/commands/artifact.js.map +1 -0
- package/dist/commands/audit-log.d.ts +32 -0
- package/dist/commands/audit-log.js +102 -0
- package/dist/commands/audit-log.js.map +1 -0
- package/dist/commands/auth.d.ts +47 -0
- package/dist/commands/auth.js +117 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/caps.d.ts +82 -0
- package/dist/commands/caps.js +213 -0
- package/dist/commands/caps.js.map +1 -0
- package/dist/commands/create.d.ts +22 -0
- package/dist/commands/create.js +76 -0
- package/dist/commands/create.js.map +1 -0
- package/dist/commands/deploy.d.ts +45 -0
- package/dist/commands/deploy.js +892 -0
- package/dist/commands/deploy.js.map +1 -0
- package/dist/commands/dev.d.ts +33 -0
- package/dist/commands/dev.js +655 -0
- package/dist/commands/dev.js.map +1 -0
- package/dist/commands/directories.d.ts +60 -0
- package/dist/commands/directories.js +83 -0
- package/dist/commands/directories.js.map +1 -0
- package/dist/commands/doctor.d.ts +13 -0
- package/dist/commands/doctor.js +123 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/embed-keys.d.ts +61 -0
- package/dist/commands/embed-keys.js +165 -0
- package/dist/commands/embed-keys.js.map +1 -0
- package/dist/commands/env.d.ts +59 -0
- package/dist/commands/env.js +300 -0
- package/dist/commands/env.js.map +1 -0
- package/dist/commands/files.d.ts +14 -0
- package/dist/commands/files.js +84 -0
- package/dist/commands/files.js.map +1 -0
- package/dist/commands/git-credential.d.ts +1 -0
- package/dist/commands/git-credential.js +70 -0
- package/dist/commands/git-credential.js.map +1 -0
- package/dist/commands/kartas.d.ts +101 -0
- package/dist/commands/kartas.js +289 -0
- package/dist/commands/kartas.js.map +1 -0
- package/dist/commands/keys.d.ts +33 -0
- package/dist/commands/keys.js +141 -0
- package/dist/commands/keys.js.map +1 -0
- package/dist/commands/login.d.ts +22 -0
- package/dist/commands/login.js +78 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/logs.d.ts +14 -0
- package/dist/commands/logs.js +89 -0
- package/dist/commands/logs.js.map +1 -0
- package/dist/commands/members.d.ts +32 -0
- package/dist/commands/members.js +77 -0
- package/dist/commands/members.js.map +1 -0
- package/dist/commands/model-keys.d.ts +22 -0
- package/dist/commands/model-keys.js +222 -0
- package/dist/commands/model-keys.js.map +1 -0
- package/dist/commands/open.d.ts +29 -0
- package/dist/commands/open.js +119 -0
- package/dist/commands/open.js.map +1 -0
- package/dist/commands/parity.d.ts +15 -0
- package/dist/commands/parity.js +73 -0
- package/dist/commands/parity.js.map +1 -0
- package/dist/commands/rollback.d.ts +21 -0
- package/dist/commands/rollback.js +26 -0
- package/dist/commands/rollback.js.map +1 -0
- package/dist/commands/schedules.d.ts +52 -0
- package/dist/commands/schedules.js +231 -0
- package/dist/commands/schedules.js.map +1 -0
- package/dist/commands/scopes.d.ts +3 -0
- package/dist/commands/scopes.js +21 -0
- package/dist/commands/scopes.js.map +1 -0
- package/dist/commands/sessions.d.ts +53 -0
- package/dist/commands/sessions.js +142 -0
- package/dist/commands/sessions.js.map +1 -0
- package/dist/commands/setup.d.ts +28 -0
- package/dist/commands/setup.js +949 -0
- package/dist/commands/setup.js.map +1 -0
- package/dist/commands/status.d.ts +19 -0
- package/dist/commands/status.js +63 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/update.d.ts +14 -0
- package/dist/commands/update.js +120 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/commands/usage.d.ts +3 -0
- package/dist/commands/usage.js +37 -0
- package/dist/commands/usage.js.map +1 -0
- package/dist/commands/webhooks.d.ts +47 -0
- package/dist/commands/webhooks.js +156 -0
- package/dist/commands/webhooks.js.map +1 -0
- package/dist/commands/whoami.d.ts +7 -0
- package/dist/commands/whoami.js +17 -0
- package/dist/commands/whoami.js.map +1 -0
- package/dist/completions.d.ts +13 -0
- package/dist/completions.js +120 -0
- package/dist/completions.js.map +1 -0
- package/dist/config.d.ts +20 -0
- package/dist/config.js +98 -0
- package/dist/config.js.map +1 -0
- package/dist/context.d.ts +14 -0
- package/dist/context.js +38 -0
- package/dist/context.js.map +1 -0
- package/dist/device-flow.d.ts +9 -0
- package/dist/device-flow.js +127 -0
- package/dist/device-flow.js.map +1 -0
- package/dist/harness-preflight.d.ts +24 -0
- package/dist/harness-preflight.js +252 -0
- package/dist/harness-preflight.js.map +1 -0
- package/dist/harnesses.d.ts +11 -0
- package/dist/harnesses.js +112 -0
- package/dist/harnesses.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/login-offer.d.ts +9 -0
- package/dist/login-offer.js +36 -0
- package/dist/login-offer.js.map +1 -0
- package/dist/manifest.d.ts +15 -0
- package/dist/manifest.js +142 -0
- package/dist/manifest.js.map +1 -0
- package/dist/money.d.ts +1 -0
- package/dist/money.js +16 -0
- package/dist/money.js.map +1 -0
- package/dist/output.d.ts +2 -0
- package/dist/output.js +20 -0
- package/dist/output.js.map +1 -0
- package/dist/runtime.d.ts +19 -0
- package/dist/runtime.js +95 -0
- package/dist/runtime.js.map +1 -0
- package/dist/scaffold.d.ts +16 -0
- package/dist/scaffold.js +74 -0
- package/dist/scaffold.js.map +1 -0
- package/dist/setup-scan.d.ts +21 -0
- package/dist/setup-scan.js +127 -0
- package/dist/setup-scan.js.map +1 -0
- package/dist/templates.d.ts +14 -0
- package/dist/templates.js +65 -0
- package/dist/templates.js.map +1 -0
- package/dist/validators.d.ts +10 -0
- package/dist/validators.js +116 -0
- package/dist/validators.js.map +1 -0
- package/dist/version-check.d.ts +25 -0
- package/dist/version-check.js +68 -0
- package/dist/version-check.js.map +1 -0
- package/node_modules/@dharta/sdk/LICENSE +21 -0
- package/node_modules/@dharta/sdk/README.md +87 -0
- package/node_modules/@dharta/sdk/dist/errors.d.ts +20 -0
- package/node_modules/@dharta/sdk/dist/errors.d.ts.map +1 -0
- package/node_modules/@dharta/sdk/dist/errors.js +51 -0
- package/node_modules/@dharta/sdk/dist/errors.js.map +1 -0
- package/node_modules/@dharta/sdk/dist/http.d.ts +26 -0
- package/node_modules/@dharta/sdk/dist/http.d.ts.map +1 -0
- package/node_modules/@dharta/sdk/dist/http.js +136 -0
- package/node_modules/@dharta/sdk/dist/http.js.map +1 -0
- package/node_modules/@dharta/sdk/dist/index.d.ts +15 -0
- package/node_modules/@dharta/sdk/dist/index.d.ts.map +1 -0
- package/node_modules/@dharta/sdk/dist/index.js +21 -0
- package/node_modules/@dharta/sdk/dist/index.js.map +1 -0
- package/node_modules/@dharta/sdk/dist/resources.d.ts +48 -0
- package/node_modules/@dharta/sdk/dist/resources.d.ts.map +1 -0
- package/node_modules/@dharta/sdk/dist/resources.js +150 -0
- package/node_modules/@dharta/sdk/dist/resources.js.map +1 -0
- package/node_modules/@dharta/sdk/dist/types.d.ts +94 -0
- package/node_modules/@dharta/sdk/dist/types.d.ts.map +1 -0
- package/node_modules/@dharta/sdk/dist/types.js +59 -0
- package/node_modules/@dharta/sdk/dist/types.js.map +1 -0
- package/node_modules/@dharta/sdk/package.json +32 -0
- package/node_modules/@dharta/sdk/src/errors.ts +58 -0
- package/node_modules/@dharta/sdk/src/http.ts +166 -0
- package/node_modules/@dharta/sdk/src/index.ts +36 -0
- package/node_modules/@dharta/sdk/src/resources.ts +197 -0
- package/node_modules/@dharta/sdk/src/types.ts +179 -0
- package/package.json +38 -0
- package/src/agent.ts +147 -0
- package/src/api-error.ts +88 -0
- package/src/browser.ts +20 -0
- package/src/cli.ts +2339 -0
- package/src/commands/agents.ts +635 -0
- package/src/commands/analytics.ts +181 -0
- package/src/commands/api.ts +297 -0
- package/src/commands/appearance.ts +79 -0
- package/src/commands/artifact.ts +331 -0
- package/src/commands/audit-log.ts +125 -0
- package/src/commands/auth.ts +186 -0
- package/src/commands/caps.ts +302 -0
- package/src/commands/create.ts +114 -0
- package/src/commands/deploy.ts +1122 -0
- package/src/commands/dev.ts +792 -0
- package/src/commands/directories.ts +136 -0
- package/src/commands/doctor.ts +138 -0
- package/src/commands/embed-keys.ts +237 -0
- package/src/commands/env.ts +369 -0
- package/src/commands/files.ts +124 -0
- package/src/commands/git-credential.ts +78 -0
- package/src/commands/kartas.ts +439 -0
- package/src/commands/keys.ts +206 -0
- package/src/commands/login.ts +115 -0
- package/src/commands/logs.ts +111 -0
- package/src/commands/members.ts +109 -0
- package/src/commands/model-keys.ts +278 -0
- package/src/commands/open.ts +173 -0
- package/src/commands/parity.ts +96 -0
- package/src/commands/rollback.ts +45 -0
- package/src/commands/schedules.ts +290 -0
- package/src/commands/scopes.ts +22 -0
- package/src/commands/sessions.ts +225 -0
- package/src/commands/setup.ts +1176 -0
- package/src/commands/status.ts +94 -0
- package/src/commands/update.ts +157 -0
- package/src/commands/usage.ts +41 -0
- package/src/commands/webhooks.ts +200 -0
- package/src/commands/whoami.ts +24 -0
- package/src/completions.ts +168 -0
- package/src/config.ts +143 -0
- package/src/context.ts +59 -0
- package/src/device-flow.ts +174 -0
- package/src/harness-preflight.ts +293 -0
- package/src/harnesses.ts +117 -0
- package/src/index.ts +3 -0
- package/src/login-offer.ts +49 -0
- package/src/manifest.ts +153 -0
- package/src/money.ts +15 -0
- package/src/output.ts +22 -0
- package/src/runtime.ts +127 -0
- package/src/scaffold.ts +102 -0
- package/src/setup-scan.ts +154 -0
- package/src/templates.ts +85 -0
- package/src/validators.ts +124 -0
- package/src/version-check.ts +75 -0
- package/templates/blank/CLAUDE.md +21 -0
- package/templates/blank/dharta.toml +11 -0
- package/templates/faq-agent/CLAUDE.md +51 -0
- package/templates/faq-agent/dharta.toml +11 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import TOML from "@iarna/toml";
|
|
5
|
+
|
|
6
|
+
// One host schema (RFC 0013 CLI-7, executing RFC 0011 HOST-5's CLI
|
|
7
|
+
// note). D11's coordinate system has three hosts, each with one auth
|
|
8
|
+
// model; the CLI talks to two of them:
|
|
9
|
+
// base_url - the control API (Bearer dh_live_): login, projects,
|
|
10
|
+
// templates, rollback. The Rails app - dharta.ai today,
|
|
11
|
+
// console.dharta.ai after the HOST-3 cutover.
|
|
12
|
+
// git_base_url - the hosted git server (Basic + API key): where
|
|
13
|
+
// `dharta deploy` points the remote and which host the
|
|
14
|
+
// credential helper answers for.
|
|
15
|
+
// The agent host (agent.dharta.ai, the consumer endpoint) is never
|
|
16
|
+
// configured here: `dharta open` reads the project's agent_url from the
|
|
17
|
+
// control plane, and `dharta dev` serves the same protocol locally.
|
|
18
|
+
export interface Profile {
|
|
19
|
+
api_key: string;
|
|
20
|
+
base_url: string;
|
|
21
|
+
git_base_url?: string;
|
|
22
|
+
// RFC 0043 D8: the data-plane git host this profile clones karta workspaces from
|
|
23
|
+
// (learned from /me at login). Distinct from git_base_url (the RFC 0011 push host) -
|
|
24
|
+
// the clone bytes flow straight from the data plane, never through the control plane.
|
|
25
|
+
workspace_git_base_url?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface Config {
|
|
29
|
+
default_profile: string;
|
|
30
|
+
profiles: Record<string, Profile>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Allow tests to redirect HOME without setting process.env.HOME globally.
|
|
34
|
+
let homeOverride: string | undefined;
|
|
35
|
+
|
|
36
|
+
export function setHomeForTesting(home: string | undefined): void {
|
|
37
|
+
homeOverride = home;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function configPath(): string {
|
|
41
|
+
const home = homeOverride ?? process.env.HOME ?? homedir();
|
|
42
|
+
return path.join(home, ".dharta", "config.toml");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// NOT api.dharta.ai: that name resolves to the data plane (the RFC 0011
|
|
46
|
+
// as-built base-URL confusion this constant fixes). The control API
|
|
47
|
+
// lives on the Rails app.
|
|
48
|
+
const DEFAULT_BASE_URL = "https://dharta.ai";
|
|
49
|
+
const DEFAULT_GIT_BASE_URL = "https://git.dharta.ai";
|
|
50
|
+
|
|
51
|
+
export async function loadConfig(): Promise<Config | null> {
|
|
52
|
+
try {
|
|
53
|
+
const raw = await fs.readFile(configPath(), "utf8");
|
|
54
|
+
const parsed = TOML.parse(raw) as unknown as Partial<Config>;
|
|
55
|
+
if (!parsed.default_profile || !parsed.profiles) return null;
|
|
56
|
+
return {
|
|
57
|
+
default_profile: parsed.default_profile,
|
|
58
|
+
profiles: parsed.profiles,
|
|
59
|
+
};
|
|
60
|
+
} catch (err: unknown) {
|
|
61
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function saveProfile(
|
|
67
|
+
name: string,
|
|
68
|
+
profile: Profile,
|
|
69
|
+
): Promise<string> {
|
|
70
|
+
const target = configPath();
|
|
71
|
+
await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
72
|
+
const existing = (await loadConfig()) ?? {
|
|
73
|
+
default_profile: name,
|
|
74
|
+
profiles: {},
|
|
75
|
+
};
|
|
76
|
+
existing.profiles[name] = profile;
|
|
77
|
+
existing.default_profile = name;
|
|
78
|
+
await fs.writeFile(
|
|
79
|
+
target,
|
|
80
|
+
TOML.stringify(existing as unknown as TOML.JsonMap),
|
|
81
|
+
{
|
|
82
|
+
mode: 0o600,
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
return target;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Remove a stored profile (auth logout). Returns false if it didn't
|
|
89
|
+
// exist. If the default profile was removed, the first remaining one
|
|
90
|
+
// becomes the default; the file is deleted when no profiles remain.
|
|
91
|
+
export async function removeProfile(name: string): Promise<boolean> {
|
|
92
|
+
const cfg = await loadConfig();
|
|
93
|
+
if (!cfg || !cfg.profiles[name]) return false;
|
|
94
|
+
delete cfg.profiles[name];
|
|
95
|
+
|
|
96
|
+
const remaining = Object.keys(cfg.profiles);
|
|
97
|
+
if (remaining.length === 0) {
|
|
98
|
+
await fs.rm(configPath(), { force: true });
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
if (cfg.default_profile === name) cfg.default_profile = remaining[0];
|
|
102
|
+
await fs.writeFile(
|
|
103
|
+
configPath(),
|
|
104
|
+
TOML.stringify(cfg as unknown as TOML.JsonMap),
|
|
105
|
+
{ mode: 0o600 },
|
|
106
|
+
);
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function activeProfile(): Promise<Profile> {
|
|
111
|
+
const cfg = await loadConfig();
|
|
112
|
+
if (!cfg) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
"Not logged in. Run `dharta login` to store an API key in ~/.dharta/config.toml.",
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
const profile = cfg.profiles[cfg.default_profile];
|
|
118
|
+
if (!profile) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`Default profile '${cfg.default_profile}' not found in ${configPath()}. ` +
|
|
121
|
+
"Run `dharta login` to recreate it.",
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
api_key: profile.api_key,
|
|
126
|
+
base_url: profile.base_url || DEFAULT_BASE_URL,
|
|
127
|
+
git_base_url: profile.git_base_url || DEFAULT_GIT_BASE_URL,
|
|
128
|
+
workspace_git_base_url: profile.workspace_git_base_url,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Base URL for commands that work without a credential (init's template
|
|
133
|
+
// fetch): env override, then the stored profile, then the default.
|
|
134
|
+
export async function resolveBaseUrl(): Promise<string> {
|
|
135
|
+
if (process.env.DHARTA_BASE_URL) return process.env.DHARTA_BASE_URL;
|
|
136
|
+
try {
|
|
137
|
+
return (await activeProfile()).base_url;
|
|
138
|
+
} catch {
|
|
139
|
+
return DEFAULT_BASE_URL;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export { DEFAULT_BASE_URL, DEFAULT_GIT_BASE_URL };
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Dharta, HttpClient } from "@dharta/sdk";
|
|
2
|
+
import { activeProfile, DEFAULT_BASE_URL } from "./config.js";
|
|
3
|
+
import { cliFormatError, cliFormatNetworkError } from "./api-error.js";
|
|
4
|
+
|
|
5
|
+
export interface CliContext {
|
|
6
|
+
sdk: Dharta;
|
|
7
|
+
http: HttpClient;
|
|
8
|
+
baseUrl: string;
|
|
9
|
+
// Exposed for `dharta deploy`, which hands the key to git's
|
|
10
|
+
// credential helper via the child process env.
|
|
11
|
+
apiKey: string;
|
|
12
|
+
// The resolved fetch impl (a test stub, or undefined = global fetch). Exposed so
|
|
13
|
+
// `dharta files` can build a second HttpClient for the DATA-PLANE files host (a
|
|
14
|
+
// different origin than the control-plane base_url) while staying testable.
|
|
15
|
+
fetch?: typeof fetch;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Allow tests to inject a stub fetch + base config without touching disk.
|
|
19
|
+
export interface CtxOverrides {
|
|
20
|
+
apiKey?: string;
|
|
21
|
+
baseUrl?: string;
|
|
22
|
+
fetch?: typeof fetch;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function buildContext(
|
|
26
|
+
over: CtxOverrides = {},
|
|
27
|
+
): Promise<CliContext> {
|
|
28
|
+
let apiKey = over.apiKey;
|
|
29
|
+
let baseUrl = over.baseUrl;
|
|
30
|
+
// Env overrides config everywhere (the GH_TOKEN pattern, RFC 0013
|
|
31
|
+
// §2); explicit per-call overrides beat both. DHARTA_BASE_URL applies
|
|
32
|
+
// regardless of where the key came from, matching resolveBaseUrl.
|
|
33
|
+
baseUrl = baseUrl ?? process.env.DHARTA_BASE_URL;
|
|
34
|
+
if (!apiKey && process.env.DHARTA_API_KEY) {
|
|
35
|
+
apiKey = process.env.DHARTA_API_KEY;
|
|
36
|
+
}
|
|
37
|
+
if (!apiKey) {
|
|
38
|
+
const p = await activeProfile();
|
|
39
|
+
apiKey = p.api_key;
|
|
40
|
+
baseUrl = baseUrl ?? p.base_url;
|
|
41
|
+
}
|
|
42
|
+
if (!baseUrl) baseUrl = DEFAULT_BASE_URL;
|
|
43
|
+
const fetchImpl = over.fetch;
|
|
44
|
+
const sdk = new Dharta({
|
|
45
|
+
apiKey,
|
|
46
|
+
baseUrl,
|
|
47
|
+
fetch: fetchImpl,
|
|
48
|
+
formatError: cliFormatError,
|
|
49
|
+
formatNetworkError: cliFormatNetworkError,
|
|
50
|
+
});
|
|
51
|
+
const http = new HttpClient({
|
|
52
|
+
apiKey,
|
|
53
|
+
baseUrl,
|
|
54
|
+
fetch: fetchImpl,
|
|
55
|
+
formatError: cliFormatError,
|
|
56
|
+
formatNetworkError: cliFormatNetworkError,
|
|
57
|
+
});
|
|
58
|
+
return { sdk, http, baseUrl, apiKey, fetch: fetchImpl };
|
|
59
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// RFC 8628 device-flow client for `dharta login` (RFC 0013 CLI-1).
|
|
2
|
+
// Mints a code pair on the control plane, shows the user code, opens the
|
|
3
|
+
// approve page, and polls the token endpoint until the browser approval
|
|
4
|
+
// mints a named CLI credential.
|
|
5
|
+
|
|
6
|
+
import { openBrowser } from "./browser.js";
|
|
7
|
+
|
|
8
|
+
export interface DeviceFlowOptions {
|
|
9
|
+
baseUrl: string;
|
|
10
|
+
clientName?: string;
|
|
11
|
+
fetchImpl?: typeof fetch;
|
|
12
|
+
log?: (line: string) => void;
|
|
13
|
+
openBrowser?: (url: string) => Promise<boolean>;
|
|
14
|
+
sleep?: (ms: number) => Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface MintResponse {
|
|
18
|
+
device_code: string;
|
|
19
|
+
user_code: string;
|
|
20
|
+
verification_uri: string;
|
|
21
|
+
verification_uri_complete?: string;
|
|
22
|
+
expires_in: number;
|
|
23
|
+
interval?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const EXPIRED_MESSAGE =
|
|
27
|
+
"The login code expired before it was approved. Run `dharta login` again.";
|
|
28
|
+
// Consecutive network failures tolerated before giving up — the
|
|
29
|
+
// approval window is minutes long, so one wifi blip must not abort it.
|
|
30
|
+
const MAX_CONSECUTIVE_NETWORK_FAILURES = 5;
|
|
31
|
+
|
|
32
|
+
// Resolves to the freshly minted API key (dh_live_…).
|
|
33
|
+
export async function deviceFlowLogin(opts: DeviceFlowOptions): Promise<string> {
|
|
34
|
+
const fetchImpl = (opts.fetchImpl ?? globalThis.fetch).bind(globalThis);
|
|
35
|
+
const log = opts.log ?? ((line: string) => console.log(line));
|
|
36
|
+
const sleep =
|
|
37
|
+
opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
|
|
38
|
+
const base = opts.baseUrl.replace(/\/+$/, "");
|
|
39
|
+
|
|
40
|
+
let mintRes: Response;
|
|
41
|
+
try {
|
|
42
|
+
mintRes = await fetchImpl(`${base}/cli/device_authorizations`, {
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
45
|
+
body: JSON.stringify({ client_name: opts.clientName }),
|
|
46
|
+
});
|
|
47
|
+
} catch (error) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`Could not start the login: could not reach ${base} (${formatCause(error)}). ` +
|
|
50
|
+
"Check your network and try `dharta login` again.",
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
if (!mintRes.ok) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`Could not start the login (${mintRes.status} from ${base}). ` +
|
|
56
|
+
"Check your network and try `dharta login` again.",
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
let mint: MintResponse;
|
|
60
|
+
try {
|
|
61
|
+
mint = (await mintRes.json()) as MintResponse;
|
|
62
|
+
} catch (error) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`Could not start the login: ${base} returned invalid JSON (${formatCause(error)}). ` +
|
|
65
|
+
"Check DHARTA_BASE_URL or try `dharta login` again.",
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
if (
|
|
69
|
+
typeof mint.device_code !== "string" ||
|
|
70
|
+
typeof mint.user_code !== "string" ||
|
|
71
|
+
typeof mint.verification_uri !== "string" ||
|
|
72
|
+
typeof mint.expires_in !== "number"
|
|
73
|
+
) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`Could not start the login: ${base} returned an incomplete device login response. ` +
|
|
76
|
+
"Check DHARTA_BASE_URL or try `dharta login` again.",
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const approveUrl = mint.verification_uri_complete ?? mint.verification_uri;
|
|
81
|
+
log(`! First, verify this code in your browser: ${mint.user_code}`);
|
|
82
|
+
|
|
83
|
+
const opened = await (opts.openBrowser ?? openBrowser)(approveUrl);
|
|
84
|
+
log(
|
|
85
|
+
opened
|
|
86
|
+
? `Opening ${approveUrl} in your browser…`
|
|
87
|
+
: `Open this URL to approve: ${approveUrl}`,
|
|
88
|
+
);
|
|
89
|
+
log("Waiting for approval…");
|
|
90
|
+
|
|
91
|
+
// RFC 8628: interval is optional, default 5s. Guard against a
|
|
92
|
+
// missing/garbage value — setTimeout(NaN) fires immediately and
|
|
93
|
+
// would hot-loop the poll.
|
|
94
|
+
let intervalMs = Math.max(Number(mint.interval) || 5, 1) * 1000;
|
|
95
|
+
const deadline = Date.now() + mint.expires_in * 1000;
|
|
96
|
+
let networkFailures = 0;
|
|
97
|
+
|
|
98
|
+
while (Date.now() < deadline) {
|
|
99
|
+
await sleep(intervalMs);
|
|
100
|
+
|
|
101
|
+
let res: Response;
|
|
102
|
+
try {
|
|
103
|
+
res = await fetchImpl(`${base}/cli/device_authorizations/token`, {
|
|
104
|
+
method: "POST",
|
|
105
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
106
|
+
body: JSON.stringify({ device_code: mint.device_code }),
|
|
107
|
+
});
|
|
108
|
+
} catch {
|
|
109
|
+
// Transient network failure mid-poll: keep waiting, the
|
|
110
|
+
// deadline bounds the loop.
|
|
111
|
+
networkFailures += 1;
|
|
112
|
+
if (networkFailures >= MAX_CONSECUTIVE_NETWORK_FAILURES) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`Lost the connection to ${base} while waiting for approval. ` +
|
|
115
|
+
"Check your network and run `dharta login` again.",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
networkFailures = 0;
|
|
121
|
+
|
|
122
|
+
if (res.ok) {
|
|
123
|
+
let token: string | undefined;
|
|
124
|
+
try {
|
|
125
|
+
token = ((await res.json()) as { access_token?: string }).access_token;
|
|
126
|
+
} catch {
|
|
127
|
+
// fall through to the throw below
|
|
128
|
+
}
|
|
129
|
+
if (!token) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
"The server approved the login but returned no token. Run `dharta login` again.",
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
return token;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (res.status === 429) {
|
|
138
|
+
// Rate-limited (Rack::Attack): back off like slow_down rather
|
|
139
|
+
// than keeping the cadence that tripped the limit.
|
|
140
|
+
await res.text().catch(() => {});
|
|
141
|
+
intervalMs += 5000;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let error = "";
|
|
146
|
+
try {
|
|
147
|
+
error = ((await res.json()) as { error?: string }).error ?? "";
|
|
148
|
+
} catch {
|
|
149
|
+
// non-JSON error body: fall through to the unknown-error throw
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
switch (error) {
|
|
153
|
+
case "authorization_pending":
|
|
154
|
+
continue;
|
|
155
|
+
case "slow_down":
|
|
156
|
+
intervalMs += 5000; // RFC 8628 §3.5: back off by 5s on slow_down
|
|
157
|
+
continue;
|
|
158
|
+
case "expired_token":
|
|
159
|
+
throw new Error(EXPIRED_MESSAGE);
|
|
160
|
+
case "access_denied":
|
|
161
|
+
throw new Error("The login request was denied in the browser.");
|
|
162
|
+
default:
|
|
163
|
+
throw new Error(
|
|
164
|
+
`Login failed (${error || `HTTP ${res.status}`}). Run \`dharta login\` again.`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
throw new Error(EXPIRED_MESSAGE);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function formatCause(error: unknown): string {
|
|
173
|
+
return error instanceof Error && error.message ? error.message : String(error);
|
|
174
|
+
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// RFC 0035 D7: an advisory, client-side pre-flight for a dharta.toml [harness]
|
|
2
|
+
// version declaration. Release acceptance on the control plane is authoritative;
|
|
3
|
+
// this check is stale-able (the catalog is CDN-cached) and bypassable (the API
|
|
4
|
+
// and old CLIs exist), so a green pre-flight is not a guarantee. Its only job is
|
|
5
|
+
// fast LOCAL feedback before a deploy round-trips:
|
|
6
|
+
//
|
|
7
|
+
// - block a version pin the platform can't honour (exit non-zero, like the
|
|
8
|
+
// server would, but instantly and with the supported list), and
|
|
9
|
+
// - surface a local-vs-cloud version gap so the developer deploys knowingly.
|
|
10
|
+
//
|
|
11
|
+
// Resolution rule A (locked): a range like "2" resolves to the LATEST matching
|
|
12
|
+
// supported version for everyone and for CI - deterministic, the same on every
|
|
13
|
+
// machine. The locally-installed build never changes what deploys; it only
|
|
14
|
+
// *guides* an optional parity pin. So this pre-flight warns on a gap and offers
|
|
15
|
+
// the closest version to pin, but the deploy target itself stays deterministic.
|
|
16
|
+
//
|
|
17
|
+
// It must never block a deploy on its own failure: any error reaching the
|
|
18
|
+
// catalog, parsing, or probing the local binary degrades to "proceed" and lets
|
|
19
|
+
// the authoritative server have the final word. The two deliberate aborts are an
|
|
20
|
+
// unsatisfiable pin and a developer answering "no" to the gap prompt.
|
|
21
|
+
|
|
22
|
+
import { existsSync } from "node:fs";
|
|
23
|
+
import { readFile } from "node:fs/promises";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { spawn } from "node:child_process";
|
|
26
|
+
import * as readline from "node:readline";
|
|
27
|
+
import TOML from "@iarna/toml";
|
|
28
|
+
import { sniffHarnessType } from "./harnesses.js";
|
|
29
|
+
import { whichAll } from "./runtime.js";
|
|
30
|
+
|
|
31
|
+
export interface CatalogVersion {
|
|
32
|
+
version: string;
|
|
33
|
+
status: string;
|
|
34
|
+
}
|
|
35
|
+
export interface CatalogHarness {
|
|
36
|
+
id: string;
|
|
37
|
+
name: string;
|
|
38
|
+
default_version?: string | null;
|
|
39
|
+
versions: CatalogVersion[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The binary whose `--version` reports the locally-installed harness build. Only
|
|
43
|
+
// types we can probe are listed; an unlisted type yields a null local version
|
|
44
|
+
// (the pin is still validated against the catalog, just no gap warning).
|
|
45
|
+
const LOCAL_BIN: Record<string, string> = {
|
|
46
|
+
"claude-code": "claude",
|
|
47
|
+
opencode: "opencode",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export interface PreflightDeps {
|
|
51
|
+
dir: string;
|
|
52
|
+
baseUrl: string;
|
|
53
|
+
log: (line: string) => void;
|
|
54
|
+
// Injectable seams so unit tests never touch the network, a real binary, or a
|
|
55
|
+
// TTY. Defaults wire the real implementations.
|
|
56
|
+
fetchImpl?: typeof fetch;
|
|
57
|
+
detectLocalVersion?: (type: string) => Promise<string | null>;
|
|
58
|
+
confirm?: (question: string) => Promise<boolean>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// `dharta.toml`'s [harness] table, shape-mirrored from the data plane's
|
|
62
|
+
// parse_harness_table (spec.py): an optional type + optional string version.
|
|
63
|
+
interface HarnessTable {
|
|
64
|
+
type?: string;
|
|
65
|
+
version?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Read [harness] from dir/dharta.toml. Returns null when there's no manifest, no
|
|
69
|
+
// [harness] table, or the file can't be parsed - all "nothing to check" cases.
|
|
70
|
+
async function readHarnessTable(dir: string): Promise<HarnessTable | null> {
|
|
71
|
+
const path = join(dir, "dharta.toml");
|
|
72
|
+
if (!existsSync(path)) return null;
|
|
73
|
+
let parsed: unknown;
|
|
74
|
+
try {
|
|
75
|
+
parsed = TOML.parse(await readFile(path, "utf8"));
|
|
76
|
+
} catch {
|
|
77
|
+
return null; // a malformed manifest fails loudly at the server; not our job
|
|
78
|
+
}
|
|
79
|
+
const table = (parsed as Record<string, unknown>).harness;
|
|
80
|
+
if (table === null || typeof table !== "object") return null;
|
|
81
|
+
const t = table as Record<string, unknown>;
|
|
82
|
+
return {
|
|
83
|
+
type: typeof t.type === "string" && t.type.trim() ? t.type.trim() : undefined,
|
|
84
|
+
version: typeof t.version === "string" && t.version.trim() ? t.version.trim() : undefined,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Back-compat type sniff, mirroring the data plane's sniff_harness_type: a tree
|
|
89
|
+
// is claude-code only when it carries Claude config and NOT OpenCode config;
|
|
90
|
+
// anything else (incl. a bare folder) is the opencode default. Used only when
|
|
91
|
+
// [harness] declares a version but omits the type.
|
|
92
|
+
export function sniffType(dir: string): string {
|
|
93
|
+
return sniffHarnessType(dir);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Compare two dotted numeric versions; positive when a > b. Pads the shorter
|
|
97
|
+
// with zeros and compares segment-by-segment (Gem::Version-style, the server's
|
|
98
|
+
// ordering). Non-numeric segments compare as 0 - claude-code/opencode versions
|
|
99
|
+
// are plain numerics, and a malformed entry shouldn't crash the advisory path.
|
|
100
|
+
export function compareVersions(a: string, b: string): number {
|
|
101
|
+
const pa = a.split(".");
|
|
102
|
+
const pb = b.split(".");
|
|
103
|
+
const n = Math.max(pa.length, pb.length);
|
|
104
|
+
for (let i = 0; i < n; i++) {
|
|
105
|
+
const da = Number.parseInt(pa[i] ?? "0", 10) || 0;
|
|
106
|
+
const db = Number.parseInt(pb[i] ?? "0", 10) || 0;
|
|
107
|
+
if (da !== db) return da - db;
|
|
108
|
+
}
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// True when `version`'s dot segments start with all of `prefix`'s segments
|
|
113
|
+
// ("2" matches 2.x.x; "2.1" matches 2.1.x; an exact string also matches). Mirrors
|
|
114
|
+
// HarnessVersion.version_has_prefix? - a plain string prefix would wrongly match
|
|
115
|
+
// "20.x" for "2"; segment compare won't.
|
|
116
|
+
export function versionHasPrefix(version: string, prefix: string): boolean {
|
|
117
|
+
const v = version.split(".");
|
|
118
|
+
const p = prefix.split(".");
|
|
119
|
+
if (p.length === 0 || p.length > v.length) return false;
|
|
120
|
+
return p.every((seg, i) => v[i] === seg);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Advisory mirror of Harness#resolve_version (rule A): blank -> the default (if
|
|
124
|
+
// supported), exact -> that version, prefix -> the LATEST matching. Returns null
|
|
125
|
+
// when nothing matches, exactly when the server would fail acceptance.
|
|
126
|
+
export function resolvePreview(
|
|
127
|
+
constraint: string | undefined,
|
|
128
|
+
supported: string[],
|
|
129
|
+
defaultVersion?: string | null,
|
|
130
|
+
): string | null {
|
|
131
|
+
if (!constraint) {
|
|
132
|
+
return defaultVersion && supported.includes(defaultVersion) ? defaultVersion : null;
|
|
133
|
+
}
|
|
134
|
+
if (supported.includes(constraint)) return constraint;
|
|
135
|
+
const matching = supported.filter((v) => versionHasPrefix(v, constraint));
|
|
136
|
+
if (matching.length === 0) return null;
|
|
137
|
+
return matching.slice().sort((a, b) => compareVersions(b, a))[0];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// The supported version closest to the local build, to offer as a parity pin:
|
|
141
|
+
// the longest shared leading-segment prefix with `local` (stay in the same
|
|
142
|
+
// major.minor line), tie-broken by the latest within that line. Null when
|
|
143
|
+
// there's no local build or no supported versions.
|
|
144
|
+
export function closestForParity(local: string | null, supported: string[]): string | null {
|
|
145
|
+
if (!local || supported.length === 0) return null;
|
|
146
|
+
const lp = local.split(".");
|
|
147
|
+
const shared = (v: string): number => {
|
|
148
|
+
const vp = v.split(".");
|
|
149
|
+
let n = 0;
|
|
150
|
+
while (n < lp.length && n < vp.length && lp[n] === vp[n]) n++;
|
|
151
|
+
return n;
|
|
152
|
+
};
|
|
153
|
+
const maxShared = Math.max(...supported.map(shared));
|
|
154
|
+
return supported
|
|
155
|
+
.filter((v) => shared(v) === maxShared)
|
|
156
|
+
.sort((a, b) => compareVersions(b, a))[0];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Fetch the public harness catalog (GET /harnesses.json). Returns null on any
|
|
160
|
+
// failure - the pre-flight then proceeds and the server decides.
|
|
161
|
+
async function fetchCatalog(
|
|
162
|
+
baseUrl: string,
|
|
163
|
+
fetchImpl: typeof fetch,
|
|
164
|
+
): Promise<CatalogHarness[] | null> {
|
|
165
|
+
try {
|
|
166
|
+
const res = await fetchImpl(`${baseUrl.replace(/\/$/, "")}/harnesses.json`);
|
|
167
|
+
if (!res.ok) return null;
|
|
168
|
+
const body = (await res.json()) as { harnesses?: CatalogHarness[] };
|
|
169
|
+
return Array.isArray(body.harnesses) ? body.harnesses : null;
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Probe the locally-installed harness build via `<bin> --version`, returning the
|
|
176
|
+
// first dotted-numeric token (e.g. "2.1.190" from "2.1.190 (Claude Code)") or
|
|
177
|
+
// null when the binary isn't on PATH or doesn't answer. Best-effort: any failure
|
|
178
|
+
// is a null, never a throw.
|
|
179
|
+
async function detectLocalVersionDefault(type: string): Promise<string | null> {
|
|
180
|
+
const bin = LOCAL_BIN[type];
|
|
181
|
+
if (!bin) return null;
|
|
182
|
+
if ((await whichAll(bin)).length === 0) return null;
|
|
183
|
+
const out = await runVersion(bin);
|
|
184
|
+
const match = out?.match(/\d+(?:\.\d+)+/);
|
|
185
|
+
return match ? match[0] : null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function runVersion(bin: string): Promise<string | null> {
|
|
189
|
+
return new Promise((resolve) => {
|
|
190
|
+
let settled = false;
|
|
191
|
+
const done = (v: string | null) => {
|
|
192
|
+
if (!settled) {
|
|
193
|
+
settled = true;
|
|
194
|
+
resolve(v);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
try {
|
|
198
|
+
const proc = spawn(bin, ["--version"], { stdio: ["ignore", "pipe", "ignore"] });
|
|
199
|
+
const out: Buffer[] = [];
|
|
200
|
+
proc.stdout?.on("data", (c) => out.push(c));
|
|
201
|
+
proc.on("error", () => done(null));
|
|
202
|
+
proc.on("close", () => done(Buffer.concat(out).toString().trim() || null));
|
|
203
|
+
// A hung binary must not hang the deploy.
|
|
204
|
+
const timer = setTimeout(() => {
|
|
205
|
+
proc.kill();
|
|
206
|
+
done(null);
|
|
207
|
+
}, 4000);
|
|
208
|
+
timer.unref?.();
|
|
209
|
+
} catch {
|
|
210
|
+
done(null);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// TTY-aware [Y/n] confirm, default yes. Non-interactive (no TTY, or piped stdin
|
|
216
|
+
// in CI) auto-confirms so a deploy pipeline never hangs waiting on input - the
|
|
217
|
+
// gap was already printed, so it's surfaced either way.
|
|
218
|
+
function confirmTTYDefault(question: string): Promise<boolean> {
|
|
219
|
+
if (!process.stdin.isTTY) return Promise.resolve(true);
|
|
220
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
221
|
+
return new Promise((resolve) => {
|
|
222
|
+
rl.question(question, (answer) => {
|
|
223
|
+
rl.close();
|
|
224
|
+
const a = answer.trim().toLowerCase();
|
|
225
|
+
resolve(a === "" || a === "y" || a === "yes");
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Right-pad a label so the values line up in the printed table.
|
|
231
|
+
function row(label: string, value: string): string {
|
|
232
|
+
return ` ${label.padEnd(24)}${value}`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Run the pre-flight. Resolves normally to let the deploy proceed; throws to
|
|
236
|
+
// abort it (an unsatisfiable pin, or a declined gap prompt). Activates only when
|
|
237
|
+
// [harness] declares a version - an omitted version is always the curated
|
|
238
|
+
// default, so there's nothing to validate and the post-deploy echo shows what
|
|
239
|
+
// was frozen.
|
|
240
|
+
export async function harnessPreflight(deps: PreflightDeps): Promise<void> {
|
|
241
|
+
const table = await readHarnessTable(deps.dir);
|
|
242
|
+
if (!table?.version) return; // no declared constraint -> nothing to check
|
|
243
|
+
|
|
244
|
+
const type = table.type ?? sniffType(deps.dir);
|
|
245
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
246
|
+
const catalog = await fetchCatalog(deps.baseUrl, fetchImpl);
|
|
247
|
+
if (!catalog) return; // catalog unreachable -> defer to the authoritative server
|
|
248
|
+
|
|
249
|
+
const harness = catalog.find((h) => h.id === type);
|
|
250
|
+
if (!harness) return; // unknown to the advisory catalog -> let the server judge
|
|
251
|
+
const supported = harness.versions.filter((v) => v.status === "supported").map((v) => v.version);
|
|
252
|
+
|
|
253
|
+
const detect = deps.detectLocalVersion ?? detectLocalVersionDefault;
|
|
254
|
+
const local = await detect(type).catch(() => null);
|
|
255
|
+
const localLabel = local ?? "not detected";
|
|
256
|
+
|
|
257
|
+
const resolved = resolvePreview(table.version, supported, harness.default_version);
|
|
258
|
+
|
|
259
|
+
if (resolved === null) {
|
|
260
|
+
// The pin matches no supported version. Block - the platform can't run a
|
|
261
|
+
// version it doesn't have - with the supported list so the fix is obvious.
|
|
262
|
+
const supportedLabel = supported.length ? supported.join(", ") : "(none published)";
|
|
263
|
+
throw new Error(
|
|
264
|
+
`Harness version pre-flight failed.\n` +
|
|
265
|
+
row(`Local ${type}:`, localLabel) + "\n" +
|
|
266
|
+
row("dharta.toml version:", table.version) + "\n" +
|
|
267
|
+
row("Supported on Dharta:", supportedLabel) + "\n\n" +
|
|
268
|
+
` No Dharta-supported ${type} version matches "${table.version}". ` +
|
|
269
|
+
`Update or clear the version in [harness] in dharta.toml, then re-deploy.`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (local && local !== resolved) {
|
|
274
|
+
// The deploy target is deterministic (rule A: latest matching); the local
|
|
275
|
+
// build just differs. Surface the gap and the closest version to pin for
|
|
276
|
+
// parity, then let the developer decide.
|
|
277
|
+
const closest = closestForParity(local, supported);
|
|
278
|
+
deps.log(`⚠ Your local ${type} build differs from the deploy target.`);
|
|
279
|
+
deps.log(row(`Local ${type}:`, local));
|
|
280
|
+
deps.log(row("dharta.toml version:", `${table.version} (deploys as ${resolved})`));
|
|
281
|
+
if (closest && closest !== resolved) {
|
|
282
|
+
deps.log(row("Closest to your local:", `${closest} (pin it in [harness] for parity)`));
|
|
283
|
+
}
|
|
284
|
+
const confirm = deps.confirm ?? confirmTTYDefault;
|
|
285
|
+
const proceed = await confirm(` Deploy on ${type} ${resolved} anyway? [Y/n] `);
|
|
286
|
+
if (!proceed) {
|
|
287
|
+
throw new Error(
|
|
288
|
+
`Deploy cancelled - local ${type} ${local} differs from the deploy target ${resolved}. ` +
|
|
289
|
+
`Pin a version in [harness] in dharta.toml, or re-run to accept the difference.`,
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|