attio 0.0.1-experimental.20250408 → 0.0.1-experimental.20250409

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.
Files changed (56) hide show
  1. package/lib/api/api.js +97 -29
  2. package/lib/api/fetcher.js +31 -27
  3. package/lib/api/schemas.js +6 -0
  4. package/lib/auth/auth.js +127 -96
  5. package/lib/auth/keychain.js +82 -43
  6. package/lib/build/server/generate-server-entry.js +6 -6
  7. package/lib/build.js +2 -2
  8. package/lib/commands/build/build-javascript.js +1 -1
  9. package/lib/commands/build/validate-typescript.js +1 -1
  10. package/lib/commands/build.js +2 -2
  11. package/lib/commands/dev/boot.js +8 -8
  12. package/lib/commands/dev/bundle-javascript.js +1 -1
  13. package/lib/commands/dev/prepare-build-contexts.js +1 -1
  14. package/lib/commands/dev/upload.js +1 -18
  15. package/lib/commands/dev.js +5 -4
  16. package/lib/commands/init/create-project.js +7 -39
  17. package/lib/commands/init.js +6 -14
  18. package/lib/commands/login.js +8 -2
  19. package/lib/commands/logout.js +8 -2
  20. package/lib/commands/version/create/bundle-javascript.js +3 -3
  21. package/lib/commands/version/create.js +11 -11
  22. package/lib/commands/version/list.js +4 -4
  23. package/lib/commands/whoami.js +5 -6
  24. package/lib/errors.js +1 -0
  25. package/lib/print-errors.js +165 -0
  26. package/lib/spinners/determine-workspace.spinner.js +1 -24
  27. package/lib/spinners/get-app-slug-from-package-json.js +1 -20
  28. package/lib/util/copy-with-replace.js +2 -2
  29. package/lib/util/create-directory.js +1 -1
  30. package/lib/util/load-attio-cli-version.js +1 -26
  31. package/lib/util/upload-bundle.js +1 -1
  32. package/package.json +1 -1
  33. package/lib/auth/ensure-authed.js +0 -14
  34. package/lib/commands/dev/start-graphql-server.js +0 -32
  35. package/lib/commands/init/ask-language.js +0 -11
  36. package/lib/templates/javascript/.env +0 -12
  37. package/lib/templates/javascript/eslint.config.js +0 -48
  38. package/lib/templates/javascript/package.json +0 -26
  39. package/lib/templates/javascript/src/assets/icon.png +0 -0
  40. package/lib/templates/javascript/src/cat-fact.jsx +0 -16
  41. package/lib/templates/javascript/src/get-cat-fact.server.js +0 -6
  42. package/lib/templates/javascript/src/hello-world-action.jsx +0 -17
  43. package/lib/templates/javascript/src/hello-world-dialog.jsx +0 -26
  44. package/lib/templates/typescript/.eslintignore +0 -3
  45. package/lib/templates/typescript/src/assets/icon.png +0 -0
  46. /package/lib/{templates/typescript → template}/.env +0 -0
  47. /package/lib/{templates/javascript → template}/.eslintignore +0 -0
  48. /package/lib/{templates/common → template}/README.md +0 -0
  49. /package/lib/{templates/typescript → template}/eslint.config.js +0 -0
  50. /package/lib/{templates/typescript → template}/package.json +0 -0
  51. /package/lib/{templates/common → template}/src/assets/icon.png +0 -0
  52. /package/lib/{templates/typescript → template}/src/cat-fact.tsx +0 -0
  53. /package/lib/{templates/typescript → template}/src/get-cat-fact.server.ts +0 -0
  54. /package/lib/{templates/typescript → template}/src/hello-world-action.tsx +0 -0
  55. /package/lib/{templates/typescript → template}/src/hello-world-dialog.tsx +0 -0
  56. /package/lib/{templates/typescript → template}/tsconfig.json +0 -0
package/lib/api/api.js CHANGED
@@ -1,16 +1,19 @@
1
1
  import { Fetcher } from "./fetcher.js";
2
2
  import { APP } from "../env.js";
3
3
  import { isErrored, complete, errored } from "@attio/fetchable";
4
- import { whoamiSchema, listDevWorkspacesResponseSchema, createVersionSchema, appInfoSchema, completeBundleUploadSchema, startUploadSchema, createDevVersionSchema, installationSchema, versionsSchema, TEST_WORKSPACES, TEST_APP_INFO, } from "./schemas.js";
4
+ import { whoamiSchema, listDevWorkspacesResponseSchema, createVersionSchema, appInfoSchema, completeBundleUploadSchema, startUploadSchema, createDevVersionSchema, installationSchema, versionsSchema, TEST_WORKSPACES, TEST_APP_INFO, tokenResponseSchema, } from "./schemas.js";
5
5
  class CliApi {
6
6
  _fetcher;
7
7
  constructor() {
8
8
  this._fetcher = new Fetcher();
9
9
  }
10
10
  async whoami() {
11
- const result = await this._fetcher.get(`${APP}/api/auth/whoami`, whoamiSchema);
11
+ const result = await this._fetcher.get({
12
+ path: `${APP}/api/auth/whoami`,
13
+ schema: whoamiSchema,
14
+ });
12
15
  if (isErrored(result)) {
13
- return errored({ code: "WHOAMI_ERROR", fetcherError: result.error });
16
+ return errored({ code: "WHOAMI_ERROR", error: result.error });
14
17
  }
15
18
  return complete(result.value.user);
16
19
  }
@@ -18,19 +21,26 @@ class CliApi {
18
21
  if (process.env.NODE_ENV === "test") {
19
22
  return complete(TEST_WORKSPACES);
20
23
  }
21
- const result = await this._fetcher.get("/dev-workspaces", listDevWorkspacesResponseSchema);
24
+ const result = await this._fetcher.get({
25
+ path: "/dev-workspaces",
26
+ schema: listDevWorkspacesResponseSchema,
27
+ });
22
28
  if (isErrored(result)) {
23
- return errored({ code: "FETCH_WORKSPACES_ERROR", fetcherError: result.error });
29
+ return errored({ code: "FETCH_WORKSPACES_ERROR", error: result.error });
24
30
  }
25
31
  return complete(result.value.workspaces);
26
32
  }
27
33
  async createVersion({ appId, major, cliVersion, }) {
28
- const result = await this._fetcher.post(`/apps/${appId}/prod-versions`, {
29
- major,
30
- cli_version: cliVersion,
31
- }, createVersionSchema);
34
+ const result = await this._fetcher.post({
35
+ path: `/apps/${appId}/prod-versions`,
36
+ body: {
37
+ major,
38
+ cli_version: cliVersion,
39
+ },
40
+ schema: createVersionSchema,
41
+ });
32
42
  if (isErrored(result)) {
33
- return errored({ code: "CREATE_VERSION_ERROR", fetcherError: result.error });
43
+ return errored({ code: "CREATE_VERSION_ERROR", error: result.error });
34
44
  }
35
45
  return result;
36
46
  }
@@ -38,61 +48,119 @@ class CliApi {
38
48
  if (process.env.NODE_ENV === "test") {
39
49
  return complete(TEST_APP_INFO.app);
40
50
  }
41
- const result = await this._fetcher.get(`/apps/by-slug/${appSlug}`, appInfoSchema);
51
+ const result = await this._fetcher.get({
52
+ path: `/apps/by-slug/${appSlug}`,
53
+ schema: appInfoSchema,
54
+ });
42
55
  if (isErrored(result)) {
43
- return errored({ code: "FETCH_APP_INFO_ERROR", fetcherError: result.error });
56
+ return errored({ code: "FETCH_APP_INFO_ERROR", error: result.error });
44
57
  }
45
58
  return complete(result.value.app);
46
59
  }
47
60
  async completeBundleUpload({ appId, devVersionId, bundleId, }) {
48
- const result = await this._fetcher.post(`/apps/${appId}/dev-versions/${devVersionId}/bundles/${bundleId}/complete`, {}, completeBundleUploadSchema);
61
+ const result = await this._fetcher.post({
62
+ path: `/apps/${appId}/dev-versions/${devVersionId}/bundles/${bundleId}/complete`,
63
+ body: {},
64
+ schema: completeBundleUploadSchema,
65
+ });
49
66
  if (isErrored(result)) {
50
- return errored({ code: "COMPLETE_BUNDLE_UPLOAD_ERROR", fetcherError: result.error });
67
+ return errored({ code: "COMPLETE_BUNDLE_UPLOAD_ERROR", error: result.error });
51
68
  }
52
69
  return complete(undefined);
53
70
  }
54
71
  async completeProdBundleUpload({ appId, bundleId, major, minor, }) {
55
- const result = await this._fetcher.post(`/apps/${appId}/prod-versions/${major}/${minor}/bundles/${bundleId}/complete`, {}, completeBundleUploadSchema);
72
+ const result = await this._fetcher.post({
73
+ path: `/apps/${appId}/prod-versions/${major}/${minor}/bundles/${bundleId}/complete`,
74
+ body: {},
75
+ schema: completeBundleUploadSchema,
76
+ });
56
77
  if (isErrored(result)) {
57
- return errored({ code: "COMPLETE_PROD_BUNDLE_UPLOAD_ERROR", fetcherError: result.error });
78
+ return errored({ code: "COMPLETE_PROD_BUNDLE_UPLOAD_ERROR", error: result.error });
58
79
  }
59
80
  return complete(undefined);
60
81
  }
61
82
  async startUpload({ appId, devVersionId, }) {
62
- const result = await this._fetcher.post(`/apps/${appId}/dev-versions/${devVersionId}/bundles`, {}, startUploadSchema);
83
+ const result = await this._fetcher.post({
84
+ path: `/apps/${appId}/dev-versions/${devVersionId}/bundles`,
85
+ body: {},
86
+ schema: startUploadSchema,
87
+ });
63
88
  if (isErrored(result)) {
64
- return errored({ code: "START_UPLOAD_ERROR", fetcherError: result.error });
89
+ return errored({ code: "START_UPLOAD_ERROR", error: result.error });
65
90
  }
66
91
  return result;
67
92
  }
68
93
  async createDevVersion({ appId, cliVersion, targetWorkspaceId, environmentVariables, }) {
69
- const result = await this._fetcher.post(`/apps/${appId}/dev-versions`, {
70
- major: 1,
71
- target_workspace_id: targetWorkspaceId,
72
- environment_variables: environmentVariables,
73
- cli_version: cliVersion,
74
- }, createDevVersionSchema);
94
+ const result = await this._fetcher.post({
95
+ path: `/apps/${appId}/dev-versions`,
96
+ body: {
97
+ major: 1,
98
+ target_workspace_id: targetWorkspaceId,
99
+ environment_variables: environmentVariables,
100
+ cli_version: cliVersion,
101
+ },
102
+ schema: createDevVersionSchema,
103
+ });
75
104
  if (isErrored(result)) {
76
- return errored({ code: "CREATE_DEV_VERSION_ERROR", fetcherError: result.error });
105
+ return errored({ code: "CREATE_DEV_VERSION_ERROR", error: result.error });
77
106
  }
78
107
  return result;
79
108
  }
80
109
  async fetchInstallation({ appId, workspaceId, }) {
81
- const result = await this._fetcher.get(`/apps/${appId}/workspace/${workspaceId}/dev-installation`, installationSchema);
110
+ const result = await this._fetcher.get({
111
+ path: `/apps/${appId}/workspace/${workspaceId}/dev-installation`,
112
+ schema: installationSchema,
113
+ });
82
114
  if (isErrored(result)) {
83
115
  if (result.error.code === "HTTP_ERROR" && result.error.status === 404) {
84
116
  return complete(null);
85
117
  }
86
- return errored({ code: "FETCH_INSTALLATION_ERROR", fetcherError: result.error });
118
+ return errored({ code: "FETCH_INSTALLATION_ERROR", error: result.error });
87
119
  }
88
120
  return result;
89
121
  }
90
122
  async fetchVersions(appId) {
91
- const result = await this._fetcher.get(`/apps/${appId}/prod-versions`, versionsSchema);
123
+ const result = await this._fetcher.get({
124
+ path: `/apps/${appId}/prod-versions`,
125
+ schema: versionsSchema,
126
+ });
92
127
  if (isErrored(result)) {
93
- return errored({ code: "FETCH_VERSIONS_ERROR", fetcherError: result.error });
128
+ return errored({ code: "FETCH_VERSIONS_ERROR", error: result.error });
94
129
  }
95
130
  return complete(result.value.app_prod_versions);
96
131
  }
132
+ async exchangeToken({ receivedCode, verifierString, redirectUri, clientId, }) {
133
+ const params = new URLSearchParams();
134
+ params.append("grant_type", "authorization_code");
135
+ params.append("code", receivedCode);
136
+ params.append("client_id", clientId);
137
+ params.append("redirect_uri", redirectUri);
138
+ params.append("code_verifier", verifierString);
139
+ const result = await this._fetcher.post({
140
+ path: `${APP}/oidc/token`,
141
+ body: params,
142
+ schema: tokenResponseSchema,
143
+ authenticated: "Not Authenticated",
144
+ });
145
+ if (isErrored(result)) {
146
+ return errored({ code: "GET_TOKEN_ERROR", error: result.error });
147
+ }
148
+ return result;
149
+ }
150
+ async refreshToken({ refreshToken, clientId, }) {
151
+ const params = new URLSearchParams();
152
+ params.append("grant_type", "refresh_token");
153
+ params.append("refresh_token", refreshToken);
154
+ params.append("client_id", clientId);
155
+ const result = await this._fetcher.post({
156
+ path: `${APP}/oidc/token`,
157
+ body: params,
158
+ schema: tokenResponseSchema,
159
+ });
160
+ if (isErrored(result)) {
161
+ return errored({ code: "REFRESH_TOKEN_ERROR", error: result.error });
162
+ }
163
+ return result;
164
+ }
97
165
  }
98
166
  export const API = new CliApi();
@@ -1,17 +1,24 @@
1
- import { complete, errored } from "@attio/fetchable";
1
+ import { complete, errored, isErrored } from "@attio/fetchable";
2
2
  import { API } from "../env.js";
3
- import { ensureAuthed } from "../auth/ensure-authed.js";
4
- import chalk from "chalk";
3
+ import { Authenticator } from "../auth/auth.js";
5
4
  export class Fetcher {
6
- async _fetch(path, fetchOptions, schema) {
7
- const token = await ensureAuthed();
5
+ async _fetch({ path, fetchOptions, schema, authenticated, }) {
6
+ const tokenResult = authenticated === "Authenticated" ? await Authenticator.ensureAuthed() : complete(null);
7
+ if (isErrored(tokenResult)) {
8
+ return errored({
9
+ code: "UNAUTHORIZED",
10
+ error: tokenResult.error,
11
+ });
12
+ }
13
+ const token = tokenResult.value;
8
14
  try {
9
15
  const response = await fetch(path.startsWith("https") ? path : `${API}${path}`, {
10
16
  ...fetchOptions,
11
17
  headers: {
12
18
  "x-attio-platform": "developer-cli",
13
- "Authorization": `Bearer ${token}`,
19
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
14
20
  "Content-Type": "application/json",
21
+ ...fetchOptions.headers,
15
22
  },
16
23
  });
17
24
  if (!response.ok) {
@@ -39,27 +46,24 @@ export class Fetcher {
39
46
  });
40
47
  }
41
48
  }
42
- async get(path, schema) {
43
- return this._fetch(path, { method: "GET" }, schema);
44
- }
45
- async post(path, body, schema) {
46
- return this._fetch(path, {
47
- method: "POST",
48
- body: JSON.stringify(body),
49
- }, schema);
49
+ async get({ path, schema, headers = {}, authenticated = "Authenticated", }) {
50
+ return this._fetch({ path, fetchOptions: { method: "GET", headers }, schema, authenticated });
50
51
  }
51
- }
52
- export function printFetcherError(message, error) {
53
- process.stderr.write(`${chalk.red("✖ ")}${message}\n`);
54
- switch (error.code) {
55
- case "HTTP_ERROR":
56
- process.stderr.write(`HTTP Error (${error.status}): ${error.message}\n`);
57
- break;
58
- case "INVALID_RESPONSE":
59
- process.stderr.write(`Invalid response: ${error.message}\n`);
60
- break;
61
- case "NETWORK_ERROR":
62
- process.stderr.write(`Network error: ${error.message}\n`);
63
- break;
52
+ async post({ path, body, schema, headers = {}, authenticated = "Authenticated", }) {
53
+ return this._fetch({
54
+ path,
55
+ fetchOptions: {
56
+ method: "POST",
57
+ body: body instanceof URLSearchParams ? body.toString() : JSON.stringify(body),
58
+ headers: {
59
+ "Content-Type": body instanceof URLSearchParams
60
+ ? "application/x-www-form-urlencoded"
61
+ : "application/json",
62
+ ...headers,
63
+ },
64
+ },
65
+ schema,
66
+ authenticated,
67
+ });
64
68
  }
65
69
  }
@@ -74,3 +74,9 @@ export const versionSchema = z.object({
74
74
  export const versionsSchema = z.object({
75
75
  app_prod_versions: z.array(versionSchema),
76
76
  });
77
+ export const tokenResponseSchema = z.object({
78
+ access_token: z.string(),
79
+ token_type: z.literal("Bearer"),
80
+ refresh_token: z.string(),
81
+ expires_in: z.number(),
82
+ });
package/lib/auth/auth.js CHANGED
@@ -4,118 +4,149 @@ import { randomBytes, createHash } from "crypto";
4
4
  import open from "open";
5
5
  import { APP } from "../env.js";
6
6
  import { findAvailablePort } from "../util/find-available-port.js";
7
- import { loadAuthToken, saveAuthToken, deleteAuthToken } from "./keychain.js";
8
- import { z } from "zod";
9
- import { hardExit } from "../util/hard-exit.js";
7
+ import { Keychain } from "./keychain.js";
10
8
  import { API } from "../api/api.js";
11
- import { isComplete } from "@attio/fetchable";
12
- const CLIENT_ID = "f881c6f1-82d7-48a5-a581-649596167845";
13
- const tokenResponseSchema = z.object({
14
- access_token: z.string(),
15
- token_type: z.string(),
16
- expires_in: z.number(),
17
- });
18
- async function exchangeCodeForToken(code, codeVerifier, redirectUri) {
19
- const tokenUrl = `${APP}/oidc/token`;
20
- const params = new URLSearchParams();
21
- params.append("grant_type", "authorization_code");
22
- params.append("code", code);
23
- params.append("client_id", CLIENT_ID);
24
- params.append("redirect_uri", redirectUri);
25
- params.append("code_verifier", codeVerifier);
26
- try {
27
- const response = await fetch(tokenUrl, {
28
- method: "POST",
29
- headers: {
30
- "Content-Type": "application/x-www-form-urlencoded",
31
- },
32
- body: params.toString(),
33
- });
34
- if (!response.ok) {
35
- const errorText = await response.text();
36
- return hardExit(`Failed to exchange code for token: ${errorText} (status: ${response.status})`);
9
+ import { isErrored, complete, errored } from "@attio/fetchable";
10
+ import { printFetcherError, printKeychainError } from "../print-errors.js";
11
+ class AuthenticatorImpl {
12
+ clientId = "f881c6f1-82d7-48a5-a581-649596167845";
13
+ refreshTimeout = null;
14
+ async ensureAuthed() {
15
+ const existingTokenResult = await Keychain.load();
16
+ if (isErrored(existingTokenResult)) {
17
+ return this.promptToAuthenticate();
37
18
  }
38
- const data = await response.json();
39
- const result = tokenResponseSchema.safeParse(data);
40
- if (!result.success) {
41
- return hardExit("Invalid token response");
19
+ const existingToken = existingTokenResult.value;
20
+ if (existingToken === null || existingToken.expires_in < Date.now()) {
21
+ return this.promptToAuthenticate();
42
22
  }
43
- return result.data;
44
- }
45
- catch (error) {
46
- return hardExit(`Failed to exchange code for token: ${error instanceof Error ? error.message : String(error)}`);
23
+ this.scheduleRefresh(existingToken);
24
+ return complete(existingToken.access_token);
47
25
  }
48
- }
49
- export async function auth() {
50
- const existingTokenResult = await loadAuthToken();
51
- if (existingTokenResult !== null) {
52
- const userResult = await API.whoami();
53
- const user = isComplete(userResult) ? userResult.value : null;
54
- if (user !== null) {
55
- return existingTokenResult;
26
+ async promptToAuthenticate() {
27
+ if (process.env.NODE_ENV !== "test") {
28
+ process.stdout.write("You need to log in with Attio. Press Enter to continue...\n\n");
29
+ await new Promise((resolve) => process.stdin.once("data", resolve));
56
30
  }
57
- await deleteAuthToken();
31
+ return this.authenticate();
58
32
  }
59
- const verifier = randomBytes(32);
60
- const verifierString = verifier.toString("base64url");
61
- const hash = createHash("sha256");
62
- hash.update(verifier);
63
- const challenge = hash.digest();
64
- const challengeString = challenge.toString("base64url");
65
- const state = randomBytes(32).toString("base64url");
66
- const port = await findAvailablePort(3000, 65000);
67
- const redirectUri = `http://localhost:${port}`;
68
- let resolveToken;
69
- const tokenPromise = new Promise((resolve) => {
70
- resolveToken = resolve;
71
- });
72
- const app = new Hono();
73
- let serverRef;
74
- app.get("/", async (c) => {
75
- const query = c.req.query();
76
- const receivedCode = query.authorization_code;
77
- const receivedState = query.state;
78
- if (receivedState !== state) {
79
- hardExit("State mismatch - possible CSRF attack");
33
+ async authenticate() {
34
+ const existingTokenResult = await Keychain.load();
35
+ if (isErrored(existingTokenResult)) {
36
+ return existingTokenResult;
80
37
  }
81
- if (!receivedCode) {
82
- hardExit("No authorization code received");
38
+ const existingToken = existingTokenResult.value;
39
+ if (existingToken !== null && existingToken.expires_in > Date.now()) {
40
+ return complete(existingToken.access_token);
83
41
  }
84
- const tokenResult = await exchangeCodeForToken(receivedCode, verifierString, redirectUri);
85
- setTimeout(() => {
86
- serverRef.close();
87
- }, 1000);
88
- resolveToken(tokenResult.access_token);
89
- return c.html(`
42
+ const verifier = randomBytes(32);
43
+ const verifierString = verifier.toString("base64url");
44
+ const hash = createHash("sha256");
45
+ hash.update(verifier);
46
+ const challenge = hash.digest();
47
+ const challengeString = challenge.toString("base64url");
48
+ const state = randomBytes(32).toString("base64url");
49
+ const port = await findAvailablePort(3000, 65000);
50
+ const redirectUri = `http://localhost:${port}`;
51
+ let resolveAsyncResult;
52
+ const asyncResult = new Promise((resolve) => {
53
+ resolveAsyncResult = resolve;
54
+ });
55
+ const app = new Hono();
56
+ let serverRef;
57
+ app.get("/", async (c) => {
58
+ const query = c.req.query();
59
+ const receivedCode = query.authorization_code;
60
+ const receivedState = query.state;
61
+ if (receivedState !== state) {
62
+ resolveAsyncResult(errored({ code: "OAUTH_STATE_MISMATCH" }));
63
+ }
64
+ if (!receivedCode) {
65
+ resolveAsyncResult(errored({ code: "NO_AUTHORIZATION_CODE" }));
66
+ }
67
+ const tokenResult = await API.exchangeToken({
68
+ receivedCode,
69
+ verifierString,
70
+ redirectUri,
71
+ clientId: this.clientId,
72
+ });
73
+ setTimeout(() => {
74
+ serverRef.close();
75
+ resolveAsyncResult(tokenResult);
76
+ }, 1_000);
77
+ return c.html(`
90
78
  <html>
91
79
  <body>
92
80
  <script>window.location.href = '${APP}/authorized';</script>
93
81
  </body>
94
82
  </html>
95
83
  `);
96
- });
97
- serverRef = serve({
98
- fetch: app.fetch,
99
- port,
100
- });
101
- try {
102
- const authUrl = new URL(`${APP}/oidc/authorize`);
103
- authUrl.searchParams.append("scope", "openid");
104
- authUrl.searchParams.append("response_type", "code");
105
- authUrl.searchParams.append("client_id", CLIENT_ID);
106
- authUrl.searchParams.append("redirect_uri", redirectUri);
107
- authUrl.searchParams.append("state", state);
108
- authUrl.searchParams.append("code_challenge", challengeString);
109
- authUrl.searchParams.append("code_challenge_method", "S256");
110
- await open(authUrl.toString());
111
- const token = await tokenPromise;
112
- if (token) {
84
+ });
85
+ serverRef = serve({
86
+ fetch: app.fetch,
87
+ port,
88
+ });
89
+ try {
90
+ const authUrl = new URL(`${APP}/oidc/authorize`);
91
+ authUrl.searchParams.append("scope", "openid offline_access");
92
+ authUrl.searchParams.append("response_type", "code");
93
+ authUrl.searchParams.append("client_id", this.clientId);
94
+ authUrl.searchParams.append("redirect_uri", redirectUri);
95
+ authUrl.searchParams.append("state", state);
96
+ authUrl.searchParams.append("code_challenge", challengeString);
97
+ authUrl.searchParams.append("code_challenge_method", "S256");
98
+ await open(authUrl.toString());
99
+ const tokenResult = await asyncResult;
100
+ if (isErrored(tokenResult)) {
101
+ return tokenResult;
102
+ }
103
+ const token = tokenResult.value;
113
104
  process.stdout.write("🔑 Saving new token to keychain\n");
114
- await saveAuthToken(token);
105
+ const keychainToken = {
106
+ access_token: token.access_token,
107
+ refresh_token: token.refresh_token,
108
+ token_type: token.token_type,
109
+ expires_in: token.expires_in,
110
+ };
111
+ const saveResult = await Keychain.save(keychainToken);
112
+ if (isErrored(saveResult)) {
113
+ return saveResult;
114
+ }
115
+ this.scheduleRefresh(keychainToken);
116
+ return complete(token.access_token);
117
+ }
118
+ finally {
119
+ serverRef.close();
120
+ }
121
+ }
122
+ scheduleRefresh(token) {
123
+ if (this.refreshTimeout !== null) {
124
+ clearTimeout(this.refreshTimeout);
115
125
  }
116
- return token;
126
+ this.refreshTimeout = setTimeout(async () => await this.refreshToken(token), Math.max(0, token.expires_in - Date.now() - 5_000));
117
127
  }
118
- finally {
119
- serverRef.close();
128
+ async refreshToken(token) {
129
+ const refreshTokenResult = await API.refreshToken({
130
+ refreshToken: token.refresh_token,
131
+ clientId: this.clientId,
132
+ });
133
+ if (isErrored(refreshTokenResult)) {
134
+ printFetcherError("Error refreshing token", refreshTokenResult.error);
135
+ return;
136
+ }
137
+ const refreshedToken = refreshTokenResult.value;
138
+ const keychainToken = {
139
+ access_token: refreshedToken.access_token,
140
+ refresh_token: refreshedToken.refresh_token,
141
+ token_type: refreshedToken.token_type,
142
+ expires_in: refreshedToken.expires_in,
143
+ };
144
+ const saveResult = await Keychain.save(keychainToken);
145
+ if (isErrored(saveResult)) {
146
+ printKeychainError(saveResult.error);
147
+ return;
148
+ }
149
+ this.scheduleRefresh(keychainToken);
120
150
  }
121
151
  }
152
+ export const Authenticator = new AuthenticatorImpl();
@@ -1,52 +1,91 @@
1
- import { hardExit } from "../util/hard-exit.js";
2
- const TEST_TOKEN = "TEST";
3
- let keytar = null;
4
- if (process.env.NODE_ENV !== "test") {
5
- try {
6
- keytar = (await import("keytar")).default;
1
+ import { complete, errored } from "@attio/fetchable";
2
+ import { z } from "zod";
3
+ const authTokenSchema = z.object({
4
+ access_token: z.string(),
5
+ refresh_token: z.string(),
6
+ token_type: z.literal("Bearer"),
7
+ expires_in: z.number(),
8
+ });
9
+ class KeytarKeychain {
10
+ SERVICE_NAME = "attio-cli";
11
+ ACCOUNT_NAME = "developer";
12
+ keytarPromise;
13
+ constructor() {
14
+ this.keytarPromise = import("keytar").then((module) => module.default);
7
15
  }
8
- catch (e) {
9
- console.warn("Keychain functionality not available");
10
- }
11
- }
12
- const SERVICE_NAME = "attio-cli";
13
- const ACCOUNT_NAME = "developer";
14
- export async function saveAuthToken(token) {
15
- if (!keytar) {
16
- return hardExit("Keychain functionality not available");
17
- }
18
- try {
19
- await keytar.setPassword(SERVICE_NAME, ACCOUNT_NAME, token);
20
- }
21
- catch (error) {
22
- return hardExit(error instanceof Error ? error.message : "Failed to save token to keychain");
23
- }
24
- }
25
- export async function loadAuthToken() {
26
- if (process.env.NODE_ENV === "test") {
27
- return TEST_TOKEN;
28
- }
29
- if (!keytar) {
30
- return hardExit("Keychain functionality not available");
16
+ async save(token) {
17
+ const keytar = await this.keytarPromise;
18
+ try {
19
+ await keytar.setPassword(this.SERVICE_NAME, this.ACCOUNT_NAME, JSON.stringify(token));
20
+ return complete(undefined);
21
+ }
22
+ catch (error) {
23
+ return errored({
24
+ code: "SAVE_KEYCHAIN_ERROR",
25
+ error,
26
+ });
27
+ }
31
28
  }
32
- try {
33
- return await keytar.getPassword(SERVICE_NAME, ACCOUNT_NAME);
29
+ async load() {
30
+ const keytar = await this.keytarPromise;
31
+ let unparsedToken = null;
32
+ try {
33
+ unparsedToken = await keytar.getPassword(this.SERVICE_NAME, this.ACCOUNT_NAME);
34
+ }
35
+ catch (error) {
36
+ return errored({ code: "LOAD_KEYCHAIN_ERROR", error });
37
+ }
38
+ if (unparsedToken === null) {
39
+ return complete(null);
40
+ }
41
+ let jsonToken;
42
+ try {
43
+ jsonToken = JSON.parse(unparsedToken);
44
+ }
45
+ catch (error) {
46
+ return errored({
47
+ code: "LOAD_KEYCHAIN_ERROR",
48
+ error,
49
+ });
50
+ }
51
+ const parsedToken = authTokenSchema.safeParse(jsonToken);
52
+ if (!parsedToken.success) {
53
+ return errored({
54
+ code: "LOAD_KEYCHAIN_ERROR",
55
+ error: parsedToken.error,
56
+ });
57
+ }
58
+ return complete(parsedToken.data);
34
59
  }
35
- catch (error) {
36
- return hardExit(error instanceof Error ? error.message : "Failed to load token from keychain");
60
+ async delete() {
61
+ const keytar = await this.keytarPromise;
62
+ try {
63
+ await keytar.deletePassword(this.SERVICE_NAME, this.ACCOUNT_NAME);
64
+ return complete(undefined);
65
+ }
66
+ catch (error) {
67
+ return errored({
68
+ code: "DELETE_KEYCHAIN_ERROR",
69
+ error,
70
+ });
71
+ }
37
72
  }
38
73
  }
39
- export async function deleteAuthToken() {
40
- if (!keytar) {
41
- if (process.env.NODE_ENV === "test") {
42
- return;
43
- }
44
- return hardExit("Keychain functionality not available");
74
+ class TestKeychain {
75
+ TEST_TOKEN = {
76
+ access_token: "TEST",
77
+ refresh_token: "TEST",
78
+ token_type: "Bearer",
79
+ expires_in: Number.MAX_SAFE_INTEGER,
80
+ };
81
+ async save(_token) {
82
+ return complete(undefined);
45
83
  }
46
- try {
47
- await keytar.deletePassword(SERVICE_NAME, ACCOUNT_NAME);
84
+ async load() {
85
+ return complete(this.TEST_TOKEN);
48
86
  }
49
- catch (error) {
50
- return hardExit(error instanceof Error ? error.message : "Failed to delete token from keychain");
87
+ async delete() {
88
+ return complete(undefined);
51
89
  }
52
90
  }
91
+ export const Keychain = process.env.NODE_ENV === "test" ? new TestKeychain() : new KeytarKeychain();