@feastalytics/cli 0.1.2 → 0.1.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 +47 -0
- package/dist/cli.js +274 -162
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -93,6 +93,53 @@ To refresh from a local checkout instead of GitHub, run `npx skills add ./feast
|
|
|
93
93
|
- `FEAST_API_URL` — override the API base URL (e.g. a local dev server)
|
|
94
94
|
- `FEAST_API_KEY` — override the static API key
|
|
95
95
|
|
|
96
|
+
### Non-interactive credentials
|
|
97
|
+
|
|
98
|
+
`feast login` stores tokens under `~/.config/feast-cli/` and refreshes them on
|
|
99
|
+
demand. That does not work where there is no interactive login and no writable
|
|
100
|
+
home directory — most importantly inside a sandboxed agent, where the token is
|
|
101
|
+
supplied by the host and must never be readable by the agent itself.
|
|
102
|
+
|
|
103
|
+
Set `FEAST_ACCESS_TOKEN` for that case:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
export FEAST_ACCESS_TOKEN="$TOKEN"
|
|
107
|
+
export FEAST_ORGANIZATION_ID=4fafb31e-dd46-4081-9778-c298e577a1d1
|
|
108
|
+
export FEAST_PREFERRED_ROLE=OWNER
|
|
109
|
+
feast call listCampaigns
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
- `FEAST_ACCESS_TOKEN` — access token sent verbatim as `x-access-token`
|
|
113
|
+
- `FEAST_ORGANIZATION_ID` — **pins** the organization
|
|
114
|
+
- `FEAST_PREFERRED_ROLE` — **pins** the role; a role name (`OWNER`) or a full role ARN
|
|
115
|
+
|
|
116
|
+
When `FEAST_ACCESS_TOKEN` is set the CLI reads no token from disk, refreshes
|
|
117
|
+
nothing, and **never decodes the token**. Normally the organization and role are
|
|
118
|
+
read from the token's `cognito:groups` claim, so both must be supplied instead.
|
|
119
|
+
|
|
120
|
+
Not decoding the token is the point, not a limitation: it lets the token be an
|
|
121
|
+
opaque placeholder that a secret store substitutes for the real value after the
|
|
122
|
+
request leaves the machine, so a compromised sandbox yields nothing.
|
|
123
|
+
|
|
124
|
+
`FEAST_ORGANIZATION_ID` and `FEAST_PREFERRED_ROLE` pin the session rather than
|
|
125
|
+
just defaulting it. `--org` and `--role` may repeat the pinned value but cannot
|
|
126
|
+
change it:
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
$ FEAST_ORGANIZATION_ID=org-A feast call listCampaigns --org org-B
|
|
130
|
+
FEAST_ORGANIZATION_ID pins this session to org-A, so --org org-B is not allowed.
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Pinning the role is how you run an agent at **reduced** privilege. The API
|
|
134
|
+
validates a requested role against the user's real Cognito groups, so it will
|
|
135
|
+
happily accept `OWNER` from a user who is an owner — only the pin can hold that
|
|
136
|
+
user's agent down to `VIEWER`.
|
|
137
|
+
|
|
138
|
+
Neither pin is a security boundary on its own: both are ordinary environment
|
|
139
|
+
variables, so anything that can set env vars in the same process tree can change
|
|
140
|
+
them. They stop a tool call from wandering, not a determined process. Scope the
|
|
141
|
+
token itself if you need a hard guarantee.
|
|
142
|
+
|
|
96
143
|
## Development
|
|
97
144
|
|
|
98
145
|
```bash
|
package/dist/cli.js
CHANGED
|
@@ -8656,146 +8656,6 @@ function errorMessage(error) {
|
|
|
8656
8656
|
// src/main.ts
|
|
8657
8657
|
import fs2 from "fs";
|
|
8658
8658
|
|
|
8659
|
-
// src/browserLogin.ts
|
|
8660
|
-
import { spawn } from "child_process";
|
|
8661
|
-
import { createHash, randomBytes } from "crypto";
|
|
8662
|
-
import http from "http";
|
|
8663
|
-
var CALLBACK_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
8664
|
-
function base64url(buffer) {
|
|
8665
|
-
return buffer.toString("base64url");
|
|
8666
|
-
}
|
|
8667
|
-
function tabHtml(title, body) {
|
|
8668
|
-
return `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title>
|
|
8669
|
-
<style>body{font-family:-apple-system,system-ui,sans-serif;background:#0f0f0f;color:#f5f5f5;
|
|
8670
|
-
display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0}
|
|
8671
|
-
.card{text-align:center;padding:2rem 3rem}.t{font-size:1.4rem;font-weight:600;margin-bottom:.5rem}
|
|
8672
|
-
.b{color:#a1a1a1}</style></head><body><div class="card">
|
|
8673
|
-
<div class="t">${title}</div><div class="b">${body}</div></div></body></html>`;
|
|
8674
|
-
}
|
|
8675
|
-
function openBrowser(url) {
|
|
8676
|
-
let command;
|
|
8677
|
-
let args;
|
|
8678
|
-
if (process.platform === "darwin") {
|
|
8679
|
-
command = "open";
|
|
8680
|
-
args = [url];
|
|
8681
|
-
} else if (process.platform === "win32") {
|
|
8682
|
-
command = "cmd";
|
|
8683
|
-
args = ["/c", "start", "", url];
|
|
8684
|
-
} else {
|
|
8685
|
-
command = "xdg-open";
|
|
8686
|
-
args = [url];
|
|
8687
|
-
}
|
|
8688
|
-
try {
|
|
8689
|
-
const child = spawn(command, args, { stdio: "ignore", detached: true });
|
|
8690
|
-
child.on("error", () => {
|
|
8691
|
-
});
|
|
8692
|
-
child.unref();
|
|
8693
|
-
} catch {
|
|
8694
|
-
}
|
|
8695
|
-
}
|
|
8696
|
-
function startLoopback(expectedState) {
|
|
8697
|
-
return new Promise((resolveOuter, rejectOuter) => {
|
|
8698
|
-
let resolveCode = () => {
|
|
8699
|
-
};
|
|
8700
|
-
let rejectCode = () => {
|
|
8701
|
-
};
|
|
8702
|
-
const waitForCode = new Promise((resolve, reject) => {
|
|
8703
|
-
resolveCode = resolve;
|
|
8704
|
-
rejectCode = reject;
|
|
8705
|
-
});
|
|
8706
|
-
waitForCode.catch(() => {
|
|
8707
|
-
});
|
|
8708
|
-
const server = http.createServer((req, res) => {
|
|
8709
|
-
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
8710
|
-
if (url.pathname !== "/callback") {
|
|
8711
|
-
res.writeHead(404);
|
|
8712
|
-
res.end();
|
|
8713
|
-
return;
|
|
8714
|
-
}
|
|
8715
|
-
const error = url.searchParams.get("error");
|
|
8716
|
-
if (error != null) {
|
|
8717
|
-
res.writeHead(200, { "Content-Type": "text/html" });
|
|
8718
|
-
res.end(tabHtml("Authorization declined", "You can close this tab."));
|
|
8719
|
-
rejectCode(new Error(`Authorization declined: ${error}`));
|
|
8720
|
-
return;
|
|
8721
|
-
}
|
|
8722
|
-
if (url.searchParams.get("state") !== expectedState) {
|
|
8723
|
-
res.writeHead(400, { "Content-Type": "text/html" });
|
|
8724
|
-
res.end(
|
|
8725
|
-
tabHtml("Invalid request", "State mismatch \u2014 retry from the CLI.")
|
|
8726
|
-
);
|
|
8727
|
-
rejectCode(new Error("State mismatch \u2014 aborting (possible CSRF)"));
|
|
8728
|
-
return;
|
|
8729
|
-
}
|
|
8730
|
-
const code = url.searchParams.get("code");
|
|
8731
|
-
if (code == null || code.length === 0) {
|
|
8732
|
-
res.writeHead(400);
|
|
8733
|
-
res.end();
|
|
8734
|
-
rejectCode(new Error("Authorization callback did not include a code"));
|
|
8735
|
-
return;
|
|
8736
|
-
}
|
|
8737
|
-
res.writeHead(200, {
|
|
8738
|
-
"Content-Type": "text/html",
|
|
8739
|
-
Connection: "close"
|
|
8740
|
-
});
|
|
8741
|
-
res.end(
|
|
8742
|
-
tabHtml(
|
|
8743
|
-
"You're all set \u2713",
|
|
8744
|
-
"The feast CLI is authorized. Close this tab and return to your terminal."
|
|
8745
|
-
)
|
|
8746
|
-
);
|
|
8747
|
-
resolveCode(code);
|
|
8748
|
-
});
|
|
8749
|
-
const timeout = setTimeout(() => {
|
|
8750
|
-
rejectCode(
|
|
8751
|
-
new Error("Timed out waiting for browser authorization (5 minutes)")
|
|
8752
|
-
);
|
|
8753
|
-
}, CALLBACK_TIMEOUT_MS);
|
|
8754
|
-
const close = () => {
|
|
8755
|
-
clearTimeout(timeout);
|
|
8756
|
-
server.closeAllConnections?.();
|
|
8757
|
-
server.close();
|
|
8758
|
-
};
|
|
8759
|
-
server.on("error", rejectOuter);
|
|
8760
|
-
server.listen(0, "127.0.0.1", () => {
|
|
8761
|
-
server.unref();
|
|
8762
|
-
const address = server.address();
|
|
8763
|
-
const port = typeof address === "object" && address != null ? address.port : 0;
|
|
8764
|
-
resolveOuter({ port, waitForCode, close });
|
|
8765
|
-
});
|
|
8766
|
-
});
|
|
8767
|
-
}
|
|
8768
|
-
async function browserLogin() {
|
|
8769
|
-
const codeVerifier = base64url(randomBytes(32));
|
|
8770
|
-
const codeChallenge = base64url(
|
|
8771
|
-
createHash("sha256").update(codeVerifier).digest()
|
|
8772
|
-
);
|
|
8773
|
-
const state = base64url(randomBytes(16));
|
|
8774
|
-
const { port, waitForCode, close } = await startLoopback(state);
|
|
8775
|
-
try {
|
|
8776
|
-
const authUrl = `${getWebBaseUrl()}/oauth?port=${port}&state=${encodeURIComponent(state)}&challenge=${encodeURIComponent(codeChallenge)}`;
|
|
8777
|
-
console.error("Opening your browser to authorize the feast CLI...");
|
|
8778
|
-
console.error(`If it doesn't open, visit:
|
|
8779
|
-
${authUrl}
|
|
8780
|
-
`);
|
|
8781
|
-
openBrowser(authUrl);
|
|
8782
|
-
const code = await waitForCode;
|
|
8783
|
-
const client = createHttpCaller({});
|
|
8784
|
-
const response = await client.api.auth.exchangeAuthCode.mutate({
|
|
8785
|
-
code,
|
|
8786
|
-
codeVerifier
|
|
8787
|
-
});
|
|
8788
|
-
const tokens = response?.tokens;
|
|
8789
|
-
if (tokens?.accessToken?.jwtToken == null) {
|
|
8790
|
-
throw new Error("Authorization failed: empty token response");
|
|
8791
|
-
}
|
|
8792
|
-
saveTokens(tokens);
|
|
8793
|
-
return tokens;
|
|
8794
|
-
} finally {
|
|
8795
|
-
close();
|
|
8796
|
-
}
|
|
8797
|
-
}
|
|
8798
|
-
|
|
8799
8659
|
// src/generated/manifest.ts
|
|
8800
8660
|
var CLI_MANIFEST = {
|
|
8801
8661
|
"awsAccountId": "891817334910",
|
|
@@ -10879,7 +10739,8 @@ var CLI_MANIFEST = {
|
|
|
10879
10739
|
"funnel",
|
|
10880
10740
|
"organizationFilePublic",
|
|
10881
10741
|
"organizationFilePrivate",
|
|
10882
|
-
"creativeLibrary"
|
|
10742
|
+
"creativeLibrary",
|
|
10743
|
+
"creativeLibraryBroll"
|
|
10883
10744
|
]
|
|
10884
10745
|
},
|
|
10885
10746
|
"key": {
|
|
@@ -11360,7 +11221,7 @@ var CLI_MANIFEST = {
|
|
|
11360
11221
|
{
|
|
11361
11222
|
"id": "getMediaUploadUrl",
|
|
11362
11223
|
"domain": "core",
|
|
11363
|
-
"description": "Get a presigned S3 upload URL for a media file in a given scope (passBuilder, funnel, organizationFilePublic, organizationFilePrivate, creativeLibrary). Set cropped to store a cropped variant under the scope's cropped/ subfolder. PUT the file bytes to the returned presignedUrl, then reference the returned url/key.",
|
|
11224
|
+
"description": "Get a presigned S3 upload URL for a media file in a given scope (passBuilder, funnel, organizationFilePublic, organizationFilePrivate, creativeLibrary, creativeLibraryBroll). Set cropped to store a cropped variant under the scope's cropped/ subfolder. PUT the file bytes to the returned presignedUrl, then reference the returned url/key.",
|
|
11364
11225
|
"needsApproval": false,
|
|
11365
11226
|
"type": "mutation",
|
|
11366
11227
|
"path": [
|
|
@@ -11378,7 +11239,8 @@ var CLI_MANIFEST = {
|
|
|
11378
11239
|
"funnel",
|
|
11379
11240
|
"organizationFilePublic",
|
|
11380
11241
|
"organizationFilePrivate",
|
|
11381
|
-
"creativeLibrary"
|
|
11242
|
+
"creativeLibrary",
|
|
11243
|
+
"creativeLibraryBroll"
|
|
11382
11244
|
]
|
|
11383
11245
|
},
|
|
11384
11246
|
"fileName": {
|
|
@@ -11581,7 +11443,7 @@ var CLI_MANIFEST = {
|
|
|
11581
11443
|
{
|
|
11582
11444
|
"id": "listMedia",
|
|
11583
11445
|
"domain": "core",
|
|
11584
|
-
"description": "List uploaded media files for the organization across one or more scopes (passBuilder, funnel, organizationFilePublic, organizationFilePrivate, creativeLibrary). Each file is tagged with its scope and a canDelete flag. Set mediaOnly to return only image/video files. Set cropped to list the cropped variants (each scope's cropped/ subfolder) instead of the base files.",
|
|
11446
|
+
"description": "List uploaded media files for the organization across one or more scopes (passBuilder, funnel, organizationFilePublic, organizationFilePrivate, creativeLibrary, creativeLibraryBroll). Each file is tagged with its scope and a canDelete flag. Set mediaOnly to return only image/video files. Set cropped to list the cropped variants (each scope's cropped/ subfolder) instead of the base files.",
|
|
11585
11447
|
"needsApproval": false,
|
|
11586
11448
|
"type": "query",
|
|
11587
11449
|
"path": [
|
|
@@ -11601,7 +11463,8 @@ var CLI_MANIFEST = {
|
|
|
11601
11463
|
"funnel",
|
|
11602
11464
|
"organizationFilePublic",
|
|
11603
11465
|
"organizationFilePrivate",
|
|
11604
|
-
"creativeLibrary"
|
|
11466
|
+
"creativeLibrary",
|
|
11467
|
+
"creativeLibraryBroll"
|
|
11605
11468
|
]
|
|
11606
11469
|
},
|
|
11607
11470
|
"minItems": 1
|
|
@@ -18265,6 +18128,234 @@ ${available}`
|
|
|
18265
18128
|
};
|
|
18266
18129
|
}
|
|
18267
18130
|
|
|
18131
|
+
// src/actingContext.ts
|
|
18132
|
+
function environmentAccessToken() {
|
|
18133
|
+
const token = process.env.FEAST_ACCESS_TOKEN;
|
|
18134
|
+
return token != null && token.length > 0 ? token : void 0;
|
|
18135
|
+
}
|
|
18136
|
+
function firstNonEmpty(...values) {
|
|
18137
|
+
for (const value of values) {
|
|
18138
|
+
if (value != null && value.length > 0) {
|
|
18139
|
+
return value;
|
|
18140
|
+
}
|
|
18141
|
+
}
|
|
18142
|
+
return void 0;
|
|
18143
|
+
}
|
|
18144
|
+
function roleLabelFromArn(arn) {
|
|
18145
|
+
return arn.split("/").pop()?.split(".").pop() ?? arn;
|
|
18146
|
+
}
|
|
18147
|
+
function buildPreferredRole(organizationId, role) {
|
|
18148
|
+
if (role.startsWith("arn:")) {
|
|
18149
|
+
return role;
|
|
18150
|
+
}
|
|
18151
|
+
if (!CLI_MANIFEST.organizationRoles.includes(role)) {
|
|
18152
|
+
throw new Error(
|
|
18153
|
+
`Unknown role "${role}". Expected one of ${CLI_MANIFEST.organizationRoles.join(", ")}, or a full role ARN.`
|
|
18154
|
+
);
|
|
18155
|
+
}
|
|
18156
|
+
return formRoleArn(organizationId, role);
|
|
18157
|
+
}
|
|
18158
|
+
function resolvePinned(pinned, flag, variableName, flagName) {
|
|
18159
|
+
if (pinned == null) {
|
|
18160
|
+
return flag;
|
|
18161
|
+
}
|
|
18162
|
+
if (flag != null && flag !== pinned) {
|
|
18163
|
+
throw new Error(
|
|
18164
|
+
`${variableName} pins this session to ${pinned}, so ${flagName} ${flag} is not allowed.`
|
|
18165
|
+
);
|
|
18166
|
+
}
|
|
18167
|
+
return pinned;
|
|
18168
|
+
}
|
|
18169
|
+
function resolveFromEnvironment(accessToken, orgFlag, roleFlag) {
|
|
18170
|
+
const organizationId = resolvePinned(
|
|
18171
|
+
firstNonEmpty(process.env.FEAST_ORGANIZATION_ID),
|
|
18172
|
+
orgFlag,
|
|
18173
|
+
"FEAST_ORGANIZATION_ID",
|
|
18174
|
+
"--org"
|
|
18175
|
+
);
|
|
18176
|
+
if (organizationId == null) {
|
|
18177
|
+
throw new Error(
|
|
18178
|
+
"FEAST_ACCESS_TOKEN is set, so the organization cannot be read from the token. Pass --org <organizationId> or set FEAST_ORGANIZATION_ID."
|
|
18179
|
+
);
|
|
18180
|
+
}
|
|
18181
|
+
const role = resolvePinned(
|
|
18182
|
+
firstNonEmpty(process.env.FEAST_PREFERRED_ROLE),
|
|
18183
|
+
roleFlag,
|
|
18184
|
+
"FEAST_PREFERRED_ROLE",
|
|
18185
|
+
"--role"
|
|
18186
|
+
);
|
|
18187
|
+
if (role == null) {
|
|
18188
|
+
throw new Error(
|
|
18189
|
+
"FEAST_ACCESS_TOKEN is set, so the role cannot be read from the token. Pass --role <role> or set FEAST_PREFERRED_ROLE."
|
|
18190
|
+
);
|
|
18191
|
+
}
|
|
18192
|
+
const preferredRole = buildPreferredRole(organizationId, role);
|
|
18193
|
+
return {
|
|
18194
|
+
accessToken,
|
|
18195
|
+
organizationId,
|
|
18196
|
+
preferredRole,
|
|
18197
|
+
roleLabel: roleLabelFromArn(preferredRole),
|
|
18198
|
+
source: "environment"
|
|
18199
|
+
};
|
|
18200
|
+
}
|
|
18201
|
+
async function resolveActingContext(orgFlag, roleFlag) {
|
|
18202
|
+
const environmentToken = environmentAccessToken();
|
|
18203
|
+
if (environmentToken != null) {
|
|
18204
|
+
return resolveFromEnvironment(environmentToken, orgFlag, roleFlag);
|
|
18205
|
+
}
|
|
18206
|
+
const tokens = await ensureFreshTokens();
|
|
18207
|
+
const accessToken = tokens.accessToken.jwtToken;
|
|
18208
|
+
const organizationId = resolveOrganizationId(accessToken, orgFlag);
|
|
18209
|
+
const resolved = resolvePreferredRole(accessToken, organizationId);
|
|
18210
|
+
return {
|
|
18211
|
+
accessToken,
|
|
18212
|
+
organizationId,
|
|
18213
|
+
preferredRole: resolved.arn,
|
|
18214
|
+
roleLabel: resolved.orgRole.role,
|
|
18215
|
+
source: "credentials"
|
|
18216
|
+
};
|
|
18217
|
+
}
|
|
18218
|
+
|
|
18219
|
+
// src/browserLogin.ts
|
|
18220
|
+
import { spawn } from "child_process";
|
|
18221
|
+
import { createHash, randomBytes } from "crypto";
|
|
18222
|
+
import http from "http";
|
|
18223
|
+
var CALLBACK_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
18224
|
+
function base64url(buffer) {
|
|
18225
|
+
return buffer.toString("base64url");
|
|
18226
|
+
}
|
|
18227
|
+
function tabHtml(title, body) {
|
|
18228
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title>
|
|
18229
|
+
<style>body{font-family:-apple-system,system-ui,sans-serif;background:#0f0f0f;color:#f5f5f5;
|
|
18230
|
+
display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0}
|
|
18231
|
+
.card{text-align:center;padding:2rem 3rem}.t{font-size:1.4rem;font-weight:600;margin-bottom:.5rem}
|
|
18232
|
+
.b{color:#a1a1a1}</style></head><body><div class="card">
|
|
18233
|
+
<div class="t">${title}</div><div class="b">${body}</div></div></body></html>`;
|
|
18234
|
+
}
|
|
18235
|
+
function openBrowser(url) {
|
|
18236
|
+
let command;
|
|
18237
|
+
let args;
|
|
18238
|
+
if (process.platform === "darwin") {
|
|
18239
|
+
command = "open";
|
|
18240
|
+
args = [url];
|
|
18241
|
+
} else if (process.platform === "win32") {
|
|
18242
|
+
command = "cmd";
|
|
18243
|
+
args = ["/c", "start", "", url];
|
|
18244
|
+
} else {
|
|
18245
|
+
command = "xdg-open";
|
|
18246
|
+
args = [url];
|
|
18247
|
+
}
|
|
18248
|
+
try {
|
|
18249
|
+
const child = spawn(command, args, { stdio: "ignore", detached: true });
|
|
18250
|
+
child.on("error", () => {
|
|
18251
|
+
});
|
|
18252
|
+
child.unref();
|
|
18253
|
+
} catch {
|
|
18254
|
+
}
|
|
18255
|
+
}
|
|
18256
|
+
function startLoopback(expectedState) {
|
|
18257
|
+
return new Promise((resolveOuter, rejectOuter) => {
|
|
18258
|
+
let resolveCode = () => {
|
|
18259
|
+
};
|
|
18260
|
+
let rejectCode = () => {
|
|
18261
|
+
};
|
|
18262
|
+
const waitForCode = new Promise((resolve, reject) => {
|
|
18263
|
+
resolveCode = resolve;
|
|
18264
|
+
rejectCode = reject;
|
|
18265
|
+
});
|
|
18266
|
+
waitForCode.catch(() => {
|
|
18267
|
+
});
|
|
18268
|
+
const server = http.createServer((req, res) => {
|
|
18269
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
18270
|
+
if (url.pathname !== "/callback") {
|
|
18271
|
+
res.writeHead(404);
|
|
18272
|
+
res.end();
|
|
18273
|
+
return;
|
|
18274
|
+
}
|
|
18275
|
+
const error = url.searchParams.get("error");
|
|
18276
|
+
if (error != null) {
|
|
18277
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
18278
|
+
res.end(tabHtml("Authorization declined", "You can close this tab."));
|
|
18279
|
+
rejectCode(new Error(`Authorization declined: ${error}`));
|
|
18280
|
+
return;
|
|
18281
|
+
}
|
|
18282
|
+
if (url.searchParams.get("state") !== expectedState) {
|
|
18283
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
18284
|
+
res.end(
|
|
18285
|
+
tabHtml("Invalid request", "State mismatch \u2014 retry from the CLI.")
|
|
18286
|
+
);
|
|
18287
|
+
rejectCode(new Error("State mismatch \u2014 aborting (possible CSRF)"));
|
|
18288
|
+
return;
|
|
18289
|
+
}
|
|
18290
|
+
const code = url.searchParams.get("code");
|
|
18291
|
+
if (code == null || code.length === 0) {
|
|
18292
|
+
res.writeHead(400);
|
|
18293
|
+
res.end();
|
|
18294
|
+
rejectCode(new Error("Authorization callback did not include a code"));
|
|
18295
|
+
return;
|
|
18296
|
+
}
|
|
18297
|
+
res.writeHead(200, {
|
|
18298
|
+
"Content-Type": "text/html",
|
|
18299
|
+
Connection: "close"
|
|
18300
|
+
});
|
|
18301
|
+
res.end(
|
|
18302
|
+
tabHtml(
|
|
18303
|
+
"You're all set \u2713",
|
|
18304
|
+
"The feast CLI is authorized. Close this tab and return to your terminal."
|
|
18305
|
+
)
|
|
18306
|
+
);
|
|
18307
|
+
resolveCode(code);
|
|
18308
|
+
});
|
|
18309
|
+
const timeout = setTimeout(() => {
|
|
18310
|
+
rejectCode(
|
|
18311
|
+
new Error("Timed out waiting for browser authorization (5 minutes)")
|
|
18312
|
+
);
|
|
18313
|
+
}, CALLBACK_TIMEOUT_MS);
|
|
18314
|
+
const close = () => {
|
|
18315
|
+
clearTimeout(timeout);
|
|
18316
|
+
server.closeAllConnections?.();
|
|
18317
|
+
server.close();
|
|
18318
|
+
};
|
|
18319
|
+
server.on("error", rejectOuter);
|
|
18320
|
+
server.listen(0, "127.0.0.1", () => {
|
|
18321
|
+
server.unref();
|
|
18322
|
+
const address = server.address();
|
|
18323
|
+
const port = typeof address === "object" && address != null ? address.port : 0;
|
|
18324
|
+
resolveOuter({ port, waitForCode, close });
|
|
18325
|
+
});
|
|
18326
|
+
});
|
|
18327
|
+
}
|
|
18328
|
+
async function browserLogin() {
|
|
18329
|
+
const codeVerifier = base64url(randomBytes(32));
|
|
18330
|
+
const codeChallenge = base64url(
|
|
18331
|
+
createHash("sha256").update(codeVerifier).digest()
|
|
18332
|
+
);
|
|
18333
|
+
const state = base64url(randomBytes(16));
|
|
18334
|
+
const { port, waitForCode, close } = await startLoopback(state);
|
|
18335
|
+
try {
|
|
18336
|
+
const authUrl = `${getWebBaseUrl()}/oauth?port=${port}&state=${encodeURIComponent(state)}&challenge=${encodeURIComponent(codeChallenge)}`;
|
|
18337
|
+
console.error("Opening your browser to authorize the feast CLI...");
|
|
18338
|
+
console.error(`If it doesn't open, visit:
|
|
18339
|
+
${authUrl}
|
|
18340
|
+
`);
|
|
18341
|
+
openBrowser(authUrl);
|
|
18342
|
+
const code = await waitForCode;
|
|
18343
|
+
const client = createHttpCaller({});
|
|
18344
|
+
const response = await client.api.auth.exchangeAuthCode.mutate({
|
|
18345
|
+
code,
|
|
18346
|
+
codeVerifier
|
|
18347
|
+
});
|
|
18348
|
+
const tokens = response?.tokens;
|
|
18349
|
+
if (tokens?.accessToken?.jwtToken == null) {
|
|
18350
|
+
throw new Error("Authorization failed: empty token response");
|
|
18351
|
+
}
|
|
18352
|
+
saveTokens(tokens);
|
|
18353
|
+
return tokens;
|
|
18354
|
+
} finally {
|
|
18355
|
+
close();
|
|
18356
|
+
}
|
|
18357
|
+
}
|
|
18358
|
+
|
|
18268
18359
|
// src/prompt.ts
|
|
18269
18360
|
import readline from "readline";
|
|
18270
18361
|
function promptText(question) {
|
|
@@ -18349,6 +18440,7 @@ Commands:
|
|
|
18349
18440
|
|
|
18350
18441
|
Call options:
|
|
18351
18442
|
--org <organizationId> Organization to act on (required unless you belong to exactly one)
|
|
18443
|
+
--role <role|roleArn> Role to act as, e.g. OWNER (required when FEAST_ACCESS_TOKEN is set)
|
|
18352
18444
|
--input <json> Tool input as a JSON string (default: {})
|
|
18353
18445
|
--input-file <path> Tool input from a JSON file
|
|
18354
18446
|
--yes Skip the confirmation prompt for mutations
|
|
@@ -18356,11 +18448,18 @@ Call options:
|
|
|
18356
18448
|
Environment:
|
|
18357
18449
|
FEAST_API_URL Override the API base URL (default: production)
|
|
18358
18450
|
FEAST_WEB_URL Override the web app base URL for browser login (default: production)
|
|
18359
|
-
FEAST_NO_UPDATE_CHECK Disable the daily check for a newer published version
|
|
18451
|
+
FEAST_NO_UPDATE_CHECK Disable the daily check for a newer published version
|
|
18452
|
+
|
|
18453
|
+
Non-interactive credentials (for sandboxed agents):
|
|
18454
|
+
FEAST_ACCESS_TOKEN Access token to send verbatim. When set, no token is read
|
|
18455
|
+
from disk and none is refreshed, and the token is never
|
|
18456
|
+
decoded \u2014 so the organization and role must be supplied too.
|
|
18457
|
+
FEAST_ORGANIZATION_ID Pins the organization. --org must match it or be omitted.
|
|
18458
|
+
FEAST_PREFERRED_ROLE Pins the role. --role must match it or be omitted.`;
|
|
18360
18459
|
function parseArgs(argv) {
|
|
18361
18460
|
const positional = [];
|
|
18362
18461
|
const flags = {};
|
|
18363
|
-
const valueFlags = /* @__PURE__ */ new Set(["org", "input", "input-file", "domain"]);
|
|
18462
|
+
const valueFlags = /* @__PURE__ */ new Set(["org", "role", "input", "input-file", "domain"]);
|
|
18364
18463
|
for (let i = 0; i < argv.length; i++) {
|
|
18365
18464
|
const arg = argv[i];
|
|
18366
18465
|
if (!arg.startsWith("--")) {
|
|
@@ -18382,6 +18481,11 @@ function parseArgs(argv) {
|
|
|
18382
18481
|
return { positional, flags };
|
|
18383
18482
|
}
|
|
18384
18483
|
async function commandLogin(args) {
|
|
18484
|
+
if (environmentAccessToken() != null) {
|
|
18485
|
+
throw new Error(
|
|
18486
|
+
"FEAST_ACCESS_TOKEN is set, so this CLI is using credentials from the environment. Unset it to log in and store tokens on disk."
|
|
18487
|
+
);
|
|
18488
|
+
}
|
|
18385
18489
|
if (args.flags["password"] === true) {
|
|
18386
18490
|
const username = args.positional[0] ?? await promptText("Username: ");
|
|
18387
18491
|
if (username.length === 0) {
|
|
@@ -18402,7 +18506,20 @@ async function commandLogin(args) {
|
|
|
18402
18506
|
console.info(`Logged in as ${payload.username ?? payload.sub}`);
|
|
18403
18507
|
console.info(`Tokens stored in ${getCredentialsPath()}`);
|
|
18404
18508
|
}
|
|
18405
|
-
async function commandWhoami() {
|
|
18509
|
+
async function commandWhoami(args) {
|
|
18510
|
+
if (environmentAccessToken() != null) {
|
|
18511
|
+
const acting = await resolveActingContext(
|
|
18512
|
+
args.flags["org"],
|
|
18513
|
+
args.flags["role"]
|
|
18514
|
+
);
|
|
18515
|
+
console.info("Credentials: FEAST_ACCESS_TOKEN (environment)");
|
|
18516
|
+
console.info(`Organization: ${acting.organizationId}`);
|
|
18517
|
+
console.info(`Role: ${acting.roleLabel}`);
|
|
18518
|
+
console.info(
|
|
18519
|
+
"The token is opaque to this CLI, so the user and their other organizations cannot be listed."
|
|
18520
|
+
);
|
|
18521
|
+
return;
|
|
18522
|
+
}
|
|
18406
18523
|
const tokens = await ensureFreshTokens();
|
|
18407
18524
|
const payload = decodeJwtPayload(tokens.accessToken.jwtToken);
|
|
18408
18525
|
const orgRoles = listOrgRoles(tokens.accessToken.jwtToken);
|
|
@@ -18544,25 +18661,20 @@ ${issues.join("\n")}
|
|
|
18544
18661
|
Run: feast describe ${tool.id}`
|
|
18545
18662
|
);
|
|
18546
18663
|
}
|
|
18547
|
-
const
|
|
18548
|
-
|
|
18549
|
-
|
|
18550
|
-
tokens.accessToken.jwtToken,
|
|
18551
|
-
args.flags["org"]
|
|
18552
|
-
);
|
|
18553
|
-
const resolved = resolvePreferredRole(
|
|
18554
|
-
tokens.accessToken.jwtToken,
|
|
18555
|
-
organizationId
|
|
18664
|
+
const acting = await resolveActingContext(
|
|
18665
|
+
args.flags["org"],
|
|
18666
|
+
args.flags["role"]
|
|
18556
18667
|
);
|
|
18668
|
+
const isWrite = tool.type === "mutation" || tool.needsApproval;
|
|
18557
18669
|
console.error(
|
|
18558
|
-
`Acting on organization ${organizationId} as ${
|
|
18670
|
+
`Acting on organization ${acting.organizationId} as ${acting.roleLabel}`
|
|
18559
18671
|
);
|
|
18560
18672
|
const client = createHttpCaller({
|
|
18561
|
-
accessToken:
|
|
18562
|
-
preferredRole:
|
|
18673
|
+
accessToken: acting.accessToken,
|
|
18674
|
+
preferredRole: acting.preferredRole
|
|
18563
18675
|
});
|
|
18564
18676
|
if (isWrite) {
|
|
18565
|
-
const organization = await verifyOrganization(client, organizationId);
|
|
18677
|
+
const organization = await verifyOrganization(client, acting.organizationId);
|
|
18566
18678
|
if (args.flags["yes"] !== true) {
|
|
18567
18679
|
const orgLabel = organization.name != null ? `"${organization.name}" (${organization.id})` : organization.id;
|
|
18568
18680
|
const approved = await promptConfirm(
|
|
@@ -18590,7 +18702,7 @@ async function runCli(argv) {
|
|
|
18590
18702
|
console.info("Logged out");
|
|
18591
18703
|
break;
|
|
18592
18704
|
case "whoami":
|
|
18593
|
-
await commandWhoami();
|
|
18705
|
+
await commandWhoami(args);
|
|
18594
18706
|
break;
|
|
18595
18707
|
case "tools":
|
|
18596
18708
|
commandTools(args);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feastalytics/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Command-line client for the Feastalytics platform — list, create, and update campaigns, automations, offers, and members-program rewards from the terminal.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|