attio 0.0.1-experimental.20250813 → 0.0.1-experimental.20250813.2

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 CHANGED
@@ -1,4 +1,4 @@
1
- # Attio Extension CLI
1
+ # Attio App SDK CLI
2
2
 
3
3
  ## ⚠️ This package is experimental and not yet ready for use. ⚠️
4
4
 
package/lib/api/api.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { complete, errored, isErrored } from "@attio/fetchable-npm";
2
2
  import { APP } from "../env.js";
3
3
  import { Fetcher } from "./fetcher.js";
4
- import { appInfoSchema, completeBundleUploadSchema, createDevVersionSchema, createVersionSchema, installationSchema, listDevWorkspacesResponseSchema, startUploadSchema, TEST_APP_INFO, TEST_WORKSPACES, tokenResponseSchema, versionsSchema, whoamiSchema, } from "./schemas.js";
4
+ import { ablyAuthResponseSchema, appInfoSchema, completeBundleUploadSchema, createDevVersionSchema, createVersionSchema, installationSchema, listDevWorkspacesResponseSchema, startUploadSchema, TEST_APP_INFO, TEST_WORKSPACES, tokenResponseSchema, versionsSchema, whoamiSchema, } from "./schemas.js";
5
5
  class ApiImpl {
6
6
  _fetcher;
7
7
  constructor() {
@@ -162,5 +162,18 @@ class ApiImpl {
162
162
  }
163
163
  return result;
164
164
  }
165
+ async authenticateAbly(tokenParams) {
166
+ const result = await this._fetcher.post({
167
+ path: "/integrations/ably/auth",
168
+ body: {
169
+ token_params: tokenParams,
170
+ },
171
+ schema: ablyAuthResponseSchema,
172
+ });
173
+ if (isErrored(result)) {
174
+ return errored({ code: "ABLY_AUTHENTICATION_ERROR", error: result.error });
175
+ }
176
+ return result;
177
+ }
165
178
  }
166
179
  export const api = new ApiImpl();
@@ -80,3 +80,10 @@ export const tokenResponseSchema = z.object({
80
80
  refresh_token: z.string(),
81
81
  expires_in: z.number(),
82
82
  });
83
+ export const ablyAuthResponseSchema = z.object({
84
+ capability: z.string(),
85
+ clientId: z.string().optional(),
86
+ expires: z.number(),
87
+ issued: z.number(),
88
+ token: z.string(),
89
+ });
package/lib/attio.js CHANGED
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
- import { init } from "./commands/init.js";
4
3
  import { build } from "./commands/build.js";
5
4
  import { dev } from "./commands/dev.js";
6
- import { version } from "./commands/version/index.js";
5
+ import { init } from "./commands/init.js";
7
6
  import { login } from "./commands/login.js";
8
7
  import { logout } from "./commands/logout.js";
8
+ import { logs } from "./commands/logs.js";
9
+ import { version } from "./commands/version/index.js";
9
10
  import { whoami } from "./commands/whoami.js";
10
11
  const program = new Command();
11
12
  program
@@ -19,4 +20,5 @@ program
19
20
  .addCommand(login)
20
21
  .addCommand(logout)
21
22
  .addCommand(whoami)
23
+ .addCommand(logs)
22
24
  .parse();
@@ -33,7 +33,7 @@ export const optionsSchema = z.object({
33
33
  });
34
34
  export const dev = new Command("dev")
35
35
  .description("Develop your Attio app")
36
- .addOption(new Option("--workspace <slug>", "The slug of the workspace to use"))
36
+ .addOption(new Option("-w, --workspace <slug>", "The slug of the workspace to use"))
37
37
  .action(async (unparsedOptions) => {
38
38
  const { workspace: workspaceSlug } = optionsSchema.parse(unparsedOptions);
39
39
  const cleanupFunctions = [];
@@ -0,0 +1,7 @@
1
+ import { z } from "zod";
2
+ const severitySchema = z.union([z.literal("INFO"), z.literal("WARNING"), z.literal("ERROR")]);
3
+ export const logEventSchema = z.object({
4
+ message: z.string(),
5
+ severity: severitySchema,
6
+ timestamp: z.string().datetime(),
7
+ });
@@ -0,0 +1,52 @@
1
+ export class LogsBuffer {
2
+ bufferDelayMs;
3
+ buffer = [];
4
+ listeners = [];
5
+ flushInterval;
6
+ constructor(bufferDelayMs) {
7
+ this.bufferDelayMs = bufferDelayMs;
8
+ this.flushInterval = setInterval(() => {
9
+ this.flush();
10
+ }, bufferDelayMs / 5);
11
+ }
12
+ close() {
13
+ if (this.flushInterval) {
14
+ clearInterval(this.flushInterval);
15
+ }
16
+ this.flushInterval = null;
17
+ }
18
+ listen(listener) {
19
+ this.assertNotClosed();
20
+ this.listeners.push(listener);
21
+ }
22
+ add(event) {
23
+ this.assertNotClosed();
24
+ const receivedAt = Date.now();
25
+ const insertIndex = this.buffer.findIndex((bufferItem) => event.timestamp > bufferItem.event.timestamp);
26
+ if (insertIndex >= 0) {
27
+ this.buffer.splice(insertIndex, 0, { event, receivedAt });
28
+ }
29
+ else {
30
+ this.buffer.push({ event, receivedAt });
31
+ }
32
+ }
33
+ flush() {
34
+ const now = Date.now();
35
+ const firstFlushIndex = this.buffer.findIndex((bufferItem) => now - bufferItem.receivedAt > this.bufferDelayMs);
36
+ if (firstFlushIndex === -1) {
37
+ return;
38
+ }
39
+ const eventsToFlush = this.buffer
40
+ .splice(firstFlushIndex, this.buffer.length - firstFlushIndex)
41
+ .reverse()
42
+ .map(({ event }) => event);
43
+ for (const listener of this.listeners) {
44
+ listener(eventsToFlush);
45
+ }
46
+ }
47
+ assertNotClosed() {
48
+ if (this.flushInterval === null) {
49
+ throw new Error("This buffer was closed");
50
+ }
51
+ }
52
+ }
@@ -0,0 +1,23 @@
1
+ import { complete, isErrored } from "@attio/fetchable-npm";
2
+ import { getRealtime } from "../../util/realtime.js";
3
+ import { logEventSchema } from "./log-event.js";
4
+ import { LogsBuffer } from "./logs-buffer.js";
5
+ export async function subscribeToLogs({ workspaceId, appId, }, listener) {
6
+ const logsBuffer = new LogsBuffer(200);
7
+ logsBuffer.listen((event) => event.forEach(listener));
8
+ const realtime = getRealtime();
9
+ const channelName = `ecosystem-app-logs:${workspaceId}:${appId}`;
10
+ const realtimeSubscriptionResult = await realtime.subscribe(channelName, "app-dev-log-emitted", logEventSchema, (event) => {
11
+ logsBuffer.add(event);
12
+ });
13
+ if (isErrored(realtimeSubscriptionResult)) {
14
+ return realtimeSubscriptionResult;
15
+ }
16
+ const realtimeSubscription = realtimeSubscriptionResult.value;
17
+ return complete({
18
+ unsubscribe: async () => {
19
+ logsBuffer.close();
20
+ await realtimeSubscription.unsubscribe();
21
+ },
22
+ });
23
+ }
@@ -0,0 +1,93 @@
1
+ import chalk from "chalk";
2
+ import { Command, Option } from "commander";
3
+ import { z } from "zod";
4
+ import { isErrored } from "@attio/fetchable-npm";
5
+ import { authenticator } from "../auth/auth.js";
6
+ import { printDetermineWorkspaceError, printFetcherError, printLogSubscriptionError, printPackageJsonError, } from "../print-errors.js";
7
+ import { determineWorkspace } from "../spinners/determine-workspace.spinner.js";
8
+ import { getAppInfo } from "../spinners/get-app-info.spinner.js";
9
+ import { getAppSlugFromPackageJson } from "../spinners/get-app-slug-from-package-json.js";
10
+ import { spinnerify } from "../util/spinner.js";
11
+ import { subscribeToLogs } from "./log/subscribe-to-logs.js";
12
+ export const optionsSchema = z.object({
13
+ workspace: z.string().optional(),
14
+ });
15
+ export const logs = new Command("logs")
16
+ .description("Stream development server logs")
17
+ .addOption(new Option("-w, --workspace <slug>", "The slug of the workspace to get the logs from"))
18
+ .action(async (unparsedOptions) => {
19
+ const { workspace: workspaceSlug } = optionsSchema.parse(unparsedOptions);
20
+ await authenticator.ensureAuthed();
21
+ const appSlugResult = await getAppSlugFromPackageJson();
22
+ if (isErrored(appSlugResult)) {
23
+ printPackageJsonError(appSlugResult.error);
24
+ process.exit(1);
25
+ }
26
+ const appSlug = appSlugResult.value;
27
+ const appInfoResult = await getAppInfo(appSlug);
28
+ if (isErrored(appInfoResult)) {
29
+ printFetcherError("Error loading app info", appInfoResult.error);
30
+ process.exit(1);
31
+ }
32
+ const appInfo = appInfoResult.value;
33
+ const workspaceResult = await determineWorkspace(workspaceSlug);
34
+ if (isErrored(workspaceResult)) {
35
+ printDetermineWorkspaceError(workspaceResult.error);
36
+ process.exit(1);
37
+ }
38
+ const workspace = workspaceResult.value;
39
+ const subscriptionResult = await spinnerify("Connecting to log server...", "Connected to log server", async () => {
40
+ return subscribeToLogs({ workspaceId: workspace.workspace_id, appId: appInfo.app_id }, ({ message, severity, timestamp }) => {
41
+ let coloredSeverity;
42
+ switch (severity) {
43
+ case "INFO":
44
+ coloredSeverity = chalk.blue(severity);
45
+ break;
46
+ case "ERROR":
47
+ coloredSeverity = chalk.red(severity);
48
+ break;
49
+ case "WARNING":
50
+ coloredSeverity = chalk.yellow(severity);
51
+ break;
52
+ default:
53
+ coloredSeverity = severity;
54
+ }
55
+ const logLine = `${timestamp} ${coloredSeverity}: ${message.trim()}`;
56
+ process.stdout.write(`\n${logLine}`);
57
+ });
58
+ });
59
+ if (isErrored(subscriptionResult)) {
60
+ printLogSubscriptionError(subscriptionResult.error);
61
+ process.exit(2);
62
+ }
63
+ let isShuttingDown = false;
64
+ const shutdown = async (exitCode = 0) => {
65
+ if (isShuttingDown) {
66
+ return;
67
+ }
68
+ isShuttingDown = true;
69
+ try {
70
+ await subscriptionResult.value.unsubscribe();
71
+ }
72
+ catch {
73
+ exitCode = Math.min(exitCode, 1);
74
+ }
75
+ finally {
76
+ process.exit(exitCode);
77
+ }
78
+ };
79
+ ["SIGINT", "SIGTERM"].forEach((signal) => process.on(signal, async () => shutdown(0)));
80
+ process.on("beforeExit", async (exitCode) => {
81
+ await shutdown(exitCode);
82
+ });
83
+ process.on("uncaughtException", async (error) => {
84
+ process.stderr.write(chalk.red(`Uncaught exception: ${error}\n`));
85
+ await shutdown(1);
86
+ });
87
+ process.on("unhandledRejection", async (error) => {
88
+ process.stderr.write(chalk.red(`Unhandled rejection: ${error}\n`));
89
+ await shutdown(1);
90
+ });
91
+ await new Promise(() => {
92
+ });
93
+ });
@@ -163,3 +163,15 @@ export function printAuthenticationError(error) {
163
163
  return error;
164
164
  }
165
165
  }
166
+ export function printLogSubscriptionError(error) {
167
+ switch (error.code) {
168
+ case "FAILED_TO_AUTHENTICATE_CHANNEL":
169
+ process.stderr.write(chalk.red("Failed to authenticate to logs server\n"));
170
+ break;
171
+ case "FAILED_TO_SUBSCRIBE_TO_EVENTS":
172
+ process.stderr.write(chalk.red("Failed to subscribe to logs\n"));
173
+ break;
174
+ default:
175
+ return error;
176
+ }
177
+ }
@@ -1,6 +1,6 @@
1
1
  # title-to-be-replaced
2
2
 
3
- ## `slug-to-be-replaced` Attio Extension
3
+ ## `slug-to-be-replaced` Attio app
4
4
 
5
5
  ### Build
6
6
 
@@ -0,0 +1,62 @@
1
+ import Ably from "ably";
2
+ import { complete, errored, isErrored } from "@attio/fetchable-npm";
3
+ import { api } from "../api/api.js";
4
+ export class Realtime {
5
+ _realtime;
6
+ _channels = new Map();
7
+ constructor() {
8
+ this._realtime = new Ably.Realtime({
9
+ useTokenAuth: true,
10
+ autoConnect: false,
11
+ tls: true,
12
+ echoMessages: false,
13
+ authCallback: async (tokenParams, callback) => {
14
+ const tokenResult = await api.authenticateAbly(tokenParams);
15
+ if (isErrored(tokenResult)) {
16
+ return callback(tokenResult.error.code, null);
17
+ }
18
+ return callback(null, tokenResult.value);
19
+ },
20
+ });
21
+ }
22
+ async subscribe(channelName, eventName, eventDataSchema, listener) {
23
+ let channel = this._channels.get(channelName);
24
+ if (!channel) {
25
+ channel = this._realtime.channels.get(channelName);
26
+ this._channels.set(channelName, channel);
27
+ try {
28
+ await this._realtime.auth.authorize({
29
+ capability: {
30
+ [`${channelName}`]: ["subscribe"],
31
+ },
32
+ });
33
+ }
34
+ catch (error) {
35
+ return errored({ code: "FAILED_TO_AUTHENTICATE_CHANNEL", error });
36
+ }
37
+ }
38
+ try {
39
+ await channel.presence.enter();
40
+ await channel.subscribe(eventName, (event) => {
41
+ const parsedEventData = eventDataSchema.safeParse(event.data.data);
42
+ if (parsedEventData.success) {
43
+ listener(parsedEventData.data);
44
+ }
45
+ });
46
+ return complete({
47
+ unsubscribe: async () => {
48
+ channel.unsubscribe(listener);
49
+ await channel.presence.leave();
50
+ },
51
+ });
52
+ }
53
+ catch (error) {
54
+ return errored({ code: "FAILED_TO_SUBSCRIBE_TO_EVENTS", error });
55
+ }
56
+ }
57
+ }
58
+ let realtime;
59
+ export function getRealtime() {
60
+ realtime ??= new Realtime();
61
+ return realtime;
62
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "attio",
3
- "version": "0.0.1-experimental.20250813",
3
+ "version": "0.0.1-experimental.20250813.2",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "lib",
@@ -38,6 +38,7 @@
38
38
  "@hono/graphql-server": "0.6.2",
39
39
  "@hono/node-server": "1.14.4",
40
40
  "@inquirer/prompts": "7.5.3",
41
+ "ably": "^2.0.1",
41
42
  "boxen": "^8.0.1",
42
43
  "chalk": "^5.4.1",
43
44
  "chokidar": "^3.6.0",