@shipfox/client-onboarding 22.0.3 → 23.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/client-onboarding",
3
3
  "license": "MIT",
4
- "version": "22.0.3",
4
+ "version": "23.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -18,10 +18,10 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "@swc/helpers": "^0.5.17",
21
- "@shipfox/client-agent": "22.0.3",
22
- "@shipfox/client-integrations": "22.0.3",
23
- "@shipfox/client-projects": "22.0.3",
24
- "@shipfox/client-shell": "22.0.3"
21
+ "@shipfox/client-agent": "23.0.0",
22
+ "@shipfox/client-integrations": "23.0.0",
23
+ "@shipfox/client-projects": "23.0.0",
24
+ "@shipfox/client-shell": "23.0.0"
25
25
  },
26
26
  "peerDependencies": {
27
27
  "@tanstack/react-query": "^5.101.0",
@@ -29,10 +29,15 @@
29
29
  "react": "^19.0.0",
30
30
  "react-dom": "^19.0.0"
31
31
  },
32
+ "imports": {
33
+ "#*": "./dist/*"
34
+ },
32
35
  "scripts": {
33
36
  "build": "shipfox-swc",
34
37
  "check": "shipfox-biome-check",
35
38
  "check:fix": "shipfox-biome-check --write",
39
+ "storybook": "storybook dev -p 6015",
40
+ "storybook:build": "storybook build -o storybook-static",
36
41
  "test": "shipfox-vitest-run",
37
42
  "type": "shipfox-tsc-check",
38
43
  "type:emit": "shipfox-tsc-emit"
@@ -0,0 +1,277 @@
1
+ import type {
2
+ IntegrationCapability,
3
+ IntegrationConnection,
4
+ IntegrationProvider,
5
+ } from '@shipfox/client-integrations';
6
+ import {describe, expect, test} from '@shipfox/vitest/vi';
7
+ import {deriveIntegrationReadiness} from './integration-readiness.js';
8
+
9
+ function provider(
10
+ key: string,
11
+ capabilities: IntegrationCapability[] = [],
12
+ displayName = key,
13
+ ): IntegrationProvider {
14
+ return {provider: key, displayName, capabilities};
15
+ }
16
+
17
+ function connection(
18
+ providerKey: string,
19
+ overrides: Partial<IntegrationConnection> = {},
20
+ ): IntegrationConnection {
21
+ return {
22
+ id: `connection-${providerKey}`,
23
+ workspaceId: 'workspace',
24
+ provider: providerKey,
25
+ externalAccountId: 'account',
26
+ slug: `${providerKey}_account`,
27
+ displayName: providerKey,
28
+ lifecycleStatus: 'active',
29
+ capabilities: [],
30
+ createdAt: '2026-01-01T00:00:00.000Z',
31
+ updatedAt: '2026-01-01T00:00:00.000Z',
32
+ ...overrides,
33
+ };
34
+ }
35
+
36
+ const GITHUB = provider('github', ['source_control', 'agent_tools'], 'GitHub');
37
+ const LINEAR = provider('linear', ['agent_tools'], 'Linear');
38
+ const WEBHOOK = provider('webhook', [], 'Webhook');
39
+
40
+ describe('deriveIntegrationReadiness', () => {
41
+ test('reports a provider as connected when at least one connection is active', () => {
42
+ const readiness = deriveIntegrationReadiness({
43
+ providers: [LINEAR],
44
+ connections: [
45
+ connection('linear', {lifecycleStatus: 'active'}),
46
+ connection('linear', {id: 'other', lifecycleStatus: 'error'}),
47
+ ],
48
+ });
49
+
50
+ expect(readiness.providers).toEqual([
51
+ {
52
+ provider: 'linear',
53
+ displayName: 'Linear',
54
+ capabilities: ['agent_tools'],
55
+ connected: true,
56
+ attention: false,
57
+ },
58
+ ]);
59
+ });
60
+
61
+ test('reports a provider as needing attention when connections exist but none is active', () => {
62
+ const readiness = deriveIntegrationReadiness({
63
+ providers: [LINEAR],
64
+ connections: [
65
+ connection('linear', {lifecycleStatus: 'error'}),
66
+ connection('linear', {id: 'other', lifecycleStatus: 'disabled'}),
67
+ ],
68
+ });
69
+
70
+ expect(readiness.providers).toEqual([
71
+ {
72
+ provider: 'linear',
73
+ displayName: 'Linear',
74
+ capabilities: ['agent_tools'],
75
+ connected: false,
76
+ attention: true,
77
+ },
78
+ ]);
79
+ expect(readiness.attentionProviders).toEqual(['linear']);
80
+ });
81
+
82
+ test('reports a provider with no connections as neither connected nor in attention', () => {
83
+ const readiness = deriveIntegrationReadiness({
84
+ providers: [LINEAR, GITHUB],
85
+ connections: [connection('github', {lifecycleStatus: 'active'})],
86
+ });
87
+
88
+ expect(readiness.providers).toEqual([
89
+ {
90
+ provider: 'linear',
91
+ displayName: 'Linear',
92
+ capabilities: ['agent_tools'],
93
+ connected: false,
94
+ attention: false,
95
+ },
96
+ {
97
+ provider: 'github',
98
+ displayName: 'GitHub',
99
+ capabilities: ['source_control', 'agent_tools'],
100
+ connected: true,
101
+ attention: false,
102
+ },
103
+ ]);
104
+ });
105
+
106
+ test('ignores connections for providers outside the catalog', () => {
107
+ const readiness = deriveIntegrationReadiness({
108
+ providers: [LINEAR],
109
+ connections: [connection('unknown', {lifecycleStatus: 'active'})],
110
+ });
111
+
112
+ expect(readiness.providers).toEqual([
113
+ {
114
+ provider: 'linear',
115
+ displayName: 'Linear',
116
+ capabilities: ['agent_tools'],
117
+ connected: false,
118
+ attention: false,
119
+ },
120
+ ]);
121
+ expect(readiness.hasToolIntegration).toBe(false);
122
+ });
123
+
124
+ test('orders attention providers by the most recent connection update first', () => {
125
+ const readiness = deriveIntegrationReadiness({
126
+ providers: [GITHUB, LINEAR, WEBHOOK],
127
+ connections: [
128
+ connection('linear', {
129
+ lifecycleStatus: 'error',
130
+ updatedAt: '2026-03-01T00:00:00.000Z',
131
+ }),
132
+ connection('webhook', {
133
+ lifecycleStatus: 'error',
134
+ updatedAt: '2026-02-01T00:00:00.000Z',
135
+ }),
136
+ connection('github', {
137
+ lifecycleStatus: 'disabled',
138
+ updatedAt: '2026-01-01T00:00:00.000Z',
139
+ }),
140
+ ],
141
+ });
142
+
143
+ expect(readiness.attentionProviders).toEqual(['linear', 'webhook', 'github']);
144
+ });
145
+
146
+ test('uses the newest of several connections per provider for ordering', () => {
147
+ const readiness = deriveIntegrationReadiness({
148
+ providers: [LINEAR, WEBHOOK],
149
+ connections: [
150
+ connection('linear', {
151
+ id: 'stale',
152
+ lifecycleStatus: 'error',
153
+ updatedAt: '2026-01-01T00:00:00.000Z',
154
+ }),
155
+ connection('linear', {
156
+ id: 'fresh',
157
+ lifecycleStatus: 'disabled',
158
+ updatedAt: '2026-04-01T00:00:00.000Z',
159
+ }),
160
+ connection('webhook', {
161
+ lifecycleStatus: 'error',
162
+ updatedAt: '2026-03-01T00:00:00.000Z',
163
+ }),
164
+ ],
165
+ });
166
+
167
+ expect(readiness.attentionProviders).toEqual(['linear', 'webhook']);
168
+ });
169
+
170
+ test('keeps catalog order when attention providers updated at the same time', () => {
171
+ const readiness = deriveIntegrationReadiness({
172
+ providers: [GITHUB, LINEAR, WEBHOOK],
173
+ connections: [
174
+ connection('linear', {lifecycleStatus: 'error'}),
175
+ connection('webhook', {lifecycleStatus: 'error'}),
176
+ connection('github', {lifecycleStatus: 'error'}),
177
+ ],
178
+ });
179
+
180
+ expect(readiness.attentionProviders).toEqual(['github', 'linear', 'webhook']);
181
+ });
182
+
183
+ test('skips connections with an unparsable updated_at when ordering', () => {
184
+ const readiness = deriveIntegrationReadiness({
185
+ providers: [LINEAR, WEBHOOK],
186
+ connections: [
187
+ connection('linear', {lifecycleStatus: 'error', updatedAt: 'not-a-date'}),
188
+ connection('webhook', {lifecycleStatus: 'error', updatedAt: '2026-02-01T00:00:00.000Z'}),
189
+ ],
190
+ });
191
+
192
+ expect(readiness.attentionProviders).toEqual(['webhook', 'linear']);
193
+ });
194
+
195
+ test('reports no attention providers when no connection needs attention', () => {
196
+ const readiness = deriveIntegrationReadiness({
197
+ providers: [GITHUB, LINEAR],
198
+ connections: [
199
+ connection('github', {lifecycleStatus: 'active'}),
200
+ connection('linear', {lifecycleStatus: 'active'}),
201
+ ],
202
+ });
203
+
204
+ expect(readiness.attentionProviders).toEqual([]);
205
+ });
206
+
207
+ test('reports hasSourceControl only for an active source-control connection', () => {
208
+ expect(
209
+ deriveIntegrationReadiness({
210
+ providers: [GITHUB],
211
+ connections: [connection('github', {lifecycleStatus: 'active'})],
212
+ }).hasSourceControl,
213
+ ).toBe(true);
214
+
215
+ expect(
216
+ deriveIntegrationReadiness({
217
+ providers: [GITHUB],
218
+ connections: [connection('github', {lifecycleStatus: 'error'})],
219
+ }).hasSourceControl,
220
+ ).toBe(false);
221
+
222
+ expect(
223
+ deriveIntegrationReadiness({
224
+ providers: [LINEAR],
225
+ connections: [connection('linear', {lifecycleStatus: 'active'})],
226
+ }).hasSourceControl,
227
+ ).toBe(false);
228
+ });
229
+
230
+ test('reports hasToolIntegration for an active connection without source_control', () => {
231
+ expect(
232
+ deriveIntegrationReadiness({
233
+ providers: [LINEAR],
234
+ connections: [connection('linear', {lifecycleStatus: 'active'})],
235
+ }).hasToolIntegration,
236
+ ).toBe(true);
237
+
238
+ expect(
239
+ deriveIntegrationReadiness({
240
+ providers: [WEBHOOK],
241
+ connections: [connection('webhook', {lifecycleStatus: 'active'})],
242
+ }).hasToolIntegration,
243
+ ).toBe(true);
244
+ });
245
+
246
+ test('GitHub never satisfies hasToolIntegration', () => {
247
+ expect(
248
+ deriveIntegrationReadiness({
249
+ providers: [GITHUB],
250
+ connections: [connection('github', {lifecycleStatus: 'active'})],
251
+ }).hasToolIntegration,
252
+ ).toBe(false);
253
+ });
254
+
255
+ test('an inactive tool connection does not satisfy hasToolIntegration', () => {
256
+ expect(
257
+ deriveIntegrationReadiness({
258
+ providers: [LINEAR],
259
+ connections: [connection('linear', {lifecycleStatus: 'error'})],
260
+ }).hasToolIntegration,
261
+ ).toBe(false);
262
+ });
263
+
264
+ test('combines providers in one readiness report', () => {
265
+ const readiness = deriveIntegrationReadiness({
266
+ providers: [GITHUB, LINEAR, WEBHOOK],
267
+ connections: [
268
+ connection('github', {lifecycleStatus: 'active'}),
269
+ connection('linear', {lifecycleStatus: 'error'}),
270
+ ],
271
+ });
272
+
273
+ expect(readiness.hasSourceControl).toBe(true);
274
+ expect(readiness.hasToolIntegration).toBe(false);
275
+ expect(readiness.attentionProviders).toEqual(['linear']);
276
+ });
277
+ });
@@ -0,0 +1,93 @@
1
+ import {
2
+ type IntegrationCapability,
3
+ type IntegrationConnection,
4
+ type IntegrationProvider,
5
+ isUsableConnection,
6
+ } from '@shipfox/client-integrations';
7
+
8
+ /**
9
+ * Per-provider connection state for the workspace setup readiness model.
10
+ * `connected` and `attention` are mutually exclusive: a provider with at
11
+ * least one active connection is connected, a provider whose connections
12
+ * exist but are all inactive needs attention, and a provider without
13
+ * connections is neither.
14
+ */
15
+ export interface IntegrationProviderReadiness {
16
+ provider: string;
17
+ displayName: string;
18
+ capabilities: ReadonlyArray<IntegrationCapability>;
19
+ connected: boolean;
20
+ attention: boolean;
21
+ }
22
+
23
+ export interface WorkspaceIntegrationReadiness {
24
+ providers: ReadonlyArray<IntegrationProviderReadiness>;
25
+ /** Providers in attention, most recently updated connection first. */
26
+ attentionProviders: readonly string[];
27
+ /** At least one active connection to a source-control provider. */
28
+ hasSourceControl: boolean;
29
+ /** At least one active connection to a provider without `source_control`. */
30
+ hasToolIntegration: boolean;
31
+ }
32
+
33
+ export interface IntegrationReadinessInput {
34
+ providers: readonly IntegrationProvider[];
35
+ connections: readonly IntegrationConnection[];
36
+ }
37
+
38
+ /**
39
+ * Derives the workspace integration readiness from the provider catalog and
40
+ * the workspace's connections. The result is the shared input for the setup
41
+ * checklist and for the first-workflow spec's contextual "Connect X" prompt.
42
+ */
43
+ export function deriveIntegrationReadiness({
44
+ providers,
45
+ connections,
46
+ }: IntegrationReadinessInput): WorkspaceIntegrationReadiness {
47
+ const readiness = providers.map((provider) => {
48
+ const providerConnections = connections.filter(
49
+ (connection) => connection.provider === provider.provider,
50
+ );
51
+ const connected = providerConnections.some(isUsableConnection);
52
+ return {
53
+ provider: provider.provider,
54
+ displayName: provider.displayName,
55
+ capabilities: provider.capabilities,
56
+ connected,
57
+ attention: providerConnections.length > 0 && !connected,
58
+ };
59
+ });
60
+
61
+ const latestConnectionUpdate = latestConnectionUpdateByProvider(connections);
62
+
63
+ const attentionProviders = readiness
64
+ .filter((provider) => provider.attention)
65
+ .sort((a, b) => {
66
+ const aUpdate = latestConnectionUpdate.get(a.provider) ?? 0;
67
+ const bUpdate = latestConnectionUpdate.get(b.provider) ?? 0;
68
+ return bUpdate - aUpdate;
69
+ })
70
+ .map((provider) => provider.provider);
71
+
72
+ const hasSourceControl = readiness.some(
73
+ (provider) => provider.connected && provider.capabilities.includes('source_control'),
74
+ );
75
+ const hasToolIntegration = readiness.some(
76
+ (provider) => provider.connected && !provider.capabilities.includes('source_control'),
77
+ );
78
+
79
+ return {providers: readiness, attentionProviders, hasSourceControl, hasToolIntegration};
80
+ }
81
+
82
+ function latestConnectionUpdateByProvider(
83
+ connections: readonly IntegrationConnection[],
84
+ ): Map<string, number> {
85
+ const updates = new Map<string, number>();
86
+ for (const connection of connections) {
87
+ const timestamp = Date.parse(connection.updatedAt);
88
+ if (Number.isNaN(timestamp)) continue;
89
+ const current = updates.get(connection.provider) ?? 0;
90
+ if (timestamp > current) updates.set(connection.provider, timestamp);
91
+ }
92
+ return updates;
93
+ }