atmn 1.1.25 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +120 -0
- package/dist/bin.js +23102 -0
- package/dist/index.js +2906 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +27 -74
- package/src/actions/api/callApi.ts +280 -0
- package/src/actions/api/registerApiCommands.ts +121 -0
- package/src/actions/env/fetchOrgInfo.ts +32 -0
- package/src/actions/env/types/orgInfo.ts +18 -0
- package/src/actions/env.ts +94 -0
- package/src/actions/init/runInit.ts +401 -0
- package/src/actions/login/keyless.ts +135 -0
- package/src/actions/login.ts +144 -0
- package/src/actions/pull/appendPlanVersionFixture.ts +373 -0
- package/src/actions/pull/applyPreview.ts +553 -0
- package/src/actions/pull/applySettingsPreview.ts +114 -0
- package/src/actions/pull/changedFixtureKeys.ts +88 -0
- package/src/actions/pull/listSourceFiles.ts +29 -0
- package/src/actions/pull/locateFixture.ts +78 -0
- package/src/actions/pull/resolveCollectionTarget.ts +198 -0
- package/src/actions/pull/rewriteConfig.ts +57 -0
- package/src/actions/pull/scaffoldConfig.ts +118 -0
- package/src/actions/pull.ts +399 -0
- package/src/actions/push/backfillInternalIds.ts +394 -0
- package/src/actions/push/deprecatedFields.ts +55 -0
- package/src/actions/push.ts +294 -0
- package/src/actions/reset/runReset.ts +58 -0
- package/src/actions/sandbox/createSandbox.ts +83 -0
- package/src/actions/sandbox/deleteSandbox.ts +91 -0
- package/src/actions/sandbox/listSandboxes.ts +28 -0
- package/src/actions/sandbox/types/sandboxClient.ts +16 -0
- package/src/actions/sandbox/useSandbox.ts +157 -0
- package/src/actions/sandbox/withSandboxScopeHint.ts +27 -0
- package/src/actions/skills/skills.ts +246 -0
- package/src/auth/announceAuthorizationUrl.ts +26 -0
- package/src/auth/browser/openSystemBrowser.ts +7 -0
- package/src/auth/browser/tryOpenBrowser.ts +17 -0
- package/src/auth/browser/watchLauncher.ts +46 -0
- package/src/auth/buildAuthorizationUrl.ts +36 -0
- package/src/auth/callbackPages.ts +126 -0
- package/src/auth/createOrgApiKeys.ts +46 -0
- package/src/auth/keyless.ts +119 -0
- package/src/auth/oauthConfig.ts +63 -0
- package/src/auth/runOAuthFlow.ts +230 -0
- package/src/auth/types/browserOpener.ts +5 -0
- package/src/auth/types/impersonationTokens.ts +23 -0
- package/src/auth/types/oauthTokens.ts +13 -0
- package/src/auth/types/orgApiKeys.ts +6 -0
- package/src/bin.ts +9 -0
- package/src/cli.ts +648 -0
- package/src/config/configPackageName.ts +9 -0
- package/src/config/legacyConfig.ts +25 -0
- package/src/config/loadConfig.ts +231 -0
- package/src/env/assertSandboxTarget.ts +20 -0
- package/src/env/loadEnv.ts +184 -0
- package/src/env/resolveTarget.ts +134 -0
- package/src/env/sandboxKeyName.ts +18 -0
- package/src/generated/apiRoutes.ts +3787 -0
- package/src/generated/client.ts +51564 -0
- package/src/generated/emit.ts +1163 -0
- package/src/generated/emitRuntime.ts +522 -0
- package/src/generated/features.ts +146 -0
- package/src/generated/labels.ts +29 -0
- package/src/generated/licenses.ts +287 -0
- package/src/generated/lintRules.ts +2207 -0
- package/src/generated/lintRuntime.ts +865 -0
- package/src/generated/plans.ts +1628 -0
- package/src/generated/referralPrograms.ts +22 -0
- package/src/generated/rewards.ts +58 -0
- package/src/generated/settings.ts +21 -0
- package/src/generated/skills.ts +305 -0
- package/src/generated/variants.ts +934 -0
- package/src/generated/wire.ts +334 -0
- package/src/http/autumnFetch.ts +30 -0
- package/src/index.ts +20 -0
- package/src/project/chooseConfigDir.ts +41 -0
- package/src/project/resolveProject.ts +115 -0
- package/src/project/rootMarker.ts +40 -0
- package/src/prompt/prompt.ts +186 -0
- package/src/prompt/select.ts +162 -0
- package/src/render/renderEnv.ts +77 -0
- package/src/render/renderPreview.ts +945 -0
- package/src/render/renderSandboxes.ts +92 -0
- package/src/render/stripTerminalControls.ts +19 -0
- package/src/repo/findRepoRoot.ts +79 -0
- package/src/surgery/appendPropertyEdit.ts +67 -0
- package/src/surgery/appendToArray.ts +87 -0
- package/src/surgery/appendToBinding.ts +19 -0
- package/src/surgery/appendToCollection.ts +40 -0
- package/src/surgery/appendToFixtureArray.ts +71 -0
- package/src/surgery/arrayBinding.ts +30 -0
- package/src/surgery/deleteFixtureLiteral.ts +81 -0
- package/src/surgery/deleteReference.ts +48 -0
- package/src/surgery/ensureBuilderImport.ts +65 -0
- package/src/surgery/findFixture.ts +238 -0
- package/src/surgery/fixtureEdit.ts +156 -0
- package/src/surgery/fixtureLocation.ts +32 -0
- package/src/surgery/insertCollection.ts +86 -0
- package/src/surgery/insertFirstProperty.ts +73 -0
- package/src/surgery/patchFixtureProperty.ts +152 -0
- package/src/surgery/patchSingletonProperty.ts +221 -0
- package/src/surgery/replaceFixture.ts +28 -0
- package/src/surgery/setFixtureProperty.ts +55 -0
- package/src/surgery/staticFixtureRule.ts +48 -0
- package/src/version.ts +5 -0
- package/dist/cli.js +0 -146296
- package/dist/compose/index.js +0 -122
- package/dist/src/compose/builders/builderFunctions.d.ts +0 -84
- package/dist/src/compose/builders/rewardFunctions.d.ts +0 -5
- package/dist/src/compose/builders/variantFunctions.d.ts +0 -2
- package/dist/src/compose/index.d.ts +0 -19
- package/dist/src/compose/models/featureModels.d.ts +0 -262
- package/dist/src/compose/models/index.d.ts +0 -3
- package/dist/src/compose/models/planModels.d.ts +0 -562
- package/dist/src/compose/models/rewardModels.d.ts +0 -52
- package/dist/src/compose/models/variantModels.d.ts +0 -34
- package/readme.md +0 -186
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { autumnFetch } from "../http/autumnFetch";
|
|
2
|
+
import { getApiKeysEndpoint } from "./oauthConfig";
|
|
3
|
+
import type { OrgApiKeys } from "./types/orgApiKeys";
|
|
4
|
+
|
|
5
|
+
type ApiKeysResponse = {
|
|
6
|
+
sandbox_key?: string;
|
|
7
|
+
prod_key?: string;
|
|
8
|
+
org_id?: string;
|
|
9
|
+
message?: string;
|
|
10
|
+
error?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Mint the org's sandbox and production keys from the OAuth access token. */
|
|
14
|
+
export const createOrgApiKeys = async ({
|
|
15
|
+
accessToken,
|
|
16
|
+
backendUrl,
|
|
17
|
+
fetch = autumnFetch,
|
|
18
|
+
}: {
|
|
19
|
+
accessToken: string;
|
|
20
|
+
backendUrl: string;
|
|
21
|
+
fetch?: typeof globalThis.fetch;
|
|
22
|
+
}): Promise<OrgApiKeys> => {
|
|
23
|
+
const response = await fetch(getApiKeysEndpoint({ backendUrl }), {
|
|
24
|
+
method: "POST",
|
|
25
|
+
headers: {
|
|
26
|
+
authorization: `Bearer ${accessToken}`,
|
|
27
|
+
"content-type": "application/json",
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const body = (await response.json().catch(() => ({}))) as ApiKeysResponse;
|
|
32
|
+
|
|
33
|
+
if (!response.ok) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
body.message ??
|
|
36
|
+
body.error ??
|
|
37
|
+
`Failed to create API keys (${response.status})`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
sandboxKey: body.sandbox_key,
|
|
43
|
+
prodKey: body.prod_key,
|
|
44
|
+
orgId: body.org_id,
|
|
45
|
+
};
|
|
46
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { AutumnApiError } from "../generated/client";
|
|
2
|
+
import { autumnFetch } from "../http/autumnFetch";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Keyless onboarding: a sandbox org with no owner, minted for an agent or a
|
|
6
|
+
* human who has no account yet, and linked to an account later. Not in the
|
|
7
|
+
* spec, so hand-written like the key-minting call rather than generated.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type ProvisionedOrg = {
|
|
11
|
+
organizationId: string;
|
|
12
|
+
organizationSlug: string;
|
|
13
|
+
apiKey: string;
|
|
14
|
+
claimToken: string;
|
|
15
|
+
/** ISO 8601: after this the org can no longer be linked to anyone. */
|
|
16
|
+
claimExpiresAt: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type ClaimStarted = {
|
|
20
|
+
claimUrl: string;
|
|
21
|
+
/** ISO 8601: after this the browser claim link stops working. */
|
|
22
|
+
expiresAt: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type Fetch = typeof globalThis.fetch;
|
|
26
|
+
|
|
27
|
+
const post = async ({
|
|
28
|
+
baseUrl,
|
|
29
|
+
path,
|
|
30
|
+
body,
|
|
31
|
+
secretKey,
|
|
32
|
+
fetch = autumnFetch,
|
|
33
|
+
}: {
|
|
34
|
+
baseUrl: string;
|
|
35
|
+
path: string;
|
|
36
|
+
body: Record<string, unknown>;
|
|
37
|
+
secretKey?: string;
|
|
38
|
+
fetch?: Fetch;
|
|
39
|
+
}): Promise<Record<string, unknown>> => {
|
|
40
|
+
const response = await fetch(`${baseUrl}${path}`, {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: {
|
|
43
|
+
"content-type": "application/json",
|
|
44
|
+
...(secretKey === undefined
|
|
45
|
+
? {}
|
|
46
|
+
: { authorization: `Bearer ${secretKey}` }),
|
|
47
|
+
},
|
|
48
|
+
body: JSON.stringify(body),
|
|
49
|
+
});
|
|
50
|
+
const text = await response.text();
|
|
51
|
+
const parsed: unknown = text ? JSON.parse(text) : null;
|
|
52
|
+
if (!response.ok)
|
|
53
|
+
throw new AutumnApiError({ status: response.status, body: parsed, path });
|
|
54
|
+
return (parsed ?? {}) as Record<string, unknown>;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const provisionKeylessOrg = async ({
|
|
58
|
+
baseUrl,
|
|
59
|
+
name,
|
|
60
|
+
slug,
|
|
61
|
+
fetch,
|
|
62
|
+
}: {
|
|
63
|
+
baseUrl: string;
|
|
64
|
+
name: string;
|
|
65
|
+
slug: string;
|
|
66
|
+
fetch?: Fetch;
|
|
67
|
+
}): Promise<ProvisionedOrg> => {
|
|
68
|
+
const body = await post({
|
|
69
|
+
baseUrl,
|
|
70
|
+
path: "/agent.provision",
|
|
71
|
+
body: { name: name.slice(0, MAX_NAME_LENGTH), slug },
|
|
72
|
+
fetch,
|
|
73
|
+
});
|
|
74
|
+
return {
|
|
75
|
+
organizationId: String(body.organization_id),
|
|
76
|
+
organizationSlug: String(body.organization_slug),
|
|
77
|
+
apiKey: String(body.api_key),
|
|
78
|
+
claimToken: String(body.claim_token),
|
|
79
|
+
claimExpiresAt: String(body.claim_expires_at),
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** Creates and emails a browser claim link for the org `secretKey` belongs to. */
|
|
84
|
+
export const startClaim = async ({
|
|
85
|
+
baseUrl,
|
|
86
|
+
secretKey,
|
|
87
|
+
email,
|
|
88
|
+
fetch,
|
|
89
|
+
}: {
|
|
90
|
+
baseUrl: string;
|
|
91
|
+
secretKey: string;
|
|
92
|
+
email: string;
|
|
93
|
+
fetch?: Fetch;
|
|
94
|
+
}): Promise<ClaimStarted> => {
|
|
95
|
+
const body = await post({
|
|
96
|
+
baseUrl,
|
|
97
|
+
path: "/agent.start_claim",
|
|
98
|
+
body: { email },
|
|
99
|
+
secretKey,
|
|
100
|
+
fetch,
|
|
101
|
+
});
|
|
102
|
+
return {
|
|
103
|
+
claimUrl: String(body.claim_url),
|
|
104
|
+
expiresAt: String(body.expires_at),
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** A slug the server accepts: lowercase alphanumerics, `-` or `_` between words. */
|
|
109
|
+
/** The server caps a name and a slug at 100 characters. */
|
|
110
|
+
const MAX_NAME_LENGTH = 100;
|
|
111
|
+
|
|
112
|
+
export const slugFor = (name: string): string =>
|
|
113
|
+
name
|
|
114
|
+
.toLowerCase()
|
|
115
|
+
.replace(/^@[^/]+\//, "")
|
|
116
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
117
|
+
.replace(/^-+|-+$/g, "")
|
|
118
|
+
.slice(0, MAX_NAME_LENGTH)
|
|
119
|
+
.replace(/-+$/, "") || "autumn";
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The registered Better Auth client for the atmn CLI, carried over from v2 so
|
|
3
|
+
* existing consent records keep working. `resolveTarget` lets `--client-id`
|
|
4
|
+
* or `AUTUMN_CLIENT_ID` replace it.
|
|
5
|
+
*/
|
|
6
|
+
export const CLI_CLIENT_ID = "hAWUopQqLnsSwuRgeRzIBzKslwXmQUSr";
|
|
7
|
+
|
|
8
|
+
const OAUTH_PORT_BASE = 31448;
|
|
9
|
+
const OAUTH_PORT_RANGE = 5;
|
|
10
|
+
|
|
11
|
+
/** Callback ports tried in order; every one is registered as a redirect URI. */
|
|
12
|
+
export const OAUTH_PORTS = Array.from(
|
|
13
|
+
{ length: OAUTH_PORT_RANGE },
|
|
14
|
+
(_, offset) => OAUTH_PORT_BASE + offset,
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Modern read/write scopes covering everything the CLI reads and everything the
|
|
19
|
+
* minted keys are used for. The server rewrites v2's CRUDL scopes to these.
|
|
20
|
+
* platform:* is what `atmn sandbox create|delete` needs, migrations:write what
|
|
21
|
+
* `atmn reset` needs, and organisation:write what a config's `settings` block
|
|
22
|
+
* needs; a key minted before any was requested has to be re-minted with
|
|
23
|
+
* `atmn login`.
|
|
24
|
+
*/
|
|
25
|
+
export const CLI_OAUTH_SCOPES = [
|
|
26
|
+
"organisation:read",
|
|
27
|
+
"organisation:write",
|
|
28
|
+
"customers:read",
|
|
29
|
+
"customers:write",
|
|
30
|
+
"features:read",
|
|
31
|
+
"features:write",
|
|
32
|
+
"plans:read",
|
|
33
|
+
"plans:write",
|
|
34
|
+
"migrations:read",
|
|
35
|
+
"migrations:write",
|
|
36
|
+
"rewards:read",
|
|
37
|
+
"rewards:write",
|
|
38
|
+
"apiKeys:read",
|
|
39
|
+
"apiKeys:write",
|
|
40
|
+
"platform:read",
|
|
41
|
+
"platform:write",
|
|
42
|
+
] as const;
|
|
43
|
+
|
|
44
|
+
export const getOAuthRedirectUri = ({ port }: { port: number }): string =>
|
|
45
|
+
`http://localhost:${port}/`;
|
|
46
|
+
|
|
47
|
+
export const getAuthorizationEndpoint = ({
|
|
48
|
+
backendUrl,
|
|
49
|
+
}: {
|
|
50
|
+
backendUrl: string;
|
|
51
|
+
}): string => `${backendUrl}/api/auth/oauth2/authorize`;
|
|
52
|
+
|
|
53
|
+
export const getTokenEndpoint = ({
|
|
54
|
+
backendUrl,
|
|
55
|
+
}: {
|
|
56
|
+
backendUrl: string;
|
|
57
|
+
}): string => `${backendUrl}/api/auth/oauth2/token`;
|
|
58
|
+
|
|
59
|
+
export const getApiKeysEndpoint = ({
|
|
60
|
+
backendUrl,
|
|
61
|
+
}: {
|
|
62
|
+
backendUrl: string;
|
|
63
|
+
}): string => `${backendUrl}/cli/api-keys`;
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import {
|
|
3
|
+
generateCodeVerifier,
|
|
4
|
+
generateState,
|
|
5
|
+
type OAuth2Client,
|
|
6
|
+
OAuth2RequestError,
|
|
7
|
+
} from "arctic";
|
|
8
|
+
import {
|
|
9
|
+
buildAuthorizationUrl,
|
|
10
|
+
createOAuthClient,
|
|
11
|
+
} from "./buildAuthorizationUrl";
|
|
12
|
+
import { renderErrorPage, renderSuccessPage } from "./callbackPages";
|
|
13
|
+
import { getTokenEndpoint, OAUTH_PORTS } from "./oauthConfig";
|
|
14
|
+
import { readImpersonationTokens } from "./types/impersonationTokens";
|
|
15
|
+
import type { AuthorizationOutcome } from "./types/oauthTokens";
|
|
16
|
+
|
|
17
|
+
const AUTHORIZATION_TIMEOUT_MS = 5 * 60 * 1000;
|
|
18
|
+
|
|
19
|
+
export type AuthorizationUrlListener = ({
|
|
20
|
+
url,
|
|
21
|
+
}: {
|
|
22
|
+
url: string;
|
|
23
|
+
}) => Promise<void> | void;
|
|
24
|
+
|
|
25
|
+
export type RunOAuthFlowOptions = {
|
|
26
|
+
clientId: string;
|
|
27
|
+
backendUrl: string;
|
|
28
|
+
scopes: readonly string[];
|
|
29
|
+
onAuthorizationUrl: AuthorizationUrlListener;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type CallbackOutcome = {
|
|
33
|
+
page: string;
|
|
34
|
+
outcome?: AuthorizationOutcome;
|
|
35
|
+
error?: Error;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const failedCallback = ({ message }: { message: string }): CallbackOutcome => ({
|
|
39
|
+
page: renderErrorPage({ message }),
|
|
40
|
+
error: new Error(message),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const exchangeAuthorizationCode = async ({
|
|
44
|
+
client,
|
|
45
|
+
backendUrl,
|
|
46
|
+
code,
|
|
47
|
+
codeVerifier,
|
|
48
|
+
}: {
|
|
49
|
+
client: OAuth2Client;
|
|
50
|
+
backendUrl: string;
|
|
51
|
+
code: string;
|
|
52
|
+
codeVerifier: string;
|
|
53
|
+
}): Promise<CallbackOutcome> => {
|
|
54
|
+
try {
|
|
55
|
+
const tokens = await client.validateAuthorizationCode(
|
|
56
|
+
getTokenEndpoint({ backendUrl }),
|
|
57
|
+
code,
|
|
58
|
+
codeVerifier,
|
|
59
|
+
);
|
|
60
|
+
return {
|
|
61
|
+
page: renderSuccessPage(),
|
|
62
|
+
outcome: {
|
|
63
|
+
kind: "oauth",
|
|
64
|
+
tokens: {
|
|
65
|
+
accessToken: tokens.accessToken(),
|
|
66
|
+
tokenType: "Bearer",
|
|
67
|
+
expiresInSeconds: tokens.accessTokenExpiresInSeconds(),
|
|
68
|
+
refreshToken: tokens.hasRefreshToken()
|
|
69
|
+
? tokens.refreshToken()
|
|
70
|
+
: undefined,
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
} catch (error) {
|
|
75
|
+
const message =
|
|
76
|
+
error instanceof OAuth2RequestError
|
|
77
|
+
? `OAuth error: ${error.code}`
|
|
78
|
+
: "Token exchange failed";
|
|
79
|
+
return failedCallback({ message });
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const readCallback = async ({
|
|
84
|
+
callbackUrl,
|
|
85
|
+
client,
|
|
86
|
+
backendUrl,
|
|
87
|
+
state,
|
|
88
|
+
codeVerifier,
|
|
89
|
+
}: {
|
|
90
|
+
callbackUrl: URL;
|
|
91
|
+
client: OAuth2Client;
|
|
92
|
+
backendUrl: string;
|
|
93
|
+
state: string;
|
|
94
|
+
codeVerifier: string;
|
|
95
|
+
}): Promise<CallbackOutcome> => {
|
|
96
|
+
const denial = callbackUrl.searchParams.get("error");
|
|
97
|
+
if (denial) {
|
|
98
|
+
return failedCallback({
|
|
99
|
+
message: callbackUrl.searchParams.get("error_description") ?? denial,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
if (callbackUrl.searchParams.get("state") !== state) {
|
|
103
|
+
return failedCallback({ message: "Invalid state — possible CSRF" });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// An impersonating dashboard session hands back tokens instead of a code.
|
|
107
|
+
const impersonation = readImpersonationTokens(callbackUrl.searchParams);
|
|
108
|
+
if (impersonation) {
|
|
109
|
+
return {
|
|
110
|
+
page: renderSuccessPage(),
|
|
111
|
+
outcome: { kind: "impersonation", tokens: impersonation },
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const code = callbackUrl.searchParams.get("code");
|
|
116
|
+
if (!code) return failedCallback({ message: "Missing authorization code" });
|
|
117
|
+
|
|
118
|
+
return await exchangeAuthorizationCode({
|
|
119
|
+
client,
|
|
120
|
+
backendUrl,
|
|
121
|
+
code,
|
|
122
|
+
codeVerifier,
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/** Resolves null when the port is taken, so the caller can try the next one. */
|
|
127
|
+
const listenForCallback = ({
|
|
128
|
+
port,
|
|
129
|
+
onCallback,
|
|
130
|
+
onListening,
|
|
131
|
+
}: {
|
|
132
|
+
port: number;
|
|
133
|
+
onCallback: ({
|
|
134
|
+
callbackUrl,
|
|
135
|
+
}: {
|
|
136
|
+
callbackUrl: URL;
|
|
137
|
+
}) => Promise<CallbackOutcome>;
|
|
138
|
+
onListening: () => void;
|
|
139
|
+
}): Promise<AuthorizationOutcome | null> =>
|
|
140
|
+
new Promise((resolve, reject) => {
|
|
141
|
+
const server = createServer(async (request, response) => {
|
|
142
|
+
const callbackUrl = new URL(
|
|
143
|
+
request.url ?? "/",
|
|
144
|
+
`http://localhost:${port}`,
|
|
145
|
+
);
|
|
146
|
+
if (callbackUrl.pathname !== "/") {
|
|
147
|
+
response.writeHead(404).end("Not found");
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const { page, outcome, error } = await onCallback({ callbackUrl });
|
|
152
|
+
clearTimeout(timeout);
|
|
153
|
+
|
|
154
|
+
// Settle only once the page is flushed — the browser's keep-alive socket
|
|
155
|
+
// would otherwise outlive the command and hang the CLI.
|
|
156
|
+
response.writeHead(200, { "Content-Type": "text/html" }).end(page, () => {
|
|
157
|
+
shutDown();
|
|
158
|
+
if (outcome) resolve(outcome);
|
|
159
|
+
else reject(error ?? new Error("Authorization returned no tokens"));
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const shutDown = () => {
|
|
164
|
+
server.close();
|
|
165
|
+
server.closeAllConnections();
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const timeout = setTimeout(() => {
|
|
169
|
+
shutDown();
|
|
170
|
+
reject(new Error("Authorization timed out. Please try again."));
|
|
171
|
+
}, AUTHORIZATION_TIMEOUT_MS);
|
|
172
|
+
|
|
173
|
+
server.once("error", (error: NodeJS.ErrnoException) => {
|
|
174
|
+
clearTimeout(timeout);
|
|
175
|
+
if (error.code === "EADDRINUSE") resolve(null);
|
|
176
|
+
else reject(error);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
server.listen(port, onListening);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* PKCE authorization against a loopback callback. The URL is handed to the
|
|
184
|
+
* caller the moment the server is listening, so it can be shown and opened.
|
|
185
|
+
*/
|
|
186
|
+
export const runOAuthFlow = async ({
|
|
187
|
+
clientId,
|
|
188
|
+
backendUrl,
|
|
189
|
+
scopes,
|
|
190
|
+
onAuthorizationUrl,
|
|
191
|
+
}: RunOAuthFlowOptions): Promise<AuthorizationOutcome> => {
|
|
192
|
+
const codeVerifier = generateCodeVerifier();
|
|
193
|
+
const state = generateState();
|
|
194
|
+
|
|
195
|
+
for (const port of OAUTH_PORTS) {
|
|
196
|
+
const client = createOAuthClient({ clientId, port });
|
|
197
|
+
const authorizationUrl = buildAuthorizationUrl({
|
|
198
|
+
client,
|
|
199
|
+
backendUrl,
|
|
200
|
+
scopes,
|
|
201
|
+
state,
|
|
202
|
+
codeVerifier,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const outcome = await listenForCallback({
|
|
206
|
+
port,
|
|
207
|
+
onCallback: ({ callbackUrl }) =>
|
|
208
|
+
readCallback({
|
|
209
|
+
callbackUrl,
|
|
210
|
+
client,
|
|
211
|
+
backendUrl,
|
|
212
|
+
state,
|
|
213
|
+
codeVerifier,
|
|
214
|
+
}),
|
|
215
|
+
onListening: () => {
|
|
216
|
+
void Promise.resolve(
|
|
217
|
+
onAuthorizationUrl({ url: authorizationUrl.toString() }),
|
|
218
|
+
).catch(() => {
|
|
219
|
+
// Announcing is best-effort; the flow still works if it fails.
|
|
220
|
+
});
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
if (outcome) return outcome;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
throw new Error(
|
|
228
|
+
`All OAuth callback ports (${OAUTH_PORTS.at(0)}-${OAUTH_PORTS.at(-1)}) are in use.`,
|
|
229
|
+
);
|
|
230
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** One-hour OAuth tokens handed back when the dashboard session impersonates a customer. */
|
|
2
|
+
export type ImpersonationTokens = {
|
|
3
|
+
sandboxToken: string;
|
|
4
|
+
liveToken: string;
|
|
5
|
+
/** ISO 8601. */
|
|
6
|
+
expiresAt: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const IMPERSONATION_CALLBACK_PARAMS = {
|
|
10
|
+
sandboxToken: "impersonation_sandbox_token",
|
|
11
|
+
liveToken: "impersonation_live_token",
|
|
12
|
+
expiresAt: "impersonation_expires_at",
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
export const readImpersonationTokens = (
|
|
16
|
+
params: URLSearchParams,
|
|
17
|
+
): ImpersonationTokens | null => {
|
|
18
|
+
const sandboxToken = params.get(IMPERSONATION_CALLBACK_PARAMS.sandboxToken);
|
|
19
|
+
const liveToken = params.get(IMPERSONATION_CALLBACK_PARAMS.liveToken);
|
|
20
|
+
const expiresAt = params.get(IMPERSONATION_CALLBACK_PARAMS.expiresAt);
|
|
21
|
+
if (!sandboxToken || !liveToken || !expiresAt) return null;
|
|
22
|
+
return { sandboxToken, liveToken, expiresAt };
|
|
23
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ImpersonationTokens } from "./impersonationTokens";
|
|
2
|
+
|
|
3
|
+
export type OAuthTokens = {
|
|
4
|
+
accessToken: string;
|
|
5
|
+
tokenType: "Bearer";
|
|
6
|
+
expiresInSeconds?: number;
|
|
7
|
+
refreshToken?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** What the loopback callback delivered: a grant to exchange, or ready-made tokens. */
|
|
11
|
+
export type AuthorizationOutcome =
|
|
12
|
+
| { kind: "oauth"; tokens: OAuthTokens }
|
|
13
|
+
| { kind: "impersonation"; tokens: ImpersonationTokens };
|
package/src/bin.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { run } from "./cli";
|
|
3
|
+
|
|
4
|
+
// The npm entry: node has no import.meta.main, so the run happens here.
|
|
5
|
+
run({ argv: process.argv }).catch((error: unknown) => {
|
|
6
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
7
|
+
process.stderr.write(`${message}\n`);
|
|
8
|
+
process.exit(1);
|
|
9
|
+
});
|