@datumos/datum-cli 0.0.0 → 0.588.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +8 -0
- package/README.md +37 -0
- package/cli/a2aCommand.js +225 -0
- package/cli/artifactsPublish.js +356 -0
- package/cli/authCommands.js +168 -0
- package/cli/bundleSignature.js +111 -0
- package/cli/commandTree.js +243 -0
- package/cli/credentialProcess.js +54 -0
- package/cli/credentials.js +93 -0
- package/cli/datum.js +814 -0
- package/cli/entraLogin.js +254 -0
- package/cli/help.js +509 -0
- package/cli/invoke.js +427 -0
- package/cli/mcpServe.js +361 -0
- package/cli/outputsDownload.js +126 -0
- package/cli/previewCommand.js +333 -0
- package/cli/satellitePairToken.js +48 -0
- package/cli/secureOrigin.js +37 -0
- package/cli/selfUpdate.js +779 -0
- package/cli/spaceManifestCommand.js +495 -0
- package/cli/spaceManifestDir.js +481 -0
- package/cli/spaceRepoTemplate.js +463 -0
- package/cli/updateCommand.js +184 -0
- package/npm-shrinkwrap.json +1231 -0
- package/openapi/spec.js +51199 -0
- package/package.json +25 -3
- package/spaces/reservedHostLabels.js +66 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { originOf } from "./credentials.js";
|
|
2
|
+
import { UsageError } from "./invoke.js";
|
|
3
|
+
import { insecureOriginReason } from "./secureOrigin.js";
|
|
4
|
+
import { RESERVED_SPACE_SLUGS } from "../spaces/reservedHostLabels.js";
|
|
5
|
+
export class NotLoggedInError extends Error {
|
|
6
|
+
constructor(baseUrl) {
|
|
7
|
+
super(`not signed in to ${baseUrl} — run \`datum login --base-url ${baseUrl}\` (or set DATUM_API_KEY)`);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export class LoginFlowError extends Error {
|
|
11
|
+
code;
|
|
12
|
+
constructor(message,
|
|
13
|
+
/** The OAuth error code when the IdP produced one. */
|
|
14
|
+
code = "") {
|
|
15
|
+
super(message);
|
|
16
|
+
this.code = code;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Refresh when less than this remains — same skew the server tolerates. */
|
|
20
|
+
export const TOKEN_SKEW_MS = 60_000;
|
|
21
|
+
const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
22
|
+
const form = (o) => new URLSearchParams(o).toString();
|
|
23
|
+
const FORM_HEADERS = { "content-type": "application/x-www-form-urlencoded", accept: "application/json" };
|
|
24
|
+
/** GET /api/auth/cli — translated into actionable CLI errors. */
|
|
25
|
+
export async function fetchCliLoginConfig(baseUrl, io = {}) {
|
|
26
|
+
// A sign-in's tokens are later sent to this origin — never a plain-http one (#2605).
|
|
27
|
+
const insecure = insecureOriginReason(baseUrl);
|
|
28
|
+
if (insecure)
|
|
29
|
+
throw new UsageError(insecure);
|
|
30
|
+
const doFetch = io.fetchImpl ?? fetch;
|
|
31
|
+
const url = `${originOf(baseUrl)}/api/auth/cli`;
|
|
32
|
+
const res = await doFetch(url, { headers: { accept: "application/json" }, redirect: "manual" });
|
|
33
|
+
const body = (await res.json().catch(() => ({})));
|
|
34
|
+
if (res.status === 404 && body.code === "sso_not_configured") {
|
|
35
|
+
throw new UsageError(`${baseUrl} has no SSO login configured — an org admin sets the OIDC issuer and client id in Settings → Access`);
|
|
36
|
+
}
|
|
37
|
+
if (res.status === 404 && body.code === "device_flow_unsupported") {
|
|
38
|
+
throw new UsageError(`${baseUrl}'s identity provider offers no device-code sign-in`);
|
|
39
|
+
}
|
|
40
|
+
if (res.status === 403) {
|
|
41
|
+
throw new UsageError(`${baseUrl} answers 403 to the CLI login — the SSO login feature is off on that deployment`);
|
|
42
|
+
}
|
|
43
|
+
if (!res.ok || !body.issuer || !body.clientId || !body.deviceAuthorizationEndpoint || !body.tokenEndpoint) {
|
|
44
|
+
throw new UsageError(`${url} did not answer with a CLI login config (HTTP ${res.status}${body.error ? `: ${body.error}` : ""}) — is --base-url the Datum origin, and is this Datum ≥ the version with developer access?`);
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
issuer: body.issuer,
|
|
48
|
+
clientId: body.clientId,
|
|
49
|
+
scope: body.scope ?? "",
|
|
50
|
+
deviceAuthorizationEndpoint: body.deviceAuthorizationEndpoint,
|
|
51
|
+
tokenEndpoint: body.tokenEndpoint,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** Step 1 of RFC 8628: ask the IdP for a user code. */
|
|
55
|
+
export async function startDeviceFlow(cfg, io = {}) {
|
|
56
|
+
const doFetch = io.fetchImpl ?? fetch;
|
|
57
|
+
const now = io.now ?? Date.now;
|
|
58
|
+
const res = await doFetch(cfg.deviceAuthorizationEndpoint, {
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: FORM_HEADERS,
|
|
61
|
+
body: form({ client_id: cfg.clientId, scope: cfg.scope }),
|
|
62
|
+
});
|
|
63
|
+
const data = (await res.json().catch(() => ({})));
|
|
64
|
+
if (!res.ok || typeof data.device_code !== "string" || typeof data.user_code !== "string") {
|
|
65
|
+
throw new LoginFlowError(`the identity provider refused to start the device sign-in (HTTP ${res.status}${describeOauthError(data)})`, typeof data.error === "string" ? data.error : "");
|
|
66
|
+
}
|
|
67
|
+
const expiresIn = typeof data.expires_in === "number" ? data.expires_in : 900;
|
|
68
|
+
const interval = typeof data.interval === "number" ? data.interval : 5;
|
|
69
|
+
return {
|
|
70
|
+
deviceCode: data.device_code,
|
|
71
|
+
userCode: data.user_code,
|
|
72
|
+
verificationUri: typeof data.verification_uri === "string" ? data.verification_uri : "",
|
|
73
|
+
...(typeof data.verification_uri_complete === "string"
|
|
74
|
+
? { verificationUriComplete: data.verification_uri_complete }
|
|
75
|
+
: {}),
|
|
76
|
+
expiresAt: now() + expiresIn * 1000,
|
|
77
|
+
intervalMs: Math.max(interval, 0) * 1000,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** Step 2: poll the token endpoint until the person finishes (or the code
|
|
81
|
+
* dies). `authorization_pending` keeps polling; `slow_down` backs off by
|
|
82
|
+
* 5 s (the RFC's rule); anything else ends the flow. */
|
|
83
|
+
export async function pollDeviceFlow(cfg, device, io = {}) {
|
|
84
|
+
const doFetch = io.fetchImpl ?? fetch;
|
|
85
|
+
const sleep = io.sleep ?? defaultSleep;
|
|
86
|
+
const now = io.now ?? Date.now;
|
|
87
|
+
let interval = device.intervalMs;
|
|
88
|
+
while (now() < device.expiresAt) {
|
|
89
|
+
if (io.signal?.aborted)
|
|
90
|
+
throw new LoginFlowError("sign-in cancelled", "cancelled");
|
|
91
|
+
const res = await doFetch(cfg.tokenEndpoint, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: FORM_HEADERS,
|
|
94
|
+
body: form({
|
|
95
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
96
|
+
client_id: cfg.clientId,
|
|
97
|
+
device_code: device.deviceCode,
|
|
98
|
+
}),
|
|
99
|
+
});
|
|
100
|
+
const data = (await res.json().catch(() => ({})));
|
|
101
|
+
if (res.ok && typeof data.access_token === "string")
|
|
102
|
+
return toTokenSet(data, now());
|
|
103
|
+
const error = typeof data.error === "string" ? data.error : "";
|
|
104
|
+
if (error === "authorization_pending") {
|
|
105
|
+
await sleep(interval);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (error === "slow_down") {
|
|
109
|
+
interval += 5000;
|
|
110
|
+
await sleep(interval);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (error === "authorization_declined" || error === "access_denied") {
|
|
114
|
+
throw new LoginFlowError("the sign-in was declined", error);
|
|
115
|
+
}
|
|
116
|
+
if (error === "expired_token") {
|
|
117
|
+
throw new LoginFlowError("the device code expired before the sign-in completed — run `datum login` again", error);
|
|
118
|
+
}
|
|
119
|
+
throw new LoginFlowError(`the identity provider rejected the sign-in (HTTP ${res.status}${describeOauthError(data)})`, error);
|
|
120
|
+
}
|
|
121
|
+
throw new LoginFlowError("the device code expired before the sign-in completed — run `datum login` again", "expired_token");
|
|
122
|
+
}
|
|
123
|
+
/** The refresh-token grant — rotates the refresh token when the IdP does. */
|
|
124
|
+
export async function refreshTokenSet(cfg, refreshToken, io = {}) {
|
|
125
|
+
const doFetch = io.fetchImpl ?? fetch;
|
|
126
|
+
const now = io.now ?? Date.now;
|
|
127
|
+
const res = await doFetch(cfg.tokenEndpoint, {
|
|
128
|
+
method: "POST",
|
|
129
|
+
headers: FORM_HEADERS,
|
|
130
|
+
body: form({
|
|
131
|
+
grant_type: "refresh_token",
|
|
132
|
+
client_id: cfg.clientId,
|
|
133
|
+
refresh_token: refreshToken,
|
|
134
|
+
...(cfg.scope ? { scope: cfg.scope } : {}),
|
|
135
|
+
}),
|
|
136
|
+
});
|
|
137
|
+
const data = (await res.json().catch(() => ({})));
|
|
138
|
+
if (!res.ok || typeof data.access_token !== "string") {
|
|
139
|
+
throw new LoginFlowError(`the session could not be refreshed (HTTP ${res.status}${describeOauthError(data)}) — run \`datum login\` again`, typeof data.error === "string" ? data.error : "");
|
|
140
|
+
}
|
|
141
|
+
return { ...toTokenSet(data, now()), refreshToken: typeof data.refresh_token === "string" ? data.refresh_token : refreshToken };
|
|
142
|
+
}
|
|
143
|
+
function toTokenSet(data, nowMs) {
|
|
144
|
+
const expiresIn = typeof data.expires_in === "number" ? data.expires_in : 3600;
|
|
145
|
+
return {
|
|
146
|
+
accessToken: data.access_token,
|
|
147
|
+
expiresAt: nowMs + expiresIn * 1000,
|
|
148
|
+
...(typeof data.refresh_token === "string" ? { refreshToken: data.refresh_token } : {}),
|
|
149
|
+
...(typeof data.scope === "string" ? { scope: data.scope } : {}),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
function describeOauthError(data) {
|
|
153
|
+
const error = typeof data.error === "string" ? data.error : "";
|
|
154
|
+
const description = typeof data.error_description === "string" ? data.error_description.split("\n")[0].slice(0, 200) : "";
|
|
155
|
+
return error ? `: ${error}${description ? ` — ${description}` : ""}` : "";
|
|
156
|
+
}
|
|
157
|
+
/** Payload of a JWT WITHOUT verification — display only (the account name,
|
|
158
|
+
* expiry). The server verifies; the CLI merely shows. */
|
|
159
|
+
export function decodeJwtPayload(token) {
|
|
160
|
+
const parts = token.split(".");
|
|
161
|
+
if (parts.length !== 3)
|
|
162
|
+
return {};
|
|
163
|
+
try {
|
|
164
|
+
return JSON.parse(Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return {};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
export function accountNameOf(tokens) {
|
|
171
|
+
const payload = decodeJwtPayload(tokens.accessToken);
|
|
172
|
+
for (const k of ["preferred_username", "upn", "email"]) {
|
|
173
|
+
if (typeof payload[k] === "string")
|
|
174
|
+
return payload[k];
|
|
175
|
+
}
|
|
176
|
+
return "";
|
|
177
|
+
}
|
|
178
|
+
/** The whole `datum login`: config → device code → (caller shows it) →
|
|
179
|
+
* poll → store. `onCode` runs the moment the code exists. */
|
|
180
|
+
export async function deviceLogin(baseUrl, store, onCode, io = {}) {
|
|
181
|
+
const cfg = await fetchCliLoginConfig(baseUrl, io);
|
|
182
|
+
const device = await startDeviceFlow(cfg, io);
|
|
183
|
+
onCode(device);
|
|
184
|
+
const tokens = await pollDeviceFlow(cfg, device, io);
|
|
185
|
+
const login = {
|
|
186
|
+
baseUrl: originOf(baseUrl),
|
|
187
|
+
issuer: cfg.issuer,
|
|
188
|
+
clientId: cfg.clientId,
|
|
189
|
+
scope: cfg.scope,
|
|
190
|
+
tokenEndpoint: cfg.tokenEndpoint,
|
|
191
|
+
account: accountNameOf(tokens),
|
|
192
|
+
accessToken: tokens.accessToken,
|
|
193
|
+
expiresAt: tokens.expiresAt,
|
|
194
|
+
...(tokens.refreshToken ? { refreshToken: tokens.refreshToken } : {}),
|
|
195
|
+
signedInAt: new Date((io.now ?? Date.now)()).toISOString(),
|
|
196
|
+
};
|
|
197
|
+
store.set(login);
|
|
198
|
+
return login;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* A currently valid access token for `baseUrl` — refreshed (and re-stored)
|
|
202
|
+
* when it is about to expire. Throws NotLoggedInError when there is no
|
|
203
|
+
* login, LoginFlowError when the refresh is refused (the person must sign
|
|
204
|
+
* in again).
|
|
205
|
+
*/
|
|
206
|
+
export async function ensureAccessToken(baseUrl, store, io = {}) {
|
|
207
|
+
const now = io.now ?? Date.now;
|
|
208
|
+
// Every caller sends the token to `baseUrl` next (the MCP shim, a2a, whoami).
|
|
209
|
+
const insecure = insecureOriginReason(baseUrl);
|
|
210
|
+
if (insecure)
|
|
211
|
+
throw new UsageError(insecure);
|
|
212
|
+
const login = store.get(baseUrl);
|
|
213
|
+
if (!login)
|
|
214
|
+
throw new NotLoggedInError(originOf(baseUrl));
|
|
215
|
+
if (now() < login.expiresAt - TOKEN_SKEW_MS)
|
|
216
|
+
return login.accessToken;
|
|
217
|
+
if (!login.refreshToken)
|
|
218
|
+
throw new NotLoggedInError(originOf(baseUrl));
|
|
219
|
+
const fresh = await refreshTokenSet(login, login.refreshToken, io);
|
|
220
|
+
store.set({
|
|
221
|
+
...login,
|
|
222
|
+
accessToken: fresh.accessToken,
|
|
223
|
+
expiresAt: fresh.expiresAt,
|
|
224
|
+
...(fresh.refreshToken ? { refreshToken: fresh.refreshToken } : {}),
|
|
225
|
+
account: accountNameOf(fresh) || login.account,
|
|
226
|
+
});
|
|
227
|
+
return fresh.accessToken;
|
|
228
|
+
}
|
|
229
|
+
/** Turns an org base URL into the SPACE host's URL: `https://datum.example`
|
|
230
|
+
* + `adops` → `https://adops.datum.example` (spaces-multitenancy: one
|
|
231
|
+
* subdomain label per space). Idempotent when the host already starts
|
|
232
|
+
* with the slug. An organisation ENTRY host — a reserved label of the
|
|
233
|
+
* space family, `auth.` / `api.` / `www.` … — sits BESIDE the spaces, not
|
|
234
|
+
* above them: `https://auth.datum.example` + `adops` →
|
|
235
|
+
* `https://adops.datum.example`, never `adops.auth.datum.example`, a name
|
|
236
|
+
* nothing serves (#2792: a run's DATUM_BASE_URL is such a host). */
|
|
237
|
+
export function spaceBaseUrl(baseUrl, space) {
|
|
238
|
+
const url = new URL(baseUrl);
|
|
239
|
+
const slug = space.trim().toLowerCase();
|
|
240
|
+
if (!slug)
|
|
241
|
+
return url.origin;
|
|
242
|
+
if (url.hostname === slug || url.hostname.startsWith(`${slug}.`))
|
|
243
|
+
return url.origin;
|
|
244
|
+
// An IP literal takes no subdomain label (the URL parser would silently
|
|
245
|
+
// drop the assignment) — say so instead of quietly hitting the org host.
|
|
246
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(url.hostname) || url.hostname.startsWith("[")) {
|
|
247
|
+
throw new UsageError(`--space needs a DNS-name base URL (a space is a subdomain); ${url.hostname} is an IP address`);
|
|
248
|
+
}
|
|
249
|
+
const [first, ...rest] = url.hostname.split(".");
|
|
250
|
+
const entry = rest.length >= 2 && RESERVED_SPACE_SLUGS.includes(first ?? "");
|
|
251
|
+
url.hostname = entry ? `${slug}.${rest.join(".")}` : `${slug}.${url.hostname}`;
|
|
252
|
+
return url.origin;
|
|
253
|
+
}
|
|
254
|
+
//# sourceMappingURL=entraLogin.js.map
|